Compare commits

..

1 Commits

Author SHA1 Message Date
leo 65905d3b86 ⬆️(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:04:43 +02:00
393 changed files with 6857 additions and 27552 deletions
-9
View File
@@ -1,9 +0,0 @@
[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
@@ -1,20 +0,0 @@
# 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
@@ -0,0 +1,28 @@
---
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
@@ -0,0 +1,23 @@
---
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! -->
@@ -0,0 +1,22 @@
---
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
@@ -0,0 +1,11 @@
## Purpose
Description...
## Proposal
Description...
- [] item 1...
- [] item 2...
-19
View File
@@ -1,19 +0,0 @@
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
+205 -71
View File
@@ -11,30 +11,180 @@ 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
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 }}
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 .
test-back:
runs-on: ubuntu-latest
needs: build-mails
permissions:
contents: read
defaults:
@@ -76,9 +226,8 @@ 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-access-key
AWS_S3_SECRET_ACCESS_KEY: meet-secret-access-key
AWS_S3_REGION_NAME: local
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
OIDC_RS_CLIENT_ID: meet
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
@@ -88,33 +237,47 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Create writable /data
run: |
sudo mkdir -p /data/media && \
sudo mkdir -p /data/static
- name: Build or restore the mail templates
uses: suitenumerique/ci/actions/mail-templates@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
- 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') }}
# Creates the access key and the bucket on startup
- name: Start Garage
- name: Start MinIO
run: |
docker run -d --name garage \
docker pull minio/minio
docker run -d --name minio \
-p 9000:9000 \
-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
-e "MINIO_ACCESS_KEY=meet" \
-e "MINIO_SECRET_KEY=password" \
-v /data/media:/data \
minio/minio server --console-address :9001 /data
- name: Wait for Garage to be ready
# Tool to wait for a service to be ready
- name: Install Dockerize
run: |
timeout 30 sh -c 'until docker exec garage /garage health; do sleep 1; done'
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"
- name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
@@ -147,9 +310,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: "garage:9000"
AWS_S3_ACCESS_KEY_ID: "meet-access-key"
AWS_S3_SECRET_ACCESS_KEY: "meet-secret-access-key"
AWS_S3_ENDPOINT_URL: "minio:9000"
AWS_S3_ACCESS_KEY_ID: "meet"
AWS_S3_SECRET_ACCESS_KEY: "password"
WHISPERX_BASE_URL: "https://configure-your-url.com"
WHISPERX_ASR_MODEL: "large-v2"
WHISPERX_API_KEY: "test-whisperx-secret"
@@ -161,8 +324,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Install ffmpeg
run: |
@@ -190,8 +351,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Install dependencies
run: cd src/frontend/ && npm ci --ignore-scripts
@@ -202,27 +361,6 @@ jobs:
- name: Check format
run: cd src/frontend/ && npm run check
test-front:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Install Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: "24"
- name: Install dependencies
run: cd src/frontend/ && npm ci --ignore-scripts
- name: Run tests
run: cd src/frontend/ && npm test
lint-sdk:
runs-on: ubuntu-latest
permissions:
@@ -233,8 +371,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Install dependencies
run: npm ci --ignore-scripts
@@ -256,8 +392,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Install dependencies
run: npm ci --ignore-scripts
-14
View File
@@ -1,14 +0,0 @@
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
@@ -0,0 +1,33 @@
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 }}
+252 -49
View File
@@ -1,5 +1,5 @@
name: Docker images
run-name: Docker images
name: Docker Hub Workflow
run-name: Docker Hub Workflow
on:
workflow_dispatch:
@@ -15,62 +15,265 @@ 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:
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 }}
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 }}
notify-argocd:
permissions:
contents: read
needs:
- build-and-push
- build-and-push-frontend-generic
- build-and-push-frontend-dinum
- build-and-push-backend
- build-and-push-summary
- build-and-push-agents
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
steps:
- uses: suitenumerique/ci/actions/argocd-webhook-notification@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
- uses: numerique-gouv/action-argocd-webhook-notification@cac2ee67896eb13e84e804f60c4271370424eaa8 # main
id: notify
with:
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
+23 -7
View File
@@ -1,17 +1,33 @@
name: Release Helm chart
name: Release Chart
run-name: Release Chart
on:
push:
branches:
- main
paths:
- src/helm/meet/**
permissions:
contents: read
jobs:
release:
permissions:
contents: write
uses: suitenumerique/ci/.github/workflows/_release-helm-chart.yml@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
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 }}
-21
View File
@@ -1,21 +0,0 @@
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
@@ -1,5 +0,0 @@
rules:
unpinned-uses:
config:
policies:
"suitenumerique/*": ref-pin
-4
View File
@@ -86,7 +86,3 @@ docker/livekit/rootCA.pem
# Frontend rollup-plugin-visualizer
/src/frontend/rollup-plugin-visualizer/*
# NixOS
.devenv
.pre-commit-config.yaml
-3
View File
@@ -22,9 +22,6 @@
# See http://jorisroovers.github.io/gitlint/user_defined_rules for details
extra-path=gitlint/
# Use "search" semantics for regexes (gitlint's future default)
regex-style-search=True
# [title-max-length]
# line-length=80
+1 -167
View File
@@ -10,182 +10,16 @@ and this project adheres to
### Added
- ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
- ✨(backend) add room soft-deletion to the external API
- ✨(all) allow any authenticated user to start a recording
- ✨(backend) authenticate external API calls with Menshen exchanged tokens
- ✨(backend) audit Menshen calls and users created from introspected tokens
### Fixed
- 🔒️(frontend) upgrade libx11 to fix CVE-2026-88806
- 🔒️(backend) throttle the start recording endpoint
## [1.35.0] - 2026-10-13
### Added
- ✨(frontend) let users pick the recording encoding
- ✨(backend) introduce a token exchange endpoint for iframe embeds
- ⚡️(backend) send recording notification emails from a Celery task
- ✨(backend) allow static feature flags without an analytics backend #1809
### Changed
- ⚡️(backend) hash application secrets with SHA-256
- ⚡️(backend) reduce domain queries on the application token endpoint
- ♻️️️(backend) use a dedicated auth scheme for LiveKit token auth
- ♻️(all) stop relying on cookies for the lobby flow
- ♿️(frontend) show a visible focus outline on menu items #1797
- ⬆️(backend) upgrade pylint to 4.1.3
### Fixed
- 🔒️(backend) prevent editing client id and secret in Django admin
- 🔒️(frontend) upgrade tiff to fix CVE-2026-4775
- 🔒️(backend) bind accepted lobby entries to the current username
- 🔒️(backend) sign lobby participant id
- 🐛(frontend) omit the default speaker id sent to LiveKit
- 🐛(frontend) play the speaker test when no output is picked
- 🐛(frontend) keep the admin panel in place when changing a setting #1804
## [1.34.0] - 2026-10-07
### Added
- ✨(helm) import environment variables from Secrets and ConfigMaps
- 🔒(backend) throttle meeting link generation
- 🔒️(backend) add a daily cap on room creation
- 🔧(summary) add setting to control Sentry traces sampling rate
- ✨(frontend) let signed-out visitors start a meeting
- ✨(backend) expose `allow_unregistered_rooms` in the frontend configuration
- ✅(frontend) add vitest so the frontend can carry unit tests
- ♿️(frontend) make participant pagination readable and keyboard reachable #1775
- ✨(backend) add structured audit logging facility
- ✨(backend) audit external API token and room operations
- 🔒️(backend) audit writes and bulk actions made in the Django admin
- ✨(backend) audit recordings, transcripts and summaries
- ✨(backend) audit transit code issuance and exchange
### Changed
- ✨(frontend) warn users when the connection falls back to TURN
- 🔧(backend) configure the technical documentation url
### Fixed
- 🐛(frontend) enforce recording-mode permissions on the checkboxes
- 🔒️(agents) fix util-linux CVEs reported by Cyberwatch
- 🔒️(backend) fix HIGH CVEs in Django and urllib3
- 🔒️(agents) upgrade libpcre2-8-0 to fix CVE-2026-103111
- 🔒️(frontend) upgrade pcre2 to fix CVE-2026-103111
- 🐛(summary) disable default S3 checksums for GCS-compatible storage
- 🔒️(summary) redact meeting content from Sentry events
- 🐛(frontend) hide tooltips until they have a computed placement
- 🐛(brevo) use django-lasuite for marketing management
- ♿️(frontend) expose loading state to assistive technology
- 🐛(frontend) honour Keep hand raised when picture-in-picture is open
## [1.33.0] - 2026-09-30
### 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
@@ -474,7 +308,7 @@ and this project adheres to
### Fixed
- ♿️(frontend) improve accessibility of the Effects panel #1401
- ♿️(frontend) improve accessibilty of the Effects panel #1401
## [1.20.0] - 2026-06-12
+4 -3
View File
@@ -1,7 +1,7 @@
# Django Meet
# ---- base image to inherit from ----
FROM python:3.13.15-alpine3.24 AS base
FROM python:3.13.5-alpine3.21 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip
@@ -37,13 +37,14 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- mails ----
FROM node:22-alpine AS mail-builder
FROM node:22 AS mail-builder
COPY ./src/mail /mail/app
WORKDIR /mail/app
RUN npm ci --ignore-scripts && npm run build
RUN yarn install --frozen-lockfile && \
yarn build
# ---- static link collector ----
+4 -54
View File
@@ -69,22 +69,6 @@ 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
@@ -93,9 +77,6 @@ default: help
data/media:
@mkdir -p data/media
$(STORAGE_DIRS):
@mkdir -p $@
data/static:
@mkdir -p data/static
@@ -104,7 +85,6 @@ 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 \
@@ -169,7 +149,7 @@ run-summary: ## start only the summary application and all needed services
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
.PHONY: run-summary
run-agents: ## start the LiveKit agents (opt-in, see docs/developping_locally.md)
run-agents: ## start the multi-user-transcriber agent
@$(MAKE) run-agent-multi-user-transcriber
@$(MAKE) run-agent-metadata-collector
.PHONY: run-agents
@@ -186,6 +166,7 @@ run:
run: ## start the wsgi (production) and development server
@$(MAKE) run-backend
@$(MAKE) run-summary
@$(MAKE) run-agents
@$(COMPOSE) up --force-recreate -d frontend
.PHONY: run
@@ -259,8 +240,7 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
$(MAKE) test-back-parallel ARGS="$${args}" && \
$(MAKE) test-summary ARGS="$${args}" && \
$(MAKE) test-frontend
$(MAKE) test-summary ARGS="$${args}"
.PHONY: test
test-back: ## run back-end tests (pass extra pytest args via ARGS)
@@ -278,10 +258,6 @@ test-summary: ## run summary tests (pass extra pytest args via ARGS)
bin/pytest-summary $${args}
.PHONY: test-summary
test-frontend: ## run the frontend unit tests
cd $(PATH_FRONT) && npm test
.PHONY: test-frontend
makemigrations: ## run django makemigrations for the Meet project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@@ -316,7 +292,7 @@ shell: ## connect to database shell
# -- Database
dbshell: ## connect to database shell
@$(COMPOSE_EXEC_APP) python manage.py dbshell
docker compose exec app-dev python manage.py dbshell
.PHONY: dbshell
resetdb: FLUSH_ARGS ?=
@@ -341,38 +317,12 @@ 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:
+3 -3
View File
@@ -11,7 +11,7 @@
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/meet"/>
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
<img alt="GitHub license" src="https://img.shields.io/github/license/suitenumerique/meet"/>
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
</a>
<a href="https://digitalpublicgoods.net/r/la-suite-meet-simple-video-conferencing">
<img src="https://img.shields.io/badge/Verified-DPG-3333AB?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iMzEiIGhlaWdodD0iMzMiIHZpZXdCb3g9IjAgMCAzMSAzMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0LjIwMDggMjEuMzY3OEwxMC4xNzM2IDE4LjAxMjRMMTEuNTIxOSAxNi40MDAzTDEzLjk5MjggMTguNDU5TDE5LjYyNjkgMTIuMjExMUwyMS4xOTA5IDEzLjYxNkwxNC4yMDA4IDIxLjM2NzhaTTI0LjYyNDEgOS4zNTEyN0wyNC44MDcxIDMuMDcyOTdMMTguODgxIDUuMTg2NjJMMTUuMzMxNCAtMi4zMzA4MmUtMDVMMTEuNzgyMSA1LjE4NjYyTDUuODU2MDEgMy4wNzI5N0w2LjAzOTA2IDkuMzUxMjdMMCAxMS4xMTc3TDMuODQ1MjEgMTYuMDg5NUwwIDIxLjA2MTJMNi4wMzkwNiAyMi44Mjc3TDUuODU2MDEgMjkuMTA2TDExLjc4MjEgMjYuOTkyM0wxNS4zMzE0IDMyLjE3OUwxOC44ODEgMjYuOTkyM0wyNC44MDcxIDI5LjEwNkwyNC42MjQxIDIyLjgyNzdMMzAuNjYzMSAyMS4wNjEyTDI2LjgxNzYgMTYuMDg5NUwzMC42NjMxIDExLjExNzdMMjQuNjI0MSA5LjM1MTI3WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==" alt="DPG Badge"/>
@@ -49,8 +49,8 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
- Telephony integration
- Secure participation with robust authentication and access control
- Customizable frontend style
- LiveKit advanced features including:
- speaker detection
- LiveKit Advances features including :
- speaker detection
- simulcast
- end-to-end optimizations
- selective subscription
-225
View File
@@ -16,231 +16,6 @@ the following command inside your docker container:
## [Unreleased]
### Purging deleted rooms
Deleting a room now only soft deletes it: it is hidden but keeps its slug and PIN code. The `purge_inactive_rooms` command also permanently deletes soft-deleted rooms. See [the room purge documentation](docs/features/room-purge.md).
- Inactive soft-deleted rooms are purged along with the other inactive rooms: if `ROOM_INACTIVITY_DELETION_DAYS` is already set, nothing else is needed.
- The new `ROOM_DELETED_RETENTION_DAYS` setting purges soft-deleted rooms that many days after their deletion, whether they are inactive or not. It is unset by default.
- As for inactive rooms, a deleted room holding a saved recording its users may still access is kept until that recording expires.
### Recording permission
We've introduced the ability for any authenticated participant to start a recording or transcription, but this behavior remains configurable.
You can keep recording and transcription restricted to room admins and owners by setting `RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED` to `false`.
## [v1.34.0]
### Marketing / Brevo integration now uses `django-lasuite`
The in-house marketing service (`core.services.marketing`) has been removed and
replaced by the shared implementation from `django-lasuite`
(`lasuite.marketing`). This fixes a bug where updating a user's contact on
Brevo overwrote their list memberships, removing lists set by other
La Suite products. Existing lists are now preserved and merged.
**Celery worker required.** Newsletter signup on login
(`SIGNUP_NEW_USER_TO_MARKETING_EMAIL=True`) is now dispatched as an
asynchronous Celery task (`lasuite.marketing.tasks.create_or_update_contact`)
instead of a synchronous call with a 1s timeout. Make sure a Celery worker is
running alongside the backend, otherwise contacts will never be pushed to Brevo.
**Configuration changes.** The following environment variables / settings are
**removed** and no longer read:
- `MARKETING_SERVICE_CLASS`
- `BREVO_API_KEY`
- `BREVO_API_CONTACT_LIST_IDS`
- `BREVO_API_CONTACT_ATTRIBUTES` (previous default: `{"VISIO_USER": True}`)
- `BREVO_API_TIMEOUT`
They are replaced by a single `LASUITE_MARKETING` setting, configured through:
| Variable | Default | Description |
| ------------------------------ | ------------------------------------------------ | -------------------------------------------- |
| `LASUITE_MARKETING_BACKEND` | `lasuite.marketing.backends.dummy.DummyBackend` | Backend class path |
| `LASUITE_MARKETING_PARAMETERS` | `{}` | Keyword arguments passed to the backend |
⚠️ The default backend is now a **dummy** (no-op). If you previously used
Brevo, you must explicitly configure it, otherwise signups are silently dropped:
LASUITE_MARKETING_BACKEND=lasuite.marketing.backends.brevo.BrevoBackend
LASUITE_MARKETING_PARAMETERS={"api_key": "<your-brevo-api-key>", "api_contact_list_ids": [1, 2], "api_contact_attributes": {"VISIO_USER": True}}
Migration mapping:
- `BREVO_API_KEY` → `api_key`
- `BREVO_API_CONTACT_LIST_IDS` → `api_contact_list_ids`
- `BREVO_API_CONTACT_ATTRIBUTES` → `api_contact_attributes` (re-add
`{"VISIO_USER": True}` if you relied on the old default)
- `BREVO_API_TIMEOUT` → no equivalent (the request runs in a background task)
Note: `BREVO_API_KEY` used to support being read from a secret file; the API key
now lives inside `LASUITE_MARKETING_PARAMETERS`, so adapt how you inject that
secret (e.g. build the whole variable from your secret store).
### 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.33.0
### Purging inactive rooms
Rooms now keep track of the last time they were started (`last_started_at`), fed by LiveKit's `room_started` webhook. A new `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for `ROOM_INACTIVITY_DELETION_DAYS` days. See [the room purge documentation](docs/features/room-purge.md).
- The feature is **disabled by default**: nothing is deleted unless you set `ROOM_INACTIVITY_DELETION_DAYS`.
- The migration marks every existing room as started at the time of the upgrade, so no existing room can be purged before a full inactivity period has elapsed after upgrading.
- Rooms holding a saved recording their users may still access are kept: any saved recording, or, when `RECORDING_EXPIRATION_DAYS` is set, a saved recording created within that window.
- Inactivity is measured from LiveKit's `room_started` webhook: if it is not delivered to your backend, rooms in daily use look inactive and get purged.
- When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again and can be reused when a meeting is created from that same URL.
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights.
* With `ALLOW_UNREGISTERED_ROOMS=true`, any user can reopen the purged room by navigating to the same URL. In that case, the room is created dynamically and no corresponding room entry is persisted in the database.
### Local development: MinIO replaced by Garage
The development stacks now use [Garage](https://garagehq.deuxfleurs.fr/) instead of MinIO as S3 storage. Garage keeps its own format in `data/media/meta` and `data/media/data` and cannot read what MinIO left there, so local recordings and files will be lost.
To migrate a local environment:
1. Stop the stack and remove its containers, including the former `minio` one: `docker compose down --remove-orphans`
2. Optionally reclaim the space used by MinIO: `rm -rf data/media && make data/media`
3. In your `env.d/development/*` files, replace `minio:9000` by `garage:9000`, the `meet` / `password` credentials by `meet-access-key` / `meet-secret-access-key`, and add `AWS_S3_REGION_NAME=local` (or delete these files and run `make create-env-files`)
4. Run `make create-env-files` to generate `env.d/development/garage`, which holds a random RPC secret for Garage.
5. Rebuild the images, since the summary and agent images now install boto3 instead of minio
### Summary service and metadata collector: boto3 replaces the minio client
The summary service and the metadata collector agent now talk to S3 through boto3 instead of the minio client, with the same settings.
Requests are now signed for `AWS_S3_REGION_NAME` as-is. When it is not set, the region is no longer looked up from the bucket: boto3 falls back to `AWS_DEFAULT_REGION`. If you left `AWS_S3_REGION_NAME` unset, set it to your provider's region before upgrading, or providers that check the signing region will reject the transcripts, summaries and meeting metadata uploads, as well as their signed URLs.
Also:
- Signed URLs to transcripts and summaries are now always path-style (`<endpoint>/<bucket>/<key>`), whereas the minio client used virtual-hosted-style URLs
- The metadata collector now accepts `AWS_S3_ENDPOINT_URL` with or without a scheme, like the summary service: the scheme always follows `AWS_S3_SECURE_ACCESS`.
### Helm chart: media services default to Garage
The `meet` chart now defaults `serviceMedia.host` and `serviceMediaFiles.host` to `garage.meet.svc.cluster.local`, and the `upstream-vhost` annotation of `ingressMedia` and `ingressMediaFiles` to `garage.meet.svc.cluster.local:9000`. If you relied on the former `minio.meet.svc.cluster.local` defaults, set these values explicitly to your S3 service before upgrading, or recordings and files stop being served under `/media`.
## v1.30.0
### Removing S3 storage-event webhooks for recordings
+8 -9
View File
@@ -104,16 +104,15 @@ k8s_yaml(secret_yaml_generic(
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev-keycloak} template .'))
k8s_resource('dev-backend-garage-cors', resource_deps=['dev-backend-garage'])
k8s_resource('dev-backend-keycloak', resource_deps=['dev-backend-keycloak-pg'])
k8s_resource('meet-backend', resource_deps=['dev-backend-postgres', 'dev-backend-garage-cors', 'dev-backend-redis', 'dev-backend-keycloak', 'livekit-livekit-server'])
k8s_resource('meet-celery-backend', resource_deps=['dev-backend-redis'])
k8s_resource('meet-celery-summarize', resource_deps=['dev-backend-redis'])
k8s_resource('meet-celery-summary-backend', resource_deps=['dev-backend-redis'])
k8s_resource('meet-celery-transcribe-default', resource_deps=['dev-backend-redis'])
k8s_resource('livekit-livekit-server', resource_deps=['dev-backend-redis'])
k8s_resource('minio-bucket', resource_deps=['minio'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
k8s_resource('meet-celery-backend', resource_deps=['redis'])
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
k8s_resource('meet-celery-transcribe-default', resource_deps=['redis'])
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
k8s_resource('livekit-egress', resource_deps=['livekit-livekit-server'])
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
# Trigger once on launch
k8s_resource(
'meet-backend-createsuperuser',
+10 -9
View File
@@ -5,7 +5,7 @@ set -eo pipefail
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
UNSET_USER=0
export COMPOSE_FILE="${COMPOSE_FILE:-${REPO_DIR}/compose.yml}"
COMPOSE_FILE="${REPO_DIR}/compose.yml"
COMPOSE_PROJECT="meet"
@@ -42,6 +42,7 @@ function _docker_compose() {
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
docker compose \
-p "${COMPOSE_PROJECT}" \
-f "${COMPOSE_FILE}" \
--project-directory "${REPO_DIR}" \
"$@"
}
@@ -55,12 +56,12 @@ function _docker_compose() {
function _dc_run() {
_set_user
user_args=()
if [ -n "$USER_ID" ]; then
user_args=("--user=$USER_ID")
user_args="--user=$USER_ID"
if [ -z $USER_ID ]; then
user_args=""
fi
_docker_compose run --rm "${user_args[@]}" "$@"
_docker_compose run --rm $user_args "$@"
}
# _dc_exec: wrap docker compose exec command
@@ -74,12 +75,12 @@ function _dc_exec() {
echo "🐳(compose) exec command: '\$@'"
user_args=()
if [ -n "$USER_ID" ]; then
user_args=("--user=$USER_ID")
user_args="--user=$USER_ID"
if [ -z $USER_ID ]; then
user_args=""
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 successfully"
echo "[custom-logo] INFO: Custom logo downloaded successfuly"
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 -TERM -P $$' SIGTERM
trap "pkill SIGTERM -P $$" SIGTERM
# wait for a single child to finish,
wait -n
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# shellcheck source=bin/_config.sh
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_docker_compose "$@"
+5 -4
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
set -o errexit
CURRENT_DIR=$(pwd)
NAMESPACE=${1:-meet}
SECRET_NAME=${2:-bitwarden-cli-meet}
TEMP_SECRET_FILE=$(mktemp)
@@ -29,10 +30,10 @@ check_secret_exists() {
# Collect user input securely
get_user_input() {
echo "Please provide the following information:"
read -r -p "Enter your Vaultwarden email login: " LOGIN
read -r -s -p "Enter your Vaultwarden password: " PASSWORD
read -p "Enter your Vaultwarden email login: " LOGIN
read -s -p "Enter your Vaultwarden password: " PASSWORD
echo
read -r -p "Enter your Vaultwarden server url: " URL
read -p "Enter your Vaultwarden server url: " URL
}
# Create and apply the secret
@@ -76,7 +77,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 -r -p "Enter release version number (e.g., 1.2.3): " VERSION
read -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,5 +1,4 @@
#!/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
+44 -50
View File
@@ -5,56 +5,46 @@ services:
env_file:
- env.d/development/postgresql
ports:
- "127.0.0.1:15432:5432"
- "15432:5432"
redis:
image: redis:5
ports:
- "127.0.0.1:6379:6379"
mailcatcher:
image: sj26/mailcatcher:latest
ports:
- "127.0.0.1:1081:1080"
- "1081:1080"
garage:
minio:
user: ${DOCKER_USER:-1000}
image: dxflrs/garage:v2.4.1
command: /garage server --single-node --default-bucket
env_file:
- env.d/development/garage
image: minio/minio
environment:
- GARAGE_DEFAULT_ACCESS_KEY=meet-access-key
- GARAGE_DEFAULT_SECRET_KEY=meet-secret-access-key
- GARAGE_DEFAULT_BUCKET=meet-media-storage
- MINIO_ROOT_USER=meet
- MINIO_ROOT_PASSWORD=password
ports:
- '127.0.0.1:9000:9000'
- '9000:9000'
- '9001:9001'
healthcheck:
test: [ "CMD", "/garage", "health" ]
test: [ "CMD", "mc", "ready", "local" ]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server --console-address :9001 /data
volumes:
- ./docker/files/etc/garage/garage.toml:/etc/garage.toml:ro
- ./data/media:/var/lib/garage
- ./data/media:/data
# 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
createbuckets:
image: minio/mc
depends_on:
garage:
minio:
condition: service_healthy
restart: true
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"]}]}'
entrypoint: >
sh -c "
/usr/bin/mc alias set meet http://minio:9000 meet password && \
/usr/bin/mc mb meet/meet-media-storage && \
exit 0;"
app-dev:
build:
@@ -71,7 +61,7 @@ services:
- env.d/development/common
- env.d/development/postgresql
ports:
- "127.0.0.1:8071:8000"
- "8071:8000"
volumes:
- ./src/backend:/app
- ./data/static:/data/static
@@ -80,7 +70,7 @@ services:
- postgresql
- mailcatcher
- redis
- garage-cors
- createbuckets
extra_hosts:
- "127.0.0.1.nip.io:host-gateway"
networks:
@@ -120,7 +110,7 @@ services:
- postgresql
- redis
- livekit
- garage
- minio
celery:
user: ${DOCKER_USER:-1000}
@@ -137,7 +127,7 @@ services:
nginx:
image: nginx:1.25
ports:
- "127.0.0.1:8083:8083"
- "8083:8083"
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
@@ -155,11 +145,10 @@ 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:
- "127.0.0.1:3000:8080"
- "3000:8080"
dockerize:
image: jwilder/dockerize
@@ -175,7 +164,7 @@ services:
working_dir: /app
node:
image: node:22-alpine
image: node:22
user: "${DOCKER_USER:-1000}"
environment:
HOME: /tmp
@@ -185,7 +174,7 @@ services:
kc_postgresql:
image: postgres:14.3
ports:
- "127.0.0.1:5433:5432"
- "5433:5432"
env_file:
- env.d/development/kc_postgresql
@@ -213,7 +202,7 @@ services:
KC_DB_SCHEMA: public
PROXY_ADDRESS_FORWARDING: 'true'
ports:
- "127.0.0.1:8080:8080"
- "8080:8080"
depends_on:
- kc_postgresql
@@ -222,10 +211,10 @@ services:
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
ports:
- "7880:7880"
- "127.0.0.1:7881:7881"
- "127.0.0.1:7882:7882/udp"
- "127.0.0.1:3478:3478/udp"
- "127.0.0.1:30000-30100:30000-30100/udp"
- "7881:7881"
- "7882:7882/udp"
- "3478:3478/udp"
- "30000-30100:30000-30100/udp"
volumes:
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
depends_on:
@@ -255,7 +244,7 @@ services:
- /app/.venv
depends_on:
- livekit
- garage
- minio
develop:
watch:
- action: rebuild
@@ -273,6 +262,11 @@ services:
- ./src/agents:/app
- /app/.venv
redis-summary:
image: redis
ports:
- "6379:6379"
app-summary-dev:
build:
context: src/summary
@@ -283,11 +277,11 @@ services:
env_file:
- env.d/development/summary
ports:
- "127.0.0.1:8001:8000"
- "8001:8000"
volumes:
- ./src/summary:/app
depends_on:
- redis
- redis-summary
celery-summary-transcribe:
container_name: celery-summary-transcribe
@@ -301,9 +295,9 @@ services:
volumes:
- ./src/summary:/app
depends_on:
- redis
- redis-summary
- app-summary-dev
- garage
- minio
develop:
watch:
- action: rebuild
@@ -321,9 +315,9 @@ services:
volumes:
- ./src/summary:/app
depends_on:
- redis
- redis-summary
- app-summary-dev
- garage
- minio
develop:
watch:
- action: rebuild
-86
View File
@@ -1,86 +0,0 @@
{
"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"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1791909798,
"narHash": "sha256-VAhpCOmrun/KXimYfR8NM88bciKCui7CqHHhThSzgag=",
"ref": "refs/heads/master",
"rev": "1ba1dbfe28738c9a71c266be6efa5b89e75491a9",
"shallow": true,
"type": "git",
"url": "https://github.com/cachix/git-hooks.nix"
},
"original": {
"shallow": true,
"type": "git",
"url": "https://github.com/cachix/git-hooks.nix"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1792295010,
"narHash": "sha256-F7meJtbqGttmUoMmYjRRgkVs42sn7bh7vvRvLPlUN98=",
"ref": "nixos-26.05",
"rev": "8ce73368d1efd4d657e9064d29b144a52046b4e8",
"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",
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
-267
View File
@@ -1,267 +0,0 @@
# =============================================================================
# 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;
git-hooks.hooks.gitlint.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
'';
};
}
-10
View File
@@ -1,10 +0,0 @@
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
git-hooks:
url: git+https://github.com/cachix/git-hooks.nix?shallow=1
inputs:
nixpkgs:
follows: nixpkgs
-48
View File
@@ -1,48 +0,0 @@
# 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
- "127.0.0.1:8081:8080"
-35
View File
@@ -1,35 +0,0 @@
# 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
+11 -6
View File
@@ -54,14 +54,19 @@ RUN npx webpack --mode production
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
USER root
RUN apk upgrade --no-cache libexpat && \
apk add --no-cache --upgrade 'pcre2>=10.49-r0' 'tiff>=4.7.2-r0' \
'libx11>=1.8.14-r0' && \
apk del curl
USER nginx
# 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 \
libexpat>=2.8.4-r0 \
&& apk del curl
USER nginx
-1
View File
@@ -1,6 +1,5 @@
:root {
--fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif;
--avatar-cap-height: 0.7;
}
.Header-beforeLogo {
-15
View File
@@ -1,15 +0,0 @@
# 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"
+3 -33
View File
@@ -4,36 +4,6 @@ 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
@@ -47,9 +17,9 @@ server {
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Garage
proxy_pass http://garage:9000/meet-media-storage/;
proxy_set_header Host garage:9000;
# Get resource from Minio
proxy_pass http://minio:9000/meet-media-storage/;
proxy_set_header Host minio:9000;
# To use with ds_proxy
# proxy_pass http://ds-proxy:4444/upstream/meet-media-storage/;
# proxy_set_header Host ds-proxy:4444;
@@ -14,7 +14,3 @@ 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"
)
@@ -21,10 +21,3 @@ 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 -32
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
- Garage for storing files (room recordings)
- MinIO 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:
@@ -107,37 +107,6 @@ $ npm i
$ npm run dev
```
### LiveKit agents (optional)
The LiveKit agents are not started by `make run`. Each one runs its own
container and stays connected to LiveKit, which costs CPU and memory you
don't need unless you work on the features they power. Start them only
when you need them.
| Agent | Feature | Make command | Setting in `env.d/development/common` |
|---|---|---|---|
| `metadata-collector-dev` | Recording metadata (used to identify speakers in transcripts) | `make run-agent-metadata-collector` | `METADATA_COLLECTOR_ENABLED=True` |
| `multi-user-transcriber-dev` | Live subtitles | `make run-agent-multi-user-transcriber` | `ROOM_SUBTITLE_ENABLED=True` |
To start both at once:
```shellscript
$ make run-agents
```
Then set the matching settings to `True` and restart the backend so it
picks them up:
```shellscript
$ make run-backend
```
The multi-user transcriber also needs a speech-to-text provider. Configure
`STT_PROVIDER` and its credentials in
`env.d/development/multi_user_transcriber`.
Keep the settings and the agents in sync: if a setting is `True` while its
agent is stopped, the backend still dispatches jobs to it and the feature
fails silently.
---
## Adding Content
-495
View File
@@ -1,495 +0,0 @@
# Audit logging
La Suite Meet keeps an **audit log**: a record of the notable things that happen in the product, such as a room created
through the external API, a recording started, a sign-in, or a change made in the Django admin. Every entry answers the
same questions: *who* did *what*, *to what*, *on whose behalf*, *from where*, and *did it work?*
It is written for:
- **operators and security teams** investigating an incident or watching for abuse
- **data protection officers**, who need to know who accessed or changed what
- **developers** adding a new audited action (see [For developers](#for-developers-emitting-events))
Each event is a single line of JSON written to standard output, kept apart from the application logs.
Events follow the [Elastic Common Schema](https://www.elastic.co/docs/reference/ecs)
(ECS) 9.5.0, which most SIEMs understand out of the box.
Personal data is kept to a minimum. A person is identified by their account id, their OIDC `sub` and the domain of their
email address. Secrets, transit codes and the messages of unexpected exceptions are never recorded.
## What an event looks like
Events are written on the dedicated `audit` logger, one per line. Pretty-printed, one looks like this:
```json
{
"@timestamp": "2026-09-15T08:41:12.345+00:00",
"ecs": {"version": "9.5.0"},
"data_stream": {"type": "logs", "dataset": "meet.audit", "namespace": "default"},
"service": {"name": "meet", "environment": "production", "version": "1.34.0", "node": {"name": "meet-backend-6d8f7b9c4-x2x7q"}},
"event": {
"kind": "event",
"id": "0b3f6a0e-5d1c-4f43-9a8e-2b7c1d0e4f5a",
"action": "room.create",
"category": ["api"],
"type": ["creation"],
"outcome": "success",
"dataset": "meet.audit"
},
"client": {"ip": "1.2.3.4"},
"source": {"ip": "1.2.3.4"},
"http": {"request": {"id": "6f1c0d0e-2a8b-4c1d-9e7f-0a1b2c3d4e5f", "method": "POST"}, "response": {"status_code": 201}},
"url": {"path": "/external-api/v1.0/rooms/"},
"user_agent": {"original": "calendar-app/2.3"},
"user": {"id": "beecd833-4be4-4675-b139-a196b07144a9", "domain": "gouv.fr"},
"organization": {"id": "calendar-app"},
"entity": {
"target": {
"id": "9ae54744-ae64-44f4-b094-71e137556b66",
"sub_type": "room",
"name": "Daily standup",
"raw": {"slug": "daily-standup", "access_level": "trusted"}
}
},
"lasuite": {
"actor": {"type": "application", "sub": "0edebfa3-1355-4891-ae63-daf9fd37ac04"},
"auth": {"method": "application_jwt"},
"application": {"client_id": "calendar-app"},
"outcome": "success"
},
"log": {"level": "info", "logger": "audit"}
}
```
Read it as: *the application `calendar-app`, acting on behalf of a user from `gouv.fr`, created the room "Daily standup"
from `1.2.3.4`, and it worked.*
When an action is refused, the event keeps the name of the action that was attempted, here still `room.create`. Only
the fields describing the outcome change:
```json
{
"event": {"action": "room.create", "outcome": "failure", "type": ["creation", "denied"], "reason": "permission_denied"},
"lasuite": {"outcome": "denied"},
"http": {"response": {"status_code": 403}},
"error": {"message": "..."},
"log": {"level": "warning", "logger": "audit"}
}
```
## Key concepts
These words are used throughout this page and in the code (`core/audit/`). The *In the event* column says where to
find each one in the JSON above.
### The record
| Term | Meaning | In the event |
|---|---|---|
| **Audit Event** | One immutable record that a notable action was attempted, by whom, on what, and with what outcome. Written as a single JSON line on the Audit Log. | The whole document, identified by `event.id` |
| **Audit Log** | The stream of Audit Events. It is distinct from the application logs: a logger and a dataset of its own. | `data_stream.*`, `event.dataset` |
| **Action** | The dotted, stable name of what was attempted, such as `room.create` or `application.token.issue`. It names the attempt, not its result: a refused `room.create` is still a `room.create`. Every Action belongs to the Catalogue. | `event.action` |
| **Catalogue** | The closed list of Actions the code may emit, with their meaning and fields: see [Catalogue](#catalogue). Writes made through the Django admin are its one templated family, `admin.<target>.<verb>`. | — |
| **Details** | The fields specific to one Action, such as the scopes granted with a token or the fields a room update changed. The Catalogue lists them. | `lasuite.details.*` |
### Who acted
| Term | Meaning | In the event |
|---|---|---|
| **Actor** | Who is behind an Action. It always has an Actor Type. When a person's account is involved, the event also carries the account id, its OIDC `sub` when it has one, and its email domain. | `lasuite.actor.*`, `user.*` |
| **Actor Type** | What kind of Actor it is: `user`, `application`, `service`, `system` or `anonymous`. See [Actors](#actors). Device accounts are `user`s like any other account. | `lasuite.actor.type` |
| **Auth Method** | How the Actor proved who it is for this request: `session`, `oidc`, `password`, `application_jwt`, `client_credentials`, `addons_jwt`, `user_access_jwt`, `transit_code`, `resource_server`, `menshen_token`, `livekit_token`, `shared_secret`, `none` when it did not authenticate, or `unknown`. | `lasuite.auth.method` |
| **Application** | A registered third-party integration, identified by its client id, that calls the external API on behalf of users. | `lasuite.application.client_id` |
| **Delegation** | An Application acting for one specific user, through the delegated token it obtained with `application.token.issue`. The event then names both: the Application as the Actor, and the user in `user.*`. | `lasuite.actor.type` = `application`, with `user.*` set |
| **Provisional User** | An account an Application created from an email address before that person ever signed in. It has no OIDC `sub` yet, so its account id is what links its `user.provision` event to its later `user.login`. | `event.action` = `user.provision` |
| **Machine User** | The identity of an internal service calling the backend with a shared secret: the LiveKit SIP bridge (`roomkit`), the summary service (`summary`), and the LiveKit server reporting that a recording ended (`livekit`). It is an Actor of type `service`, never an account. | `service.origin.name` |
| **Tenant** | The organisation an event belongs to: the calling Application when there is one, otherwise the Actor's email domain. | `organization.id` |
### What it was done to
| Term | Meaning | In the event |
|---|---|---|
| **Target** | The resource the Action was attempted on: a room, a recording, an access grant, an application, a user or a transit code. Described by its type, its id and a few stable fields. See [Targets](#targets). | `entity.target.*` |
| **User Target** | The person the Action was *about*, when there is one: a provisioned user, the user an access grants a role to, the account an admin edits, or the user a transit code is minted for. Not to be confused with `user.*`, the account whose authority was used. | `user.target.*` |
### How it went
| Term | Meaning | In the event |
|---|---|---|
| **Outcome** | Whether the Action succeeded (`success`), failed (`failure`), was refused (`denied`), or ended in a way the backend could not interpret (`unknown`). ECS has no `denied`: in `event.outcome`, a refusal is a `failure`. | `lasuite.outcome`, `event.outcome` |
| **Reason** | Why an Action did not succeed, from a closed list: `authentication_failed`, `permission_denied`, `rate_limited`, `validation_error`, `not_found`, `conflict`, `internal_error`. | `event.reason` |
| **Refusal** | An Action the backend would not let happen, such as a 401, 403 or 429. It is recorded under the Action that was attempted, with Outcome `denied`, never as an Action of its own. | `lasuite.outcome` = `denied` |
### Where and when
| Term | Meaning | In the event |
|---|---|---|
| **Source** | Where the request came from: the real client IP (never a proxy's), the HTTP method, the URL path and the user agent. Empty when no request is behind the event. | `client.ip`, `source.ip`, `http.request.method`, `url.path`, `user_agent.original` |
| **Request Id** | The id shared by the Audit Events and the web server access log of one HTTP request, what other tools call a trace id. The backend generates it, unless the ingress is trusted to set it. | `http.request.id` |
| **Emitter** | The backend instance that wrote the event: its name, environment, release and host (the pod, on Kubernetes). Never the caller. | `service.name`, `service.environment`, `service.version`, `service.node.name` |
| **Peer Service** | Another service of the deployment taking part in the Action: the one that called the backend, or the one the backend called. | `service.origin.name`, `service.target.name` |
### Where events go
| Term | Meaning | In the event |
|---|---|---|
| **Sink** | Where Audit Events are written: the `audit` logger, whose handler writes to `AUDIT_LOG_STREAM`, standard output by default. A log shipper takes them from there. | `log.logger` = `audit` |
| **Data Stream** | The data stream events are meant for, `logs-meet.audit-default` by default. It is what lets a shipper route audit events away from the application logs. | `data_stream.*` |
### Embedded frontends
| Term | Meaning | In the event |
|---|---|---|
| **Transit Code** | A single-use, short-lived code an Application mints for its delegated user and hands to an embedded frontend, which exchanges it for a User Access Token. Only its SHA-256 is ever logged. | `entity.target.sub_type` = `transit_code` |
| **User Access Token** | The token an embedded frontend uses to call the API as the user, where session cookies are blocked (in an iframe, for example). Requests made with it are recorded as the Application acting for that user. | `lasuite.auth.method` = `user_access_jwt` |
## Answering common questions
These are the fields to filter on for the questions that come up most. They work the same whatever tool you search
your logs with.
**Who created, changed or deleted this room?**
Filter on `entity.target.sub_type: room` with the room's id in `entity.target.id`, or its slug in
`entity.target.raw.slug`. `event.action` says what happened, and `user.id` or `lasuite.application.client_id` who did
it. Rooms are audited when they are handled through the external API (`room.*`) or the Django admin (`admin.room.*`).
**What has this application done?**
Filter on `lasuite.application.client_id`. To include the attempts made before its credentials were verified, such as a
wrong secret or an inactive application, also search `lasuite.details.claimed_client_id`.
**What did this person do, and what was done to their account?**
`user.id` is the account whose authority an action used, and `user.target.id` the account an action was about: search
both. To follow someone across the other La Suite products, use their OIDC `sub`, in `lasuite.actor.sub`.
**Which requests were refused, and why?**
Filter on `lasuite.outcome: denied` and group by `event.reason`. A burst of `authentication_failed` on `user.login` or
`application.token.issue` from a single `source.ip` deserves a closer look.
**What else happened during this request?**
Take its `http.request.id`. The same id is sent back in the `X-Request-ID` response header, and appears on every audit
event of the request and in the Gunicorn access log as `rid=`. The application logs do not carry it. A single request
may produce several events: an application obtaining a token for someone who never signed in produces both
`user.provision` and `application.token.issue`.
## Fields reference
Events follow the [Elastic Common Schema](https://www.elastic.co/docs/reference/ecs) 9.5.0. Only two namespaces hold
what ECS does not define: `lasuite.*`, and `entity.target.raw.*` for the fields of a target.
| Field | Meaning |
|---|---|
| `@timestamp` | When the event was emitted: ISO 8601, in UTC, with millisecond precision |
| `data_stream.*`, `event.dataset` | `logs`, `<AUDIT_LOG_SERVICE_NAME>.audit` and `AUDIT_LOG_DATA_STREAM_NAMESPACE`: what sets the audit stream apart from the application logs |
| `service.name`, `service.environment`, `service.version`, `service.node.name` | The Emitter: `AUDIT_LOG_SERVICE_NAME`, the current environment, the release and the host name (the pod, on Kubernetes). Never the caller |
| `service.origin.name` | The internal service that called the backend, when the actor is a `service`: `roomkit`, `summary` or `livekit` |
| `service.target.name` | The service the backend called, such as `summary` for `recording.transcript.request` |
| `event.id` | Unique id of the event, so that a shipper retrying it cannot create a duplicate |
| `event.action` | What was attempted, from the [Catalogue](#catalogue) |
| `event.category`, `event.type` | The ECS classification (`api`, `authentication`, `iam`... and `creation`, `change`, `access`, `denied`, `user`...). Always a combination ECS expects, see [Classification](#classification) |
| `event.outcome` | ECS `success` or `failure` (refusals included), or `unknown` when the result was reported in terms the backend does not recognise |
| `event.reason` | Why it did not succeed: `authentication_failed`, `permission_denied`, `rate_limited`, `validation_error`, `not_found`, `conflict`, `internal_error` |
| `lasuite.outcome` | `success`, `failure`, `denied` or `unknown`: the same as `event.outcome`, with refusals kept apart |
| `lasuite.actor.type` | `user`, `application`, `service`, `system` or `anonymous`, see [Actors](#actors) |
| `lasuite.actor.sub` | The OIDC `sub` of the account in `user.*`, when it has one: the identity it shares with the other La Suite products |
| `lasuite.auth.method` | `session`, `application_jwt`, `addons_jwt`, `user_access_jwt`, `resource_server`, `menshen_token`, `livekit_token`, `shared_secret`, `client_credentials`, `transit_code`, `oidc`, `password`, `none`, or `unknown` for an authentication class that is not registered. Requests served outside DRF, such as the admin and logout, report `session` when signed in |
| `lasuite.application.client_id` | The external application acting, if any. Only set once its credentials are verified. For a Menshen token, Menshen's own client id (see [Actors](#actors)) |
| `user.id`, `user.domain` | The account whose authority the action used (see [Actors](#actors)): its primary key and email domain. The email address itself is never recorded |
| `user.roles` | That account's privileges when the event happened: `superuser`, `staff` |
| `user.target.id`, `user.target.domain` | The account the action was about: the target when it is a user, or the user an access grants a role to. `user.*` stays the actor |
| `organization.id` | The Tenant: the application's client id when there is one, otherwise the user's email domain |
| `entity.target.*` | The resource acted on, see [Targets](#targets) |
| `lasuite.details` | Fields specific to the action, see the [Catalogue](#catalogue) |
| `client.ip`, `source.ip` | The real client address, the same one DRF's throttles identify (see `NUM_PROXIES`) |
| `http.request.method`, `url.path` | The request as received |
| `user_agent.original` | The client's `User-Agent`, cut at 1024 characters |
| `http.request.id` | The Request Id. Also sent back as the `X-Request-ID` response header and logged by Gunicorn as `rid=`. Generated by the backend unless `REQUEST_ID_TRUST_HEADER` is set |
| `http.response.status_code` | The status of the response, for API calls and refusals |
| `error.message` | A human-readable reason for a failure |
| `error.type` | The class of an unhandled exception. Its message is left out, as it may contain personal data |
| `log.level` | `info` for a success, `warning` for failures, refusals and unknown outcomes, `error` for internal errors |
### Targets
The resource an action was performed on is described with ECS `entity.target`, the field set ECS 9.3 introduced for
"the targeted entity of an action taken". ECS still flags the `entity` fields as beta.
| Field | Meaning |
|---|---|
| `entity.target.id` | Its primary key |
| `entity.target.sub_type` | Its model: `room`, `recording`, `user`, `application`, `resourceaccess` or `transit_code` (identified by its SHA-256) |
| `entity.target.type` | Its ECS entity type, when one fits: `user` for an account, `application` for an application |
| `entity.target.name` | Its name, when its model registers one |
| `entity.target.raw.*` | The other fields its model registers, such as a room's `slug` and `access_level`, or the OIDC `sub` of a user |
### Actors
`lasuite.actor.type` says *who* acted, and `user.*` *whose authority* the action used. They are not always the same: when
an application acts for a user, the actor is the application, but `user.*` is that user.
| `lasuite.actor.type` | Who | `user.*` |
|---|---|---|
| `user` | A person's account acting for itself: through a session, an OIDC or password login, an add-on token, or the LiveKit token of a known account | That account |
| `application` | A client application acting on behalf of a user. Either a Meet application, through its client credentials, its delegated token or the user access token of its embedded frontend, or another La Suite application, through the resource server or a token exchanged with Menshen. `lasuite.application.client_id` names it | The delegating user |
| `service` | An internal service of the deployment, named by `service.origin.name`: the LiveKit SIP bridge (`roomkit`), the summary service (`summary`), or the LiveKit server reporting on a recording (`livekit`) | Absent |
| `system` | The backend itself, with no inbound request | Absent |
| `anonymous` | A caller that did not authenticate, or failed to | Absent |
The backend picks the actor type by applying these rules in order:
1. A client id is known, usually from the token payload: `application`.
2. There is neither a request nor an actor: `system`.
3. The caller authenticated without an account, as a Machine User: `service`.
4. The caller is an account: `user`.
5. Anything else: `anonymous`.
A La Suite application calling the external API with a token it exchanged through Menshen, La Suite's token exchange
server, is recorded as an `application`, with `lasuite.auth.method` `menshen_token`. Menshen reports itself as the client
the token was issued to, not the application that exchanged it, so `lasuite.application.client_id` and `organization.id`
are Menshen's client id for every such call. The token is never recorded, nor its `jti`, which for an opaque token
Menshen takes from the token itself, nor the email Menshen reports.
### Classification
`event.category` and `event.type` are only ever combined in the ways the ECS 9.5.0 `expected_event_types` allow:
- Each action declares its category, `api` by default, and its types. A type its category does not expect fails at
import time, so a misclassified action can't ship.
- A refusal adds the `denied` type only when the category expects it. An API call becomes `["creation", "denied"]`. A
failed login stays `["start"]`, and `event.outcome`, `event.reason` and `lasuite.outcome` show that it was refused.
- A 401 is also filed under `authentication`, as in `["api", "authentication"]`, so that it counts as a failed
authentication.
When an audited API action raises an exception DRF does not handle, the event is still recorded before the exception
propagates: as a `failure` with reason `internal_error`, status code `500` and `error.type`.
## Catalogue
Action names are dotted and lower-case, in the form `<target>.<verb>`. They name what was *attempted*: whether it worked
is told by `event.outcome`, `lasuite.outcome` and `event.reason`, never by the name.
### Applications and delegation
| `event.action` | Emitted when | Notable fields |
|---|---|---|
| `application.token.issue` | An application asks for a delegated token (`POST /external-api/v1.0/application/token/`), whether it gets one or is refused. It can be refused for bad credentials, an inactive application, an invalid or unauthorized email domain, an unknown user, or a provisioning conflict | On success: `user.*` is the delegated user, `entity.target` the application, and `lasuite.details` holds `scopes`, `user_provisioned` and `expires_in`. On refusal: `event.reason`, `http.response.status_code` and `lasuite.details.requested_domain`. Until its credentials are verified, the client id the caller submitted is only recorded as `lasuite.details.claimed_client_id`: it never sets `lasuite.application` or `organization` |
| `user.provision` | An application creates a provisional user from an email address, or fails to. It is a `failure` with reason `conflict` when a concurrent request created the user first. Also emitted when a La Suite application calls the external API, through the resource server or with a Menshen token, for a `sub` no account has and `OIDC_CREATE_USER` lets the backend create one | `user.target` and `entity.target` are the user, the existing one on a conflict. `lasuite.auth.method` is `client_credentials`, `resource_server` or `menshen_token`. A provisional user has no `sub` yet: its id links this event to the winning one and to its later `user.login`. On a conflict over an email, `lasuite.details.requested_sub` is the `sub` no account was created for |
| `transit_code.issue` | An application mints a single-use transit code for its delegated user | `entity.target` is the code, whose `id` is its SHA-256. `user.target` is the user it is minted for. `lasuite.details.expires_in` |
| `transit_code.exchange` | An embedded frontend exchanges a transit code for a user access token | `event.category` is `authentication`, `lasuite.auth.method` is `transit_code`, and `entity.target` is the code's SHA-256. Once the code verifies, even if the exchange is then refused: `lasuite.actor.type` is `application` (the one that minted it), and `user.target` the user it was minted for. On success, `user.*` is that user too, with `lasuite.details.expires_in`. A code that does not verify is recorded as an `anonymous` failed authentication |
### Rooms
Rooms are audited on the external API only.
| `event.action` | Emitted when | Notable fields |
|---|---|---|
| `room.create` | A room is created through the external API, or the attempt fails | `entity.target` is the room |
| `room.update` | A room is updated through the external API, or the attempt fails | `entity.target` is the room, refusals included. `lasuite.details.updated_fields` and `previous_access_level` |
| `room.delete` | A room is deleted through the external API: soft deleted, its LiveKit room closed. Or the attempt fails | `entity.target` is the room, refusals included |
| `room.retrieve` | A room is read through the external API, or the attempt fails | `entity.target` is the room |
| `room.list` | Rooms are listed through the external API, or the attempt fails | `lasuite.details.total` |
### Recordings, transcripts and summaries
| `event.action` | Emitted when | Notable fields |
|---|---|---|
| `recording.start` | A room owner or administrator starts a recording, or the attempt fails: a recording already in progress, a worker error | `entity.target` is the recording, or the room when none was created. `lasuite.details.collect_metadata` |
| `recording.stop` | A room owner or administrator stops the recording in progress, or the attempt fails | `entity.target` is the recording, or the room when none is active |
| `recording.end` | LiveKit reports that a recording ended (`egress_ended` webhook): `success` when its media file is available, `failure` when it was aborted or failed, `unknown` for a status the backend does not map | `service.origin.name` is `livekit`. `entity.target` is the recording as it was before the report was processed. `lasuite.details.worker_event` (`completed`, `limit reached`, `aborted`, `failed`) and `error_code` |
| `recording.delete` | A recording is deleted, or the attempt fails | `entity.target` is the recording |
| `recording.transcript.request` | The backend sends a recording to the summary service to be transcribed, or fails to | `lasuite.actor.type` is `system`, `service.target.name` is `summary`, and `entity.target` is the recording. `lasuite.details.summary_requested` (whether a summary of the transcript is to be made too) and `job_id` |
| `recording.transcript.report` | The summary service reports on a transcript (`external-process-hook`), or a call to that hook is refused | `service.origin.name` is `summary`. `entity.target` is the recording, absent for an unknown job. `lasuite.details.job_id` and `status`. A failure the service reports is recorded as a `failure` |
| `recording.summary.report` | The summary service reports on a summary | Same as `recording.transcript.report` |
A recording's target says what was recorded:
- `mode` is how the media was captured: `screen_recording` for a video, `transcript` for an audio track;
- `requested_mode` is what the user asked for;
- `is_transcribed` says whether it is sent to the summary service.
`mode` and `requested_mode` differ for a transcript started with a screen capture: `mode` is then `screen_recording`,
and `requested_mode` is `transcript`.
Transcripts and summaries are not recordings, but what the summary service derives from one. They are audited under
`recording.transcript.*` and `recording.summary.*`, with the recording as their target.
### Sign-in and the admin
| `event.action` | Emitted when | Notable fields |
|---|---|---|
| `user.login` | A user signs in, or a sign-in attempt fails: `denied`, with reason `authentication_failed` | `lasuite.auth.method` is `oidc`, `password` or `unknown`. On success, it is named after the login backend, also given in `lasuite.details.auth_backend`. On failure, it is named after the credentials submitted: a password, or the nonce of the OIDC callback |
| `user.logout` | A user signs out | |
| `admin.access` | A signed-in account without staff access reaches an admin page. Always denied, once per refused page | `event.category` is `web` and `event.type` is `access`. `event.reason`, and `http.response.status_code`: the redirect to the login page |
| `admin.<target>.<verb>` | A write is made through the Django admin, see [Django admin](#django-admin) | |
## Django admin
The admin is the most sensitive part of the product, so every write made through it produces an audit event, alongside
the `LogEntry` Django already writes. Nothing is replaced and there is no extra table: the admin history keeps working.
The action name follows a template, `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`, and 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` |
This gives names such as `admin.room.update`, `admin.user.delete` or `admin.recording.action`. `event.category` is `iam`
for anything that grants access to the product, and `configuration` otherwise. Writes on a user or a group put `user` or
`group` first in `event.type`, as in `["user", "change"]`.
### What changed
- `lasuite.details.changed_fields` always lists the **names** of the fields a form changed, exactly the ones Django
reports in its own history.
- `lasuite.details.changes` gives their **values**, as `{"from": ..., "to": ...}`, but only for the fields a model
explicitly allows in the `admin_values` it is registered with (see
[Registering models](#registering-models-and-authentication-methods)).
- Anything that looks like a secret is refused, whatever the allow-list says. A password change is reported as a change
to `password`, never with its value.
- A `JSONField` on the allow-list, such as a room's `configuration`, is recorded as JSON rather than as a string, and
both versions are kept whole.
### Inlines
Objects edited through an **inline** produce their own event, linked to the parent's by `http.request.id`. Granting a
role on a room produces both `admin.room.update` and `admin.resourceaccess.create`, whose `user.target` is the account
granted the role.
### What is 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.
- **Custom actions that bypass the ORM hooks.** An action calling `queryset.update()` or `queryset.delete()` directly
is reported as a single `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` per object and no `action` event.
### How it is wired
The wiring lives in `core/audit/admin.py`. `AuditedAdminSite` adds auditing to every admin class when it is registered,
including those Django declares 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
[Registering models](#registering-models-and-authentication-methods)) only adds its category and its allowed values.
## For developers: emitting events
### Declaring an action
Every action is declared once, in `core/auditing.py`, as an `audit.Action` constant. 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")
```
`core/auditing.py` imports nothing but the audit facility, so that any module can import it, authentication classes
included. Models and authentication classes are registered apart, in `core/audit_registry.py`.
### Auditing a DRF view
A DRF view only declares the actions it audits: everything else is derived from the response. CRUD actions are mapped
in `audit_actions`. 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): ...
```
`AuditViewMixin` fills in the rest:
- It runs from DRF's `finalize_response` hook, which every response goes through, successful or not.
- The ECS category and types come from the action, or default to `api` and the DRF action.
- The outcome, reason and status code come from the response status: 401, 403 and 429 are `denied`, other errors are
`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_user_target`, `audit_actor` (`None` records no account) and `audit_details`, or override
`get_audit_fields()`.
### Auditing from anywhere else
Outside of `AuditViewMixin`, from a view or deeper in a service, call `audit.log`. A `category` or `types` given here
wins over the action's:
```python
audit.log(auditing.USER_PROVISION, target=user)
```
Without `request=`, the event reads the context of the request being served, `audit.request_context()`, which
`AuditLogMiddleware` captures when the request comes in. Outside a request, as in a Celery task, the context is empty
and the actor is the system.
> [!NOTE]
> That context holds the Django request. DRF copies its user and auth onto it, but not its authenticator, so a call
> made inside a DRF view without `request=` should pass `auth_method=`.
### How fields are filled
- **Request fields** are read from the request: the real client address, the path and the user agent.
`http.request.id` is the request id, settled by `AuditLogMiddleware` right after dockerflow assigns it, and echoed in
the `DOCKERFLOW_REQUEST_ID_HEADER_NAME` response header (`X-Request-ID` by default). The inbound id is only kept when
`REQUEST_ID_TRUST_HEADER` is set. Otherwise the backend generates one, so a client never picks it.
- **The actor** is derived from `request.user`, `request.auth` and the DRF authenticator, as described in
[Actors](#actors). The authenticator's class is mapped to an auth method by `audit.register_auth_method` (see
[Configuration](#configuration)). Without a request, the actor is the system. You can override it with `actor=`,
`actor_type=`, `auth_method=` and `client_id=`. An explicit `actor_type=` keeps the account the request is signed in
as. `actor=None` records no account: for a caller failing to authenticate, an application acting before it is given
a delegated user, or the system.
- **The target** is described by its model name, its primary key and the `fields` its model is registered with, see
[Targets](#targets). A model that is not registered is still identified. The account the action is about,
`user.target`, is found from the target, unless given as `user_target=`. Name a service the backend calls with
`target_service=`. Any other keyword argument lands under `lasuite.details`.
### Emission never raises
A broken configuration or value is reported on the application logger (and to Sentry), and the business operation goes
on. A registered field that cannot be read is left out of the target, and the event is still emitted.
## Configuration
| Variable | Default | Meaning |
|---|---|---|
| `AUDIT_LOG_LEVEL` | `INFO` | Level of the `audit` logger |
| `AUDIT_LOG_STREAM` | `ext://sys.stdout` | Where the handler writes |
| `AUDIT_LOG_SERVICE_NAME` | `meet` | `service.name`, and the dataset `<name>.audit`, with any `-` turned into `_` |
| `AUDIT_LOG_DATA_STREAM_NAMESPACE` | `default` | `data_stream.namespace`: events are routed to the data stream `logs-meet.audit-<namespace>`. It must not contain `-` |
| `NUM_PROXIES` | `1` | DRF's number of trusted proxies appending to `X-Forwarded-For`, shared with the throttles. The client is the entry that many positions from the right; anything a client injects lands further left and is ignored. `1` matches the ingress-nginx defaults; use `2` behind a load balancer that also appends |
| `REQUEST_ID_TRUST_HEADER` | `False` | Reuse the inbound request id as `http.request.id`, so that the ingress, Gunicorn 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): otherwise a client could pick the id of someone else's request |
| `DOCKERFLOW_REQUEST_ID_HEADER_NAME` | `X-Request-ID` | The header carrying that id. Read on the request only when `REQUEST_ID_TRUST_HEADER` is set, always echoed on the response |
### Registering models and authentication methods
The project describes itself to the audit facility in code, in `core/audit_registry.py`. Once the audit app is ready,
it imports the `audit_registry` module of every installed app.
**`audit.register(Model, ...)`** tells the audit log how to handle a model:
- `fields`: the fields describing it as a target;
- `admin_values`: the fields whose before and after values may be recorded for admin writes;
- `category`: the ECS category of its admin writes;
- `entity_type`: its ECS entity type, when one of the allowed values fits (`application`, `user`...);
- `user_target`: the attribute holding the account an event on it is about, such as `user` for an access.
A proxy model falls back to its concrete model. Registering a model twice raises `AlreadyRegistered`, and an entity type
ECS does not allow raises `ValueError`.
**`audit.register_auth_method(klass, name)`** sets 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, because custom backends often subclass `ModelBackend` just for its permission
checks. `ModelBackend` is built in as `password`.
+35 -43
View File
@@ -3,7 +3,7 @@
La Suite Meet offers a room recording feature that is currently in beta, with ongoing improvements planned.
The feature allows users to record their room sessions. When a recording is complete, the user who started it and the room's owners and administrators receive a notification with a link to download the recorded file. The transcript summary is sent to the user who started the recording. Recordings are automatically deleted after `RECORDING_EXPIRATION_DAYS`.
The feature allows users to record their room sessions. When a recording is complete, the room owner receives a notification with a link to download the recorded file. Recordings are automatically deleted after `RECORDING_EXPIRATION_DAYS`.
It uses LiveKit Egress to record room sessions. For reference, see the [LiveKit Egress repository](https://github.com/livekit/egress) and the [official documentation](https://docs.livekit.io/home/egress/overview/).
@@ -89,18 +89,17 @@ sequenceDiagram
| Option | Type | Default | Description |
| --------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **RECORDING_ENABLE** | Boolean | `False` | Enable or disable the room recording feature. |
| **RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED**| Boolean | `True` | When enabled, any authenticated participant currently connected to a room with the `trusted` or `public` access level can start and stop recordings. When disabled, only room admins and owners can. On `restricted` rooms, only admins and owners can record. |
| **RECORDING_OUTPUT_FOLDER** | String | `"recordings"` | Folder/prefix where recordings are stored in the object storage. |
| **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_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. |
| **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`. |
> [!NOTE]
@@ -131,59 +130,52 @@ This allows you to verify which recordings are in progress, troubleshoot egress
## Tuning recording encoding
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.
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.
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.
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.
### How values map to GStreamer
| 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) |
| 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) |
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.
### Built-in profiles
### Reference profiles
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.
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.
| 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 |
| 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 |
To pick a profile per recording (requires `RECORDING_CUSTOM_ENCODING_ENABLED=True`), the client sends it in the start-recording request:
★ Recommended starting point for typical LaSuite Meet usage.
```json
{
"mode": "screen_recording",
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}}
}
```
To change the default encoding applied to every recording:
Environment variables for the **Low CPU / small file** profile:
```bash
RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
RECORDING_ENCODING_DEFAULT_PROFILE=talking_heads
RECORDING_ENCODING_ENABLED=True
RECORDING_ENCODING_WIDTH=1280
RECORDING_ENCODING_HEIGHT=720
RECORDING_ENCODING_FRAMERATE=15
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600
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 `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).
- **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).
- **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.
-46
View File
@@ -1,46 +0,0 @@
# 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, and the rooms deleted by their owner for longer than a configurable retention period. 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.
Deleting a room through the API only soft deletes it: the room is hidden, its link tells visitors the meeting was deleted, and it keeps its slug and PIN code. A soft-deleted room is purged:
- when it is inactive, as described above: a deleted room is never considered started again, so its inactivity period keeps running, or
- when it was deleted more than `ROOM_DELETED_RETENTION_DAYS` days ago.
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` or `ROOM_DELETED_RETENTION_DAYS` is set.
```bash
python manage.py purge_inactive_rooms # delete the inactive and deleted 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. A room is kept, past its inactivity or retention period, 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. This also applies to a soft-deleted room: once purged, its link
no longer tells visitors the meeting was deleted, and its PIN code can be given to another room.
* 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.
+7 -8
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 S3 as S3 (Object Storage)
participant MinIO as MinIO (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->>S3: Fetch audio file
Celery->>MinIO: Fetch audio file
Celery->>STT: Transcribe audio (WhisperX)
STT-->>Celery: Segmented transcript
@@ -72,12 +72,11 @@ 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 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. |
| 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. |
| 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 -22
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 (S3-compatible object storage, email). See `/features` folder for details.
> [!NOTE] Some advanced features, as Recording and transcription, require additional services (MinIO, email). See `/features` folder for details.
## Software Requirements
@@ -113,33 +113,12 @@ DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
#DJANGO_EMAIL_TIMEOUT=30 # Seconds before a stalled SMTP connection times out.
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://meet.yourdomain.tld/assets/logo-suite-numerique.png"
```
### Feature flags without an analytics backend
Some features, such as automatic meeting summaries (`summary-enabled`), are gated by a feature flag read from the analytics backend. You do not need an analytics backend to enable them: leave `ANALYTICS_BACKEND` unset and declare the flags in `env.d/common`. No data is sent anywhere.
```env
ANALYTICS_BACKEND_SETTINGS={"feature_flags": {"summary-enabled": True}}
```
You can also set it directly in `compose.yaml`:
```yaml
services:
backend:
environment:
ANALYTICS_BACKEND_SETTINGS: '{"feature_flags": {"summary-enabled": True}}'
```
> [!NOTE]
> The value is a Python dictionary, not JSON: write `True`, not `true`. Set it on every container running the backend image.
## Step 3: Configure your firewall
If you are using a firewall as it is usually recommended in a production environment you will need to allow the webservice traffic on ports 80 and 443 but also to allow UDP traffic for the WebRTC service.
+116 -127
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,130 +311,119 @@ 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) |
| 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 |
| START_RECORDING_THROTTLE_RATES | Recording start throttle rate per authenticated user | 10/minute |
| START_RECORDING_DAILY_THROTTLE_RATES | Daily recording start cap per authenticated user | 100/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_TIMEOUT | Seconds before a stalled SMTP connection times out | 30 |
| 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 | |
| ROOM_DELETED_RETENTION_DAYS | Days after its deletion after which a room is purged. Unset to purge deleted rooms only once inactive | |
| RECORDING_ENABLE | Record meeting option | false |
| RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED | Let any authenticated participant connected to a trusted or public room start/stop recordings. Set to false to reserve recording to room admins/owners | true |
| 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) |
| 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 |
| 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 | |
| ANALYTICS_BACKEND | Analytics backend class, e.g. `core.analytics.posthog.PostHogAnalytics`. Nothing is sent when unset | |
| ANALYTICS_BACKEND_SETTINGS | Analytics backend settings. Without a backend, `{"feature_flags": {"summary-enabled": True}}` sets feature flags statically | {} |
| 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 |
+3 -78
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` – Update the access level and configuration of existing rooms.
* `rooms:update` – **Coming soon** Update existing rooms, e.g., add attendees to a room.
* `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,67 +310,6 @@ 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:
@@ -447,17 +386,6 @@ 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: |
@@ -499,9 +427,6 @@ 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 -76
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` – Update the access level and configuration of existing rooms.
* `lasuite_visio:rooms:update` – **Coming soon** Update existing rooms, e.g., add attendees to a room.
* `lasuite_visio:rooms:delete` – **Coming soon** Delete rooms generated by the application.
#### Upcoming Features
@@ -206,67 +206,6 @@ 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:
@@ -288,17 +227,6 @@ 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: |
@@ -340,9 +268,6 @@ 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,7 +34,6 @@ 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;
}
```
+15 -33
View File
@@ -24,10 +24,9 @@ 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://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_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
MEDIA_BASE_URL=http://localhost:3000
FILE_UPLOAD_ENABLED=True
@@ -64,39 +63,23 @@ ALLOW_UNREGISTERED_ROOMS=False
# Recording
RECORDING_ENABLE=True
SUMMARY_SERVICE_VERSION=2
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe
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).
# 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
# 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
# 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
@@ -104,11 +87,10 @@ ROOM_TELEPHONY_ENABLED=True
# ROOMKIT_ENABLED = True
# ROOMKIT_SERVER_TO_SERVER_API_TOKEN = ThisIsAnExampleKeyForDevPurposeOnly
# LiveKit agents (opt-in, start them with `make run-agents`)
# Metadata (requires the metadata-collector agent)
METADATA_COLLECTOR_ENABLED=False
# Metadata
METADATA_COLLECTOR_ENABLED=True
# Subtitle (requires the multi-user-transcriber agent)
# Subtitle
ROOM_SUBTITLE_ENABLED=False
FRONTEND_USE_FRENCH_GOV_FOOTER=False
-2
View File
@@ -1,2 +0,0 @@
# Filled with a random value by `make create-env-files`
GARAGE_RPC_SECRET=
+3 -4
View File
@@ -2,9 +2,8 @@ LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
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_S3_ENDPOINT_URL=minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
AWS_STORAGE_BUCKET_NAME=meet-media-storage
AWS_S3_SECURE_ACCESS=False
@@ -1,7 +1,6 @@
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_S3_ENDPOINT_URL=minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey
+3 -8
View File
@@ -2,16 +2,11 @@ APP_NAME="meet-app-summary-dev"
APP_API_TOKEN="password"
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
AWS_S3_ENDPOINT_URL="garage:9000"
AWS_S3_ENDPOINT_URL="minio:9000"
AWS_S3_SECURE_ACCESS=false
AWS_S3_ACCESS_KEY_ID="meet-access-key"
AWS_S3_SECRET_ACCESS_KEY="meet-secret-access-key"
AWS_S3_REGION_NAME="local"
CELERY_BROKER_URL="redis://redis:6379/2"
CELERY_RESULT_BACKEND="redis://redis:6379/2"
TASK_TRACKER_REDIS_URL="redis://redis:6379/2"
AWS_S3_ACCESS_KEY_ID="meet"
AWS_S3_SECRET_ACCESS_KEY="password"
WHISPERX_BASE_URL="https://configure-your-url.com"
WHISPERX_ASR_MODEL="large-v2"
+7 -1
View File
@@ -1,5 +1,5 @@
{
"extends": ["github>suitenumerique/ci//renovate/default"],
"extends": ["github>numerique-gouv/renovate-configuration"],
"dependencyDashboard": true,
"labels": ["dependencies", "noChangeLog"],
"packageRules": [
@@ -24,6 +24,12 @@
"matchManagers": ["pep621"],
"matchPackageNames": ["redis"]
},
{
"groupName": "allowed pylint versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["pylint"],
"allowedVersions": "<4.0.0"
},
{
"groupName": "allowed django versions",
"matchManagers": ["pep621"],
+4 -4
View File
@@ -10,7 +10,7 @@
"license": "MIT",
"dependencies": {
"core-js": "3.50.0",
"i18next": "26.4.2",
"i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
@@ -9367,9 +9367,9 @@
}
},
"node_modules/i18next": {
"version": "26.4.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
"version": "26.3.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
"funding": [
{
"type": "individual",
+1 -1
View File
@@ -27,7 +27,7 @@
},
"dependencies": {
"core-js": "3.50.0",
"i18next": "26.4.2",
"i18next": "26.3.6",
"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 occurred: ${e}`);
console.error(`Error occured: ${e}`);
})
.finally(() => {
// NOTE: doesn't work with the desktop client — the browser considers
+3 -6
View File
@@ -1,12 +1,9 @@
FROM python:3.14.7-slim AS base
FROM python:3.14.6-slim AS base
# 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 \
# Install system dependencies required by LiveKit
RUN apt-get update && apt-get install -y \
libglib2.0-0 \
libgobject-2.0-0 \
libpcre2-8-0 \
libssl3t64 \
&& rm -rf /var/lib/apt/lists/*
+18 -35
View File
@@ -6,11 +6,9 @@ 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 (
@@ -30,6 +28,8 @@ 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,30 +59,6 @@ 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."""
@@ -145,13 +121,18 @@ 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()
@@ -220,18 +201,20 @@ class MetadataCollector:
}
data = json.dumps(payload, indent=2).encode("utf-8")
stream = BytesIO(data)
try:
self.s3_client.put_object(
Bucket=self.bucket_name,
Key=self.output_filename,
Body=data,
ContentType="application/json",
self.minio_client.put_object(
self.bucket_name,
self.output_filename,
stream,
length=len(data),
content_type="application/json",
)
logger.info(
"Uploaded speaker meeting metadata",
)
except (BotoCoreError, ClientError):
except S3Error:
logger.exception(
"Failed to upload meeting metadata",
)
+9 -9
View File
@@ -1,24 +1,24 @@
[project]
name = "agents"
version = "1.35.0"
version = "1.29.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.7.0",
"livekit-plugins-deepgram==1.7.0",
"livekit-plugins-silero==1.7.0",
"livekit-agents==1.6.7",
"livekit-plugins-deepgram==1.6.7",
"livekit-plugins-silero==1.6.7",
"livekit-plugins-kyutai-lasuite==0.0.6",
"boto3==1.43.56",
"python-dotenv==1.2.3",
"protobuf==7.36.0",
"sentry-sdk==2.68.1",
"python-dotenv==1.2.2",
"protobuf==6.33.6",
"minio==7.2.20",
"sentry-sdk==2.66.1",
"websockets==17.1",
"httpx==0.28.1",
]
[project.optional-dependencies]
dev = [
"ruff==0.16.4",
"ruff==0.16.0",
]
[tool.uv]
+856 -861
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -31,6 +31,10 @@ persistent=yes
# Specify a configuration file.
#rcfile=
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages
suggestion-mode=yes
# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no
+3 -22
View File
@@ -279,23 +279,9 @@ class RoomAdmin(admin.ModelAdmin):
inlines = (ResourceAccessInline,)
search_fields = ["name", "slug", "=id"]
list_display = [
"name",
"slug",
"access_level",
"get_owner",
"created_at",
"last_started_at",
"deleted_at",
]
list_filter = ["access_level", "created_at", "last_started_at", "deleted_at"]
readonly_fields = [
"id",
"created_at",
"updated_at",
"last_started_at",
"deleted_at",
]
list_display = ["name", "slug", "access_level", "get_owner", "created_at"]
list_filter = ["access_level", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
def get_queryset(self, request):
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
@@ -490,11 +476,6 @@ class ApplicationAdminForm(forms.ModelForm):
if self.instance.pk and self.instance.scopes:
self.fields["scopes"].initial = self.instance.scopes
# On creation: display generated credentials without allowing edits
for name in ("client_id", "client_secret"):
if name in self.fields:
self.fields[name].widget.attrs["readonly"] = True
@admin.register(models.Application)
class ApplicationAdmin(admin.ModelAdmin):
+1 -1
View File
@@ -39,7 +39,7 @@ def get_analytics() -> AnalyticsBackend:
options = getattr(settings, "ANALYTICS_BACKEND_SETTINGS", {}) or {}
if not dotted_path:
return NoOpAnalytics(**options)
return NoOpAnalytics()
backend_class = import_string(dotted_path)
return backend_class(**options)
+1 -24
View File
@@ -53,23 +53,7 @@ class AnalyticsBackend(ABC):
class NoOpAnalytics(AnalyticsBackend):
"""
Default backend: discards events and serves static feature flags.
Feature flags can be declared without any analytics provider, e.g.:
ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
"""
def __init__(
self,
*,
feature_flags: Mapping[str, bool | str | None] | None = None,
**_kwargs: Any,
) -> None:
# Other settings are ignored, so that settings meant for another
# backend never break an instance running without one.
self._feature_flags = dict(feature_flags or {})
"""Default backend: silently discards everything."""
def identify(self, user: User, properties=None) -> None:
"""No-op: discards identify calls."""
@@ -79,10 +63,3 @@ class NoOpAnalytics(AnalyticsBackend):
def shutdown(self) -> None:
"""No-op: nothing to flush."""
def get_user_feature_flags(
self,
user: User,
) -> Mapping[UserFeatureFlag, bool | str | None]:
"""Return the feature flags declared in settings, for any user."""
return self._feature_flags
-2
View File
@@ -8,8 +8,6 @@ class AnalyticsEvent(StrEnum):
# Rooms
ROOM_CREATED = "room_created"
ROOM_UPDATED = "room_updated"
ROOM_DELETED = "room_deleted"
# Roomkit (meeting-room SIP devices)
ROOMKIT_JOINED = "roomkit_joined"
-16
View File
@@ -44,20 +44,6 @@ def get_frontend_configuration(request):
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"max_duration": settings.RECORDING_MAX_DURATION,
"encoding": {
"is_custom_enabled": settings.RECORDING_CUSTOM_ENCODING_ENABLED,
"available_resolutions": list(
settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS.keys()
),
"available_profiles": list(
settings.RECORDING_ENCODING_AVAILABLE_PROFILES.keys()
),
"default_resolution": settings.RECORDING_ENCODING_DEFAULT_RESOLUTION,
"default_profile": settings.RECORDING_ENCODING_DEFAULT_PROFILE,
},
"authenticated_participants_enabled": (
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED
),
},
"background_image": {
"upload_is_enabled": settings.FILE_UPLOAD_ENABLED,
@@ -85,9 +71,7 @@ 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
),
-30
View File
@@ -1,30 +0,0 @@
"""Exceptions and guards shared by the API endpoints."""
from django.utils.translation import gettext_lazy as _
from rest_framework import exceptions, status
from core import models
class RoomSoftDeleted(exceptions.APIException):
"""Raised when the requested room has been soft deleted."""
status_code = status.HTTP_410_GONE
default_detail = _("This room has been deleted.")
default_code = "room_deleted"
def ensure_room_not_deleted(resource):
"""Raise a 410 Gone if the resource is a soft-deleted room.
Accepts a room or its parent resource, as referenced by accesses. Call it
after permissions are checked, to avoid revealing room.
"""
if isinstance(resource, models.Room):
room = resource
else:
room = getattr(resource, "room", None)
if room is not None and room.is_deleted:
raise RoomSoftDeleted()
-1
View File
@@ -17,7 +17,6 @@ class FeatureFlag:
"application": "APPLICATION_ENABLED",
"roomkit": "ROOMKIT_ENABLED",
"connection_test": "CONNECTION_TEST_ENABLED",
"user_access_token": "USER_ACCESS_TOKEN_ENABLED",
}
@classmethod
-17
View File
@@ -1,17 +0,0 @@
"""Row locks guarding the API write paths."""
import contextlib
from django.db import transaction
from core import models
from core.api.exceptions import ensure_room_not_deleted
@contextlib.contextmanager
def room_locked_for_update(room):
"""Lock a room's row for the duration of a write."""
with transaction.atomic():
locked_room = models.Room.all_objects.select_for_update().get(pk=room.pk)
ensure_room_not_deleted(locked_room)
yield locked_room
+31 -33
View File
@@ -12,6 +12,10 @@ from ..services.participants_management import (
ParticipantsManagementException,
)
ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
}
class IsAuthenticated(permissions.BasePermission):
"""
@@ -23,6 +27,15 @@ 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
@@ -187,29 +200,29 @@ class IsPresentInMeeting(permissions.BasePermission):
return False
class ParticipantCapability(permissions.BasePermission):
"""Base permission for in-meeting capabilities opened up to participants.
class CanManageLobby(permissions.BasePermission):
"""Grant lobby management (list/accept/deny waiting participants).
- Room admins/owners always hold the capability.
- When the room access level is TRUSTED or PUBLIC, any
session-authenticated user who is currently connected to the meeting
holds it too.
- Room admins/owners can always manage the lobby.
- When the room access level is TRUSTED, any authenticated user who is
currently connected to the meeting can manage the lobby. Presence is
verified cache-first (Redis), falling back to the LiveKit API.
Access level is always read fresh from the DB; only presence is cached,
so changing the room access level takes effect immediately.
so changing the room to RESTRICTED takes effect immediately.
"""
message = "You are not allowed to perform this action."
enabled_setting = None
message = "You are not allowed to manage this room's lobby."
def has_object_permission(self, request, view, obj):
"""Check privileges first, then the participant presence path."""
# pylint: disable=too-many-return-statements
def has_object_permission(self, request, view, obj): # noqa: PLR0911
"""Check privileges first, then the trusted-room presence path."""
user = request.user
if not user or not user.is_authenticated:
return False
# Product choice: these capabilities are reserved for session-authenticated
# Product choice: lobby management is reserved for session-authenticated
# users with a real account, not holders of a LiveKit room token.
if request.auth and hasattr(request.auth, "video"):
return False
@@ -217,31 +230,16 @@ class ParticipantCapability(permissions.BasePermission):
if obj.is_administrator_or_owner(user):
return True
is_open_room = obj.access_level in (
RoomAccessLevel.TRUSTED,
RoomAccessLevel.PUBLIC,
)
is_enabled = not self.enabled_setting or getattr(settings, self.enabled_setting)
if not (is_open_room and is_enabled):
if obj.access_level != RoomAccessLevel.TRUSTED:
return False
self.message = "You must be connected to the meeting to manage its lobby."
try:
return ParticipantsManagement().check_if_in_meeting_cached(
room_name=str(obj.pk), identity=str(user.sub)
)
except (ParticipantNotFoundException, ParticipantsManagementException):
except ParticipantNotFoundException:
return False
except ParticipantsManagementException:
return False
class CanManageLobby(ParticipantCapability):
"""Grant lobby management (list/accept/deny waiting participants).
Public rooms have no lobby: the endpoints short-circuit there, so opening
them to participants exposes nothing.
"""
class CanManageRecording(ParticipantCapability):
"""Grant starting/stopping a room recording (screen recording or transcript)."""
enabled_setting = "RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED"
+2 -106
View File
@@ -7,26 +7,19 @@ from typing import Literal
from urllib.parse import quote
from django.conf import settings
from django.core import signing
from django.core.exceptions import SuspiciousOperation
# pylint: disable=abstract-method,no-name-in-module
from django.utils.translation import gettext_lazy as _
from django_pydantic_field.rest_framework import SchemaField
from pydantic import (
BaseModel,
Field,
field_serializer,
field_validator,
)
from pydantic import BaseModel, Field, field_serializer
from pydantic import ValidationError as PydanticValidationError
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField
from core import models, utils
from core.api.exceptions import ensure_room_not_deleted
logger = logging.getLogger(__name__)
@@ -115,7 +108,6 @@ class ResourceAccessSerializerMixin:
_("You must be administrator or owner of a room to add accesses to it.")
)
ensure_room_not_deleted(resource)
return resource
@@ -252,49 +244,6 @@ 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.
@@ -315,7 +264,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"}
@@ -338,47 +287,11 @@ 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."""
username = serializers.CharField(required=True)
participant_id = serializers.CharField(
required=False, allow_null=True, max_length=128
)
@staticmethod
def sign_participant_id(participant_id):
"""Sign with Django's SECRET_KEY and a lobby-specific namespace."""
return signing.Signer(salt="core.lobby.participant").sign(participant_id)
def validate_participant_id(self, value):
"""Require a valid server signature before looking up a participant."""
if value is None:
return None
try:
participant_id = signing.Signer(salt="core.lobby.participant").unsign(value)
except signing.BadSignature as exc:
raise serializers.ValidationError(
"Invalid participant credential."
) from exc
return serializers.UUIDField().run_validation(participant_id)
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
@@ -686,20 +599,3 @@ class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
# useless bad requests
type = serializers.CharField(required=False, allow_null=True, allow_blank=True)
status = serializers.CharField(required=False, allow_null=True, allow_blank=True)
class TransitCodeSerializer(BaseValidationOnlySerializer):
"""Validate the single-use transit code sent to the exchange endpoint."""
code = serializers.CharField(trim_whitespace=True)
def validate_code(self, value):
"""Reject codes whose length cannot match a generated one."""
# Calculates urlsafe_b64encode length without padding
expected_length = (4 * settings.TRANSIT_CODE_NBYTES + 2) // 3
if len(value) != expected_length:
raise serializers.ValidationError("Invalid transit code format.")
return value
+11 -66
View File
@@ -1,11 +1,11 @@
"""Throttling modules for the API."""
from django.conf import settings
from lasuite.drf.throttling import MonitoredThrottleMixin
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
from sentry_sdk import capture_message
from . import serializers
def sentry_monitoring_throttle_failure(message):
"""Log when a failure occurs to detect rate limiting issues."""
@@ -20,45 +20,6 @@ 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 StartRecordingUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle recording starts per authenticated user."""
scope = "start_recording"
class StartRecordingDailyUserRateThrottle(StartRecordingUserRateThrottle):
"""Cap recording starts per authenticated user over a day."""
scope = "start_recording_daily"
class RequestEntryAuthenticatedUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle authenticated user requesting room entry"""
@@ -81,14 +42,13 @@ class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
def get_cache_key(self, request, view):
"""Use the lobby participant cookie ID as the throttle cache key.
Only throttle requests carrying a participant identifier. The
identifier is returned by the first request-entry response and
echoed back by the client from the second request onward, which is
when throttling starts applying.
Only throttle if a cookie is already set. If no cookie exists yet,
return None to skip throttling — the cookie will be set on the first
response, and throttling will apply from the second request onward.
Keying on the identifier rather than the IP address prevents
penalising multiple users behind the same NAT/proxy, and is
consistent with how the lobby identifies participants.
Keying on the cookie rather than the IP address prevents penalising
multiple users behind the same NAT/proxy, and is consistent with how
LobbyService identifies participants.
Note: as per DRF documentation, application-level throttling is not a
security measure against brute-force or DoS attacks. This throttle exists
@@ -98,14 +58,10 @@ class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
if request.user and request.user.is_authenticated:
return None # Only throttle unauthenticated requests.
serializer = serializers.RequestEntrySerializer(data=request.data)
if not serializer.is_valid():
return None
participant_id = request.COOKIES.get(settings.LOBBY_COOKIE_NAME)
participant_id = serializer.validated_data.get("participant_id")
if not participant_id:
return None # No throttling for unidentified requests
if participant_id is None:
return None # No throttling for cookieless requests
return self.cache_format % {
"scope": self.scope,
@@ -141,14 +97,3 @@ class ConnectionTestAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle anonymous users requesting connection test tokens."""
scope = "connection_test"
class ExchangeAccessTokenAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle anonymous transit code exchange attempts.
Abuse mitigation only, not a security boundary: DRF throttling is
best-effort. The security of the exchange rests on the codes'
entropy and single use.
"""
scope = "exchange_access_token"
+102 -230
View File
@@ -40,11 +40,9 @@ from rest_framework import (
)
from rest_framework.settings import api_settings
from core import analytics, audit, auditing, enums, models, utils
from core import analytics, enums, models, utils
from core.api import throttling
from core.api.exceptions import ensure_room_not_deleted
from core.api.filters import ListFileFilter
from core.api.locks import room_locked_for_update
from core.enums import MEDIA_STORAGE_URL_PATTERN
from core.recording.enums import FileExtension
from core.recording.event.authentication import RecordingProcessWebhookAuthentication
@@ -57,14 +55,12 @@ 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 (
WorkerServiceMediator,
)
from core.services.invitation import InvitationService
from core.services.jwt_token import JwtTokenService
from core.services.livekit_events import (
LiveKitEventsService,
LiveKitWebhookError,
@@ -79,13 +75,16 @@ from core.services.participants_management import (
ParticipantsManagementException,
)
from core.services.room_creation import RoomCreation
from core.services.room_management import RoomManagement, RoomManagementException
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from core.services.room_roles import (
RoomRoleError,
RoomRoleService,
)
from core.services.subtitle import SubtitleException, SubtitleService
from core.services.transit_code import TransitCodeService
from core.tasks.connection_test import delete_connection_test_room
from core.tasks.file import process_file_deletion
from core.utils import generate_token
@@ -100,6 +99,60 @@ 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.
@@ -127,10 +180,7 @@ class Pagination(pagination.PageNumberPagination):
class UserViewSet(
audit.AuditViewMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
mixins.ListModelMixin,
mixins.UpdateModelMixin, viewsets.GenericViewSet, mixins.ListModelMixin
):
"""User ViewSet"""
@@ -138,9 +188,6 @@ class UserViewSet(
queryset = models.User.objects.all()
serializer_class = serializers.UserSerializer
audit_client_id = None
audit_code_refused = False
def get_queryset(self):
"""
Limit listed users by querying the email field with a trigram similarity
@@ -176,109 +223,8 @@ class UserViewSet(
self.serializer_class(request.user, context=context).data
)
@decorators.action(
detail=False,
methods=["post"],
url_path="exchange-access-token",
permission_classes=[],
throttle_classes=[throttling.ExchangeAccessTokenAnonRateThrottle],
audit_action=auditing.TRANSIT_CODE_EXCHANGE,
)
@FeatureFlag.require("user_access_token")
def exchange_access_token(self, request):
"""Exchange a single-use transit code for a user access token.
The endpoint is unauthenticated: the transit code itself, an opaque
random string obtained through the external API and delivered to
the embedded frontend via a URL fragment, is the credential. Each
code can be exchanged exactly once (consuming it deletes it from
the cache); replaying a consumed code is denied and logged.
The issued JWT authenticates the user the code was minted for on
the whole core API, exactly like a session cookie would (similar
to lib-jitsi-meet's token authentication), and never appears in
any URL. Role-based permissions apply unchanged.
"""
if request.user and request.user.is_authenticated:
raise drf_exceptions.PermissionDenied("Already authenticated.")
serializer = serializers.TransitCodeSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
code = serializer.validated_data["code"]
self.audit_target = auditing.transit_code_target(code)
code_data = TransitCodeService().consume_code(code)
if code_data is None:
self.audit_code_refused = True
raise drf_exceptions.PermissionDenied(
"Invalid, expired or already used transit code."
)
client_id = code_data.get("client_id")
self.audit_client_id = client_id
# Re-check the user at exchange time so that a deactivation after
# the transit code was minted is taken into account.
user = models.User.objects.filter(id=code_data["user_id"]).first()
self.audit_user_target = user
if user is None or not user.is_active:
raise drf_exceptions.PermissionDenied(
"This account can no longer access the application."
)
if not models.Application.has_active_scope(
client_id, models.ApplicationScope.USERS_SESSION
):
raise drf_exceptions.PermissionDenied(
"This application can no longer create user sessions."
)
token_service = JwtTokenService(
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithm=settings.USER_ACCESS_TOKEN_ALG,
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
token_type=settings.USER_ACCESS_TOKEN_TYPE,
)
data = token_service.generate_jwt(
user,
"user:access",
{
"client_id": client_id or "unknown",
"token_type": settings.USER_ACCESS_TOKEN_TYPE_CLAIM,
},
)
self.audit_actor = user
self.audit_details = {"expires_in": settings.USER_ACCESS_TOKEN_TTL}
return drf_response.Response(data)
def get_audit_fields(self, status_code, error=None):
"""Report an exchange as an authentication with its transit code.
The caller is anonymous until the code is consumed, then the
application that minted it, a refusal included, acting for its user
once the exchange succeeds. A code that does not verify is a failed
authentication, though answered with a 403. A request already signed
in is reported as it is.
"""
fields = super().get_audit_fields(status_code, error)
if self.action != "exchange_access_token" or self.request.user.is_authenticated:
return fields
fields |= {"auth_method": "transit_code", "client_id": self.audit_client_id}
if self.audit_code_refused:
fields["reason"] = audit.Reason.AUTHENTICATION_FAILED
return fields
class RoomViewSet( # pylint: disable=too-many-public-methods
audit.AuditViewMixin,
class RoomViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
@@ -292,10 +238,6 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
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."""
@@ -304,9 +246,10 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
filter_kwargs = {"pk": self.kwargs["pk"]}
except ValueError:
filter_kwargs = {"slug": slugify(self.kwargs["pk"])}
obj = get_object_or_404(models.Room.all_objects, **filter_kwargs)
queryset = self.filter_queryset(self.get_queryset())
obj = get_object_or_404(queryset, **filter_kwargs)
# May raise a permission denied
self.check_object_permissions(self.request, obj)
ensure_room_not_deleted(obj)
return obj
def retrieve(self, request, *args, **kwargs):
@@ -358,27 +301,6 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
def perform_destroy(self, instance):
"""Soft delete the room and close its LiveKit room.
The room and its recordings are kept in database for traceability.
"""
try:
RoomManagement.soft_delete(instance)
except RoomManagementException as e:
raise drf_exceptions.APIException(
"Could not delete the room, please try again."
) from e
analytics.capture(
self.request.user,
analytics.AnalyticsEvent.ROOM_DELETED,
{
"room_id": str(instance.pk),
"access_level": instance.access_level,
},
)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created room.
@@ -421,20 +343,12 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
)
def perform_update(self, serializer):
"""Persist the room update under a row lock, then sync metadata to LiveKit.
"""Persist the room update, then sync metadata to LiveKit."""
The lock is what keeps a concurrent soft delete from being undone: the
full-row write of `save()` would otherwise carry back the `deleted_at`
read before the deletion committed.
"""
old_configuration = serializer.instance.configuration
old_access_level = serializer.instance.access_level
with room_locked_for_update(serializer.instance) as locked_room:
serializer.instance = locked_room
old_configuration = locked_room.configuration
old_access_level = locked_room.access_level
room = serializer.save()
room = serializer.save()
if (
room.configuration == old_configuration
@@ -442,19 +356,33 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
):
return
RoomManagement.sync_room_metadata(room)
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,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="start-recording",
permission_classes=[
permissions.CanManageRecording,
],
audit_action=auditing.RECORDING_START,
throttle_classes=[
throttling.StartRecordingUserRateThrottle,
throttling.StartRecordingDailyUserRateThrottle,
permissions.HasPrivilegesOnRoom,
],
)
@FeatureFlag.require("recording")
@@ -472,27 +400,18 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
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_data,
options=options.model_dump(exclude_none=True) if options else {},
)
models.RecordingAccess.objects.create(
user=self.request.user,
role=models.RoleChoices.OWNER,
recording=recording,
)
recording.grant_initial_accesses(starter=self.request.user)
self.audit_target = recording
self.audit_details = {
"collect_metadata": bool(recording.options.get("collect_metadata")),
}
except (DjangoValidationError, IntegrityError):
# DjangoValidationError covers the Python-level check (full_clean);
@@ -536,9 +455,8 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
methods=["post"],
url_path="stop-recording",
permission_classes=[
permissions.CanManageRecording,
permissions.HasPrivilegesOnRoom,
],
audit_action=auditing.RECORDING_STOP,
)
@FeatureFlag.require("recording")
def stop_room_recording(self, request, pk=None): # pylint: disable=unused-argument
@@ -555,8 +473,6 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
"No active recording found for this room."
) from e
self.audit_target = recording
worker_service = get_worker_service(mode=recording.mode)
worker_manager = WorkerServiceMediator(worker_service=worker_service)
@@ -593,18 +509,13 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
participant, livekit = lobby_service.request_entry(
room=room,
user=request.user,
request=request,
**serializer.validated_data,
)
return drf_response.Response(
{
**participant.to_dict(),
"id": serializers.RequestEntrySerializer.sign_participant_id(
participant.id
),
"livekit": livekit,
}
)
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
lobby_service.prepare_response(response, participant.id)
return response
@decorators.action(
detail=True,
@@ -1021,15 +932,6 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
"""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)
@@ -1093,16 +995,8 @@ class ResourceAccessViewSet(
return queryset
def get_object(self):
"""Accesses to a soft-deleted room can be read but no longer modified."""
access = super().get_object()
if self.request.method not in drf_permissions.SAFE_METHODS:
ensure_room_not_deleted(access.resource)
return access
class RecordingViewSet(
audit.AuditViewMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
@@ -1117,23 +1011,6 @@ class RecordingViewSet(
queryset = models.Recording.objects.all()
serializer_class = serializers.RecordingSerializer
audit_actions = {"destroy": auditing.RECORDING_DELETE}
# What the summary service reports on, once its event is validated
audit_process_type = None
def get_audit_action(self):
"""Audit a summary apart from the transcript it is made from."""
if self.audit_process_type == "summary":
return auditing.RECORDING_SUMMARY_REPORT
return super().get_audit_action()
def get_audit_fields(self, status_code, error=None):
"""Report a failure of the summary service as one, though it is acknowledged."""
fields = super().get_audit_fields(status_code, error)
if status_code < 400 and (self.audit_details or {}).get("status") == "failure":
fields["outcome"] = audit.Outcome.FAILURE
return fields
def get_queryset(self):
"""Restrict recordings to the user's ones."""
user = self.request.user
@@ -1149,7 +1026,6 @@ class RecordingViewSet(
url_path="external-process-hook",
authentication_classes=[RecordingProcessWebhookAuthentication],
serializer_class=serializers.ExternalProcessEventSerializer,
audit_action=auditing.RECORDING_TRANSCRIPT_REPORT,
)
def on_external_process_event_received(self, request, pk=None): # pylint: disable=unused-argument
"""Handle incoming external process events for recordings."""
@@ -1163,16 +1039,12 @@ class RecordingViewSet(
validated_data = serializer.validated_data
job_id = validated_data["job_id"]
self.audit_process_type = validated_data.get("type")
self.audit_details = {"job_id": job_id, "status": validated_data.get("status")}
try:
recording = models.Recording.objects.get(external_process_id=job_id)
except models.Recording.DoesNotExist as e:
logger.warning("No recording found for job_id %s: %s", job_id, e)
return ok_response
self.audit_target = recording
if validated_data.get("type") == "transcript":
if validated_data.get("status") == "success":
logger.info(
-34
View File
@@ -1,34 +0,0 @@
"""Structured audit logging."""
from .actions import Action
from .actor import email_domain
from .drf import AuditViewMixin
from .emitter import AUDIT_LOGGER_NAME, log
from .enums import ActorType, EventCategory, EventType, Outcome, Reason
from .formatter import AuditJsonFormatter
from .registry import AlreadyRegistered, register, register_auth_method
from .request import request_context
from .signals import LOGIN_ACTION, LOGOUT_ACTION, connect_auth_signals
from .utils import exception_type
__all__ = [
"AUDIT_LOGGER_NAME",
"LOGIN_ACTION",
"LOGOUT_ACTION",
"Action",
"ActorType",
"AlreadyRegistered",
"AuditJsonFormatter",
"AuditViewMixin",
"EventCategory",
"EventType",
"Outcome",
"Reason",
"connect_auth_signals",
"email_domain",
"exception_type",
"log",
"register",
"register_auth_method",
"request_context",
]
-30
View File
@@ -1,30 +0,0 @@
"""Specs of the actions audit events are emitted for."""
from dataclasses import dataclass
from .ecs import DEFAULT_CATEGORY, check_classification
from .enums import EventCategory, EventType
@dataclass(frozen=True)
class Action:
"""An audited action: its dotted name and its ECS classification.
``category`` and ``types`` are the defaults of every event of the action:
a ``category`` or ``types`` given to ``log`` wins over them. Without a
category, the action is an API call.
"""
name: str
category: EventCategory | None = None
types: tuple[EventType, ...] = ()
def __post_init__(self):
"""Validate the classification against ECS, so a bad one fails at import."""
if self.category is not None:
object.__setattr__(self, "category", EventCategory(self.category))
object.__setattr__(self, "types", tuple(EventType(t) for t in self.types))
check_classification([self.category or DEFAULT_CATEGORY], self.types)
def __str__(self) -> str:
return self.name
-187
View File
@@ -1,187 +0,0 @@
"""Resolve who is acting: actor type, identifiers, auth method and tenant.
Personal data is kept to a minimum on purpose: a person is identified by its
primary key, its OIDC ``sub`` when it has one and the domain of its email
address. The address itself is never recorded.
"""
from collections.abc import Mapping
from enum import Enum
from typing import Any
from django.contrib.auth import get_user_model
from lasuite.tools.email import get_domain_from_email
from .enums import ActorType
from .registry import auth_methods, dotted_path
AUTH_METHOD_NONE = "none"
AUTH_METHOD_SESSION = "session"
AUTH_METHOD_UNKNOWN = "unknown"
USER_ROLE_FLAGS = (("is_superuser", "superuser"), ("is_staff", "staff"))
class _ActorDefault(Enum):
"""Sentinel telling an omitted ``actor`` apart from ``None``(no account)."""
FROM_REQUEST = "from_request"
FROM_REQUEST = _ActorDefault.FROM_REQUEST
DEFAULT_AUTH_METHODS = {
"rest_framework.authentication.SessionAuthentication": AUTH_METHOD_SESSION,
"rest_framework.authentication.BasicAuthentication": "basic",
"rest_framework.authentication.TokenAuthentication": "token",
"django.contrib.auth.backends.ModelBackend": "password",
}
def _auth_methods() -> dict[str, str]:
return {**DEFAULT_AUTH_METHODS, **auth_methods()}
def auth_method_for(authenticator) -> str:
"""Return the auth method name for a DRF authenticator instance."""
if authenticator is None:
return AUTH_METHOD_NONE
methods = _auth_methods()
for klass in type(authenticator).__mro__:
name = methods.get(dotted_path(klass))
if name:
return name
return AUTH_METHOD_UNKNOWN
def auth_method_for_backend(backend: str | None) -> str:
"""Return the auth method name for the dotted path of a login backend."""
return _auth_methods().get(backend or "", AUTH_METHOD_UNKNOWN)
def request_auth_method(request) -> str:
"""Return how ``request`` was authenticated.
A DRF request names its authenticator. A plain Django request, as served
by the admin or the logout view, can only be authenticated by its session.
"""
if hasattr(request, "successful_authenticator"):
return auth_method_for(request.successful_authenticator)
if _is_authenticated(getattr(request, "user", None)):
return AUTH_METHOD_SESSION
return AUTH_METHOD_NONE
def email_domain(email) -> str | None:
"""Return the lower-cased domain part of an email address, if any.
It is parsed as for ``Application.can_delegate_email``, so an audited
domain is the one a delegation was checked against.
"""
domain = get_domain_from_email(str(email)) if email else None
return domain.lower() if domain else None
def client_id_from_auth(auth) -> str | None:
"""Extract an application client id from a token payload."""
if isinstance(auth, Mapping):
value = auth.get("client_id")
return str(value) if value else None
return None
def _is_authenticated(user) -> bool:
return bool(user is not None and getattr(user, "is_authenticated", False))
def _is_account(user) -> bool:
"""Tell whether ``user`` is a user account.
It stays one once deleted, when Django clears its primary key.
"""
return isinstance(user, get_user_model())
def _is_service(user) -> bool:
"""Tell whether ``user`` authenticated without an account, as a machine user."""
return _is_authenticated(user) and not _is_account(user)
def _default_actor_type(request, user, client_id) -> ActorType:
if client_id:
return ActorType.APPLICATION
if request is None and user is None:
return ActorType.SYSTEM
if _is_service(user):
return ActorType.SERVICE
if _is_account(user):
return ActorType.USER
return ActorType.ANONYMOUS
def describe_user(user) -> dict[str, Any]:
"""Return the ECS fields identifying a person: id and email domain.
The id is missing for accounts that were deleted.
"""
return {
"id": str(user.pk) if user.pk is not None else None,
"domain": email_domain(getattr(user, "email", None)),
}
def user_sub(user) -> str | None:
"""Return the OIDC sub of an account, missing for one that never signed in."""
return getattr(user, "sub", None) or None
def user_roles(user) -> list[str] | None:
"""Return the privileges of an account at the time of the event, if any."""
roles = [role for flag, role in USER_ROLE_FLAGS if getattr(user, flag, False)]
return roles or None
def describe_actor(
request,
*,
actor: Any = FROM_REQUEST,
actor_type: ActorType | str | None = None,
client_id: str | None = None,
auth_method: str | None = None,
) -> dict[str, Any]:
"""Return the fields describing the actor.
That is the ECS ``user`` and ``organization`` fields, the name of the
service acting, if any, reported as ``service.origin.name``, and the
``lasuite`` ones. Everything is read from ``request`` unless overridden.
``actor=None`` means no account, whoever the request is signed in as:
an explicit ``actor_type`` alone does not discard it.
Without a request or an actor, the actor is the system. ``user`` is the
account whose authority the action used, see ``ActorType``: None for a
service, the system or an anonymous caller.
"""
user = getattr(request, "user", None) if actor is FROM_REQUEST else actor
client_id = client_id or client_id_from_auth(getattr(request, "auth", None))
actor_type = actor_type or _default_actor_type(request, user, client_id)
is_account = _is_account(user)
lasuite: dict[str, Any] = {
"actor": {
"type": str(ActorType(actor_type)),
"sub": user_sub(user) if is_account else None,
},
"auth": {"method": auth_method or request_auth_method(request)},
"application": {"client_id": client_id},
}
tenant = client_id or (
email_domain(getattr(user, "email", None)) if is_account else None
)
return {
"user": (
{**describe_user(user), "roles": user_roles(user)} if is_account else None
),
"organization": {"id": tenant},
"service": user.get_username() if _is_service(user) else None,
"lasuite": lasuite,
}
-361
View File
@@ -1,361 +0,0 @@
"""Audit the writes performed through the Django admin.
Every ``ModelAdmin`` registered on :class:`AuditedAdminSite` emits an audit
event when an object is created, changed or deleted, and when a bulk action
runs. Django's own ``LogEntry`` keeps being written exactly as before: this
stream is additive.
Actions are named ``admin.<target>.<verb>`` where ``<target>`` is the model
name, for instance ``admin.room.update`` or ``admin.user.delete``.
Unlike the rest of the catalogue this family is templated rather than
enumerated: it follows whatever models are registered.
Only writes are audited. Browsing a change list or a change form emits
nothing.
Which field values may be recorded, and the event category, are registered
per model; see ``core.audit.registry``.
"""
import copy
import logging
from contextlib import contextmanager
from enum import StrEnum
from functools import wraps
from typing import Any
from django.contrib.admin import ModelAdmin
from django.contrib.admin.sites import AdminSite
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from .actions import Action
from .emitter import log
from .enums import EventCategory, EventType, Outcome, Reason
from .registry import model_options
from .utils import render_value
ADMIN_ACCESS_ACTION = Action(
"admin.access", category=EventCategory.WEB, types=(EventType.ACCESS,)
)
DIFF_ATTRIBUTE = "audit_admin_diff"
PENDING_DELETIONS_ATTRIBUTE = "audit_admin_pending_deletions"
DELETED_COPIES_ATTRIBUTE = "audit_admin_deleted_copies"
UNAUDITED_ACTIONS = frozenset({"delete_selected"})
SENSITIVE_FIELD_NAMES = frozenset(
{"api_key", "client_secret", "pin_code", "secret", "sub", "token"}
)
SENSITIVE_FIELD_MARKERS = ("password", "secret", "token")
_logger = logging.getLogger(__name__)
class AdminVerb(StrEnum):
"""What was done to an object through the admin."""
CREATE = "create"
UPDATE = "update"
DELETE = "delete"
ACTION = "action"
_VERB_TYPES: dict[AdminVerb, list[EventType]] = {
AdminVerb.CREATE: [EventType.CREATION],
AdminVerb.UPDATE: [EventType.CHANGE],
AdminVerb.DELETE: [EventType.DELETION],
AdminVerb.ACTION: [EventType.CHANGE],
}
def is_sensitive(field_name: str) -> bool:
"""Tell whether the value of a field must never be recorded."""
return field_name in SENSITIVE_FIELD_NAMES or any(
marker in field_name for marker in SENSITIVE_FIELD_MARKERS
)
def value_fields_for(model: type) -> frozenset[str]:
"""Return the fields of ``model`` whose before and after values may be recorded.
Anything that looks like a secret is dropped from the ``admin_values`` of
the model here, so a mistake in the registration cannot leak one.
"""
names = model_options(model).admin_values
return frozenset(name for name in names if not is_sensitive(name))
def category_for(model: type) -> EventCategory:
"""Return the registered category, else IAM for Django's auth models.
Anything granting access to the product is IAM, the rest configuration.
"""
if category := model_options(model).category:
return category
if model is get_user_model() or issubclass(model, (Group, Permission)):
return EventCategory.IAM
return EventCategory.CONFIGURATION
def types_for(model: type, verb: AdminVerb) -> list[EventType]:
"""Return the event types of ``verb`` on ``model``.
ECS expects ``user`` or ``group`` before the verb when one was the target.
"""
if issubclass(model, get_user_model()):
return [EventType.USER, *_VERB_TYPES[verb]]
if issubclass(model, Group):
return [EventType.GROUP, *_VERB_TYPES[verb]]
return _VERB_TYPES[verb]
def action_name(model: type, verb: AdminVerb) -> str:
"""Return the audit action for ``verb`` on ``model``."""
return f"admin.{model._meta.model_name}.{verb}" # noqa: SLF001
def form_diff(form, value_fields: frozenset[str]) -> dict[str, Any]:
"""Return the names of the fields a form changed, and the allowed values.
Field names are always reported. Values are reported for allow-listed
fields only, as ``{"from": ..., "to": ...}``.
"""
changed = sorted(form.changed_data)
changes = {
name: {
"from": render_value(form.initial.get(name)),
"to": render_value(form.cleaned_data.get(name)),
}
for name in changed
if name in value_fields
}
return {"changed_fields": changed, "changes": changes}
def related_diffs(formsets) -> list[tuple[Any, AdminVerb, dict[str, Any] | None]]:
"""Return one ``(object, verb, diff)`` triple per inline object touched.
Called after ``save_related``, so the formsets already carry what they
saved. The objects they list are the very instances their forms bound, so
the matching form, and with it the before and after values, is found by
identity. Deleted objects are reported through the copies ``save_formset``
took, as deleting an instance clears its primary key.
"""
touched = []
for formset in formsets or ():
forms = {id(form.instance): form for form in formset.forms}
deleted_copies = getattr(formset, DELETED_COPIES_ATTRIBUTE, {})
value_fields = value_fields_for(formset.model)
def diff_of(obj, forms=forms, value_fields=value_fields):
form = forms.get(id(obj))
return form_diff(form, value_fields) if form is not None else None
for obj in getattr(formset, "new_objects", ()):
touched.append((obj, AdminVerb.CREATE, diff_of(obj)))
for obj, _fields in getattr(formset, "changed_objects", ()):
touched.append((obj, AdminVerb.UPDATE, diff_of(obj)))
for obj in getattr(formset, "deleted_objects", ()):
# A deleted inline has no meaningful diff
touched.append((deleted_copies.get(id(obj), obj), AdminVerb.DELETE, None))
return touched
class AuditedModelAdminMixin:
"""Emit an audit event for every write made through this ModelAdmin."""
def construct_change_message(self, request, form, formsets, add=False):
"""Stash the structured diff for the ``log_*`` hook that follows."""
message = super().construct_change_message(request, form, formsets, add)
try:
diff = {
"own": form_diff(form, value_fields_for(self.model)),
"related": related_diffs(formsets),
}
except Exception: # pylint: disable=broad-exception-caught
_logger.exception("Admin audit diff could not be built")
diff = None
setattr(request, DIFF_ATTRIBUTE, diff)
return message
def save_formset(self, request, form, formset, change):
"""Save the inline objects, keeping copies of those about to be deleted."""
setattr(
formset,
DELETED_COPIES_ATTRIBUTE,
{id(f.instance): copy.copy(f.instance) for f in formset.deleted_forms},
)
super().save_formset(request, form, formset, change)
def log_addition(self, request, obj, message):
"""Record the creation, and that of any inline object saved with it."""
entry = super().log_addition(request, obj, message)
self.audit_form_write(request, AdminVerb.CREATE, obj)
return entry
def log_change(self, request, obj, message):
"""Record the change, and that of any inline object saved with it."""
entry = super().log_change(request, obj, message)
self.audit_form_write(request, AdminVerb.UPDATE, obj)
return entry
def log_deletions(self, request, queryset):
"""Note the objects about to be deleted.
Django calls this before ``delete_model`` and ``delete_queryset``, in
both the single and the bulk path. Those emit the events, once the
deletion has succeeded or failed. Copies are kept because deleting an
instance clears its primary key.
"""
targets = list(queryset)
entries = super().log_deletions(request, targets)
setattr(
request, PENDING_DELETIONS_ATTRIBUTE, [copy.copy(obj) for obj in targets]
)
return entries
def delete_model(self, request, obj):
"""Delete the object, then record one deletion."""
with self.auditing_deletions(request, lambda: [copy.copy(obj)]):
super().delete_model(request, obj)
def delete_queryset(self, request, queryset):
"""Delete the objects, then record one deletion per object."""
with self.auditing_deletions(request, lambda: list(queryset)):
super().delete_queryset(request, queryset)
@contextmanager
def auditing_deletions(self, request, default_targets):
"""Record the deletions noted by ``log_deletions`` with their outcome.
``default_targets`` lists the objects when ``log_deletions`` did not
run, as when a custom action deletes through these methods directly.
"""
targets = getattr(request, PENDING_DELETIONS_ATTRIBUTE, None)
setattr(request, PENDING_DELETIONS_ATTRIBUTE, None)
if targets is None:
targets = default_targets()
try:
yield
except Exception as error:
for obj in targets:
self.audit_write(request, AdminVerb.DELETE, obj, error=error)
raise
for obj in targets:
self.audit_write(request, AdminVerb.DELETE, obj)
def get_actions(self, request):
"""Return the available actions, each wrapped so that running it is audited."""
return {
name: (self.audited_action(func, name), name, description)
for name, (func, _name, description) in super().get_actions(request).items()
}
def audited_action(self, func, name):
"""Wrap an admin action so every run emits an event, success or not."""
if name in UNAUDITED_ACTIONS:
return func
@wraps(func)
def run(modeladmin, request, queryset):
count = queryset.count()
try:
response = func(modeladmin, request, queryset)
except Exception as error:
modeladmin.audit_action(request, name, count, error=error)
raise
modeladmin.audit_action(request, name, count)
return response
return run
def audit_form_write(self, request, verb, obj):
"""Emit the event for a form write and for the inlines saved with it."""
diff = getattr(request, DIFF_ATTRIBUTE, None) or {}
setattr(request, DIFF_ATTRIBUTE, None)
self.audit_write(request, verb, obj, diff.get("own"))
for related_obj, related_verb, related_diff in diff.get("related", ()):
self.audit_write(request, related_verb, related_obj, related_diff)
def audit_write(self, request, verb, obj, diff=None, *, error=None): # pylint: disable=too-many-arguments
"""Emit one event for a write on ``obj``, a failed one if ``error`` is set."""
model = obj.__class__
log(
action_name(model, verb),
request=request,
outcome=Outcome.SUCCESS if error is None else Outcome.FAILURE,
reason=None if error is None else Reason.INTERNAL_ERROR,
error=error,
category=category_for(model),
types=types_for(model, verb),
target=obj,
**(diff or {}),
)
def audit_action(self, request, name, count, error=None):
"""Emit one event for a bulk action run on ``count`` objects."""
log(
action_name(self.model, AdminVerb.ACTION),
request=request,
outcome=Outcome.SUCCESS if error is None else Outcome.FAILURE,
reason=None if error is None else Reason.INTERNAL_ERROR,
category=category_for(self.model),
types=types_for(self.model, AdminVerb.ACTION),
error=error,
admin_action=name,
count=count,
)
def audited(admin_class: type) -> type:
"""Return ``admin_class`` with the audit mixin."""
if issubclass(admin_class, AuditedModelAdminMixin):
return admin_class
return type(
f"Audited{admin_class.__name__}",
(AuditedModelAdminMixin, admin_class),
{"__module__": admin_class.__module__, "__doc__": admin_class.__doc__},
)
class AuditedAdminSite(AdminSite):
"""Admin site whose model admins all emit audit events.
Installed through ``AdminConfig.default_site`` so that admin classes
declared by Django itself, or by a third-party app, are covered as well as
the project's own.
"""
def register(self, model_or_iterable, admin_class=None, **options):
"""Register the audited flavour of the given admin class."""
super().register(
model_or_iterable, audited(admin_class or ModelAdmin), **options
)
def admin_view(self, view, cacheable=False):
"""Record when a signed-in account without staff access tries an admin view.
Django asks ``has_permission`` several times per request, the login
page included, so the refusal is recorded here instead: once per
refused view. The answer is taken before the view runs, which may log
the user out.
"""
guarded = super().admin_view(view, cacheable)
@wraps(guarded)
def inner(request, *args, **kwargs):
refused = getattr(
request.user, "is_authenticated", False
) and not self.has_permission(request)
response = guarded(request, *args, **kwargs)
if refused:
log(
ADMIN_ACCESS_ACTION,
outcome=Outcome.DENIED,
reason=Reason.PERMISSION_DENIED,
request=request,
status_code=response.status_code,
)
return response
return inner
-29
View File
@@ -1,29 +0,0 @@
"""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 ``audit_registry`` module of every installed app, where
the project registers its models and authentication classes.
"""
connect_auth_signals()
autodiscover_modules("audit_registry")
class AuditedAdminConfig(AdminConfig):
"""Serve the admin from the site that audits every write."""
default_site = "core.audit.admin.AuditedAdminSite"
-191
View File
@@ -1,191 +0,0 @@
"""Django REST framework integration
``AuditViewMixin`` turns every response of an audited action into one audit
event, from DRF's ``finalize_response`` hook, which runs for successes and for
handled errors alike. An exception DRF does not handle is audited as an
internal error from ``handle_exception`` before it propagates.
The CRUD actions a viewset audits are mapped in ``audit_actions``.
Extra action names require a decorator::
class RoomViewSet(audit.AuditViewMixin, viewsets.ModelViewSet):
audit_actions = {"create": ROOM_CREATE, "retrieve": ROOM_RETRIEVE}
@action(detail=True, methods=["post"], audit_action=ROOM_INVITE)
def invite(self, request, pk=None): ...
A refusal is recorded under the action that was attempted, with its outcome
and reason derived from the response status.
"""
import copy
import logging
from collections.abc import Mapping
from typing import Any
from .actions import Action
from .actor import FROM_REQUEST
from .ecs import DEFAULT_CATEGORY
from .emitter import EVENT_FIELDS, log
from .enums import EventCategory, EventType, Outcome, Reason
from .utils import exception_type
ACTION_TYPES = {
"create": EventType.CREATION,
"update": EventType.CHANGE,
"partial_update": EventType.CHANGE,
"destroy": EventType.DELETION,
"retrieve": EventType.ACCESS,
"list": EventType.ACCESS,
}
STATUS_REASONS = {
400: Reason.VALIDATION_ERROR,
401: Reason.AUTHENTICATION_FAILED,
403: Reason.PERMISSION_DENIED,
404: Reason.NOT_FOUND,
409: Reason.CONFLICT,
429: Reason.RATE_LIMITED,
}
DENIED_STATUSES = frozenset({401, 403, 429})
_logger = logging.getLogger(__name__)
def error_message(response) -> Any:
"""Return the message of an error response, as DRF or the view wrote it."""
data = getattr(response, "data", None)
if isinstance(data, Mapping):
return data.get("detail") or data.get("error")
return None
class AuditViewMixin:
"""Emit one audit event per response of an audited action.
``audit_actions`` maps the CRUD actions only. An extra action is audited
by passing ``audit_action`` to its ``@action`` decorator.
While handling a request, a view may *assign* ``audit_target``,
``audit_user_target``, ``audit_actor`` and ``audit_details``;
``check_object_permissions`` sets the target on its own, before a refusal
can happen. The actor is the account of the request unless ``audit_actor``
is assigned, ``None`` recording no account. The account the action is
about is found from the target unless ``audit_user_target`` is assigned.
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_user_target: Any = None
audit_actor: Any = FROM_REQUEST
audit_details: Mapping[str, Any] | None = None
def __init_subclass__(cls, **kwargs):
"""Refuse extra actions in ``audit_actions``, keyed by a method name."""
super().__init_subclass__(**kwargs)
if extra := sorted(set(cls.audit_actions) - set(ACTION_TYPES)):
raise TypeError(
f"{cls.__qualname__}.audit_actions only maps CRUD actions: "
f"audit {', '.join(extra)} with @action(audit_action=...)"
)
def check_object_permissions(self, request, obj):
"""Remember the object as the target."""
self.audit_target = obj
super().check_object_permissions(request, obj)
def perform_destroy(self, instance):
"""Keep a copy of the target, since deleting an instance clears its pk."""
self.audit_target = copy.copy(instance)
super().perform_destroy(instance)
def finalize_response(self, request, response, *args, **kwargs):
"""Audit the response once DRF has built it."""
response = super().finalize_response(request, response, *args, **kwargs)
self.emit_audit_event(request, response.status_code, error_message(response))
return response
def handle_exception(self, exc):
"""Audit an exception DRF cannot turn into a response, then let it propagate.
Only its class is recorded since its message could carry personal data.
"""
try:
return super().handle_exception(exc)
except Exception as error:
self.emit_audit_event(self.request, 500, error_type=exception_type(error))
raise
def get_audit_action(self) -> Action | str | None:
"""Return what the current request audits, if anything.
An extra action is read from its handler, so a request that reaches
none, as an OPTIONS request or a refused method, audits nothing.
"""
name = getattr(self, "action", None)
if name in ACTION_TYPES:
return self.audit_actions.get(name)
handler = getattr(self, name, None) if name else None
return getattr(handler, "kwargs", {}).get("audit_action")
def emit_audit_event(self, request, status_code, error=None, error_type=None):
"""Emit the event of the current action, if it is audited.
Never raises: a response must not fail because it could not be audited.
"""
try:
action = self.get_audit_action()
if action is not None:
fields = self.get_audit_fields(status_code, error)
log(action, request=request, error_type=error_type, **fields)
except Exception: # pylint: disable=broad-exception-caught
_logger.exception("Audit event of %s could not be emitted", request.path)
def get_audit_fields(self, status_code, error=None) -> dict[str, Any]:
"""Return the fields of the event for a response of ``status_code``.
The category and types of the ``Action`` win over those derived from
the DRF action. A 401 is also filed under ``authentication``, so that
it counts as a failed authentication.
"""
action = self.get_audit_action()
category, types = None, []
if isinstance(action, Action):
category, types = action.category, list(action.types)
details = {
key: value
for key, value in (self.audit_details or {}).items()
if key not in EVENT_FIELDS
}
category = category or DEFAULT_CATEGORY
fields = {
**details,
"category": (
[category, EventCategory.AUTHENTICATION]
if status_code == 401
else category
),
"types": types
or [ACTION_TYPES.get(getattr(self, "action", None), EventType.INFO)],
"target": self.audit_target,
"user_target": self.audit_user_target,
"actor": self.audit_actor,
"status_code": status_code,
}
if status_code >= 400:
fields |= {
"outcome": (
Outcome.DENIED
if status_code in DENIED_STATUSES
else Outcome.FAILURE
),
"reason": STATUS_REASONS.get(
status_code, Reason.INTERNAL_ERROR if status_code >= 500 else None
),
"error": error,
}
return fields
-130
View File
@@ -1,130 +0,0 @@
"""What the Elastic Common Schema says about audit events.
Reference:
- https://github.com/elastic/ecs/blob/v9.5.0/schemas/event.yml
- https://github.com/elastic/ecs/blob/v9.5.0/schemas/entity.yml
"""
from collections.abc import Iterable
from django.conf import settings
from .enums import EventCategory, EventType
ECS_VERSION = "9.5.0"
# The category of an event that names none, as an API call
DEFAULT_CATEGORY = EventCategory.API
# ``expected_event_types`` of the categories in ``EventCategory``
EXPECTED_EVENT_TYPES: dict[EventCategory, frozenset[EventType]] = {
EventCategory.API: frozenset(
{
EventType.ACCESS,
EventType.ADMIN,
EventType.ALLOWED,
EventType.CHANGE,
EventType.CREATION,
EventType.DELETION,
EventType.DENIED,
EventType.END,
EventType.INFO,
EventType.START,
EventType.USER,
}
),
EventCategory.AUTHENTICATION: frozenset(
{EventType.START, EventType.END, EventType.INFO}
),
EventCategory.CONFIGURATION: frozenset(
{
EventType.ACCESS,
EventType.CHANGE,
EventType.CREATION,
EventType.DELETION,
EventType.INFO,
}
),
EventCategory.EMAIL: frozenset({EventType.INFO}),
EventCategory.FILE: frozenset(
{
EventType.ACCESS,
EventType.CHANGE,
EventType.CREATION,
EventType.DELETION,
EventType.INFO,
}
),
EventCategory.IAM: frozenset(
{
EventType.ADMIN,
EventType.CHANGE,
EventType.CREATION,
EventType.DELETION,
EventType.GROUP,
EventType.INFO,
EventType.USER,
}
),
EventCategory.SESSION: frozenset({EventType.START, EventType.END, EventType.INFO}),
EventCategory.WEB: frozenset({EventType.ACCESS, EventType.ERROR, EventType.INFO}),
}
# Allowed values of ``entity.type``
ENTITY_TYPES = frozenset(
{
"application",
"bucket",
"cloud",
"container",
"database",
"function",
"host",
"orchestrator",
"queue",
"service",
"session",
"user",
}
)
def is_expected(categories: Iterable[EventCategory], event_type: EventType) -> bool:
"""Tell whether one of ``categories`` expects ``event_type``."""
return any(event_type in EXPECTED_EVENT_TYPES[category] for category in categories)
def check_classification(
categories: Iterable[EventCategory | str], types: Iterable[EventType | str]
) -> None:
"""Raise ``ValueError`` unless every type is expected by one of the categories."""
categories = [EventCategory(category) for category in categories]
unexpected = [
str(event_type)
for event_type in (EventType(item) for item in types)
if not is_expected(categories, event_type)
]
if unexpected:
raise ValueError(
f"ECS {ECS_VERSION} does not expect event type {', '.join(unexpected)} "
f"in category {', '.join(str(category) for category in categories)}"
)
def dataset() -> str:
"""Return the dataset of audit events, ``<service>.audit``."""
service_name = settings.AUDIT_LOG_SERVICE_NAME
return f"{service_name}.audit".lower().replace("-", "_")
def stream_fields() -> dict[str, dict[str, str]]:
"""Return the ``data_stream`` and ``event.dataset`` fields of audit events."""
name = dataset()
return {
"data_stream": {
"type": "logs",
"dataset": name,
"namespace": settings.AUDIT_LOG_DATA_STREAM_NAMESPACE,
},
"event": {"dataset": name},
}
-231
View File
@@ -1,231 +0,0 @@
"""Build ECS audit documents and emit them on the ``audit`` logger."""
import functools
import inspect
import logging
import socket
import uuid
from collections.abc import Iterable
from datetime import datetime, timezone
from typing import Any
from django.conf import settings
from .actions import Action
from .actor import FROM_REQUEST, describe_actor, describe_user
from .ecs import (
DEFAULT_CATEGORY,
ECS_VERSION,
check_classification,
is_expected,
stream_fields,
)
from .enums import ActorType, EventCategory, EventType, Outcome, Reason
from .request import RequestContext, request_context
from .targets import describe_target, user_target_of
from .utils import prune_empty, render_value
AUDIT_LOGGER_NAME = "audit"
audit_logger = logging.getLogger(AUDIT_LOGGER_NAME)
logger = logging.getLogger(__name__)
def log(action: Action | str, **fields: Any) -> None:
"""Emit one audit event."""
try:
document = build_document(action, **fields)
except Exception: # pylint: disable=broad-exception-caught
logger.exception("Audit event %r could not be built", action)
return
audit_logger.log(
level_for(document["lasuite"]["outcome"], document["event"].get("reason")),
str(action),
extra={"audit": document},
)
def level_for(outcome: Outcome | str, reason: Reason | str | None) -> int:
"""Derive the logging level so call sites never choose one."""
if Outcome(outcome) == Outcome.SUCCESS:
return logging.INFO
if reason is not None and Reason(reason) == Reason.INTERNAL_ERROR:
return logging.ERROR
return logging.WARNING
@functools.cache
def service_version() -> str | None:
"""Return the release of the backend."""
return getattr(settings, "RELEASE", None)
@functools.cache
def service_node_name() -> str:
"""Return the name of the node serving, its pod name on Kubernetes."""
return socket.gethostname()
def build_document( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-locals
action: Action | str,
*,
request: Any = None,
outcome: Outcome | str = Outcome.SUCCESS,
reason: Reason | str | None = None,
category: EventCategory | str | Iterable[EventCategory | str] | None = None,
types: list[EventType | str] | None = None,
target: Any = None,
user_target: Any = None,
actor: Any = FROM_REQUEST,
actor_type: ActorType | str | None = None,
auth_method: str | None = None,
client_id: str | None = None,
target_service: str | None = None,
status_code: int | None = None,
error: Any = None,
error_type: str | None = None,
message: str | None = None,
**details: Any,
) -> dict[str, Any]:
"""Return the ECS document of an event, pruned of empty fields.
``action`` is what was attempted: an ``Action``, whose category and types
apply unless given here, or a bare dotted name (``room.create``).
``category`` may be a list, for an event filed under several.
The actor, auth method and network fields are read from ``request``, by
default from the context of the request being served.
``actor``, ``actor_type``, ``auth_method`` and ``client_id`` override them;
``actor=None`` records no account even when the request is signed in.
``target`` is the resource acted on, reported as ``entity.target``, and
``user_target`` the account the action was about, reported as
``user.target``: by default the target if it is a user, or the account
registered for its model. ``target_service`` names the peer service the
backend called, reported as ``service.target.name``. Any other keyword
argument lands under ``lasuite.details``, unless ``None`` or an empty
mapping. The value of a detail is data and is kept whole: a ``None`` or an
empty mapping inside it, as in the ``from`` and ``to`` of a change, stays.
"""
context = (
request_context() if request is None else RequestContext.from_request(request)
)
outcome = Outcome(outcome)
reason = Reason(reason) if reason is not None else None
if isinstance(action, Action):
category = category or action.category
types = types or list(action.types)
if user_target is None and target is not None:
user_target = user_target_of(target)
actor_fields = describe_actor(
context.request,
actor=actor,
actor_type=actor_type,
client_id=client_id,
auth_method=auth_method,
)
stream = stream_fields()
document = prune_empty(
{
"@timestamp": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
"ecs": {"version": ECS_VERSION},
"data_stream": stream["data_stream"],
"message": message,
"service": {
"name": settings.AUDIT_LOG_SERVICE_NAME,
"environment": getattr(settings, "ENVIRONMENT", None),
"version": service_version(),
"node": {"name": service_node_name()},
"origin": {"name": actor_fields["service"]},
"target": {"name": target_service},
},
"event": _event_fields(action, outcome, reason, category, types)
| stream["event"],
"client": {"ip": context.client_ip},
"source": {"ip": context.client_ip},
"http": {
"request": {"id": context.request_id, "method": context.method},
"response": {"status_code": status_code},
},
"url": {"path": context.path},
"user_agent": {"original": context.user_agent},
"user": {
**(actor_fields["user"] or {}),
"target": describe_user(user_target) if user_target else None,
},
"organization": actor_fields["organization"],
"entity": {
"target": describe_target(target) if target is not None else None
},
"lasuite": {
**actor_fields["lasuite"],
"outcome": str(outcome),
},
"error": {
"message": str(error) if error is not None else None,
"type": error_type,
},
}
)
# Pruned apart and one level deep only: what a detail holds is data
if details := prune_empty(render_value(details), depth=1):
document["lasuite"]["details"] = details
return document
# The allowed kwargs of the``log`` function that fill an event field
EVENT_FIELDS = frozenset(
name
for name, parameter in inspect.signature(build_document).parameters.items()
if parameter.kind is inspect.Parameter.KEYWORD_ONLY
)
def _categories(category) -> list[EventCategory]:
if category is None:
return [DEFAULT_CATEGORY]
if isinstance(category, str):
return [EventCategory(category)]
return list(dict.fromkeys(EventCategory(item) for item in category)) or [
DEFAULT_CATEGORY
]
def _event_fields(action, outcome, reason, category, types) -> dict[str, Any]:
"""Return the ECS ``event`` fields of the event."""
categories = _categories(category)
type_list = [EventType(item) for item in (types or [])]
if not type_list:
type_list = [_default_type(outcome, categories)]
if (
outcome == Outcome.DENIED
and EventType.DENIED not in type_list
and is_expected(categories, EventType.DENIED)
):
type_list.append(EventType.DENIED)
try:
check_classification(categories, type_list)
except ValueError:
logger.exception("Audit event %r is misclassified", str(action))
return {
"kind": "event",
"id": str(uuid.uuid4()),
"action": str(action),
"category": [str(item) for item in categories],
"type": [str(item) for item in type_list],
"outcome": str(Outcome.FAILURE if outcome == Outcome.DENIED else outcome),
"reason": str(reason) if reason is not None else None,
}
def _default_type(outcome: Outcome, categories: list[EventCategory]) -> EventType:
"""Return the type of an event whose action names none.
``denied`` or ``error`` when one of the categories expects it, else ``info``.
"""
preferred = {Outcome.DENIED: EventType.DENIED, Outcome.FAILURE: EventType.ERROR}
event_type = preferred.get(outcome)
if event_type is not None and is_expected(categories, event_type):
return event_type
return EventType.INFO
-79
View File
@@ -1,79 +0,0 @@
"""ECS enums shared by every audit event."""
from enum import StrEnum
class Outcome(StrEnum):
"""Whether the audited action succeeded, failed, or was refused.
``unknown`` is for an action whose result was reported in terms the
backend does not recognise.
"""
SUCCESS = "success"
FAILURE = "failure"
DENIED = "denied"
UNKNOWN = "unknown"
class Reason(StrEnum):
"""Why an action did not succeed."""
AUTHENTICATION_FAILED = "authentication_failed"
PERMISSION_DENIED = "permission_denied"
RATE_LIMITED = "rate_limited"
VALIDATION_ERROR = "validation_error"
NOT_FOUND = "not_found"
CONFLICT = "conflict"
INTERNAL_ERROR = "internal_error"
class ActorType(StrEnum):
"""Kind of principal behind an action.
``user.*`` is the account whose authority the action used: the actor for
``user``, the delegating user for ``application``, absent otherwise.
"""
# A person's account acting for itself.
USER = "user"
# A client application acting on behalf of a user, named by its client id.
APPLICATION = "application"
# An internal peer of the deployment acting on its own behalf
# named by ``service.origin.name``
SERVICE = "service"
# The backend itself, with no inbound request.
SYSTEM = "system"
# A caller that did not authenticate, or failed to.
ANONYMOUS = "anonymous"
class EventCategory(StrEnum):
"""Subset of the ECS ``event.category``."""
API = "api"
AUTHENTICATION = "authentication"
CONFIGURATION = "configuration"
EMAIL = "email"
FILE = "file"
IAM = "iam"
SESSION = "session"
WEB = "web"
class EventType(StrEnum):
"""Subset of the ECS ``event.type``."""
ACCESS = "access"
ADMIN = "admin"
ALLOWED = "allowed"
CHANGE = "change"
CREATION = "creation"
DELETION = "deletion"
DENIED = "denied"
END = "end"
ERROR = "error"
GROUP = "group"
INFO = "info"
START = "start"
USER = "user"
-39
View File
@@ -1,39 +0,0 @@
"""Render audit records as single-line ECS JSON format."""
import json
import logging
from datetime import datetime, timezone
from typing import Any
from .ecs import ECS_VERSION, stream_fields
class AuditJsonFormatter(logging.Formatter):
"""Serialise the document attached to the record under ``audit`` in ECS format."""
def format(self, record: logging.LogRecord) -> str:
document = getattr(record, "audit", None)
if not isinstance(document, dict):
stream = stream_fields()
document = {
"@timestamp": datetime.fromtimestamp(
record.created, tz=timezone.utc
).isoformat(timespec="milliseconds"),
"ecs": {"version": ECS_VERSION},
"data_stream": stream["data_stream"],
"message": record.getMessage(),
"event": {**stream["event"], "action": record.getMessage()},
}
document = {
**document,
"log": {"level": record.levelname.lower(), "logger": record.name},
}
if record.exc_info:
error: dict[str, Any] = dict(document.get("error") or {})
error["stack_trace"] = self.formatException(record.exc_info)
document["error"] = error
return json.dumps(
document, ensure_ascii=False, default=str, separators=(",", ":")
)
-116
View File
@@ -1,116 +0,0 @@
"""Declare what audit events may say about models and authentication classes.
The project registers them from an ``audit_registry`` module in one of its apps,
imported once the audit app is ready::
audit.register(
Room,
fields=("slug", "access_level"), # describe the target
admin_values=("name", "access_level"), # values diffed in the admin
category=audit.EventCategory.CONFIGURATION, # ECS category of admin writes
)
audit.register(Application, entity_type="application") # ECS ``entity.type``
audit.register(ResourceAccess, user_target="user") # the account it is about
audit.register_auth_method(ApplicationJWTAuthentication, "application_jwt")
A target is always identified by its model name and primary key, so a model
that is not registered is still identifiable, just less detailed.
"""
from dataclasses import dataclass
from django.db.models import Model
from .ecs import ENTITY_TYPES
from .enums import EventCategory
class AlreadyRegistered(Exception):
"""A model or an authentication class that was registered twice."""
@dataclass(frozen=True)
class ModelOptions:
"""What audit events may say about a model.
``fields`` describe the model when it is the target of an event, under
``entity.target``. ``admin_values`` are the fields whose before and after
values may be recorded when they change in the Django admin. ``category``
is the ECS category of admin writes: ``iam`` for anything granting access
to the product, ``configuration`` by default. ``entity_type`` is the ECS
``entity.type`` of the model, when one of its allowed values fits.
``user_target`` names the attribute holding the account an event on the
model is about, reported as ``user.target``.
"""
fields: tuple[str, ...] = ()
admin_values: tuple[str, ...] = ()
category: EventCategory | None = None
entity_type: str | None = None
user_target: str | None = None
_models: dict[type[Model], ModelOptions] = {}
_auth_methods: dict[str, str] = {}
def register( # noqa: PLR0913 # pylint: disable=too-many-arguments
model: type[Model],
*,
fields=(),
admin_values=(),
category: EventCategory | str | None = None,
entity_type: str | None = None,
user_target: str | None = None,
) -> None:
"""Declare what audit events may say about ``model``."""
if model in _models:
raise AlreadyRegistered(f"{model._meta.label} is already registered") # noqa: SLF001
if entity_type is not None and entity_type not in ENTITY_TYPES:
raise ValueError(
f"{entity_type!r} is not an ECS entity type: "
f"use one of {', '.join(sorted(ENTITY_TYPES))}"
)
_models[model] = ModelOptions(
fields=tuple(fields),
admin_values=tuple(admin_values),
category=EventCategory(category) if category is not None else None,
entity_type=entity_type,
user_target=user_target,
)
def unregister(model: type[Model]) -> ModelOptions | None:
"""Forget ``model`` and return what was registered for it, if anything."""
return _models.pop(model, None)
def model_options(model: type[Model]) -> ModelOptions:
"""Return what is registered for a model, or for its concrete model."""
for klass in (model, model._meta.concrete_model): # noqa: SLF001
if (options := _models.get(klass)) is not None:
return options
return ModelOptions()
def dotted_path(klass: type) -> str:
"""Return the dotted path Django and DRF name a class by."""
return f"{klass.__module__}.{klass.__qualname__}"
def register_auth_method(klass: type, name: str) -> None:
"""Name the ``lasuite.auth.method`` of a DRF authentication class or a login backend.
A DRF class is also the default of its subclasses. A login backend must be
registered itself: custom backends often subclass ``ModelBackend`` only for
its permission checks, and must not pass for password logins.
"""
path = dotted_path(klass)
if path in _auth_methods:
raise AlreadyRegistered(f"{path} is already registered")
_auth_methods[path] = name
def auth_methods() -> dict[str, str]:
"""Return the registered auth methods, keyed by dotted path."""
return dict(_auth_methods)
-114
View File
@@ -1,114 +0,0 @@
"""Read the network fields and the request id behind an audit event."""
import uuid
from contextvars import ContextVar, Token
from dataclasses import dataclass
from typing import Any
from django.conf import settings
from dockerflow.logging import request_id_context
from rest_framework.throttling import BaseThrottle
USER_AGENT_MAX_LENGTH = 1024
def current_request_id() -> str | None:
"""Return the id of the request being served, if any."""
return request_id_context.get(None)
def resolve_client_ip(request) -> str | None:
"""Return the address of the real client, never the one of a proxy.
It reuses DRF's throttles to identify the client.
"""
return BaseThrottle().get_ident(request) or request.META.get("REMOTE_ADDR")
@dataclass(frozen=True)
class RequestContext:
"""What an audit event reads from the request behind it.
The network fields are read once, when the request comes in. The request
itself is kept for the actor, only known once it is authenticated: it is
the Django request, onto which DRF copies its user and auth, but not its
authenticator, so the auth method of a DRF view is not known from it.
Outside a request, every field is ``None``.
"""
request: Any = None
request_id: str | None = None
method: str | None = None
path: str | None = None
client_ip: str | None = None
user_agent: str | None = None
@classmethod
def from_request(cls, request) -> "RequestContext":
"""Read the context of ``request``, its user agent cut where ECS stops."""
meta = getattr(request, "META", None) or {}
return cls(
request=request,
request_id=current_request_id(),
method=getattr(request, "method", None),
path=getattr(request, "path", None) or None,
client_ip=resolve_client_ip(request),
user_agent=meta.get("HTTP_USER_AGENT", "")[:USER_AGENT_MAX_LENGTH] or None,
)
_request_context: ContextVar[RequestContext | None] = ContextVar(
"audit_request_context", default=None
)
def request_context() -> RequestContext:
"""Return the context of the request being served, as set by ``AuditLogMiddleware``.
It is empty outside a request.
"""
return _request_context.get() or RequestContext()
def set_request_context(context: RequestContext) -> Token:
"""Make ``context`` the one of the request being served, until reset."""
return _request_context.set(context)
def reset_request_context(token: Token) -> None:
"""Restore the context that was current before ``set_request_context``."""
_request_context.reset(token)
class AuditLogMiddleware:
"""Settle the request id and the request context, then echo the id.
It must come right after ``DockerflowMiddleware``, which sets the id from
the inbound ``DOCKERFLOW_REQUEST_ID_HEADER_NAME`` header. Unless
``REQUEST_ID_TRUST_HEADER`` says the ingress overwrites that header, the id
is replaced by a fresh one before anything logs, so that a client, the web
server access log and the audit events of a request can be joined on an id
the client did not choose.
The context of the request is kept while it is served, so that an audit
event emitted far from the view, as from a service, still reads its actor
and network fields from it.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if not settings.REQUEST_ID_TRUST_HEADER:
request_id_context.set(str(uuid.uuid4()))
token = set_request_context(RequestContext.from_request(request))
try:
response = self.get_response(request)
finally:
reset_request_context(token)
header = settings.DOCKERFLOW_REQUEST_ID_HEADER_NAME
if not response.has_header(header):
response[header] = current_request_id()
return response
-81
View File
@@ -1,81 +0,0 @@
"""Audit Django's authentication signals: login, failed login, logout."""
from django.contrib.auth import BACKEND_SESSION_KEY
from django.contrib.auth.signals import (
user_logged_in,
user_logged_out,
user_login_failed,
)
from .actions import Action
from .actor import AUTH_METHOD_UNKNOWN, auth_method_for_backend
from .emitter import log
from .enums import ActorType, EventCategory, EventType, Outcome, Reason
LOGIN_ACTION = Action(
"user.login", category=EventCategory.AUTHENTICATION, types=(EventType.START,)
)
LOGOUT_ACTION = Action(
"user.logout", category=EventCategory.AUTHENTICATION, types=(EventType.END,)
)
def get_login_backend(request, user) -> str | None:
"""Return the dotted path of the backend a login went through."""
session = getattr(request, "session", None)
from_session = session.get(BACKEND_SESSION_KEY) if session is not None else None
return from_session or getattr(user, "backend", None)
def get_auth_method_from_credentials(credentials) -> str:
"""Name the mechanism of a failed login from the credentials it submitted."""
if "password" in credentials:
return "password"
if "nonce" in credentials:
return "oidc"
return AUTH_METHOD_UNKNOWN
def on_user_logged_in(sender, request, user, **kwargs): # pylint: disable=unused-argument
"""Record a successful login."""
backend = get_login_backend(request, user)
log(
LOGIN_ACTION,
request=request,
actor=user,
auth_method=auth_method_for_backend(backend),
auth_backend=backend,
)
def on_user_login_failed(sender, credentials, request, **kwargs): # pylint: disable=unused-argument
"""Record a failed login."""
log(
LOGIN_ACTION,
outcome=Outcome.DENIED,
reason=Reason.AUTHENTICATION_FAILED,
request=request,
actor=None,
actor_type=ActorType.ANONYMOUS,
auth_method=get_auth_method_from_credentials(credentials),
)
def on_user_logged_out(sender, request, user, **kwargs): # pylint: disable=unused-argument
"""Record a logout, unless no one was signed in."""
if user is None:
return
log(
LOGOUT_ACTION,
request=request,
actor=user,
)
def connect_auth_signals() -> None:
"""Connect the receivers to authentication signal."""
user_logged_in.connect(on_user_logged_in, dispatch_uid="audit.user_logged_in")
user_login_failed.connect(
on_user_login_failed, dispatch_uid="audit.user_login_failed"
)
user_logged_out.connect(on_user_logged_out, dispatch_uid="audit.user_logged_out")
-89
View File
@@ -1,89 +0,0 @@
"""Describe the resource an audit event is about, as an ECS ``entity.target``.
The fields describing each model are those registered for it, see
``core.audit.registry``. A target is always identified by its model name and
primary key, so a model that is not registered is still identifiable, just
less detailed.
"""
import logging
from collections.abc import Mapping
from typing import Any
from django.contrib.auth import get_user_model
from django.db.models import Model
from .actor import user_sub
from .registry import model_options
from .utils import prune_empty, render_value
NAME_FIELD = "name"
_logger = logging.getLogger(__name__)
def _is_user(obj: Any) -> bool:
return isinstance(obj, get_user_model())
def describe_target(obj: Any) -> dict[str, Any]:
"""Return the ``entity.target`` fields of a target.
``id`` is its primary key and ``sub_type`` its model name. ``type`` is the
ECS entity type registered for its model, ``user`` for a user. A
registered field called ``name`` is reported as ``name``, the others
under ``raw``, as the OIDC sub of a user. Empty values are left out, as is
a registered field that cannot be read, which is reported.
A mapping is taken as already described.
"""
if isinstance(obj, Mapping):
return dict(obj)
if not isinstance(obj, Model):
return {"id": str(obj), "sub_type": obj.__class__.__name__.lower()}
meta = obj._meta # noqa: SLF001
options = model_options(meta.model)
entity_type = options.entity_type or ("user" if _is_user(obj) else None)
raw: dict[str, Any] = {}
for name in options.fields:
try:
raw[name] = render_value(getattr(obj, name))
except Exception: # pylint: disable=broad-exception-caught
_logger.exception(
"Audit field %r of %s could not be read", name, meta.label
)
if _is_user(obj):
raw["sub"] = user_sub(obj)
return prune_empty(
{
"id": str(obj.pk) if obj.pk is not None else None,
"type": [entity_type] if entity_type else None,
"sub_type": meta.model_name,
"name": raw.pop(NAME_FIELD, None),
"raw": raw,
}
)
def user_target_of(obj: Any) -> Any:
"""Return the account an event on ``obj`` is about, if any.
It is ``obj`` itself for a user, else the attribute registered as the
``user_target`` of its model, as the user an access is granted to.
"""
if _is_user(obj):
return obj
if not isinstance(obj, Model):
return None
meta = obj._meta # noqa: SLF001
attribute = model_options(meta.model).user_target
if attribute is None:
return None
try:
user = getattr(obj, attribute)
except Exception: # pylint: disable=broad-exception-caught
_logger.exception(
"Audit user target %r of %s could not be read", attribute, meta.label
)
return None
return user if _is_user(user) else None
-62
View File
@@ -1,62 +0,0 @@
"""Helpers for asserting on audit events in tests."""
import logging
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import asdict
from typing import Any
from . import registry
from .actions import Action
from .emitter import AUDIT_LOGGER_NAME
class _CollectingHandler(logging.Handler):
"""Keep the documents attached to the records it receives."""
def __init__(self):
super().__init__(level=logging.DEBUG)
self.documents: list[dict[str, Any]] = []
def emit(self, record: logging.LogRecord) -> None:
document = getattr(record, "audit", None)
if not isinstance(document, dict):
document = {"message": record.getMessage()}
self.documents.append({**document, "log": {"level": record.levelname.lower()}})
@contextmanager
def capture_audit() -> Iterator[list[dict[str, Any]]]:
"""Collect the audit documents emitted inside the block"""
logger = logging.getLogger(AUDIT_LOGGER_NAME)
handler = _CollectingHandler()
previous_level = logger.level
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
try:
yield handler.documents
finally:
logger.removeHandler(handler)
logger.setLevel(previous_level)
def find_events(
events: list[dict[str, Any]], action: Action | str
) -> list[dict[str, Any]]:
"""Return the captured events whose ``event.action`` is ``action``."""
return [
event for event in events if event.get("event", {}).get("action") == str(action)
]
@contextmanager
def override_registration(model, **options) -> Iterator[None]:
"""Register ``model`` with ``options`` inside the block, whatever it was before."""
previous = registry.unregister(model)
registry.register(model, **options)
try:
yield
finally:
registry.unregister(model)
if previous is not None:
registry.register(model, **asdict(previous))
-52
View File
@@ -1,52 +0,0 @@
"""Value helpers used to assemble audit logs."""
from collections.abc import Mapping
from enum import Enum
from typing import Any
from django.db.models import Model, QuerySet
def render_value(value: Any) -> Any:
"""Render a value as something stable and JSON-friendly.
Model instances are reduced to their primary key, enums to their value.
"""
if isinstance(value, Model):
return str(value.pk)
if isinstance(value, Enum):
return value.value
if isinstance(value, Mapping):
return {str(key): render_value(item) for key, item in value.items()}
if isinstance(value, (QuerySet, list, tuple, set, frozenset)):
return [render_value(item) for item in value]
if value is None or isinstance(value, (bool, int, float, str)):
return value
return str(value)
def exception_type(error: BaseException) -> str:
"""Return the dotted name of an exception's class.
Audit events record it rather than the message, which may carry personal
data.
"""
error_class = type(error)
return f"{error_class.__module__}.{error_class.__qualname__}"
def prune_empty(value: Any, depth: int | None = None) -> Any:
"""Drop ``None`` values and empty mappings, recursively.
With ``depth``, only that many levels are pruned: the values below are
kept whole, ``None`` and empty mappings included.
"""
if not isinstance(value, Mapping) or depth == 0:
return value
pruned = {}
for key, item in value.items():
cleaned = prune_empty(item, None if depth is None else depth - 1)
if cleaned is None or (isinstance(cleaned, Mapping) and not cleaned):
continue
pruned[key] = cleaned
return pruned
-84
View File
@@ -1,84 +0,0 @@
"""How Meet describes itself to the audit facility: its models and authentication classes.
Imported by the audit app once it is ready, see ``core.audit.apps``. The actions
Meet audits are declared in ``core.auditing``.
"""
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
from core.authentication.backends import OIDCAuthenticationBackend
from core.authentication.livekit import LiveKitTokenAuthentication
from core.authentication.user_token import UserAccessJWTAuthentication
from core.external_api.authentication import (
AddonsJWTAuthentication,
ApplicationJWTAuthentication,
MenshenAuthentication,
)
from core.recording.event.authentication import HeaderBasedAuthentication
from core.roomkit.authentication import ServerToServerAuthentication
# Models
audit.register(
models.User,
category=EventCategory.IAM,
admin_values=(
"is_active",
"is_staff",
"is_superuser",
"is_device",
"groups",
"user_permissions",
),
)
audit.register(Group, category=EventCategory.IAM, admin_values=("name", "permissions"))
audit.register(
models.Application,
category=EventCategory.IAM,
entity_type="application",
fields=("client_id", "name", "is_active", "scopes"),
admin_values=("name", "is_active", "scopes"),
)
audit.register(
models.ApplicationDomain, category=EventCategory.IAM, admin_values=("domain",)
)
audit.register(
models.ResourceAccess,
category=EventCategory.IAM,
fields=("resource_id", "user_id", "role"),
admin_values=("role",),
user_target="user",
)
audit.register(
models.RecordingAccess,
category=EventCategory.IAM,
admin_values=("role",),
user_target="user",
)
audit.register(
models.Room,
fields=("slug", "name", "access_level"),
admin_values=("name", "slug", "access_level", "configuration"),
)
audit.register(
models.Recording,
fields=("room_id", "status", "mode", "requested_mode", "is_transcribed"),
admin_values=("status", "mode"),
)
audit.register(models.File, admin_values=("title", "upload_state"))
# Authentication classes and login backends -> ``lasuite.auth.method``
audit.register_auth_method(OIDCAuthenticationBackend, "oidc")
audit.register_auth_method(ApplicationJWTAuthentication, "application_jwt")
audit.register_auth_method(AddonsJWTAuthentication, "addons_jwt")
audit.register_auth_method(ResourceServerAuthentication, "resource_server")
audit.register_auth_method(MenshenAuthentication, "menshen_token")
audit.register_auth_method(LiveKitTokenAuthentication, "livekit_token")
audit.register_auth_method(UserAccessJWTAuthentication, "user_access_jwt")
audit.register_auth_method(HeaderBasedAuthentication, "shared_secret")
audit.register_auth_method(ServerToServerAuthentication, "shared_secret")
-54
View File
@@ -1,54 +0,0 @@
"""The catalogue of what Meet audits: its actions and the targets that are not models.
It imports nothing but the audit facility and leaf helpers, so that any module can
import it, authentication classes included. Models and authentication classes
are registered in ``core.audit_registry``.
"""
from core import audit
from core.audit import EventCategory, EventType
from core.services.transit_code import TransitCodeService
# Actions
APPLICATION_TOKEN_ISSUE = audit.Action(
"application.token.issue",
category=EventCategory.AUTHENTICATION,
types=(EventType.START,),
)
USER_PROVISION = audit.Action(
"user.provision",
category=EventCategory.IAM,
types=(EventType.USER, EventType.CREATION),
)
TRANSIT_CODE_ISSUE = audit.Action("transit_code.issue", types=(EventType.CREATION,))
TRANSIT_CODE_EXCHANGE = audit.Action(
"transit_code.exchange",
category=EventCategory.AUTHENTICATION,
types=(EventType.START,),
)
ROOM_CREATE = audit.Action("room.create")
ROOM_LIST = audit.Action("room.list")
ROOM_RETRIEVE = audit.Action("room.retrieve")
ROOM_DELETE = audit.Action("room.delete")
ROOM_UPDATE = audit.Action("room.update")
RECORDING_START = audit.Action("recording.start", types=(EventType.START,))
RECORDING_STOP = audit.Action("recording.stop", types=(EventType.END,))
RECORDING_END = audit.Action("recording.end", types=(EventType.END,))
RECORDING_DELETE = audit.Action("recording.delete", types=(EventType.DELETION,))
RECORDING_TRANSCRIPT_REQUEST = audit.Action(
"recording.transcript.request", types=(EventType.START,)
)
RECORDING_TRANSCRIPT_REPORT = audit.Action(
"recording.transcript.report", types=(EventType.END,)
)
RECORDING_SUMMARY_REPORT = audit.Action(
"recording.summary.report", types=(EventType.END,)
)
# Targets that are not models
def transit_code_target(code: str) -> dict[str, str]:
"""Describe a transit code as a target, by its digest: never the code itself."""
return {"id": TransitCodeService.digest(code), "sub_type": "transit_code"}
+25 -23
View File
@@ -1,20 +1,22 @@
"""Authentication Backends for the Meet core app."""
import contextlib
from django.conf import settings
from django.core.exceptions import (
SuspiciousOperation,
ValidationError,
)
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from django.utils.translation import gettext_lazy as _
from lasuite.marketing.tasks import create_or_update_contact
from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
)
from rest_framework.authentication import SessionAuthentication
from core.models import User
from core.validators import sub_validator
from core.services.marketing import (
ContactCreationError,
ContactData,
get_marketing_service,
)
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
@@ -60,28 +62,28 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
@staticmethod
def signup_to_marketing_email(email):
"""Add the user to the newsletter list on sign-in.
"""Pragmatic approach to newsletter signup during authentication flow.
Uses the team's standard Brevo integration, dispatching the contact
creation/update as an asynchronous task to keep authentication fast.
Details:
1. Uses a very short timeout (1s) to prevent blocking the auth process
2. Silently fails if the marketing service is down/slow to prioritize user experience
3. Trade-off: May miss some signups but ensures auth flow remains fast
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
"""
create_or_update_contact.delay(
email=email,
attributes={"VISIO_SOURCE": ["SIGNIN"]},
)
with contextlib.suppress(
ContactCreationError, ImproperlyConfigured, ImportError
):
marketing_service = get_marketing_service()
contact_data = ContactData(
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
)
marketing_service.create_contact(
contact_data, timeout=settings.BREVO_API_TIMEOUT
)
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
try:
return User.objects.get(sub=sub)
except User.DoesNotExist:
+2 -9
View File
@@ -9,8 +9,6 @@ from rest_framework import authentication, exceptions
UserModel = get_user_model()
LIVEKIT_AUTH_SCHEME = "X-LiveKit-Token"
class LiveKitTokenAuthentication(authentication.BaseAuthentication):
"""Authenticate using LiveKit token and load the associated Django user."""
@@ -22,14 +20,9 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
return None # No authentication attempted
parts = auth_header.split()
if not parts or parts[0].lower() != LIVEKIT_AUTH_SCHEME.lower():
# Not our scheme (e.g. "Bearer <user access token>"): defer, another
# backend may recognize it.
return None
if len(parts) != 2:
if len(parts) != 2 or parts[0].lower() != "bearer":
raise exceptions.AuthenticationFailed(
f"Authorization header must be: {LIVEKIT_AUTH_SCHEME} <token>"
"Authorization header must be: Bearer <token>"
)
token = parts[1]
@@ -1,79 +0,0 @@
"""User access JWT authentication for the Meet core API.
Allows an embedded frontend (e.g. rendered in an iframe, where third-party
session cookies are blocked) to authenticate requests on the core API with
a JWT, obtained by exchanging a single-use transit code (see
core.services.transit_code and the users exchange-access-token endpoint)
and passed as a Bearer header. The JWT itself never appears in any URL.
Similar to lib-jitsi-meet's token authentication, the token is bound to a
user, not to a resource: once authenticated, the request is treated
exactly like a session-authenticated one, and the existing role-based
permissions apply unchanged.
"""
import logging
from django.conf import settings
from rest_framework import exceptions
from core.external_api.authentication import BaseJWTAuthentication
from core.models import Application, ApplicationScope
logger = logging.getLogger(__name__)
class UserAccessJWTAuthentication(BaseJWTAuthentication):
"""JWT authentication for user access tokens.
Validates user access tokens issued by the users exchange-access-token
endpoint and authenticates the user they were issued for. A bearer
token that does not verify against the user access token secret is
deferred to the next authentication backend; a token that does verify
but carries wrong claims is rejected.
When the feature is disabled (USER_ACCESS_TOKEN_ENABLED=False), the
backend is entirely inert: `BaseJWTAuthentication.authenticate`
returns None before reading the Authorization header, deferring every
request to the next authentication backend.
"""
def __init__(self):
"""Initialize the backend with user access token settings."""
super().__init__(
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
algorithm=settings.USER_ACCESS_TOKEN_ALG,
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
token_type=settings.USER_ACCESS_TOKEN_TYPE,
is_enabled=settings.USER_ACCESS_TOKEN_ENABLED,
)
def validate_payload(self, payload):
"""Validate the token type and the issuance-audit claim.
Raises:
AuthenticationFailed: If the token verified against the user
access token secret but does not carry the expected
claims, or if the issuing application lost its grant.
"""
if payload.get("token_type") != settings.USER_ACCESS_TOKEN_TYPE_CLAIM:
logger.warning("Wrong 'token_type' in user access token payload")
raise exceptions.AuthenticationFailed("Invalid token type.")
client_id = payload.get("client_id")
if not client_id:
logger.warning("Missing 'client_id' in user access token payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
if not Application.has_active_scope(client_id, ApplicationScope.USERS_SESSION):
logger.warning(
"User access token refused: application '%s' no longer "
"holds the '%s' grant",
client_id,
ApplicationScope.USERS_SESSION,
)
raise exceptions.AuthenticationFailed("Application access revoked.")

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