Compare commits

..

30 Commits

Author SHA1 Message Date
briquet cafbc6df7c 🔒️(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.
2026-10-06 23:07:05 +02:00
briquet 2f44a8da49 📝(docs) document audit logging
Describe the audit event shape, the catalogue, how views and other code
emit events, the environment variables configuring them, and how the
project registers its actions, models and authentication classes.
2026-10-06 23:07:04 +02:00
briquet a39cdb388b ✨(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; and room create,
update, retrieve and list through the delegated rooms API.

Both viewsets use AuditViewMixin, so refusals are recorded under the
action that was attempted, with their outcome and reason

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-06 23:07:04 +02:00
briquet ce29ba4bd3 ✨(backend) add structured audit logging facility
Add a core.audit package emitting one ECS-shaped JSON line per
security-relevant action on a dedicated "audit" logger.

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. 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
trace.id.

An action is defined with a dotted name with its default ECS category
and types, validated at import, 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. Every
  target carries its model name and primary key, so an unregistered one
  is still identified, and a user target its OIDC sub and email domain.
- 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-06 23:07:02 +02:00
lebaudantoine c1c2d93932 🔧(compose) share the backend redis with the summary stack
The summary settings already default to the `redis` host, so
`redis-summary` was unused. Remove it and depend on `redis`.

Pin the summary Celery and task tracker URLs to DB 2 in
`summary.dist` to isolate them from LiveKit and the backend
Celery broker (DB 0) and the Django cache (DB 1).
2026-10-06 17:26:26 +02:00
lebaudantoine 9efb9577c4 ♻️(tilt) use the helm dev-backend chart
Use the shared helm `dev-backend` chart to deploy the backend in
the Tilt dev stack, instead of maintaining our own copy.

The goal is to share more common pieces of the dev experience and
dev stack across projects, so we do not maintain N different
versions of similar setups.

The only adjustment needed was to backport Garage into the shared
chart.

Originally started by @rouja.
2026-10-06 14:07:23 +02:00
Ovgodd 2137c6b444 🐛(frontend) let panel shortcuts close panels opened from a menu
allow menu-invoked panels to be closed by panel shortcuts
2026-10-05 15:11:04 +02:00
Ovgodd aa01733f6b ♿️(frontend) make participant pagination readable and keyboard reachable
Improves PaginationControl: clearer structure, keyboard nav, a11y improved.
The main room and the picture-in-picture window share this control.
Ctrl+Shift+G focuses the pagination.
2026-10-05 15:11:04 +02:00
lebaudantoine 03db669e51 🔒️(ci) set persist-credentials: false on actions/checkout
By default, `actions/checkout` saves the job's auth token
(`GITHUB_TOKEN` or the provided PAT) in the local git config so
later steps can run authenticated git commands. That token then
stays on disk for the rest of the job, where it can leak:

* If an artifact upload includes the checkout directory, the token
  is packaged with it and anyone with artifact access can extract
  it. On public repos that is anyone, and the token can be used
  while the job is still running ("ArtiPACKED", flagged by
  `zizmor` as `artipacked`).
* Any later step, third-party action, or build dependency can read
  the token from the git config, which widens the impact of a
  supply-chain compromise.

None of our workflows need authenticated git after checkout, so
disable credential persistence. If a step needs to push in
2026-10-05 12:41:38 +02:00
davd-gzl 97a73bf4f2 🔧(frontend) rename the make target to test-frontend
The frontend test target now sits beside test-back and
test-summary and follows their naming.
2026-10-05 12:41:38 +02:00
davd-gzl 267a2265d9 🔧(frontend) give vitest its own config and a make target
A separate vitest.config.ts keeps the tests off the build
plugins and the mediapipe version check in vite.config.ts.
make test now runs the frontend tests after the backend ones,
and test-front runs on Node 24, the current LTS.
2026-10-05 12:41:38 +02:00
davd-gzl 14507d57a9 ✅(frontend) add vitest so the frontend can carry unit tests
Add vitest as a dev dependency, a test script that runs panda
codegen first, and a test-front job, so the frontend can carry
unit tests. One test covers normalizeRoomId, a plain function,
so no DOM library comes with it.
2026-10-05 12:41:38 +02:00
davd-gzl c075db25a4 🐛(frontend) honour Keep hand raised when picture-in-picture is open
The hand button owns the lower-hand timer and the toast, and the
picture-in-picture window draws a second copy of that button, so two
offers go up and dismissing one leaves the other to lower the hand.
Move the watching into a component rendered once beside the room.
2026-10-04 23:21:31 +02:00
Rishi Gupta c0dfd88776 ♿️(frontend) expose loading state to assistive technology
Surface the page's loading state to assistive technology, so screen
readers can announce that the page is still loading instead of
reading a partially rendered state as if it were complete.
2026-10-04 22:58:21 +02:00
Rishi Gupta 94d2b80c17 ✏️(docs) fix README wording and typos
Address review feedback from #1 by fixing wording issues and typos
across the README.
2026-10-04 22:58:21 +02:00
KusalPabasara bd2dfcae80 ✨(helm) add envFrom support to chart
Allow every chart workload to import environment variables from ConfigMaps.

Keep existing empty defaults for backward compatibility.
2026-10-04 22:34:31 +02:00
lebaudantoine 0b4a83c92e 🐛(brevo) use django-lasuite for marketing management
When the user is updated, their lists on Brevo are overwritten with
the new value: this removes lists set by other products.

Switch to the common lib implementation from `django-lasuite`,
which manages this correctly.

This change was initially proposed by @qbey, but at the time our
deployment did not have a Celery worker running alongside the
backend. Since then, a Celery worker has been deployed, so the
switch to the common lib approach is now safe to adopt.
2026-10-04 22:10:16 +02:00
lebaudantoine 9187173cae ✨(frontend) warn users when the connection falls back to TURN
Highlight in the connection test when the user is connecting
through a TURN relay, especially over TLS or TCP. This usually
indicates that some network configuration is required on their
side, and gives them a concrete signal to pass to their IT team.

Suggested by a technical user, this is a first step toward making
users more autonomous when troubleshooting access to the tool.

Follow-up: show a similar warning in-product when we detect a
mid-meeting fallback to TURN/TLS. A one-time hint for first-time
users would likely be enough.
2026-10-03 23:36:42 +02:00
kaelvar 364bbf4f0b ✨(frontend) let signed-out visitors start a meeting
The home page only offers meeting creation to authenticated users, while the
backend already serves ephemeral rooms to anonymous visitors when
ALLOW_UNREGISTERED_ROOMS is enabled (the flag is checked in the room retrieve
view, not in the create one).

Expose the flag in the frontend configuration and, when it is on, show the
existing "Create a meeting" button to signed-out visitors. It navigates to a
freshly generated room id rather than calling POST /rooms/, which stays
reserved for registered rooms and for authenticated users.

The invite dialog opens for such a creator when the room is unregistered
(null id) and the navigation carries `create`. The `mode` computed in Room
is left untouched, so permissions and the join screen behave as before.

The home buttons row now wraps: signed-out visitors can see three controls
(create, join, login), and at the xsm breakpoint the fixed-width ProConnect
button leaves too little room for the other two.
2026-10-02 19:06:02 +02:00
lebaudantoine a6a12ef586 🐛(frontend) hide tooltips until they have a computed placement
A React Aria overlay is rendered at `top: 0; left: 0` until
`useOverlayPosition` computes its position, and `data-placement` is
only set once that succeeds. When the pointer moves quickly between
triggers, a closing tooltip can mount for its exit animation without
ever being positioned, React Aria does not retry, so it stays
stuck in the top-left corner.

Hide tooltips until they have a `data-placement` set, so unpositioned
tooltips never flash in the corner. Use `visibility` rather than
`display: none`, so the element stays measurable for the positioning
pass.
2026-10-02 18:19:43 +02:00
snyk-bot 2622d89f63 ⬆️(frontend) upgrade react-aria dependencies
Snyk has created this PR to upgrade react-stately from 3.48.0 to 3.49.0.
I had to bump react-aria@3.51.0 react-aria-components@1.20.0
2026-10-02 18:19:43 +02:00
lebaudantoine f2d50770cf 🔧(summary) add setting to control Sentry traces sampling rate
Replace the deprecated `enable_tracing=True` with `traces_sample_rate`,
read from a new `sentry_traces_sample_rate` setting (default 0.1,
validated to the 0.0–1.0 range).

Previously, tracing sampled 100% of transactions, which is costly and
unnecessary in production. The rate can now be tuned per environment
without a code change.
2026-10-02 17:41:02 +02:00
lebaudantoine 11e8470aa5 🔒️(summary) redact meeting content from Sentry events
Sentry attaches stack frame locals to its events. When storing a
transcript in S3 failed, the full transcript held in `data` and
`transcript` was sent to Sentry.

Keep locals for debugging, but scrub variables and nested dict keys
known to hold transcripts, summaries, LLM prompts, participants'
personal data or pre-signed URLs. Share the Sentry init between the
API and the Celery worker, and never send request bodies.
2026-10-02 17:41:02 +02:00
lebaudantoine 3bf78f0f5b 🐛(summary) disable default S3 checksums for GCS-compatible storage
Since boto3/botocore 1.36, the S3 client computes CRC32 checksums on
uploads by default (request_checksum_calculation="when_supported").
PutObject requests are then sent with aws-chunked encoding, a trailing
x-amz-checksum-crc32 header and a STREAMING-UNSIGNED-PAYLOAD-TRAILER
content hash.

Our production storage (S3NS, storage.s3nsapis.fr) is built on Google
Cloud Storage and exposes it through GCS's S3-compatible XML API, which
does not support these flexible checksums. It rejects the request with
a 403 SignatureDoesNotMatch ("Invalid argument"), so storing transcripts
failed in the Celery worker. Garage, used locally, supports them, which
is why the issue only appeared in production after switching the
client to boto3.

Add aws_request_checksum_calculation and
aws_response_checksum_validation settings, defaulting to
"when_required", and pass them to the botocore Config of the summary S3
client and the backend S3 client. Checksums are then only sent for
operations that require them, restoring the pre-1.36 behavior.
2026-10-02 17:41:02 +02:00
lebaudantoine ddd5e3fce1 ✏️(ci) fix a codespell in env variable
Minor typo introduced by the recording configurations.
2026-10-02 17:35:32 +02:00
lebaudantoine 5a9e1cb012 🔒️(frontend) upgrade pcre2 to fix CVE-2026-103111
The nginx-unprivileged:1.30.4-alpine3.24 base image ships
pcre2 10.48-r0, which is affected by CVE-2026-103111 (HIGH,
out-of-bounds write via crafted regular expression). No newer
base image tag is available yet.

Upgrade pcre2 from the Alpine v3.24 repository with a minimum
version constraint (>=10.49-r0) so the build fails instead of
silently shipping a vulnerable version if the fix is unavailable.
2026-10-02 17:35:32 +02:00
lebaudantoine c49cee3ab4 🧑‍💻(devex) fix local recording downloads
Recordings were failing to download in the local stack because of
several small issues stacked together:

* nginx: add a `/media/recordings/` location that authorizes
  against `recordings/media-auth/`. Before, every media request
  went to `files/media-auth/`, which returned 403 for recording
  paths.
* nginx: call `proxy_hide_header Content-Disposition` before
  `add_header Content-Disposition "attachment"`. Garage stored the
  header as `inline`, which combined with nginx's value into
  `inline, attachment` and broke browser downloads.
* frontend: `mediaUrl()` now uses the frontend origin, so
  recording links go through the Vite `/media` proxy instead of
  hitting Django directly on `:8071`.
* frontend: include the file extension in the download filename.

co-author: cameldev
2026-10-02 17:35:32 +02:00
lebaudantoine bbc30de490 ⚡️(devx) switch devstack to node:22-alpine
The devstack was pulling the full `node:22` image, around 1.6 GB.
Switch to `node:22-alpine`, which is much smaller, to speed up the
devstack bootstrap time and reduce disk usage.
2026-10-02 15:59:04 +02:00
lebaudantoine 14b3395e1c ⚡️(devx) use a single Redis image version in the devstack
The devstack was pulling two different versions of the Redis image.
Align everything on a single version to save a few MB of network
bandwidth when bootstrapping the stack.

Late-night minor optimization.
2026-10-02 15:59:04 +02:00
leo f673c07cb8 ✨(backend) add per-recording encoding config to start-recording API
Add new options to query start-start recording API. A resolution
("540p", "720p", "1080p") and a profile ("talking_heads", "text", "mixed")
are resolved to provide a width, height, fps and bitrate which
are passed on to the encoder. Using profiles allows for some flexibility
on quality if necessary without changing front facing user config.

Co-authored-by: sarthakbahal <sarthakbahal.45@gmail.com>
2026-10-02 14:16:01 +02:00
135 changed files with 6881 additions and 1497 deletions
+31
View File
@@ -88,6 +88,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Create writable /data
run: |
@@ -159,6 +161,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Install ffmpeg
run: |
@@ -186,6 +190,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Install dependencies
run: cd src/frontend/ && npm ci --ignore-scripts
@@ -196,6 +202,27 @@ jobs:
- name: Check format
run: cd src/frontend/ && npm run check
test-front:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Install Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: "24"
- name: Install dependencies
run: cd src/frontend/ && npm ci --ignore-scripts
- name: Run tests
run: cd src/frontend/ && npm test
lint-sdk:
runs-on: ubuntu-latest
permissions:
@@ -206,6 +233,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Install dependencies
run: npm ci --ignore-scripts
@@ -227,6 +256,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Install dependencies
run: npm ci --ignore-scripts
+24 -1
View File
@@ -10,15 +10,37 @@ and this project adheres to
### Added
- ✨(helm) import environment variables from Secrets and ConfigMaps
- 🔒(backend) throttle meeting link generation
- 🔒️(backend) add a daily cap on room creation
- 🔧(summary) add setting to control Sentry traces sampling rate
- ✨(frontend) let signed-out visitors start a meeting
- ✨(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
### Changed
- ✨(frontend) warn users when the connection falls back to TURN
- 🔧(backend) configure the technical documentation url
### Fixed
- 🐛(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
- 🐛(summary) disable default S3 checksums for GCS-compatible storage
- 🔒️(summary) redact meeting content from Sentry events
- 🐛(frontend) hide tooltips until they have a computed placement
- 🐛(brevo) use django-lasuite for marketing management
- ♿️(frontend) expose loading state to assistive technology
- 🐛(frontend) honour Keep hand raised when picture-in-picture is open
## [1.33.0] - 2026-09-30
@@ -35,6 +57,7 @@ and this project adheres to
- ♻️(agents) replace the minio client by boto3
- 🔧(compose) replace MinIO by Garage for local development
- 🔧(helm) point media services to Garage by default
- 💥(backend) replace recording encoding options with a profile model
### Fixed
@@ -76,7 +99,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
+6 -1
View File
@@ -260,7 +260,8 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
$(MAKE) test-back-parallel ARGS="$${args}" && \
$(MAKE) test-summary ARGS="$${args}"
$(MAKE) test-summary ARGS="$${args}" && \
$(MAKE) test-frontend
.PHONY: test
test-back: ## run back-end tests (pass extra pytest args via ARGS)
@@ -278,6 +279,10 @@ test-summary: ## run summary tests (pass extra pytest args via ARGS)
bin/pytest-summary $${args}
.PHONY: test-summary
test-frontend: ## run the frontend unit tests
cd $(PATH_FRONT) && npm test
.PHONY: test-frontend
makemigrations: ## run django makemigrations for the Meet project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
+3 -3
View File
@@ -11,7 +11,7 @@
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/meet"/>
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
<img alt="GitHub license" src="https://img.shields.io/github/license/suitenumerique/meet"/>
</a>
<a href="https://digitalpublicgoods.net/r/la-suite-meet-simple-video-conferencing">
<img src="https://img.shields.io/badge/Verified-DPG-3333AB?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iMzEiIGhlaWdodD0iMzMiIHZpZXdCb3g9IjAgMCAzMSAzMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0LjIwMDggMjEuMzY3OEwxMC4xNzM2IDE4LjAxMjRMMTEuNTIxOSAxNi40MDAzTDEzLjk5MjggMTguNDU5TDE5LjYyNjkgMTIuMjExMUwyMS4xOTA5IDEzLjYxNkwxNC4yMDA4IDIxLjM2NzhaTTI0LjYyNDEgOS4zNTEyN0wyNC44MDcxIDMuMDcyOTdMMTguODgxIDUuMTg2NjJMMTUuMzMxNCAtMi4zMzA4MmUtMDVMMTEuNzgyMSA1LjE4NjYyTDUuODU2MDEgMy4wNzI5N0w2LjAzOTA2IDkuMzUxMjdMMCAxMS4xMTc3TDMuODQ1MjEgMTYuMDg5NUwwIDIxLjA2MTJMNi4wMzkwNiAyMi44Mjc3TDUuODU2MDEgMjkuMTA2TDExLjc4MjEgMjYuOTkyM0wxNS4zMzE0IDMyLjE3OUwxOC44ODEgMjYuOTkyM0wyNC44MDcxIDI5LjEwNkwyNC42MjQxIDIyLjgyNzdMMzAuNjYzMSAyMS4wNjEyTDI2LjgxNzYgMTYuMDg5NUwzMC42NjMxIDExLjExNzdMMjQuNjI0MSA5LjM1MTI3WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==" alt="DPG Badge"/>
@@ -49,8 +49,8 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
- Telephony integration
- Secure participation with robust authentication and access control
- Customizable frontend style
- LiveKit Advances features including :
- speaker detection
- LiveKit advanced features including:
- speaker detection
- simulcast
- end-to-end optimizations
- selective subscription
+121
View File
@@ -53,6 +53,127 @@ Also:
### 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
values (width, height, framerate, bitrate). They are replaced by two named and configurable sets of
dimensions, a **resolution** (default: `540p`, `720p`, `1080p`) and a **profile**
(default: `talking_heads`, `text`, `mixed`, `full`), which are resolved to the width, height,
fps and video bitrate.
**The following environment variables are no longer read. If they are still set in
your deployment they are silently ignored, and your recordings will be encoded with
the new defaults instead of your tuned values.**
| Removed variable | Replaced by |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `RECORDING_ENCODING_ENABLED` | Nothing. A default encoding is now always built (see below). **Not** `RECORDING_CUSTOM_ENCODING_ENABLED`, which gates a different feature. |
| `RECORDING_ENCODING_WIDTH` | The `width` of the entry selected by `RECORDING_ENCODING_DEFAULT_RESOLUTION` in `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`. |
| `RECORDING_ENCODING_HEIGHT` | The `height` of that same entry. |
| `RECORDING_ENCODING_FRAMERATE` | The `fps` of the profile selected by `RECORDING_ENCODING_DEFAULT_PROFILE` in `RECORDING_ENCODING_AVAILABLE_PROFILES`. |
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | That profile's `kbps`. |
`RECORDING_ENCODING_AUDIO_BITRATE_KBPS` and `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S`
keep their names and meaning. The keyframe interval now defaults to `0` (unset,
encoder's choice) instead of `4.0`.
#### If you never set `RECORDING_ENCODING_ENABLED=True`
The shipped defaults (`RECORDING_ENCODING_DEFAULT_PROFILE=full`,
`RECORDING_ENCODING_DEFAULT_RESOLUTION=720p`) match LiveKit's built-in
`H264_720P_30` preset: 1280×720, 30 fps, 3000 kbps H.264 MAIN, 128 kbps AAC.
Video output is therefore unchanged.
Audio and keyframing may not be. These values are now sent explicitly as advanced
`EncodingOptions` rather than relying on LiveKit's preset, so
`RECORDING_ENCODING_AUDIO_BITRATE_KBPS` and `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S`
now apply to every recording. They previously applied only when
`RECORDING_ENCODING_ENABLED` was `True`. **If you set either of them while the
feature was disabled, they had no effect and now do**; check them before upgrading.
If you never set them, no action is required: 128 kbps AAC is what the preset used,
and the keyframe interval now defaults to `0`, which leaves the field unset so the
encoder keeps picking it as before. Set `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0`
if you want fixed 4-second keyframes (the value the setting defaulted to while it
was gated behind `RECORDING_ENCODING_ENABLED`).
To keep letting LiveKit pick the encoding instead, set either default to an empty
value:
```
RECORDING_ENCODING_DEFAULT_RESOLUTION=
RECORDING_ENCODING_DEFAULT_PROFILE=
```
#### If you had tuned `RECORDING_ENCODING_*` values
Translate your old values into a default resolution and a default profile. Declare your own resolution and/or profile. Both maps are read from the
environment as a single-line Python/JSON dict literal (parsed with
`ast.literal_eval`, so use double-quoted keys and no trailing commas, and do not
add outer quotes in `.env`-style files):
```bash
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}
RECORDING_ENCODING_AVAILABLE_PROFILES={"my_old_profile": {"fps": 15, "kbps": {"540p": 350, "720p": 600, "1080p": 1100}}}
RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
RECORDING_ENCODING_DEFAULT_PROFILE=my_old_profile
```
Both maps are validated at startup and a malformed one raises a `ValueError`:
- every entry of `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS` must declare `width` and
`height`, and every entry of `RECORDING_ENCODING_AVAILABLE_PROFILES` an `fps` and a
`kbps` map;
- every profile must define a `kbps` entry for **exactly** the keys of
`RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`; overriding one of the two maps usually
means overriding both;
- `RECORDING_ENCODING_DEFAULT_RESOLUTION` and `RECORDING_ENCODING_DEFAULT_PROFILE`,
when non-empty, must be keys of their respective map.
#### Breaking: custom worker services must accept `encoding_options`
Only concerns deployments pointing `RECORDING_WORKER_CLASSES` at their own worker
class. The shipped `VideoCompositeEgressService` and `AudioCompositeEgressService`
are already updated.
The `WorkerService` protocol's `start()` takes a third argument, and the mediator
now always passes it as a keyword when the recording carries no per-recording encoding:
```python
# before
def start(self, room_id: str, recording_id: str) -> str: ...
# now
def start(
self,
room_id: str,
recording_id: str,
encoding_options: Optional[Dict[str, Any]] = None,
) -> str: ...
```
#### Optional: per-recording encoding
`RECORDING_CUSTOM_ENCODING_ENABLED` (default `False`) toggles whether the
start-recording API accepts an `encoding` object
(`{"resolution": "720p", "profile": "talking_heads"}`, `profile` optional. It
falls back to `RECORDING_ENCODING_DEFAULT_PROFILE`) that overrides the default for
a single recording. It does not enable or disable the
default encoding, which is built from the two `RECORDING_ENCODING_DEFAULT_*`
settings either way. Leaving it at `False` preserves the previous behaviour, where
every recording uses the server-side encoding: requests carrying
`options.encoding` are rejected with a `400` before the recording is created, so
nothing is persisted and no egress is started.
Before enabling it:
- clients can only pick keys you declared; there is no way to send a raw width or bitrate
- as of this implementation, the frontend never sends `encoding`
- `encoding` is accepted but ignored for `transcript` recordings, whose audio-only
egress has no video encoding to configure.
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.30.0
+9 -8
View File
@@ -104,15 +104,16 @@ k8s_yaml(secret_yaml_generic(
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev-keycloak} template .'))
k8s_resource('garage-cors', resource_deps=['garage'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'garage-cors', 'redis', 'livekit-livekit-server'])
k8s_resource('meet-celery-backend', resource_deps=['redis'])
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
k8s_resource('meet-celery-transcribe-default', resource_deps=['redis'])
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
k8s_resource('dev-backend-garage-cors', resource_deps=['dev-backend-garage'])
k8s_resource('dev-backend-keycloak', resource_deps=['dev-backend-keycloak-pg'])
k8s_resource('meet-backend', resource_deps=['dev-backend-postgres', 'dev-backend-garage-cors', 'dev-backend-redis', 'dev-backend-keycloak', 'livekit-livekit-server'])
k8s_resource('meet-celery-backend', resource_deps=['dev-backend-redis'])
k8s_resource('meet-celery-summarize', resource_deps=['dev-backend-redis'])
k8s_resource('meet-celery-summary-backend', resource_deps=['dev-backend-redis'])
k8s_resource('meet-celery-transcribe-default', resource_deps=['dev-backend-redis'])
k8s_resource('livekit-livekit-server', resource_deps=['dev-backend-redis'])
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
k8s_resource('livekit-egress', resource_deps=['livekit-livekit-server'])
# Trigger once on launch
k8s_resource(
'meet-backend-createsuperuser',
+6 -8
View File
@@ -9,6 +9,8 @@ services:
redis:
image: redis:5
ports:
- "6379:6379"
mailcatcher:
image: sj26/mailcatcher:latest
@@ -153,6 +155,7 @@ services:
target: frontend-production
args:
VITE_API_BASE_URL: "http://localhost:8071"
VITE_MEDIA_BASE_URL: "http://localhost:8083"
VITE_APP_TITLE: "LaSuite Meet"
image: meet:frontend-development
ports:
@@ -270,11 +273,6 @@ services:
- ./src/agents:/app
- /app/.venv
redis-summary:
image: redis:5
ports:
- "6379:6379"
app-summary-dev:
build:
context: src/summary
@@ -289,7 +287,7 @@ services:
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
- redis
celery-summary-transcribe:
container_name: celery-summary-transcribe
@@ -303,7 +301,7 @@ services:
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
- redis
- app-summary-dev
- garage
develop:
@@ -323,7 +321,7 @@ services:
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
- redis
- app-summary-dev
- garage
develop:
+1
View File
@@ -58,6 +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' && \
apk del curl
USER nginx
@@ -4,6 +4,36 @@ server {
server_name localhost;
charset utf-8;
# Proxy auth for recordings (authorized by the recordings viewset)
location /media/recordings/ {
auth_request /media-auth-recordings;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $authDate $upstream_http_x_amz_date;
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
proxy_set_header Authorization $authHeader;
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
proxy_pass http://garage:9000/meet-media-storage/recordings/;
proxy_set_header Host garage:9000;
proxy_hide_header Content-Disposition;
add_header Content-Disposition "attachment";
}
location = /media-auth-recordings {
internal;
proxy_pass http://app-dev:8000/api/v1.0/recordings/media-auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
# Proxy auth for media
location /media/ {
# Auth request configuration
+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"
)
+241
View File
@@ -0,0 +1,241 @@
# Audit logging
La Suite Meet emits a structured **audit log**: one JSON line per notable action, saying who did what, on behalf of
whom, on which resource, from where, and whether it succeeded.
## What an event looks like
Events are written on the dedicated `audit` logger, one per line and look like this::
```json
{
"@timestamp": "2026-09-15T08:41:12.345+00:00",
"ecs": {"version": "8.11.0"},
"log_type": "audit",
"service": {"name": "meet", "environment": "production"},
"event": {
"kind": "event",
"action": "room.create",
"category": ["api"],
"type": ["creation"],
"outcome": "success"
},
"trace": {"id": "6f1c0d0e2a8b4c1d9e7f0a1b2c3d4e5f"},
"client": {"ip": "1.2.3.4"},
"source": {"ip": "1.2.3.4"},
"http": {"request": {"method": "POST"}},
"url": {"path": "/external-api/v1.0/rooms/"},
"user": {"id": "beecd833-4be4-4675-b139-a196b07144a9", "sub": "0edebfa3-1355-4891-ae63-daf9fd37ac04", "domain": "gouv.fr"},
"organization": {"id": "calendar-app"},
"lasuite": {
"actor": {"type": "application"},
"auth": {"method": "application_jwt"},
"application": {"client_id": "calendar-app"},
"outcome": "success",
"target": {"type": "room", "id": "9ae54744-ae64-44f4-b094-71e137556b66", "slug": "daily-standup", "name": "Daily standup", "access_level": "trusted"}
},
"log": {"level": "info", "logger": "audit"}
}
```
A refusal is recorded under the action that was attempted: the same `room.create`, with
`"event": {"outcome": "failure", "type": ["creation", "denied"], "reason": "permission_denied"}`,
`"lasuite": {"outcome": "denied"}`, `"http": {"response": {"status_code": 403}}` and `"error": {"message": "…"}`.
## Fields
Standard fields follow the [Elastic Common Schema](https://www.elastic.co/guide/en/ecs/current/index.html);
| Field | Meaning |
|---|---|
| `@timestamp` | ISO 8601 with millisecond precision in UTC timezone` |
| `log_type` | Always `audit` |
| `service.name`, `service.environment` | `AUDIT_LOG_SERVICE_NAME` and current environment: the emitter, never the caller |
| `event.action` | What was attempted, from the catalogue below |
| `event.category`, `event.type` | ECS classification (`api`, `authentication`, `iam`... / `creation`, `change`, `access`, `denied`, `user`...) |
| `event.outcome` | ECS `success` or `failure` |
| `event.reason` | Why it did not succeed: `authentication_failed`, `permission_denied`, `rate_limited`, `validation_error`, `not_found`, `conflict`, `internal_error` |
| `lasuite.outcome` | `success`, `failure` or `denied` |
| `lasuite.actor.type` | `user`, `application`, `service`, `system` or `anonymous`, see [Actors](#actors) |
| `lasuite.actor.name` | Name of a `service` actor: `roomkit`, `summary` |
| `lasuite.auth.method` | `session`, `application_jwt`, `addons_jwt`, `resource_server`, `livekit_token`, `shared_secret`, `client_credentials`, `oidc`, `password`, `none`, or `unknown` for a class that is not registered. Requests served outside DRF, as the admin and logout are, report `session` when signed in |
| `lasuite.application.client_id` | The external application acting, when there is one. Only set once its credentials are verified |
| `user.id`, `user.sub`, `user.domain` | The account whose authority the action used, see [Actors](#actors): primary key, OIDC sub when the account has one, and email domain. The email address is never recorded |
| `user.target.id`, `user.target.sub`, `user.target.domain` | The account an IAM action was performed on, when the target is a user. `user.*` stays the actor |
| `organization.id` | Tenant: the application client id when present, else the user's email domain |
| `lasuite.target` | The resource acted on: `type`, `id` and a few stable fields per type |
| `lasuite.details` | Action-specific fields (see catalogue) |
| `client.ip`, `source.ip` | Real client address, the one DRF's throttles identify (see `NUM_PROXIES`) |
| `http.request.method`, `url.path` | Request as received |
| `http.response.status_code` | Set on refusals and failures |
| `trace.id` | Request id, also echoed as the `X-Request-ID` response header and logged by Gunicorn as `rid=`. Generated by the backend unless `REQUEST_ID_TRUST_HEADER` is set |
| `error.message` | Human-readable reason of a failure |
| `error.type` | Class of an unhandled exception. Its message is left out, as it may carry personal data |
| `log.level` | `info` for success, `warning` for failures and denials, `error` for internal errors |
An audited API action that raises an exception DRF does not handle is still recorded, as a `failure` with reason
`internal_error`, status code `500` and `error.type`, before the exception propagates.
### Actors
`lasuite.actor.type` says who acted, and `user.*` whose authority the action used:
| `lasuite.actor.type` | Who | `user.*` |
|---|---|---|
| `user` | A person's account acting for itself: session, OIDC or password login, add-on token, LiveKit token of a known account | That account |
| `application` | A client application acting on behalf of a user: a Meet application through its client credentials or its delegated token, or another La Suite application through the resource server. `lasuite.application.client_id` names it | The delegating user |
| `service` | An internal peer of the deployment acting on its own behalf with a shared secret: the LiveKit SIP bridge (`roomkit`), the summary service (`summary`). Never an account. `lasuite.actor.name` names it | Absent |
| `system` | The backend itself, with no inbound request | Absent |
| `anonymous` | A caller that did not authenticate, or failed to | Absent |
A `client_id` in the token payload makes an application, a principal authenticated without an account a service, and
an account a user. An event emitted with neither a request nor an actor is the system's.
## Catalogue
| `event.action` | Emitted when | Notable fields |
|---|---|---|
| `application.token.issue` | An application requests a delegated token (`POST /external-api/v1.0/application/token/`), whether it obtains one or is refused: bad credentials, inactive application, invalid or unauthorized email domain, unknown user, provisioning conflict | On success: `user.*` = delegated user, `lasuite.target` = application, `lasuite.details.scopes`, `user_provisioned`, `expires_in`. On refusal: `event.reason`, `http.response.status_code`, `lasuite.details.requested_domain`. Until the credentials are verified, the submitted client id is only `lasuite.details.claimed_client_id`: it never sets `lasuite.application` or `organization` |
| `user.provision` | An application creates a provisional user by email, or fails to: `failure` with reason `conflict` when a concurrent request created it first | `lasuite.target` = user, the existing one on a conflict. A provisional user has no `sub` yet: its id joins this event to the winning one and to its later `user.login` |
| `room.create` | A room is created through the external API, or the attempt fails | `lasuite.target` = room |
| `room.update` | A room is updated through the external API, or the attempt fails | `lasuite.target` = room, refusals included, `lasuite.details.updated_fields`, `previous_access_level` |
| `room.retrieve` | A room is read through the external API, or the attempt fails | `lasuite.target` = room |
| `room.list` | Rooms are listed through the external API, or the attempt fails | `lasuite.details.total` |
| `user.login` | A user logs in or a login attempt fails, `denied` with reason `authentication_failed` | `lasuite.auth.method` = `oidc` or `password`, or `unknown`: named after the backend on success, `lasuite.details.auth_backend`, and after the credentials submitted on failure (a password, or the nonce of the OIDC callback) |
| `user.logout` | A user logs out | |
| `admin.access` | A signed-in account without staff access reaches an admin page (always denied), once per refused page | `event.reason`, `http.response.status_code`: the redirect to the login page |
| `admin.<target>.<verb>` | A write is made through the Django admin, see below | |
Actions are always dotted, lower-case, with the format `<target>.<verb>`, and name what was attempted: whether it
succeeded is told by `event.outcome`, `lasuite.outcome` and `event.reason`, never by the action.
## Django admin
The admin is the most sensitive surface of the product, so every write made through it emits an audit event next to
the `LogEntry` Django writes itself. Nothing is replaced and there is no extra table: the admin history keeps working.
The action is templated rather than listed: `admin.<target>.<verb>`, where `<target>` is the model name and `<verb>`
one of:
| `<verb>` | Emitted when | Notable fields |
|---|---|---|
| `create` | An object is added | `lasuite.details.changed_fields`, `changes` |
| `update` | An object is changed | `lasuite.details.changed_fields`, `changes` |
| `delete` | An object is deleted, one event per object, once the deletion has run. A deletion that raises is a `failure` with reason `internal_error`; in a bulk deletion every selected object is then reported as failed | `error.message` on failure |
| `action` | A bulk action runs | `lasuite.details.admin_action`, `count` |
So `admin.room.update`, `admin.user.delete`, `admin.recording.action`. `event.category` is `iam` for anything granting
access to the product and `configuration` otherwise. Writes on a user or a group lead `event.type` with `user` or
`group`, as in `["user", "change"]`.
`lasuite.details.changed_fields` always carries the **names** of the fields a form changed, exactly the ones Django
reports in its own history. `lasuite.details.changes` carries their **values**, as `{"from": ..., "to": ...}`, and only
for the fields a model explicitly allows in the `admin_values` it is registered with. Anything that
looks like a secret is refused there whatever the allow-list says, so a password change is reported as a change to `password` and never with its value.
A `JSONField` on the allow-list, such as a room's `configuration`, is recorded as JSON rather than stringified, and both versions are kept
whole.
Objects edited through an **inline** emit their own event, joined to the parent's by `trace.id`: granting a role on a
room produces both `admin.room.update` and `admin.resourceaccess.create`.
What is deliberately **not** covered:
- **Reads.** Opening a change list, a change form or the history page emits nothing. Django's own `LogEntry` remains
the record of who touched what.
- **A custom action bypassing the ORM hooks.** An action calling `queryset.update()` or `queryset.delete()` directly
is reported as `admin.<target>.action` with its name and the number of objects, not one event per object.
`delete_selected` is the exception: Django reports its objects through `log_deletions`, so it emits one
`admin.<target>.delete` each and no `action` event.
The wiring lives in `core/audit/admin.py`: `AuditedAdminSite` mixes the auditing into every admin class at
registration, including those declared by Django itself, and is installed through
`core.audit.apps.AuditedAdminConfig` in `INSTALLED_APPS`. A new `ModelAdmin` is therefore covered without doing
anything; registering its model (see below) only adds its category and its allowed values.
## Emitting events
Actions are declared once, in `core/auditing.py`, as `audit.Action` constants. An action may carry its ECS category
and types, which then apply to every event it emits:
```python
APPLICATION_TOKEN_ISSUE = audit.Action(
"application.token.issue",
category=EventCategory.AUTHENTICATION,
types=(EventType.START,),
)
ROOM_CREATE = audit.Action("room.create")
```
DRF views declare the actions they audit; everything else is derived from the response. CRUD actions are mapped in
`audit_actions`, and an extra action names its own on its route, so that renaming its method cannot silently stop
auditing it:
```python
from core import audit, auditing
class RoomViewSet(audit.AuditViewMixin, viewsets.GenericViewSet):
audit_actions = {"create": auditing.ROOM_CREATE, "retrieve": auditing.ROOM_RETRIEVE}
def perform_create(self, serializer):
self.audit_target = serializer.save()
@action(detail=True, methods=["post"], audit_action=auditing.ROOM_INVITE)
def invite(self, request, pk=None): ...
```
- **Views are audited by `AuditViewMixin`** from DRF's `finalize_response` hook, which runs for every response,
successful or not. The ECS category and types come from the action, else `api` and the DRF action.
The outcome, reason and status code come from the response status: 401, 403 and 429 are `denied`, other
errors `failure`, and a 401 is always filed under `authentication`. The target is the object `get_object()` returned,
unless the view assigns `audit_target`. A view can also assign `audit_actor` and `audit_details`, or override
`get_audit_fields()`.
- **Anything else calls `audit.log`**, from a view or deeper, as a service. `request` defaults to the request being
served, kept in a context variable by `AuditLogMiddleware`. Outside a request, as in a Celery task, there is none and
the actor is the system. That request is the Django one: DRF copies its user and auth onto it, but not its
authenticator, so a call made inside a DRF view without `request=` should pass `auth_method=`. A `category` or `types`
given here wins over the action's:
```python
audit.log(auditing.USER_PROVISION, target=user)
```
- **Request fields are read from `request`**: the real client address and the path. The trace id is the request id,
settled by `AuditLogMiddleware` right after dockerflow assigned it, and echoed in the
`DOCKERFLOW_REQUEST_ID_HEADER_NAME` response header (`X-Request-ID` by default). The inbound id is kept only when
`REQUEST_ID_TRUST_HEADER` is set; otherwise the backend generates one, so a client never picks it.
- **Actors are derived** from `request.user`, `request.auth` and the DRF authenticator, whose class is mapped to an
auth method by `audit.register_auth_method` (see Configuration), as described in [Actors](#actors). Without a request,
the actor is the system. It is possible to override the actor with `actor=`, `actor_type=`, `auth_method=` and
`client_id=`.
- **Targets are described** by their model name, primary key and the `fields` their model is registered with. A model
that is not registered is still identified. Extra keyword arguments land under `lasuite.details`.
- **Emission never raises.** A broken configuration or value is reported on the application logger (and Sentry) and the
business operation proceeds. A registered field that cannot be read is left out of the target, and the event is still
emitted.
## Configuration
| Variable | Default | Meaning |
|---|---|---|
| `AUDIT_LOG_LEVEL` | `INFO` | Level of the `audit` logger. |
| `AUDIT_LOG_STREAM` | `ext://sys.stdout` | Where the handler writes |
| `AUDIT_LOG_SERVICE_NAME` | `meet` | `service.name` |
| `NUM_PROXIES` | `1` | DRF's number of trusted proxies appending to `X-Forwarded-For`, shared with the throttles. The client is the entry that many positions from the right; anything a client injects lands further left and is ignored. `1` matches ingress-nginx defaults; use `2` behind a load balancer that also appends |
| `REQUEST_ID_TRUST_HEADER` | `False` | Reuse the inbound request id as `trace.id`, so the ingress, Gunicorn, application logs and audit events share one id. Only set it when the ingress overwrites the header (`proxy_set_header X-Request-ID $request_id;` on ingress-nginx, which otherwise forwards the client's one): a client could else pick the id of someone else's request |
| `DOCKERFLOW_REQUEST_ID_HEADER_NAME` | `X-Request-ID` | Header carrying that id: read on the request only when `REQUEST_ID_TRUST_HEADER` is set, always echoed on the response |
The project describes itself to the facility in code, from `core/auditing.py`. The audit app imports the `auditing`
module of every installed app once it is ready:
- `audit.register(Model, fields=..., admin_values=..., category=...)`: the `fields` describing a model as a target,
the `admin_values` whose before and after values may be recorded in the admin, and the `category` of its admin
writes. A proxy model falls back to its concrete model. Registering a model twice raises `AlreadyRegistered`.
- `audit.register_auth_method(klass, name)`: the `lasuite.auth.method` of a DRF authentication class or of a login
backend. A DRF class inherits the name of its closest registered base, and DRF's own classes are built in. A login
backend must be registered itself, as custom backends often subclass `ModelBackend` for its permission checks
alone; `ModelBackend` is built in as `password`.
+41 -34
View File
@@ -93,13 +93,13 @@ sequenceDiagram
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). |
| **RECORDING_ENCODING_WIDTH** | Integer | `1280` | Recording video width in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_HEIGHT** | Integer | `720` | Recording video height in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_FRAMERATE** | Integer | `30` | Recording video framerate (fps). Directly impacts egress worker CPU (roughly linear). Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_VIDEO_BITRATE_KBPS** | Integer | `3000` | H.264 MAIN video bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `4.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `4.0` is a standard VOD value. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_CUSTOM_ENCODING_ENABLED** | Boolean | `False` | Whether the start-recording API accepts a per-recording `encoding` object (resolution/profile) that overrides the default. When `False`, the API rejects per-recording `encoding`; when `True`, clients may pick from the available resolutions/profiles. The default encoding below is applied regardless of this flag. See [Tuning recording encoding](#tuning-recording-encoding). |
| **RECORDING_ENCODING_AVAILABLE_RESOLUTIONS** | Dict | `{"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}` | Maps a resolution name to its `{"width", "height"}` in pixels. Both the default encoding and the per-recording start-recording API pick from these keys. |
| **RECORDING_ENCODING_AVAILABLE_PROFILES** | Dict | `{"full": {"fps": 30, "kbps": {…}}, …}` | Maps a profile name to `{"fps", "kbps": {resolution: video_bitrate_kbps}}`. Every profile must define a bitrate for each available resolution (validated at startup). |
| **RECORDING_ENCODING_DEFAULT_RESOLUTION** | String | `"720p"` | Resolution used by the default encoding. When set, must be a key of `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`. Leave unset (together with, or instead of, the default profile) to disable the custom default encoding and fall back to LiveKit's built-in preset (a startup warning is emitted). |
| **RECORDING_ENCODING_DEFAULT_PROFILE** | String | `"full"` | Profile used by the default encoding. When set, must be a key of `RECORDING_ENCODING_AVAILABLE_PROFILES`. Leave unset (together with, or instead of, the default resolution) to disable the custom default encoding and fall back to LiveKit's built-in preset (a startup warning is emitted). |
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps used in the default encoding. |
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `0.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `0` leaves the field unset, letting the encoder pick; `4.0` is a standard VOD value. |
> [!NOTE]
@@ -130,52 +130,59 @@ This allows you to verify which recordings are in progress, troubleshoot egress
## Tuning recording encoding
By default, LiveKit Egress records with the built-in `H264_720P_30` preset: 1280×720 at 30 fps, 3000 kbps H.264 MAIN video and 128 kbps AAC audio. For a one-hour meeting this produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing.
Every video recording is encoded from a default resolved from `RECORDING_ENCODING_DEFAULT_PROFILE` + `RECORDING_ENCODING_DEFAULT_RESOLUTION` and passed to LiveKit as advanced `EncodingOptions`. The shipped defaults (`full` profile) match LiveKit's built-in `H264_720P_30` preset. For a one-hour meeting that produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing; lowering the default profile/resolution shrinks it. If either default is left unset, no custom default encoding is built: a warning is logged at startup and LiveKit's built-in preset is used instead.
The `RECORDING_ENCODING_*` settings let operators override this preset without modifying the source. Values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what you set is what the encoder receives.
Encoding is chosen from two maps: `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS` (`resolution → {"width", "height"}`) and `RECORDING_ENCODING_AVAILABLE_PROFILES` (`profile → {"fps", "kbps": {resolution: video_bitrate_kbps}}`):
- **Default**: `RECORDING_ENCODING_DEFAULT_PROFILE` + `RECORDING_ENCODING_DEFAULT_RESOLUTION` set the encoding used by every recording that doesn't override it. Leave either unset to fall back to LiveKit's built-in preset (a startup warning is emitted).
- **Per recording (opt-in)**: set `RECORDING_CUSTOM_ENCODING_ENABLED=True` to let clients override the default per recording. The start-recording API then accepts an `encoding` object selecting a `resolution` (required) and `profile` (optional): a resolution-only request keeps `RECORDING_ENCODING_DEFAULT_PROFILE` for fps and bitrate, so clients can only pick from pre-defined values. When `RECORDING_CUSTOM_ENCODING_ENABLED=False`, the API rejects any per-recording `encoding` and the default is used.
The resolved values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what the profile/resolution resolve to is what the encoder receives.
### How values map to GStreamer
| Setting | GStreamer element | Property |
| ------------------------------------- | ----------------- | ---------------------------------- |
| `RECORDING_ENCODING_WIDTH/HEIGHT` | capsfilter | `video/x-raw,width=W,height=H` |
| `RECORDING_ENCODING_FRAMERATE` | capsfilter | `framerate=F/1` |
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | `x264enc` | `bitrate=kbps` (kilobits) |
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
| Resolved value | GStreamer element | Property |
| ----------------------------------------- | ----------------- | ---------------------------------- |
| resolution `width` / `height` | capsfilter | `video/x-raw,width=W,height=H` |
| profile `fps` | capsfilter | `framerate=F/1` |
| profile `kbps[resolution]` | `x264enc` | `bitrate=kbps` (kilobits) |
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
The H.264 profile is fixed to MAIN and the x264 `speed-preset` to `veryfast` by LiveKit (real-time constraint) — lowering the framerate is therefore the main lever to save CPU, while lowering the bitrate is the main lever to shrink the output file.
### Reference profiles
### Built-in profiles
Rough 30-minute file-size estimates assume video + audio bitrate multiplied by duration. Actual sizes vary with content (static talking heads compress better than heavy screen motion). Egress CPU figures are indicative, measured on a single Ryzen laptop core saturated by the default preset (= 100 %); scaling is roughly linear with `framerate × bitrate` but the absolute numbers depend on the host hardware.
The default `RECORDING_ENCODING_AVAILABLE_PROFILES` ship four profiles. Framerate is fixed per profile; video bitrate (kbps) scales with resolution so quality stays consistent across sizes. File size scales roughly with `framerate × bitrate`, and so does egress CPU cost.
| Profile | Resolution | FPS | Video (kbps) | Audio (kbps) | Keyframe (s) | ~ size / 30 min | Egress CPU (vs. default) | Suitable for |
| ---------------------- | ---------- | --- | ------------ | ------------ | ------------ | --------------- | ------------------------ | --------------------------------------------------- |
| Default (preset) | 1280×720 | 30 | 3000 | 128 | 4 | **~690 MB** | 100 % | Unchanged LiveKit behaviour |
| Balanced | 1280×720 | 20 | 1000 | 96 | 4 | ~240 MB | ~67 % | Mixed content, moderate motion |
| **Low CPU / small file** | 1280×720 | 15 | 600 | 64 | 4 | **~150 MB** | ~50 % | Talking-head dominant meetings + occasional slides ★ |
| Slide-heavy | 1280×720 | 15 | 900 | 64 | 4 | ~210 MB | ~55 % | Frequent dense screen sharing (decks, IDE, docs) |
| Minimum CPU | 960×540 | 15 | 500 | 64 | 4 | ~125 MB | ~30 % | Voice-first meetings, readable text not required |
| Audio-heavy fallback | 1280×720 | 10 | 400 | 96 | 4 | ~110 MB | ~35 % | Long webinars, low motion |
| Profile | FPS | 540p (kbps) | 720p (kbps) | 1080p (kbps) | Suitable for |
| --------------- | --- | ----------- | ----------- | ------------ | -------------------------------------------------- |
| `talking_heads` | 15 | 400 | 700 | 1200 | Talking-head dominant meetings + occasional slides |
| `text` | 15 | 600 | 1000 | 1800 | Frequent dense screen sharing (decks, IDE, docs) |
| `mixed` | 20 | 900 | 1500 | 2500 | Mixed content, moderate motion |
| `full` | 30 | 2000 | 3000 | 4500 | Highest fidelity; closest to the LiveKit default preset |
★ Recommended starting point for typical LaSuite Meet usage.
To pick a profile per recording (requires `RECORDING_CUSTOM_ENCODING_ENABLED=True`), the client sends it in the start-recording request:
Environment variables for the **Low CPU / small file** profile:
```json
{
"mode": "screen_recording",
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}}
}
```
To change the default encoding applied to every recording:
```bash
RECORDING_ENCODING_ENABLED=True
RECORDING_ENCODING_WIDTH=1280
RECORDING_ENCODING_HEIGHT=720
RECORDING_ENCODING_FRAMERATE=15
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600
RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
RECORDING_ENCODING_DEFAULT_PROFILE=talking_heads
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
```
### Caveats
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The recommended preset (600 kbps × 15 fps) sits at exactly that threshold, comfortable for talking heads with occasional slide sharing. The same 600 kbps at 30 fps would only deliver 20 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **Slide-heavy** profile (900 kbps × 15 fps ≈ 60 kbits/frame).
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The `talking_heads` profile (700 kbps × 15 fps) sits just above that threshold, comfortable for talking heads with occasional slide sharing. The same bitrate at 30 fps would only deliver ~23 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **`text`** profile (1000 kbps × 15 fps ≈ 67 kbits/frame).
- **Motion handling**: the `veryfast` x264 preset is set by LiveKit and cannot be overridden here. Low-bitrate settings will therefore show more artefacts on fast motion than an offline re-encode with a slower preset would. This is the other reason FPS reduction is the safer tuning lever for meeting recordings.
- **Audio**: AAC at 64 kbps stereo is transparent for voice but starts to compress music noticeably. Keep 128 kbps if you expect music playback in meetings.
- **Codec choice**: H.264 MAIN is hardcoded on purpose. Switching to HEVC or VP9 would increase egress CPU cost 2×–5×, defeating the goal of this tuning.
+1
View File
@@ -347,6 +347,7 @@ These are the environmental options available on meet backend.
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
| FRONTEND_DOCUMENTATION_URL | URL of the documentation opened from the room options menu. If unset, the documentation menu item is hidden | |
| FRONTEND_TECHNICAL_DOCUMENTATION_URL | URL of the technical documentation (network prerequisites) linked from the footer and the connection test. If unset, both links are hidden | |
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
+23 -8
View File
@@ -70,18 +70,33 @@ SUMMARY_SERVICE_API_TOKEN=password
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# Recording encoding (LiveKit Egress advanced options).
# When RECORDING_ENCODING_ENABLED is False (default), LiveKit uses its built-in
# H264_720P_30 preset (1280x720, 30fps, 3000 kbps). Enable and tune to reduce
# file size and CPU load on the egress worker.
# RECORDING_ENCODING_ENABLED=False
# RECORDING_ENCODING_WIDTH=1280
# RECORDING_ENCODING_HEIGHT=720
# RECORDING_ENCODING_FRAMERATE=30
# RECORDING_ENCODING_VIDEO_BITRATE_KBPS=3000
# Every video recording is encoded with parameters (height, width, fps, kbps) derived
# from the pair (profile, resolution) and passed to LiveKit as advanced EncodingOptions.
# Choose the available resolutions and profiles that default settings and users can
# pick from. They must be defined as a single-line dict literal (parsed with
# ast.literal_eval: double-quoted keys, no trailing comma, no outer quotes).
# Every profile must define a kbps entry for exactly the keys of
# RECORDING_ENCODING_AVAILABLE_RESOLUTIONS (validated at startup).
# RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}
# RECORDING_ENCODING_AVAILABLE_PROFILES={"talking_heads": {"fps": 15, "kbps": {"540p": 400, "720p": 700, "1080p": 1200}}, "text": {"fps": 15, "kbps": {"540p": 600, "720p": 1000, "1080p": 1800}}, "mixed": {"fps": 20, "kbps": {"540p": 900, "720p": 1500, "1080p": 2500}}, "full": {"fps": 30, "kbps": {"540p": 2000, "720p": 3000, "1080p": 4500}}}
# Choose the default named resolution and profile to use by default. These values must
# be keys of RECORDING_ENCODING_AVAILABLE_RESOLUTIONS and RECORDING_ENCODING_AVAILABLE_PROFILES.
# RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
# RECORDING_ENCODING_DEFAULT_PROFILE=full
# Default encoding values independent of resolution/profile
# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
# Set to True to let the start-recording API override that default per recording
# with an `encoding` object, e.g. {"resolution": "720p", "profile": "talking_heads"}.
# RECORDING_CUSTOM_ENCODING_ENABLED=False
# Telephony
ROOM_TELEPHONY_ENABLED=True
+4
View File
@@ -9,6 +9,10 @@ AWS_S3_ACCESS_KEY_ID="meet-access-key"
AWS_S3_SECRET_ACCESS_KEY="meet-secret-access-key"
AWS_S3_REGION_NAME="local"
CELERY_BROKER_URL="redis://redis:6379/2"
CELERY_RESULT_BACKEND="redis://redis:6379/2"
TASK_TRACKER_REDIS_URL="redis://redis:6379/2"
WHISPERX_BASE_URL="https://configure-your-url.com"
WHISPERX_ASR_MODEL="large-v2"
WHISPERX_API_KEY="your-secret-key"
+1
View File
@@ -73,6 +73,7 @@ def get_frontend_configuration(request):
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
"default_video_codec": settings.LIVEKIT_DEFAULT_VIDEO_CODEC,
},
"allow_unregistered_rooms": settings.ALLOW_UNREGISTERED_ROOMS,
"authenticated_users_can_edit_display_name": (
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
),
+66 -2
View File
@@ -13,7 +13,12 @@ from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
from django_pydantic_field.rest_framework import SchemaField
from pydantic import BaseModel, Field, field_serializer
from pydantic import (
BaseModel,
Field,
field_serializer,
field_validator,
)
from pydantic import ValidationError as PydanticValidationError
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
@@ -244,6 +249,49 @@ class BaseValidationOnlySerializer(serializers.Serializer):
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
class EncodingConfig(BaseModel):
"""Configuration options for recording encoding.
The allowed `resolution` and `profile` values are derived at validation time
from ``settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`` and
``settings.RECORDING_ENCODING_AVAILABLE_PROFILES``, so adding a resolution or profile
to those maps is enough to make it accepted here.
Attributes:
resolution: Target video resolution.
profile: Encoding profile to fps and kbps. When `None`,
`settings.RECORDING_ENCODING_DEFAULT_PROFILE` applies.
"""
resolution: str
profile: str | None = None
model_config = {"extra": "forbid"}
@field_validator("resolution")
@classmethod
def _validate_resolution(cls, value):
"""Reject resolutions absent from RECORDING_ENCODING_AVAILABLE_RESOLUTIONS."""
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
if value not in allowed:
raise ValueError(
f"Invalid resolution '{value}'. Choose from {sorted(allowed)}."
)
return value
@field_validator("profile")
@classmethod
def _validate_profile(cls, value):
"""Reject profiles absent from RECORDING_ENCODING_AVAILABLE_PROFILES."""
if value is None:
return None
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_PROFILES)
if value not in allowed:
raise ValueError(
f"Invalid profile '{value}'. Choose from {sorted(allowed)}."
)
return value
class RecordingOptions(BaseModel):
"""Configuration options for recording.
@@ -264,7 +312,7 @@ class RecordingOptions(BaseModel):
transcribe: bool | None = None
collect_metadata: bool | None = None
original_mode: Literal["screen_recording", "transcript"] | None = None
encoding: EncodingConfig | None = None
model_config = {"extra": "forbid"}
@@ -287,6 +335,22 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
help_text="Recording options",
)
def validate_options(self, value: RecordingOptions):
"""Validate that custom encoding is enabled if encoding options are passed."""
if (
value is not None
and value.encoding is not None
and not settings.RECORDING_CUSTOM_ENCODING_ENABLED
):
# Per-recording encoding selection is gated by
# RECORDING_CUSTOM_ENCODING_ENABLED. When disabled, recordings use
# encoding defined by RECORDING_ENCODING_DEFAULT_RESOLUTION
# and RECORDING_ENCODING_DEFAULT_PROFILE.
raise serializers.ValidationError(
"Per-recording encoding selection is disabled."
)
return value
class RequestEntrySerializer(BaseValidationOnlySerializer):
"""Validate request entry data."""
+10 -1
View File
@@ -55,6 +55,7 @@ from core.recording.worker.exceptions import (
RecordingStopError,
)
from core.recording.worker.factories import (
build_encoding_options,
get_worker_service,
)
from core.recording.worker.mediator import (
@@ -327,12 +328,20 @@ class RoomViewSet(
options = serializer.validated_data.get("options")
room = self.get_object()
options_data = options.model_dump(exclude_none=True) if options else {}
if options is not None and options.encoding is not None:
# Persist the resolved encoding (concrete width/height/framerate/
# bitrate) alongside the requested resolution/profile for traceability.
options_data["encoding"]["resolved"] = build_encoding_options(
options.encoding.resolution, options.encoding.profile
)
try:
with transaction.atomic():
recording = models.Recording.objects.create(
room=room,
mode=mode,
options=options.model_dump(exclude_none=True) if options else {},
options=options_data,
)
models.RecordingAccess.objects.create(
user=self.request.user,
+32
View File
@@ -0,0 +1,32 @@
"""Structured audit logging."""
from .actions import Action
from .actor import email_domain
from .drf import AuditViewMixin
from .emitter import AUDIT_LOGGER_NAME, log
from .enums import ActorType, EventCategory, EventType, Outcome, Reason
from .formatter import AuditJsonFormatter
from .registry import AlreadyRegistered, register, register_auth_method
from .request import current_request
from .signals import LOGIN_ACTION, LOGOUT_ACTION, connect_auth_signals
__all__ = [
"AUDIT_LOGGER_NAME",
"LOGIN_ACTION",
"LOGOUT_ACTION",
"Action",
"ActorType",
"AlreadyRegistered",
"AuditJsonFormatter",
"AuditViewMixin",
"EventCategory",
"EventType",
"Outcome",
"Reason",
"connect_auth_signals",
"current_request",
"email_domain",
"log",
"register",
"register_auth_method",
]
+27
View File
@@ -0,0 +1,27 @@
"""Specs of the actions audit events are emitted for."""
from dataclasses import dataclass
from .enums import EventCategory, EventType
@dataclass(frozen=True)
class Action:
"""An audited action: its dotted name and its ECS classification.
``category`` and ``types`` are the defaults of every event of the action:
a ``category`` or ``types`` given to ``log`` wins over them.
"""
name: str
category: EventCategory | None = None
types: tuple[EventType, ...] = ()
def __post_init__(self):
"""Validate the classification, so a bad one fails at import."""
if self.category is not None:
object.__setattr__(self, "category", EventCategory(self.category))
object.__setattr__(self, "types", tuple(EventType(t) for t in self.types))
def __str__(self) -> str:
return self.name
+159
View File
@@ -0,0 +1,159 @@
"""Resolve who is acting: actor type, identifiers, auth method and tenant.
Personal data is kept to a minimum on purpose: a person is identified by its
primary key, its OIDC ``sub`` when it has one and the domain of its email
address. The address itself is never recorded.
"""
from collections.abc import Mapping
from typing import Any
from django.contrib.auth import get_user_model
from lasuite.tools.email import get_domain_from_email
from .enums import ActorType
from .registry import auth_methods, dotted_path
AUTH_METHOD_NONE = "none"
AUTH_METHOD_SESSION = "session"
AUTH_METHOD_UNKNOWN = "unknown"
DEFAULT_AUTH_METHODS = {
"rest_framework.authentication.SessionAuthentication": AUTH_METHOD_SESSION,
"rest_framework.authentication.BasicAuthentication": "basic",
"rest_framework.authentication.TokenAuthentication": "token",
"django.contrib.auth.backends.ModelBackend": "password",
}
def _auth_methods() -> dict[str, str]:
return {**DEFAULT_AUTH_METHODS, **auth_methods()}
def auth_method_for(authenticator) -> str:
"""Return the auth method name for a DRF authenticator instance."""
if authenticator is None:
return AUTH_METHOD_NONE
methods = _auth_methods()
for klass in type(authenticator).__mro__:
name = methods.get(dotted_path(klass))
if name:
return name
return AUTH_METHOD_UNKNOWN
def auth_method_for_backend(backend: str | None) -> str:
"""Return the auth method name for the dotted path of a login backend."""
return _auth_methods().get(backend or "", AUTH_METHOD_UNKNOWN)
def request_auth_method(request) -> str:
"""Return how ``request`` was authenticated.
A DRF request names its authenticator. A plain Django request, as served
by the admin or the logout view, can only be authenticated by its session.
"""
if hasattr(request, "successful_authenticator"):
return auth_method_for(request.successful_authenticator)
if _is_authenticated(getattr(request, "user", None)):
return AUTH_METHOD_SESSION
return AUTH_METHOD_NONE
def email_domain(email) -> str | None:
"""Return the lower-cased domain part of an email address, if any.
It is parsed as for ``Application.can_delegate_email``, so an audited
domain is the one a delegation was checked against.
"""
domain = get_domain_from_email(str(email)) if email else None
return domain.lower() if domain else None
def client_id_from_auth(auth) -> str | None:
"""Extract an application client id from a token payload."""
if isinstance(auth, Mapping):
value = auth.get("client_id")
return str(value) if value else None
return None
def _is_authenticated(user) -> bool:
return bool(user is not None and getattr(user, "is_authenticated", False))
def _is_account(user) -> bool:
"""Tell whether ``user`` is a user account.
It stays one once deleted, when Django clears its primary key.
"""
return isinstance(user, get_user_model())
def _is_service(user) -> bool:
"""Tell whether ``user`` authenticated without an account, as a machine user."""
return _is_authenticated(user) and not _is_account(user)
def _default_actor_type(request, user, client_id) -> ActorType:
if client_id:
return ActorType.APPLICATION
if request is None and user is None:
return ActorType.SYSTEM
if _is_service(user):
return ActorType.SERVICE
if _is_account(user):
return ActorType.USER
return ActorType.ANONYMOUS
def describe_user(user) -> dict[str, Any]:
"""Return the fields identifying a person: id, OIDC sub and email domain.
The sub is missing for accounts that never signed in, such as provisional
users, and the id for accounts that were deleted.
"""
return {
"id": str(user.pk) if user.pk is not None else None,
"sub": getattr(user, "sub", None) or None,
"domain": email_domain(getattr(user, "email", None)),
}
def describe_actor(
request,
*,
actor=None,
actor_type: ActorType | str | None = None,
client_id: str | None = None,
auth_method: str | None = None,
) -> dict[str, Any]:
"""Return the ECS ``user`` and ``organization`` fields and the ``lasuite`` ones.
Everything is read from ``request`` unless overridden. Without a request
or an actor, the actor is the system. ``user`` is the account whose
authority the action used, see ``ActorType``: None for a service, the
system or an anonymous caller.
"""
user = actor if actor is not None else getattr(request, "user", None)
client_id = client_id or client_id_from_auth(getattr(request, "auth", None))
actor_type = actor_type or _default_actor_type(request, user, client_id)
lasuite: dict[str, Any] = {
"actor": {
"type": str(ActorType(actor_type)),
"name": user.get_username() if _is_service(user) else None,
},
"auth": {"method": auth_method or request_auth_method(request)},
"application": {"client_id": client_id},
}
is_account = _is_account(user)
tenant = client_id or (
email_domain(getattr(user, "email", None)) if is_account else None
)
return {
"user": describe_user(user) if is_account else None,
"organization": {"id": tenant},
"lasuite": lasuite,
}
+348
View File
@@ -0,0 +1,348 @@
"""Audit the writes performed through the Django admin.
Every ``ModelAdmin`` registered on :class:`AuditedAdminSite` emits an audit
event when an object is created, changed or deleted, and when a bulk action
runs. Django's own ``LogEntry`` keeps being written exactly as before: this
stream is additive.
Actions are named ``admin.<target>.<verb>`` where ``<target>`` is the model
name, for instance ``admin.room.update`` or ``admin.user.delete``.
Unlike the rest of the catalogue this family is templated rather than
enumerated: it follows whatever models are registered.
Only writes are audited. Browsing a change list or a change form emits
nothing.
Which field values may be recorded, and the event category, are registered
per model; see ``core.audit.registry``.
"""
import copy
import logging
from contextlib import contextmanager
from enum import StrEnum
from functools import wraps
from typing import Any
from django.contrib.admin import ModelAdmin
from django.contrib.admin.sites import AdminSite
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from .actions import Action
from .emitter import log
from .enums import EventCategory, EventType, Outcome, Reason
from .registry import model_options
from .utils import render_value
ADMIN_ACCESS_ACTION = Action("admin.access", category=EventCategory.IAM)
DIFF_ATTRIBUTE = "audit_admin_diff"
PENDING_DELETIONS_ATTRIBUTE = "audit_admin_pending_deletions"
UNAUDITED_ACTIONS = frozenset({"delete_selected"})
SENSITIVE_FIELD_NAMES = frozenset(
{"api_key", "client_secret", "pin_code", "secret", "sub", "token"}
)
SENSITIVE_FIELD_MARKERS = ("password", "secret", "token")
_logger = logging.getLogger(__name__)
class AdminVerb(StrEnum):
"""What was done to an object through the admin."""
CREATE = "create"
UPDATE = "update"
DELETE = "delete"
ACTION = "action"
_VERB_TYPES: dict[AdminVerb, list[EventType]] = {
AdminVerb.CREATE: [EventType.CREATION],
AdminVerb.UPDATE: [EventType.CHANGE],
AdminVerb.DELETE: [EventType.DELETION],
AdminVerb.ACTION: [EventType.CHANGE],
}
def is_sensitive(field_name: str) -> bool:
"""Tell whether the value of a field must never be recorded."""
return field_name in SENSITIVE_FIELD_NAMES or any(
marker in field_name for marker in SENSITIVE_FIELD_MARKERS
)
def value_fields_for(model: type) -> frozenset[str]:
"""Return the fields of ``model`` whose before and after values may be recorded.
Anything that looks like a secret is dropped from the ``admin_values`` of
the model here, so a mistake in the registration cannot leak one.
"""
names = model_options(model).admin_values
return frozenset(name for name in names if not is_sensitive(name))
def category_for(model: type) -> EventCategory:
"""Return the registered category, else IAM for Django's auth models.
Anything granting access to the product is IAM, the rest configuration.
"""
if category := model_options(model).category:
return category
if model is get_user_model() or issubclass(model, (Group, Permission)):
return EventCategory.IAM
return EventCategory.CONFIGURATION
def types_for(model: type, verb: AdminVerb) -> list[EventType]:
"""Return the event types of ``verb`` on ``model``.
ECS expects ``user`` or ``group`` before the verb when one was the target.
"""
if issubclass(model, get_user_model()):
return [EventType.USER, *_VERB_TYPES[verb]]
if issubclass(model, Group):
return [EventType.GROUP, *_VERB_TYPES[verb]]
return _VERB_TYPES[verb]
def action_name(model: type, verb: AdminVerb) -> str:
"""Return the audit action for ``verb`` on ``model``."""
return f"admin.{model._meta.model_name}.{verb}" # noqa: SLF001
def form_diff(form, value_fields: frozenset[str]) -> dict[str, Any]:
"""Return the names of the fields a form changed, and the allowed values.
Field names are always reported. Values are reported for allow-listed
fields only, as ``{"from": ..., "to": ...}``.
"""
changed = sorted(form.changed_data)
changes = {
name: {
"from": render_value(form.initial.get(name)),
"to": render_value(form.cleaned_data.get(name)),
}
for name in changed
if name in value_fields
}
return {"changed_fields": changed, "changes": changes}
def related_diffs(formsets) -> list[tuple[Any, AdminVerb, dict[str, Any] | None]]:
"""Return one ``(object, verb, diff)`` triple per inline object touched.
Called after ``save_related``, so the formsets already carry what they
saved. The objects they list are the very instances their forms bound, so
the matching form, and with it the before and after values, is found by
identity.
"""
touched = []
for formset in formsets or ():
forms = {id(form.instance): form for form in formset.forms}
value_fields = value_fields_for(formset.model)
def diff_of(obj, forms=forms, value_fields=value_fields):
form = forms.get(id(obj))
return form_diff(form, value_fields) if form is not None else None
for obj in getattr(formset, "new_objects", ()):
touched.append((obj, AdminVerb.CREATE, diff_of(obj)))
for obj, _fields in getattr(formset, "changed_objects", ()):
touched.append((obj, AdminVerb.UPDATE, diff_of(obj)))
for obj in getattr(formset, "deleted_objects", ()):
# A deleted inline has no meaningful diff
touched.append((obj, AdminVerb.DELETE, None))
return touched
class AuditedModelAdminMixin:
"""Emit an audit event for every write made through this ModelAdmin."""
def construct_change_message(self, request, form, formsets, add=False):
"""Stash the structured diff for the ``log_*`` hook that follows."""
message = super().construct_change_message(request, form, formsets, add)
try:
diff = {
"own": form_diff(form, value_fields_for(self.model)),
"related": related_diffs(formsets),
}
except Exception: # pylint: disable=broad-exception-caught
_logger.exception("Admin audit diff could not be built")
diff = None
setattr(request, DIFF_ATTRIBUTE, diff)
return message
def log_addition(self, request, obj, message):
"""Record the creation, and that of any inline object saved with it."""
entry = super().log_addition(request, obj, message)
self.audit_form_write(request, AdminVerb.CREATE, obj)
return entry
def log_change(self, request, obj, message):
"""Record the change, and that of any inline object saved with it."""
entry = super().log_change(request, obj, message)
self.audit_form_write(request, AdminVerb.UPDATE, obj)
return entry
def log_deletions(self, request, queryset):
"""Note the objects about to be deleted.
Django calls this before ``delete_model`` and ``delete_queryset``, in
both the single and the bulk path. Those emit the events, once the
deletion has succeeded or failed. Copies are kept because deleting an
instance clears its primary key.
"""
targets = list(queryset)
entries = super().log_deletions(request, targets)
setattr(
request, PENDING_DELETIONS_ATTRIBUTE, [copy.copy(obj) for obj in targets]
)
return entries
def delete_model(self, request, obj):
"""Delete the object, then record one deletion."""
with self.auditing_deletions(request, lambda: [copy.copy(obj)]):
super().delete_model(request, obj)
def delete_queryset(self, request, queryset):
"""Delete the objects, then record one deletion per object."""
with self.auditing_deletions(request, lambda: list(queryset)):
super().delete_queryset(request, queryset)
@contextmanager
def auditing_deletions(self, request, default_targets):
"""Record the deletions noted by ``log_deletions`` with their outcome.
``default_targets`` lists the objects when ``log_deletions`` did not
run, as when a custom action deletes through these methods directly.
"""
targets = getattr(request, PENDING_DELETIONS_ATTRIBUTE, None)
setattr(request, PENDING_DELETIONS_ATTRIBUTE, None)
if targets is None:
targets = default_targets()
try:
yield
except Exception as error:
for obj in targets:
self.audit_write(request, AdminVerb.DELETE, obj, error=error)
raise
for obj in targets:
self.audit_write(request, AdminVerb.DELETE, obj)
def get_actions(self, request):
"""Return the available actions, each wrapped so that running it is audited."""
return {
name: (self.audited_action(func, name), name, description)
for name, (func, _name, description) in super().get_actions(request).items()
}
def audited_action(self, func, name):
"""Wrap an admin action so every run emits an event, success or not."""
if name in UNAUDITED_ACTIONS:
return func
@wraps(func)
def run(modeladmin, request, queryset):
count = queryset.count()
try:
response = func(modeladmin, request, queryset)
except Exception as error:
modeladmin.audit_action(request, name, count, error=error)
raise
modeladmin.audit_action(request, name, count)
return response
return run
def audit_form_write(self, request, verb, obj):
"""Emit the event for a form write and for the inlines saved with it."""
diff = getattr(request, DIFF_ATTRIBUTE, None) or {}
setattr(request, DIFF_ATTRIBUTE, None)
self.audit_write(request, verb, obj, diff.get("own"))
for related_obj, related_verb, related_diff in diff.get("related", ()):
self.audit_write(request, related_verb, related_obj, related_diff)
def audit_write(self, request, verb, obj, diff=None, *, error=None): # pylint: disable=too-many-arguments
"""Emit one event for a write on ``obj``, a failed one if ``error`` is set."""
model = obj.__class__
log(
action_name(model, verb),
request=request,
outcome=Outcome.SUCCESS if error is None else Outcome.FAILURE,
reason=None if error is None else Reason.INTERNAL_ERROR,
error=error,
category=category_for(model),
types=types_for(model, verb),
target=obj,
user_target=obj if isinstance(obj, get_user_model()) else None,
**(diff or {}),
)
def audit_action(self, request, name, count, error=None):
"""Emit one event for a bulk action run on ``count`` objects."""
log(
action_name(self.model, AdminVerb.ACTION),
request=request,
outcome=Outcome.SUCCESS if error is None else Outcome.FAILURE,
reason=None if error is None else Reason.INTERNAL_ERROR,
category=category_for(self.model),
types=types_for(self.model, AdminVerb.ACTION),
error=error,
admin_action=name,
count=count,
)
def audited(admin_class: type) -> type:
"""Return ``admin_class`` with the audit mixin."""
if issubclass(admin_class, AuditedModelAdminMixin):
return admin_class
return type(
f"Audited{admin_class.__name__}",
(AuditedModelAdminMixin, admin_class),
{"__module__": admin_class.__module__, "__doc__": admin_class.__doc__},
)
class AuditedAdminSite(AdminSite):
"""Admin site whose model admins all emit audit events.
Installed through ``AdminConfig.default_site`` so that admin classes
declared by Django itself, or by a third-party app, are covered as well as
the project's own.
"""
def register(self, model_or_iterable, admin_class=None, **options):
"""Register the audited flavour of the given admin class."""
super().register(
model_or_iterable, audited(admin_class or ModelAdmin), **options
)
def admin_view(self, view, cacheable=False):
"""Record when a signed-in account without staff access tries an admin view.
Django asks ``has_permission`` several times per request, the login
page included, so the refusal is recorded here instead: once per
refused view. The answer is taken before the view runs, which may log
the user out.
"""
guarded = super().admin_view(view, cacheable)
@wraps(guarded)
def inner(request, *args, **kwargs):
refused = getattr(
request.user, "is_authenticated", False
) and not self.has_permission(request)
response = guarded(request, *args, **kwargs)
if refused:
log(
ADMIN_ACCESS_ACTION,
outcome=Outcome.DENIED,
reason=Reason.PERMISSION_DENIED,
request=request,
status_code=response.status_code,
)
return response
return inner
+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"
+174
View File
@@ -0,0 +1,174 @@
"""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 logging
from collections.abc import Mapping
from typing import Any
from .actions import Action
from .emitter import EVENT_FIELDS, log
from .enums import EventCategory, EventType, Outcome, Reason
from .utils import exception_type
ACTION_TYPES = {
"create": EventType.CREATION,
"update": EventType.CHANGE,
"partial_update": EventType.CHANGE,
"destroy": EventType.DELETION,
"retrieve": EventType.ACCESS,
"list": EventType.ACCESS,
}
STATUS_REASONS = {
400: Reason.VALIDATION_ERROR,
401: Reason.AUTHENTICATION_FAILED,
403: Reason.PERMISSION_DENIED,
404: Reason.NOT_FOUND,
409: Reason.CONFLICT,
429: Reason.RATE_LIMITED,
}
DENIED_STATUSES = frozenset({401, 403, 429})
_logger = logging.getLogger(__name__)
def error_message(response) -> Any:
"""Return the message of an error response, as DRF or the view wrote it."""
data = getattr(response, "data", None)
if isinstance(data, Mapping):
return data.get("detail") or data.get("error")
return None
class AuditViewMixin:
"""Emit one audit event per response of an audited action.
``audit_actions`` maps the CRUD actions only. An extra action is audited
by passing ``audit_action`` to its ``@action`` decorator.
While handling a request, a view may *assign* ``audit_target``,
``audit_actor`` and ``audit_details``; ``check_object_permissions`` sets
the target on its own, before a refusal can happen. A detail named after
an event field, as ``outcome`` or ``request``, is dropped: overriding one
is done in ``get_audit_fields``.
"""
audit_actions: Mapping[str, Action | str] = {}
# Only declared so the router may pass the ``@action`` keyword arguments
# to ``as_view``; the action is read from the handler of the request.
audit_action: Action | str | None = None
audit_target: Any = None
audit_actor: Any = None
audit_details: Mapping[str, Any] | None = None
def __init_subclass__(cls, **kwargs):
"""Refuse extra actions in ``audit_actions``, keyed by a method name."""
super().__init_subclass__(**kwargs)
if extra := sorted(set(cls.audit_actions) - set(ACTION_TYPES)):
raise TypeError(
f"{cls.__qualname__}.audit_actions only maps CRUD actions: "
f"audit {', '.join(extra)} with @action(audit_action=...)"
)
def check_object_permissions(self, request, obj):
"""Remember the object as the target."""
self.audit_target = obj
super().check_object_permissions(request, obj)
def finalize_response(self, request, response, *args, **kwargs):
"""Audit the response once DRF has built it."""
response = super().finalize_response(request, response, *args, **kwargs)
self.emit_audit_event(request, response.status_code, error_message(response))
return response
def handle_exception(self, exc):
"""Audit an exception DRF cannot turn into a response, then let it propagate.
Only its class is recorded since its message could carry personal data.
"""
try:
return super().handle_exception(exc)
except Exception as error:
self.emit_audit_event(self.request, 500, error_type=exception_type(error))
raise
def get_audit_action(self) -> Action | str | None:
"""Return what the current request audits, if anything.
An extra action is read from its handler, so a request that reaches
none, as an OPTIONS request or a refused method, audits nothing.
"""
name = getattr(self, "action", None)
if name in ACTION_TYPES:
return self.audit_actions.get(name)
handler = getattr(self, name, None) if name else None
return getattr(handler, "kwargs", {}).get("audit_action")
def emit_audit_event(self, request, status_code, error=None, error_type=None):
"""Emit the event of the current action, if it is audited.
Never raises: a response must not fail because it could not be audited.
"""
try:
action = self.get_audit_action()
if action is not None:
fields = self.get_audit_fields(status_code, error)
log(action, request=request, error_type=error_type, **fields)
except Exception: # pylint: disable=broad-exception-caught
_logger.exception("Audit event of %s could not be emitted", request.path)
def get_audit_fields(self, status_code, error=None) -> dict[str, Any]:
"""Return the fields of the event for a response of ``status_code``.
The category and types of the ``Action`` win over those derived from
the DRF action.
"""
action = self.get_audit_action()
category, types = None, []
if isinstance(action, Action):
category, types = action.category, list(action.types)
details = {
key: value
for key, value in (self.audit_details or {}).items()
if key not in EVENT_FIELDS
}
fields = {
**details,
"category": category or EventCategory.API,
"types": types
or [ACTION_TYPES.get(getattr(self, "action", None), EventType.INFO)],
"target": self.audit_target,
"actor": self.audit_actor,
}
if status_code >= 400:
fields |= {
"outcome": (
Outcome.DENIED
if status_code in DENIED_STATUSES
else Outcome.FAILURE
),
"reason": STATUS_REASONS.get(
status_code, Reason.INTERNAL_ERROR if status_code >= 500 else None
),
"status_code": status_code,
"error": error,
}
if status_code == 401:
fields["category"] = EventCategory.AUTHENTICATION
return fields
+164
View File
@@ -0,0 +1,164 @@
"""Build ECS audit documents and emit them on the ``audit`` logger."""
import inspect
import logging
from datetime import datetime, timezone
from typing import Any
from django.conf import settings
from .actions import Action
from .actor import describe_actor, describe_user
from .enums import ActorType, EventCategory, EventType, Outcome, Reason
from .request import current_request, current_request_id, resolve_client_ip
from .targets import describe_target
from .utils import prune_empty, render_value
AUDIT_LOGGER_NAME = "audit"
ECS_VERSION = "8.11.0"
LOG_TYPE = "audit"
_audit_logger = logging.getLogger(AUDIT_LOGGER_NAME)
_logger = logging.getLogger(__name__)
def log(action: Action | str, **fields: Any) -> None:
"""Emit one audit event."""
try:
document = build_document(action, **fields)
except Exception: # pylint: disable=broad-exception-caught
_logger.exception("Audit event %r could not be built", action)
return
_audit_logger.log(
level_for(document["lasuite"]["outcome"], document["event"].get("reason")),
str(action),
extra={"audit": document},
)
def level_for(outcome: Outcome | str, reason: Reason | str | None) -> int:
"""Derive the logging level so call sites never choose one."""
if Outcome(outcome) == Outcome.SUCCESS:
return logging.INFO
if reason is not None and Reason(reason) == Reason.INTERNAL_ERROR:
return logging.ERROR
return logging.WARNING
def build_document( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-locals
action: Action | str,
*,
request: Any = None,
outcome: Outcome | str = Outcome.SUCCESS,
reason: Reason | str | None = None,
category: EventCategory | str | None = None,
types: list[EventType | str] | None = None,
target: Any = None,
user_target: Any = None,
actor: Any = None,
actor_type: ActorType | str | None = None,
auth_method: str | None = None,
client_id: str | None = None,
status_code: int | None = None,
error: Any = None,
error_type: str | None = None,
message: str | None = None,
**details: Any,
) -> dict[str, Any]:
"""Return the ECS document of an event, pruned of empty values.
``action`` is what was attempted: an ``Action``, whose category and types
apply unless given here, or a bare dotted name (``room.create``).
The actor, auth method and network fields are read from ``request``, by
default the request being served.
``actor``, ``actor_type``, ``auth_method`` and ``client_id`` override them.
``target`` is the resource acted on and ``user_target`` the account an IAM
action was performed on, reported as ``user.target``. Any other keyword
argument lands under ``lasuite.details``.
"""
if request is None:
request = current_request()
outcome = Outcome(outcome)
reason = Reason(reason) if reason is not None else None
if isinstance(action, Action):
category = category or action.category
types = types or list(action.types)
client_ip = resolve_client_ip(request) if request is not None else None
actor_fields = describe_actor(
request,
actor=actor,
actor_type=actor_type,
client_id=client_id,
auth_method=auth_method,
)
return prune_empty(
{
"@timestamp": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
"ecs": {"version": ECS_VERSION},
"log_type": LOG_TYPE,
"message": message,
"service": {
"name": getattr(settings, "AUDIT_LOG_SERVICE_NAME", None),
"environment": getattr(settings, "ENVIRONMENT", None),
},
"event": _event_fields(action, outcome, reason, category, types),
"trace": {"id": current_request_id()},
"client": {"ip": client_ip},
"source": {"ip": client_ip},
"http": {
"request": {"method": getattr(request, "method", None)},
"response": {"status_code": status_code},
},
"url": {"path": getattr(request, "path", None) or None},
"user": {
**(actor_fields["user"] or {}),
"target": describe_user(user_target) if user_target else None,
},
"organization": actor_fields["organization"],
"lasuite": {
**actor_fields["lasuite"],
"outcome": str(outcome),
"target": describe_target(target) if target is not None else None,
"details": render_value(details),
},
"error": {
"message": str(error) if error is not None else None,
"type": error_type,
},
}
)
# The keyword arguments of ``log`` that fill an event field rather than a detail
EVENT_FIELDS = frozenset(
name
for name, parameter in inspect.signature(build_document).parameters.items()
if parameter.kind is inspect.Parameter.KEYWORD_ONLY
)
def _event_fields(action, outcome, reason, category, types) -> dict[str, Any]:
type_list = [str(EventType(item)) for item in (types or [])]
if not type_list:
type_list = [str(_default_type(outcome))]
if outcome == Outcome.DENIED and str(EventType.DENIED) not in type_list:
type_list.append(str(EventType.DENIED))
return {
"kind": "event",
"action": str(action),
"category": [str(EventCategory(category or EventCategory.WEB))],
"type": type_list,
"outcome": "success" if outcome == Outcome.SUCCESS else "failure",
"reason": str(reason) if reason is not None else None,
}
def _default_type(outcome: Outcome) -> EventType:
if outcome == Outcome.SUCCESS:
return EventType.INFO
if outcome == Outcome.DENIED:
return EventType.DENIED
return EventType.ERROR
+74
View File
@@ -0,0 +1,74 @@
"""ECS enums shared by every audit event."""
from enum import StrEnum
class Outcome(StrEnum):
"""Whether the audited action succeeded, failed, or was refused."""
SUCCESS = "success"
FAILURE = "failure"
DENIED = "denied"
class Reason(StrEnum):
"""Why an action did not succeed."""
AUTHENTICATION_FAILED = "authentication_failed"
PERMISSION_DENIED = "permission_denied"
RATE_LIMITED = "rate_limited"
VALIDATION_ERROR = "validation_error"
NOT_FOUND = "not_found"
CONFLICT = "conflict"
INTERNAL_ERROR = "internal_error"
class ActorType(StrEnum):
"""Kind of principal behind an action.
``user.*`` is the account whose authority the action used: the actor for
``user``, the delegating user for ``application``, absent otherwise.
"""
# A person's account acting for itself.
USER = "user"
# A client application acting on behalf of a user, named by its client id.
APPLICATION = "application"
# An internal peer of the deployment acting on its own behalf with a
# shared secret, named by ``lasuite.actor.name``. Never an account.
SERVICE = "service"
# The backend itself, with no inbound request.
SYSTEM = "system"
# A caller that did not authenticate, or failed to.
ANONYMOUS = "anonymous"
class EventCategory(StrEnum):
"""Subset of the ECS ``event.category`` ."""
API = "api"
AUTHENTICATION = "authentication"
CONFIGURATION = "configuration"
EMAIL = "email"
FILE = "file"
IAM = "iam"
SESSION = "session"
WEB = "web"
class EventType(StrEnum):
"""Subset of the ECS ``event.type``."""
ACCESS = "access"
ADMIN = "admin"
ALLOWED = "allowed"
CHANGE = "change"
CREATION = "creation"
DELETION = "deletion"
DENIED = "denied"
END = "end"
ERROR = "error"
GROUP = "group"
INFO = "info"
START = "start"
USER = "user"
+35
View File
@@ -0,0 +1,35 @@
"""Render audit records as single-line ECS JSON format."""
import json
import logging
from datetime import datetime, timezone
from typing import Any
class AuditJsonFormatter(logging.Formatter):
"""Serialise the document attached to the record under ``audit`` in ECS format."""
def format(self, record: logging.LogRecord) -> str:
document = getattr(record, "audit", None)
if not isinstance(document, dict):
document = {
"@timestamp": datetime.fromtimestamp(
record.created, tz=timezone.utc
).isoformat(timespec="milliseconds"),
"log_type": "audit",
"message": record.getMessage(),
"event": {"action": record.getMessage()},
}
document = {
**document,
"log": {"level": record.levelname.lower(), "logger": record.name},
}
if record.exc_info:
error: dict[str, Any] = dict(document.get("error") or {})
error["stack_trace"] = self.formatException(record.exc_info)
document["error"] = error
return json.dumps(
document, ensure_ascii=False, default=str, separators=(",", ":")
)
+99
View File
@@ -0,0 +1,99 @@
"""Declare what audit events may say about models and authentication classes.
The project registers them from an ``auditing`` module in one of its apps,
imported once the audit app is ready::
audit.register(
Room,
fields=("slug", "access_level"), # describe the target
admin_values=("name", "access_level"), # values diffed in the admin
category=audit.EventCategory.CONFIGURATION, # ECS category of admin writes
)
audit.register_auth_method(ApplicationJWTAuthentication, "application_jwt")
A target is always identified by its model name and primary key, so a model
that is not registered is still identifiable, just less detailed.
"""
from dataclasses import dataclass
from django.db.models import Model
from .enums import EventCategory
class AlreadyRegistered(Exception):
"""A model or an authentication class that was registered twice."""
@dataclass(frozen=True)
class ModelOptions:
"""What audit events may say about a model.
``fields`` describe the model when it is the target of an event.
``admin_values`` are the fields whose before and after values may be
recorded when they change in the Django admin. ``category`` is the ECS
category of admin writes: ``iam`` for anything granting access to the
product, ``configuration`` by default.
"""
fields: tuple[str, ...] = ()
admin_values: tuple[str, ...] = ()
category: EventCategory | None = None
_models: dict[type[Model], ModelOptions] = {}
_auth_methods: dict[str, str] = {}
def register(
model: type[Model],
*,
fields=(),
admin_values=(),
category: EventCategory | str | None = None,
) -> None:
"""Declare what audit events may say about ``model``."""
if model in _models:
raise AlreadyRegistered(f"{model._meta.label} is already registered") # noqa: SLF001
_models[model] = ModelOptions(
fields=tuple(fields),
admin_values=tuple(admin_values),
category=EventCategory(category) if category is not None else None,
)
def unregister(model: type[Model]) -> ModelOptions | None:
"""Forget ``model`` and return what was registered for it, if anything."""
return _models.pop(model, None)
def model_options(model: type[Model]) -> ModelOptions:
"""Return what is registered for a model, or for its concrete model."""
for klass in (model, model._meta.concrete_model): # noqa: SLF001
if (options := _models.get(klass)) is not None:
return options
return ModelOptions()
def dotted_path(klass: type) -> str:
"""Return the dotted path Django and DRF name a class by."""
return f"{klass.__module__}.{klass.__qualname__}"
def register_auth_method(klass: type, name: str) -> None:
"""Name the ``lasuite.auth.method`` of a DRF authentication class or a login backend.
A DRF class is also the default of its subclasses. A login backend must be
registered itself: custom backends often subclass ``ModelBackend`` only for
its permission checks, and must not pass for password logins.
"""
path = dotted_path(klass)
if path in _auth_methods:
raise AlreadyRegistered(f"{path} is already registered")
_auth_methods[path] = name
def auth_methods() -> dict[str, str]:
"""Return the registered auth methods, keyed by dotted path."""
return dict(_auth_methods)
+76
View File
@@ -0,0 +1,76 @@
"""Read the network fields and the request id behind an audit event."""
import uuid
from contextvars import ContextVar, Token
from django.conf import settings
from dockerflow.logging import request_id_context
from rest_framework.throttling import BaseThrottle
_current_request: ContextVar = ContextVar("audit_request", default=None)
def current_request():
"""Return the request being served, if any, as set by ``AuditLogMiddleware``.
It is the Django request: DRF copies its user and auth onto it, but not its
authenticator, so the auth method of a DRF view is not known from it.
"""
return _current_request.get()
def set_current_request(request) -> Token:
"""Make ``request`` the request being served, until the token is reset."""
return _current_request.set(request)
def reset_current_request(token: Token) -> None:
"""Restore the request that was current before ``set_current_request``."""
_current_request.reset(token)
def current_request_id() -> str | None:
"""Return the id of the request being served, if any."""
return request_id_context.get(None)
def resolve_client_ip(request) -> str | None:
"""Return the address of the real client, never the one of a proxy.
It reuses DRF's throttles to identify the client.
"""
return BaseThrottle().get_ident(request) or request.META.get("REMOTE_ADDR")
class AuditLogMiddleware:
"""Settle the request id and the current request, then echo the id.
It must come right after ``DockerflowMiddleware``, which sets the id from
the inbound ``DOCKERFLOW_REQUEST_ID_HEADER_NAME`` header. Unless
``REQUEST_ID_TRUST_HEADER`` says the ingress overwrites that header, the id
is replaced by a fresh one before anything logs, so that a client, the web
server access log and the audit events of a request can be joined on an id
the client did not choose.
The request is kept as the current one while it is served, so that an
audit event emitted far from the view, as from a service, still reads its
actor and network fields from it.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if not settings.REQUEST_ID_TRUST_HEADER:
request_id_context.set(str(uuid.uuid4()))
token = set_current_request(request)
try:
response = self.get_response(request)
finally:
reset_current_request(token)
header = settings.DOCKERFLOW_REQUEST_ID_HEADER_NAME
if not response.has_header(header):
response[header] = current_request_id()
return response
+85
View File
@@ -0,0 +1,85 @@
"""Audit Django's authentication signals: login, failed login, logout."""
from django.contrib.auth import BACKEND_SESSION_KEY
from django.contrib.auth.signals import (
user_logged_in,
user_logged_out,
user_login_failed,
)
from .actions import Action
from .actor import AUTH_METHOD_UNKNOWN, auth_method_for_backend
from .emitter import log
from .enums import ActorType, EventCategory, EventType, Outcome, Reason
LOGIN_ACTION = Action(
"user.login", category=EventCategory.AUTHENTICATION, types=(EventType.START,)
)
LOGOUT_ACTION = Action(
"user.logout", category=EventCategory.AUTHENTICATION, types=(EventType.END,)
)
def get_login_backend(request, user) -> str | None:
"""Return the dotted path of the backend a login went through."""
session = getattr(request, "session", None)
from_session = session.get(BACKEND_SESSION_KEY) if session is not None else None
return from_session or getattr(user, "backend", None)
def auth_method_from_credentials(credentials) -> str:
"""Name the mechanism of a failed login from the credentials it submitted."""
if "password" in credentials:
return "password"
if "nonce" in credentials:
return "oidc"
return AUTH_METHOD_UNKNOWN
def on_user_logged_in(sender, request, user, **kwargs): # pylint: disable=unused-argument
"""Record a successful login."""
backend = get_login_backend(request, user)
log(
LOGIN_ACTION,
request=request,
actor=user,
auth_method=auth_method_for_backend(backend),
auth_backend=backend,
)
def on_user_login_failed(sender, credentials, request, **kwargs): # pylint: disable=unused-argument
"""Record a failed login.
It is a refusal, like a 401 on the API, whether the credentials were
rejected or a backend raised ``PermissionDenied``. Its actor is anonymous
even without a request, as when ``authenticate`` is called without one.
"""
log(
LOGIN_ACTION,
outcome=Outcome.DENIED,
reason=Reason.AUTHENTICATION_FAILED,
request=request,
actor_type=ActorType.ANONYMOUS,
auth_method=auth_method_from_credentials(credentials),
)
def on_user_logged_out(sender, request, user, **kwargs): # pylint: disable=unused-argument
"""Record a logout, unless no one was signed in."""
if user is None:
return
log(
LOGOUT_ACTION,
request=request,
actor=user,
)
def connect_auth_signals() -> None:
"""Connect the receivers to authentication signal."""
user_logged_in.connect(on_user_logged_in, dispatch_uid="audit.user_logged_in")
user_login_failed.connect(
on_user_login_failed, dispatch_uid="audit.user_login_failed"
)
user_logged_out.connect(on_user_logged_out, dispatch_uid="audit.user_logged_out")
+48
View File
@@ -0,0 +1,48 @@
"""Describe the resource an audit event is about.
The fields describing each model are those registered for it, see
``core.audit.registry``. A target is always identified by its model name and
primary key, so a model that is not registered is still identifiable, just
less detailed.
"""
import logging
from collections.abc import Mapping
from typing import Any
from django.contrib.auth import get_user_model
from django.db.models import Model
from .actor import describe_user
from .registry import model_options
from .utils import render_value
_logger = logging.getLogger(__name__)
def describe_target(obj: Any) -> dict[str, Any]:
"""Return ``{"type": ..., "id": ..., **fields}`` for a target.
A user will carries its OIDC sub and its email domain.
A registered field that cannot be read is left out and simply reported.
"""
if isinstance(obj, Mapping):
return dict(obj)
if not isinstance(obj, Model):
return {"type": obj.__class__.__name__.lower(), "id": str(obj)}
meta = obj._meta # noqa: SLF001
document: dict[str, Any] = {
"type": meta.model_name,
"id": str(obj.pk) if obj.pk is not None else None,
}
for name in model_options(meta.model).fields:
try:
document[name] = render_value(getattr(obj, name))
except Exception: # pylint: disable=broad-exception-caught
_logger.exception(
"Audit field %r of %s could not be read", name, meta.label
)
if isinstance(obj, get_user_model()):
document |= describe_user(obj)
return document
+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))
+48
View File
@@ -0,0 +1,48 @@
"""Value helpers used to assemble audit logs."""
from collections.abc import Mapping
from enum import Enum
from typing import Any
from django.db.models import Model, QuerySet
def render_value(value: Any) -> Any:
"""Render a value as something stable and JSON-friendly.
Model instances are reduced to their primary key, enums to their value.
"""
if isinstance(value, Model):
return str(value.pk)
if isinstance(value, Enum):
return value.value
if isinstance(value, Mapping):
return {str(key): render_value(item) for key, item in value.items()}
if isinstance(value, (QuerySet, list, tuple, set, frozenset)):
return [render_value(item) for item in value]
if value is None or isinstance(value, (bool, int, float, str)):
return value
return str(value)
def exception_type(error: BaseException) -> str:
"""Return the dotted name of an exception's class.
Audit events record it rather than the message, which may carry personal
data.
"""
error_class = type(error)
return f"{error_class.__module__}.{error_class.__qualname__}"
def prune_empty(value: Any) -> Any:
"""Drop ``None`` values and empty mappings, recursively."""
if not isinstance(value, Mapping):
return value
pruned = {}
for key, item in value.items():
cleaned = prune_empty(item)
if cleaned is None or (isinstance(cleaned, dict) and not cleaned):
continue
pruned[key] = cleaned
return pruned
+91
View File
@@ -0,0 +1,91 @@
"""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")
# Models
audit.register(
models.User,
category=EventCategory.IAM,
admin_values=(
"is_active",
"is_staff",
"is_superuser",
"is_device",
"groups",
"user_permissions",
),
)
audit.register(Group, category=EventCategory.IAM, admin_values=("name", "permissions"))
audit.register(
models.Application,
category=EventCategory.IAM,
fields=("client_id", "name", "is_active", "scopes"),
admin_values=("name", "is_active", "scopes"),
)
audit.register(
models.ApplicationDomain, category=EventCategory.IAM, admin_values=("domain",)
)
audit.register(
models.ResourceAccess,
category=EventCategory.IAM,
fields=("resource_id", "user_id", "role"),
admin_values=("role",),
)
audit.register(
models.RecordingAccess, category=EventCategory.IAM, admin_values=("role",)
)
audit.register(
models.Room,
fields=("slug", "name", "access_level"),
admin_values=("name", "slug", "access_level", "configuration"),
)
audit.register(
models.Recording,
fields=("room_id", "status", "mode"),
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")
+8 -25
View File
@@ -1,26 +1,19 @@
"""Authentication Backends for the Meet core app."""
import contextlib
from django.conf import settings
from django.core.exceptions import (
ImproperlyConfigured,
SuspiciousOperation,
ValidationError,
)
from django.utils.translation import gettext_lazy as _
from lasuite.marketing.tasks import create_or_update_contact
from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
)
from rest_framework.authentication import SessionAuthentication
from core.models import User
from core.services.marketing import (
ContactCreationError,
ContactData,
get_marketing_service,
)
from core.validators import sub_validator
@@ -67,25 +60,15 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
@staticmethod
def signup_to_marketing_email(email):
"""Pragmatic approach to newsletter signup during authentication flow.
"""Add the user to the newsletter list on sign-in.
Details:
1. Uses a very short timeout (1s) to prevent blocking the auth process
2. Silently fails if the marketing service is down/slow to prioritize user experience
3. Trade-off: May miss some signups but ensures auth flow remains fast
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
Uses the team's standard Brevo integration, dispatching the contact
creation/update as an asynchronous task to keep authentication fast.
"""
with contextlib.suppress(
ContactCreationError, ImproperlyConfigured, ImportError
):
marketing_service = get_marketing_service()
contact_data = ContactData(
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
)
marketing_service.create_contact(
contact_data, timeout=settings.BREVO_API_TIMEOUT
)
create_or_update_contact.delay(
email=email,
attributes={"VISIO_SOURCE": ["SIGNIN"]},
)
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
+54 -25
View File
@@ -1,7 +1,6 @@
"""External API endpoints"""
import copy
from logging import getLogger
from django.conf import settings
from django.contrib.auth.hashers import check_password
@@ -23,7 +22,7 @@ from rest_framework import (
status as drf_status,
)
from core import analytics, api, models
from core import analytics, api, audit, auditing, models
from core.api.feature_flag import FeatureFlag
from core.services.jwt_token import JwtTokenService
from core.services.room_management import RoomManagement
@@ -35,18 +34,19 @@ from ..services.provisional_user_service import (
)
from . import authentication, permissions, serializers
logger = getLogger(__name__)
class ApplicationViewSet(viewsets.ViewSet):
class ApplicationViewSet(audit.AuditViewMixin, viewsets.ViewSet):
"""API endpoints for application authentication and token generation."""
audit_client_id = None
@decorators.action(
detail=False,
methods=["post"],
url_path="token",
url_name="token",
parser_classes=[drf_parsers.FormParser, drf_parsers.JSONParser],
audit_action=auditing.APPLICATION_TOKEN_ISSUE,
)
@FeatureFlag.require("application")
def generate_jwt_access_token(self, request, *args, **kwargs):
@@ -68,6 +68,10 @@ class ApplicationViewSet(viewsets.ViewSet):
client_id = serializer.validated_data["client_id"]
client_secret = serializer.validated_data["client_secret"]
email = serializer.validated_data["scope"]
self.audit_client_id = client_id
self.audit_details = {"requested_domain": audit.email_domain(email)}
try:
application = models.Application.objects.get(client_id=client_id)
@@ -80,7 +84,8 @@ class ApplicationViewSet(viewsets.ViewSet):
if not application.is_active:
raise drf_exceptions.AuthenticationFailed("Application is inactive")
email = serializer.validated_data["scope"]
self.audit_target = application
try:
validate_email(email)
except ValidationError:
@@ -92,11 +97,6 @@ class ApplicationViewSet(viewsets.ViewSet):
)
if not application.can_delegate_email(email):
logger.warning(
"Application %s denied delegation for %s",
application.client_id,
email,
)
return drf_response.Response(
{
"error": "This application is not authorized for this email domain.",
@@ -105,7 +105,7 @@ class ApplicationViewSet(viewsets.ViewSet):
)
try:
user, _ = ProvisionalUserService().get_or_create(email, client_id)
user, created = ProvisionalUserService().get_or_create(email, client_id)
except ProvisionalUserCreationDisabledError as not_found_error:
raise drf_exceptions.NotFound("User not found.") from not_found_error
except ProvisionalUserIntegrityError:
@@ -134,13 +134,42 @@ class ApplicationViewSet(viewsets.ViewSet):
},
)
self.audit_actor = user
self.audit_details = {
"scopes": list(application.scopes or []),
"user_provisioned": created,
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
}
return drf_response.Response(
data,
status=drf_status.HTTP_200_OK,
)
def get_audit_fields(self, status_code, error=None):
"""Report the application as the actor once its credentials are verified.
Until then the submitted client id is only a claim: it is kept apart so
that it never names the application or the tenant of the event.
"""
application = self.audit_target
fields = {
**super().get_audit_fields(status_code, error),
"auth_method": "client_credentials",
"actor_type": audit.ActorType.ANONYMOUS,
}
if application:
fields |= {
"actor_type": audit.ActorType.APPLICATION,
"client_id": application.client_id,
}
else:
fields["claimed_client_id"] = self.audit_client_id
return fields
class RoomViewSet(
audit.AuditViewMixin,
mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
@@ -163,6 +192,13 @@ class RoomViewSet(
http_method_names = ["get", "post", "patch", "head", "options"]
audit_actions = {
"list": auditing.ROOM_LIST,
"retrieve": auditing.ROOM_RETRIEVE,
"create": auditing.ROOM_CREATE,
"partial_update": auditing.ROOM_UPDATE,
}
authentication_classes = [
authentication.ApplicationJWTAuthentication,
authentication.AddonsJWTAuthentication,
@@ -191,29 +227,22 @@ class RoomViewSet(
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
self.audit_details = {"total": self.paginator.page.paginator.count}
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
self.audit_details = {"total": len(serializer.data)}
return drf_response.Response(serializer.data)
def _track_room_event(self, room, event, **extra_properties):
"""Log a room operation for auditing and forward it to analytics."""
"""Add a room operation to the audit event and forward it to analytics."""
self.audit_target = room
self.audit_details = extra_properties
auth_method = type(self.request.successful_authenticator).__name__
client_id = (self.request.auth or {}).get("client_id", "unknown")
# Log for auditing
details = "".join(f", {key}={value}" for key, value in extra_properties.items())
logger.info(
"Room %s via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s%s",
event.removeprefix("room_"),
room.id,
self.request.user.id,
client_id,
auth_method,
details,
)
analytics.capture(
self.request.user,
event,
@@ -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
+55 -16
View File
@@ -22,6 +22,46 @@ _RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
def build_encoding_options(resolution, profile=None):
"""Assemble the LiveKit ``EncodingOptions`` kwargs for a resolution/profile.
Single source of truth shared by the default encoding
(``WorkerServiceConfig.from_settings``) and the per-recording encoding
persisted by the start-recording API, so both paths always produce the
same shape.
The profile-independent fields (audio bitrate, keyframe interval and the
pinned codec / frequency constants) are always included.
An omitted profile falls back to RECORDING_ENCODING_DEFAULT_PROFILE.
Framerate and bitrate are left to LiveKit only when the operator
declared no default profile at all.
"""
profile = profile or settings.RECORDING_ENCODING_DEFAULT_PROFILE
options: Dict[str, Any] = {
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": _RECORDING_VIDEO_CODEC,
"audio_codec": _RECORDING_AUDIO_CODEC,
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
}
if resolution:
resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[
resolution
]
options["width"] = resolution_config["width"]
options["height"] = resolution_config["height"]
if resolution and profile:
profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
options["framerate"] = profile_config["fps"]
options["video_bitrate"] = profile_config["kbps"][resolution]
return options
@dataclass(frozen=True)
class WorkerServiceConfig:
"""Declare Worker Service common configurations"""
@@ -38,22 +78,16 @@ class WorkerServiceConfig:
logger.debug("Loading WorkerServiceConfig from settings.")
# The default encoding is resolved from the default profile/resolution and
# applied to every recording that carries no per-recording encoding.
# When either default is missing, we leave this as None so LiveKit falls
# back to its built-in preset.
resolution = settings.RECORDING_ENCODING_DEFAULT_RESOLUTION
profile = settings.RECORDING_ENCODING_DEFAULT_PROFILE
encoding_options: Optional[Dict[str, Any]] = None
if settings.RECORDING_ENCODING_ENABLED:
# Single source of truth for the EncodingOptions kwargs:
# operator-tunable values live in Django settings, codec / frequency
# are pinned constants. The services layer only unpacks this dict.
encoding_options = {
"width": settings.RECORDING_ENCODING_WIDTH,
"height": settings.RECORDING_ENCODING_HEIGHT,
"framerate": settings.RECORDING_ENCODING_FRAMERATE,
"video_bitrate": settings.RECORDING_ENCODING_VIDEO_BITRATE_KBPS,
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": _RECORDING_VIDEO_CODEC,
"audio_codec": _RECORDING_AUDIO_CODEC,
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
}
if resolution and profile:
encoding_options = build_encoding_options(resolution, profile)
return cls(
output_folder=settings.RECORDING_OUTPUT_FOLDER,
@@ -78,7 +112,12 @@ class WorkerService(Protocol):
def __init__(self, config: WorkerServiceConfig):
"""Initialize the service with the given configuration."""
def start(self, room_id: str, recording_id: str) -> str:
def start(
self,
room_id: str,
recording_id: str,
encoding_options: Optional[Dict[str, Any]] = None,
) -> str:
"""Start a recording for a specified room."""
def stop(self, worker_id: str) -> str:
@@ -51,8 +51,11 @@ class WorkerServiceMediator:
raise RecordingStartError()
room_name = str(recording.room.id)
encoding_options = (recording.options.get("encoding") or {}).get("resolved")
try:
worker_id = self._worker_service.start(room_name, recording.id)
worker_id = self._worker_service.start(
room_name, recording.id, encoding_options=encoding_options
)
except (WorkerRequestError, WorkerConnectionError, WorkerResponseError) as e:
logger.exception(
"Failed to start recording for room %s: %s", recording.room.slug, e
+25 -15
View File
@@ -9,7 +9,7 @@ from livekit import api as livekit_api
from ... import utils
from ..enums import FileExtension
from .exceptions import WorkerConnectionError, WorkerResponseError
from .exceptions import WorkerConnectionError, WorkerRequestError, WorkerResponseError
from .factories import WorkerServiceConfig
logger = logging.getLogger(__name__)
@@ -108,28 +108,33 @@ class BaseEgressService:
self._log_egress_error(response, "failed to stop")
return "FAILED_TO_STOP"
def start(self, room_name, recording_id):
def start(self, room_name, recording_id, encoding_options=None):
"""Start the egress process for a recording (not implemented in the base class).
Each derived class must implement this method, providing the necessary parameters for
its specific egress type (e.g. audio_only, streaming output).
"""
raise NotImplementedError("Subclass must implement this method.")
def _build_encoding_options(self):
"""Build a LiveKit EncodingOptions from the service config, or None.
def _resolve_encoding_options(self, encoding_options):
"""Build a LiveKit EncodingOptions from a resolved kwargs dict, or None.
``encoding_options`` is the per-recording dict persisted by the API in
``recording.options["encoding"]["resolved"]``; it falls back to the
default encoding carried by the service config.
When None is returned, the caller should omit the `advanced` field so
LiveKit Egress falls back to its built-in preset (H264_720P_30).
The full EncodingOptions kwargs (operator-tunable values + pinned
codec / frequency constants) are assembled in `WorkerServiceConfig`,
so this method is a thin protobuf adapter.
"""
opts = self._config.encoding_options
if not opts:
encoding_options = encoding_options or self._config.encoding_options
if not encoding_options:
return None
return livekit_api.EncodingOptions(**opts)
try:
return livekit_api.EncodingOptions(**encoding_options)
except (TypeError, ValueError) as e:
# Protobuf raises TypeError on a wrongly typed value (e.g. a float
# framerate) and ValueError on an unknown field or an out-of-range int.
raise WorkerRequestError(f"Invalid encoding options: {e}") from e
class VideoCompositeEgressService(BaseEgressService):
@@ -137,7 +142,7 @@ class VideoCompositeEgressService(BaseEgressService):
hrid = "video-recording-composite-livekit-egress"
def start(self, room_name, recording_id):
def start(self, room_name, recording_id, encoding_options=None):
"""Start the video composite egress process for a recording."""
# Save room's recording as a mp4 video file.
@@ -158,7 +163,7 @@ class VideoCompositeEgressService(BaseEgressService):
"layout": "speaker-light",
}
advanced = self._build_encoding_options()
advanced = self._resolve_encoding_options(encoding_options)
if advanced is not None:
request_kwargs["advanced"] = advanced
@@ -177,8 +182,13 @@ class AudioCompositeEgressService(BaseEgressService):
hrid = "audio-recording-composite-livekit-egress"
def start(self, room_name, recording_id):
"""Start the audio composite egress process for a recording."""
def start(self, room_name, recording_id, encoding_options=None):
"""Start the audio composite egress process for a recording.
``encoding_options`` is accepted for signature compatibility with the
WorkerService protocol but ignored: audio-only egress has no
encoding to configure.
"""
# Save room's recording as an ogg audio file.
file_type = livekit_api.EncodedFileType.OGG
-138
View File
@@ -1,138 +0,0 @@
"""Marketing service in charge of pushing data for marketing automation."""
import logging
from dataclasses import dataclass
from functools import lru_cache
from typing import Dict, List, Optional, Protocol
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
import brevo_python
import urllib3
logger = logging.getLogger(__name__)
class ContactCreationError(Exception):
"""Raised when the contact creation fails."""
@dataclass
class ContactData:
"""Contact data for marketing service integration."""
email: str
attributes: Optional[Dict[str, str]] = None
list_ids: Optional[List[int]] = None
update_enabled: bool = True
class MarketingServiceProtocol(Protocol):
"""Interface for marketing automation service integrations."""
def create_contact(
self, contact_data: ContactData, timeout: Optional[int] = None
) -> dict:
"""Create or update a contact.
Args:
contact_data: Contact information and attributes
timeout: API request timeout in seconds
Returns:
dict: Service response
Raises:
ContactCreationError: If contact creation fails
"""
class BrevoMarketingService:
"""Brevo marketing automation integration.
Handles:
- Contact management and segmentation
- Marketing campaigns and automation
- Email communications
Configuration via Django settings:
- BREVO_API_KEY: API authentication
- BREVO_API_CONTACT_LIST_IDS: Default contact lists
- BREVO_API_CONTACT_ATTRIBUTES: Default contact attributes
"""
def __init__(self):
"""Initialize Brevo (ex-sendinblue) marketing service."""
if not settings.BREVO_API_KEY:
raise ImproperlyConfigured("Brevo API key is required")
configuration = brevo_python.Configuration()
configuration.api_key["api-key"] = settings.BREVO_API_KEY
self._api_client = brevo_python.ApiClient(configuration)
def create_contact(self, contact_data: ContactData, timeout=None) -> dict:
"""Create or update a Brevo contact.
Args:
contact_data: Contact information and attributes
timeout: API request timeout in seconds
Returns:
dict: Brevo API response
Raises:
ContactCreationError: If contact creation fails
ImproperlyConfigured: If required settings are missing
Note:
Contact attributes must be pre-configured in Brevo.
Changes to attributes can impact existing workflows.
"""
if not settings.BREVO_API_CONTACT_LIST_IDS:
raise ImproperlyConfigured(
"Default Brevo List IDs must be configured in settings."
)
contact_api = brevo_python.ContactsApi(self._api_client)
attributes = {
**settings.BREVO_API_CONTACT_ATTRIBUTES,
**(contact_data.attributes or {}),
}
list_ids = (contact_data.list_ids or []) + settings.BREVO_API_CONTACT_LIST_IDS
contact = brevo_python.CreateContact(
email=contact_data.email,
attributes=attributes,
list_ids=list_ids,
update_enabled=contact_data.update_enabled,
)
api_configurations = {}
if timeout is not None:
api_configurations["_request_timeout"] = timeout
try:
response = contact_api.create_contact(contact, **api_configurations)
except (
brevo_python.rest.ApiException,
urllib3.exceptions.ReadTimeoutError,
) as err:
logger.warning("Failed to create contact in Brevo", exc_info=True)
raise ContactCreationError("Failed to create contact in Brevo") from err
return response
@lru_cache(maxsize=1)
def get_marketing_service() -> MarketingServiceProtocol:
"""Return cached instance of configured marketing service."""
marketing_service_cls = import_string(settings.MARKETING_SERVICE_CLASS)
return marketing_service_cls()
@@ -1,14 +1,10 @@
"""Service for provisional user creation."""
import logging
from django.conf import settings
from django.core.exceptions import SuspiciousOperation, ValidationError
from django.db import IntegrityError
from core import models
logger = logging.getLogger(__name__)
from core import audit, auditing, models
class ProvisionalUserError(Exception):
@@ -54,6 +50,20 @@ class ProvisionalUserService:
"Multiple user accounts share a common email."
) from e
def _audit(self, client_id: str, **fields) -> None:
"""Emit a ``user.provision`` event on behalf of the application.
The user is identified by its id since it has no `sub` yet, therefore
the user id is what correlate this event to the ones of its later sign-ins.
"""
audit.log(
auditing.USER_PROVISION,
actor_type=audit.ActorType.APPLICATION,
auth_method="client_credentials",
client_id=client_id,
**fields,
)
def get_or_create(
self, email: str, client_id: str
) -> tuple[models.User | None, bool]:
@@ -86,21 +96,16 @@ class ProvisionalUserService:
user = models.User(sub=None, email=email)
user.set_unusable_password()
user.save()
logger.info(
"Provisional user created via application: user_id=%s, email=%s, client_id=%s",
user.id,
email,
client_id,
)
return user, True
except (IntegrityError, ValidationError) as e:
logger.warning(
"Race condition on provisional user creation, fetching existing: "
"email=%s, client_id=%s",
email,
client_id,
)
# Race condition on provisional user creation
user = self._get_by_email(email)
self._audit(
client_id,
outcome=audit.Outcome.FAILURE,
reason=audit.Reason.CONFLICT,
target=user,
error_type=type(e).__name__,
)
if user:
return user, False
@@ -108,3 +113,6 @@ class ProvisionalUserService:
raise ProvisionalUserIntegrityError(
"Failed to create or retrieve provisional user."
) from e
self._audit(client_id, target=user)
return user, True
+1
View File
@@ -0,0 +1 @@
"""Tests for the audit logging facility."""
+383
View File
@@ -0,0 +1,383 @@
"""Tests for the audit of writes made through the Django admin."""
import json
from unittest import mock
from django.test import override_settings
import pytest
from core import models
from core.audit.testing import find_events
from core.factories import (
FileFactory,
RecordingFactory,
RoomFactory,
UserFactory,
UserResourceAccessFactory,
)
pytestmark = pytest.mark.django_db
# Admin pages render static files: serve them without a manifest.
plain_storages = override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
}
)
@pytest.fixture(name="staff_client")
def staff_client_fixture(client):
"""A client signed in as a superuser, able to reach every admin page."""
client.force_login(UserFactory(is_staff=True, is_superuser=True))
return client
def room_payload(room=None, accesses=0, **overrides):
"""Return what the room change form expects, inline management included."""
payload = {
"name": room.name if room else "Weekly sync",
"slug": room.slug if room else "weekly-sync",
"access_level": room.access_level if room else models.RoomAccessLevel.PUBLIC,
"configuration": "{}",
# ``configuration`` has a callable default, so the form renders a hidden
# ``initial-`` input. Without it the field always looks changed.
"initial-configuration": "{}",
"pin_code": (room.pin_code if room else None) or "",
"accesses-TOTAL_FORMS": str(accesses),
"accesses-INITIAL_FORMS": "0",
"accesses-MIN_NUM_FORMS": "0",
"accesses-MAX_NUM_FORMS": "1000",
}
payload.update(overrides)
return payload
def test_room_creation_is_audited(audit_events, staff_client):
"""Adding a room through the admin records a creation on the room."""
response = staff_client.post("/admin/core/room/add/", room_payload())
assert response.status_code == 302
[event] = find_events(audit_events, "admin.room.create")
assert event["event"]["category"] == ["configuration"]
assert event["event"]["type"] == ["creation"]
assert event["event"]["outcome"] == "success"
assert event["lasuite"]["target"]["type"] == "room"
assert event["lasuite"]["target"]["slug"] == "weekly-sync"
assert event["lasuite"]["details"]["changes"]["name"] == {"to": "Weekly sync"}
def test_room_change_records_field_names_and_allowed_values(audit_events, staff_client):
"""A change reports the raw field names, and the values of allowed fields."""
room = RoomFactory(access_level=models.RoomAccessLevel.PUBLIC)
response = staff_client.post(
f"/admin/core/room/{room.pk}/change/",
room_payload(room, access_level=models.RoomAccessLevel.RESTRICTED),
)
assert response.status_code == 302
[event] = find_events(audit_events, "admin.room.update")
assert event["event"]["type"] == ["change"]
assert event["lasuite"]["details"]["changed_fields"] == ["access_level"]
assert event["lasuite"]["details"]["changes"] == {
"access_level": {"from": "public", "to": "restricted"}
}
def test_room_configuration_change_records_both_versions(audit_events, staff_client):
"""The configuration is allow-listed, and kept as JSON rather than stringified."""
room = RoomFactory(configuration={"a": 1})
response = staff_client.post(
f"/admin/core/room/{room.pk}/change/",
room_payload(
room,
configuration='{"a": 2, "b": "new"}',
**{"initial-configuration": '{"a": 1}'},
),
)
assert response.status_code == 302
[event] = find_events(audit_events, "admin.room.update")
assert event["lasuite"]["details"]["changed_fields"] == ["configuration"]
assert event["lasuite"]["details"]["changes"]["configuration"] == {
"from": {"a": 1},
"to": {"a": 2, "b": "new"},
}
def test_room_deletion_is_audited(audit_events, staff_client):
"""Deleting a room from its own page records a deletion."""
room = RoomFactory()
response = staff_client.post(f"/admin/core/room/{room.pk}/delete/", {"post": "yes"})
assert response.status_code == 302
[event] = find_events(audit_events, "admin.room.delete")
assert event["event"]["type"] == ["deletion"]
assert event["lasuite"]["target"]["id"] == str(room.pk)
def test_inline_access_grant_emits_its_own_iam_event(audit_events, staff_client):
"""A role granted through the inline is an IAM event of its own."""
room = RoomFactory()
user = UserFactory()
response = staff_client.post(
f"/admin/core/room/{room.pk}/change/",
room_payload(
room,
accesses=1,
**{
"accesses-0-user": str(user.pk),
"accesses-0-role": models.RoleChoices.OWNER,
"accesses-0-id": "",
"accesses-0-resource": str(room.pk),
},
),
)
assert response.status_code == 302
[room_event] = find_events(audit_events, "admin.room.update")
[access_event] = find_events(audit_events, "admin.resourceaccess.create")
assert access_event["event"]["category"] == ["iam"]
assert access_event["event"]["type"] == ["creation"]
assert access_event["lasuite"]["target"]["role"] == "owner"
assert access_event["lasuite"]["details"]["changes"]["role"] == {"to": "owner"}
# The grant and the room change belong to the same request.
assert access_event["trace"]["id"] == room_event["trace"]["id"]
def test_user_change_targets_the_user_and_never_leaks_the_password(
audit_events, staff_client
):
"""Promoting a user is an IAM event naming the account, never its secret."""
user = UserFactory(email="promoted@example.com", is_staff=False)
response = staff_client.post(
f"/admin/core/user/{user.pk}/password/",
{
"usable_password": "true",
"password1": "sup3r-s3cret-value",
"password2": "sup3r-s3cret-value",
},
)
assert response.status_code == 302
[event] = find_events(audit_events, "admin.user.update")
assert event["event"]["category"] == ["iam"]
assert event["event"]["type"] == ["user", "change"]
assert event["user"]["target"] == {
"id": str(user.pk),
"sub": user.sub,
"domain": "example.com",
}
assert event["lasuite"]["details"]["changed_fields"] == ["password"]
assert "changes" not in event["lasuite"]["details"]
assert "sup3r-s3cret-value" not in json.dumps(event)
def test_user_permission_change_records_the_flag_values(audit_events, staff_client):
"""Staff and superuser flags are allow-listed, so their values are kept."""
user = UserFactory(is_staff=False, is_superuser=False)
response = staff_client.post(
f"/admin/core/user/{user.pk}/change/",
{
"admin_email": "",
"language": user.language,
"timezone": str(user.timezone),
"is_active": "on",
"is_staff": "on",
"files_created-TOTAL_FORMS": "0",
"files_created-INITIAL_FORMS": "0",
"files_created-MIN_NUM_FORMS": "0",
"files_created-MAX_NUM_FORMS": "1000",
},
)
assert response.status_code == 302
[event] = find_events(audit_events, "admin.user.update")
assert event["lasuite"]["details"]["changes"]["is_staff"] == {
"from": False,
"to": True,
}
assert event["user"]["target"]["id"] == str(user.pk)
def test_bulk_delete_audits_each_object_but_not_the_action(audit_events, staff_client):
"""``delete_selected`` reports its objects, and nothing about itself."""
recordings = RecordingFactory.create_batch(2)
response = staff_client.post(
"/admin/core/recording/",
{
"action": "delete_selected",
"_selected_action": [str(recording.pk) for recording in recordings],
"post": "yes",
},
)
assert response.status_code == 302
events = find_events(audit_events, "admin.recording.delete")
assert {event["lasuite"]["target"]["id"] for event in events} == {
str(recording.pk) for recording in recordings
}
assert find_events(audit_events, "admin.recording.action") == []
def test_custom_action_is_audited(audit_events, staff_client):
"""A custom admin action reports its name and how many objects it ran on."""
recordings = RecordingFactory.create_batch(2)
response = staff_client.post(
"/admin/core/recording/",
{
"action": "mark_as_failed_to_stop",
"_selected_action": [str(recording.pk) for recording in recordings],
},
)
assert response.status_code == 302
[event] = find_events(audit_events, "admin.recording.action")
assert event["event"]["outcome"] == "success"
assert event["lasuite"]["details"] == {
"admin_action": "mark_as_failed_to_stop",
"count": 2,
}
def test_hard_deleted_file_is_audited_once(audit_events, staff_client):
"""``FileAdmin`` hard deletes without going through ``Model.delete``."""
file = FileFactory()
response = staff_client.post(f"/admin/core/file/{file.pk}/delete/", {"post": "yes"})
assert response.status_code == 302
[event] = find_events(audit_events, "admin.file.delete")
assert event["lasuite"]["target"]["id"] == str(file.pk)
def test_failed_deletion_is_audited_as_a_failure(audit_events, staff_client):
"""A deletion that raises is recorded as failed, never as done."""
file = FileFactory()
with (
mock.patch("core.admin.hard_delete_file", side_effect=RuntimeError("S3 down")),
pytest.raises(RuntimeError),
):
staff_client.post(f"/admin/core/file/{file.pk}/delete/", {"post": "yes"})
[event] = find_events(audit_events, "admin.file.delete")
assert event["event"]["type"] == ["deletion"]
assert event["event"]["reason"] == "internal_error"
assert event["lasuite"]["outcome"] == "failure"
assert event["lasuite"]["target"]["id"] == str(file.pk)
assert event["error"] == {"message": "S3 down"}
assert event["log"]["level"] == "error"
def test_failed_bulk_deletion_is_audited_as_a_failure(audit_events, staff_client):
"""A bulk deletion that raises reports every selected object as failed."""
files = FileFactory.create_batch(2)
with (
mock.patch("core.admin.hard_delete_file", side_effect=RuntimeError("S3 down")),
pytest.raises(RuntimeError),
):
staff_client.post(
"/admin/core/file/",
{
"action": "delete_selected",
"_selected_action": [str(file.pk) for file in files],
"post": "yes",
},
)
events = find_events(audit_events, "admin.file.delete")
assert {event["lasuite"]["target"]["id"] for event in events} == {
str(file.pk) for file in files
}
assert {event["lasuite"]["outcome"] for event in events} == {"failure"}
@plain_storages
def test_reading_the_admin_emits_nothing(audit_events, staff_client):
"""Browsing is not audited: only writes are."""
room = RoomFactory()
UserResourceAccessFactory(resource=room, user=UserFactory())
assert staff_client.get("/admin/").status_code == 200
assert staff_client.get("/admin/core/room/").status_code == 200
assert staff_client.get(f"/admin/core/room/{room.pk}/change/").status_code == 200
assert staff_client.get(f"/admin/core/room/{room.pk}/history/").status_code == 200
assert [
event["event"]["action"]
for event in audit_events
if event["event"]["action"].startswith("admin.")
] == []
def test_non_staff_user_reaching_the_admin_is_recorded(audit_events, client):
"""A signed-in account without staff access trying the admin is a denial."""
client.force_login(UserFactory(is_staff=False))
response = client.get("/admin/core/room/")
assert response.status_code == 302
[event] = find_events(audit_events, "admin.access")
assert event["event"]["category"] == ["iam"]
assert event["event"]["reason"] == "permission_denied"
assert event["lasuite"]["outcome"] == "denied"
assert event["lasuite"]["auth"] == {"method": "session"}
assert event["http"]["response"] == {"status_code": 302}
assert event["log"]["level"] == "warning"
@plain_storages
def test_non_staff_user_is_recorded_once_per_refused_view(audit_events, client):
"""Landing on the login page after the refusal records nothing more."""
client.force_login(UserFactory(is_staff=False))
response = client.get("/admin/", follow=True)
assert response.redirect_chain[-1][0].startswith("/admin/login/")
assert response.status_code == 200
assert len(find_events(audit_events, "admin.access")) == 1
def test_anonymous_visitor_is_not_recorded(audit_events, client):
"""An anonymous hit is a redirect to the login page, not a denial worth keeping."""
assert client.get("/admin/").status_code == 302
assert find_events(audit_events, "admin.access") == []
+363
View File
@@ -0,0 +1,363 @@
"""Tests for the audit of DRF views through ``AuditViewMixin``."""
# pylint: disable=missing-function-docstring,unused-argument
from unittest import mock
from django.core.exceptions import PermissionDenied as DjangoPermissionDenied
import pytest
from rest_framework import (
decorators,
exceptions,
mixins,
permissions,
routers,
viewsets,
)
from rest_framework.response import Response
from rest_framework.test import APIRequestFactory
from core import audit, models
from core.audit.testing import find_events
from core.authentication.backends import SessionAuthenticationWith401
from core.factories import RoomFactory
pytestmark = pytest.mark.django_db
class ThingViewSet(audit.AuditViewMixin, viewsets.ViewSet):
"""A viewset auditing ``list`` and ``create`` but not ``destroy``."""
authentication_classes = [SessionAuthenticationWith401]
permission_classes = []
audit_actions = {"list": "thing.list", "create": "thing.create"}
error = None
def list(self, request):
if self.error is not None:
raise self.error
self.audit_details = {"total": 3}
return Response([])
def create(self, request):
return Response({"error": "Already exists."}, status=409)
def destroy(self, request, pk=None):
return Response(status=204)
class RoomViewSet(
audit.AuditViewMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
"""A viewset whose target comes from ``get_object``."""
authentication_classes = []
permission_classes = []
queryset = models.Room.objects.all()
audit_actions = {"retrieve": "room.retrieve"}
def get_serializer(self, *args, **kwargs):
return type("Serializer", (), {"data": {}})()
GRANT = audit.Action(
"thing.grant",
category=audit.EventCategory.IAM,
types=(audit.EventType.CREATION,),
)
class GrantViewSet(audit.AuditViewMixin, viewsets.ViewSet):
"""A viewset whose extra action declares its audit on the route."""
authentication_classes = [SessionAuthenticationWith401]
permission_classes = []
error = None
@decorators.action(detail=False, methods=["post"], audit_action=GRANT)
def grant(self, request):
if self.error is not None:
raise self.error
return Response({})
@decorators.action(detail=False, methods=["post"])
def ping(self, request):
return Response({})
class ListingViewSet(ThingViewSet):
"""A ``ThingViewSet`` keeping the details it was built with."""
def list(self, request):
return Response([])
class DenyObjects(permissions.BasePermission):
"""Refuse every object, whatever the request."""
def has_object_permission(self, request, view, obj):
return False
def test_success_is_audited_with_the_view_details(audit_events):
"""A successful action is recorded with its type and the view's details."""
view = ThingViewSet.as_view({"get": "list"})
response = view(APIRequestFactory().get("/things/", REMOTE_ADDR="1.2.3.4"))
assert response.status_code == 200
[event] = find_events(audit_events, "thing.list")
assert event["event"]["category"] == ["api"]
assert event["event"]["type"] == ["access"]
assert event["event"]["outcome"] == "success"
assert event["lasuite"]["details"] == {"total": 3}
assert event["client"] == {"ip": "1.2.3.4"}
assert event["url"] == {"path": "/things/"}
assert event["http"] == {"request": {"method": "GET"}}
def test_missing_credentials_are_audited_as_authentication_denial(audit_events):
"""A 401 is a denial in the authentication category."""
view = ThingViewSet.as_view({"get": "list"}, error=exceptions.NotAuthenticated())
response = view(APIRequestFactory().get("/things/"))
assert response.status_code == 401
[event] = find_events(audit_events, "thing.list")
assert event["event"]["category"] == ["authentication"]
assert event["event"]["type"] == ["access", "denied"]
assert event["event"]["reason"] == "authentication_failed"
assert event["lasuite"]["outcome"] == "denied"
assert event["lasuite"]["actor"] == {"type": "anonymous"}
assert event["http"]["response"] == {"status_code": 401}
assert event["error"]["message"] == "Authentication credentials were not provided."
assert event["log"]["level"] == "warning"
assert "details" not in event["lasuite"]
@pytest.mark.parametrize(
"error,status_code,outcome,reason",
[
(
exceptions.AuthenticationFailed("bad"),
401,
"denied",
"authentication_failed",
),
(exceptions.PermissionDenied("scope"), 403, "denied", "permission_denied"),
(DjangoPermissionDenied("nope"), 403, "denied", "permission_denied"),
(exceptions.Throttled(wait=10), 429, "denied", "rate_limited"),
(
exceptions.ValidationError({"name": ["x"]}),
400,
"failure",
"validation_error",
),
(exceptions.NotFound(), 404, "failure", "not_found"),
(exceptions.MethodNotAllowed("PUT"), 405, "failure", None),
],
)
def test_errors_are_audited_from_the_status_code(
audit_events, error, status_code, outcome, reason
):
"""The outcome and the reason are derived from the response status."""
view = ThingViewSet.as_view({"get": "list"}, error=error)
response = view(APIRequestFactory().get("/things/"))
assert response.status_code == status_code
[event] = find_events(audit_events, "thing.list")
assert event["lasuite"]["outcome"] == outcome
assert event["event"].get("reason") == reason
assert event["http"]["response"] == {"status_code": status_code}
def test_error_message_of_a_view_response(audit_events):
"""An error response built by the view reports its ``error`` message."""
view = ThingViewSet.as_view({"post": "create"})
response = view(APIRequestFactory().post("/things/"))
assert response.status_code == 409
[event] = find_events(audit_events, "thing.create")
assert event["event"]["type"] == ["creation"]
assert event["event"]["reason"] == "conflict"
assert event["lasuite"]["outcome"] == "failure"
assert event["error"] == {"message": "Already exists."}
def test_actions_missing_from_the_map_are_not_audited(audit_events):
"""Only the actions listed in ``audit_actions`` emit events."""
view = ThingViewSet.as_view({"delete": "destroy"})
response = view(APIRequestFactory().delete("/things/1/"), pk="1")
assert response.status_code == 204
assert audit_events == []
def test_unhandled_exception_is_audited_as_internal_error(audit_events):
"""An exception DRF does not handle is recorded, by class only, then raised."""
view = ThingViewSet.as_view(
{"get": "list"}, error=RuntimeError("user@example.com is broken")
)
with pytest.raises(RuntimeError):
view(APIRequestFactory().get("/things/"))
[event] = find_events(audit_events, "thing.list")
assert event["event"]["type"] == ["access"]
assert event["event"]["reason"] == "internal_error"
assert event["lasuite"]["outcome"] == "failure"
assert event["http"]["response"] == {"status_code": 500}
assert event["error"] == {"type": "builtins.RuntimeError"}
assert event["log"]["level"] == "error"
assert "user@example.com" not in str(event)
def test_object_permission_denial_keeps_the_target(audit_events):
"""A refusal on a detail route names the object that was refused."""
room = RoomFactory()
view = RoomViewSet.as_view({"get": "retrieve"}, permission_classes=[DenyObjects])
response = view(APIRequestFactory().get(f"/rooms/{room.pk}/"), pk=str(room.pk))
assert response.status_code == 403
[event] = find_events(audit_events, "room.retrieve")
assert event["lasuite"]["outcome"] == "denied"
assert event["lasuite"]["target"]["id"] == str(room.pk)
def test_object_of_a_detail_route_is_the_target(audit_events):
"""The object of a detail route becomes the target of the event."""
room = RoomFactory()
view = RoomViewSet.as_view({"get": "retrieve"})
response = view(APIRequestFactory().get(f"/rooms/{room.pk}/"), pk=str(room.pk))
assert response.status_code == 200
[event] = find_events(audit_events, "room.retrieve")
assert event["lasuite"]["target"]["id"] == str(room.pk)
assert event["lasuite"]["target"]["type"] == "room"
def test_extra_actions_cannot_be_mapped_by_method_name():
"""Renaming a method must not silently stop auditing it."""
with pytest.raises(TypeError, match="grant"):
class MappedViewSet(audit.AuditViewMixin, viewsets.ViewSet): # pylint: disable=unused-variable
"""Maps an extra action in ``audit_actions``."""
audit_actions = {"list": "thing.list", "grant": "thing.grant"}
def test_extra_action_is_audited_from_its_route(audit_events):
"""The ``audit_action`` of a routed ``@action`` names the event."""
router = routers.SimpleRouter()
router.register("things", GrantViewSet, basename="thing")
[route] = [url for url in router.urls if url.name == "thing-grant"]
response = route.callback(APIRequestFactory().post("/things/grant/"))
assert response.status_code == 200
[event] = find_events(audit_events, GRANT)
assert event["event"]["category"] == ["iam"]
assert event["event"]["type"] == ["creation"]
def test_extra_action_is_audited_without_a_router(audit_events):
"""A view built by hand reads ``audit_action`` from its handler."""
view = GrantViewSet.as_view({"post": "grant"})
response = view(APIRequestFactory().post("/things/grant/"))
assert response.status_code == 200
assert len(find_events(audit_events, GRANT)) == 1
def test_extra_action_without_audit_action_is_not_audited(audit_events):
"""An ``@action`` that does not name an audit action emits nothing."""
view = GrantViewSet.as_view({"post": "ping"})
response = view(APIRequestFactory().post("/things/ping/"))
assert response.status_code == 200
assert audit_events == []
def test_unauthenticated_extra_action_is_an_authentication_denial(audit_events):
"""A 401 still files the event under ``authentication``, whatever the spec."""
view = GrantViewSet.as_view({"post": "grant"}, error=exceptions.NotAuthenticated())
response = view(APIRequestFactory().post("/things/grant/"))
assert response.status_code == 401
[event] = find_events(audit_events, GRANT)
assert event["event"]["category"] == ["authentication"]
assert event["event"]["type"] == ["creation", "denied"]
@pytest.mark.parametrize("method", ["options", "get"])
def test_requests_reaching_no_extra_action_are_not_audited(audit_events, method):
"""An OPTIONS request, or a method the route refuses, audits nothing.
The router hands the route's ``audit_action`` to every view it builds, the
ones answering those requests included.
"""
router = routers.SimpleRouter()
router.register("things", GrantViewSet, basename="thing")
[route] = [url for url in router.urls if url.name == "thing-grant"]
response = route.callback(getattr(APIRequestFactory(), method)("/things/grant/"))
assert response.status_code == (200 if method == "options" else 405)
assert audit_events == []
def test_details_never_override_event_fields(audit_events):
"""A detail named after an event field is dropped, never raising."""
view = ListingViewSet.as_view(
{"get": "list"},
audit_details={"request": None, "outcome": "failure", "total": 3},
)
response = view(APIRequestFactory().get("/things/"))
assert response.status_code == 200
[event] = find_events(audit_events, "thing.list")
assert event["event"]["outcome"] == "success"
assert event["url"] == {"path": "/things/"}
assert event["lasuite"]["details"] == {"total": 3}
def test_failing_audit_never_fails_the_response(audit_events):
"""An error assembling the event is logged, and the response is kept."""
view = ThingViewSet.as_view({"get": "list"})
with mock.patch.object(
ThingViewSet, "get_audit_fields", side_effect=RuntimeError("boom")
):
response = view(APIRequestFactory().get("/things/"))
assert response.status_code == 200
assert audit_events == []
@@ -0,0 +1,486 @@
"""Tests for building and emitting audit events."""
import json
import logging
import sys
from datetime import datetime
from django.contrib.auth.models import AnonymousUser
from django.test import RequestFactory
import pytest
from dockerflow.logging import request_id_context
from core import audit
from core.audit import request as audit_request
from core.audit.formatter import AuditJsonFormatter
from core.audit.testing import find_events, override_registration
from core.factories import RoomFactory, UserFactory
from core.models import Room
from core.recording.event.authentication import MachineUser
pytestmark = pytest.mark.django_db
def test_audit_log_emits_ecs_document(audit_events):
"""A minimal call produces a complete, pruned ECS document."""
audit.log("room.create", target={"type": "room", "id": "1"}, extra="x")
assert len(audit_events) == 1
event = audit_events[0]
assert event["log_type"] == "audit"
assert event["ecs"] == {"version": "8.11.0"}
assert event["service"] == {"name": "meet", "environment": "test"}
assert event["event"] == {
"kind": "event",
"action": "room.create",
"category": ["web"],
"type": ["info"],
"outcome": "success",
}
assert event["lasuite"] == {
"actor": {"type": "system"},
"auth": {"method": "none"},
"outcome": "success",
"target": {"type": "room", "id": "1"},
"details": {"extra": "x"},
}
assert event["log"]["level"] == "info"
assert "user" not in event
assert "organization" not in event
def test_audit_log_timestamp_is_utc_with_explicit_offset(audit_events):
"""Timestamps are ISO 8601, millisecond precision, UTC with offset."""
audit.log("something")
timestamp = audit_events[0]["@timestamp"]
parsed = datetime.fromisoformat(timestamp)
assert timestamp.endswith("+00:00")
assert parsed.utcoffset().total_seconds() == 0
@pytest.mark.parametrize(
"outcome,reason,expected",
[
("success", None, ("info", "success", ["info"])),
("failure", "validation_error", ("warning", "failure", ["error"])),
("denied", "permission_denied", ("warning", "failure", ["denied"])),
("failure", "internal_error", ("error", "failure", ["error"])),
],
)
def test_audit_log_outcome_reason_and_level(audit_events, outcome, reason, expected):
"""The level is derived from the outcome."""
level, wire_outcome, types = expected
audit.log("something", outcome=outcome, reason=reason)
event = audit_events[0]
assert event["event"]["outcome"] == wire_outcome
assert event["event"].get("reason") == reason
assert event["event"]["type"] == types
assert event["lasuite"]["outcome"] == outcome
assert event["log"]["level"] == level
def test_audit_log_denied_adds_denied_type_to_explicit_types(audit_events):
"""A denial always carries the ``denied`` ECS type."""
audit.log("something", outcome="denied", reason="rate_limited", types=["access"])
event = audit_events[0]
assert event["event"]["type"] == ["access", "denied"]
def test_audit_log_accepts_categories_and_types(audit_events):
"""Category and types are validated against the ECS subset."""
audit.log(
"user.login",
category=audit.EventCategory.AUTHENTICATION,
types=[audit.EventType.START],
)
event = audit_events[0]
assert event["event"]["category"] == ["authentication"]
assert event["event"]["type"] == ["start"]
def test_audit_log_classifies_an_action_by_its_spec(audit_events):
"""An ``Action`` brings its category and types, and names the event."""
action = audit.Action(
"thing.grant",
category=audit.EventCategory.IAM,
types=(audit.EventType.CREATION,),
)
audit.log(action)
event = audit_events[0]
assert event["event"]["action"] == "thing.grant"
assert event["event"]["category"] == ["iam"]
assert event["event"]["type"] == ["creation"]
def test_audit_log_arguments_win_over_the_spec(audit_events):
"""A category or types given to ``log`` override those of the ``Action``."""
action = audit.Action(
"thing.grant",
category=audit.EventCategory.IAM,
types=(audit.EventType.CREATION,),
)
audit.log(
action,
category=audit.EventCategory.CONFIGURATION,
types=[audit.EventType.CHANGE],
)
event = audit_events[0]
assert event["event"]["category"] == ["configuration"]
assert event["event"]["type"] == ["change"]
def test_action_spec_validates_its_classification():
"""A category or type outside the ECS subset fails where it is declared."""
with pytest.raises(ValueError):
audit.Action("thing.grant", category="nonsense")
with pytest.raises(ValueError):
audit.Action("thing.grant", types=("nonsense",))
def test_audit_log_records_the_error_type(audit_events):
"""The class of an error lands in ``error.type``, next to its message."""
audit.log(
"anything",
outcome="failure",
reason="internal_error",
error="boom",
error_type="builtins.RuntimeError",
)
assert audit_events[0]["error"] == {
"message": "boom",
"type": "builtins.RuntimeError",
}
def test_audit_log_fails_open_on_invalid_input(audit_events, caplog):
"""A bad call never raises: it is reported on the application logger."""
with caplog.at_level(logging.ERROR, logger="core.audit.emitter"):
audit.log("something", outcome="maybe")
assert audit_events == []
assert "could not be built" in caplog.text
def test_audit_log_skips_a_target_field_that_cannot_be_read(audit_events, caplog):
"""A broken registration costs the field, not the event."""
room = RoomFactory()
with (
override_registration(Room, fields=("no_such_field", "slug")),
caplog.at_level(logging.ERROR, logger="core.audit.targets"),
):
audit.log("anything", target=room)
[event] = audit_events
assert event["lasuite"]["target"] == {
"type": "room",
"id": str(room.pk),
"slug": room.slug,
}
assert "no_such_field" in caplog.text
def test_audit_log_describes_registered_targets(audit_events):
"""Describe rooms with the fields registered in ``core.auditing``."""
room = RoomFactory(name="Daily standup")
audit.log("room.create", target=room)
assert audit_events[0]["lasuite"]["target"] == {
"type": "room",
"id": str(room.pk),
"slug": room.slug,
"name": "Daily standup",
"access_level": room.access_level,
}
def test_audit_log_normalises_details(audit_events):
"""Nested details are rendered: enums, models as keys, lists, no ``None``."""
room = RoomFactory()
audit.log(
"something",
rooms=[room],
nested={"outcome": audit.Outcome.DENIED},
empty=None,
)
details = audit_events[0]["lasuite"]["details"]
assert details["rooms"] == [str(room.pk)]
assert details["nested"] == {"outcome": "denied"}
assert "empty" not in details
def test_audit_log_reads_request_fields(audit_events):
"""Should read the HTTP fields from the request, the trace id from dockerflow."""
token = request_id_context.set("trace-1")
request = RequestFactory().post(
"/external-api/v1.0/rooms/",
data="{}",
content_type="application/json",
REMOTE_ADDR="1.2.3.4",
)
try:
audit.log("anything", request=request)
finally:
request_id_context.reset(token)
event = audit_events[0]
assert event["http"] == {"request": {"method": "POST"}}
assert event["url"] == {"path": "/external-api/v1.0/rooms/"}
assert event["client"] == {"ip": "1.2.3.4"}
assert event["trace"] == {"id": "trace-1"}
def test_audit_log_defaults_to_the_current_request(audit_events):
"""Should read the request fields from the request being served."""
request = RequestFactory().post("/rooms/", REMOTE_ADDR="1.2.3.4")
request.user = AnonymousUser()
token = audit_request.set_current_request(request)
try:
audit.log("anything")
finally:
audit_request.reset_current_request(token)
event = audit_events[0]
assert event["http"] == {"request": {"method": "POST"}}
assert event["url"] == {"path": "/rooms/"}
assert event["client"] == {"ip": "1.2.3.4"}
assert event["lasuite"]["actor"] == {"type": "anonymous"}
def test_audit_log_explicit_request_wins_over_the_current_one(audit_events):
"""Should prefer the request passed to the one being served."""
token = audit_request.set_current_request(RequestFactory().get("/current/"))
try:
audit.log("anything", request=RequestFactory().get("/explicit/"))
finally:
audit_request.reset_current_request(token)
assert audit_events[0]["url"] == {"path": "/explicit/"}
def test_audit_log_reports_the_client_not_the_proxy(audit_events):
"""Should report the forwarded client address, not the one of the proxy."""
request = RequestFactory().get(
"/", REMOTE_ADDR="1.2.3.4", HTTP_X_FORWARDED_FOR="5.6.7.8"
)
audit.log("something", request=request)
event = audit_events[0]
assert event["client"]["ip"] == "5.6.7.8"
assert event["source"] == {"ip": "5.6.7.8"}
def test_audit_log_actor_user_is_id_sub_and_domain_only(audit_events):
"""A human actor is identified without email or name."""
user = UserFactory(email="john.doe@example.com", full_name="John Doe")
request = RequestFactory().get("/")
request.user = user
audit.log("anything", request=request)
event = audit_events[0]
assert event["user"] == {
"id": str(user.pk),
"sub": user.sub,
"domain": "example.com",
}
assert event["lasuite"]["actor"] == {"type": "user"}
assert event["lasuite"]["auth"] == {"method": "session"}
assert event["organization"] == {"id": "example.com"}
assert "John" not in json.dumps(event)
assert "john.doe" not in json.dumps(event)
def test_audit_log_anonymous_plain_request_has_no_auth_method(audit_events):
"""A plain Django request without a signed-in user is not authenticated."""
request = RequestFactory().get("/")
request.user = AnonymousUser()
audit.log("anything", request=request)
assert audit_events[0]["lasuite"]["auth"] == {"method": "none"}
def test_audit_log_actor_application_with_delegated_user(audit_events):
"""A client id in the token payload makes the actor an application."""
user = UserFactory(email="user@example.com")
request = RequestFactory().get("/")
request.user = user
request.auth = {"client_id": "app-1", "delegated": True}
audit.log("something", request=request)
event = audit_events[0]
assert event["lasuite"]["actor"] == {"type": "application"}
assert event["lasuite"]["application"] == {"client_id": "app-1"}
assert event["user"] == {
"id": str(user.pk),
"sub": user.sub,
"domain": "example.com",
}
assert event["organization"] == {"id": "app-1"}
def test_audit_log_actor_service(audit_events):
"""Machine users are services identified by name."""
request = RequestFactory().get("/")
request.user = MachineUser("roomkit")
audit.log("something", request=request)
event = audit_events[0]
assert event["lasuite"]["actor"] == {"type": "service", "name": "roomkit"}
assert "user" not in event
assert "organization" not in event
def test_audit_log_actor_deleted_user_is_not_a_service(audit_events):
"""A deleted account has no primary key left, yet it is still a user.
A service is named by its username, which for an account is an email address.
"""
user = UserFactory(email="john.doe@example.com", admin_email="admin@example.com")
user.delete()
request = RequestFactory().get("/")
request.user = user
audit.log("anything", request=request)
event = audit_events[0]
assert event["lasuite"]["actor"] == {"type": "user"}
assert event["user"] == {"sub": user.sub, "domain": "example.com"}
assert "admin@example.com" not in json.dumps(event)
def test_audit_log_explicit_overrides(audit_events):
"""Actor, actor type, auth method and client id can be forced."""
user = UserFactory(email="user@example.com")
audit.log(
"something",
actor=user,
actor_type="system",
auth_method="oidc",
client_id="app-2",
)
event = audit_events[0]
assert event["lasuite"]["actor"] == {"type": "system"}
assert event["lasuite"]["auth"] == {"method": "oidc"}
assert event["lasuite"]["application"] == {"client_id": "app-2"}
assert event["user"]["id"] == str(user.pk)
assert event["organization"] == {"id": "app-2"}
def test_audit_log_status_code_error_and_message(audit_events):
"""Response status, error message and free text have their ECS slots."""
audit.log(
"something",
outcome="denied",
reason="permission_denied",
status_code=403,
error="Insufficient permissions.",
message="scope missing",
)
event = audit_events[0]
assert event["http"] == {"response": {"status_code": 403}}
assert event["error"] == {"message": "Insufficient permissions."}
assert event["message"] == "scope missing"
def test_audit_json_formatter_renders_one_line_of_json():
"""The formatter emits compact, single-line, non-ASCII friendly JSON."""
record = logging.makeLogRecord(
{
"name": "audit",
"levelname": "INFO",
"msg": "anything",
"audit": {"event": {"action": "anything"}, "note": "multi\nline wörld"},
}
)
rendered = AuditJsonFormatter().format(record)
assert "\n" not in rendered
assert "wörld" in rendered
assert json.loads(rendered) == {
"event": {"action": "anything"},
"note": "multi\nline wörld",
"log": {"level": "info", "logger": "audit"},
}
def test_audit_json_formatter_wraps_plain_records():
"""A plain record on the audit logger still renders as JSON."""
record = logging.makeLogRecord(
{"name": "audit", "levelname": "WARNING", "msg": "log %s", "args": ("x",)}
)
rendered = json.loads(AuditJsonFormatter().format(record))
assert rendered["log_type"] == "audit"
assert rendered["event"] == {"action": "log x"}
assert rendered["message"] == "log x"
assert rendered["@timestamp"].endswith("+00:00")
def test_audit_json_formatter_adds_stack_trace():
"""An attached traceback lands under ``error.stack_trace``."""
try:
raise ValueError("boom")
except ValueError:
record = logging.makeLogRecord(
{"name": "audit", "levelname": "ERROR", "msg": "x", "audit": {}}
)
record.exc_info = sys.exc_info()
rendered = json.loads(AuditJsonFormatter().format(record))
assert "ValueError: boom" in rendered["error"]["stack_trace"]
def test_find_events_filters_by_action(audit_events):
"""The test helper narrows captured events by action."""
audit.log("first")
audit.log("second")
found = find_events(audit_events, "second")
assert [event["event"]["action"] for event in found] == ["second"]
@@ -0,0 +1,96 @@
"""Tests for the registry of audited models and authentication classes."""
from types import SimpleNamespace
from django.contrib.auth.models import Group
import pytest
from core import audit
from core.audit.actor import auth_method_for, auth_method_for_backend
from core.audit.registry import (
ModelOptions,
auth_methods,
dotted_path,
model_options,
unregister,
)
from core.audit.testing import override_registration
from core.external_api.authentication import ApplicationJWTAuthentication
from core.models import Resource, Room
def test_register_twice_is_refused():
"""A model is registered once, like in the admin."""
audit.register(Resource, fields=("id",))
try:
with pytest.raises(audit.AlreadyRegistered):
audit.register(Resource)
finally:
unregister(Resource)
def test_register_refuses_unknown_options():
"""A misspelled option is an error, not silently ignored."""
with pytest.raises(TypeError):
audit.register(Resource, field=("name",)) # pylint: disable=unexpected-keyword-arg
assert model_options(Resource) == ModelOptions()
def test_register_refuses_unknown_categories():
"""A category outside the ECS subset fails where it is registered."""
with pytest.raises(ValueError):
audit.register(Resource, category="nonsense")
assert model_options(Resource) == ModelOptions()
def test_model_options_falls_back_to_the_concrete_model():
"""A proxy model is described as the model it proxies."""
proxy = type("ProxyRoom", (), {"_meta": SimpleNamespace(concrete_model=Room)})
with override_registration(Room, fields=("slug",)):
assert model_options(proxy).fields == ("slug",)
def test_override_registration_restores_the_previous_one():
"""The test helper puts back what the project registered."""
registered = model_options(Room)
with override_registration(Room, fields=("slug",)):
assert model_options(Room).fields == ("slug",)
assert model_options(Room) == registered
def test_project_declarations_are_discovered():
"""``core.auditing`` is imported when the audit app is ready."""
assert model_options(Room).fields == ("slug", "name", "access_level")
assert model_options(Group).category == audit.EventCategory.IAM
assert auth_methods()[dotted_path(ApplicationJWTAuthentication)] == (
"application_jwt"
)
assert (
auth_method_for_backend(
"core.authentication.backends.OIDCAuthenticationBackend"
)
== "oidc"
)
def test_register_auth_method_twice_is_refused():
"""An authentication class is named once."""
with pytest.raises(audit.AlreadyRegistered):
audit.register_auth_method(ApplicationJWTAuthentication, "other")
def test_auth_method_is_inherited_by_subclasses():
"""A DRF class takes the name of its closest registered base."""
class CustomAuthentication(ApplicationJWTAuthentication):
"""A project subclass nobody registered."""
authenticator = object.__new__(CustomAuthentication)
assert auth_method_for(authenticator) == "application_jwt"
@@ -0,0 +1,224 @@
"""Tests for the network fields and the request id of audit events."""
import uuid
from django.http import HttpResponse
from django.test import RequestFactory
import pytest
from dockerflow.logging import request_id_context
from faker import Faker
from core.api.throttling import CreationCallbackAnonRateThrottle
from core.audit import request as audit_request
fake = Faker()
def _set_num_proxies(settings, count):
"""Trust ``count`` proxies, as DRF's ``NUM_PROXIES`` setting."""
settings.REST_FRAMEWORK = {**settings.REST_FRAMEWORK, "NUM_PROXIES": count}
@pytest.fixture(name="dockerflow_request_id")
def fixture_dockerflow_request_id():
"""Simulate the dockerflow middleware having assigned a request id."""
request_id = fake.uuid4()
token = request_id_context.set(request_id)
try:
yield request_id
finally:
request_id_context.reset(token)
def test_resolve_client_ip_without_forwarded_header():
"""Should use the peer address when no proxy header is present."""
peer_ip = fake.ipv4()
request = RequestFactory().get("/", REMOTE_ADDR=peer_ip)
assert audit_request.resolve_client_ip(request) == peer_ip
def test_resolve_client_ip_prefers_the_client_over_the_proxy():
"""Should return the client the trusted proxy saw, not the proxy address."""
request = RequestFactory().get(
"/", REMOTE_ADDR="1.2.3.4", HTTP_X_FORWARDED_FOR="4.5.6.7, 10.0.0.1"
)
assert audit_request.resolve_client_ip(request) == "10.0.0.1"
def test_resolve_client_ip_skips_trusted_proxies(settings):
"""Should skip the load balancer entry when two proxies are trusted."""
_set_num_proxies(settings, 2)
request = RequestFactory().get(
"/", HTTP_X_FORWARDED_FOR="1.1.1.1, 2.2.2.2, 8.8.8.8"
)
assert audit_request.resolve_client_ip(request) == "2.2.2.2"
def test_resolve_client_ip_clamps_when_fewer_addresses_than_proxies(settings):
"""Should never index out of range on a short chain."""
_set_num_proxies(settings, 5)
request = RequestFactory().get("/", HTTP_X_FORWARDED_FOR="1.2.3.4")
assert audit_request.resolve_client_ip(request) == "1.2.3.4"
def test_resolve_client_ip_ignores_an_empty_forwarded_header():
"""Should fall back to the peer address when the header is blank."""
peer_ip = fake.ipv4()
request = RequestFactory().get("/", REMOTE_ADDR=peer_ip, HTTP_X_FORWARDED_FOR=" , ")
assert audit_request.resolve_client_ip(request) == peer_ip
def test_resolve_client_ip_without_trusted_proxy(settings):
"""Should ignore the header entirely when no proxy is trusted."""
_set_num_proxies(settings, 0)
request = RequestFactory().get(
"/", REMOTE_ADDR="1.2.3.4", HTTP_X_FORWARDED_FOR="4.5.6.7"
)
assert audit_request.resolve_client_ip(request) == "1.2.3.4"
def test_resolve_client_ip_is_the_throttle_identity(settings):
"""Should identify the client exactly as Meet's throttles do."""
_set_num_proxies(settings, 2)
request = RequestFactory().get(
"/", REMOTE_ADDR="1.2.3.4", HTTP_X_FORWARDED_FOR="6.6.6.6, 5.6.7.8, 10.0.0.1"
)
assert audit_request.resolve_client_ip(request) == "5.6.7.8"
assert CreationCallbackAnonRateThrottle().get_ident(request) == "5.6.7.8"
def test_resolve_client_ip_tolerates_bare_requests():
"""Should accept requests built by hand, which have an empty META."""
request = RequestFactory().get("/")
request.META = {}
assert audit_request.resolve_client_ip(request) is None
def test_current_request_id_is_dockerflow_request_id(dockerflow_request_id):
"""Should reuse the dockerflow request id as the trace id."""
assert audit_request.current_request_id() == dockerflow_request_id
def test_current_request_id_outside_a_request():
"""Should have no id when dockerflow did not assign one."""
assert audit_request.current_request_id() is None
def test_middleware_replaces_an_untrusted_request_id(dockerflow_request_id):
"""Should not reuse an inbound id unless the ingress is trusted to set it."""
middleware = audit_request.AuditLogMiddleware(lambda request: HttpResponse())
response = middleware(RequestFactory().get("/"))
request_id = response["X-Request-ID"]
assert request_id != dockerflow_request_id
assert str(uuid.UUID(request_id)) == request_id
assert audit_request.current_request_id() == request_id
def test_middleware_echoes_a_trusted_request_id(settings, dockerflow_request_id):
"""Should keep and echo the inbound id when the ingress is trusted."""
settings.REQUEST_ID_TRUST_HEADER = True
middleware = audit_request.AuditLogMiddleware(lambda request: HttpResponse())
response = middleware(RequestFactory().get("/"))
assert response["X-Request-ID"] == dockerflow_request_id
def test_middleware_echoes_on_the_configured_header(settings, dockerflow_request_id):
"""Should echo the id on the header dockerflow reads it from."""
settings.REQUEST_ID_TRUST_HEADER = True
settings.DOCKERFLOW_REQUEST_ID_HEADER_NAME = "X-Trace-ID"
middleware = audit_request.AuditLogMiddleware(lambda request: HttpResponse())
response = middleware(RequestFactory().get("/"))
assert response["X-Trace-ID"] == dockerflow_request_id
assert not response.has_header("X-Request-ID")
@pytest.mark.usefixtures("dockerflow_request_id")
def test_middleware_keeps_an_existing_response_header():
"""Should leave an X-Request-ID set by the view untouched."""
def view(request): # pylint: disable=unused-argument
response = HttpResponse()
response["X-Request-ID"] = "from-the-view"
return response
response = audit_request.AuditLogMiddleware(view)(RequestFactory().get("/"))
assert response["X-Request-ID"] == "from-the-view"
@pytest.mark.django_db
def test_request_id_from_the_client_is_replaced_by_default(client):
"""Should answer with an id of its own, not the one the client sent."""
response = client.get("/api/v1.0/config/", HTTP_X_REQUEST_ID="abc-123")
assert response.status_code == 200
assert response["X-Request-ID"] != "abc-123"
assert uuid.UUID(response["X-Request-ID"])
@pytest.mark.django_db
def test_request_id_flows_through_the_test_client_when_trusted(client, settings):
"""Should echo the id dockerflow read when the ingress is trusted."""
settings.REQUEST_ID_TRUST_HEADER = True
response = client.get("/api/v1.0/config/", HTTP_X_REQUEST_ID="abc-123")
assert response.status_code == 200
assert response["X-Request-ID"] == "abc-123"
@pytest.mark.django_db
def test_request_id_is_echoed_on_responses_of_outer_middleware(client):
"""Should reach responses that never get to the view, as slash redirects."""
response = client.get("/api/v1.0/config")
assert response.status_code == 301
assert uuid.UUID(response["X-Request-ID"])
def test_current_request_outside_a_request():
"""Should have no current request when none is being served."""
assert audit_request.current_request() is None
def test_middleware_sets_the_current_request_while_serving():
"""Should expose the request to the code serving it, then forget it."""
seen = []
def view(request): # pylint: disable=unused-argument
seen.append(audit_request.current_request())
return HttpResponse()
request = RequestFactory().get("/")
audit_request.AuditLogMiddleware(view)(request)
assert seen == [request]
assert audit_request.current_request() is None
def test_middleware_forgets_the_current_request_when_the_view_raises():
"""Should not leak the request to the next one when the view raises."""
def view(request):
raise RuntimeError("boom")
with pytest.raises(RuntimeError):
audit_request.AuditLogMiddleware(view)(RequestFactory().get("/"))
assert audit_request.current_request() is None
@@ -0,0 +1,160 @@
"""Tests for the audit of Django's authentication signals."""
import json
from django.contrib.auth import authenticate, login
from django.contrib.sessions.middleware import SessionMiddleware
from django.http import HttpResponse
from django.test import RequestFactory
import pytest
from core import audit
from core.audit.testing import find_events
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
def _request_with_session(method="get"):
request = getattr(RequestFactory(), method)("/", REMOTE_ADDR="1.2.3.4")
SessionMiddleware(lambda req: HttpResponse())(request)
return request
def test_login_is_audited(audit_events, client):
"""A login records the user, the mechanism and the backend."""
user = UserFactory(email="user@example.com")
client.force_login(user)
[event] = find_events(audit_events, "user.login")
assert event["event"]["category"] == ["authentication"]
assert event["event"]["type"] == ["start"]
assert event["event"]["outcome"] == "success"
assert event["user"] == {
"id": str(user.pk),
"sub": user.sub,
"domain": "example.com",
}
assert event["lasuite"]["actor"] == {"type": "user"}
assert event["lasuite"]["auth"] == {"method": "password"}
assert event["lasuite"]["details"]["auth_backend"].endswith("ModelBackend")
def test_login_through_oidc_backend_is_named_oidc(audit_events):
"""The OIDC backend is reported as the ``oidc`` auth method."""
user = UserFactory()
user.backend = "core.authentication.backends.OIDCAuthenticationBackend"
request = _request_with_session()
login(request, user)
[event] = find_events(audit_events, "user.login")
assert event["lasuite"]["auth"] == {"method": "oidc"}
assert event["client"] == {"ip": "1.2.3.4"}
assert event["lasuite"]["details"]["auth_backend"] == user.backend
def test_login_given_its_backend_is_named_after_it(audit_events):
"""A backend passed to ``login`` rather than set by ``authenticate`` counts."""
backend = "core.authentication.backends.OIDCAuthenticationBackend"
login(_request_with_session(), UserFactory(), backend=backend)
[event] = find_events(audit_events, "user.login")
assert event["lasuite"]["auth"] == {"method": "oidc"}
assert event["lasuite"]["details"]["auth_backend"] == backend
def test_login_through_an_unlisted_backend_is_unknown(audit_events):
"""A backend missing from the setting is unknown, even a ModelBackend subclass."""
user = UserFactory()
user.backend = "django.contrib.auth.backends.RemoteUserBackend"
login(_request_with_session(), user)
[event] = find_events(audit_events, "user.login")
assert event["lasuite"]["auth"] == {"method": "unknown"}
assert event["lasuite"]["details"]["auth_backend"] == user.backend
def test_failed_login_is_audited_without_credentials(audit_events):
"""A failed login is a warning that never contains the credentials."""
request = _request_with_session("post")
assert authenticate(request=request, username="nobody", password="s3cret") is None
[event] = find_events(audit_events, "user.login")
assert event["event"]["outcome"] == "failure"
assert event["event"]["type"] == ["start", "denied"]
assert event["event"]["reason"] == "authentication_failed"
assert event["lasuite"]["outcome"] == "denied"
assert event["lasuite"]["actor"] == {"type": "anonymous"}
assert event["lasuite"]["auth"] == {"method": "password"}
assert event["log"]["level"] == "warning"
assert "s3cret" not in json.dumps(event)
assert "nobody" not in json.dumps(event)
def test_failed_login_without_request_is_anonymous(audit_events):
"""A failed login is anonymous even when no request is at hand."""
assert authenticate(username="nobody", password="s3cret") is None
[event] = find_events(audit_events, "user.login")
assert event["event"]["outcome"] == "failure"
assert event["lasuite"]["outcome"] == "denied"
assert event["lasuite"]["actor"] == {"type": "anonymous"}
assert event["lasuite"]["auth"] == {"method": "password"}
@pytest.mark.parametrize(
"credentials,method",
[
# What the OIDC callback hands to ``authenticate``.
({"nonce": "n-0nce", "code_verifier": "v3rifier"}, "oidc"),
({"token": "t0ken"}, "unknown"),
],
)
def test_failed_login_is_named_after_its_credentials(audit_events, credentials, method):
"""A failed attempt is named after what it submitted, never recording it."""
request = _request_with_session()
assert authenticate(request=request, **credentials) is None
[event] = find_events(audit_events, "user.login")
assert event["event"]["outcome"] == "failure"
assert event["lasuite"]["outcome"] == "denied"
assert event["lasuite"]["auth"] == {"method": method}
for value in credentials.values():
assert value not in json.dumps(event)
def test_logout_is_audited(audit_events, client):
"""A logout records the user who left."""
user = UserFactory()
client.force_login(user)
client.logout()
[event] = find_events(audit_events, "user.logout")
assert event["event"]["type"] == ["end"]
assert event["user"]["id"] == str(user.pk)
def test_logout_without_a_signed_in_user_is_not_audited(audit_events, client):
"""Django signals a logout without a user when no one was signed in."""
client.logout()
assert find_events(audit_events, "user.logout") == []
def test_connect_auth_signals_is_idempotent(audit_events, client):
"""Connecting twice does not duplicate events."""
audit.connect_auth_signals()
audit.connect_auth_signals()
user = UserFactory()
client.force_login(user)
assert len(find_events(audit_events, "user.login")) == 1
@@ -0,0 +1,116 @@
"""Tests for the description of audit targets."""
from django.utils.functional import SimpleLazyObject
import pytest
from core.audit.registry import ModelOptions, model_options
from core.audit.targets import describe_target
from core.audit.testing import override_registration
from core.factories import RecordingFactory, RoomFactory, UserFactory
from core.models import Recording, Resource, Room
pytestmark = pytest.mark.django_db
def test_describe_target_reads_the_registered_fields():
"""A model is described by its name, its key and its registered fields."""
room = RoomFactory()
with override_registration(Room, fields=("slug", "access_level")):
described = describe_target(room)
assert described == {
"type": "room",
"id": str(room.pk),
"slug": room.slug,
"access_level": room.access_level,
}
def test_describe_target_renders_values():
"""Foreign keys, enums and other values are rendered for JSON."""
recording = RecordingFactory()
with override_registration(Recording, fields=("room_id", "room")):
described = describe_target(recording)
assert described == {
"type": "recording",
"id": str(recording.pk),
"room_id": str(recording.room_id),
"room": str(recording.room_id),
}
def test_describe_target_without_fields():
"""A model registered without fields stays identifiable."""
room = RoomFactory()
with override_registration(Room):
described = describe_target(room)
assert described == {"type": "room", "id": str(room.pk)}
def test_describe_target_identifies_users_without_their_email():
"""A user is identified by its key, OIDC sub and email domain."""
user = UserFactory(email="jane@Example.org", sub="oidc-sub-1")
assert describe_target(user) == {
"type": "user",
"id": str(user.pk),
"sub": "oidc-sub-1",
"domain": "example.org",
}
def test_describe_target_of_a_user_without_sub():
"""A user who never signed in, such as a provisional one, has no sub.
The empty value is pruned when the event is built.
"""
user = UserFactory(email="jane@example.org", sub=None)
assert describe_target(user) == {
"type": "user",
"id": str(user.pk),
"sub": None,
"domain": "example.org",
}
def test_describe_target_sees_through_lazy_objects():
"""A lazy proxy is described as the object it wraps."""
room = RoomFactory()
with override_registration(Room, fields=("slug",)):
described = describe_target(SimpleLazyObject(lambda: room))
assert described == {
"type": "room",
"id": str(room.pk),
"slug": room.slug,
}
def test_describe_target_mapping_passes_through():
"""A ready-made dict is used verbatim."""
assert describe_target({"type": "x", "id": "1"}) == {"type": "x", "id": "1"}
def test_describe_target_of_a_plain_object():
"""Anything else is identified by its class and string form."""
class Thing: # pylint: disable=missing-class-docstring
def __str__(self):
return "thing-1"
assert describe_target(Thing()) == {"type": "thing", "id": "thing-1"}
def test_model_options_by_model():
"""Options are looked up by model, and default to nothing."""
with override_registration(Room, fields=("slug",)):
assert model_options(Room) == ModelOptions(fields=("slug",))
assert model_options(Resource) == ModelOptions()
@@ -0,0 +1,27 @@
"""
Test audit.utils.prune_empty
"""
from core.audit.utils import prune_empty
def test_prune_empty_drops_none_and_empty_mappings():
"""Should drop None and emptied mappings but keep falsy values."""
document = {
"none": None,
"emptied": {"inner": None, "deeper": {"again": None}},
"kept": {"zero": 0, "false": False, "blank": "", "none": None},
"list": [],
}
assert prune_empty(document) == {
"kept": {"zero": 0, "false": False, "blank": ""},
"list": [],
}
def test_prune_empty_leaves_non_mappings_untouched():
"""Should return anything that is not a mapping as it is."""
assert prune_empty([None, {}]) == [None, {}]
assert prune_empty("text") == "text"
assert prune_empty(None) is None
@@ -2,14 +2,14 @@
from unittest import mock
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from django.core.exceptions import SuspiciousOperation
import pytest
from lasuite.marketing.tasks import create_or_update_contact
from core import models
from core.authentication.backends import OIDCAuthenticationBackend
from core.factories import UserFactory
from core.services import marketing
pytestmark = pytest.mark.django_db
@@ -606,8 +606,8 @@ def test_marketing_signup_existing_user(
mock_signup.assert_not_called()
@mock.patch("core.authentication.backends.get_marketing_service")
def test_signup_to_marketing_email_success(mock_marketing):
@mock.patch.object(create_or_update_contact, "delay")
def test_signup_to_marketing_email_success(mock_create_or_update_contact):
"""Test successful marketing signup."""
email = "test@example.com"
@@ -616,46 +616,6 @@ def test_signup_to_marketing_email_success(mock_marketing):
OIDCAuthenticationBackend.signup_to_marketing_email(email)
# Verify service interaction
mock_service = mock_marketing.return_value
mock_service.create_contact.assert_called_once()
@pytest.mark.parametrize(
"error",
[
ImportError,
ImproperlyConfigured,
],
)
@mock.patch("core.authentication.backends.get_marketing_service")
def test_marketing_signup_handles_service_initialization_errors(
mock_marketing, error, settings
):
"""Tests errors that occur when trying to get/initialize the marketing service."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
mock_marketing.side_effect = error
# Should not raise any exception
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
@pytest.mark.parametrize(
"error",
[
marketing.ContactCreationError,
ImproperlyConfigured,
ImportError,
],
)
@mock.patch("core.authentication.backends.get_marketing_service")
def test_marketing_signup_handles_contact_creation_errors(
mock_marketing, error, settings
):
"""Tests errors that occur during the contact creation process."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
mock_marketing.return_value.create_contact.side_effect = error
# Should not raise any exception
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
mock_create_or_update_contact.assert_called_once_with(
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
)
+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):
@@ -0,0 +1,184 @@
"""Tests for the per-recording encoding resolution in BaseEgressService."""
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
from unittest.mock import Mock
from django.conf import settings
from django.test import override_settings
import pytest
from livekit import api as livekit_api
from pydantic import ValidationError as PydanticValidationError
from core.api.serializers import EncodingConfig
from core.recording.worker.exceptions import WorkerRequestError
from core.recording.worker.factories import build_encoding_options
from core.recording.worker.services import VideoCompositeEgressService
def make_config():
"""Build a minimal WorkerServiceConfig-like mock for service instantiation."""
config = Mock()
config.bucket_args = {
"endpoint": "https://s3.test.com",
"access_key": "test_key",
"secret": "test_secret",
"region": "test-region",
"bucket": "test-bucket",
"force_path_style": True,
}
config.encoding_options = None
return config
@pytest.fixture
def service():
"""Return a VideoCompositeEgressService with mocked handle_request."""
svc = VideoCompositeEgressService(make_config())
svc._handle_request = Mock()
return svc
# --- build_encoding_options ---
def test_build_options_without_profile_uses_default_profile():
"""A resolution-only config should fall back to the default profile.
Left unset, framerate and video_bitrate take LiveKit's own EncodingOptions
defaults. The profile-independent fields (audio bitrate, keyframe interval,
codec/frequency pins) are always present, matching the default encoding.
"""
default_profile = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[
settings.RECORDING_ENCODING_DEFAULT_PROFILE
]
resolved = build_encoding_options("540p")
assert resolved == {
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": livekit_api.VideoCodec.H264_MAIN,
"audio_codec": livekit_api.AudioCodec.AAC,
"audio_frequency": 48000,
"width": 960,
"height": 540,
"framerate": default_profile["fps"],
"video_bitrate": default_profile["kbps"]["540p"],
}
@override_settings(RECORDING_ENCODING_DEFAULT_PROFILE="")
def test_build_options_omits_profile_fields_without_default_profile():
"""With no default profile declared, framerate/bitrate are left to LiveKit."""
resolved = build_encoding_options("720p", None)
assert resolved == {
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": livekit_api.VideoCodec.H264_MAIN,
"audio_codec": livekit_api.AudioCodec.AAC,
"audio_frequency": 48000,
"width": 1280,
"height": 720,
}
assert "framerate" not in resolved
assert "video_bitrate" not in resolved
def test_encoding_config_requires_resolution():
"""A profile-only or empty encoding config should be rejected at validation."""
with pytest.raises(PydanticValidationError):
EncodingConfig(profile="mixed")
with pytest.raises(PydanticValidationError):
EncodingConfig()
# --- _resolve_encoding_options ---
@pytest.mark.parametrize("encoding_options", [None, {}])
def test_resolve_options_returns_none_when_empty(service, encoding_options):
"""Resolver should return None when the resolved dict is empty or missing."""
assert service._resolve_encoding_options(encoding_options) is None
@pytest.mark.parametrize(
"encoding_options",
[
{"framerate": 29.97},
{"width": "1280"},
{"unknown_field": 1},
],
)
def test_resolve_options_invalid_raises_worker_request_error(service, encoding_options):
"""Malformed encoding options should surface as a WorkerRequestError."""
with pytest.raises(WorkerRequestError):
service._resolve_encoding_options(encoding_options)
@pytest.mark.parametrize(
"resolution",
list(settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS),
)
@pytest.mark.parametrize(
"profile",
list(settings.RECORDING_ENCODING_AVAILABLE_PROFILES),
)
def test_resolve_profile_resolution_combinations(service, profile, resolution):
"""Every (profile, resolution) pair should resolve to the values from settings."""
resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution]
expected_width = resolution_config["width"]
expected_height = resolution_config["height"]
profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
expected_fps = profile_config["fps"]
expected_bitrate = profile_config["kbps"][resolution]
resolved = build_encoding_options(resolution, profile)
result = service._resolve_encoding_options(resolved)
assert result.width == expected_width
assert result.height == expected_height
assert result.framerate == expected_fps
assert result.video_bitrate == expected_bitrate
# Profile-independent fields match the default encoding, never dropped.
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
assert result.audio_codec == livekit_api.AudioCodec.AAC
assert result.audio_frequency == 48000
def test_resolve_options_none_profile_uses_default_profile(service):
"""A missing profile should resolve to the default profile's fps/bitrate."""
default_profile = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[
settings.RECORDING_ENCODING_DEFAULT_PROFILE
]
resolved = build_encoding_options("720p", None)
result = service._resolve_encoding_options(resolved)
assert result.width == 1280
assert result.height == 720
assert result.framerate == default_profile["fps"]
assert result.video_bitrate == default_profile["kbps"]["720p"]
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
@override_settings(RECORDING_ENCODING_DEFAULT_PROFILE="")
def test_resolve_options_passes_zero_when_no_default_profile(service):
"""With no default profile, fps/bitrate reach LiveKit unset (protobuf 0).
The pinned codec / audio fields are still applied.
"""
resolved = build_encoding_options("720p", None)
result = service._resolve_encoding_options(resolved)
assert result.width == 1280
assert result.height == 720
assert result.framerate == 0
assert result.video_bitrate == 0
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
assert result.audio_codec == livekit_api.AudioCodec.AAC
@@ -40,6 +40,16 @@ def test_settings():
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
"AWS_S3_REGION_NAME": "test-region",
"AWS_STORAGE_BUCKET_NAME": "test-bucket",
"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS": {
"720p": {"width": 1280, "height": 720}
},
"RECORDING_ENCODING_AVAILABLE_PROFILES": {
"full": {"fps": 30, "kbps": {"720p": 3000}}
},
"RECORDING_ENCODING_DEFAULT_RESOLUTION": "720p",
"RECORDING_ENCODING_DEFAULT_PROFILE": "full",
"RECORDING_ENCODING_AUDIO_BITRATE_KBPS": 128,
"RECORDING_ENCODING_KEY_FRAME_INTERVAL_S": 4.0,
}
# Use override_settings to properly patch Django settings
@@ -66,8 +76,18 @@ def test_config_initialization(default_config):
"bucket": "test-bucket",
"force_path_style": True,
}
# Encoding override is opt-in; disabled by default.
assert default_config.encoding_options is None
# The default encoding is always resolved from the default profile/resolution.
assert default_config.encoding_options == {
"width": 1280,
"height": 720,
"framerate": 30,
"video_bitrate": 3000,
"audio_bitrate": 128,
"key_frame_interval": 4.0,
"video_codec": livekit_api_codec.VideoCodec.H264_MAIN,
"audio_codec": livekit_api_codec.AudioCodec.AAC,
"audio_frequency": 48000,
}
def test_config_immutability(default_config):
@@ -76,6 +96,7 @@ def test_config_immutability(default_config):
default_config.output_folder = "new/path"
@pytest.mark.parametrize("custom_encoding_enabled", [True, False])
@override_settings(
RECORDING_OUTPUT_FOLDER="/test/output",
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
@@ -84,23 +105,25 @@ def test_config_immutability(default_config):
AWS_S3_SECRET_ACCESS_KEY="test_secret",
AWS_S3_REGION_NAME="test-region",
AWS_STORAGE_BUCKET_NAME="test-bucket",
RECORDING_ENCODING_ENABLED=True,
RECORDING_ENCODING_WIDTH=1280,
RECORDING_ENCODING_HEIGHT=720,
RECORDING_ENCODING_FRAMERATE=15,
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600,
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"720p": {"width": 1280, "height": 720}},
RECORDING_ENCODING_AVAILABLE_PROFILES={"low": {"fps": 15, "kbps": {"720p": 600}}},
RECORDING_ENCODING_DEFAULT_RESOLUTION="720p",
RECORDING_ENCODING_DEFAULT_PROFILE="low",
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64,
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
)
def test_config_encoding_options_enabled():
"""When RECORDING_ENCODING_ENABLED is True, encoding options are populated.
def test_config_encoding_options_default(custom_encoding_enabled):
"""The default encoding is always resolved from the default profile/resolution.
The dict mixes operator-tunable values from settings with pinned codec /
frequency constants, so the services layer can simply unpack it.
The default fallback resolves the default profile/resolution and mixes those
operator-tunable values with pinned codec / frequency constants. This works
regardless of RECORDING_CUSTOM_ENCODING_ENABLED, which only gates the
per-recording API, so both toggle states produce the same default.
"""
WorkerServiceConfig.from_settings.cache_clear()
config = WorkerServiceConfig.from_settings()
with override_settings(RECORDING_CUSTOM_ENCODING_ENABLED=custom_encoding_enabled):
WorkerServiceConfig.from_settings.cache_clear()
config = WorkerServiceConfig.from_settings()
assert config.encoding_options == {
"width": 1280,
@@ -115,6 +138,27 @@ def test_config_encoding_options_enabled():
}
@pytest.mark.parametrize(
("default_resolution", "default_profile"),
[("", "full"), ("720p", ""), ("", "")],
)
def test_config_encoding_options_none_when_default_missing(
test_settings, default_resolution, default_profile
):
"""A missing default resolution/profile leaves encoding_options None.
The service then omits the `advanced` field so LiveKit uses its built-in preset.
"""
with override_settings(
RECORDING_ENCODING_DEFAULT_RESOLUTION=default_resolution,
RECORDING_ENCODING_DEFAULT_PROFILE=default_profile,
):
WorkerServiceConfig.from_settings.cache_clear()
config = WorkerServiceConfig.from_settings()
assert config.encoding_options is None
@override_settings(
RECORDING_OUTPUT_FOLDER="/test/output",
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
@@ -50,7 +50,7 @@ def test_start_recording_success(mock_update_metadata, mediator, mock_worker_ser
# Verify worker service call
expected_room_name = str(mock_recording.room.id)
mock_worker_service.start.assert_called_once_with(
expected_room_name, mock_recording.id
expected_room_name, mock_recording.id, encoding_options=None
)
# Verify recording updates
@@ -64,6 +64,38 @@ def test_start_recording_success(mock_update_metadata, mediator, mock_worker_ser
)
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_start_recording_passes_resolved_encoding(
mock_update_metadata, mediator, mock_worker_service
):
"""The resolved encoding persisted in recording.options reaches the worker."""
mock_worker_service.start.return_value = "test-worker-123"
resolved = {
"key_frame_interval": 4.0,
"width": 1280,
"height": 720,
"framerate": 15,
"video_bitrate": 700,
}
mock_recording = RecordingFactory(
status=RecordingStatusChoices.INITIATED,
worker_id=None,
options={
"encoding": {
"resolution": "720p",
"profile": "talking_heads",
"resolved": resolved,
}
},
)
mediator.start(mock_recording)
mock_worker_service.start.assert_called_once_with(
str(mock_recording.room.id), mock_recording.id, encoding_options=resolved
)
@pytest.mark.parametrize(
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
)
@@ -2,11 +2,12 @@
Test rooms API endpoints in the Meet core app: start recording.
"""
# pylint: disable=redefined-outer-name,unused-argument
# pylint: disable=redefined-outer-name,unused-argument,no-member
from unittest import mock
import pytest
from livekit import api as livekit_api
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
@@ -470,6 +471,224 @@ def test_start_recording_options_unknown_field_rejected(settings):
assert response.status_code == 400
def test_start_recording_options_encoding_valid(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept a valid encoding configuration."""
settings.RECORDING_ENABLE = True
settings.RECORDING_CUSTOM_ENCODING_ENABLED = 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": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
},
format="json",
)
assert response.status_code == 201
def test_start_recording_options_encoding_rejected_when_custom_encoding_disabled(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Per-recording encoding is rejected when RECORDING_CUSTOM_ENCODING_ENABLED is off."""
settings.RECORDING_ENABLE = True
settings.RECORDING_CUSTOM_ENCODING_ENABLED = False
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": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
},
format="json",
)
assert response.status_code == 400
assert not Recording.objects.filter(room=room).exists()
def test_start_recording_persists_resolved_encoding(
settings, mock_worker_service_factory, mock_worker_manager
):
"""The resolved encoding should be persisted in recording.options alongside
the requested resolution/profile for traceability."""
settings.RECORDING_ENABLE = True
settings.RECORDING_CUSTOM_ENCODING_ENABLED = 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": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options["encoding"] == {
"resolution": "720p",
"profile": "talking_heads",
"resolved": {
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": livekit_api.VideoCodec.H264_MAIN,
"audio_codec": livekit_api.AudioCodec.AAC,
"audio_frequency": 48000,
"width": 1280,
"height": 720,
"framerate": 15,
"video_bitrate": 700,
},
}
def test_start_recording_resolution_only_uses_default_profile(
settings, mock_worker_service_factory, mock_worker_manager
):
"""An encoding without a profile should resolve the default profile."""
settings.RECORDING_ENABLE = True
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
settings.RECORDING_ENCODING_DEFAULT_PROFILE = "talking_heads"
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": {"encoding": {"resolution": "540p"}}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
resolved = recording.options["encoding"]["resolved"]
assert resolved["width"] == 960
assert resolved["height"] == 540
assert resolved["framerate"] == 15
assert resolved["video_bitrate"] == 400
# The requested payload is persisted as sent: no profile was asked for.
assert "profile" not in recording.options["encoding"]
def test_start_recording_forwards_resolved_encoding_to_worker(
settings, mock_worker_service, mock_worker_service_factory
):
"""The resolved encoding should passed on to the worker."""
settings.RECORDING_ENABLE = True
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
mock_worker_service.start.return_value = "egress-123"
with mock.patch("core.services.room_management.RoomManagement.update_metadata"):
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{
"mode": "screen_recording",
"options": {
"encoding": {"resolution": "720p", "profile": "talking_heads"}
},
},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
mock_worker_service.start.assert_called_once_with(
str(room.id),
recording.id,
encoding_options=recording.options["encoding"]["resolved"],
)
def test_start_recording_options_encoding_invalid_resolution(settings):
"""Should reject invalid encoding resolution values."""
settings.RECORDING_ENABLE = True
settings.RECORDING_CUSTOM_ENCODING_ENABLED = 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": {"encoding": {"resolution": "4K"}}},
format="json",
)
assert response.status_code == 400
def test_start_recording_options_encoding_unknown_key_rejected(settings):
"""Should reject unknown keys in encoding configuration."""
settings.RECORDING_ENABLE = True
settings.RECORDING_CUSTOM_ENCODING_ENABLED = 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": {"encoding": {"bitrate": 9000}},
},
format="json",
)
assert response.status_code == 400
def test_start_recording_options_without_encoding_unchanged(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Requests without encoding should keep existing options behavior."""
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": {"language": "fr"}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"language": "fr"}
@pytest.mark.parametrize("value", ["foo", 12])
def test_start_recording_options_invalid_transcribe_type(settings, value):
"""Should reject non-boolean transcribe values."""
@@ -1,212 +0,0 @@
"""
Test marketing services.
"""
# pylint: disable=W0621,W0613
from unittest import mock
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
import brevo_python
import pytest
import urllib3
from core.services.marketing import (
BrevoMarketingService,
ContactCreationError,
ContactData,
get_marketing_service,
)
def test_init_missing_api_key(settings):
"""Test initialization with missing API key."""
settings.BREVO_API_KEY = None
with pytest.raises(ImproperlyConfigured, match="Brevo API key is required"):
BrevoMarketingService()
def test_create_contact_missing_list_ids(settings):
"""Test contact creation with missing list IDs."""
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = None
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
with pytest.raises(
ImproperlyConfigured, match="Default Brevo List IDs must be configured"
):
brevo_service.create_contact(valid_contact_data)
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_success(mock_contact_api):
"""Test successful contact creation."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
mock_api.create_contact.return_value = {"id": "test-id"}
response = brevo_service.create_contact(valid_contact_data)
assert response == {"id": "test-id"}
mock_api.create_contact.assert_called_once()
contact_arg = mock_api.create_contact.call_args[0][0]
assert contact_arg.email == "test@example.com"
assert contact_arg.attributes == {
**settings.BREVO_API_CONTACT_ATTRIBUTES,
**valid_contact_data.attributes,
}
assert set(contact_arg.list_ids) == {1, 2, 3, 4}
assert contact_arg.update_enabled is True
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_with_timeout(mock_contact_api):
"""Test contact creation with timeout."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
brevo_service.create_contact(valid_contact_data, timeout=30)
mock_api.create_contact.assert_called_once()
assert mock_api.create_contact.call_args[1]["_request_timeout"] == 30
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_api_error(mock_contact_api):
"""Test contact creation API error handling."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
mock_api.create_contact.side_effect = brevo_python.rest.ApiException()
with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"):
brevo_service.create_contact(valid_contact_data)
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_timeout_error(mock_contact_api):
"""Test contact creation timeout error handling."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
mock_api.create_contact.side_effect = urllib3.exceptions.ReadTimeoutError(
pool=mock.Mock(),
url="https://api.brevo.com/v3/endpoint",
message="HTTPSConnectionPool(host='api.brevo.com', port=443): Read timed out.",
)
with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"):
brevo_service.create_contact(valid_contact_data)
@pytest.fixture
def clear_marketing_cache():
"""Clear marketing service cache between tests."""
get_marketing_service.cache_clear()
yield
get_marketing_service.cache_clear()
def test_get_marketing_service_caching(clear_marketing_cache):
"""Test marketing service caching behavior."""
settings.BREVO_API_KEY = "test-api-key"
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
service1 = get_marketing_service()
service2 = get_marketing_service()
assert service1 is service2
assert isinstance(service1, BrevoMarketingService)
def test_get_marketing_service_invalid_class(clear_marketing_cache):
"""Test handling of invalid service class."""
settings.MARKETING_SERVICE_CLASS = "invalid.service.path"
with pytest.raises(ImportError):
get_marketing_service()
@mock.patch("core.services.marketing.import_string")
def test_service_instantiation_called_once(mock_import_string, clear_marketing_cache):
"""Test service class is instantiated only once."""
settings.BREVO_API_KEY = "test-api-key"
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
get_marketing_service.cache_clear()
mock_service_cls = mock.Mock()
mock_service_instance = mock.Mock()
mock_service_cls.return_value = mock_service_instance
mock_import_string.return_value = mock_service_cls
service1 = get_marketing_service()
service2 = get_marketing_service()
mock_import_string.assert_called_once_with(settings.MARKETING_SERVICE_CLASS)
mock_service_cls.assert_called_once()
assert service1 is service2
assert service1 is mock_service_instance
@@ -0,0 +1,108 @@
"""Test provisional user service."""
# pylint: disable=W0621,W0613
import json
from unittest import mock
from django.db import IntegrityError
import pytest
from core.audit.testing import find_events
from core.factories import UserFactory
from core.models import User
from core.services.provisional_user_service import (
ProvisionalUserIntegrityError,
ProvisionalUserService,
)
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def allow_provisioning(settings):
"""Enable provisional user creation."""
settings.APPLICATION_ALLOW_USER_CREATION = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
def test_get_or_create_existing_user_is_not_audited(audit_events):
"""Finding an existing user provisions nothing, so it emits no event."""
user = UserFactory(email="john.doe@example.com")
assert ProvisionalUserService().get_or_create(
"John.Doe@example.com", "my-client"
) == (user, False)
assert not find_events(audit_events, "user.provision")
def test_get_or_create_creation_is_audited(audit_events):
"""A created user is identified by its id, without sub nor email."""
user, created = ProvisionalUserService().get_or_create(
"john.doe@example.com", "my-client"
)
assert created is True
assert user.sub is None
[event] = find_events(audit_events, "user.provision")
assert event["log"] == {"level": "info"}
assert event["event"]["outcome"] == "success"
assert event["event"]["type"] == ["user", "creation"]
assert event["lasuite"]["actor"] == {"type": "application"}
assert event["lasuite"]["auth"] == {"method": "client_credentials"}
assert event["lasuite"]["application"] == {"client_id": "my-client"}
assert event["lasuite"]["target"] == {
"type": "user",
"id": str(user.pk),
"domain": "example.com",
}
assert "john.doe" not in json.dumps(event)
@mock.patch.object(ProvisionalUserService, "_get_by_email")
def test_get_or_create_lost_race_is_audited_with_the_existing_user(
mock_get_by_email, audit_events
):
"""A lost race reports the user the concurrent request created."""
existing_user = UserFactory(sub=None, email="john.doe@example.com")
mock_get_by_email.side_effect = [None, existing_user]
assert ProvisionalUserService().get_or_create(
"john.doe@example.com", "my-client"
) == (existing_user, False)
assert User.objects.filter(email="john.doe@example.com").count() == 1
[event] = find_events(audit_events, "user.provision")
assert event["log"] == {"level": "warning"}
assert event["event"]["outcome"] == "failure"
assert event["event"]["reason"] == "conflict"
assert event["error"] == {"type": "ValidationError"}
assert event["lasuite"]["application"] == {"client_id": "my-client"}
assert event["lasuite"]["target"] == {
"type": "user",
"id": str(existing_user.pk),
"domain": "example.com",
}
assert "john.doe" not in json.dumps(event)
@mock.patch.object(User, "save", side_effect=IntegrityError)
@mock.patch.object(ProvisionalUserService, "_get_by_email", return_value=None)
def test_get_or_create_unrecoverable_conflict_is_audited(
mock_get_by_email, mock_save, audit_events
):
"""A conflict without any user to fall back on is audited without target."""
with pytest.raises(ProvisionalUserIntegrityError):
ProvisionalUserService().get_or_create("john.doe@example.com", "my-client")
[event] = find_events(audit_events, "user.provision")
assert event["event"]["outcome"] == "failure"
assert event["event"]["reason"] == "conflict"
assert event["error"] == {"type": "IntegrityError"}
assert "target" not in event["lasuite"]
@@ -17,6 +17,7 @@ from lasuite.oidc_resource_server.authentication import ResourceServerAuthentica
from rest_framework.test import APIClient
from core.analytics import AnalyticsEvent
from core.audit.testing import find_events
from core.factories import ApplicationFactory, RoomFactory, UserFactory
from core.models import (
Application,
@@ -2375,3 +2376,278 @@ def test_api_rooms_addons_disabled_does_not_break_application_auth(settings):
assert response.status_code == 200
assert response.data["count"] == 1
assert response.data["results"][0]["id"] == str(room.id)
def test_api_rooms_create_is_audited(audit_events):
"""Creating a room records the application, the delegated user and the room."""
user = UserFactory(email="jean-neige@winterfell.com")
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
application = Application.objects.get()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post(
"/external-api/v1.0/rooms/", {}, format="json", REMOTE_ADDR="1.2.3.4"
)
assert response.status_code == 201
room = Room.objects.get(id=response.data["id"])
[event] = find_events(audit_events, "room.create")
assert event["event"]["type"] == ["creation"]
assert event["event"]["outcome"] == "success"
assert event["lasuite"]["actor"] == {"type": "application"}
assert event["lasuite"]["auth"] == {"method": "application_jwt"}
assert event["lasuite"]["application"] == {"client_id": str(application.client_id)}
assert event["user"] == {
"id": str(user.pk),
"sub": user.sub,
"domain": "winterfell.com",
}
assert event["organization"] == {"id": str(application.client_id)}
assert event["lasuite"]["target"] == {
"type": "room",
"id": str(room.pk),
"slug": room.slug,
"name": room.name,
"access_level": "trusted",
}
assert event["client"]["ip"] == "1.2.3.4"
assert event["http"]["request"]["method"] == "POST"
assert event["url"]["path"] == "/external-api/v1.0/rooms/"
assert event["trace"]["id"] == response["X-Request-ID"]
assert "jean-neige@winterfell.com" not in str(event)
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_is_audited(mock_update_metadata, audit_events):
"""Updating a room records what changed and the previous access level."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
configuration={},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 200
mock_update_metadata.assert_called_once()
[event] = find_events(audit_events, "room.update")
assert event["event"]["type"] == ["change"]
assert event["lasuite"]["target"]["id"] == str(room.pk)
assert event["lasuite"]["target"]["access_level"] == "restricted"
assert event["lasuite"]["details"] == {
"updated_fields": ["access_level"],
"previous_access_level": "trusted",
}
def test_api_rooms_update_refused_is_audited_with_its_target(audit_events):
"""A refused update names the room it was aimed at."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.MEMBER)])
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 403
[event] = find_events(audit_events, "room.update")
assert event["event"]["reason"] == "permission_denied"
assert event["lasuite"]["outcome"] == "denied"
assert event["lasuite"]["target"]["id"] == str(room.pk)
@mock.patch.object(
RoomManagement, "sync_room_metadata", side_effect=RuntimeError("LiveKit down")
)
def test_api_rooms_update_crashing_is_audited(mock_sync_room_metadata, audit_events):
"""An update saved but not synced to LiveKit is recorded as a failure."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)], access_level=RoomAccessLevel.TRUSTED
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
with pytest.raises(RuntimeError):
client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
mock_sync_room_metadata.assert_called_once()
[event] = find_events(audit_events, "room.update")
assert event["event"]["reason"] == "internal_error"
assert event["lasuite"]["outcome"] == "failure"
assert event["lasuite"]["target"]["id"] == str(room.pk)
assert event["http"]["response"] == {"status_code": 500}
assert event["error"] == {"type": "builtins.RuntimeError"}
def test_api_rooms_list_is_audited(audit_events):
"""Listing records how many rooms were visible to the user."""
user = UserFactory()
RoomFactory(users=[(user, RoleChoices.OWNER)])
RoomFactory(users=[(user, RoleChoices.OWNER)])
RoomFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
[event] = find_events(audit_events, "room.list")
assert event["event"]["type"] == ["access"]
assert event["lasuite"]["details"] == {"total": 2}
assert "target" not in event["lasuite"]
assert event["user"]["id"] == str(user.pk)
def test_api_rooms_retrieve_is_audited(audit_events):
"""Reading a room is recorded as an access to that room."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 200
[event] = find_events(audit_events, "room.retrieve")
assert event["event"]["type"] == ["access"]
assert event["lasuite"]["target"]["id"] == str(room.pk)
assert event["lasuite"]["target"]["slug"] == room.slug
def test_api_rooms_missing_token_is_audited_as_denial(audit_events):
"""An unauthenticated call is recorded under the action it attempted."""
response = APIClient().get("/external-api/v1.0/rooms/", REMOTE_ADDR="1.2.3.4")
assert response.status_code == 401
[event] = find_events(audit_events, "room.list")
assert event["event"]["category"] == ["authentication"]
assert event["event"]["type"] == ["access", "denied"]
assert event["event"]["reason"] == "authentication_failed"
assert event["lasuite"]["outcome"] == "denied"
assert event["lasuite"]["actor"] == {"type": "anonymous"}
assert "details" not in event["lasuite"]
assert event["http"]["response"] == {"status_code": 401}
assert event["client"]["ip"] == "1.2.3.4"
assert event["url"]["path"] == "/external-api/v1.0/rooms/"
def test_api_rooms_missing_scope_is_audited_as_denial(audit_events):
"""A token without the required scope is a permission denial by the application."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
application = Application.objects.get()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
assert response.status_code == 403
[event] = find_events(audit_events, "room.create")
assert event["event"]["type"] == ["creation", "denied"]
assert event["event"]["reason"] == "permission_denied"
assert event["lasuite"]["actor"] == {"type": "application"}
assert event["lasuite"]["auth"] == {"method": "application_jwt"}
assert event["lasuite"]["application"] == {"client_id": str(application.client_id)}
assert event["user"]["id"] == str(user.pk)
assert event["http"]["response"] == {"status_code": 403}
assert "Required scope" in event["error"]["message"]
assert "target" not in event["lasuite"]
def test_api_rooms_addons_token_is_audited_as_user(audit_events):
"""An add-on token has no application: the actor is the user."""
user = UserFactory(email="jean-neige@winterfell.com")
RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
[event] = find_events(audit_events, "room.list")
assert event["lasuite"]["actor"] == {"type": "user"}
assert event["lasuite"]["auth"] == {"method": "addons_jwt"}
assert "application" not in event["lasuite"]
assert event["organization"] == {"id": "winterfell.com"}
@responses.activate
def test_api_rooms_resource_server_is_audited_as_application(audit_events, settings):
"""A La Suite application calling through the resource server acts for the user.
The application is the client the introspected token was issued to.
"""
user = UserFactory(sub="very-specific-sub")
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet lasuite_meet:rooms:list",
"active": True,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
[event] = find_events(audit_events, "room.list")
assert event["lasuite"]["actor"] == {"type": "application"}
assert event["lasuite"]["auth"] == {"method": "resource_server"}
assert event["lasuite"]["application"] == {"client_id": "some_service_provider"}
assert event["user"]["id"] == str(user.pk)
@@ -4,6 +4,7 @@ Tests for external API /token endpoint
# pylint: disable=W0621
import json
from unittest import mock
from urllib.parse import urlencode
@@ -12,6 +13,7 @@ import pytest
from freezegun import freeze_time
from rest_framework.test import APIClient
from core.audit.testing import find_events
from core.factories import (
ApplicationDomainFactory,
ApplicationFactory,
@@ -674,3 +676,225 @@ def test_api_applications_token_new_user_race_condition_unrecoverable(
assert response.status_code == 409
assert mock_get_or_create.call_count == 1
def _application(**kwargs):
"""Create an application whose plain secret is ``test-secret-123``."""
kwargs.setdefault("is_active", True)
application = ApplicationFactory(**kwargs)
application.client_secret = "test-secret-123"
application.save()
return application
def _post_token(client_id, client_secret, scope, **extra):
"""Post a client-credentials token request."""
return APIClient().post(
"/external-api/v1.0/application/token/",
{
"client_id": client_id,
"client_secret": client_secret,
"grant_type": "client_credentials",
"scope": scope,
},
format="json",
**extra,
)
def test_api_applications_generate_token_success_is_audited(audit_events, settings):
"""An issued token records the application, the delegated user and scopes."""
user = UserFactory(email="jean-neige@winterfell.com")
application = _application(scopes=[ApplicationScope.ROOMS_LIST])
response = _post_token(
application.client_id,
"test-secret-123",
"jean-neige@winterfell.com",
REMOTE_ADDR="1.2.3.4",
)
assert response.status_code == 200
[event] = find_events(audit_events, "application.token.issue")
assert event["event"]["category"] == ["authentication"]
assert event["event"]["type"] == ["start"]
assert event["event"]["outcome"] == "success"
assert event["lasuite"]["actor"] == {"type": "application"}
assert event["lasuite"]["auth"] == {"method": "client_credentials"}
assert event["lasuite"]["application"] == {"client_id": application.client_id}
assert event["user"] == {
"id": str(user.pk),
"sub": user.sub,
"domain": "winterfell.com",
}
assert event["organization"] == {"id": application.client_id}
assert event["lasuite"]["target"] == {
"type": "application",
"id": str(application.pk),
"client_id": application.client_id,
"name": application.name,
"is_active": True,
"scopes": ["rooms:list"],
}
assert event["lasuite"]["details"] == {
"scopes": ["rooms:list"],
"user_provisioned": False,
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
}
assert event["client"]["ip"] == "1.2.3.4"
assert event["url"]["path"] == "/external-api/v1.0/application/token/"
assert event["trace"]["id"] == response["X-Request-ID"]
assert "jean-neige@winterfell.com" not in json.dumps(event)
def test_api_applications_generate_token_wrong_secret_is_audited(audit_events):
"""A wrong secret is a denial: the submitted client id is only a claim."""
UserFactory(email="jean-neige@winterfell.com")
application = _application()
response = _post_token(application.client_id, "wrong-secret", "user@example.com")
assert response.status_code == 401
[event] = find_events(audit_events, "application.token.issue")
assert event["event"]["category"] == ["authentication"]
assert event["event"]["type"] == ["start", "denied"]
assert event["event"]["reason"] == "authentication_failed"
assert event["lasuite"]["outcome"] == "denied"
assert event["lasuite"]["actor"] == {"type": "anonymous"}
assert event["lasuite"]["auth"] == {"method": "client_credentials"}
assert "application" not in event["lasuite"]
assert "organization" not in event
assert event["lasuite"]["details"] == {
"requested_domain": "example.com",
"claimed_client_id": application.client_id,
}
assert "target" not in event["lasuite"]
assert event["http"]["response"] == {"status_code": 401}
assert event["error"] == {"message": "Invalid credentials"}
assert event["log"]["level"] == "warning"
assert "jean-neige@winterfell.com" not in json.dumps(event)
def test_api_applications_generate_token_unknown_client_is_audited(audit_events):
"""An unknown client id is still recorded, so brute force is visible."""
response = _post_token("does-not-exist", "whatever", "jean-neige@winterfell.com")
assert response.status_code == 401
[event] = find_events(audit_events, "application.token.issue")
assert event["event"]["reason"] == "authentication_failed"
assert event["lasuite"]["details"]["claimed_client_id"] == "does-not-exist"
assert "application" not in event["lasuite"]
assert "organization" not in event
def test_api_applications_generate_token_inactive_application_is_audited(
audit_events,
):
"""A disabled application is refused with an explicit message."""
UserFactory(email="jean-neige@winterfell.com")
application = _application(is_active=False)
response = _post_token(
application.client_id, "test-secret-123", "jean-neige@winterfell.com"
)
assert response.status_code == 401
[event] = find_events(audit_events, "application.token.issue")
assert event["event"]["reason"] == "authentication_failed"
assert event["error"] == {"message": "Application is inactive"}
def test_api_applications_generate_token_domain_denied_is_audited(audit_events):
"""Delegating outside the allowed domains is a permission denial."""
UserFactory(email="user@random.com")
application = _application()
ApplicationDomainFactory(application=application, domain="allowed.com")
response = _post_token(application.client_id, "test-secret-123", "user@random.com")
assert response.status_code == 403
[event] = find_events(audit_events, "application.token.issue")
assert event["event"]["reason"] == "permission_denied"
assert event["lasuite"]["actor"] == {"type": "application"}
assert event["lasuite"]["target"]["id"] == str(application.pk)
assert event["lasuite"]["details"] == {"requested_domain": "random.com"}
assert event["http"]["response"] == {"status_code": 403}
def test_api_applications_generate_token_invalid_email_is_audited(audit_events):
"""An invalid scope is a validation failure by an authenticated application."""
application = _application()
response = _post_token(application.client_id, "test-secret-123", "not-an-email")
assert response.status_code == 400
[event] = find_events(audit_events, "application.token.issue")
assert event["event"]["reason"] == "validation_error"
assert event["lasuite"]["actor"] == {"type": "application"}
assert event["http"]["response"] == {"status_code": 400}
assert "details" not in event["lasuite"]
def test_api_applications_generate_token_unknown_user_is_audited(audit_events):
"""An unknown user with provisioning disabled is a not-found denial."""
application = _application()
response = _post_token(
application.client_id, "test-secret-123", "nobody@example.com"
)
assert response.status_code == 404
[event] = find_events(audit_events, "application.token.issue")
assert event["event"]["reason"] == "not_found"
assert event["lasuite"]["details"] == {"requested_domain": "example.com"}
assert event["http"]["response"] == {"status_code": 404}
def test_api_applications_generate_token_provisioning_is_audited(
audit_events, settings
):
"""Provisioning a user is its own event, correlated with the token issue."""
settings.APPLICATION_ALLOW_USER_CREATION = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
application = _application(scopes=[ApplicationScope.ROOMS_LIST])
response = _post_token(
application.client_id, "test-secret-123", "new.user@example.com"
)
assert response.status_code == 200
user = User.objects.get(email="new.user@example.com")
[provision] = find_events(audit_events, "user.provision")
assert provision["event"]["category"] == ["iam"]
assert provision["event"]["type"] == ["user", "creation"]
assert provision["lasuite"]["actor"] == {"type": "application"}
# Same mechanism as the token issue it belongs to
assert provision["lasuite"]["auth"] == {"method": "client_credentials"}
assert provision["lasuite"]["application"] == {"client_id": application.client_id}
assert provision["lasuite"]["target"] == {
"type": "user",
"id": str(user.pk),
"domain": "example.com",
}
assert provision["trace"]["id"] == response["X-Request-ID"]
assert provision["url"]["path"] == "/external-api/v1.0/application/token/"
assert "new.user@example.com" not in json.dumps(provision)
[issue] = find_events(audit_events, "application.token.issue")
assert issue["lasuite"]["details"]["user_provisioned"] is True
assert issue["user"]["id"] == str(user.pk)
+252 -39
View File
@@ -23,6 +23,8 @@ import dj_database_url
import sentry_sdk
from configurations import Configuration, values
from lasuite.configuration.values import SecretFileValue
from pydantic import BaseModel, PositiveInt, TypeAdapter
from pydantic import ValidationError as PydanticValidationError
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.logging import ignore_logger
@@ -65,6 +67,27 @@ class VideoCodecValue(values.Value):
return codec
class ResolutionSpec(BaseModel):
"""An value of RECORDING_ENCODING_AVAILABLE_RESOLUTIONS."""
width: PositiveInt
height: PositiveInt
class ProfileSpec(BaseModel):
"""An value of RECORDING_ENCODING_AVAILABLE_PROFILES.
`kbps` maps each resolution key to its video bitrate.
"""
fps: PositiveInt
kbps: dict[str, PositiveInt]
RESOLUTION_MAP_ADAPTER = TypeAdapter(dict[str, ResolutionSpec])
PROFILE_MAP_ADAPTER = TypeAdapter(dict[str, ProfileSpec])
class Base(Configuration):
"""
This is the base configuration every configuration (aka environment) should inherit from. It
@@ -288,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",
@@ -308,6 +332,7 @@ class Base(Configuration):
INSTALLED_APPS = [
# Meet
"core",
"core.audit.apps.AuditConfig",
"demo",
"drf_spectacular",
# Third party apps
@@ -317,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",
@@ -360,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",
@@ -443,6 +476,11 @@ class Base(Configuration):
"documentation_url": values.Value(
None, environ_name="FRONTEND_DOCUMENTATION_URL", environ_prefix=None
),
"technical_documentation_url": values.Value(
None,
environ_name="FRONTEND_TECHNICAL_DOCUMENTATION_URL",
environ_prefix=None,
),
"external_home_url": values.Value(
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
),
@@ -785,35 +823,81 @@ class Base(Configuration):
# These settings affect screen recordings handled by VideoCompositeEgressService;
# they are silently ignored by AudioCompositeEgressService (audio-only transcript
# recordings), whose request never carries advanced EncodingOptions.
# When disabled, LiveKit falls back to its built-in H264_720P_30 preset
# (1280x720, 30 fps, 3000 kbps H.264 MAIN video, 128 kbps AAC audio).
# When enabled, the values below are passed to LiveKit as EncodingOptions
# (advanced) and replace the preset. Lowering framerate and bitrate reduces
# output file size and CPU load on the egress worker.
RECORDING_ENCODING_ENABLED = values.BooleanValue(
False, environ_name="RECORDING_ENCODING_ENABLED", environ_prefix=None
#
# A default encoding is applied to every recording: it is resolved from the default
# profile and resolution below and passed to LiveKit as EncodingOptions (advanced),
# replacing LiveKit's built-in H264_720P_30 preset. Lowering framerate and bitrate
# reduces output file size and CPU load on the egress worker. If either
# RECORDING_ENCODING_DEFAULT_RESOLUTION or RECORDING_ENCODING_DEFAULT_PROFILE is
# unset, no default encoding is built (a startup warning is emitted) and LiveKit's
# built-in preset is used instead.
#
# RECORDING_CUSTOM_ENCODING_ENABLED gates whether the start-recording API lets a
# client override that default per recording (via an `encoding` object selecting a
# resolution/profile). When False, the API rejects per-recording `encoding` and
# every recording uses the default; when True, clients may pick from the
# available resolutions/profiles below.
RECORDING_CUSTOM_ENCODING_ENABLED = values.BooleanValue(
False, environ_name="RECORDING_CUSTOM_ENCODING_ENABLED", environ_prefix=None
)
RECORDING_ENCODING_WIDTH = values.PositiveIntegerValue(
1280, environ_name="RECORDING_ENCODING_WIDTH", environ_prefix=None
)
RECORDING_ENCODING_HEIGHT = values.PositiveIntegerValue(
720, environ_name="RECORDING_ENCODING_HEIGHT", environ_prefix=None
)
RECORDING_ENCODING_FRAMERATE = values.PositiveIntegerValue(
30, environ_name="RECORDING_ENCODING_FRAMERATE", environ_prefix=None
)
RECORDING_ENCODING_VIDEO_BITRATE_KBPS = values.PositiveIntegerValue(
3000,
environ_name="RECORDING_ENCODING_VIDEO_BITRATE_KBPS",
# Map resolution string -> {"width", "height"} in pixels.
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS = values.DictValue(
{
"540p": {"width": 960, "height": 540},
"720p": {"width": 1280, "height": 720},
"1080p": {"width": 1920, "height": 1080},
},
environ_name="RECORDING_ENCODING_AVAILABLE_RESOLUTIONS",
environ_prefix=None,
)
# Map profile string -> {"fps", "kbps": {resolution: video_bitrate_kbps}}.
# Bitrate scales with resolution so quality stays consistent across sizes.
RECORDING_ENCODING_AVAILABLE_PROFILES = values.DictValue(
{
"talking_heads": {
"fps": 15,
"kbps": {"540p": 400, "720p": 700, "1080p": 1200},
},
"text": {
"fps": 15,
"kbps": {"540p": 600, "720p": 1000, "1080p": 1800},
},
"mixed": {
"fps": 20,
"kbps": {"540p": 900, "720p": 1500, "1080p": 2500},
},
"full": {
"fps": 30,
"kbps": {"540p": 2000, "720p": 3000, "1080p": 4500},
},
},
environ_name="RECORDING_ENCODING_AVAILABLE_PROFILES",
environ_prefix=None,
)
# Defaults used when no profile/resolution is specified per recording.
# Must be keys of the two dicts above (validated at startup).
RECORDING_ENCODING_DEFAULT_PROFILE = values.Value(
"full",
environ_name="RECORDING_ENCODING_DEFAULT_PROFILE",
environ_prefix=None,
)
RECORDING_ENCODING_DEFAULT_RESOLUTION = values.Value(
"720p",
environ_name="RECORDING_ENCODING_DEFAULT_RESOLUTION",
environ_prefix=None,
)
# Settings independent of profile/resolution.
RECORDING_ENCODING_AUDIO_BITRATE_KBPS = values.PositiveIntegerValue(
128,
environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS",
environ_prefix=None,
)
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S = values.FloatValue(
4.0,
0.0,
environ_name="RECORDING_ENCODING_KEY_FRAME_INTERVAL_S",
environ_prefix=None,
)
@@ -821,6 +905,7 @@ class Base(Configuration):
SUMMARY_SERVICE_VERSION = values.PositiveIntegerValue(
1, environ_name="SUMMARY_SERVICE_VERSION", environ_prefix=None
)
SUMMARY_SERVICE_ENDPOINT = values.Value(
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
)
@@ -863,24 +948,18 @@ class Base(Configuration):
environ_name="SIGNUP_NEW_USER_TO_MARKETING_EMAIL",
environ_prefix=None,
)
MARKETING_SERVICE_CLASS = values.Value(
"core.services.marketing.BrevoMarketingService",
environ_name="MARKETING_SERVICE_CLASS",
environ_prefix=None,
)
BREVO_API_KEY = SecretFileValue(
None, environ_name="BREVO_API_KEY", environ_prefix=None
)
BREVO_API_CONTACT_LIST_IDS = values.ListValue(
[],
environ_name="BREVO_API_CONTACT_LIST_IDS",
environ_prefix=None,
converter=int,
)
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
BREVO_API_TIMEOUT = values.PositiveIntegerValue(
1, environ_name="BREVO_API_TIMEOUT", environ_prefix=None
)
LASUITE_MARKETING = {
"BACKEND": values.Value(
"lasuite.marketing.backends.dummy.DummyBackend",
environ_name="LASUITE_MARKETING_BACKEND",
environ_prefix=None,
),
"PARAMETERS": values.DictValue(
default={},
environ_name="LASUITE_MARKETING_PARAMETERS",
environ_prefix=None,
),
}
# Lobby configurations
PRESENCE_KEY_PREFIX = values.Value(
@@ -1141,6 +1220,28 @@ class Base(Configuration):
environ_prefix=None,
)
AUDIT_LOG_LEVEL = values.Value(
"INFO", environ_name="AUDIT_LOG_LEVEL", environ_prefix=None
)
AUDIT_LOG_STREAM = values.Value(
"ext://sys.stdout", environ_name="AUDIT_LOG_STREAM", environ_prefix=None
)
AUDIT_LOG_SERVICE_NAME = values.Value(
"meet", environ_name="AUDIT_LOG_SERVICE_NAME", environ_prefix=None
)
# Reuse the inbound request id as the trace id
# Only enable it when the ingress overwrites the header
# When off, the backend generates the id.
REQUEST_ID_TRUST_HEADER = values.BooleanValue(
False, environ_name="REQUEST_ID_TRUST_HEADER", environ_prefix=None
)
DOCKERFLOW_REQUEST_ID_HEADER_NAME = values.Value(
"X-Request-ID",
environ_name="DOCKERFLOW_REQUEST_ID_HEADER_NAME",
environ_prefix=None,
)
LOGGING_SILENCED_401_PATHS = values.ListValue(
default=["/api/v1.0/users/me/"],
environ_name="LOGGING_SILENCED_401_PATHS",
@@ -1158,6 +1259,9 @@ class Base(Configuration):
"format": "{asctime} {name} {levelname} {message}",
"style": "{",
},
"audit_json": {
"()": "core.audit.formatter.AuditJsonFormatter",
},
},
"filters": {
"silence_expected_401": {
@@ -1170,6 +1274,11 @@ class Base(Configuration):
"formatter": "simple",
"filters": ["silence_expected_401"],
},
"audit_console": {
"class": "logging.StreamHandler",
"stream": AUDIT_LOG_STREAM,
"formatter": "audit_json",
},
},
# Override root logger to send it to console
"root": {
@@ -1202,6 +1311,11 @@ class Base(Configuration):
),
"propagate": False,
},
"audit": {
"handlers": ["audit_console"],
"level": AUDIT_LOG_LEVEL,
"propagate": False,
},
},
}
@@ -1235,6 +1349,86 @@ class Base(Configuration):
},
}
@classmethod
def _check_recording_encoding_maps(cls):
"""Ensure the per-recording encoding maps are well-formed and consistent.
Each entry of RECORDING_ENCODING_AVAILABLE_RESOLUTIONS must declare a width and
a height, each entry of RECORDING_ENCODING_AVAILABLE_PROFILES an fps and a kbps
map, and every profile must define a bitrate for each declared resolution.
The default profile / resolution feed the default encoding. When either is
missing, no custom default encoding can be built: a warning is emitted and
recordings fall back to LiveKit's built-in preset. When both are set, they
must reference keys that actually exist in the maps above.
"""
resolutions = set(cls.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
profiles = set(cls.RECORDING_ENCODING_AVAILABLE_PROFILES)
for name, adapter in (
("RECORDING_ENCODING_AVAILABLE_RESOLUTIONS", RESOLUTION_MAP_ADAPTER),
("RECORDING_ENCODING_AVAILABLE_PROFILES", PROFILE_MAP_ADAPTER),
):
try:
adapter.validate_python(getattr(cls, name), strict=True)
except PydanticValidationError as exc:
raise ValueError(f"{name} is malformed: {exc}") from exc
for (
profile,
profile_config,
) in cls.RECORDING_ENCODING_AVAILABLE_PROFILES.items(): # pylint: disable=no-member
profile_resolutions = set(profile_config["kbps"])
if profile_resolutions != resolutions:
raise ValueError(
f"Profile '{profile}' in RECORDING_ENCODING_AVAILABLE_PROFILES must "
"define a bitrate for exactly the resolutions in "
"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS, mismatch on: "
f"{resolutions ^ profile_resolutions}"
)
# Check that default resolutions and profiles are actually defined
if (
cls.RECORDING_ENCODING_DEFAULT_RESOLUTION
and cls.RECORDING_ENCODING_DEFAULT_RESOLUTION not in resolutions
):
raise ValueError(
"RECORDING_ENCODING_DEFAULT_RESOLUTION "
f"'{cls.RECORDING_ENCODING_DEFAULT_RESOLUTION}' is not a key of "
f"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS ({sorted(resolutions)})."
)
if (
cls.RECORDING_ENCODING_DEFAULT_PROFILE
and cls.RECORDING_ENCODING_DEFAULT_PROFILE not in profiles
):
raise ValueError(
"RECORDING_ENCODING_DEFAULT_PROFILE "
f"'{cls.RECORDING_ENCODING_DEFAULT_PROFILE}' is not a key of "
f"RECORDING_ENCODING_AVAILABLE_PROFILES ({sorted(profiles)})."
)
missing = [
name
for name, value in (
(
"RECORDING_ENCODING_DEFAULT_RESOLUTION",
cls.RECORDING_ENCODING_DEFAULT_RESOLUTION,
),
(
"RECORDING_ENCODING_DEFAULT_PROFILE",
cls.RECORDING_ENCODING_DEFAULT_PROFILE,
),
)
if not value
]
if missing:
warnings.warn(
f"{' and '.join(missing)} not set; recordings will use LiveKit's "
"built-in encoding preset instead of a custom default encoding.",
UserWarning,
stacklevel=2,
)
@classmethod
def post_setup(cls):
"""Post setup configuration.
@@ -1248,6 +1442,8 @@ class Base(Configuration):
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
)
cls._check_recording_encoding_maps()
if (
cls.SUMMARY_SERVICE_VERSION == 1
and cls.SUMMARY_SERVICE_ENDPOINT is not None
@@ -1300,6 +1496,8 @@ class Base(Configuration):
# Ignore the logs added by the DockerflowMiddleware
ignore_logger("request.summary")
# Audit events are a data stream, not errors to report
ignore_logger("audit")
class Build(Base):
@@ -1351,16 +1549,31 @@ class Test(Base):
{
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"audit_json": {
"()": "core.audit.formatter.AuditJsonFormatter",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
"audit_console": {
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
"formatter": "audit_json",
},
},
"loggers": {
"meet": {
"handlers": ["console"],
"level": "DEBUG",
},
"audit": {
"handlers": ["audit_console"],
"level": "INFO",
"propagate": False,
},
},
}
)
+4
View File
@@ -39,6 +39,9 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
ARG VITE_APP_TITLE
ENV VITE_APP_TITLE=${VITE_APP_TITLE}
ARG VITE_MEDIA_BASE_URL
ENV VITE_MEDIA_BASE_URL=${VITE_MEDIA_BASE_URL}
RUN npm run build
# ---- Front-end image ----
@@ -46,6 +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' && \
apk del curl
USER nginx
+311 -36
View File
@@ -31,11 +31,11 @@
"livekit-client": "2.21.0",
"posthog-js": "1.418.10",
"react": "18.3.1",
"react-aria": "3.50.0",
"react-aria-components": "1.19.0",
"react-aria": "3.51.0",
"react-aria-components": "1.20.0",
"react-dom": "18.3.1",
"react-i18next": "17.0.12",
"react-stately": "3.48.0",
"react-stately": "3.49.0",
"use-sound": "5.0.0",
"valtio": "2.3.2",
"wouter": "3.10.0"
@@ -64,7 +64,8 @@
"typescript-eslint": "8.60.1",
"vite": "8.0.14",
"vite-plugin-static-copy": "4.1.1",
"vite-plugin-svgr": "5.2.0"
"vite-plugin-svgr": "5.2.0",
"vitest": "5.0.2"
}
},
"node_modules/@adobe/react-spectrum": {
@@ -883,9 +884,9 @@
}
},
"node_modules/@internationalized/date": {
"version": "3.12.2",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz",
"integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==",
"version": "3.12.3",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.3.tgz",
"integrity": "sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
@@ -901,9 +902,9 @@
}
},
"node_modules/@internationalized/string": {
"version": "3.2.9",
"resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.9.tgz",
"integrity": "sha512-kzP/M/mbQxODlmOt4bIQZ2SBVUWUSqMLXooXixnX7noche8WHaQcA+nwFN1K2KCF/cp+LDUhcJsCicwkvhD1pg==",
"version": "3.2.10",
"resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.10.tgz",
"integrity": "sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
@@ -942,9 +943,9 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true,
"license": "MIT"
},
@@ -1819,9 +1820,9 @@
}
},
"node_modules/@react-types/shared": {
"version": "3.36.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.0.tgz",
"integrity": "sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ==",
"version": "3.36.1",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.1.tgz",
"integrity": "sha512-AzsuD9OfxTOZMMvTRhlN3oHBwOmFN7tDh27LzqmHt4+uOgPhJT7ZM7/kVs/8/o0WxayMUIk3hBmCFRHv1FUoag==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
@@ -2506,6 +2507,24 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/dom-mediacapture-record": {
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
@@ -2786,6 +2805,64 @@
}
}
},
"node_modules/@vitest/mocker": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz",
"integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.31",
"@vitest/spy": "5.0.2",
"estree-walker": "^3.0.3",
"magic-string": "^1.2.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/mocker/node_modules/magic-string": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/@vitest/spy": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz",
"integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz",
@@ -3151,6 +3228,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -4130,6 +4217,16 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -5030,6 +5127,13 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -5532,6 +5636,16 @@
"node": ">=18.0.0"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
@@ -8647,6 +8761,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/obug": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -9384,19 +9512,19 @@
}
},
"node_modules/react-aria": {
"version": "3.50.0",
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.50.0.tgz",
"integrity": "sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==",
"version": "3.51.0",
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.51.0.tgz",
"integrity": "sha512-AyWLw0XR38cFPwBu/ErgGaVrc5dupLEKmRlMXTGvFKOtbaGRQ2+yQJkjVhpdHhoRhU4+G+tJDFeHDTS8tK3bfQ==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@internationalized/date": "^3.12.3",
"@internationalized/number": "^3.6.7",
"@internationalized/string": "^3.2.9",
"@react-types/shared": "^3.36.0",
"@internationalized/string": "^3.2.10",
"@react-types/shared": "^3.36.1",
"@swc/helpers": "^0.5.0",
"aria-hidden": "^1.2.3",
"clsx": "^2.0.0",
"react-stately": "3.48.0",
"react-stately": "3.49.0",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
@@ -9405,17 +9533,18 @@
}
},
"node_modules/react-aria-components": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.19.0.tgz",
"integrity": "sha512-2smSS5nqJ8cGYMQezuUXveZm7eMyHCqTN6mDpylQBYLYbdF5dxCCuW1DHn1VKLe1DybSfPvX/cZtJlDmvFfn8A==",
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.20.0.tgz",
"integrity": "sha512-BMbpIgoV9aELeBrB0Y120NgoigHb5OdcJwc+4e7uSnbTbamea6lo+gqcc4LAxzMaK3Jf+7LI1oCDE6yANsmxIQ==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@react-types/shared": "^3.36.0",
"@internationalized/date": "^3.12.3",
"@internationalized/string": "^3.2.10",
"@react-types/shared": "^3.36.1",
"@swc/helpers": "^0.5.0",
"client-only": "^0.0.1",
"react-aria": "3.50.0",
"react-stately": "3.48.0"
"react-aria": "3.51.0",
"react-stately": "3.49.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
@@ -9469,15 +9598,15 @@
"license": "MIT"
},
"node_modules/react-stately": {
"version": "3.48.0",
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.48.0.tgz",
"integrity": "sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA==",
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.49.0.tgz",
"integrity": "sha512-13iNq2KzBrRAzxRc+n53hgROfIistiYY/sPtIhCw1qUB7/kmo+X1xEU2uiS5zcCIrc55AUPwoHqOIIpKWSwB9A==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@internationalized/date": "^3.12.3",
"@internationalized/number": "^3.6.7",
"@internationalized/string": "^3.2.9",
"@react-types/shared": "^3.36.0",
"@internationalized/string": "^3.2.10",
"@react-types/shared": "^3.36.1",
"@swc/helpers": "^0.5.0",
"use-sync-external-store": "^1.6.0"
},
@@ -10219,6 +10348,13 @@
"node": ">= 0.8"
}
},
"node_modules/std-env": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
"integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
"dev": true,
"license": "MIT"
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -10525,6 +10661,26 @@
"xtend": "~4.0.1"
}
},
"node_modules/tinybench": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz",
"integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/tinyexec": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
"integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -11609,6 +11765,112 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vitest": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.2.tgz",
"integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/mocker": "5.0.2",
"chai": "^6.2.2",
"es-module-lexer": "^2.3.2",
"expect-type": "^1.4.0",
"magic-string": "^1.2.3",
"obug": "^2.1.4",
"picomatch": "^4.0.7",
"std-env": "^4.2.0",
"tinybench": "^6.1.4",
"tinyexec": "^1.3.0",
"tinyglobby": "^0.2.17",
"why-is-node-running": "^3.2.1"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "5.0.2",
"@vitest/browser-preview": "5.0.2",
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
"@vitest/coverage-istanbul": "5.0.2",
"@vitest/coverage-v8": "5.0.2",
"@vitest/ui": "5.0.2",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/vitest/node_modules/magic-string": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/walk-sync": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz",
@@ -11793,6 +12055,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/why-is-node-running": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz",
"integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==",
"dev": true,
"license": "MIT",
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=20.11"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+6 -4
View File
@@ -7,6 +7,7 @@
"dev": "panda codegen && vite",
"build": "panda codegen && tsc -b && vite build",
"build:debug": "VITE_ANALYZE=true npm run build -- --debug",
"test": "panda codegen && vitest run",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:fix": "eslint . --fix",
"preview": "vite preview",
@@ -38,11 +39,11 @@
"livekit-client": "2.21.0",
"posthog-js": "1.418.10",
"react": "18.3.1",
"react-aria": "3.50.0",
"react-aria-components": "1.19.0",
"react-aria": "3.51.0",
"react-aria-components": "1.20.0",
"react-dom": "18.3.1",
"react-i18next": "17.0.12",
"react-stately": "3.48.0",
"react-stately": "3.49.0",
"use-sound": "5.0.0",
"valtio": "2.3.2",
"wouter": "3.10.0"
@@ -71,6 +72,7 @@
"typescript-eslint": "8.60.1",
"vite": "8.0.14",
"vite-plugin-static-copy": "4.1.1",
"vite-plugin-svgr": "5.2.0"
"vite-plugin-svgr": "5.2.0",
"vitest": "5.0.2"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
export const mediaUrl = (path: string) => {
const origin =
import.meta.env.VITE_API_BASE_URL ||
import.meta.env.VITE_MEDIA_BASE_URL ||
(typeof window !== 'undefined' ? window.location.origin : '')
// Remove leading/trailing slashes from origin/path if it exists
+2
View File
@@ -22,12 +22,14 @@ export interface ApiConfig {
url: string
}
documentation_url?: string
technical_documentation_url?: string
external_home_url?: string
silence_livekit_debug_logs?: boolean
is_silent_login_enabled?: boolean
custom_css_url?: string
use_french_gov_footer?: boolean
use_proconnect_button?: boolean
allow_unregistered_rooms?: boolean
idle_disconnect_warning_delay?: number
recording?: {
is_enabled?: boolean
@@ -19,7 +19,9 @@ export const LoadingScreen = ({
<Screen layout={layout} header={header} footer={footer}>
<CenteredContent>
<Center>
<p>{t('loading')}</p>
<p role="status" aria-live="polite">
{t('loading')}
</p>
</Center>
</CenteredContent>
</Screen>
@@ -22,6 +22,12 @@ export type IceCandidateInfo = {
port?: number
/** Local candidates only, and not reported by every browser. */
networkType?: string
/**
* For a local relay candidate, the TURN URL it was gathered from
* (e.g. `turns:turn.example.com:443?transport=tcp`). Used as a fallback
* when the browser does not report `relayProtocol`.
*/
url?: string
}
export type IceCandidatePair = {
@@ -42,6 +48,57 @@ export type IceCandidateReport = {
working: IceCandidatePair[]
}
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null
/** Narrows the loosely typed `data` stored on a step result. */
export const isIceCandidateReport = (
data: unknown
): data is IceCandidateReport =>
isObject(data) &&
Array.isArray(data.working) &&
(data.selected === null ||
(isObject(data.selected) && isObject(data.selected.local)))
/**
* Transport between the browser and the TURN server for a local relay
* candidate: udp, tcp or tls, or undefined when it cannot be determined.
*
* `protocol` is deliberately not used here: on a relay candidate it describes
* the TURN allocation (server to peer), which is UDP even when the client
* reaches the TURN server over TLS.
*/
export const getRelayTransport = (
candidate: IceCandidateInfo
): string | undefined => {
if (candidate.relayProtocol) return candidate.relayProtocol.toLowerCase()
if (!candidate.url) return undefined
const url = candidate.url.toLowerCase()
if (url.startsWith('turns:')) return 'tls'
if (!url.startsWith('turn:')) return undefined
const transport = /[?&]transport=(udp|tcp)\b/.exec(url)?.[1]
// RFC 7065: a turn: URI without a transport parameter defaults to UDP.
return transport ?? 'udp'
}
/**
* True when the selected pair goes through a TURN relay reached over TCP or
* TLS. Media still flows, but TCP head-of-line blocking usually degrades
* audio and video under packet loss.
*
* Direct routes (host, srflx, prflx), including ICE-TCP to the SFU, are out of
* scope: the warning and its documentation are about TURN fallbacks.
* An undetermined transport is not evidence of a bad route.
*/
export const isRelayedOverTcp = (data: unknown): boolean => {
if (!isIceCandidateReport(data) || !data.selected) return false
const { local } = data.selected
if (local.type !== 'relay') return false
const transport = getRelayTransport(local)
return transport === 'tcp' || transport === 'tls'
}
const PROBE_WIDTH = 320
const PROBE_HEIGHT = 180
const PROBE_FPS = 15
@@ -57,6 +114,7 @@ const readCandidate = (stats?: Stats): IceCandidateInfo => {
protocol: stats.protocol as string | undefined,
relayProtocol: stats.relayProtocol as string | undefined,
networkType: stats.networkType as string | undefined,
url: stats.url as string | undefined,
...(INCLUDE_CANDIDATE_ADDRESSES
? {
address: stats.address as string | undefined,
@@ -67,7 +125,10 @@ const readCandidate = (stats?: Stats): IceCandidateInfo => {
}
const describeCandidate = (candidate: IceCandidateInfo) => {
const transport = candidate.relayProtocol ?? candidate.protocol ?? 'unknown'
const transport =
(candidate.type === 'relay' ? getRelayTransport(candidate) : undefined) ??
candidate.protocol ??
'unknown'
const endpoint =
candidate.address === undefined
? ''
@@ -2,18 +2,44 @@ import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { ProgressBar } from 'react-aria-components'
import { css, cx } from '@/styled-system/css'
import { A } from '@/primitives'
import { useConfig } from '@/api/useConfig'
import type { ConnectionTestStats } from '../types'
import { statusSquareClass } from './stepAppearance'
type SummaryState = 'idle' | 'running' | 'passed' | 'partial' | 'failed'
type SummaryState =
| 'idle'
| 'running'
| 'passed'
| 'partial'
| 'failed'
| 'warning'
/** Only a failure earns a colour: everything else stays near-black. */
/** Only a failure or a degraded route earns a colour: everything else stays near-black. */
const stateColorClass: Record<SummaryState, string> = {
idle: css({ color: 'greyscale.1000' }),
running: css({ color: 'greyscale.1000' }),
passed: css({ color: 'greyscale.1000' }),
partial: css({ color: 'greyscale.1000' }),
failed: css({ color: 'danger.600' }),
warning: css({ color: 'warning' }),
}
/**
* A hard failure still outranks a warning step; a warning outranks 'partial'
* because a measured degraded route matters more than skipped camera or
* microphone checks.
*/
const getSummaryState = (
stats: ConnectionTestStats,
isRunning: boolean
): SummaryState => {
if (isRunning) return 'running'
if (!stats.hasStarted) return 'idle'
if (stats.failed > 0) return 'failed'
if (stats.warnings > 0) return 'warning'
if (stats.skipped > 0) return 'partial'
return 'passed'
}
const cardClass = css({
@@ -183,16 +209,15 @@ export const ConnectionTestSummary = ({
children?: ReactNode
}) => {
const { t } = useTranslation('connectionTest')
const { data: config } = useConfig()
const state: SummaryState = isRunning
? 'running'
: !stats.hasStarted
? 'idle'
: stats.failed > 0
? 'failed'
: stats.skipped > 0
? 'partial'
: 'passed'
// Network prerequisites for the reader's IT department. Instance specific,
// so it comes from the backend; without it the warning shows no link.
const networkDocUrl = config?.technical_documentation_url
const state = getSummaryState(stats, isRunning)
// Skipped device checks still deserve their hint under a route warning.
const showPartialHint = state === 'warning' && stats.skipped > 0
return (
<section className={cardClass}>
@@ -206,7 +231,27 @@ export const ConnectionTestSummary = ({
: t(`summary.${state}`)}
</p>
<p className={hintClass}>{t(`summary.${state}Hint`)}</p>
<p className={hintClass}>
{t(`summary.${state}Hint`)}
{state === 'warning' && networkDocUrl && (
<>
{' '}
<A
href={networkDocUrl}
target="_blank"
rel="noopener noreferrer"
size="sm"
externalIcon
aria-label={t('summary.warningDocLinkAriaLabel')}
>
{t('summary.warningDocLink')}
</A>
</>
)}
</p>
{showPartialHint && (
<p className={hintClass}>{t('summary.partialHint')}</p>
)}
</div>
{stats.hasStarted && (
@@ -237,6 +282,13 @@ export const ConnectionTestSummary = ({
value={stats.passed}
label={t('counts.passed')}
/>
{stats.warnings > 0 && (
<Counter
squareClass={statusSquareClass.warning}
value={stats.warnings}
label={t('counts.warnings')}
/>
)}
<Counter
squareClass={statusSquareClass.skipped}
value={stats.skipped}
@@ -15,15 +15,20 @@ export const statusSquareClass: Record<ConnectionTestStepStatus, string> = {
animation: 'pulse_background 1.2s ease-in-out infinite',
}),
success: css({ backgroundColor: 'success.600' }),
warning: css({ backgroundColor: 'warning' }),
failed: css({ backgroundColor: 'danger.600' }),
skipped: css({ backgroundColor: 'greyscale.300' }),
}
/** Colour is carried by the square; the label stays near-black except on failure. */
/**
* Colour is carried by the square; the label stays near-black except on
* failure and warning.
*/
export const statusTextClass: Record<ConnectionTestStepStatus, string> = {
pending: css({ color: 'greyscale.500' }),
running: css({ color: 'greyscale.700' }),
success: css({ color: 'greyscale.1000' }),
warning: css({ color: 'warning', fontWeight: 'medium' }),
failed: css({ color: 'danger.600', fontWeight: 'medium' }),
skipped: css({ color: 'greyscale.500' }),
}
@@ -8,7 +8,10 @@ import {
type CheckInfo,
} from 'livekit-client'
import { fetchConnectionTestDetails } from '../api/fetchConnectionTestDetails'
import { SelectedCandidateCheck } from '../checks/selectedCandidate'
import {
isRelayedOverTcp,
SelectedCandidateCheck,
} from '../checks/selectedCandidate'
import {
createInitialSteps,
type ConnectionTestLog,
@@ -49,10 +52,23 @@ const getErrorMessage = (error: unknown, fallback = 'Unknown error') =>
const isPermissionError = (error: unknown) =>
error instanceof Error && PERMISSION_ERROR_NAMES.has(error.name)
const toStepStatus = (info: CheckInfo): ConnectionTestStepStatus => {
const status = CHECK_STATUS_TO_STEP[info.status] ?? 'failed'
return status === 'success' && isRelayedOverTcp(info.data)
? 'warning'
: status
}
const fromCheckInfo = (info: CheckInfo): Partial<ConnectionTestStepResult> => ({
status: CHECK_STATUS_TO_STEP[info.status] ?? 'failed',
status: toStepStatus(info),
summary: info.description,
logs: info.logs,
// Only SelectedCandidateCheck sets `data` (the ICE candidate report).
// Consumers narrow it with a type guard (see isIceCandidateReport).
data:
typeof info.data === 'object' && info.data !== null
? (info.data as Record<string, unknown>)
: undefined,
})
const groupDevicesByKind = (devices: MediaDeviceInfo[]) => {
@@ -15,6 +15,7 @@ export type ConnectionTestStepStatus =
| 'pending'
| 'running'
| 'success'
| 'warning'
| 'failed'
| 'skipped'
@@ -63,6 +64,7 @@ export type ConnectionTestStats = {
total: number
settled: number
passed: number
warnings: number
failed: number
skipped: number
hasStarted: boolean
@@ -77,24 +79,27 @@ export const summarizeSteps = (
steps: ConnectionTestStepResult[]
): ConnectionTestStats => {
let passed = 0
let warnings = 0
let failed = 0
let skipped = 0
let pending = 0
for (const step of steps) {
if (step.status === 'success') passed += 1
else if (step.status === 'warning') warnings += 1
else if (step.status === 'failed') failed += 1
else if (step.status === 'skipped') skipped += 1
else if (step.status === 'pending') pending += 1
}
const total = steps.length
const settled = passed + failed + skipped
const settled = passed + warnings + failed + skipped
return {
total,
settled,
passed,
warnings,
failed,
skipped,
hasStarted: pending < total,
@@ -0,0 +1,19 @@
import { useTranslation } from 'react-i18next'
import { Button } from '@/primitives'
import { navigateTo } from '@/navigation/navigateTo'
import { generateRoomId } from '@/features/rooms'
export const CreateUnregisteredMeetingButton = () => {
const { t } = useTranslation('home')
return (
<Button
variant="primary"
data-attr="create-unregistered-meeting"
onPress={() =>
navigateTo('room', generateRoomId(), { state: { create: true } })
}
>
{t('createMeeting')}
</Button>
)
}
@@ -9,6 +9,7 @@ import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
import { IntroSlider } from '../components/IntroSlider'
import { MoreLink } from '../components/MoreLink'
import { CreateMeetingMenu } from '../components/CreateMeetingMenu'
import { CreateUnregisteredMeetingButton } from '../components/CreateUnregisteredMeetingButton'
import { ReactNode, useEffect, useState } from 'react'
import { css } from '@/styled-system/css'
@@ -189,13 +190,19 @@ const Home = () => {
display: 'flex',
gap: 0.5,
flexDirection: { base: 'column', xsm: 'row' },
flexWrap: 'wrap',
alignItems: { base: 'center', xsm: 'items-start' },
})}
>
{isLoggedIn ? (
<CreateMeetingMenu />
) : (
<LoginButton proConnectHint={false} />
<>
{data?.allow_unregistered_rooms && (
<CreateUnregisteredMeetingButton />
)}
<LoginButton proConnectHint={false} />
</>
)}
<DialogTrigger>
<Button
@@ -7,7 +7,6 @@ import {
useSwipe,
} from '@livekit/components-react'
import { mergeProps } from '@/utils/mergeProps'
import { PaginationIndicator } from './PaginationIndicator'
import { useGridLayout } from '../hooks/useGridLayout'
import { PaginationControl } from './PaginationControl'
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
@@ -62,13 +61,7 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
>
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
{tracks.length > layout.maxTiles && (
<>
<PaginationIndicator
totalPageCount={pagination.totalPageCount}
currentPage={pagination.currentPage}
/>
<PaginationControl pagesContainer={gridEl} {...pagination} />
</>
<PaginationControl {...pagination} focusShortcut />
)}
</div>
)
@@ -1,99 +1,147 @@
import * as React from 'react'
import { createInteractingObservable } from '@livekit/components-core'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { Button } from '@/primitives'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
import { css, cva, type RecipeVariantProps } from '@/styled-system/css'
import { useCallback, useRef } from 'react'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
export interface PaginationControlProps {
const paginationToolbar = cva({
base: {
display: 'flex',
alignItems: 'center',
gap: '0.125rem',
backgroundColor: 'primaryDark.50',
borderRadius: '2rem',
border: '1px solid',
borderColor: 'primaryDark.200',
padding: '0.375rem',
},
variants: {
placement: {
overlay: {
position: 'absolute',
bottom: '1rem',
left: '50%',
zIndex: 2,
transform: 'translateX(-50%)',
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.45)',
},
inline: {
alignSelf: 'center',
flexShrink: 0,
marginTop: '1rem',
},
},
},
defaultVariants: {
placement: 'overlay',
},
})
export type PaginationControlProps = RecipeVariantProps<
typeof paginationToolbar
> & {
totalPageCount: number
nextPage: () => void
prevPage: () => void
currentPage: number
pagesContainer?: React.RefObject<HTMLElement>
// The shortcut listens on the main window only, so a single instance may own it.
focusShortcut?: boolean
}
const arrowButtonClass = css({
_disabled: {
cursor: 'default',
backgroundColor: 'transparent !important',
'& svg': {
opacity: 0.35,
},
_focusVisible: {
outline: '2px solid',
outlineColor: 'focusRing',
outlineOffset: '2px',
},
},
})
export function PaginationControl({
totalPageCount,
nextPage,
prevPage,
currentPage,
pagesContainer: connectedElement,
placement,
focusShortcut = false,
}: PaginationControlProps) {
const { t } = useTranslation('rooms', { keyPrefix: 'pagination' })
const [interactive, setInteractive] = useState(false)
const prevButtonRef = useRef<HTMLButtonElement>(null)
const nextButtonRef = useRef<HTMLButtonElement>(null)
const isSinglePage = totalPageCount <= 1
const isFirstPage = currentPage <= 1
const isLastPage = currentPage >= totalPageCount
useEffect(() => {
let subscription:
| ReturnType<ReturnType<typeof createInteractingObservable>['subscribe']>
| undefined
if (connectedElement) {
subscription = createInteractingObservable(
connectedElement.current,
2000
).subscribe(setInteractive)
}
return () => {
if (subscription) {
subscription.unsubscribe()
}
}
}, [connectedElement])
const focusPagination = useCallback(() => {
const target = isLastPage ? prevButtonRef.current : nextButtonRef.current
target?.focus()
}, [isLastPage])
if (totalPageCount <= 1) return null
useRegisterKeyboardShortcut({
id: focusShortcut ? 'focus-pagination' : undefined,
handler: focusPagination,
isDisabled: isSinglePage,
unregisterOnUnmount: true,
})
if (isSinglePage) return null
const pageCount = t('count', { currentPage, totalPageCount })
return (
<nav
aria-label={t('label')}
className={css({
position: 'absolute',
bottom: '1rem',
left: '50%',
transform: 'translateX(-50%)',
alignItems: 'stretch',
backgroundColor: 'var(--lk-control-bg)',
borderRadius: 'var(--lk-border-radius)',
transition: 'opacity ease-in-out .15s',
display: 'none',
border: '1px solid',
borderColor: 'primaryDark.100',
overflow: 'hidden',
})}
style={{
display: interactive ? 'flex' : 'none',
}}
data-lk-user-interaction={interactive}
<div
role="group"
aria-label={`${t('label')}, ${pageCount}`}
className={paginationToolbar({ placement })}
>
<Button
isDisabled={currentPage == 1}
onPress={prevPage}
size="xs"
variant="quaternaryText"
ref={prevButtonRef}
aria-disabled={isFirstPage}
onPress={isFirstPage ? undefined : prevPage}
size="sm"
square
variant="primaryTextDark"
className={arrowButtonClass}
aria-label={t('previous')}
tooltip={t('previous')}
>
<RiArrowLeftSLine />
<RiArrowLeftSLine size={20} />
</Button>
<span
role="status"
className={css({
padding: '0.25rem 0.5rem',
color: 'white',
fontSize: '0.8125rem',
fontWeight: 500,
minWidth: '3.5rem',
textAlign: 'center',
userSelect: 'none',
padding: '0 0.35rem',
whiteSpace: 'nowrap',
})}
>
{t('count', {
currentPage,
totalPageCount,
})}
{pageCount}
</span>
<Button
isDisabled={currentPage == totalPageCount}
onPress={nextPage}
size="xs"
variant="quaternaryText"
ref={nextButtonRef}
aria-disabled={isLastPage}
onPress={isLastPage ? undefined : nextPage}
size="sm"
square
variant="primaryTextDark"
className={arrowButtonClass}
aria-label={t('next')}
tooltip={t('next')}
>
<RiArrowRightSLine />
<RiArrowRightSLine size={20} />
</Button>
</nav>
</div>
)
}
@@ -1,30 +0,0 @@
import * as React from 'react'
export interface PaginationIndicatorProps {
totalPageCount: number
currentPage: number
}
export const PaginationIndicator: (
props: PaginationIndicatorProps & React.RefAttributes<HTMLDivElement>
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
HTMLDivElement,
PaginationIndicatorProps
>(function PaginationIndicator(
{ totalPageCount, currentPage }: PaginationIndicatorProps,
ref
) {
const bubbles = new Array(totalPageCount).fill('').map((_, index) => {
if (index + 1 === currentPage) {
return <span data-lk-active key={index} />
} else {
return <span key={index} />
}
})
return (
<div ref={ref} className="lk-pagination-indicator" aria-hidden="true">
{bubbles}
</div>
)
})
@@ -1,96 +0,0 @@
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
interface PipPaginationProps {
totalPageCount: number
currentPage: number
nextPage: () => void
prevPage: () => void
}
export const PipPagination = ({
totalPageCount,
currentPage,
nextPage,
prevPage,
}: PipPaginationProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'pagination' })
if (totalPageCount <= 1) return null
return (
<Nav aria-label={t('label')}>
<ArrowButton
type="button"
onClick={prevPage}
disabled={currentPage === 1}
aria-label={t('previous')}
>
<RiArrowLeftSLine size={18} />
</ArrowButton>
<Counter role="status">
{t('count', { currentPage, totalPageCount })}
</Counter>
<ArrowButton
type="button"
onClick={nextPage}
disabled={currentPage === totalPageCount}
aria-label={t('next')}
>
<RiArrowRightSLine size={18} />
</ArrowButton>
</Nav>
)
}
const Nav = styled('nav', {
base: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.25rem',
marginTop: '1rem',
flexShrink: 0,
},
})
const ArrowButton = styled('button', {
base: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '1.75rem',
height: '1.75rem',
borderRadius: '4px',
border: 'none',
cursor: 'pointer',
color: 'white',
backgroundColor: 'primaryDark.100',
transition: 'opacity 0.15s, background-color 0.15s',
'&:hover:not(:disabled)': {
backgroundColor: 'primaryDark.75',
},
'&:focus-visible': {
outline: '2px solid',
outlineColor: 'white',
outlineOffset: '2px',
},
'&:disabled': {
opacity: 0.3,
cursor: 'default',
},
},
})
const Counter = styled('span', {
base: {
fontSize: '0.75rem',
color: 'white',
opacity: 0.8,
whiteSpace: 'nowrap',
padding: '0 0.25rem',
minWidth: '3rem',
textAlign: 'center',
},
})
@@ -4,7 +4,7 @@ import { RoomEvent, Track } from 'livekit-client'
import { styled } from '@/styled-system/jsx'
import { PipFocusLayout } from './PipFocusLayout'
import { PipGridLayout } from './PipGridLayout'
import { PipPagination } from './PipPagination'
import { PaginationControl } from '@/features/layout/components/PaginationControl'
import { PipScreenShareLayout } from './PipScreenShareLayout'
import { StageFrame } from './StageFrame'
import { MAX_PIP_TILES } from '../../utils/pipGrid'
@@ -120,12 +120,7 @@ const PaginatedStage = ({
}) => (
<StageWrapper>
<StageFrame>{children}</StageFrame>
<PipPagination
totalPageCount={pagination.totalPageCount}
currentPage={pagination.currentPage}
nextPage={pagination.nextPage}
prevPage={pagination.prevPage}
/>
<PaginationControl {...pagination} placement="inline" />
</StageWrapper>
)
@@ -40,11 +40,15 @@ const StyledRACDialog = styled(Dialog, {
})
export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
const roomData = useRoomData()
const isCreatingUnregisteredRoom =
roomData?.id === null && !!history.state?.create
const [isDismissed, setIsDismissed] = useState(false)
const showInviteDialog =
!isDismissed && (mode === 'create' || isCreatingUnregisteredRoom)
const roomUrl = roomData?.slug ? getRouteUrl('room', roomData.slug) : ''
const telephony = useTelephony()
@@ -78,7 +82,7 @@ export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
variant="tertiaryText"
size="xs"
onPress={() => {
setShowInviteDialog(false)
setIsDismissed(true)
}}
aria-label={t('closeDialog')}
>
@@ -0,0 +1,43 @@
import { useIsSpeaking, useRoomContext } from '@livekit/components-react'
import { useEffect, useState } from 'react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import {
closeLowerHandToasts,
showLowerHandToast,
} from '@/features/notifications/utils'
const SPEAKING_DETECTION_DELAY = 3000
/**
* Offers to lower the local participant's raised hand after
* SPEAKING_DETECTION_DELAY of speaking. Mount it once: each copy runs its own
* timer and shows its own toast.
*/
export const LowerHandOnSpeaking = () => {
const room = useRoomContext()
const { isHandRaised, lowerHand } = useRaisedHand({
participant: room.localParticipant,
})
const isSpeaking = useIsSpeaking(room.localParticipant)
const [hasOffered, setHasOffered] = useState(false)
useEffect(() => {
if (isHandRaised) return
setHasOffered(false)
closeLowerHandToasts()
}, [isHandRaised])
useEffect(() => {
if (!isSpeaking || !isHandRaised || hasOffered) return
const timer = setTimeout(() => {
setHasOffered(true)
showLowerHandToast(room.localParticipant, lowerHand)
}, SPEAKING_DETECTION_DELAY)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSpeaking, isHandRaised, hasOffered])
return null
}
@@ -2,19 +2,12 @@ import { useTranslation } from 'react-i18next'
import { RiHand } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css'
import { useIsSpeaking, useRoomContext } from '@livekit/components-react'
import { useRoomContext } from '@livekit/components-react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { useEffect, useRef, useState } from 'react'
import {
closeLowerHandToasts,
showLowerHandToast,
} from '@/features/notifications/utils'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
const SPEAKING_DETECTION_DELAY = 3000
type Props = Pick<NonNullable<ButtonRecipeProps>, 'variant'> & ToggleButtonProps
export const HandToggle = ({
@@ -25,64 +18,15 @@ export const HandToggle = ({
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
const room = useRoomContext()
const { isHandRaised, toggleRaisedHand, lowerHand } = useRaisedHand({
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
participant: room.localParticipant,
})
const isSpeaking = useIsSpeaking(room.localParticipant)
const speakingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [hasShownToast, setHasShownToast] = useState(false)
const resetToastState = () => {
setHasShownToast(false)
}
useEffect(() => {
if (isHandRaised) return
closeLowerHandToasts()
}, [isHandRaised])
const handleToggle = () => {
toggleRaisedHand()
resetToastState()
}
useRegisterKeyboardShortcut({
id: 'raise-hand',
handler: handleToggle,
handler: toggleRaisedHand,
})
useEffect(() => {
const shouldShowToast = isSpeaking && isHandRaised && !hasShownToast
if (shouldShowToast && !speakingTimerRef.current) {
speakingTimerRef.current = setTimeout(() => {
speakingTimerRef.current = null
setHasShownToast(true)
const onClose = () => {
lowerHand()
resetToastState()
}
showLowerHandToast(room.localParticipant, onClose)
}, SPEAKING_DETECTION_DELAY)
}
if ((!isSpeaking || !isHandRaised) && speakingTimerRef.current) {
clearTimeout(speakingTimerRef.current)
speakingTimerRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSpeaking, isHandRaised, hasShownToast, lowerHand])
// Clear any pending timer on unmount
useEffect(() => {
return () => {
if (speakingTimerRef.current) {
clearTimeout(speakingTimerRef.current)
speakingTimerRef.current = null
}
}
}, [])
const tooltipLabel = isHandRaised ? 'lower' : 'raise'
return (
@@ -100,7 +44,7 @@ export const HandToggle = ({
tooltip={t(tooltipLabel)}
isSelected={isHandRaised}
onPress={(e) => {
handleToggle()
toggleRaisedHand()
onPress?.(e)
}}
data-attr={`controls-hand-${tooltipLabel}`}
@@ -31,35 +31,20 @@ export const useSidePanel = () => {
const isSidePanelOpen = !!activePanelId
const isSubPanelOpen = !!activeSubPanelId
const toggleAdmin = () => {
layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
// Reads the live store, not the render snapshot: shortcut handlers outlive
// toggles that unmount (e.g. inside a closed overflow menu).
const togglePanel = (panelId: PanelId) => {
layoutStore.activePanelId =
layoutStore.activePanelId === panelId ? null : panelId
if (layoutStore.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleParticipants = () => {
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleChat = () => {
layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleEffects = () => {
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleTools = () => {
layoutStore.activePanelId = isToolsOpen ? null : PanelId.TOOLS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleInfo = () => {
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleAdmin = () => togglePanel(PanelId.ADMIN)
const toggleParticipants = () => togglePanel(PanelId.PARTICIPANTS)
const toggleChat = () => togglePanel(PanelId.CHAT)
const toggleEffects = () => togglePanel(PanelId.EFFECTS)
const toggleTools = () => togglePanel(PanelId.TOOLS)
const toggleInfo = () => togglePanel(PanelId.INFO)
const openTranscript = () => {
layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT
@@ -31,6 +31,7 @@ import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer'
import { ChatProvider } from '@/features/chat/components/ChatProvider'
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
import { LowerHandOnSpeaking } from '@/features/rooms/livekit/components/LowerHandOnSpeaking'
import { LobbyProvider } from '@/features/rooms/components/LobbyProvider'
/**
@@ -119,6 +120,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<ConnectionObserver />
<SyncDevicePreferences />
<RoomSilentMicDetector />
<LowerHandOnSpeaking />
<MediaStateObserver />
<ChatProvider />
<LobbyProvider />
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { normalizeRoomId } from './isRoomValid'
describe('normalizeRoomId', () => {
it('lowercases and re-inserts the hyphens of a ten-letter id', () => {
expect(normalizeRoomId('ABCDEFGHIJ')).toBe('abc-defg-hij')
expect(normalizeRoomId('abc-defghij')).toBe('abc-defg-hij')
})
it('returns any other input unchanged', () => {
expect(normalizeRoomId('abc-def')).toBe('abc-def')
expect(normalizeRoomId('Not-A-Room')).toBe('Not-A-Room')
})
})
@@ -7,6 +7,7 @@ export type ShortcutCategory = 'navigation' | 'media' | 'interaction'
export type ShortcutId =
| 'open-shortcuts'
| 'focus-toolbar'
| 'focus-pagination'
| 'toggle-microphone'
| 'toggle-camera'
| 'push-to-talk'
@@ -44,6 +45,11 @@ export const shortcutCatalog: ShortcutDescriptor[] = [
category: 'navigation',
shortcut: { key: 'F2' },
},
{
id: 'focus-pagination',
category: 'navigation',
shortcut: { key: 'G', ctrlKey: true, shiftKey: true },
},
{
id: 'toggle-microphone',
category: 'media',
@@ -7,12 +7,16 @@ export type useRegisterKeyboardShortcutProps = {
id?: ShortcutId
handler: () => Promise<void | boolean | undefined> | void
isDisabled?: boolean
// Opt-in: controls rendered inside menus unmount when the menu closes, and
// their shortcuts must keep working.
unregisterOnUnmount?: boolean
}
export const useRegisterKeyboardShortcut = ({
id,
handler,
isDisabled = false,
unregisterOnUnmount = false,
}: useRegisterKeyboardShortcutProps) => {
useEffect(() => {
if (!id) return
@@ -21,8 +25,14 @@ export const useRegisterKeyboardShortcut = ({
const formattedKey = formatShortcutKey(descriptor.shortcut)
if (isDisabled) {
keyboardShortcutsStore.shortcuts.delete(formattedKey)
} else {
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
return
}
}, [handler, id, isDisabled])
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
if (!unregisterOnUnmount) return
return () => {
if (keyboardShortcutsStore.shortcuts.get(formattedKey) === handler) {
keyboardShortcutsStore.shortcuts.delete(formattedKey)
}
}
}, [handler, id, isDisabled, unregisterOnUnmount])
}
+23 -16
View File
@@ -126,6 +126,9 @@ export const Footer = () => {
return null
}
const isConnectionTestEnabled = !!data.diagnostics?.connection_test_enabled
const technicalDocumentationUrl = data.technical_documentation_url
return (
<footer
className={css({
@@ -256,7 +259,9 @@ export const Footer = () => {
{t('links.data')}
</A>
</StyledLi>
<StyledLi divider>
<StyledLi
divider={isConnectionTestEnabled || !!technicalDocumentationUrl}
>
<Link
underline={false}
footer="minor"
@@ -266,8 +271,8 @@ export const Footer = () => {
{t('links.accessibility')}
</Link>
</StyledLi>
{data?.diagnostics?.connection_test_enabled && (
<StyledLi divider>
{isConnectionTestEnabled && (
<StyledLi divider={!!technicalDocumentationUrl}>
<Link
underline={false}
footer="minor"
@@ -278,19 +283,21 @@ export const Footer = () => {
</Link>
</StyledLi>
)}
<StyledLi>
<A
externalIcon
underline={false}
footer="minor"
href="https://docs.numerique.gouv.fr/docs/f2baa1b9-f29e-4d58-959d-65d4376fc6b8/"
aria-label={
t('links.technicalDetails') + ' - ' + t('links.ariaLabel')
}
>
{t('links.technicalDetails')}
</A>
</StyledLi>
{technicalDocumentationUrl && (
<StyledLi>
<A
externalIcon
underline={false}
footer="minor"
href={technicalDocumentationUrl}
aria-label={
t('links.technicalDetails') + ' - ' + t('links.ariaLabel')
}
>
{t('links.technicalDetails')}
</A>
</StyledLi>
)}
</SecondRow>
<ThirdRow>
{t('mentions')}{' '}
@@ -29,11 +29,13 @@
"pending": "Ausstehend",
"running": "Läuft…",
"success": "Erfolgreich",
"warning": "Nicht optimal",
"failed": "Fehlgeschlagen",
"skipped": "Übersprungen"
},
"counts": {
"passed": "erfolgreich",
"warnings": "nicht optimal",
"failed": "fehlgeschlagen",
"skipped": "übersprungen"
},
@@ -46,6 +48,10 @@
"passedHint": "Ihr Browser, Ihre Geräte und Ihr Netzwerk sind für eine Besprechung bereit.",
"partial": "Teilweiser Test",
"partialHint": "Einige Prüfungen wurden übersprungen. Erlauben Sie den Zugriff auf Ihre Kamera und Ihr Mikrofon, um diese zu testen.",
"warning": "Verbindung nicht optimal",
"warningHint": "Sie können an Ihren Besprechungen teilnehmen, aber die Bild- und Tonqualität kann aufgrund Ihrer Netzwerkeinstellungen beeinträchtigt sein. Ihre IT-Abteilung kann hier Abhilfe schaffen.",
"warningDocLink": "Netzwerkanforderungen für Ihre IT-Abteilung",
"warningDocLinkAriaLabel": "Netzwerkanforderungen für Ihre IT-Abteilung öffnen – öffnet in neuem Tab",
"failed_one": "{{count}} Prüfung fehlgeschlagen",
"failed_other": "{{count}} Prüfungen fehlgeschlagen",
"failedHint": "Öffnen Sie die fehlgeschlagenen Prüfungen für weitere Details und senden Sie den Bericht an Ihre IT-Abteilung."
+1
View File
@@ -784,6 +784,7 @@
"actions": {
"open-shortcuts": "Tastenkürzel-Hilfe öffnen",
"focus-toolbar": "Fokus auf die untere Symbolleiste",
"focus-pagination": "Fokus auf die Teilnehmerseiten",
"toggle-microphone": "Mikrofon umschalten",
"toggle-camera": "Kamera umschalten",
"push-to-talk": "Push-to-talk",
@@ -29,11 +29,13 @@
"pending": "Pending",
"running": "Running…",
"success": "Passed",
"warning": "Not optimal",
"failed": "Failed",
"skipped": "Skipped"
},
"counts": {
"passed": "passed",
"warnings": "not optimal",
"failed": "failed",
"skipped": "skipped"
},
@@ -46,6 +48,10 @@
"passedHint": "Your browser, your devices and your network are ready for a meeting.",
"partial": "Partially tested",
"partialHint": "Some checks were skipped. Allow access to your camera and microphone to test them.",
"warning": "Suboptimal connection",
"warningHint": "You can join your meetings, but video and audio quality may be reduced because of your network settings. Your IT department can improve this.",
"warningDocLink": "Network requirements for your IT department",
"warningDocLinkAriaLabel": "Open the network requirements for your IT department - opens in new window",
"failed_one": "{{count}} check failed",
"failed_other": "{{count}} checks failed",
"failedHint": "Open the failed checks below for details, then send the report to your IT department."
+1
View File
@@ -784,6 +784,7 @@
"actions": {
"open-shortcuts": "Open shortcuts help",
"focus-toolbar": "Focus bottom toolbar",
"focus-pagination": "Focus participant pagination",
"toggle-microphone": "Toggle microphone",
"toggle-camera": "Toggle camera",
"push-to-talk": "Push-to-talk (hold to unmute)",
@@ -29,11 +29,13 @@
"pending": "En espera",
"running": "En curso…",
"success": "Correcto",
"warning": "No óptimo",
"failed": "Error",
"skipped": "Omitido"
},
"counts": {
"passed": "correctas",
"warnings": "no óptimas",
"failed": "con errores",
"skipped": "omitidas"
},
@@ -46,6 +48,10 @@
"passedHint": "Tu navegador, tus dispositivos y tu red están listos para una reunión.",
"partial": "Prueba parcial",
"partialHint": "Se han omitido algunas comprobaciones. Autoriza el acceso a tu cámara y a tu micrófono para probarlos.",
"warning": "Conexión no óptima",
"warningHint": "Puedes participar en tus reuniones, pero la calidad de la imagen y del sonido puede verse reducida por la configuración de tu red. Tu servicio informático puede mejorar la situación.",
"warningDocLink": "Requisitos de red para tu servicio informático",
"warningDocLinkAriaLabel": "Abrir los requisitos de red para tu servicio informático - se abre en una nueva ventana",
"failed_one": "{{count}} verificación en error",
"failed_other": "{{count}} verificaciones en error",
"failedHint": "Abre las verificaciones en error para ver el detalle y transmite después el informe a tu servicio informático."
+1
View File
@@ -783,6 +783,7 @@
"actions": {
"open-shortcuts": "Abrir la ayuda de atajos",
"focus-toolbar": "Poner el foco en la barra de herramientas inferior",
"focus-pagination": "Poner el foco en la paginación de participantes",
"toggle-microphone": "Activar o desactivar el micrófono",
"toggle-camera": "Activar o desactivar la cámara",
"push-to-talk": "Pulsar para hablar (mantener para reactivar)",
@@ -29,11 +29,13 @@
"pending": "En attente",
"running": "En cours…",
"success": "Réussi",
"warning": "Non optimal",
"failed": "Échec",
"skipped": "Ignoré"
},
"counts": {
"passed": "réussis",
"warnings": "non optimaux",
"failed": "en échec",
"skipped": "ignorés"
},
@@ -46,6 +48,10 @@
"passedHint": "Votre navigateur, vos périphériques et votre réseau sont prêts pour une réunion.",
"partial": "Test partiel",
"partialHint": "Certaines vérifications ont été ignorées. Autorisez l'accès à votre caméra et à votre microphone pour les tester.",
"warning": "Connexion non optimale",
"warningHint": "Vous pouvez participer à vos réunions, mais la qualité de l'image et du son risque d'être réduite à cause des réglages de votre réseau. Votre service informatique peut améliorer la situation.",
"warningDocLink": "Prérequis réseau à transmettre à votre service informatique",
"warningDocLinkAriaLabel": "Ouvrir les prérequis réseau à transmettre à votre service informatique - ouvre dans une nouvelle fenêtre",
"failed_one": "{{count}} vérification en échec",
"failed_other": "{{count}} vérifications en échec",
"failedHint": "Ouvrez les vérifications en échec pour voir le détail, puis transmettez le rapport à votre service informatique."
+1
View File
@@ -784,6 +784,7 @@
"actions": {
"open-shortcuts": "Ouvrir l’aide des raccourcis",
"focus-toolbar": "Mettre le focus sur la barre d’outils du bas",
"focus-pagination": "Mettre le focus sur la pagination des participants",
"toggle-microphone": "Activer ou désactiver le micro",
"toggle-camera": "Activer ou désactiver la caméra",
"push-to-talk": "Appuyer pour parler (maintenir pour réactiver)",
@@ -29,11 +29,13 @@
"pending": "In afwachting",
"running": "Bezig…",
"success": "Geslaagd",
"warning": "Niet optimaal",
"failed": "Mislukt",
"skipped": "Overgeslagen"
},
"counts": {
"passed": "geslaagd",
"warnings": "niet optimaal",
"failed": "mislukt",
"skipped": "overgeslagen"
},
@@ -46,6 +48,10 @@
"passedHint": "Je browser, apparaten en netwerk zijn klaar voor een vergadering.",
"partial": "Gedeeltelijke test",
"partialHint": "Sommige controles zijn overgeslagen. Geef toegang tot je camera en microfoon om deze te testen.",
"warning": "Verbinding niet optimaal",
"warningHint": "Je kunt deelnemen aan je vergaderingen, maar de beeld- en geluidskwaliteit kan minder zijn door de instellingen van je netwerk. Je IT-afdeling kan dit verbeteren.",
"warningDocLink": "Netwerkvereisten voor je IT-afdeling",
"warningDocLinkAriaLabel": "Netwerkvereisten voor je IT-afdeling openen - opent in nieuw venster",
"failed_one": "{{count}} controle mislukt",
"failed_other": "{{count}} controles mislukt",
"failedHint": "Open de mislukte controles voor meer details en stuur het rapport door naar je IT-afdeling."
+1
View File
@@ -784,6 +784,7 @@
"actions": {
"open-shortcuts": "Sneltoetsenhulp openen",
"focus-toolbar": "Focus op de onderste werkbalk",
"focus-pagination": "Focus op de paginering van deelnemers",
"toggle-microphone": "Microfoon aan/uit",
"toggle-camera": "Camera aan/uit",
"push-to-talk": "Push-to-talk (ingedrukt houden om te activeren)",

Some files were not shown because too many files have changed in this diff Show More