Compare commits

...

128 Commits

Author SHA1 Message Date
briquet 68632d73ab 🔒️(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 12:15:10 +02:00
briquet fc92ac353e 📝(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 12:15:03 +02:00
briquet e9f8ecc9c9 ✨(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 12:14:18 +02:00
briquet dca78ae601 ✨(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 actor, auth method, tenant and network fields are read from the request
passed to audit.log. A person is identified by primary key, OIDC sub when
the account has one and email domains.

Actions are audit.Action specs carrying their ECS category and types, so
a call site only names what was attempted. The project declares the rest
in code, from an auditing module the audit app autodiscovers:
audit.register lists the fields describing a model as a target, and
audit.register_auth_method names the DRF authentication classes and the
login backends. A field that cannot be read is skipped, not the event.

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, so a refusal is recorded under the
action that was attempted. An exception DRF does not handle is recorded
as an internal error before it propagates. The core.audit app also
records Django login and logout signals.
2026-10-06 12:14:17 +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
lebaudantoine bd0329d162 🔒️(agents) upgrade libpcre2-8-0 to fix CVE-2026-103111
The python:3.14.7-slim base image ships libpcre2-8-0
10.46-1~deb13u2, which is affected by CVE-2026-103111 (HIGH):
an out-of-bounds write triggered by a crafted regular expression.

Explicitly install libpcre2-8-0 in the base stage so apt pulls
the patched 10.46-1~deb13u3 from trixie-security. All stages
(builder, development, production) inherit the fix.

This line can be dropped once an upstream python slim image
ships the patched package.
2026-10-01 16:37:29 +02:00
lebaudantoine cedaa32ab7 🔒️(backend) fix HIGH CVEs in Django and urllib3
Address the following HIGH severity CVEs reported by Trivy on the
backend image:

* Django 5.2.16 → 5.2.17
  - CVE-2026-15307 — remote code execution via GeoDjango spatial
    lookups.

* urllib3 2.7.0 → 2.8.0
  - CVE-2026-97687 — traffic interception via HTTPS proxy TLS
    configuration override.
  - CVE-2026-97689 — denial of service via unbounded memory
    allocation in the chunk parser.
2026-10-01 16:37:29 +02:00
lebaudantoine 22adccb353 👷(ci) ignore unfixed Debian CVEs in trivy scans
The trivy scan fails on HIGH vulnerabilities found in the Debian 13
base images (util-linux, acl, ncurses, systemd and perl-base). None
of them has a fixed version available yet, so there is nothing we
can upgrade to clear them.

List these CVEs in a shared .github/.trivyignore and pass it to
every image scan, so the scan stays blocking for any new HIGH or
CRITICAL vulnerability. Remove the entries once Debian ships a fix.
2026-10-01 00:13:36 +02:00
lebaudantoine 3ab651d6c7 👷(ci) pin the shared CI repo to v0.0.1
Instead of referencing the shared CI repo on `main`, pin it to the
initial tagged version `v0.0.1`, so the CI behavior is stable and
does not silently change when the shared repo is updated.
2026-10-01 00:13:36 +02:00
lebaudantoine 919af928aa 🚨(ci) fix the shellcheck job
Get the shellcheck CI job to pass again by addressing the issues it
flagged across our shell scripts.

Note: I am not 100% sure of every fix applied here. Reviewers should
feel free to challenge specific changes and suggest better ones
where relevant.
2026-10-01 00:13:36 +02:00
lebaudantoine 3ed38f1c48 🚨(ci) fix the spellcheck job
Get the spellcheck CI job to pass again by:

* Fixing the actual spelling issues it caught in the project.
* Excluding generated files from the scan, since they are not
  written by us.
* Excluding translation files, whose content is not necessarily in
  English and would trigger false positives.
2026-10-01 00:13:36 +02:00
lebaudantoine f172c5795e 👷(ci) add Menshen scan for GitHub Actions vulnerabilities
Wire Menshen into the CI to scan the GitHub Actions we use and flag
vulnerable ones, following the same approach as other projects that
recently adopted it.

Note: I am not fully sure about the current setup. Reviewers should
feel free to adjust the configuration or the integration point as
they see fit.
2026-10-01 00:13:36 +02:00
lebaudantoine 262b168414 🔥(ci) drop unused Crowdin workflows
The Crowdin workflows were not used by the project and had turned
into dead CI code.

Remove them to reduce noise and keep the CI configuration limited
to what is actually running.
2026-10-01 00:13:36 +02:00
lebaudantoine 6c371c8cb3 👷(ci) migrate CI to the shared workflows repository
First iteration of a migration toward a centralized repository
containing our shared CI logic.

Goals:

* Manage GitHub Actions version upgrades in one place.
* Make it easier to audit what actually runs in CI from a security
  perspective.
* Avoid duplicating CI logic across projects and having each
  repository slowly diverge over time.
* Centralize as many of our custom GitHub Actions as possible,
  including some that still live in the old `numerique-gouv`
  organization.
* Centralize the Renovate configuration alongside the workflows.

Inspired by the Accounts project, which recently simplified and
reorganized its CI setup.

This PR starts moving meet's CI to the shared repository so we can
validate the approach on a real project. For now, reusable
workflows are pinned to `main`; once we agree on the structure and
content of the central repository, they should be pinned to a
specific commit SHA instead.
2026-10-01 00:13:36 +02:00
lebaudantoine 1a8906c0a1 🔒️(agents) fix util-linux CVEs reported by Cyberwatch
Address the following CVEs in util-linux, reported by Cyberwatch on
the agents image. The python:3.14.6-slim tag is no longer rebuilt
and still ships util-linux 2.41-5. Bump the base image to
python:3.14.7-slim, which ships the patched 2.41.5-0+deb13u1
(DSA-6442-1), to cover them all:

* CVE-2026-53612 (7.0) — TOCTOU in mount post-mount ownership/mode
  changes.
* CVE-2026-53613 (7.0) — TOCTOU in mount via ancestor directory swap.
* CVE-2026-53614 (7.0) — SUID mount(8) nosuid/noexec bypass via
  `LIBMOUNT_FORCE_MOUNT2`.
* CVE-2026-13595 (5.3) — flaw in the libblkid library.
* CVE-2026-27456 (4.7) — TOCTOU in SUID mount(8) when setting up
  loop devices.
2026-09-30 16:21:22 +02:00
lebaudantoine 99ba8e330e 🐛(frontend) enforce recording-mode permissions on the checkboxes
Permissions on the recording panel checkboxes were not properly
enforced. A user with only partial access to some recording modes
could still tick a mode's checkbox and, for example, launch a
transcription from the recording panel even though they were not
authorized to.

Gate each checkbox on the user's actual permissions so that only
authorized modes can be started from the panel.
2026-09-30 15:15:19 +02:00
lebaudantoine 059e5f1ec4 🔒️(backend) add a daily cap on room creation
The per-minute room creation throttle absorbs bursts but does not stop
a compromised account from steadily creating rooms over hours or days.

Add RoomCreationDailyUserRateThrottle, a per-user throttle with its own
"room_creation_daily" scope, applied to room creation only alongside
the existing short-term throttle. It defaults to 1000 rooms per day and
is configurable via ROOM_CREATION_DAILY_THROTTLE_RATES.

Tests use a controllable clock and patch rates with monkeypatch so they
are restored after each test.
2026-09-30 14:57:10 +02:00
Lebaud Antoine 39ab9359e4 🔒️(backend) throttle meeting link generation
Add throttling on the endpoint used to generate meeting links, so a
compromised authenticated account cannot silently generate thousands
of links without hitting any suspicious errors or alerts.

The limits are set high enough not to affect legitimate usage, while
capping the damage a leaked account can do.
2026-09-30 14:57:10 +02:00
lebaudantoine d0a0d60ece 🔖(minor) bump release to 1.33.0 2026-09-30 13:03:41 +02:00
lebaudantoine 27bd136741 🩹(doc) fix changelog organization after a bad rebase
The CHANGELOG entries ended up in the wrong order after a rebase
performed while merging a recent PR.

Restore the intended organization of the entries so the file reads
correctly again.
2026-09-30 12:29:39 +02:00
lebaudantoine ad2ca6b4ef 🔒️(backend) fix critical and high CVEs in PyJWT
Bump PyJWT from 2.13.0 to 2.14.0 to address the following CVEs
reported by Trivy:

* CVE-2026-102268 (CRITICAL)
* CVE-2026-102266 (HIGH) — authentication bypass via empty HMAC
  key acceptance.
* CVE-2026-102267 (HIGH) — verification key substitution via
  unvalidated JWKS redirects.
* CVE-2026-102271 (HIGH) — authentication bypass via acceptance
  of DER public keys as HMAC.
* CVE-2026-102272 (HIGH) — token forgery via improper Unicode
  byte-order mark handling.
* CVE-2026-102273 (HIGH) — token forgery via acceptance of
  public JWK containers as HMAC.
2026-09-30 12:18:37 +02:00
leo 4071984f8e ⬆️(dependencies) update python dependencies
Update python dependencies.

Co-Authored-By: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-29 11:47:32 +02:00
lebaudantoine 2ae602606c ⚡️(frontend) disable posthog-js periodic feature flag reloads
Since posthog-js 1.356.0, feature flags are reloaded every 5
minutes by default while the tab is visible. Meet sessions are
long-lived visible tabs, so this multiplied the number of `/flags`
requests we send.

Restore the pre-1.356.0 behavior: only (re)load flags on init and
on identity
2026-09-29 11:14:05 +02:00
briquet f28389b624 👷(helm) run the inactive rooms purge command as cronjob
Schedule `purge_inactive_rooms` every night along with the other
housekeeping commands.
2026-09-29 11:08:38 +02:00
briquet cbb8740f41 📝(docs) document the inactive rooms purge
Add a basic documentation of the purge_inactive_rooms command
2026-09-29 11:08:38 +02:00
briquet b4b9fe54fb ♻️(backend) add command to purge inactive rooms
Add a `purge_inactive_rooms` management command which permanently
deletes the rooms that were not started for
`ROOM_INACTIVITY_DELETION_DAYS` days. Rooms never started are aged from
their creation date.

Important points :
- The setting is unset by default, which disables the purge.
- Rooms holding a recording their users may still access are kept,
- It refuses to run while unregistered rooms are allowed, as the link
  of a purged room would turn into a public unregistered room,
2026-09-29 11:08:38 +02:00
briquet 88685d613a 🧐(backend) track room last start datetime from livekit webhook
Rooms pile up in database with no way to tell the ones still in use from
the abandoned ones. Record on the room the last time LiveKit reported it
as started, in a new `last_started_at` field which stays NULL until the
first time the room is started on a LiveKit node.

The migration stamps existing rooms with the migration time to avoid
deleting preexisting rooms.
2026-09-29 11:08:38 +02:00
briquet 6cde1b4461 🔨(dev) add Makefile targets to list and download files from Garage
Garage has no web console, so inspecting recordings, transcripts and
summaries locally meant writing aws-cli commands by hand.

For each of recordings, transcripts and summaries:

- `make <folder>-list` lists the files from the most recent, and
- `make <folder>-download-latest` downloads the latest one into
data/<folder>.

Only files with the folder's expected extensions are kept, so the Egress
manifests stored next to recordings are skipped.
2026-09-29 10:52:49 +02:00
briquet 68fe3f96fc 🔧(helm) point media services to Garage by default
The media ingresses and their ExternalName services defaulted to the
MinIO service of the development stack, which is now Garage.
Self-hosted relying on these defaults must set serviceMedia*.host and the
upstream-vhost annotations explicitly, see UPGRADE.md.
2026-09-29 10:52:49 +02:00
briquet 3f9942a61d 🔧(compose) replace MinIO by Garage for local development
The development stacks now run Garage instead of MinIO which is
deprecated.

Garage is a bit stricter than MinIO:
- key IDs and secrets must be at least 8 and 16 characters long
- requests must be signed for its region, so every development env now sets
  AWS_S3_REGION_NAME=local;
- cross-origin requests are denied unless the bucket CORS rules allow
  them, so a one-shot aws-cli container allows the frontend origin to
  upload files straight to the bucket.
2026-09-29 10:52:49 +02:00
briquet 62a2515c6b ✅(summary) test the S3 FileClient service
Ensure that the new combination of boto + Garage work well
together and honor the region and secure parameters.
2026-09-29 10:52:49 +02:00
briquet fa9b30c6bf ♻️(agents) replace the minio client by boto3
Switch from the minio client to the boto3 python client, in the metadata
collector agent and the summary service. AWS_S3_REGION_NAME is passed
as-is to boto3, the region is not looked up from the bucket.
2026-09-29 10:52:49 +02:00
lebaudantoine 4d9ee4e9c5 🔧(devx) call summary v2 without trailing slash from dev backend
In the dev stack, configure the backend to call the summary service
using its v2 API by default.

Also strip the trailing `/` from the task endpoint, which was
triggering a redirect on every call.
2026-09-29 10:40:28 +02:00
lebaudantoine 72cd5a8f18 🔥(github) remove local GitHub PR and issue templates
The organization now provides default GitHub templates for pull
requests and issues at the org level, so individual repositories no
longer need to duplicate the same Markdown files.

Delete the local templates so this project uses the organization
defaults, reducing the amount of code we maintain and centralizing
the practice across repositories.
2026-09-28 17:03:41 +02:00
lebaudantoine 21dc63b8ce 🔖(patch) bump release to 1.32.1 2026-09-25 16:47:35 +02:00
lebaudantoine 8d5d42cfdb 🔒️(agent) fix CRITICAL CVE-2026-63072 / CVE-2026-63073 in libssl3t64
Address the following CVEs reported by Trivy on the LiveKit agent
image against `libssl3t64` 3.5.7-1~deb13u2:

* CVE-2026-63073 — CRITICAL (CVSS 9.8)
* CVE-2026-63072

Bump `libssl3t64` to the patched version to pick up both fixes.
2026-09-25 16:40:39 +02:00
lebaudantoine 2480a76b62 🔒️(backend) fix CVE-2026-73228 and CVE-2026-73229 in drf
Address the following MEDIUM severity CVEs reported against
`djangorestframework` 3.17.1:

* CVE-2026-73228 (CVSS 5.3)
* CVE-2026-73229 (CVSS 4.3)

Bump `djangorestframework` to the patched version to pick up the
fixes.
2026-09-25 16:40:39 +02:00
lebaudantoine 31c8f3ec06 🔖(minor) bump release to 1.32.0 2026-09-25 15:18:15 +02:00
snyk-bot a8c4aee0c2 ⬆️(addons) upgrade i18next from 26.4.0 to 26.4.2
Snyk has created this PR to upgrade i18next from 26.4.1 to 26.4.2.

See this package in npm:
i18next

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/af693e79-8c43-4c09-ab65-60580515c9e8?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-09-25 11:56:59 +02:00
Lebaud Antoine 9a2ad63524 🔥(backend) remove unused API viewset and permission helpers
Dead code elements spotted by @briquet.
2026-09-24 23:08:00 +02:00
lebaudantoine 67f9e54784 🔒️(frontend) fix HIGH CVE-2026-93990 in libexpat
Bump `libexpat` from 2.8.4-r0 to 2.8.5-r0 to address the following
HIGH severity CVE, reported by Trivy on the frontend image
(alpine 3.24.1):

* CVE-2026-93990 — expat: XML injection via malformed UTF-16
  input.

  https://avd.aquasec.com/nvd/cve-2026-93990
2026-09-24 18:05:32 +02:00
lebaudantoine 5d3255ddbc 🔇(backend) drop warning log when room metadata is updated
Most call sites of `update_metadata` already wrap the call in a
try/except that logs the failure at info level.

Remove the warning log inside `update_metadata` itself to avoid
redundant logs, without losing any information.
2026-09-24 18:05:32 +02:00
leo d89b01b681 🐛(recording) handle FAILED and ABORTED LiveKit egresses
`EGRESS_ABORTED` and `EGRESS_FAILED` events were previously ignored, leaving
recordings indefinitely in `ACTIVE` state and potentially blocking subsequent
recordings with 409 errors. Add handling and logging for failed and aborted
egresses, discarding failed recordings while preserving the existing behavior
for savable recordings.

Rename `handle_complete` to `handle_savable` to reflect that it handles both
`EGRESS_COMPLETE` and `EGRESS_LIMIT_REACHED`.

Slight refactor to separate LiveKit event handling from recording concerns as
part of a general separation concern to allow for future SFU swapping.

NB:
- FAILED recordings are currently discarded although exploitable media files
may exist
- There is a theoretical hole: if stop observes EGRESS_FAILED before the
egress_ended webhook is processed, the recording is immediately marked as
FAILED. Since only ACTIVE and STOPPED recordings are savable, the webhook
then skips the failure notification and LiveKit error log. In that rare race
condition, participants may therefore not see the failure toast. We accept
this trade-off for now, as this should be very infrequent.
- Another theoretical hole: There is a short race window where the user
clicks stop while the limit-reached status is being processed. Since the user
explicitly requested the stop, we consider skipping the limit notification
acceptable and do not handle this case.

fix(recording): log aborted worker events at info level
2026-09-24 18:05:32 +02:00
briquet 660c0ed684 🔒️(backend) upgrade base image to python:3.13.15-alpine3.24
Fixes an XML injection attack in libexpat (CVE-2026-93990)
2026-09-24 17:59:24 +02:00
lebaudantoine 8d980192c8 🚸(frontend) inform user that recording waits until a track is published
When a user starts a recording or a transcription while no track is
published yet, the recording stays in a "starting" state until an
appropriate track is available.

Show an explicit message on start explaining that the recording
will remain in "starting" state until a track of the required type
is published. The expected track type depends on the recording
type (audio-only vs. audio + video).

This situation was generating a lot of support requests, with users
asking why the recording did not actually start.
2026-09-24 00:24:40 +02:00
Ovgodd de1f7158f5 📝(changelog) shorten the reception resolution entry
The changelog check fails at 80 columns. Dropping "the" keeps
the reception resolution entry under that limit.
2026-09-22 16:21:00 +02:00
Ovgodd 6e17c6533c ♿️(frontend) use i18n strings for screen share wheel zoom shortcuts
Use i18n strings for displaying screen share zoom shortcuts in the UI controls.
2026-09-22 16:21:00 +02:00
Ovgodd 27e32c0370 ♿️(frontend) add keyboard navigation to screen share zoom toolbar
Arrows move between controls instead of panning, w/ en/fr/nl/de hint update.
2026-09-22 16:21:00 +02:00
Cyril 6d4403d4fa ♻️(frontend) refactor screen share zoom pan with useMove
use react-aria useMove for pan, zoom/pan refs for DOM updates,
2026-09-22 16:21:00 +02:00
Cyril 37ae308825 ♿️(frontend) add wheel zoom shortcut hints to screen share controls
Show Ctrl/Cmd+scroll zoom shortcut in tooltips, aria, SR hints, w/ en/fr/nl/de.
2026-09-22 16:21:00 +02:00
Cyril 16fd2dc4e8 💄(frontend) improve screen share zoom toolbar sizing and containment
Slightly enlarge toolbar controls while clipping hover states
inside the pill, so buttons no longer overflow the bar.
2026-09-22 16:21:00 +02:00
Cyril 9791a8a3b2 💄(frontend) use distinct expand/collapse icons for fullscreen actions
Replace fullscreen icons with expand-diagonal-line and collapse-diagonal-line
2026-09-22 16:21:00 +02:00
Cyril 5b0dece79b 🌐(frontend) add i18n keys for screen share zoom controls
English and French labels, SR announcements and pan navigation hint.
2026-09-22 16:21:00 +02:00
Cyril 96135a0263 ✨(frontend) add zoomable screen share video component
Wraps VideoTrack with zoom/pan, keyboard nav and screen reader announcements.
2026-09-22 16:21:00 +02:00
Cyril ce2e2a4d64 ✨(frontend) add ScreenShareZoomControls toolbar component
Bottom-right toolbar with zoom, fit-to-window and fullscreen buttons.
2026-09-22 16:21:00 +02:00
Cyril e5dc9c2f15 ✨(frontend) add useScreenShareZoom hook for zoom and pan
Manages zoom level, pan offset, wheel zoom, drag-to-pan and keyboard panBy.
2026-09-22 16:21:00 +02:00
kaelvar e97eab9b5e 🐛(frontend) apply the saved reception resolution when joining a meeting
`VideoResolutionSubscription` applied the saved reception resolution on
`RoomEvent.TrackPublished`. livekit-client does not raise that event for cameras
that were already sending when the local participant joined, so a user who had
chosen Low definition still received High definition from everyone already in
the meeting, and Low definition only from whoever joined after them. Nothing in
the UI showed the discrepancy: the setting kept displaying Low definition.

Apply the preference to the publications we already know about when the effect
runs, and keep listening on `TrackPublished` — which stays the earliest point to
cap a camera that starts after us — plus `TrackSubscribed`, which is the first
event raised for the cameras that were already sending.

That initial pass also covers a change of preference mid-call, which
`VideoTab.updateExistingRemoteVideoQuality` was doing separately. Removed, it is
now the same code path for joining and for changing the setting.

The three entry points overlap on purpose; the `publication.videoQuality` guard
makes the repeats free. It reads as High definition when nothing was ever
requested, so the default case costs no signal round trip either.

Fixes #1606.
2026-09-22 15:07:47 +02:00
lebaudantoine 436b3dc9df 🔖(helm) release chart 0.0.28 2026-09-22 13:43:04 +02:00
briquet 6ea2a85810 🚑️(summary) serve health endpoints with the dockerflow router
The dockerflow package ships with a FastAPI router to serve healthcheck
endpoints. A specific Redis test was activated to check the broker.
2026-09-22 12:00:20 +02:00
briquet 3d1ea88e8f ✅(backend) test the dockerflow health endpoints
Test kubernetes probes liveness/readiness endpoints
2026-09-22 12:00:20 +02:00
briquet beb74af574 🔊(backend) change the dockerflow logger level to WARNING
We want the liveness/readiness failure reason to show explicitly in logs
2026-09-22 12:00:20 +02:00
briquet c785b4a627 ♻️(backend) serve the dockerflow views early in the middleware stack
`DockerflowMiddleware` serves the endpoints `/__heartbeat__`, `/__lbheartbeat__
that we use for the kubernetes probes. Sitting at the bottom of
MIDDLEWARE, every Kubernetes probe traversed all middlewares  which is not
efficient.
2026-09-22 12:00:20 +02:00
briquet a9a4246abb 🐛(helm) swap liveness and readiness probes
The backend and summary probes had the two Dockerflow endpoints the wrong way
round. `/__lbheartbeat__` returns an unconditional 200 as soon as the server is
up and touches no dependency, while `/__heartbeat__` runs the Dockerflow checks
and answers 500 when one of them errors.

Wired as they were, a database error made `/__heartbeat__` fail on every
backend pod at once, restarting them all. Since a restart cannot fix a
database outage, it's better to use these check on the readiness probe
and start routing traffic when the database is reachable.

- Probe liveness on `/__lbheartbeat__` and readiness on `/__heartbeat__`
- Add a startup probe on `/__lbheartbeat__`, polled every 5s with a
  `failureThreshold` of 12, leaving the pod a minute to boot
- Drop `initialDelaySeconds` from liveness and readiness, now that the startup
  probe holds them off until the server answers
- Set `timeoutSeconds` to 5s on every probe, up from the 1s Kubernetes default
- Set the readiness `failureThreshold` to 3
2026-09-22 12:00:20 +02:00
briquet 3cf7f60eaa 🐛(helm) render periodSeconds and failureThreshold on probes
The `meet.probes.abstract` helper was missing `periodSeconds` block which means
Kubernetes fell back to its 10s default instead of the chart value.
It's now possible to configure the  `failureThreshold` and `successThreshold`.
2026-09-22 12:00:20 +02:00
snyk-bot bf215f1513 ⬆️(addons) upgrade i18next from 26.4.0 to 26.4.1
Snyk has created this PR to upgrade i18next from 26.4.0 to 26.4.1.

See this package in npm:
i18next

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/af693e79-8c43-4c09-ab65-60580515c9e8?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-09-22 10:43:55 +02:00
lebaudantoine 75836fc817 ⬆️(devx) update the MinIO image on the Tilt stack
Bump the MinIO image used by the Tilt dev stack to a more recent
version, since the previous public image we relied on was removed.
2026-09-19 20:54:42 +02:00
briquet 1affe65b4a 🔧(dev) configure bureautix proxy for image builds
Builds through the Docker API of the Podman service receive none of the
proxy variables in their RUN steps and fail systematically because of
the proxy rejection. Plain HTTP connections are also rejected by the
proxy with a HTTP 405 method error.

- Passes http_proxy, https_proxy and no_proxy from the shell as build args
- Make the Debian mirror of the agents image a build argument and
  override it for bureautix to force https usage
2026-09-18 16:10:31 +02:00
briquet c34ecbd3ef 🔧(dev) remove the unused bin/compose wrapper
Nothing in the repository nor the CI calls it
2026-09-18 15:17:58 +02:00
briquet 78960d9769 🔧(dev) use a dedicated folder for docker compose overrides
Move extra compose files to docker/compose.d folder
2026-09-18 15:17:51 +02:00
briquet 41d07d36ee 🔧(dev) use port 8081 for keycloak admin on bureautix workstations
The Bureautix workstation proxy listens on 8080, which collides with
Keycloak's published admin port.
2026-09-18 15:17:44 +02:00
Briquet f7386e1741 🔧(dev) add a devenv shell for nix-based workstations
Add the minimal requirements to build and run the project locally on NixOS

- `devenv update` update devenv using NixOS 26.05 stable repositories
- `devenv shell` activates the devenv
- `devenv --profile <profile>` shell uses additional packages when
  activated (profile=agent|summary|k8s)
2026-09-18 15:17:36 +02:00
briquet f1da04eb27 🔧(podman) pin the livekit rtc section for local usage
Pin the LiveKit rtc section: the browser reaches the server through
podman's published ports on loopback while egress and the agents reach
it over the podman network, and those two have no address in common.

`advertise_internal_ip` keeps the container's own interface address as a
host candidate alongside the node_ip one, so LiveKit offers both and ICE
picks whichever works. Without it egress only ever sees 127.0.0.1, which
is the egress container itself, and its peer connection timeouts

`use_external_ip` is turned off since it would advertise the STUN-discovered
public IP, which no local peer can hairpin to.
2026-09-18 15:17:29 +02:00
briquet 2970420b84 🔧(podman) make the dev stack work with rootless podman
Rootless podman maps container UID 0 to the host user and every other
container UID to a subuid that owns nothing in the worktree, so the usual
DOCKER_USER=$(id -u):$(id -g) makes every bind mount effectively
read-only.

Add a compose.podman.yml to override the compose.yml and set
`userns_mode: keep-id` in order to map the host user to the same UID and UID
inside the container. The merge of the docker compose file is now done with
the COMPOSE_FILE environment variable
2026-09-18 15:17:21 +02:00
tanguy chenier 771f58c0aa 🐛(frontend) play the waiting room notification sound on every arrival
The waiting room has its own sound in notifications.mp3, and nothing could play
it: the sprite is named "waiting" while triggerNotificationSound passes a
NotificationType, and howler returns without playing when the sprite id is
unknown. ParticipantWaiting was also absent from the sound settings, so the
check on the store would have refused it first. The toast borrowed the
participant joined sound instead.

The sound was tied to the waiting list going from empty to non empty, so a
second person arriving while someone was still waiting was silent, which is the
case the issue describes.

Name the sprite after the notification type, register the type in the settings,
and sound every arrival, detected on the participant ids so that an admission
and an arrival between two refreshes do not cancel each other out.

closes #1705
2026-09-17 17:06:06 +02:00
briquet b159b20695 🐛(backend) read the Sentry release from pyproject.toml
get_release() read the version from a version.json file  but nothing generates
it during the CI Docker image build, therefore the release reported to Sentry
was always "NA".

Read the version from pyproject.toml instead, which is bumped at each
release and copied into the image.
2026-09-16 15:54:40 +02:00
leo 226d004838 ✅(agents) fix subtitle test
Result of test for display of subtitles was depending on local
env config, potentially failing. Fix this by overriding settings.
2026-09-16 14:20:44 +02:00
lebaudantoine b723b7bb62 🐛(frontend) fix file permissions in the Docker image
Incorrect file permissions in the frontend Docker image caused
problems when running the project, and were surfaced by @briquet
while setting it up with Podman.

Adjust the ownership and permissions applied during the build so
the image works cleanly under Docker and Podman alike.
2026-09-15 00:01:22 +02:00
lebaudantoine cb36df9104 🔧(devx) pull the MinIO image from quay.io
The quay.io/minio/minio mirror remains publicly available, accepts the
same environment variables.
2026-09-14 20:38:44 +02:00
snyk-bot d85123d0c5 ⬆(frontend) upgrade humanize-duration from 3.33.2 to 3.34.1
Snyk has created this PR to upgrade humanize-duration from 3.33.2 to 3.34.1.

See this package in npm:
humanize-duration

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-09-14 15:40:56 +02:00
snyk-bot b2abf814fa ⬆️(addons) upgrade i18next from 26.3.6 to 26.4.0
Snyk has created this PR to upgrade i18next from 26.3.6 to 26.4.0.

See this package in npm:
i18next

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/af693e79-8c43-4c09-ab65-60580515c9e8?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-09-14 09:10:04 +02:00
Miguel Victoria cfdf1c2f92 ✨(backend) add default video codec on apiConfig struct
Co-authored-by: David <60177543+davd-gzl@users.noreply.github.com>
2026-09-14 09:03:05 +02:00
Michel-Marie Maudet 4139f542d3 🔧(ci) pull the MinIO image from quay.io
Docker Hub now denies anonymous pulls of minio/minio (pull access
denied), which fails the test-back job for every pull request. The
quay.io/minio/minio mirror remains publicly available, accepts the
same environment variables, and the container lookup in the Configure
MinIO step still matches the image name.

thx @mmaudet
2026-09-14 07:43:02 +02:00
snyk-bot eda66640df ⬆️(frontend) upgrade posthog-js from 1.414.0 to 1.418.10
Snyk has created this PR to upgrade posthog-js from 1.414.0 to 1.418.10.

See this package in npm:
posthog-js

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-09-14 07:38:10 +02:00
snyk-bot 3fa05ea785 ⬆️(frontend) upgrade react-i18next from 17.0.10 to 17.0.12
Snyk has created this PR to upgrade react-i18next from 17.0.10 to 17.0.12.

See this package in npm:
react-i18next

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-09-14 07:03:25 +02:00
lebaudantoine 30b68052e1 ⚡️(frontend) defer loading the Crisp script until idle
Load the Crisp JavaScript module only once the frontend is idle,
instead of during the initial page load.

Keeps the critical path lighter and prevents Crisp from competing
with the app's own bootstrap for network and CPU on slow devices.
2026-09-13 00:03:55 +02:00
lebaudantoine 04fd79b56b 🔒️(backend) reject inactive users in resource server backend
The resource server backend returned any user matching the token's
`sub` claim without checking `User.is_active`. The upstream lasuite
backend only validates the token's introspection `active` claim, so a
deactivated Django account kept API access until its token expired.

Raise `SuspiciousOperation` in `get_or_create_user` when the user is
inactive, which the authentication class turns into a 401, consistent
with `BaseJWTAuthentication`. Add unit and end-to-end tests.
2026-09-10 11:10:53 +02:00
leo e336122cfa 💬(frontend) clarify video recording wording
Video recording from transcription panel did not explicitly
mention video, leading to confusion from some users. Make
wording more explicit.
2026-09-09 20:03:36 +02:00
lebaudantoine 172dc70649 ✨(backend) allow configuring trace sampling
Add a configuration knob for the trace sampling rate, so we can
enable tracing on middleware and cache spans when debugging slow
requests in production.

Sampling is set to 0 by default, so tracing stays fully off unless
explicitly enabled.
2026-09-09 20:02:57 +02:00
lebaudantoine e0ab7f191f 📈(frontend) include LiveKit SIDs in the connection analytics event
Attach the LiveKit SIDs (room and participant) to the connection
analytics event.

Makes it easier to debug problematic sessions and to correlate a
room session with the corresponding LiveKit logs.
2026-09-09 13:38:44 +02:00
lebaudantoine 455b315dbb 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
Around 0.76% of incoming LiveKit webhooks were being flagged as
unprocessable and returned a 422, even though LiveKit was sending
legitimate data — just with event types we do not handle. This
inflated error metrics and made real webhook issues harder to spot.

Return a 200 for these webhooks instead. When a new, unhandled
event type shows up, log a warning so we can decide whether it is
worth adding explicit handling.
2026-09-09 12:09:13 +02:00
lebaudantoine 3089b03062 🔇(backend) silence noisy request summary info logs
The request summary info logs were spamming the log stream, making
around 46% of the total volume, without carrying any exploitable
information.

Silence them so the remaining logs are easier to explore and cheaper
to store; roughly halves the overall log volume.
2026-09-09 11:17:49 +02:00
lebaudantoine 60febb3b57 🔇(backend) silence expected 401 warnings on /me
On a busy morning, `/me` alone produced 72k warning logs — 97% of
all warnings. They all come from anonymous requests to `/me`
without credentials, which is normal: `/me` is how the app
determines the current auth status.

These warnings carry no diagnostic value on this endpoint, so
silence them there to cut down on log volume.
2026-09-09 11:17:49 +02:00
lebaudantoine bf76ab1ddf 🔒️(backend) enforce display name setting on rename API
AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME was only enforced at
LiveKit token generation and by hiding the name field in the frontend.
The `rooms/{id}/rename/` endpoint never checked it, so any authenticated
user with a valid room token could rename themselves via the API even
when the self-hoster had disabled it.

Return 403 from the rename action for authenticated users when the
setting is disabled, mirroring the `can_edit` rule in
`core.utils.generate_token`. Anonymous participants are unaffected, as
they have no account name to fall back on.

Add tests covering the disabled/enabled cases for authenticated users
and the anonymous exception.
2026-09-08 01:26:27 +02:00
lebaudantoine 7565ede0a7 🔖(minor) bump release to 1.31.0 2026-09-08 00:45:01 +02:00
lebaudantoine 1a15e9f44e ✨(frontend) align feedback buttons with rating card
Match the button row width to the rating card (100%, max 410px) and
make both buttons share it equally so their edges line up with the card.
2026-09-07 23:55:05 +02:00
lebaudantoine 7838d8acfe 🐛(frontend) refetch waiting participants when the lobby becomes disabled
When the lobby is disabled mid-meeting (e.g. the room is switched to
public), the waiting participants list stopped being refetched, so
the previously cached list stayed visible with stale data.

Trigger a refetch in that case as well, so the list is cleared and
the moderator UI no longer shows waiting participants for a lobby
that is no longer active.
2026-09-07 23:30:27 +02:00
lebaudantoine 3bb388b937 ✨(backend) sort waiting participants by their arrival time
Highlighted by a suggestion from @florent, the waiting participant
list was not sorted, so moderators could see participants in an
arbitrary order.

Add an explicit `entered_at` attribute on each waiting participant,
so the list can be sorted by arrival time. Participants are now
shown in a stable order of arrival, both across polls and across
moderators.
2026-09-07 23:30:27 +02:00
lebaudantoine e1cc8105db 💄(frontend) position the login hint dynamically next to the button
Compute the position of the login hint at render time so it is
always displayed close to the login button, regardless of the
button's placement or the current viewport size.
2026-09-07 20:12:57 +02:00
lebaudantoine 7844dfcc12 📈(frontend) track missing lobby participant on accept/reject
When a moderator accepts or rejects a lobby entry that no longer
exists, emit a tracking event so we can measure how often it
happens.

This signal will help tune the lobby polling interval: too many
"not found" events means the moderator side is working from a stale
list. Keep raising the error to the client on top of tracking it,
so the frontend still surfaces the issue (its current handling of
this case is still incomplete).
2026-09-07 20:12:57 +02:00
lebaudantoine ef71003721 🔊(backend) log request duration in Gunicorn workers
Include the time taken by each request in the Gunicorn worker
access logs, so we can spot slow endpoints and correlate latency
patterns directly from the logs.
2026-09-07 20:12:57 +02:00
lebaudantoine f74d23c57e ⚡️(backend) refactor presence cache to bound key lookups per room
The previous presence cache lookup keyed off a scan over the whole
cache, so its cost was O(db_size) rather than O(room_size).
Combined with the recent switch to cursor-based `SCAN` at an
inappropriate page size, this caused a lot of Redis round-trips and
noticeably slowed down the backend pods under load.

Refactor the presence cache to keep a per-room set of all its
participant keys. Lookups now iterate that set instead of scanning
the whole database.

Complexity is now bounded by room size, not database size, which
should restore the backend performance to its previous levels while
keeping the lobby behavior unchanged.
2026-09-07 20:12:57 +02:00
lebaudantoine acedb21045 ⚡️(backend) refactor lobby storage to bound key lookups per room
The previous lobby lookup keyed off a scan over the whole cache, so
its cost was O(db_size) rather than O(room_size). Combined with the
recent switch to cursor-based `SCAN` at an inappropriate page size,
this caused a lot of Redis round-trips and noticeably slowed down
the backend pods under load.

Refactor the lobby storage to keep a per-room set of all its lobby
keys. Lookups now iterate that set instead of scanning the whole
database:

* Membership in the set acts as a memory of who is supposedly in
  the lobby for a given room.
* Individual keys are then read to check who is actually still
  waiting or accepted.

Complexity is now bounded by room size, not database size, which
should restore the backend performance to its previous levels while
keeping the lobby behavior unchanged.
2026-09-07 20:12:57 +02:00
lebaudantoine 67e7d382e3 ⚡️(frontend) add trailing slash on the /me endpoint call
The `/me` endpoint was called without a trailing slash, so every
request was going through a 301 redirect before hitting the actual
endpoint.

This endpoint is called by every user at least once per session, so
based on the logs, avoiding the redirect should cut the volume of
requests hitting it by around 10%.
2026-09-07 20:12:57 +02:00
lebaudantoine 164ac8d948 ⚡️(frontend) increase lobby polling interval on both sides
Increase the polling interval used by the lobby feature, on both the
waiting participant side and the moderator side.

The goal is to reduce the volume of requests the lobby generates,
trading a bit of data freshness for better performance.

It will de facto reduce pressure on the backend.

We will observe the impact in production, and revisit these
intervals if the delays turn out to be too aggressive.
2026-09-07 20:12:57 +02:00
lebaudantoine e3deb37fbe 🔒️(frontend) upgrade base image to 1.30.4-alpine3.24
Bump the frontend base image to `1.30.4-alpine3.24`, which picks up
fixes for the CVEs listed below and lets us drop the individual
dependency pins that were only there to address earlier known CVEs.

Address the following HIGH severity CVEs in libuuid / util-linux,
reported by Trivy. Bumping to 2.41.6-r1 (bundled in the new base
image) covers all of them:

* CVE-2026-53612 — TOCTOU in mount post-mount ownership/mode
  changes.
* CVE-2026-53613 — TOCTOU in mount via ancestor directory swap.
* CVE-2026-53614 — SUID mount(8) nosuid/noexec bypass via
  LIBMOUNT_FORCE_MOUNT2.
* CVE-2026-76642 — failed external mount helper still runs
  privileged X-mount post-hooks.
* CVE-2026-78408 — nsenter --join-cgroup leaks root cgroup
  migration authority (fixed in 2.41.6-r1).
* CVE-2026-78410 — restricted bind mounts do not pin the source,
  allowing X-mount.owner/group/mode escalation.
2026-09-07 16:40:03 +02:00
lebaudantoine 33929324d0 🐛(frontend) keep feedback buttons on one line for fr/es/en
A minor layout regression appeared when switching to the Marianne
font: the feedback buttons wrapped onto two lines instead of
staying on one.

Adjust the layout so the buttons stay on a single line regardless
of the font in use.
2026-09-07 16:40:03 +02:00
lebaudantoine adc74f846c 🐛(frontend) center Avatar initials with a font-aware cap-height ratio
The previous implementation centered initials by measuring `<text>`
with `getBBox()`, which returns the font's advance-width by
ascent-to-descent band, not the ink of the glyphs. On fonts with an
asymmetric band, initials rendered off-center. Marianne is a
particularly clear case: ascent 1131 / descent 256 puts the band
center 87.5/1000 above the caps' optical center, so every avatar
sat ~4.6 viewBox units (~1.5–1.8 px) too low. A follow-up rewrite
using canvas `actualBoundingBox*` metrics fixed it but pulled in a
runtime measurement rig (shared canvas, ref, state, layout effect,
`fonts.load` + `loadingdone`, plus combining-mark stripping) just
to place two uppercase letters.

Since initials are always uppercased, optical centering has a
closed form: `baseline = center + capHeight/2`. Real-world text
fonts have cap heights in a narrow ~0.66–0.73 em band (Marianne is
0.70), so:

    translateY(calc(var(--avatar-cap-height, 0.7) * 0.5em))

is exact for the stock font and within ~0.4 px for any plausible
replacement. No JS, SSR-safe, no first-paint jump, no font-loading
race. Accents float above the cap box instead of dragging the
letter down.

The single font-dependent number remaining (cap height) is exposed
as a CSS variable, so self-hosters overriding the font can override
it next to the font itself, or leave the default. Once
`text-box: trim-both cap alphabetic` ships broadly, even the
variable can go.
2026-09-05 10:43:22 +02:00
lebaudantoine 78ba03a52b 🐛(frontend) restore automatic lower-hand on speaking
Following the re-rendering optimization refactoring, the automatic
lower-hand feature broke: the way `isSpeaking` was read no longer
made sense once we limited how often components in the app
re-render.

Fix the detection so the raised hand is again lowered automatically
when the participant starts speaking, without relying on frequent
re-renders.
2026-09-05 00:29:50 +02:00
lebaudantoine ac4be27445 🐛(backend) allow all printable ASCII in the user sub field
The user `sub` field was rejecting some ASCII characters that are
actually valid according to the OIDC spec.

Loosen the validation to accept the full ASCII range except control
characters, so the field is compliant with the RFC and works with
any spec-compliant identity provider.

Based on the Stack Overflow discussion in question 279832.

Closes #1609.
2026-09-03 17:32:31 +02:00
leo eb6b3ba1df ✨(backend) update a room's attributes from the external API
Update a room's access level and/or configuration using PATCH from the
external API. Log modifications and send to analytics.
2026-09-03 17:04:07 +02:00
leo 8473069670 ⬆️(docker) upgrade LiveKit server to v1.13.6
The compose stack referenced livekit/livekit-server without a tag,
which resolved to :latest. Docker doesn't re-resolve a mutable tag it
already holds locally, so images earlier than v1.12.0 crashed
on startup:

    could not parse config: yaml: unmarshal errors:
      line 20: field allow_restricted_peer_cidrs not found in
      type config.TURNConfig

LiveKit parses its config in strict mode, and allow_restricted_peer_cidrs
only exists since v1.12.0. The TURN block added in bd81c994 therefore
carried an minimum version which was not reflected.

The Tilt/Helm stack builds its own image from docker/livekit/Dockerfile
to inject the mkcert root CA, and that was still based on v1.9.4. Bump
it to the same version so both dev stacks run the same server.
2026-09-03 00:16:04 +02:00
Paul Csiki cf3960db95 ✨(backend) add Traefik reverse proxy support for media-auth
Adds support for serving media behind Traefik, which currently cannot work
at all.

The media-auth subrequest views read the original request URL from a
hardcoded HTTP_X_ORIGINAL_URL header. That header is an nginx-ingress
convention. Traefik's ForwardAuth middleware sends X-Forwarded-Uri instead
and has no mechanism to emit X-Original-URL, so behind Traefik every
recording download and file attachment is rejected with a bare 403 --
indistinguishable from a legitimate permission denial, which makes it
painful to diagnose.

Add MEDIA_AUTH_ORIGINAL_URL_HEADER, defaulting to HTTP_X_ORIGINAL_URL so
existing nginx-ingress deployments are unaffected. Traefik deployments set
it to HTTP_X_FORWARDED_URI. It is used in both places that resolve the
header: RecordingViewSet._auth_get_original_url and the file attachment
_authorize_subrequest. The log message on a missing header now names the
header actually expected, which is what makes the failure diagnosable.

This mirrors the setting the sibling Docs project already exposes
(suitenumerique/docs, MEDIA_AUTH_ORIGINAL_URL_HEADER) for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 18:07:23 +02:00
lebaudantoine d80d31897c 🔒️(frontend) fix HIGH CVEs in libexpat 2.8.2-r0
Address the following HIGH severity CVEs in libexpat 2.8.2-r0,
reported by Trivy:

* CVE-2026-66046
* CVE-2026-76641
2026-09-02 15:05:01 +02:00
kaelvar 63a7751072 ✨(frontend) add 1080p sending resolution option
The sending resolution selector stopped at 720p while `VideoPresets` already
exposes `h1080` (1920x1080), so publishers on a good uplink could not make use
of the capacity they had. Add "Very high definition (1080p)" above the existing
entries, translated in the five supported locales.

The default stays `h720`, so nothing changes unless a user goes and picks the
new entry. Being explicit about what that costs, since 1080p roughly doubles a
publisher's uplink: this is a per-user choice, and an instance operator has no
way today to decline it. Whether that warrants a server-side setting alongside
the existing `ApiConfig` flags is a call for maintainers — happy to add one if
you want it, rather than change the API contract unasked in a frontend PR.

While here, make the option list harder to get wrong. Resolutions now come from
a single `VIDEO_RESOLUTIONS` tuple that `VideoResolution` derives from, the
selector items are built by mapping over it against a
`Record<VideoResolution, string>` of labels — so a resolution cannot be added
to one and forgotten in the other — and a persisted value that is not in the
tuple falls back to `h720` instead of reaching `VideoPresets[...]` as
`undefined`, since `loadUserChoices` spreads localStorage without validating
it.

Known limitation, unchanged by this patch: `restartTrack` passes the resolution
as an `ideal` constraint, so a camera that cannot reach the selected height
degrades silently. That is already true of 720p on a 480p webcam; 1080p is the
first step where the gap is the common case rather than the edge one.
2026-09-02 15:05:01 +02:00
kaelvar 1ac1778521 🐛(frontend) keep the sending resolution picked while the camera is off
`handleVideoResolutionChange` did all of its work inside `if (videoTrack)`,
including `saveVideoPublishResolution`. With the camera off there is no camera
publication, so choosing a resolution did nothing at all: it was neither applied
nor recorded, while the selector went on showing the value the user had just
picked. Turning the camera back on then published at the old resolution, and so
did the next session.

Found on a self-hosted instance: a user set the sending resolution with the
camera off, turned it back on, and the publisher kept sending 720p. Nothing in
the UI suggested the choice had been dropped.

Persist the choice first and unconditionally, then restart the track only when
there is one to restart.

Persisting alone is not enough within a session. `roomOptions` is only read by
`new Room(...)`, so a store update never reaches a room that is already built.

Sync the VideoDeviceControl with the userChoiesStore resolution, as we did for
the device id and the processor configuration.

The early return is the honest shape here: with no live track there is nothing
to await, and the defaults above already cover what happens next.
2026-09-02 00:11:43 +02:00
lebaudantoine fcc58065d2 🩹(changelog) fix changelog entry ordering
Restore the correct order of entries in the CHANGELOG, which got
shuffled somewhere between rebases.
2026-09-01 22:08:37 +02:00
276 changed files with 16011 additions and 5029 deletions
+9
View File
@@ -0,0 +1,9 @@
[codespell]
# Files that are not English, or generated
skip = ./.git,*.pdf,*.po,*.pot,*.json,*.lock,package-lock.json,
./LICENSES,
./src/summary/summary/core/locales,
./src/summary/summary/core/prompt.py
# Valid words in French (connexion) or in the code (statics)
ignore-words-list = connexion,statics
check-filenames = true
+20
View File
@@ -0,0 +1,20 @@
# Debian 13 base image (python:3.14-slim): no fixed version available yet.
# Review regularly and remove entries once Debian ships a fix.
# util-linux
CVE-2026-76642
CVE-2026-78408
CVE-2026-78409
CVE-2026-78410
# acl
CVE-2026-54369
# ncurses
CVE-2025-69720
# systemd
CVE-2026-16742
# perl-base (fix deferred by Debian)
CVE-2026-9538
-28
View File
@@ -1,28 +0,0 @@
---
name: 🐛 Bug Report
about: If something is not working as expected 🤔.
---
## Bug Report
**Problematic behavior**
A clear and concise description of the behavior.
**Expected behavior/code**
A clear and concise description of what you expected to happen (or code).
**Steps to Reproduce**
1. Do this...
2. Then this...
3. And then the bug happens!
**Environment**
- Meet version:
- Platform:
**Possible Solution**
<!--- Only if you have suggestions on a fix for the bug -->
**Additional context/Screenshots**
Add any other context about the problem here. If applicable, add screenshots to help explain.
-23
View File
@@ -1,23 +0,0 @@
---
name: ✨ Feature Request
about: I have a suggestion (and may want to build it 💪)!
---
## Feature Request
**Is your feature request related to a problem or unsupported use case? Please describe.**
A clear and concise description of what the problem is. For example: I need to do some task and I have an issue...
**Describe the solution you'd like**
A clear and concise description of what you want to happen. Add any considered drawbacks.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Discovery, Documentation, Adoption, Migration Strategy**
If you can, explain how users will be able to use this and possibly write out a version the docs (if applicable).
Maybe a screenshot or design?
**Do you want to work on it through a Pull Request?**
<!-- Make sure to coordinate with us before you spend too much time working on an implementation! -->
@@ -1,22 +0,0 @@
---
name: 🤗 Support Question
about: If you have a question 💬, or something was not clear from the docs!
---
<!-- ^ Click "Preview" for a nicer view! ^
We primarily use GitHub as an issue tracker. If however you're encountering an issue not covered in the docs, we may be able to help! -->
---
Please make sure you have read our [main Readme](https://github.com/numerique-gouv/meet).
Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/meet/issues).
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
**Topic**
What's the general area of your question: for example, docker setup, database schema, search functionality,...
**Question**
Try to be as specific as possible so we can help you as best we can. Please be patient 🙏
-11
View File
@@ -1,11 +0,0 @@
## Purpose
Description...
## Proposal
Description...
- [] item 1...
- [] item 2...
+19
View File
@@ -0,0 +1,19 @@
name: Changelog Workflow
on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
jobs:
changelog:
uses: suitenumerique/ci/.github/workflows/_changelog.yml@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
+40 -205
View File
@@ -11,180 +11,30 @@ permissions:
contents: read
jobs:
lint-git:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' # Makes sense only for pull requests
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
fetch-depth: 0
- name: show
run: git log
- name: Enforce absence of print statements in code
if: always()
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude).github/workflows/**' | grep "print("
- name: Check absence of fixup commits
if: always()
run: |
! git log | grep 'fixup!'
- name: Install uv
if: always()
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Lint commit messages added to main
if: always()
run: uvx --no-build --from gitlint-core==0.19.1 gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
check-changelog:
runs-on: ubuntu-latest
if: |
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
github.event_name == 'pull_request'
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
lint-changelog:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
if [ $max_line_length -ge 80 ]; then
echo "ERROR: CHANGELOG has lines longer than 80 characters."
exit 1
fi
build-mails:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: "22"
- name: Restore the mail templates
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Install yarn
if: steps.mail-templates.outputs.cache-hit != 'true'
run: npm install -g --ignore-scripts yarn@1.22.22
- name: Install node dependencies
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile --ignore-scripts
- name: Build mails
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn build
- name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
lint-back:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install the project
run: uv sync --locked --all-extras
- name: Check code formatting with ruff
run: uv run --no-sync --no-build ruff format . --diff
- name: Lint code with ruff
run: uv run --no-sync --no-build ruff check .
- name: Lint code with pylint
run: uv run --no-sync --no-build pylint meet demo core
lint-agents:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/agents
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install the project
run: uv sync --locked --all-extras --no-build
- name: Check code formatting with ruff
run: uv run --no-sync --no-build ruff format . --diff
- name: Lint code with ruff
run: uv run --no-sync --no-build ruff check .
lint-summary:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/summary
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install the project
run: uv sync --locked --all-extras
- name: Check code formatting with ruff
run: uv run --no-sync --no-build ruff format . --diff
- name: Lint code with ruff
run: uv run --no-sync --no-build ruff check .
lint-python:
name: lint ${{ matrix.service }}
strategy:
fail-fast: false
matrix:
include:
- service: backend
working_directory: src/backend
pylint_targets: meet demo core
- service: agents
working_directory: src/agents
pylint_targets: ""
- service: summary
working_directory: src/summary
pylint_targets: ""
uses: suitenumerique/ci/.github/workflows/_python-lint.yml@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
with:
working_directory: ${{ matrix.working_directory }}
python_version: "3.13"
pylint_targets: ${{ matrix.pylint_targets }}
test-back:
runs-on: ubuntu-latest
needs: build-mails
permissions:
contents: read
defaults:
@@ -226,8 +76,9 @@ jobs:
REDIS_URL: redis://localhost:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_ACCESS_KEY_ID: meet-access-key
AWS_S3_SECRET_ACCESS_KEY: meet-secret-access-key
AWS_S3_REGION_NAME: local
OIDC_RS_CLIENT_ID: meet
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
@@ -243,41 +94,25 @@ jobs:
sudo mkdir -p /data/media && \
sudo mkdir -p /data/static
- name: Restore the mail templates
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Build or restore the mail templates
uses: suitenumerique/ci/actions/mail-templates@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
- name: Start MinIO
# Creates the access key and the bucket on startup
- name: Start Garage
run: |
docker pull minio/minio
docker run -d --name minio \
docker run -d --name garage \
-p 9000:9000 \
-e "MINIO_ACCESS_KEY=meet" \
-e "MINIO_SECRET_KEY=password" \
-v /data/media:/data \
minio/minio server --console-address :9001 /data
-v "${GITHUB_WORKSPACE}/docker/files/etc/garage/garage.toml:/etc/garage.toml:ro" \
-e "GARAGE_RPC_SECRET=$(openssl rand -hex 32)" \
-e "GARAGE_DEFAULT_ACCESS_KEY=meet-access-key" \
-e "GARAGE_DEFAULT_SECRET_KEY=meet-secret-access-key" \
-e "GARAGE_DEFAULT_BUCKET=meet-media-storage" \
dxflrs/garage:v2.4.1 \
/garage server --single-node --default-bucket
# Tool to wait for a service to be ready
- name: Install Dockerize
- name: Wait for Garage to be ready
run: |
curl --proto "=https" --proto-redir "=https" --tlsv1.2 -sSLf \
https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz |
sudo tar -C /usr/local/bin -xzv
- name: Wait for MinIO to be ready
run: |
dockerize -wait tcp://localhost:9000 -timeout 10s
- name: Configure MinIO
run: |
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
docker exec ${MINIO} sh -c \
"mc alias set meet http://localhost:9000 meet password && \
mc alias ls && \
mc mb meet/meet-media-storage"
timeout 30 sh -c 'until docker exec garage /garage health; do sleep 1; done'
- name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
@@ -310,9 +145,9 @@ jobs:
env:
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
AWS_S3_ENDPOINT_URL: "minio:9000"
AWS_S3_ACCESS_KEY_ID: "meet"
AWS_S3_SECRET_ACCESS_KEY: "password"
AWS_S3_ENDPOINT_URL: "garage:9000"
AWS_S3_ACCESS_KEY_ID: "meet-access-key"
AWS_S3_SECRET_ACCESS_KEY: "meet-secret-access-key"
WHISPERX_BASE_URL: "https://configure-your-url.com"
WHISPERX_ASR_MODEL: "large-v2"
WHISPERX_API_KEY: "test-whisperx-secret"
+14
View File
@@ -0,0 +1,14 @@
name: Project quality Workflow
on:
pull_request:
permissions:
contents: read
jobs:
quality:
uses: suitenumerique/ci/.github/workflows/_project-quality.yml@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
with:
print_check_paths: src/backend src/summary src/agents
codespell_ignore_words: "unsecure"
-33
View File
@@ -1,33 +0,0 @@
name: Download Crowdin translations
on:
workflow_dispatch:
types: [file-fully-translated]
permissions:
contents: write
pull-requests: write
jobs:
crowdin:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Download Crowdin files
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
with:
upload_sources: false
upload_translations: false
download_translations: true
localization_branch_name: l10n_crowdin_translations
create_pull_request: true
pull_request_title: "New Crowdin translations"
pull_request_body: "New Crowdin pull request with translations"
pull_request_base_branch_name: "main"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
CROWDIN_BASE_PATH: ${{ github.workspace }}
+49 -252
View File
@@ -1,5 +1,5 @@
name: Docker Hub Workflow
run-name: Docker Hub Workflow
name: Docker images
run-name: Docker images
on:
workflow_dispatch:
@@ -15,265 +15,62 @@ on:
permissions:
contents: read
env:
DOCKER_USER: 1001:127
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
DOCKER_CONTAINER_REGISTRY_NAMESPACE: lasuite
IS_MULTI_PLATFORM_BUILD: ${{ startsWith(github.ref, 'refs/tags/v') }}
BUILD_PLATFORMS: ${{ startsWith(github.ref, 'refs/tags/v') && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
jobs:
build-and-push-backend:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
-
name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
target: backend-production
platforms: ${{ env.BUILD_PLATFORMS }}
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-frontend-generic:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
-
name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
platforms: ${{ env.BUILD_PLATFORMS }}
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-frontend-dinum:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
-
name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
with:
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: ./docker/dinum-frontend/Dockerfile
target: frontend-production
platforms: ${{ env.BUILD_PLATFORMS }}
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-summary:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
-
name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
continue-on-error: true
with:
docker-build-args: '-f src/summary/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
docker-context: './src/summary'
-
name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: ./src/summary
file: ./src/summary/Dockerfile
target: production
platforms: ${{ env.BUILD_PLATFORMS }}
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-agents:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
-
name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: lasuite/meet-agents
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
continue-on-error: true
with:
docker-build-args: '-f src/agents/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
docker-context: './src/agents'
-
name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: ./src/agents
file: ./src/agents/Dockerfile
target: production
platforms: ${{ env.BUILD_PLATFORMS }}
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push:
name: ${{ matrix.service }}
strategy:
fail-fast: false
matrix:
include:
- service: backend
image_name: lasuite/meet-backend
context: .
file: ./Dockerfile
target: backend-production
- service: frontend
image_name: lasuite/meet-frontend
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
- service: frontend-dinum
image_name: lasuite/meet-frontend-dinum
context: .
file: ./docker/dinum-frontend/Dockerfile
target: frontend-production
- service: summary
image_name: lasuite/meet-summary
context: ./src/summary
file: ./src/summary/Dockerfile
target: production
- service: agents
image_name: lasuite/meet-agents
context: ./src/agents
file: ./src/agents/Dockerfile
target: production
uses: suitenumerique/ci/.github/workflows/_docker-publish.yml@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
with:
image_name: ${{ matrix.image_name }}
context: ${{ matrix.context }}
file: ${{ matrix.file }}
target: ${{ matrix.target }}
docker_user: "1001:127"
is_multi_platform: ${{ startsWith(github.ref, 'refs/tags/v') }}
should_push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
trivy_scan: true
trivy_ignore_files: ./.github/.trivyignore
secrets:
DOCKER_HUB_USER: ${{ secrets.DOCKER_HUB_USER }}
DOCKER_HUB_PASSWORD: ${{ secrets.DOCKER_HUB_PASSWORD }}
notify-argocd:
permissions:
contents: read
needs:
- build-and-push-frontend-generic
- build-and-push-frontend-dinum
- build-and-push-backend
- build-and-push-summary
- build-and-push-agents
- build-and-push
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
steps:
- uses: numerique-gouv/action-argocd-webhook-notification@cac2ee67896eb13e84e804f60c4271370424eaa8 # main
- uses: suitenumerique/ci/actions/argocd-webhook-notification@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
id: notify
with:
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
+7 -23
View File
@@ -1,33 +1,17 @@
name: Release Chart
run-name: Release Chart
name: Release Helm chart
on:
push:
branches:
- main
paths:
- src/helm/meet/**
permissions:
contents: read
jobs:
release:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
fetch-depth: 0
- name: Cleanup
run: rm -rf ./src/helm/extra
- name: Install Helm
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Publish Helm charts
uses: numerique-gouv/helm-gh-pages@2cf477ae49d7c70037ceb1685803f4f7bad9b981 # add-overwrite-option
with:
charts_dir: ./src/helm
linting: on
token: ${{ secrets.GITHUB_TOKEN }}
uses: suitenumerique/ci/.github/workflows/_release-helm-chart.yml@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
+21
View File
@@ -0,0 +1,21 @@
name: Security analysis
on:
push:
branches:
- main
pull_request:
branches:
- "**"
permissions: {}
jobs:
zizmor:
permissions:
contents: read
actions: read
security-events: write
uses: suitenumerique/ci/.github/workflows/_zizmor.yml@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
with:
config: .github/zizmor.yml
+5
View File
@@ -0,0 +1,5 @@
rules:
unpinned-uses:
config:
policies:
"suitenumerique/*": ref-pin
+3
View File
@@ -86,3 +86,6 @@ docker/livekit/rootCA.pem
# Frontend rollup-plugin-visualizer
/src/frontend/rollup-plugin-visualizer/*
# NixOS
.devenv
+129 -4
View File
@@ -8,11 +8,136 @@ and this project adheres to
## [Unreleased]
## [1.30.0] - 2026-09-01
### Added
- 🔒(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
- ✨(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) close side panel with Escape key #1507
- ✨(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
## [1.33.0] - 2026-09-30
### Added
- ✨(backend) purge rooms inactive for a configurable period
- 🔨(makefile) add targets to list and download files stored in Garage
### Changed
- ⬆️(backend) update python dependencies
- ⬆️(summary) update python dependencies
- ⬆️(agents) update python dependencies
- ♻️(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
- 🔒️(backend) fix critical and high CVEs in PyJWT
- ⚡️(frontend) disable posthog-js periodic feature flag reloads
## [1.32.1] - 2026-09-25
### Fixed
- 🔒️(backend) fix CVE-2026-73228 and CVE-2026-73229 in drf
- 🔒️(agent) fix CRITICAL CVE-2026-63072 / CVE-2026-63073 in libssl3t64
## [1.32.0] - 2026-09-25
### Added
- ✨(backend) make the LiveKit default video codec configurable
- 🔧(dev) add support for Bureautix workstations
- ✨(frontend) add screen share zoom controls #1498
### Changed
- 🔥(backend) remove unused API viewset and permission helpers
- 🔊(backend) pin the dockerflow logger level to WARNING
- 🚑️(summary) serve health endpoints with the dockerflow router
- ♻️(backend) serve the dockerflow views early in the middleware stack
- 📈(frontend) include LiveKit SIDs in the connection analytics event
- 🔇(backend) silence expected 401 warnings on /me
- 🔇(backend) silence noisy request summary info logs
- ⚡️(frontend) defer loading the Crisp script until idle
- ⬆️(frontend) upgrade react-i18next from 17.0.10 to 17.0.12
- ⬆️(frontend) upgrade posthog-js from 1.414.0 to 1.418.10
- ⬆️(addons) upgrade i18next from 26.3.6 to 26.4.0
- ⬆️(frontend) upgrade humanize-duration from 3.33.2 to 3.34.1
- ⬆️(addons) upgrade i18next from 26.4.0 to 26.4.2
- 🔖(helm) release chart 0.0.28
- ♻️(backend) decouple recording event handling from LiveKit egress statuses
### Fixed
- 🐛(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
- 🐛(frontend) apply saved reception resolution when joining a meeting #1714
- 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
- 🔒️(backend) enforce display name setting on rename API
- 🔒️(backend) reject inactive users in resource server backend
- 🐛(frontend) fix file permissions in the Docker image
- 🚸(frontend) inform user that recording waits until a track is published
- 🔒(backend) upgrade base image to python:3.13.5-alpine3.24
- 🐛(backend) handle failed and aborted egresses
- 🩹(frontend) notify participants when a recording fails or is aborted
- 🔒️(frontend) fix HIGH CVE-2026-93990 in libexpat
## [1.31.0] - 2026-09-08
### Added
- ✨(frontend) add 1080p sending resolution option #1660
- ✨(backend) add Traefik support via configurable media-auth url header #1649
- ✨(backend) update a room's attributes from the external API
- 🔊(backend) log request duration in Gunicorn workers
- 📈(frontend) track missing lobby participant on accept/reject
- ✨(backend) sort waiting participants by their arrival time
### Changed
- ⬆️(dev) pin LiveKit server to v1.13.6
- 🔒(frontend) upgrade base image to 1.30.4-alpine3.24
### Fixed
- 🐛(backend) allow any printable ASCII characters in user sub field #1673
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
- 🐛(frontend) restore automatic lower-hand on speaking
- 🐛(frontend) center Avatar initials with a font-aware cap-height ratio
- 🐛(frontend) keep feedback buttons on one line for fr/es/en
- ⚡️(frontend) increase lobby polling interval on both sides
- ⚡️(frontend) add trailing slash on the /me endpoint call
- ⚡️(backend) refactor lobby storage to bound key lookups per room
- ⚡️(backend) refactor presence cache to bound key lookups per room
- 💄(frontend) position the login hint dynamically next to the button
## [1.30.0] - 2026-09-01
### Added
@@ -29,6 +154,7 @@ and this project adheres to
- ⬆️(frontend) upgrade @fontsource/opendyslexic from 5.2.5 to 5.3.0
- ⬆️(addons) upgrade core-js from 3.49.0 to 3.50.0
- ♻️(backend) factorize s3 client creation in utils
- ♿️(frontend) close side panel with Escape key #1507
### Fixed
@@ -39,7 +165,6 @@ and this project adheres to
### Added
- ✨(any) let any authenticated user manage the lobby on trusted rooms
### Changed
- 📱(frontend) collapse mobile control bar items on narrow viewports
@@ -298,7 +423,7 @@ and this project adheres to
### Fixed
- ♿️(frontend) improve accessibilty of the Effects panel #1401
- ♿️(frontend) improve accessibility of the Effects panel #1401
## [1.20.0] - 2026-06-12
+3 -4
View File
@@ -1,7 +1,7 @@
# Django Meet
# ---- base image to inherit from ----
FROM python:3.13.5-alpine3.21 AS base
FROM python:3.13.15-alpine3.24 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip
@@ -37,14 +37,13 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- mails ----
FROM node:22 AS mail-builder
FROM node:22-alpine AS mail-builder
COPY ./src/mail /mail/app
WORKDIR /mail/app
RUN yarn install --frozen-lockfile && \
yarn build
RUN npm ci --ignore-scripts && npm run build
# ---- static link collector ----
+47 -1
View File
@@ -69,6 +69,22 @@ LINT_SUMMARY = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT)
# -- Frontend
PATH_FRONT = ./src/frontend
# -- Storage
GARAGE_BUCKET = meet-media-storage
STORAGE_FOLDERS = recordings transcripts summaries
STORAGE_DIRS = $(addprefix data/,$(STORAGE_FOLDERS))
COMPOSE_RUN_AWS = $(COMPOSE_RUN) --user $(DOCKER_USER)
AWS_CLI = garage-cors --endpoint-url=http://garage:9000
# Extensions listed in each folder (skips the Egress manifests in recordings/)
recordings_EXTENSIONS = mp4 ogg
transcripts_EXTENSIONS = json
summaries_EXTENSIONS = txt
# $(1): folder. Lists its objects with a known extension, most recent first
storage_list = s3api list-objects-v2 --bucket $(GARAGE_BUCKET) \
--prefix $(1)/
storage_query = reverse(sort_by(Contents[?$(foreach ext,$($(1)_EXTENSIONS), \
ends_with(Key, `".$(ext)"`) ||) `false`] || `[]`, &LastModified))
# ==============================================================================
# RULES
@@ -77,6 +93,9 @@ default: help
data/media:
@mkdir -p data/media
$(STORAGE_DIRS):
@mkdir -p $@
data/static:
@mkdir -p data/static
@@ -85,6 +104,7 @@ data/static:
create-env-files: ## Copy the dist env files to env files
create-env-files: \
env.d/development/common \
env.d/development/garage \
env.d/development/crowdin \
env.d/development/postgresql \
env.d/development/kc_postgresql \
@@ -292,7 +312,7 @@ shell: ## connect to database shell
# -- Database
dbshell: ## connect to database shell
docker compose exec app-dev python manage.py dbshell
@$(COMPOSE_EXEC_APP) python manage.py dbshell
.PHONY: dbshell
resetdb: FLUSH_ARGS ?=
@@ -317,12 +337,38 @@ env.d/development/summary:
env.d/development/kube-secret:
cp -n env.d/development/kube-secret.dist env.d/development/kube-secret
env.d/development/garage:
sed "s/^GARAGE_RPC_SECRET=.*/GARAGE_RPC_SECRET=$$(openssl rand -hex 32)/" \
env.d/development/garage.dist > env.d/development/garage
env.d/development/multi_user_transcriber:
cp -n env.d/development/multi_user_transcriber.dist env.d/development/multi_user_transcriber
env.d/development/metadata_collector:
cp -n env.d/development/metadata_collector.dist env.d/development/metadata_collector
# -- Storage
recordings-download-latest: ## download the latest recording from Garage into data/recordings
transcripts-download-latest: ## download the latest transcript from Garage into data/transcripts
summaries-download-latest: ## download the latest summary from Garage into data/summaries
$(STORAGE_FOLDERS:%=%-download-latest): %-download-latest: data/%
@key=$$($(COMPOSE_RUN_AWS) -T $(AWS_CLI) $(call storage_list,$*) \
--query '$(call storage_query,$*)[0].Key' --output text) && \
if [ "$$key" = "None" ]; then echo "No $* found"; exit 1; fi && \
$(COMPOSE_RUN_AWS) --volume $(CURDIR)/data/$*:/aws/data/$* \
$(AWS_CLI) s3 cp "s3://$(GARAGE_BUCKET)/$$key" data/$*/
.PHONY: $(STORAGE_FOLDERS:%=%-download-latest)
recordings-list: ## list recordings stored in Garage, most recent first
transcripts-list: ## list transcripts stored in Garage, most recent first
summaries-list: ## list summaries stored in Garage, most recent first
$(STORAGE_FOLDERS:%=%-list): %-list:
@$(COMPOSE_RUN_AWS) $(AWS_CLI) $(call storage_list,$*) \
--query '$(call storage_query,$*)[].{Date: LastModified, Key: Key, "Size (bytes)": Size}' \
--output table
.PHONY: $(STORAGE_FOLDERS:%=%-list)
# -- Internationalization
env.d/development/crowdin:
+159
View File
@@ -16,6 +16,165 @@ the following command inside your docker container:
## [Unreleased]
### Purging inactive rooms
Rooms now keep track of the last time they were started (`last_started_at`), fed by LiveKit's `room_started` webhook. A new `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for `ROOM_INACTIVITY_DELETION_DAYS` days. See [the room purge documentation](docs/features/room-purge.md).
- The feature is **disabled by default**: nothing is deleted unless you set `ROOM_INACTIVITY_DELETION_DAYS`.
- The migration marks every existing room as started at the time of the upgrade, so no existing room can be purged before a full inactivity period has elapsed after upgrading.
- Rooms holding a saved recording their users may still access are kept: any saved recording, or, when `RECORDING_EXPIRATION_DAYS` is set, a saved recording created within that window.
- Inactivity is measured from LiveKit's `room_started` webhook: if it is not delivered to your backend, rooms in daily use look inactive and get purged.
- When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again and can be reused when a meeting is created from that same URL.
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights.
* With `ALLOW_UNREGISTERED_ROOMS=true`, any user can reopen the purged room by navigating to the same URL. In that case, the room is created dynamically and no corresponding room entry is persisted in the database.
### Local development: MinIO replaced by Garage
The development stacks now use [Garage](https://garagehq.deuxfleurs.fr/) instead of MinIO as S3 storage. Garage keeps its own format in `data/media/meta` and `data/media/data` and cannot read what MinIO left there, so local recordings and files will be lost.
To migrate a local environment:
1. Stop the stack and remove its containers, including the former `minio` one: `docker compose down --remove-orphans`
2. Optionally reclaim the space used by MinIO: `rm -rf data/media && make data/media`
3. In your `env.d/development/*` files, replace `minio:9000` by `garage:9000`, the `meet` / `password` credentials by `meet-access-key` / `meet-secret-access-key`, and add `AWS_S3_REGION_NAME=local` (or delete these files and run `make create-env-files`)
4. Run `make create-env-files` to generate `env.d/development/garage`, which holds a random RPC secret for Garage.
5. Rebuild the images, since the summary and agent images now install boto3 instead of minio
### Summary service and metadata collector: boto3 replaces the minio client
The summary service and the metadata collector agent now talk to S3 through boto3 instead of the minio client, with the same settings.
Requests are now signed for `AWS_S3_REGION_NAME` as-is. When it is not set, the region is no longer looked up from the bucket: boto3 falls back to `AWS_DEFAULT_REGION`, then to `us-east-1`. If you left `AWS_S3_REGION_NAME` unset, set it to your provider's region before upgrading, or providers that check the signing region will reject the transcripts, summaries and meeting metadata uploads, as well as their signed URLs.
Also:
- Signed URLs to transcripts and summaries are now always path-style (`<endpoint>/<bucket>/<key>`), whereas the minio client used virtual-hosted-style URLs
- The metadata collector now accepts `AWS_S3_ENDPOINT_URL` with or without a scheme, like the summary service: the scheme always follows `AWS_S3_SECURE_ACCESS`.
### Helm chart: media services default to Garage
The `meet` chart now defaults `serviceMedia.host` and `serviceMediaFiles.host` to `garage.meet.svc.cluster.local`, and the `upstream-vhost` annotation of `ingressMedia` and `ingressMediaFiles` to `garage.meet.svc.cluster.local:9000`. If you relied on the former `minio.meet.svc.cluster.local` defaults, set these values explicitly to your S3 service before upgrading, or recordings and files stop being served under `/media`.
### 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
### Removing S3 storage-event webhooks for recordings
+2 -2
View File
@@ -104,8 +104,8 @@ k8s_yaml(secret_yaml_generic(
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev-keycloak} template .'))
k8s_resource('minio-bucket', resource_deps=['minio'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
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'])
+9 -10
View File
@@ -5,7 +5,7 @@ set -eo pipefail
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
UNSET_USER=0
COMPOSE_FILE="${REPO_DIR}/compose.yml"
export COMPOSE_FILE="${COMPOSE_FILE:-${REPO_DIR}/compose.yml}"
COMPOSE_PROJECT="meet"
@@ -42,7 +42,6 @@ function _docker_compose() {
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
docker compose \
-p "${COMPOSE_PROJECT}" \
-f "${COMPOSE_FILE}" \
--project-directory "${REPO_DIR}" \
"$@"
}
@@ -56,12 +55,12 @@ function _docker_compose() {
function _dc_run() {
_set_user
user_args="--user=$USER_ID"
if [ -z $USER_ID ]; then
user_args=""
user_args=()
if [ -n "$USER_ID" ]; then
user_args=("--user=$USER_ID")
fi
_docker_compose run --rm $user_args "$@"
_docker_compose run --rm "${user_args[@]}" "$@"
}
# _dc_exec: wrap docker compose exec command
@@ -75,12 +74,12 @@ function _dc_exec() {
echo "🐳(compose) exec command: '\$@'"
user_args="--user=$USER_ID"
if [ -z $USER_ID ]; then
user_args=""
user_args=()
if [ -n "$USER_ID" ]; then
user_args=("--user=$USER_ID")
fi
_docker_compose exec $user_args "$@"
_docker_compose exec "${user_args[@]}" "$@"
}
# _django_manage: wrap django's manage.py command with docker compose
+1 -1
View File
@@ -40,7 +40,7 @@ if [ -n "$CUSTOM_LOGO_URL" ]; then
[[ "$IS_SVG" == false ]] && echo "[custom-logo] ERROR: not a valid SVG file" >&2 && exit 1
mv -f "$TMP_FILE" "$LOGO_FILE"
echo "[custom-logo] INFO: Custom logo downloaded successfuly"
echo "[custom-logo] INFO: Custom logo downloaded successfully"
fi
mv src/backend/* ./
+1 -1
View File
@@ -7,7 +7,7 @@ gunicorn -b 0.0.0.0:8000 meet.wsgi:application --log-file - &
bin/run &
# if the current shell is killed, also terminate all its children
trap "pkill SIGTERM -P $$" SIGTERM
trap 'pkill -TERM -P $$' SIGTERM
# wait for a single child to finish,
wait -n
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/env bash
# shellcheck source=bin/_config.sh
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_docker_compose "$@"
+4 -5
View File
@@ -1,7 +1,6 @@
#!/usr/bin/env bash
set -o errexit
CURRENT_DIR=$(pwd)
NAMESPACE=${1:-meet}
SECRET_NAME=${2:-bitwarden-cli-meet}
TEMP_SECRET_FILE=$(mktemp)
@@ -30,10 +29,10 @@ check_secret_exists() {
# Collect user input securely
get_user_input() {
echo "Please provide the following information:"
read -p "Enter your Vaultwarden email login: " LOGIN
read -s -p "Enter your Vaultwarden password: " PASSWORD
read -r -p "Enter your Vaultwarden email login: " LOGIN
read -r -s -p "Enter your Vaultwarden password: " PASSWORD
echo
read -p "Enter your Vaultwarden server url: " URL
read -r -p "Enter your Vaultwarden server url: " URL
}
# Create and apply the secret
@@ -77,7 +76,7 @@ main() {
exit 0
fi
echo -e ${TEMP_SECRET_FILE}
echo -e "${TEMP_SECRET_FILE}"
get_user_input
echo -e "\nCreating Vaultwarden secret…"
+2 -2
View File
@@ -3,7 +3,7 @@
mkdir -p "$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/"
PRE_COMMIT_FILE="$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/pre-commit"
cat <<'EOF' >$PRE_COMMIT_FILE
cat <<'EOF' >"$PRE_COMMIT_FILE"
#!/bin/bash
# directories containing potential secrets
@@ -27,4 +27,4 @@ for d in $DIRS; do
done
EOF
chmod +x $PRE_COMMIT_FILE
chmod +x "$PRE_COMMIT_FILE"
+1 -1
View File
@@ -68,7 +68,7 @@ fi
# Ask user for release version number
echo ""
read -p "Enter release version number (e.g., 1.2.3): " VERSION
read -r -p "Enter release version number (e.g., 1.2.3): " VERSION
# Validate version format (basic semver check)
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+1
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env bash
git submodule update --init --recursive
# shellcheck disable=SC2016
git submodule foreach 'git fetch origin; git checkout $(git rev-parse --abbrev-ref HEAD); git reset --hard origin/$(git rev-parse --abbrev-ref HEAD); git submodule update --recursive; git clean -dfx'
+1 -1
View File
@@ -8,6 +8,6 @@ environments=$(awk '/environments:/ {flag=1; next} flag && NF {print} !NF {flag=
for env in $environments; do
echo "################### $env lint ###################"
helmfile -e $env -f src/helm/helmfile.yaml lint || exit 1
helmfile -e "$env" -f src/helm/helmfile.yaml lint || exit 1
echo -e "\n"
done
+35 -26
View File
@@ -15,36 +15,44 @@ services:
ports:
- "1081:1080"
minio:
garage:
user: ${DOCKER_USER:-1000}
image: minio/minio
image: dxflrs/garage:v2.4.1
command: /garage server --single-node --default-bucket
env_file:
- env.d/development/garage
environment:
- MINIO_ROOT_USER=meet
- MINIO_ROOT_PASSWORD=password
- GARAGE_DEFAULT_ACCESS_KEY=meet-access-key
- GARAGE_DEFAULT_SECRET_KEY=meet-secret-access-key
- GARAGE_DEFAULT_BUCKET=meet-media-storage
ports:
- '9000:9000'
- '9001:9001'
- '127.0.0.1:9000:9000'
healthcheck:
test: [ "CMD", "mc", "ready", "local" ]
test: [ "CMD", "/garage", "health" ]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server --console-address :9001 /data
volumes:
- ./data/media:/data
- ./docker/files/etc/garage/garage.toml:/etc/garage.toml:ro
- ./data/media:/var/lib/garage
createbuckets:
image: minio/mc
# Garage denies cross-origin requests by default: allow the frontend to upload files
garage-cors:
image: amazon/aws-cli:2.37.1
environment:
- AWS_ACCESS_KEY_ID=meet-access-key
- AWS_SECRET_ACCESS_KEY=meet-secret-access-key
- AWS_DEFAULT_REGION=local
depends_on:
minio:
garage:
condition: service_healthy
restart: true
entrypoint: >
sh -c "
/usr/bin/mc alias set meet http://minio:9000 meet password && \
/usr/bin/mc mb meet/meet-media-storage && \
exit 0;"
command:
- s3api
- put-bucket-cors
- --endpoint-url=http://garage:9000
- --bucket=meet-media-storage
- '--cors-configuration={"CORSRules": [{"AllowedOrigins": ["http://localhost:3000"], "AllowedMethods": ["GET", "HEAD", "PUT"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"]}]}'
app-dev:
build:
@@ -70,7 +78,7 @@ services:
- postgresql
- mailcatcher
- redis
- createbuckets
- garage-cors
extra_hosts:
- "127.0.0.1.nip.io:host-gateway"
networks:
@@ -110,7 +118,7 @@ services:
- postgresql
- redis
- livekit
- minio
- garage
celery:
user: ${DOCKER_USER:-1000}
@@ -145,6 +153,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:
@@ -164,7 +173,7 @@ services:
working_dir: /app
node:
image: node:22
image: node:22-alpine
user: "${DOCKER_USER:-1000}"
environment:
HOME: /tmp
@@ -207,7 +216,7 @@ services:
- kc_postgresql
livekit:
image: livekit/livekit-server
image: livekit/livekit-server:v1.13.6
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
ports:
- "7880:7880"
@@ -244,7 +253,7 @@ services:
- /app/.venv
depends_on:
- livekit
- minio
- garage
develop:
watch:
- action: rebuild
@@ -263,7 +272,7 @@ services:
- /app/.venv
redis-summary:
image: redis
image: redis:5
ports:
- "6379:6379"
@@ -297,7 +306,7 @@ services:
depends_on:
- redis-summary
- app-summary-dev
- minio
- garage
develop:
watch:
- action: rebuild
@@ -317,7 +326,7 @@ services:
depends_on:
- redis-summary
- app-summary-dev
- minio
- garage
develop:
watch:
- action: rebuild
+47
View File
@@ -0,0 +1,47 @@
{
"nodes": {
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1778705847,
"narHash": "sha256-EQnZCy7r4VMO6KDoytxHBa0mFbM1D9g1kaDfs/s0YZA=",
"ref": "refs/tags/v2.1.2",
"rev": "ea3d94ac9d6bf6a1313773170122ca4e2ef5a0be",
"revCount": 6569,
"type": "git",
"url": "https://github.com/cachix/devenv"
},
"original": {
"dir": "src/modules",
"ref": "refs/tags/v2.1.2",
"type": "git",
"url": "https://github.com/cachix/devenv"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1789542786,
"narHash": "sha256-ajQuqcxnj6RYnwCjA/9FTCnrdR2+BDzstE8UembXf60=",
"ref": "nixos-26.05",
"rev": "4c7870105e7f1fdf9c48688c8d7efc21abf0688a",
"shallow": true,
"type": "git",
"url": "https://github.com/NixOS/nixpkgs"
},
"original": {
"ref": "nixos-26.05",
"shallow": true,
"type": "git",
"url": "https://github.com/NixOS/nixpkgs"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+265
View File
@@ -0,0 +1,265 @@
# =============================================================================
# devenv.nix — La Suite Meet ("Visio") developer environment
# =============================================================================
{
pkgs,
lib,
config,
...
}:
let
python = pkgs.python313;
nodejs = pkgs.nodejs_22;
backendDir = "src/backend";
agentsDir = "src/agents";
summaryDir = "src/summary";
frontendDir = "src/frontend";
readDotEnv =
file:
let
lines = lib.splitString "\n" (builtins.readFile file);
unquote =
v:
let
len = builtins.stringLength v;
in
if len >= 2 && lib.hasPrefix "\"" v && lib.hasSuffix "\"" v then
builtins.substring 1 (len - 2) v
else if len >= 2 && lib.hasPrefix "'" v && lib.hasSuffix "'" v then
builtins.substring 1 (len - 2) v
else
v;
parseLine =
line:
let
m = builtins.match "[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(.*)" line;
in
if m == null then null else { name = builtins.elemAt m 0; value = unquote (builtins.elemAt m 1); };
in
builtins.listToAttrs (builtins.filter (x: x != null) (map parseLine lines));
# Reuse existing .env
dotEnv =
(readDotEnv ./env.d/development/common.dist)
// (readDotEnv ./env.d/development/postgresql.dist);
sharedEnv = builtins.removeAttrs dotEnv [ "PYTHONPATH" ]; # only makes sense inside the backend container.
in
{
options.meet = {
agents.enable = lib.mkEnableOption "tooling for the LiveKit agents in src/agents";
summary.enable = lib.mkEnableOption "tooling for the summary service in src/summary";
k8s.enable = lib.mkEnableOption "Kubernetes dev utilities";
};
config = {
# Profile can be activated with devenv --profile <profile> shell
profiles = {
agents.module = {
meet.agents.enable = true;
};
summary.module = {
meet.summary.enable = true;
};
k8s.module = {
meet.k8s.enable = true;
};
};
languages.python = {
enable = true;
package = python;
directory = backendDir;
manylinux.enable = pkgs.stdenv.hostPlatform.isLinux;
libraries = [
"${config.devenv.dotfile}/profile"
pkgs.file
pkgs.zlib
pkgs.libffi
pkgs.openssl
];
uv.enable = true;
uv.sync.enable = false;
venv.enable = false;
lsp.enable = true;
};
languages.javascript = {
enable = true;
package = nodejs;
directory = frontendDir;
npm.enable = true;
yarn.enable = true;
corepack.enable = false;
};
languages.typescript.enable = false;
languages.nix.enable = true;
packages =
with pkgs;
[
gnumake
file
shared-mime-info
gettext
postgresql_16
git
curl
jq
podman
podman-compose
docker-client
]
# -- LiveKit agents
++ lib.optionals config.meet.agents.enable [
glib
portaudio
livekit-cli
]
# -- summary service
++ lib.optionals config.meet.summary.enable [
redis
]
# -- Kubernetes tools
++ lib.optionals config.meet.k8s.enable [
kubectl
kubernetes-helm
helmfile
tilt
kind
mkcert
];
env = sharedEnv // {
UV_LINK_MODE = "copy";
PYTHONDONTWRITEBYTECODE = "1";
PYTHONUNBUFFERED = "1";
UV_PROJECT_ENVIRONMENT = lib.mkForce ".venv";
COMPOSE_PROJECT_NAME = "meet";
DJANGO_DATA_DIR = "${config.devenv.root}/data";
# Database / Pgsql
DB_HOST = "127.0.0.1";
DB_PORT = "15432";
PGHOST = "127.0.0.1";
PGPORT = "15432";
PGDATABASE = sharedEnv.DB_NAME;
PGUSER = sharedEnv.DB_USER;
PGPASSWORD = sharedEnv.DB_PASSWORD;
REDIS_URL = "redis://127.0.0.1:6379/1";
CELERY_BROKER_URL = "redis://127.0.0.1:6379/0";
# S3 / Garage
AWS_S3_ENDPOINT_URL = "http://127.0.0.1:9000";
# OIDC
OIDC_OP_JWKS_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/certs";
OIDC_OP_TOKEN_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/token";
OIDC_OP_USER_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/userinfo";
OIDC_OP_INTROSPECTION_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/token/introspect";
# summary service
SUMMARY_SERVICE_ENDPOINT = "http://127.0.0.1:8001/api/v2/async-jobs/transcribe/";
SUMMARY_SERVICE_VERSION = "2";
# Mail
DJANGO_EMAIL_HOST = "127.0.0.1";
};
scripts = {
meet-venv = {
description = "Create/refresh meet uv virtualenvs for backend, agents and summary";
exec = ''
set -euo pipefail
cd "$DEVENV_ROOT"
echo "==> ${backendDir} (uv sync --locked, dependency-groups)"
( cd "${backendDir}" && uv sync --locked --all-groups )
echo "==> ${agentsDir} (uv sync --locked --all-extras)"
( cd "${agentsDir}" && uv sync --locked --all-extras )
echo "==> ${summaryDir} (uv sync --locked --all-extras)"
( cd "${summaryDir}" && uv sync --locked --all-extras )
echo
echo "Synced the following virtualenvs successfully:"
echo " ${backendDir}/.venv"
echo " ${agentsDir}/.venv"
echo " ${summaryDir}/.venv"
'';
};
};
enterShell = ''
# Make podman socket accessible in order to launch regular docker commands.
# Set MEET_PODMAN_SOCKET=0 to keep the DOCKER_HOST of the calling environment.
case "''${MEET_PODMAN_SOCKET:-1}" in
0|false|no|off) ;;
*)
_rundir="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
export DOCKER_HOST="unix://$_rundir/podman/podman.sock"
unset _rundir
;;
esac
# Compose files to merge
_compose_dir="${config.devenv.root}/docker/compose.d"
_compose_files="${config.devenv.root}/compose.yml"
export DOCKER_USER="$(id -u):$(id -g)"
case "''${DOCKER_HOST:-}" in
*podman*)
_compose_files="$_compose_files:$_compose_dir/compose.podman.yml"
# Build images with Podman/Buildah rather than BuildKit. `docker
# compose build` otherwise has buildx boot a moby/buildkit container,
# and that container lands in its own network namespace with neither
# the proxy in its environment nor any route to it.
# Buildah has neither problem: base images are resolved by the Podman systemd
# service, which inherits the proxy from its systemd socket activated unit, and
# RUN steps execute in the *host* network namespace
export DOCKER_BUILDKIT=0
export COMPOSE_BAKE=false
;;
esac
# Apply Bureautix override
if [ -n "''${http_proxy:-}" ]; then
_compose_files="$_compose_files:$_compose_dir/compose.bureautix.yml"
fi
export COMPOSE_FILE="$_compose_files"
unset _compose_dir _compose_files
# Make binaries accessible
for _d in \
"$DEVENV_ROOT/${backendDir}/.venv/bin" \
"$DEVENV_ROOT/${agentsDir}/.venv/bin" \
"$DEVENV_ROOT/${summaryDir}/.venv/bin" \
"$DEVENV_ROOT/${frontendDir}/node_modules/.bin"
do
[ -d "$_d" ] && export PATH="$_d:$PATH"
done
unset _d
'';
};
}
+5
View File
@@ -0,0 +1,5 @@
inputs:
nixpkgs:
url: git+https://github.com/NixOS/nixpkgs?ref=nixos-26.05&shallow=1
devenv:
url: git+https://github.com/cachix/devenv?ref=refs/tags/v2.1.2&dir=src/modules
+48
View File
@@ -0,0 +1,48 @@
# Bureautix proxy overrides
#
# Builds submitted through the Docker API of the Podman service get none of
# the proxy variables in their RUN steps. We need to pass the http_proxy explicitly
# otherwise all connections fail during the build.
x-proxy-vars: &proxy-vars
http_proxy: ${http_proxy:-}
https_proxy: ${https_proxy:-}
no_proxy: ${no_proxy:-}
services:
app:
build:
args:
<<: *proxy-vars
app-dev:
build:
args:
<<: *proxy-vars
frontend:
build:
args:
<<: *proxy-vars
metadata-collector-dev:
build:
args:
<<: *proxy-vars
multi-user-transcriber-dev:
build:
args:
<<: *proxy-vars
app-summary-dev:
build:
args:
<<: *proxy-vars
celery-summary-transcribe:
build:
args:
<<: *proxy-vars
celery-summary-summarize:
build:
args:
<<: *proxy-vars
# The local proxy listens on 8080 and collides with Keycloak's published admin port.
keycloak:
ports: !override
- "8081:8080"
+35
View File
@@ -0,0 +1,35 @@
# Rootless Podman override for compose.yml.
#
# Rootless Podman maps container UID 0 to the host user and every other
# container UID to a subuid that owns nothing in the worktree. Meet compose.ymlruns
# its containers as DOCKER_USER=$(id -u):$(id -g), which would land on such a
# subuid and make every bind mount effectively read-only.
#
# `userns_mode: keep-id` maps the host user to the same UID and GID inside the
# container instead, so DOCKER_USER keeps its Docker value and files written
# through a bind mount are owned by the host user on both sides.
#
# Only the services that mount the worktree and run as DOCKER_USER are listed.
x-keep-id: &keep-id
userns_mode: keep-id
services:
app-dev:
<<: *keep-id
celery-dev:
<<: *keep-id
garage:
<<: *keep-id
garage-cors:
<<: *keep-id
node:
<<: *keep-id
crowdin:
<<: *keep-id
metadata-collector-dev:
<<: *keep-id
multi-user-transcriber-dev:
<<: *keep-id
app-summary-dev:
<<: *keep-id
+5 -10
View File
@@ -54,18 +54,13 @@ RUN npx webpack --mode production
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
USER root
# Security patches for known CVEs
RUN apk update && apk upgrade \
libcrypto3>=3.5.7-r0 \
libssl3>=3.5.7-r0 \
musl \
musl-utils \
zlib>=1.3.2-r0 \
&& apk del curl
RUN apk upgrade --no-cache libexpat && \
apk add --no-cache --upgrade 'pcre2>=10.49-r0' && \
apk del curl
USER nginx
USER nginx
+1
View File
@@ -1,5 +1,6 @@
:root {
--fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif;
--avatar-cap-height: 0.7;
}
.Header-beforeLogo {
+15
View File
@@ -0,0 +1,15 @@
# Garage configuration for local development only: single node, no replication.
# See https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "lmdb"
replication_factor = 1
rpc_bind_addr = "127.0.0.1:3901"
rpc_public_addr = "127.0.0.1:3901"
[s3_api]
api_bind_addr = "[::]:9000"
# Clients must sign their requests for this region (AWS_S3_REGION_NAME)
s3_region = "local"
+33 -3
View File
@@ -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
@@ -17,9 +47,9 @@ server {
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Minio
proxy_pass http://minio:9000/meet-media-storage/;
proxy_set_header Host minio:9000;
# Get resource from Garage
proxy_pass http://garage:9000/meet-media-storage/;
proxy_set_header Host garage:9000;
# To use with ds_proxy
# proxy_pass http://ds-proxy:4444/upstream/meet-media-storage/;
# proxy_set_header Host ds-proxy:4444;
@@ -14,3 +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'
" rid=%({x-request-id}o)s"
)
+1 -1
View File
@@ -1,4 +1,4 @@
FROM livekit/livekit-server:v1.9.4
FROM livekit/livekit-server:v1.13.6
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
COPY rootCA.pem /etc/ssl/certs/
@@ -21,3 +21,10 @@ turn:
- 192.168.0.0/16
- 172.16.0.0/12
rtc:
node_ip: 127.0.0.1
advertise_internal_ip: true
udp_port: 7882
tcp_port: 7881
use_external_ip: false
+1 -1
View File
@@ -13,7 +13,7 @@ These components rely on a few key services:
- PostgreSQL for storing data (users, rooms, recordings)
- Redis for caching and inter-service communication
- MinIO for storing files (room recordings)
- Garage for storing files (room recordings)
- Celery workers for meeting transcript (optional, required for AI beta features)
We provide two stack options for getting Visio up and running for development:
+238
View File
@@ -0,0 +1,238 @@
# 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": "3b1d…", "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 | `lasuite.target` = user |
| `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`** with the request at hand. A `category` or `types` given here wins over the
action's:
```python
audit.log(auditing.USER_PROVISION, request=request, 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.
+40
View File
@@ -0,0 +1,40 @@
# Room purge
Rooms pile up over time and most of them are only used once. The `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for a configurable number of days. It is disabled by default.
## How it works
Each time LiveKit tells the backend that a room has started (`room_started` webhook), the backend records the date on the room (`last_started_at`).
A room is inactive when:
- it was last started more than `ROOM_INACTIVITY_DELETION_DAYS` days ago, or
- it was never started and was created more than `ROOM_INACTIVITY_DELETION_DAYS` days ago.
Rooms that existed before this feature was deployed are considered started on the day of the release, so none of them can be purged before a full inactivity period has elapsed.
The command is meant to run once a day. The Helm chart schedules it in `backend.cronjobs` (`purge-inactive-rooms`, 01:00); it does nothing until `ROOM_INACTIVITY_DELETION_DAYS` is set.
```bash
python manage.py purge_inactive_rooms # delete the inactive rooms
python manage.py purge_inactive_rooms --dry-run # only list the rooms that would be deleted
```
## Rooms that are kept
A recording can only be reached through its room. An inactive room is kept as long as it holds a saved recording its users may still access:
- with `RECORDING_EXPIRATION_DAYS` set, a saved recording created less than that many days ago,
- with `RECORDING_EXPIRATION_DAYS` unset, any saved recording.
## What happens to a purged room
The room is deleted from the database, along with its accesses, its telephony PIN code, and the recording entries it still holds — the expired ones and those that were never saved, since any other recording would have protected the room — together with their own accesses.
The recording **files in the bucket are left untouched**: the backend never deletes anything from the storage, it only drops the database entries pointing at it. Removing the files is the job of the bucket lifecycle policy, which should match `RECORDING_EXPIRATION_DAYS` (see the [recording documentation](recording.md)). When the two do not match, the purge leaves objects behind: they become unreachable, since serving a recording requires its database entry, but they keep costing storage.
⚠️ When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again
and can be reused when a meeting is created from that same URL.
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights.
* With `ALLOW_UNREGISTERED_ROOMS=true`, any user can reopen the purged room by navigating to the same URL. In that case, the room is created dynamically and no corresponding room entry is persisted in the database.
+8 -7
View File
@@ -42,7 +42,7 @@ sequenceDiagram
participant Backend as Backend API
participant Summary as Summary Service
participant Celery as Celery Workers (transcribe-queue)
participant MinIO as MinIO (Object Storage)
participant S3 as S3 (Object Storage)
participant STT as WhisperX API
participant Docs as LaSuite Docs
@@ -50,7 +50,7 @@ sequenceDiagram
Note right of Backend: Payload contains 7 params: owner_id, filename, email, sub, room, recording_date, recording_time
Summary->>Celery: Register task (transcribe-queue)
Celery->>MinIO: Fetch audio file
Celery->>S3: Fetch audio file
Celery->>STT: Transcribe audio (WhisperX)
STT-->>Celery: Segmented transcript
@@ -72,11 +72,12 @@ sequenceDiagram
| celery_result_backend | String | `"redis://redis/0"` | Celery result backend URL. |
| celery_max_retries | Integer | `1` | Maximum number of retries for Celery tasks. |
| transcribe_queue | String | `"transcribe-queue"` | Name of the Celery queue for transcription tasks. |
| aws_storage_bucket_name | String | — | Name of the S3/MinIO bucket used for storing recordings. |
| aws_s3_endpoint_url | String | — | Endpoint URL of the S3/MinIO storage. |
| aws_s3_access_key_id | String | — | Access key for S3/MinIO. |
| aws_s3_secret_access_key | Secret | — | Secret key for S3/MinIO. |
| aws_s3_secure_access | Boolean | `True` | Use HTTPS for S3/MinIO requests. |
| aws_storage_bucket_name | String | — | Name of the S3 bucket used for storing recordings. |
| aws_s3_endpoint_url | String | — | Endpoint URL of the S3 storage. |
| aws_s3_access_key_id | String | — | Access key for S3. |
| aws_s3_secret_access_key | Secret | — | Secret key for S3. |
| aws_s3_secure_access | Boolean | `True` | Use HTTPS for S3 requests. |
| aws_s3_region_name | String | — | Region used to sign S3 requests, passed as-is to boto3. |
| whisperx_api_key | Secret | — | API key for accessing WhisperX. |
| whisperx_base_url | String | `"https://api.whisperx.com/v1"` | Base URL for the WhisperX API. |
| whisperx_asr_model | String | `"whisper-1"` | ASR model used for transcription. |
+1 -1
View File
@@ -14,7 +14,7 @@ All services are required to run the minimalist instance of LaSuite Meet. Click
| **OIDC Provider** | User authentication | [Keycloak setup](../examples/compose/keycloak/README.md) |
| **SMTP Service** | Email notifications | - |
> [!NOTE] Some advanced features, as Recording and transcription, require additional services (MinIO, email). See `/features` folder for details.
> [!NOTE] Some advanced features, as Recording and transcription, require additional services (S3-compatible object storage, email). See `/features` folder for details.
## Software Requirements
+120 -116
View File
@@ -14,7 +14,7 @@ This document is a step-by-step guide that describes how to install LaSuite Meet
If you do not have a kubernetes test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script located in this repo under **bin/start-kind.sh**.
IMPORTANT: The kind method will only deploy meet as a local instance(127.0.0.1) that can only be accessed from the device where it has been deployed.
IMPORTANT: The kind method will only deploy meet as a local instance(127.0.0.1) that can only be accessed from the device where it has been deployed.
To be able to use the script, you will need to install the following components:
@@ -311,119 +311,123 @@ frontend:
These are the environmental options available on meet backend.
| Option | Description | default |
|-------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
| DATA_DIR | Data directory location | /data |
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
| DJANGO_SECRET_KEY | Secret key used for Django security | |
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
| DB_NAME | Name of the database | meet |
| DB_USER | User used to connect to database | dinum |
| DB_PASSWORD | Password used to connect to the database | pass |
| DB_HOST | Hostname of the database | localhost |
| DB_PORT | Port to connect to database | 5432 |
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
| AWS_S3_SECRET_ACCESS_KEY | S3 secret key | |
| AWS_S3_REGION_NAME | S3 region | |
| AWS_STORAGE_BUCKET_NAME | S3 bucket name | meet-media-storage |
| DJANGO_LANGUAGE_CODE | Default language | en-us |
| REDIS_URL | Redis endpoint | redis://redis:6379/1 |
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
| CSRF_TRUSTED_ORIGINS | CSRF trusted origins list | [] |
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
| FRONTEND_ANALYTICS | Analytics information | {} |
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
| 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_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 |
| DJANGO_EMAIL_HOST | Host of the email server | |
| DJANGO_EMAIL_HOST_USER | User to connect to the email server | |
| DJANGO_EMAIL_HOST_PASSWORD | Password to connect to the email server | |
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
| EMAIL_BRAND_NAME | Email branding name | |
| EMAIL_SUPPORT_EMAIL | Support email address | |
| EMAIL_LOGO_IMG | Email logo image | |
| EMAIL_DOMAIN | Email domain | |
| EMAIL_APP_BASE_URL | Email app base URL | |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
| DJANGO_CORS_ALLOWED_ORIGINS | Origins to allow (string list) | [] |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | Origins to allow (regex patterns) | [] |
| SENTRY_DSN | Sentry server DSN | |
| DJANGO_CELERY_BROKER_URL | Celery broker host | redis://redis:6379/0 |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} |
| OIDC_CREATE_USER | Create OIDC user if not exists | true |
| OIDC_VERIFY_SSL | Verify SSL for OIDC | true |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | false |
| OIDC_RP_SIGN_ALGO | Token verification algorithm used by OIDC | RS256 |
| OIDC_RP_CLIENT_ID | OIDC client ID | meet |
| OIDC_RP_CLIENT_SECRET | OIDC client secret | |
| OIDC_OP_JWKS_ENDPOINT | OIDC endpoint for JWKS | |
| OIDC_OP_AUTHORIZATION_ENDPOINT | OIDC endpoint for authorization | |
| OIDC_OP_TOKEN_ENDPOINT | OIDC endpoint for token | |
| OIDC_OP_USER_ENDPOINT | OIDC endpoint for user | |
| OIDC_OP_USER_ENDPOINT_FORMAT | OIDC endpoint format (AUTO, JWT, JSON) | AUTO |
| OIDC_OP_LOGOUT_ENDPOINT | OIDC endpoint for logout | |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters for OIDC request | {} |
| OIDC_RP_SCOPES | OIDC scopes | openid email |
| OIDC_USE_NONCE | Use nonce for OIDC | true |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC | false |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed redirect hosts for OIDC | [] |
| OIDC_STORE_ID_TOKEN | Store OIDC ID token | true |
| OIDC_REDIRECT_FIELD_NAME | Redirect field for OIDC | returnTo |
| OIDC_USERINFO_FULLNAME_FIELDS | Full name claim from OIDC token | ["given_name", "usual_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | Short name claim from OIDC token | given_name |
| OIDC_USERINFO_ESSENTIAL_CLAIMS | Required claims from OIDC token | [] |
| OIDC_USE_PKCE | Enable the use of PKCE (Proof Key for Code Exchange) during the OAuth 2.0 authorization code flow. Recommended for enhanced security. | False |
| OIDC_PKCE_CODE_CHALLENGE_METHOD | Method used to generate the PKCE code challenge. Common values include S256 and plain. Refer to the mozilla-django-oidc documentation for supported options. | S256 |
| OIDC_PKCE_CODE_VERIFIER_SIZE | Length of the random string used as the PKCE code verifier. Must be an integer between 43 and 128, inclusive. | 64 |
| LOGIN_REDIRECT_URL | Login redirect URL | |
| LOGIN_REDIRECT_URL_FAILURE | Login redirect URL for failure | |
| LOGOUT_REDIRECT_URL | URL to redirect to on logout | |
| ALLOW_LOGOUT_GET_METHOD | Allow logout through GET method | true |
| LIVEKIT_API_KEY | LiveKit API key | |
| LIVEKIT_API_SECRET | LiveKit API secret | |
| LIVEKIT_API_URL | LiveKit API URL | |
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
| LIVEKIT_FORCE_WSS_PROTOCOL | Enables WSS protocol conversion for legacy browser compatibility (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs fail in WebSocket() constructor. | false |
| LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND | Firefox-only connection warmup: pre-calls WebSocket endpoint (expecting 401) to initialize cache, resolving proxy/network connectivity issues. | false |
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
| RECORDING_ENABLE | Record meeting option | false |
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
| Option | Description | default |
|-------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|
| DATA_DIR | Data directory location | /data |
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
| DJANGO_SECRET_KEY | Secret key used for Django security | |
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
| DB_NAME | Name of the database | meet |
| DB_USER | User used to connect to database | dinum |
| DB_PASSWORD | Password used to connect to the database | pass |
| DB_HOST | Hostname of the database | localhost |
| DB_PORT | Port to connect to database | 5432 |
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
| AWS_S3_SECRET_ACCESS_KEY | S3 secret key | |
| AWS_S3_REGION_NAME | S3 region | |
| AWS_STORAGE_BUCKET_NAME | S3 bucket name | meet-media-storage |
| DJANGO_LANGUAGE_CODE | Default language | en-us |
| REDIS_URL | Redis endpoint | redis://redis:6379/1 |
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
| ROOM_CREATION_THROTTLE_RATES | Room creation throttle rate per authenticated user | 50/minute | 50/minute |
| ROOM_CREATION_DAILY_THROTTLE_RATES | Daily room creation cap per authenticated user | 1000/day |
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
| CSRF_TRUSTED_ORIGINS | CSRF trusted origins list | [] |
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
| FRONTEND_ANALYTICS | Analytics information | {} |
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
| 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 |
| DJANGO_EMAIL_HOST | Host of the email server | |
| DJANGO_EMAIL_HOST_USER | User to connect to the email server | |
| DJANGO_EMAIL_HOST_PASSWORD | Password to connect to the email server | |
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
| EMAIL_BRAND_NAME | Email branding name | |
| EMAIL_SUPPORT_EMAIL | Support email address | |
| EMAIL_LOGO_IMG | Email logo image | |
| EMAIL_DOMAIN | Email domain | |
| EMAIL_APP_BASE_URL | Email app base URL | |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
| DJANGO_CORS_ALLOWED_ORIGINS | Origins to allow (string list) | [] |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | Origins to allow (regex patterns) | [] |
| SENTRY_DSN | Sentry server DSN | |
| DJANGO_CELERY_BROKER_URL | Celery broker host | redis://redis:6379/0 |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} |
| OIDC_CREATE_USER | Create OIDC user if not exists | true |
| OIDC_VERIFY_SSL | Verify SSL for OIDC | true |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | false |
| OIDC_RP_SIGN_ALGO | Token verification algorithm used by OIDC | RS256 |
| OIDC_RP_CLIENT_ID | OIDC client ID | meet |
| OIDC_RP_CLIENT_SECRET | OIDC client secret | |
| OIDC_OP_JWKS_ENDPOINT | OIDC endpoint for JWKS | |
| OIDC_OP_AUTHORIZATION_ENDPOINT | OIDC endpoint for authorization | |
| OIDC_OP_TOKEN_ENDPOINT | OIDC endpoint for token | |
| OIDC_OP_USER_ENDPOINT | OIDC endpoint for user | |
| OIDC_OP_USER_ENDPOINT_FORMAT | OIDC endpoint format (AUTO, JWT, JSON) | AUTO |
| OIDC_OP_LOGOUT_ENDPOINT | OIDC endpoint for logout | |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters for OIDC request | {} |
| OIDC_RP_SCOPES | OIDC scopes | openid email |
| OIDC_USE_NONCE | Use nonce for OIDC | true |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC | false |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed redirect hosts for OIDC | [] |
| OIDC_STORE_ID_TOKEN | Store OIDC ID token | true |
| OIDC_REDIRECT_FIELD_NAME | Redirect field for OIDC | returnTo |
| OIDC_USERINFO_FULLNAME_FIELDS | Full name claim from OIDC token | ["given_name", "usual_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | Short name claim from OIDC token | given_name |
| OIDC_USERINFO_ESSENTIAL_CLAIMS | Required claims from OIDC token | [] |
| OIDC_USE_PKCE | Enable the use of PKCE (Proof Key for Code Exchange) during the OAuth 2.0 authorization code flow. Recommended for enhanced security. | False |
| OIDC_PKCE_CODE_CHALLENGE_METHOD | Method used to generate the PKCE code challenge. Common values include S256 and plain. Refer to the mozilla-django-oidc documentation for supported options. | S256 |
| OIDC_PKCE_CODE_VERIFIER_SIZE | Length of the random string used as the PKCE code verifier. Must be an integer between 43 and 128, inclusive. | 64 |
| LOGIN_REDIRECT_URL | Login redirect URL | |
| LOGIN_REDIRECT_URL_FAILURE | Login redirect URL for failure | |
| LOGOUT_REDIRECT_URL | URL to redirect to on logout | |
| ALLOW_LOGOUT_GET_METHOD | Allow logout through GET method | true |
| LIVEKIT_API_KEY | LiveKit API key | |
| LIVEKIT_API_SECRET | LiveKit API secret | |
| LIVEKIT_API_URL | LiveKit API URL | |
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
| LIVEKIT_FORCE_WSS_PROTOCOL | Enables WSS protocol conversion for legacy browser compatibility (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs fail in WebSocket() constructor. | false |
| LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND | Firefox-only connection warmup: pre-calls WebSocket endpoint (expecting 401) to initialize cache, resolving proxy/network connectivity issues. | false |
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
| ROOM_INACTIVITY_DELETION_DAYS | Days without being started after which a room is purged. Unset to never purge | |
| RECORDING_ENABLE | Record meeting option | false |
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
| BREVO_API_KEY | Brevo API key for marketing emails | |
| BREVO_API_CONTACT_LIST_IDS | Brevo API contact list IDs | [] |
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | Brevo contact attributes | {"VISIO_USER": true} |
| BREVO_API_TIMEOUT | Brevo timeout in seconds | 1 |
| LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby |
| LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 |
| LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 |
| LOBBY_ACCEPTED_TIMEOUT | Lobby accept timeout in seconds | 21600 (6 hours) |
| LOBBY_NOTIFICATION_TYPE | Lobby notification types | participantWaiting |
| LOBBY_COOKIE_NAME | Lobby cookie name | lobbyParticipantId |
| ROOM_CREATION_CALLBACK_CACHE_TIMEOUT | Room creation callback cache timeout | 600 (10 minutes) |
| ROOM_TELEPHONY_ENABLED | Enable SIP telephony feature | false |
| ROOM_TELEPHONY_PIN_LENGTH | Telephony PIN length | 10 |
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Telephony PIN maximum retries | 5 |
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
| BREVO_API_KEY | Brevo API key for marketing emails | |
| BREVO_API_CONTACT_LIST_IDS | Brevo API contact list IDs | [] |
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | Brevo contact attributes | {"VISIO_USER": true} |
| BREVO_API_TIMEOUT | Brevo timeout in seconds | 1 |
| LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby |
| LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 |
| LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 |
| LOBBY_ACCEPTED_TIMEOUT | Lobby accept timeout in seconds | 21600 (6 hours) |
| LOBBY_NOTIFICATION_TYPE | Lobby notification types | participantWaiting |
| LOBBY_COOKIE_NAME | Lobby cookie name | lobbyParticipantId |
| ROOM_CREATION_CALLBACK_CACHE_TIMEOUT | Room creation callback cache timeout | 600 (10 minutes) |
| ROOM_TELEPHONY_ENABLED | Enable SIP telephony feature | false |
| ROOM_TELEPHONY_PIN_LENGTH | Telephony PIN length | 10 |
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Telephony PIN maximum retries | 5 |
+78 -3
View File
@@ -16,11 +16,11 @@ info:
* `rooms:list` – List rooms accessible to the delegated user.
* `rooms:retrieve` – Retrieve details of a specific room.
* `rooms:create` – Create new rooms.
* `rooms:update` – **Coming soon** Update existing rooms, e.g., add attendees to a room.
* `rooms:update` – Update the access level and configuration of existing rooms.
* `rooms:delete` – **Coming soon** Delete rooms generated by the application.
#### Upcoming Features
* **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically.
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
@@ -310,6 +310,67 @@ paths:
'404':
$ref: '#/components/responses/RoomNotFoundError'
patch:
tags:
- Rooms
summary: Update a room
description: |
Partially updates a room. Only the delegated user's rooms where they are
administrator or owner can be updated; any other role gets a `403`.
**Updatable fields:** `access_level` and `configuration`. Every other field
(`id`, `name`, `slug`, `pin_code`) is read-only and silently ignored when sent.
`configuration` is replaced as a whole, it is not merged with the stored one.
Send the complete object you want the room to end up with.
Full replacement (`PUT`) is not supported. Use `PATCH` instead.
operationId: updateRoom
security:
- BearerAuth: [rooms:update]
parameters:
- name: id
in: path
required: true
description: Room UUID
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RoomUpdate'
examples:
accessLevelOnly:
summary: Change the access level
value:
access_level: "restricted"
configurationOnly:
summary: Replace the room configuration
value:
configuration:
everyone_can_mute: true
responses:
'200':
description: Room updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
'400':
$ref: '#/components/responses/BadRequestError'
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
'404':
$ref: '#/components/responses/RoomNotFoundError'
'405':
description: |
Method not allowed, `PUT` is not supported on this endpoint.
components:
securitySchemes:
BearerAuth:
@@ -386,6 +447,17 @@ components:
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomUpdate:
type: object
description: |
Fields that can be updated on an existing room. Both are optional, omitted
fields keep their current value.
properties:
access_level:
$ref: '#/components/schemas/RoomAccessLevel'
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomConfiguration:
type: object
description: |
@@ -427,6 +499,9 @@ components:
- `public`: Anyone with the room link can join directly, no authentication required.
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
`public` is rejected with a `400` unless the deployment explicitly enables it
for this API. This applies both when creating a room and when updating one.
example: "trusted"
Room:
+76 -1
View File
@@ -20,7 +20,7 @@ info:
* `lasuite_visio:rooms:list` – List rooms accessible to the delegated user.
* `lasuite_visio:rooms:retrieve` – Retrieve details of a specific room.
* `lasuite_visio:rooms:create` – Create new rooms.
* `lasuite_visio:rooms:update` – **Coming soon** Update existing rooms, e.g., add attendees to a room.
* `lasuite_visio:rooms:update` – Update the access level and configuration of existing rooms.
* `lasuite_visio:rooms:delete` – **Coming soon** Delete rooms generated by the application.
#### Upcoming Features
@@ -206,6 +206,67 @@ paths:
'404':
$ref: '#/components/responses/RoomNotFoundError'
patch:
tags:
- Rooms
summary: Update a room
description: |
Partially updates a room. Only rooms where the user is administrator or
owner can be updated; any other role gets a `403`.
**Updatable fields:** `access_level` and `configuration`. Every other field
(`id`, `name`, `slug`, `pin_code`) is read-only and silently ignored when sent.
`configuration` is replaced as a whole, it is not merged with the stored one.
Send the complete object you want the room to end up with.
Full replacement (`PUT`) is not supported. Use `PATCH` instead.
operationId: updateRoom
security:
- BearerAuth: [rooms:update]
parameters:
- name: id
in: path
required: true
description: Room UUID
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RoomUpdate'
examples:
accessLevelOnly:
summary: Change the access level
value:
access_level: "restricted"
configurationOnly:
summary: Replace the room configuration
value:
configuration:
everyone_can_mute: true
responses:
'200':
description: Room updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
'400':
$ref: '#/components/responses/BadRequestError'
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
'404':
$ref: '#/components/responses/RoomNotFoundError'
'405':
description: |
Method not allowed, `PUT` is not supported on this endpoint.
components:
securitySchemes:
BearerAuth:
@@ -227,6 +288,17 @@ components:
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomUpdate:
type: object
description: |
Fields that can be updated on an existing room. Both are optional, omitted
fields keep their current value.
properties:
access_level:
$ref: '#/components/schemas/RoomAccessLevel'
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomConfiguration:
type: object
description: |
@@ -268,6 +340,9 @@ components:
- `public`: Anyone with the room link can join directly, no authentication required.
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
`public` is rejected with a `400` unless the deployment explicitly enables it
for this API. This applies both when creating a room and when updating one.
example: "trusted"
Room:
+1
View File
@@ -34,6 +34,7 @@ Let's say you want to change the font of our application to a custom font. You c
:root {
--fonts-sans: 'Roboto', ui-sans-serif, system-ui, sans-serif;
--avatar-cap-height: 0.7;
}
```
+29 -12
View File
@@ -24,9 +24,10 @@ MEET_BASE_URL="http://localhost:8072"
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_DOMAIN_REPLACE=http://localhost:9000
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
AWS_S3_ENDPOINT_URL=http://garage:9000
AWS_S3_ACCESS_KEY_ID=meet-access-key
AWS_S3_SECRET_ACCESS_KEY=meet-secret-access-key
AWS_S3_REGION_NAME=local
MEDIA_BASE_URL=http://localhost:3000
FILE_UPLOAD_ENABLED=True
@@ -63,23 +64,39 @@ ALLOW_UNREGISTERED_ROOMS=False
# Recording
RECORDING_ENABLE=True
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
SUMMARY_SERVICE_VERSION=2
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe
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
+2
View File
@@ -0,0 +1,2 @@
# Filled with a random value by `make create-env-files`
GARAGE_RPC_SECRET=
+4 -3
View File
@@ -2,8 +2,9 @@ LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
AWS_S3_ENDPOINT_URL=minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
AWS_S3_ENDPOINT_URL=garage:9000
AWS_S3_ACCESS_KEY_ID=meet-access-key
AWS_S3_SECRET_ACCESS_KEY=meet-secret-access-key
AWS_S3_REGION_NAME=local
AWS_STORAGE_BUCKET_NAME=meet-media-storage
AWS_S3_SECURE_ACCESS=False
@@ -1,6 +1,7 @@
AWS_S3_ENDPOINT_URL=minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
AWS_S3_ENDPOINT_URL=garage:9000
AWS_S3_ACCESS_KEY_ID=meet-access-key
AWS_S3_SECRET_ACCESS_KEY=meet-secret-access-key
AWS_S3_REGION_NAME=local
LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey
+4 -3
View File
@@ -2,11 +2,12 @@ APP_NAME="meet-app-summary-dev"
APP_API_TOKEN="password"
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
AWS_S3_ENDPOINT_URL="minio:9000"
AWS_S3_ENDPOINT_URL="garage:9000"
AWS_S3_SECURE_ACCESS=false
AWS_S3_ACCESS_KEY_ID="meet"
AWS_S3_SECRET_ACCESS_KEY="password"
AWS_S3_ACCESS_KEY_ID="meet-access-key"
AWS_S3_SECRET_ACCESS_KEY="meet-secret-access-key"
AWS_S3_REGION_NAME="local"
WHISPERX_BASE_URL="https://configure-your-url.com"
WHISPERX_ASR_MODEL="large-v2"
+1 -1
View File
@@ -1,5 +1,5 @@
{
"extends": ["github>numerique-gouv/renovate-configuration"],
"extends": ["github>suitenumerique/ci//renovate/default"],
"dependencyDashboard": true,
"labels": ["dependencies", "noChangeLog"],
"packageRules": [
+4 -4
View File
@@ -10,7 +10,7 @@
"license": "MIT",
"dependencies": {
"core-js": "3.50.0",
"i18next": "26.3.6",
"i18next": "26.4.2",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
@@ -9367,9 +9367,9 @@
}
},
"node_modules/i18next": {
"version": "26.3.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
"version": "26.4.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
"funding": [
{
"type": "individual",
+1 -1
View File
@@ -27,7 +27,7 @@
},
"dependencies": {
"core-js": "3.50.0",
"i18next": "26.3.6",
"i18next": "26.4.2",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
+1 -1
View File
@@ -21,7 +21,7 @@ const { initI18n, translateUI } = require("../common/i18n");
document.querySelector("#close-msg").style.display = "block";
})
.catch((e) => {
console.error(`Error occured: ${e}`);
console.error(`Error occurred: ${e}`);
})
.finally(() => {
// NOTE: doesn't work with the desktop client — the browser considers
+6 -3
View File
@@ -1,9 +1,12 @@
FROM python:3.14.6-slim AS base
FROM python:3.14.7-slim AS base
# Install system dependencies required by LiveKit
RUN apt-get update && apt-get install -y \
# Install system dependencies required by LiveKit, fetching packages over HTTPS only for Bureautix proxy
RUN sed -i "s|^URIs: http://|URIs: https://|" /etc/apt/sources.list.d/debian.sources \
&& apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0 \
libgobject-2.0-0 \
libpcre2-8-0 \
libssl3t64 \
&& rm -rf /var/lib/apt/lists/*
+35 -18
View File
@@ -6,9 +6,11 @@ import logging
import os
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from io import BytesIO
from typing import List, Optional
import boto3
from botocore.config import Config
from botocore.exceptions import BotoCoreError, ClientError
from dotenv import load_dotenv
from livekit import api, rtc
from livekit.agents import (
@@ -28,8 +30,6 @@ from livekit.agents import (
room_io as lk_room_io,
)
from livekit.plugins import silero
from minio import Minio
from minio.error import S3Error
from exceptions import MissingConfigError
from observability import configure_sentry, set_job_context
@@ -59,6 +59,30 @@ server = AgentServer(
server.setup_fnc = prewarm
def create_s3_client():
"""Create an S3 client for the configured endpoint and region.
The endpoint may be given with or without a scheme: the scheme always
follows AWS_S3_SECURE_ACCESS.
"""
endpoint = (
os.getenv("AWS_S3_ENDPOINT_URL", "")
.removeprefix("https://")
.removeprefix("http://")
.rstrip("/")
)
secure = os.getenv("AWS_S3_SECURE_ACCESS", "False").lower() == "true"
return boto3.client(
"s3",
endpoint_url=f"{'https' if secure else 'http'}://{endpoint}",
aws_access_key_id=os.getenv("AWS_S3_ACCESS_KEY_ID"),
aws_secret_access_key=os.getenv("AWS_S3_SECRET_ACCESS_KEY"),
region_name=os.getenv("AWS_S3_REGION_NAME"),
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
)
@dataclass
class MetadataEvent:
"""A single timestamped event recorded during a meeting."""
@@ -121,18 +145,13 @@ class MetadataCollector:
def __init__(self, ctx: JobContext, recording_id: str):
"""Initialize metadata agent."""
self.minio_client = Minio(
endpoint=os.getenv("AWS_S3_ENDPOINT_URL"),
access_key=os.getenv("AWS_S3_ACCESS_KEY_ID"),
secret_key=os.getenv("AWS_S3_SECRET_ACCESS_KEY"),
secure=os.getenv("AWS_S3_SECURE_ACCESS", "False").lower() == "true",
)
if (bucket_name := os.getenv("AWS_STORAGE_BUCKET_NAME")) is not None:
self.bucket_name = bucket_name
else:
raise MissingConfigError
self.s3_client = create_s3_client()
self.ctx = ctx
self._sessions: dict[str, AgentSession] = {}
self._tasks: set[asyncio.Task] = set()
@@ -201,20 +220,18 @@ class MetadataCollector:
}
data = json.dumps(payload, indent=2).encode("utf-8")
stream = BytesIO(data)
try:
self.minio_client.put_object(
self.bucket_name,
self.output_filename,
stream,
length=len(data),
content_type="application/json",
self.s3_client.put_object(
Bucket=self.bucket_name,
Key=self.output_filename,
Body=data,
ContentType="application/json",
)
logger.info(
"Uploaded speaker meeting metadata",
)
except S3Error:
except (BotoCoreError, ClientError):
logger.exception(
"Failed to upload meeting metadata",
)
+9 -9
View File
@@ -1,24 +1,24 @@
[project]
name = "agents"
version = "1.29.0"
version = "1.33.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.6.7",
"livekit-plugins-deepgram==1.6.7",
"livekit-plugins-silero==1.6.7",
"livekit-agents==1.7.0",
"livekit-plugins-deepgram==1.7.0",
"livekit-plugins-silero==1.7.0",
"livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.2",
"protobuf==6.33.6",
"minio==7.2.20",
"sentry-sdk==2.66.1",
"boto3==1.43.56",
"python-dotenv==1.2.3",
"protobuf==7.36.0",
"sentry-sdk==2.68.1",
"websockets==17.1",
"httpx==0.28.1",
]
[project.optional-dependencies]
dev = [
"ruff==0.16.0",
"ruff==0.16.4",
]
[tool.uv]
+859 -854
View File
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -279,9 +279,16 @@ class RoomAdmin(admin.ModelAdmin):
inlines = (ResourceAccessInline,)
search_fields = ["name", "slug", "=id"]
list_display = ["name", "slug", "access_level", "get_owner", "created_at"]
list_filter = ["access_level", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
list_display = [
"name",
"slug",
"access_level",
"get_owner",
"created_at",
"last_started_at",
]
list_filter = ["access_level", "created_at", "last_started_at"]
readonly_fields = ["id", "created_at", "updated_at", "last_started_at"]
def get_queryset(self, request):
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
+1
View File
@@ -8,6 +8,7 @@ class AnalyticsEvent(StrEnum):
# Rooms
ROOM_CREATED = "room_created"
ROOM_UPDATED = "room_updated"
# Roomkit (meeting-room SIP devices)
ROOMKIT_JOINED = "roomkit_joined"
+2
View File
@@ -71,7 +71,9 @@ def get_frontend_configuration(request):
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
"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
),
-13
View File
@@ -12,10 +12,6 @@ from ..services.participants_management import (
ParticipantsManagementException,
)
ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
}
class IsAuthenticated(permissions.BasePermission):
"""
@@ -27,15 +23,6 @@ class IsAuthenticated(permissions.BasePermission):
return bool(request.auth) or request.user.is_authenticated
class IsAuthenticatedOrSafe(IsAuthenticated):
"""Allows access to authenticated users (or anonymous users but only on safe methods)."""
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return True
return super().has_permission(request, view)
class IsSelf(IsAuthenticated):
"""
Allows access only to authenticated users. Alternative method checking the presence
+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."""
+27
View File
@@ -20,6 +20,33 @@ class MonitoredUserRateThrottle(MonitoredThrottleMixin, UserRateThrottle):
"""Throttle for the monitored scoped rate throttle."""
class RoomCreationUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle room creation per authenticated user.
Can be declared at the viewset level: every action other than "create"
is left unthrottled, so the same class can be reused on any viewset
exposing a room creation endpoint.
"""
scope = "room_creation"
def get_cache_key(self, request, view):
"""Throttle only room creations."""
if getattr(view, "action", None) != "create":
return None
return super().get_cache_key(request, view)
class RoomCreationDailyUserRateThrottle(RoomCreationUserRateThrottle):
"""Cap room creation per authenticated user over a day.
Complements the short-term RoomCreationUserRateThrottle, which absorbs
bursts but lets a user steadily create rooms over hours or days.
"""
scope = "room_creation_daily"
class RequestEntryAuthenticatedUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle authenticated user requesting room entry"""
+42 -87
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 (
@@ -75,11 +76,7 @@ from core.services.participants_management import (
ParticipantsManagementException,
)
from core.services.room_creation import RoomCreation
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from core.services.room_management import RoomManagement
from core.services.room_roles import (
RoomRoleError,
RoomRoleService,
@@ -99,60 +96,6 @@ from .feature_flag import FeatureFlag
logger = getLogger(__name__)
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
A generic Viewset aims to be used in a nested route context.
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
It allows to define all url kwargs and lookup fields to perform the lookup.
"""
lookup_fields: list[str] = ["pk"]
lookup_url_kwargs: list[str] = []
def __getattribute__(self, file):
"""
This method is overridden to allow to get the last lookup field or lookup url kwarg
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
to keep compatibility with all methods used by the parent class `GenericViewSet`.
"""
if file in ["lookup_field", "lookup_url_kwarg"]:
return getattr(self, file + "s", [None])[-1]
return super().__getattribute__(file)
def get_queryset(self):
"""
Get the list of files for this view.
`lookup_fields` attribute is enumerated here to perform the nested lookup.
"""
queryset = super().get_queryset()
# The last lookup field is removed to perform the nested lookup as it corresponds
# to the object pk, it is used within get_object method.
lookup_url_kwargs = (
self.lookup_url_kwargs[:-1]
if self.lookup_url_kwargs
else self.lookup_fields[:-1]
)
filter_kwargs = {}
for index, lookup_url_kwarg in enumerate(lookup_url_kwargs):
if lookup_url_kwarg not in self.kwargs:
raise KeyError(
f"Expected view {self.__class__.__name__} to be called with a URL "
f'keyword argument named "{lookup_url_kwarg}". Fix your URL conf, or '
"set the `.lookup_fields` attribute on the view correctly."
)
filter_kwargs.update(
{self.lookup_fields[index]: self.kwargs[lookup_url_kwarg]}
)
return queryset.filter(**filter_kwargs)
class SerializerPerActionMixin:
"""
A mixin to allow to define serializer classes for each action.
@@ -238,6 +181,10 @@ class RoomViewSet(
permission_classes = [permissions.RoomPermissions]
queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer
throttle_classes = [
throttling.RoomCreationUserRateThrottle,
throttling.RoomCreationDailyUserRateThrottle,
]
def get_object(self):
"""Allow getting a room by its slug."""
@@ -356,26 +303,7 @@ class RoomViewSet(
):
return
metadata = {
"configuration": room.configuration,
"access_level": room.access_level,
}
try:
RoomManagement().update_metadata(
room_name=str(room.id),
metadata=metadata,
)
except RoomNotFoundException:
logger.info(
"LiveKit room %s does not exist yet, skipping metadata sync",
room.id,
)
except RoomManagementException:
logger.warning(
"Failed to sync metadata to LiveKit for room %s",
room.id,
)
RoomManagement.sync_room_metadata(room)
@decorators.action(
detail=True,
@@ -400,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,
@@ -932,6 +868,15 @@ class RoomViewSet(
"""Rename the current participant in the room."""
room = self.get_object()
if (
not settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
and request.user.is_authenticated
):
return drf_response.Response(
{"error": "Authenticated participants cannot edit their display name"},
status=drf_status.HTTP_403_FORBIDDEN,
)
serializer = serializers.RenameParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
@@ -1076,9 +1021,10 @@ class RecordingViewSet(
def _auth_get_original_url(self, request):
"""
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
Extracts and parses the original URL from the configured header.
Raises PermissionDenied if the header is missing.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
The original url is passed by the reverse proxy in the header named by the
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
@@ -1088,9 +1034,13 @@ class RecordingViewSet(
reasons.
"""
# Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER)
if not original_url:
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
logger.warning(
"Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER "
"to the header your reverse proxy sends.",
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
)
raise drf_exceptions.PermissionDenied()
logger.debug("Original url: '%s'", original_url)
@@ -1415,7 +1365,8 @@ class FileViewSet(
Authorize access based on the original URL of an Nginx subrequest
and user permissions. Returns a dictionary of URL parameters if authorized.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
The original url is passed by the reverse proxy in the header named by the
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
@@ -1434,9 +1385,13 @@ class FileViewSet(
- PermissionDenied if authorization fails.
"""
# Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER)
if not original_url:
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
logger.warning(
"Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER "
"to the header your reverse proxy sends.",
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
)
raise drf_exceptions.PermissionDenied()
parsed_url = urlparse(original_url)
+30
View File
@@ -0,0 +1,30 @@
"""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 .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",
"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, so ``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
+161
View File
@@ -0,0 +1,161 @@
"""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_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``;
``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``.
"""
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, so models stay free of audit concerns::
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)
+46
View File
@@ -0,0 +1,46 @@
"""Read the network fields and the request id behind an audit event."""
import uuid
from django.conf import settings
from dockerflow.logging import request_id_context
from rest_framework.throttling import BaseThrottle
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, then echo it on the response.
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.
"""
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()))
response = self.get_response(request)
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
+94
View File
@@ -0,0 +1,94 @@
"""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. Those of CRUD views take their types from the DRF action.
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: the ``fields`` describing them as a target, the ``admin_values``
# whose before and after values may be recorded in the admin, and the
# ``category`` of their admin writes: ``iam`` for anything granting access to
# the product, ``configuration`` by default.
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")
+19 -1
View File
@@ -3,7 +3,11 @@
import contextlib
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from django.core.exceptions import (
ImproperlyConfigured,
SuspiciousOperation,
ValidationError,
)
from django.utils.translation import gettext_lazy as _
from lasuite.oidc_login.backends import (
@@ -17,6 +21,7 @@ from core.services.marketing import (
ContactData,
get_marketing_service,
)
from core.validators import sub_validator
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
@@ -84,6 +89,19 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
sub = str(sub)
try:
sub_validator(sub)
except ValidationError as err:
raise SuspiciousOperation(
"User info contained an invalid sub claim"
) from err
if len(sub) > 255:
raise SuspiciousOperation("User info contained an invalid sub claim")
try:
return User.objects.get(sub=sub)
except User.DoesNotExist:
@@ -286,6 +286,10 @@ class ResourceServerBackend(LaSuiteBackend):
if user is None and settings.OIDC_CREATE_USER:
user = self.create_user(sub)
if user is not None and not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user.pk)
raise SuspiciousOperation("User account is disabled.")
return user
def create_user(self, sub):
+115 -33
View File
@@ -1,6 +1,6 @@
"""External API endpoints"""
from logging import getLogger
import copy
from django.conf import settings
from django.contrib.auth.hashers import check_password
@@ -22,9 +22,10 @@ from rest_framework import (
status as drf_status,
)
from core import analytics, api, models
from core import analytics, api, audit, auditing, models
from core.api.feature_flag import FeatureFlag
from core.services.jwt_token import JwtTokenService
from core.services.room_management import RoomManagement
from ..services.provisional_user_service import (
ProvisionalUserCreationDisabledError,
@@ -33,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):
@@ -66,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)
@@ -78,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:
@@ -90,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.",
@@ -103,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:
@@ -112,6 +114,16 @@ class ApplicationViewSet(viewsets.ViewSet):
status=drf_status.HTTP_409_CONFLICT,
)
if created:
audit.log(
auditing.USER_PROVISION,
request=request,
target=user,
actor_type=audit.ActorType.APPLICATION,
auth_method="client_credentials",
client_id=client_id,
)
scope = " ".join(application.scopes or [])
token_service = JwtTokenService(
@@ -132,16 +144,46 @@ 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,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""Application-delegated API for room management.
@@ -154,8 +196,19 @@ class RoomViewSet(
- list: List rooms the user has access to (requires 'rooms:list' scope)
- retrieve: Get room details (requires 'rooms:retrieve' scope)
- create: Create a new room owned by the user (requires 'rooms:create' scope)
- partial_update: Update a room's access level and configuration, for
administrators and owners only (requires 'rooms:update' scope)
"""
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,
@@ -184,12 +237,37 @@ 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 perform_create(self, serializer):
def _track_room_event(self, room, event, **extra_properties):
"""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")
analytics.capture(
self.request.user,
event,
{
"room_id": str(room.pk),
"access_level": room.access_level,
"client_id": client_id,
"external_api": True,
"auth_method": auth_method,
**extra_properties,
"$set": {"email": self.request.user.email},
},
)
def perform_create(self, serializer: serializers.RoomSerializer):
"""Set the current user as owner of the newly created room."""
room = serializer.save()
models.ResourceAccess.objects.create(
@@ -198,27 +276,31 @@ class RoomViewSet(
role=models.RoleChoices.OWNER,
)
auth_method = type(self.request.successful_authenticator).__name__
client_id = (self.request.auth or {}).get("client_id", "unknown")
self._track_room_event(room, analytics.AnalyticsEvent.ROOM_CREATED)
# Log for auditing
logger.info(
"Room created via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s",
room.id,
self.request.user.id,
client_id,
auth_method,
def perform_update(self, serializer: serializers.RoomSerializer):
"""Persist the room update, sync it to LiveKit, then log and track it."""
previous_values = {
"access_level": serializer.instance.access_level,
"configuration": copy.deepcopy(serializer.instance.configuration),
}
room = serializer.save()
# Report the fields that actually changed, not the ones that were submitted.
updated_fields = sorted(
field
for field, previous_value in previous_values.items()
if getattr(room, field) != previous_value
)
analytics.capture(
self.request.user,
analytics.AnalyticsEvent.ROOM_CREATED,
{
"room_id": str(room.pk),
"access_level": room.access_level,
"client_id": client_id,
"external_api": True,
"auth_method": auth_method,
"$set": {"email": self.request.user.email},
},
if updated_fields:
RoomManagement.sync_room_metadata(room)
self._track_room_event(
room,
analytics.AnalyticsEvent.ROOM_UPDATED,
updated_fields=updated_fields,
previous_access_level=previous_values["access_level"],
)
-4
View File
@@ -48,8 +48,6 @@ class ResourceFactory(factory.django.DjangoModelFactory):
else:
UserResourceAccessFactory(resource=self, user=item[0], role=item[1])
self.save()
class UserResourceAccessFactory(factory.django.DjangoModelFactory):
"""Create fake resource user accesses for testing."""
@@ -97,8 +95,6 @@ class RecordingFactory(factory.django.DjangoModelFactory):
recording=self, user=item[0], role=item[1]
)
self.save()
class UserRecordingAccessFactory(factory.django.DjangoModelFactory):
"""Create fake recording user accesses for testing."""
+25
View File
@@ -0,0 +1,25 @@
"""Logging filters for the core application."""
import logging
from django.conf import settings
class SilenceExpected401(logging.Filter):
"""Drop the expected 401 from anonymous hits on the /me endpoint.
The frontend probes `/users/me/` to check authentication; a 401 for
anonymous users is normal, not a warning worth logging.
"""
def filter(self, record):
"""Return False for a 401 on a silenced path, True otherwise."""
if getattr(record, "status_code", None) != 401:
return True
request = getattr(record, "request", None)
path = getattr(request, "path", None)
if not path:
return True
return path not in settings.LOGGING_SILENCED_401_PATHS
@@ -0,0 +1,94 @@
"""Purge inactive rooms."""
from datetime import timedelta
from itertools import batched
from logging import getLogger
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db.models import Exists, OuterRef, Q
from django.utils import timezone
from core.models import Recording, RecordingStatusChoices, Room
logger = getLogger(__name__)
CHUNK_SIZE = 500
class Command(BaseCommand):
"""
Delete rooms that have not been started for ROOM_INACTIVITY_DELETION_DAYS days:
- rooms which were last started before that period
- rooms never started and created before that period
Rooms holding a saved recording that has not expired are kept.
"""
help = "Purge inactive rooms"
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="List the rooms that would be purged without deleting them",
)
def handle(self, *args, **options):
"""Browse inactive rooms and delete them chunk by chunk."""
if not settings.ROOM_INACTIVITY_DELETION_DAYS:
self.stdout.write(
"Purging inactive rooms is disabled "
"(ROOM_INACTIVITY_DELETION_DAYS is not set)."
)
return
now = timezone.now()
inactive_rooms = self.get_inactive_rooms(now)
inactive_count = inactive_rooms.count()
if not inactive_count:
self.stdout.write("No inactive room to purge.")
return
if options["dry_run"]:
self.stdout.write(
f"[dry-run] {inactive_count} inactive room(s) would be purged:"
)
names = inactive_rooms.values_list("name", flat=True)
for name in names.iterator(chunk_size=CHUNK_SIZE):
self.stdout.write(f"- {name}")
return
purged_count = 0
rooms = inactive_rooms.values_list("pk", "slug").iterator(chunk_size=CHUNK_SIZE)
for chunk in batched(rooms, CHUNK_SIZE, strict=False):
for room_id, slug in chunk:
logger.info("Purging inactive room %s (%s)", room_id, slug)
_, deleted_by_model = inactive_rooms.filter(
pk__in=[room_id for room_id, _ in chunk]
).delete()
purged_count += deleted_by_model.get("core.Room", 0)
self.stdout.write(f"Purged {purged_count} inactive room(s).")
@staticmethod
def get_inactive_rooms(now):
"""Return the rooms inactive for too long that no recording protects."""
threshold = now - timedelta(days=settings.ROOM_INACTIVITY_DELETION_DAYS)
is_inactive = Q(last_started_at__lt=threshold) | Q(
last_started_at__isnull=True, created_at__lt=threshold
)
protected_recordings = Recording.objects.filter(
room=OuterRef("pk"), status__in=RecordingStatusChoices.saved_statuses()
)
if settings.RECORDING_EXPIRATION_DAYS:
protected_recordings = protected_recordings.filter(
created_at__gte=now - timedelta(days=settings.RECORDING_EXPIRATION_DAYS)
)
return Room.objects.filter(is_inactive, ~Exists(protected_recordings))
+3 -1
View File
@@ -8,6 +8,8 @@ import uuid
from django.conf import settings
from django.db import migrations, models
import core.validators
class Migration(migrations.Migration):
@@ -41,7 +43,7 @@ class Migration(migrations.Migration):
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Printable ASCII characters only.', max_length=255, null=True, unique=True, validators=[core.validators.sub_validator], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
@@ -0,0 +1,18 @@
# Generated by Django 5.2.16 on 2026-09-23 16:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0022_user_default_room_access_level_and_more'),
]
operations = [
migrations.AlterField(
model_name='recording',
name='status',
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed', 'Failed'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded'), ('external_process_successful', 'External process successful'), ('external_process_failed', 'External process failed')], default='initiated', max_length=50),
),
]
@@ -0,0 +1,18 @@
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('core', '0023_alter_recording_status'),
]
operations = [
migrations.AddField(
model_name='room',
name='last_started_at',
field=models.DateTimeField(blank=True, default=django.utils.timezone.now, editable=False, help_text='date and time at which the room was last started', null=True, verbose_name='last started at'),
preserve_default=False,
),
]
+22 -18
View File
@@ -27,6 +27,7 @@ from timezone_field import TimeZoneField
from . import fields, utils
from .recording.enums import FileExtension
from .validators import sub_validator
logger = getLogger(__name__)
@@ -57,6 +58,7 @@ class RecordingStatusChoices(models.TextChoices):
STOPPED = "stopped", _("Stopped")
SAVED = "saved", _("Saved")
ABORTED = "aborted", _("Aborted")
FAILED = "failed", _("Failed")
FAILED_TO_START = "failed_to_start", _("Failed to Start")
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
@@ -78,6 +80,7 @@ class RecordingStatusChoices(models.TextChoices):
cls.STOPPED,
cls.SAVED,
cls.ABORTED,
cls.FAILED,
cls.EXTERNAL_PROCESS_SUCCESSFUL,
cls.EXTERNAL_PROCESS_FAILED,
cls.FAILED_TO_START,
@@ -85,9 +88,15 @@ class RecordingStatusChoices(models.TextChoices):
}
@classmethod
def is_unsuccessful(cls, status):
"""Determine if the recording status represents an unsuccessful state."""
return status in {cls.ABORTED, cls.FAILED_TO_START, cls.FAILED_TO_STOP}
def saved_statuses(cls):
"""Return the statuses of a recording whose file users can access."""
return {
cls.NOTIFICATION_SUCCEEDED,
cls.SAVED,
cls.EXTERNAL_PROCESS_SUCCESSFUL,
cls.EXTERNAL_PROCESS_FAILED,
}
class RecordingModeChoices(models.TextChoices):
@@ -145,19 +154,11 @@ class BaseModel(models.Model):
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_ characters."
),
)
sub = models.CharField(
_("sub"),
help_text=_(
"Optional for pending users; required upon account activation. "
"255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
"255 characters or fewer. Printable ASCII characters only."
),
max_length=255,
unique=True,
@@ -436,6 +437,13 @@ class Room(Resource):
verbose_name=_("Room PIN code"),
help_text=_("Unique n-digit code that identifies this room in telephony mode."),
)
last_started_at = models.DateTimeField(
verbose_name=_("last started at"),
help_text=_("date and time at which the room was last started"),
blank=True,
null=True,
editable=False,
)
class Meta:
db_table = "meet_room"
@@ -589,6 +597,7 @@ class Recording(BaseModel):
4. NOTIFICATION_SUCCEEDED: External service has been notified of this recording
Error States:
- FAILED: Egress failed mid-recording
- FAILED_TO_START: Worker failed to initialize recording
- FAILED_TO_STOP: Worker failed during stop operation
- ABORTED: Recording was terminated before completion
@@ -691,12 +700,7 @@ class Recording(BaseModel):
@property
def is_saved(self) -> bool:
"""Check if the recording is in a saved state."""
return self.status in {
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
RecordingStatusChoices.SAVED,
RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL,
RecordingStatusChoices.EXTERNAL_PROCESS_FAILED,
}
return self.status in RecordingStatusChoices.saved_statuses()
@property
def extension(self):
+47
View File
@@ -8,3 +8,50 @@ class FileExtension(Enum):
OGG = "ogg"
MP4 = "mp4"
class RecordingWorkerEvent(Enum):
"""Lifecycle events a recording worker reports about a recording.
It is intended to be free of SFU-specific vocabulary.
"""
# The worker accepted the request but is not recording yet.
STARTING = "starting"
# The worker is recording.
STARTED = "started"
# The worker stopped recording and is flushing the media file.
SAVING = "saving"
# The recording ended, its media file is available.
COMPLETED = "completed"
# The recording ended on its configured limit, its media file is available.
LIMIT_REACHED = "limit reached"
# The worker stopped before it ever started recording, there is no media file.
ABORTED = "aborted"
# The worker hit a runtime error once recording had started; its media file
# may be available.
FAILED = "failed"
@classmethod
def is_terminal(cls, event):
"""Determine if the event ends the recording's lifecycle (successful or not)."""
return event in TERMINAL_EVENTS
SUCCESSFUL_EVENTS = frozenset(
{
RecordingWorkerEvent.COMPLETED,
RecordingWorkerEvent.LIMIT_REACHED,
}
)
UNSUCCESSFUL_EVENTS = frozenset(
{
RecordingWorkerEvent.ABORTED,
RecordingWorkerEvent.FAILED,
}
)
TERMINAL_EVENTS = SUCCESSFUL_EVENTS | UNSUCCESSFUL_EVENTS
@@ -12,7 +12,7 @@ logger = logging.getLogger(__name__)
class MachineUser:
"""Represent a non-interactive system user for automated operations."""
"""Represent an internal service authenticated by a shared secret, not an account."""
def __init__(self, username: str = "machine_user") -> None:
self.pk = None
@@ -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

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