mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-05 21:11:50 +00:00
Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b570c3602 | |||
| af705d77d7 | |||
| 5fa07a01f3 | |||
| ad582bea7c | |||
| 9187173cae | |||
| 364bbf4f0b | |||
| a6a12ef586 | |||
| 2622d89f63 | |||
| f2d50770cf | |||
| 11e8470aa5 | |||
| 3bf78f0f5b | |||
| ddd5e3fce1 | |||
| 5a9e1cb012 | |||
| c49cee3ab4 | |||
| bbc30de490 | |||
| 14b3395e1c | |||
| f673c07cb8 | |||
| bd0329d162 | |||
| cedaa32ab7 | |||
| 22adccb353 | |||
| 3ab651d6c7 | |||
| 919af928aa | |||
| 3ed38f1c48 | |||
| f172c5795e | |||
| 262b168414 | |||
| 6c371c8cb3 | |||
| 1a8906c0a1 | |||
| 99ba8e330e | |||
| 059e5f1ec4 | |||
| 39ab9359e4 | |||
| d0a0d60ece | |||
| 27bd136741 | |||
| ad2ca6b4ef | |||
| 4071984f8e | |||
| 2ae602606c | |||
| f28389b624 | |||
| cbb8740f41 | |||
| b4b9fe54fb | |||
| 88685d613a | |||
| 6cde1b4461 | |||
| 68fe3f96fc | |||
| 3f9942a61d | |||
| 62a2515c6b | |||
| fa9b30c6bf | |||
| 4d9ee4e9c5 | |||
| 72cd5a8f18 | |||
| 21dc63b8ce | |||
| 8d5d42cfdb | |||
| 2480a76b62 | |||
| 31c8f3ec06 | |||
| a8c4aee0c2 | |||
| 9a2ad63524 | |||
| 67f9e54784 | |||
| 5d3255ddbc | |||
| d89b01b681 | |||
| 660c0ed684 | |||
| 8d980192c8 | |||
| de1f7158f5 | |||
| 6e17c6533c | |||
| 27e32c0370 | |||
| 6d4403d4fa | |||
| 37ae308825 | |||
| 16fd2dc4e8 | |||
| 9791a8a3b2 | |||
| 5b0dece79b | |||
| 96135a0263 | |||
| ce2e2a4d64 | |||
| e5dc9c2f15 | |||
| e97eab9b5e | |||
| 436b3dc9df | |||
| 6ea2a85810 | |||
| 3d1ea88e8f | |||
| beb74af574 | |||
| c785b4a627 | |||
| a9a4246abb | |||
| 3cf7f60eaa | |||
| bf215f1513 | |||
| 75836fc817 | |||
| 1affe65b4a | |||
| c34ecbd3ef | |||
| 78960d9769 | |||
| 41d07d36ee | |||
| f7386e1741 | |||
| f1da04eb27 | |||
| 2970420b84 | |||
| 771f58c0aa | |||
| b159b20695 | |||
| 226d004838 | |||
| b723b7bb62 | |||
| cb36df9104 | |||
| d85123d0c5 | |||
| b2abf814fa | |||
| cfdf1c2f92 | |||
| 4139f542d3 | |||
| eda66640df | |||
| 3fa05ea785 | |||
| 30b68052e1 | |||
| 04fd79b56b | |||
| e336122cfa | |||
| 172dc70649 | |||
| e0ab7f191f | |||
| 455b315dbb | |||
| 3089b03062 | |||
| 60febb3b57 | |||
| bf76ab1ddf |
@@ -0,0 +1,9 @@
|
||||
[codespell]
|
||||
# Files that are not English, or generated
|
||||
skip = ./.git,*.pdf,*.po,*.pot,*.json,*.lock,package-lock.json,
|
||||
./LICENSES,
|
||||
./src/summary/summary/core/locales,
|
||||
./src/summary/summary/core/prompt.py
|
||||
# Valid words in French (connexion) or in the code (statics)
|
||||
ignore-words-list = connexion,statics
|
||||
check-filenames = true
|
||||
@@ -0,0 +1,20 @@
|
||||
# Debian 13 base image (python:3.14-slim): no fixed version available yet.
|
||||
# Review regularly and remove entries once Debian ships a fix.
|
||||
|
||||
# util-linux
|
||||
CVE-2026-76642
|
||||
CVE-2026-78408
|
||||
CVE-2026-78409
|
||||
CVE-2026-78410
|
||||
|
||||
# acl
|
||||
CVE-2026-54369
|
||||
|
||||
# ncurses
|
||||
CVE-2025-69720
|
||||
|
||||
# systemd
|
||||
CVE-2026-16742
|
||||
|
||||
# perl-base (fix deferred by Debian)
|
||||
CVE-2026-9538
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
name: 🐛 Bug Report
|
||||
about: If something is not working as expected 🤔.
|
||||
|
||||
---
|
||||
|
||||
## Bug Report
|
||||
|
||||
**Problematic behavior**
|
||||
A clear and concise description of the behavior.
|
||||
|
||||
**Expected behavior/code**
|
||||
A clear and concise description of what you expected to happen (or code).
|
||||
|
||||
**Steps to Reproduce**
|
||||
1. Do this...
|
||||
2. Then this...
|
||||
3. And then the bug happens!
|
||||
|
||||
**Environment**
|
||||
- Meet version:
|
||||
- Platform:
|
||||
|
||||
**Possible Solution**
|
||||
<!--- Only if you have suggestions on a fix for the bug -->
|
||||
|
||||
**Additional context/Screenshots**
|
||||
Add any other context about the problem here. If applicable, add screenshots to help explain.
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
name: ✨ Feature Request
|
||||
about: I have a suggestion (and may want to build it 💪)!
|
||||
|
||||
---
|
||||
|
||||
## Feature Request
|
||||
|
||||
**Is your feature request related to a problem or unsupported use case? Please describe.**
|
||||
A clear and concise description of what the problem is. For example: I need to do some task and I have an issue...
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen. Add any considered drawbacks.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Discovery, Documentation, Adoption, Migration Strategy**
|
||||
If you can, explain how users will be able to use this and possibly write out a version the docs (if applicable).
|
||||
Maybe a screenshot or design?
|
||||
|
||||
**Do you want to work on it through a Pull Request?**
|
||||
<!-- Make sure to coordinate with us before you spend too much time working on an implementation! -->
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
name: 🤗 Support Question
|
||||
about: If you have a question 💬, or something was not clear from the docs!
|
||||
|
||||
---
|
||||
|
||||
<!-- ^ Click "Preview" for a nicer view! ^
|
||||
We primarily use GitHub as an issue tracker. If however you're encountering an issue not covered in the docs, we may be able to help! -->
|
||||
|
||||
---
|
||||
|
||||
Please make sure you have read our [main Readme](https://github.com/numerique-gouv/meet).
|
||||
|
||||
Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/meet/issues).
|
||||
|
||||
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
|
||||
|
||||
**Topic**
|
||||
What's the general area of your question: for example, docker setup, database schema, search functionality,...
|
||||
|
||||
**Question**
|
||||
Try to be as specific as possible so we can help you as best we can. Please be patient 🙏
|
||||
@@ -1,11 +0,0 @@
|
||||
## Purpose
|
||||
|
||||
Description...
|
||||
|
||||
|
||||
## Proposal
|
||||
|
||||
Description...
|
||||
|
||||
- [] item 1...
|
||||
- [] item 2...
|
||||
@@ -0,0 +1,19 @@
|
||||
name: Changelog Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changelog:
|
||||
uses: suitenumerique/ci/.github/workflows/_changelog.yml@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
|
||||
+40
-205
@@ -11,180 +11,30 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint-git:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: show
|
||||
run: git log
|
||||
- name: Enforce absence of print statements in code
|
||||
if: always()
|
||||
run: |
|
||||
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude).github/workflows/**' | grep "print("
|
||||
- name: Check absence of fixup commits
|
||||
if: always()
|
||||
run: |
|
||||
! git log | grep 'fixup!'
|
||||
- name: Install uv
|
||||
if: always()
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
- name: Lint commit messages added to main
|
||||
if: always()
|
||||
run: uvx --no-build --from gitlint-core==0.19.1 gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
||||
|
||||
check-changelog:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
|
||||
github.event_name == 'pull_request'
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
fetch-depth: 50
|
||||
- name: Check that the CHANGELOG has been modified in the current branch
|
||||
run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
|
||||
|
||||
lint-changelog:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
- name: Check CHANGELOG max line length
|
||||
run: |
|
||||
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
||||
if [ $max_line_length -ge 80 ]; then
|
||||
echo "ERROR: CHANGELOG has lines longer than 80 characters."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-mails:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/mail
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
- name: Install yarn
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
run: npm install -g --ignore-scripts yarn@1.22.22
|
||||
|
||||
- name: Install node dependencies
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
run: yarn install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Build mails
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
run: yarn build
|
||||
|
||||
- name: Cache mail templates
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
lint-back:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/backend
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras
|
||||
|
||||
- name: Check code formatting with ruff
|
||||
run: uv run --no-sync --no-build ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: uv run --no-sync --no-build ruff check .
|
||||
- name: Lint code with pylint
|
||||
run: uv run --no-sync --no-build pylint meet demo core
|
||||
|
||||
lint-agents:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/agents
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras --no-build
|
||||
- name: Check code formatting with ruff
|
||||
run: uv run --no-sync --no-build ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: uv run --no-sync --no-build ruff check .
|
||||
|
||||
lint-summary:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/summary
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras
|
||||
- name: Check code formatting with ruff
|
||||
run: uv run --no-sync --no-build ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: uv run --no-sync --no-build ruff check .
|
||||
lint-python:
|
||||
name: lint ${{ matrix.service }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- service: backend
|
||||
working_directory: src/backend
|
||||
pylint_targets: meet demo core
|
||||
- service: agents
|
||||
working_directory: src/agents
|
||||
pylint_targets: ""
|
||||
- service: summary
|
||||
working_directory: src/summary
|
||||
pylint_targets: ""
|
||||
uses: suitenumerique/ci/.github/workflows/_python-lint.yml@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
|
||||
with:
|
||||
working_directory: ${{ matrix.working_directory }}
|
||||
python_version: "3.13"
|
||||
pylint_targets: ${{ matrix.pylint_targets }}
|
||||
|
||||
test-back:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-mails
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
@@ -226,8 +76,9 @@ jobs:
|
||||
REDIS_URL: redis://localhost:6379/1
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
AWS_S3_ACCESS_KEY_ID: meet-access-key
|
||||
AWS_S3_SECRET_ACCESS_KEY: meet-secret-access-key
|
||||
AWS_S3_REGION_NAME: local
|
||||
OIDC_RS_CLIENT_ID: meet
|
||||
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
|
||||
@@ -243,41 +94,25 @@ jobs:
|
||||
sudo mkdir -p /data/media && \
|
||||
sudo mkdir -p /data/static
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
- name: Build or restore the mail templates
|
||||
uses: suitenumerique/ci/actions/mail-templates@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
|
||||
|
||||
- name: Start MinIO
|
||||
# Creates the access key and the bucket on startup
|
||||
- name: Start Garage
|
||||
run: |
|
||||
docker pull minio/minio
|
||||
docker run -d --name minio \
|
||||
docker run -d --name garage \
|
||||
-p 9000:9000 \
|
||||
-e "MINIO_ACCESS_KEY=meet" \
|
||||
-e "MINIO_SECRET_KEY=password" \
|
||||
-v /data/media:/data \
|
||||
minio/minio server --console-address :9001 /data
|
||||
-v "${GITHUB_WORKSPACE}/docker/files/etc/garage/garage.toml:/etc/garage.toml:ro" \
|
||||
-e "GARAGE_RPC_SECRET=$(openssl rand -hex 32)" \
|
||||
-e "GARAGE_DEFAULT_ACCESS_KEY=meet-access-key" \
|
||||
-e "GARAGE_DEFAULT_SECRET_KEY=meet-secret-access-key" \
|
||||
-e "GARAGE_DEFAULT_BUCKET=meet-media-storage" \
|
||||
dxflrs/garage:v2.4.1 \
|
||||
/garage server --single-node --default-bucket
|
||||
|
||||
# Tool to wait for a service to be ready
|
||||
- name: Install Dockerize
|
||||
- name: Wait for Garage to be ready
|
||||
run: |
|
||||
curl --proto "=https" --proto-redir "=https" --tlsv1.2 -sSLf \
|
||||
https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz |
|
||||
sudo tar -C /usr/local/bin -xzv
|
||||
|
||||
- name: Wait for MinIO to be ready
|
||||
run: |
|
||||
dockerize -wait tcp://localhost:9000 -timeout 10s
|
||||
|
||||
- name: Configure MinIO
|
||||
run: |
|
||||
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
|
||||
docker exec ${MINIO} sh -c \
|
||||
"mc alias set meet http://localhost:9000 meet password && \
|
||||
mc alias ls && \
|
||||
mc mb meet/meet-media-storage"
|
||||
timeout 30 sh -c 'until docker exec garage /garage health; do sleep 1; done'
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
@@ -310,9 +145,9 @@ jobs:
|
||||
env:
|
||||
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
|
||||
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
|
||||
AWS_S3_ENDPOINT_URL: "minio:9000"
|
||||
AWS_S3_ACCESS_KEY_ID: "meet"
|
||||
AWS_S3_SECRET_ACCESS_KEY: "password"
|
||||
AWS_S3_ENDPOINT_URL: "garage:9000"
|
||||
AWS_S3_ACCESS_KEY_ID: "meet-access-key"
|
||||
AWS_S3_SECRET_ACCESS_KEY: "meet-secret-access-key"
|
||||
WHISPERX_BASE_URL: "https://configure-your-url.com"
|
||||
WHISPERX_ASR_MODEL: "large-v2"
|
||||
WHISPERX_API_KEY: "test-whisperx-secret"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
name: Project quality Workflow
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
uses: suitenumerique/ci/.github/workflows/_project-quality.yml@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
|
||||
with:
|
||||
print_check_paths: src/backend src/summary src/agents
|
||||
codespell_ignore_words: "unsecure"
|
||||
@@ -1,33 +0,0 @@
|
||||
name: Download Crowdin translations
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
types: [file-fully-translated]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
|
||||
- name: Download Crowdin files
|
||||
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
|
||||
with:
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
localization_branch_name: l10n_crowdin_translations
|
||||
create_pull_request: true
|
||||
pull_request_title: "New Crowdin translations"
|
||||
pull_request_body: "New Crowdin pull request with translations"
|
||||
pull_request_base_branch_name: "main"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
CROWDIN_BASE_PATH: ${{ github.workspace }}
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Docker Hub Workflow
|
||||
run-name: Docker Hub Workflow
|
||||
name: Docker images
|
||||
run-name: Docker images
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -15,265 +15,62 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
DOCKER_USER: 1001:127
|
||||
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
|
||||
DOCKER_CONTAINER_REGISTRY_NAMESPACE: lasuite
|
||||
IS_MULTI_PLATFORM_BUILD: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
BUILD_PLATFORMS: ${{ startsWith(github.ref, 'refs/tags/v') && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
|
||||
|
||||
jobs:
|
||||
build-and-push-backend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
-
|
||||
name: Set up QEMU
|
||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
||||
with:
|
||||
docker-build-args: '--target backend-production -f Dockerfile'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
target: backend-production
|
||||
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build-and-push-frontend-generic:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
-
|
||||
name: Set up QEMU
|
||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
||||
with:
|
||||
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
file: ./src/frontend/Dockerfile
|
||||
target: frontend-production
|
||||
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build-and-push-frontend-dinum:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
-
|
||||
name: Set up QEMU
|
||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
||||
with:
|
||||
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/dinum-frontend/Dockerfile
|
||||
target: frontend-production
|
||||
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build-and-push-summary:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
-
|
||||
name: Set up QEMU
|
||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
||||
continue-on-error: true
|
||||
with:
|
||||
docker-build-args: '-f src/summary/Dockerfile --target production'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
|
||||
docker-context: './src/summary'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: ./src/summary
|
||||
file: ./src/summary/Dockerfile
|
||||
target: production
|
||||
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build-and-push-agents:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
-
|
||||
name: Set up QEMU
|
||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: lasuite/meet-agents
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
||||
continue-on-error: true
|
||||
with:
|
||||
docker-build-args: '-f src/agents/Dockerfile --target production'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
|
||||
docker-context: './src/agents'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: ./src/agents
|
||||
file: ./src/agents/Dockerfile
|
||||
target: production
|
||||
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-and-push:
|
||||
name: ${{ matrix.service }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- service: backend
|
||||
image_name: lasuite/meet-backend
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
target: backend-production
|
||||
- service: frontend
|
||||
image_name: lasuite/meet-frontend
|
||||
context: .
|
||||
file: ./src/frontend/Dockerfile
|
||||
target: frontend-production
|
||||
- service: frontend-dinum
|
||||
image_name: lasuite/meet-frontend-dinum
|
||||
context: .
|
||||
file: ./docker/dinum-frontend/Dockerfile
|
||||
target: frontend-production
|
||||
- service: summary
|
||||
image_name: lasuite/meet-summary
|
||||
context: ./src/summary
|
||||
file: ./src/summary/Dockerfile
|
||||
target: production
|
||||
- service: agents
|
||||
image_name: lasuite/meet-agents
|
||||
context: ./src/agents
|
||||
file: ./src/agents/Dockerfile
|
||||
target: production
|
||||
uses: suitenumerique/ci/.github/workflows/_docker-publish.yml@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
|
||||
with:
|
||||
image_name: ${{ matrix.image_name }}
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.file }}
|
||||
target: ${{ matrix.target }}
|
||||
docker_user: "1001:127"
|
||||
is_multi_platform: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
should_push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||
trivy_scan: true
|
||||
trivy_ignore_files: ./.github/.trivyignore
|
||||
secrets:
|
||||
DOCKER_HUB_USER: ${{ secrets.DOCKER_HUB_USER }}
|
||||
DOCKER_HUB_PASSWORD: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
notify-argocd:
|
||||
permissions:
|
||||
contents: read
|
||||
needs:
|
||||
- build-and-push-frontend-generic
|
||||
- build-and-push-frontend-dinum
|
||||
- build-and-push-backend
|
||||
- build-and-push-summary
|
||||
- build-and-push-agents
|
||||
- build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
- uses: numerique-gouv/action-argocd-webhook-notification@cac2ee67896eb13e84e804f60c4271370424eaa8 # main
|
||||
- uses: suitenumerique/ci/actions/argocd-webhook-notification@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
|
||||
id: notify
|
||||
with:
|
||||
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
|
||||
|
||||
@@ -1,33 +1,17 @@
|
||||
name: Release Chart
|
||||
run-name: Release Chart
|
||||
name: Release Helm chart
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- src/helm/meet/**
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cleanup
|
||||
run: rm -rf ./src/helm/extra
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
- name: Publish Helm charts
|
||||
uses: numerique-gouv/helm-gh-pages@2cf477ae49d7c70037ceb1685803f4f7bad9b981 # add-overwrite-option
|
||||
with:
|
||||
charts_dir: ./src/helm
|
||||
linting: on
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: suitenumerique/ci/.github/workflows/_release-helm-chart.yml@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Security analysis
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
zizmor:
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
uses: suitenumerique/ci/.github/workflows/_zizmor.yml@ca6401ac83e981e5b0a94f06f68baaa6e6d0a436 # v0.0.1
|
||||
with:
|
||||
config: .github/zizmor.yml
|
||||
@@ -0,0 +1,5 @@
|
||||
rules:
|
||||
unpinned-uses:
|
||||
config:
|
||||
policies:
|
||||
"suitenumerique/*": ref-pin
|
||||
@@ -86,3 +86,6 @@ docker/livekit/rootCA.pem
|
||||
|
||||
# Frontend rollup-plugin-visualizer
|
||||
/src/frontend/rollup-plugin-visualizer/*
|
||||
|
||||
# NixOS
|
||||
.devenv
|
||||
|
||||
+101
-2
@@ -8,6 +8,106 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- 🔒(backend) throttle meeting link generation
|
||||
- 🔒️(backend) add a daily cap on room creation
|
||||
- 🔧(summary) add setting to control Sentry traces sampling rate
|
||||
- ✨(frontend) let signed-out visitors start a meeting
|
||||
- ✨(backend) expose `allow_unregistered_rooms` in the frontend configuration
|
||||
- ✨(backend) add structured audit logging facility
|
||||
- ✨(backend) audit external API token and room operations
|
||||
- 🔒️(backend) audit writes and bulk actions made in the Django admin
|
||||
|
||||
### Changed
|
||||
|
||||
- ✨(frontend) warn users when the connection falls back to TURN
|
||||
- 🔧(backend) configure the technical documentation url
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) enforce recording-mode permissions on the checkboxes
|
||||
- 🔒️(agents) fix util-linux CVEs reported by Cyberwatch
|
||||
- 🔒️(backend) identify throttled clients by IP using NUM_PROXIES
|
||||
- 🔒️(backend) fix HIGH CVEs in Django and urllib3
|
||||
- 🔒️(agents) upgrade libpcre2-8-0 to fix CVE-2026-103111
|
||||
- 🔒️(frontend) upgrade pcre2 to fix CVE-2026-103111
|
||||
- 🐛(summary) disable default S3 checksums for GCS-compatible storage
|
||||
- 🔒️(summary) redact meeting content from Sentry events
|
||||
- 🐛(frontend) hide tooltips until they have a computed placement
|
||||
|
||||
## [1.33.0] - 2026-09-30
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) purge rooms inactive for a configurable period
|
||||
- 🔨(makefile) add targets to list and download files stored in Garage
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(backend) update python dependencies
|
||||
- ⬆️(summary) update python dependencies
|
||||
- ⬆️(agents) update python dependencies
|
||||
- ♻️(agents) replace the minio client by boto3
|
||||
- 🔧(compose) replace MinIO by Garage for local development
|
||||
- 🔧(helm) point media services to Garage by default
|
||||
- 💥(backend) replace recording encoding options with a profile model
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(backend) fix critical and high CVEs in PyJWT
|
||||
- ⚡️(frontend) disable posthog-js periodic feature flag reloads
|
||||
|
||||
## [1.32.1] - 2026-09-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(backend) fix CVE-2026-73228 and CVE-2026-73229 in drf
|
||||
- 🔒️(agent) fix CRITICAL CVE-2026-63072 / CVE-2026-63073 in libssl3t64
|
||||
|
||||
## [1.32.0] - 2026-09-25
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) make the LiveKit default video codec configurable
|
||||
- 🔧(dev) add support for Bureautix workstations
|
||||
- ✨(frontend) add screen share zoom controls #1498
|
||||
|
||||
### Changed
|
||||
|
||||
- 🔥(backend) remove unused API viewset and permission helpers
|
||||
- 🔊(backend) pin the dockerflow logger level to WARNING
|
||||
- 🚑️(summary) serve health endpoints with the dockerflow router
|
||||
- ♻️(backend) serve the dockerflow views early in the middleware stack
|
||||
- 📈(frontend) include LiveKit SIDs in the connection analytics event
|
||||
- 🔇(backend) silence expected 401 warnings on /me
|
||||
- 🔇(backend) silence noisy request summary info logs
|
||||
- ⚡️(frontend) defer loading the Crisp script until idle
|
||||
- ⬆️(frontend) upgrade react-i18next from 17.0.10 to 17.0.12
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.414.0 to 1.418.10
|
||||
- ⬆️(addons) upgrade i18next from 26.3.6 to 26.4.0
|
||||
- ⬆️(frontend) upgrade humanize-duration from 3.33.2 to 3.34.1
|
||||
- ⬆️(addons) upgrade i18next from 26.4.0 to 26.4.2
|
||||
- 🔖(helm) release chart 0.0.28
|
||||
- ♻️(backend) decouple recording event handling from LiveKit egress statuses
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(helm) probe liveness on __lbheartbeat__ and readiness on __heartbeat__
|
||||
- 🐛(helm) render periodSeconds and failureThreshold on probes
|
||||
- 🐛(backend) report the app release to Sentry instead of "NA"
|
||||
- 🐛(frontend) play the waiting room notification sound on every arrival
|
||||
- 🐛(frontend) apply saved reception resolution when joining a meeting #1714
|
||||
- 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
|
||||
- 🔒️(backend) enforce display name setting on rename API
|
||||
- 🔒️(backend) reject inactive users in resource server backend
|
||||
- 🐛(frontend) fix file permissions in the Docker image
|
||||
- 🚸(frontend) inform user that recording waits until a track is published
|
||||
- 🔒(backend) upgrade base image to python:3.13.5-alpine3.24
|
||||
- 🐛(backend) handle failed and aborted egresses
|
||||
- 🩹(frontend) notify participants when a recording fails or is aborted
|
||||
- 🔒️(frontend) fix HIGH CVE-2026-93990 in libexpat
|
||||
|
||||
## [1.31.0] - 2026-09-08
|
||||
|
||||
### Added
|
||||
@@ -65,7 +165,6 @@ and this project adheres to
|
||||
### Added
|
||||
|
||||
- ✨(any) let any authenticated user manage the lobby on trusted rooms
|
||||
|
||||
### Changed
|
||||
|
||||
- 📱(frontend) collapse mobile control bar items on narrow viewports
|
||||
@@ -324,7 +423,7 @@ and this project adheres to
|
||||
|
||||
### Fixed
|
||||
|
||||
- ♿️(frontend) improve accessibilty of the Effects panel #1401
|
||||
- ♿️(frontend) improve accessibility of the Effects panel #1401
|
||||
|
||||
## [1.20.0] - 2026-06-12
|
||||
|
||||
|
||||
+3
-4
@@ -1,7 +1,7 @@
|
||||
# Django Meet
|
||||
|
||||
# ---- base image to inherit from ----
|
||||
FROM python:3.13.5-alpine3.21 AS base
|
||||
FROM python:3.13.15-alpine3.24 AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip
|
||||
@@ -37,14 +37,13 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:22 AS mail-builder
|
||||
FROM node:22-alpine AS mail-builder
|
||||
|
||||
COPY ./src/mail /mail/app
|
||||
|
||||
WORKDIR /mail/app
|
||||
|
||||
RUN yarn install --frozen-lockfile && \
|
||||
yarn build
|
||||
RUN npm ci --ignore-scripts && npm run build
|
||||
|
||||
|
||||
# ---- static link collector ----
|
||||
|
||||
@@ -69,6 +69,22 @@ LINT_SUMMARY = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT)
|
||||
# -- Frontend
|
||||
PATH_FRONT = ./src/frontend
|
||||
|
||||
# -- Storage
|
||||
GARAGE_BUCKET = meet-media-storage
|
||||
STORAGE_FOLDERS = recordings transcripts summaries
|
||||
STORAGE_DIRS = $(addprefix data/,$(STORAGE_FOLDERS))
|
||||
COMPOSE_RUN_AWS = $(COMPOSE_RUN) --user $(DOCKER_USER)
|
||||
AWS_CLI = garage-cors --endpoint-url=http://garage:9000
|
||||
# Extensions listed in each folder (skips the Egress manifests in recordings/)
|
||||
recordings_EXTENSIONS = mp4 ogg
|
||||
transcripts_EXTENSIONS = json
|
||||
summaries_EXTENSIONS = txt
|
||||
# $(1): folder. Lists its objects with a known extension, most recent first
|
||||
storage_list = s3api list-objects-v2 --bucket $(GARAGE_BUCKET) \
|
||||
--prefix $(1)/
|
||||
storage_query = reverse(sort_by(Contents[?$(foreach ext,$($(1)_EXTENSIONS), \
|
||||
ends_with(Key, `".$(ext)"`) ||) `false`] || `[]`, &LastModified))
|
||||
|
||||
# ==============================================================================
|
||||
# RULES
|
||||
|
||||
@@ -77,6 +93,9 @@ default: help
|
||||
data/media:
|
||||
@mkdir -p data/media
|
||||
|
||||
$(STORAGE_DIRS):
|
||||
@mkdir -p $@
|
||||
|
||||
data/static:
|
||||
@mkdir -p data/static
|
||||
|
||||
@@ -85,6 +104,7 @@ data/static:
|
||||
create-env-files: ## Copy the dist env files to env files
|
||||
create-env-files: \
|
||||
env.d/development/common \
|
||||
env.d/development/garage \
|
||||
env.d/development/crowdin \
|
||||
env.d/development/postgresql \
|
||||
env.d/development/kc_postgresql \
|
||||
@@ -292,7 +312,7 @@ shell: ## connect to database shell
|
||||
# -- Database
|
||||
|
||||
dbshell: ## connect to database shell
|
||||
docker compose exec app-dev python manage.py dbshell
|
||||
@$(COMPOSE_EXEC_APP) python manage.py dbshell
|
||||
.PHONY: dbshell
|
||||
|
||||
resetdb: FLUSH_ARGS ?=
|
||||
@@ -317,12 +337,38 @@ env.d/development/summary:
|
||||
env.d/development/kube-secret:
|
||||
cp -n env.d/development/kube-secret.dist env.d/development/kube-secret
|
||||
|
||||
env.d/development/garage:
|
||||
sed "s/^GARAGE_RPC_SECRET=.*/GARAGE_RPC_SECRET=$$(openssl rand -hex 32)/" \
|
||||
env.d/development/garage.dist > env.d/development/garage
|
||||
|
||||
env.d/development/multi_user_transcriber:
|
||||
cp -n env.d/development/multi_user_transcriber.dist env.d/development/multi_user_transcriber
|
||||
|
||||
env.d/development/metadata_collector:
|
||||
cp -n env.d/development/metadata_collector.dist env.d/development/metadata_collector
|
||||
|
||||
# -- Storage
|
||||
|
||||
recordings-download-latest: ## download the latest recording from Garage into data/recordings
|
||||
transcripts-download-latest: ## download the latest transcript from Garage into data/transcripts
|
||||
summaries-download-latest: ## download the latest summary from Garage into data/summaries
|
||||
$(STORAGE_FOLDERS:%=%-download-latest): %-download-latest: data/%
|
||||
@key=$$($(COMPOSE_RUN_AWS) -T $(AWS_CLI) $(call storage_list,$*) \
|
||||
--query '$(call storage_query,$*)[0].Key' --output text) && \
|
||||
if [ "$$key" = "None" ]; then echo "No $* found"; exit 1; fi && \
|
||||
$(COMPOSE_RUN_AWS) --volume $(CURDIR)/data/$*:/aws/data/$* \
|
||||
$(AWS_CLI) s3 cp "s3://$(GARAGE_BUCKET)/$$key" data/$*/
|
||||
.PHONY: $(STORAGE_FOLDERS:%=%-download-latest)
|
||||
|
||||
recordings-list: ## list recordings stored in Garage, most recent first
|
||||
transcripts-list: ## list transcripts stored in Garage, most recent first
|
||||
summaries-list: ## list summaries stored in Garage, most recent first
|
||||
$(STORAGE_FOLDERS:%=%-list): %-list:
|
||||
@$(COMPOSE_RUN_AWS) $(AWS_CLI) $(call storage_list,$*) \
|
||||
--query '$(call storage_query,$*)[].{Date: LastModified, Key: Key, "Size (bytes)": Size}' \
|
||||
--output table
|
||||
.PHONY: $(STORAGE_FOLDERS:%=%-list)
|
||||
|
||||
# -- Internationalization
|
||||
|
||||
env.d/development/crowdin:
|
||||
|
||||
+159
@@ -16,6 +16,165 @@ the following command inside your docker container:
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Purging inactive rooms
|
||||
|
||||
Rooms now keep track of the last time they were started (`last_started_at`), fed by LiveKit's `room_started` webhook. A new `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for `ROOM_INACTIVITY_DELETION_DAYS` days. See [the room purge documentation](docs/features/room-purge.md).
|
||||
|
||||
- The feature is **disabled by default**: nothing is deleted unless you set `ROOM_INACTIVITY_DELETION_DAYS`.
|
||||
- The migration marks every existing room as started at the time of the upgrade, so no existing room can be purged before a full inactivity period has elapsed after upgrading.
|
||||
- Rooms holding a saved recording their users may still access are kept: any saved recording, or, when `RECORDING_EXPIRATION_DAYS` is set, a saved recording created within that window.
|
||||
- Inactivity is measured from LiveKit's `room_started` webhook: if it is not delivered to your backend, rooms in daily use look inactive and get purged.
|
||||
- When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again and can be reused when a meeting is created from that same URL.
|
||||
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights.
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=true`, any user can reopen the purged room by navigating to the same URL. In that case, the room is created dynamically and no corresponding room entry is persisted in the database.
|
||||
|
||||
### Local development: MinIO replaced by Garage
|
||||
|
||||
The development stacks now use [Garage](https://garagehq.deuxfleurs.fr/) instead of MinIO as S3 storage. Garage keeps its own format in `data/media/meta` and `data/media/data` and cannot read what MinIO left there, so local recordings and files will be lost.
|
||||
|
||||
To migrate a local environment:
|
||||
|
||||
1. Stop the stack and remove its containers, including the former `minio` one: `docker compose down --remove-orphans`
|
||||
2. Optionally reclaim the space used by MinIO: `rm -rf data/media && make data/media`
|
||||
3. In your `env.d/development/*` files, replace `minio:9000` by `garage:9000`, the `meet` / `password` credentials by `meet-access-key` / `meet-secret-access-key`, and add `AWS_S3_REGION_NAME=local` (or delete these files and run `make create-env-files`)
|
||||
4. Run `make create-env-files` to generate `env.d/development/garage`, which holds a random RPC secret for Garage.
|
||||
5. Rebuild the images, since the summary and agent images now install boto3 instead of minio
|
||||
|
||||
### Summary service and metadata collector: boto3 replaces the minio client
|
||||
|
||||
The summary service and the metadata collector agent now talk to S3 through boto3 instead of the minio client, with the same settings.
|
||||
Requests are now signed for `AWS_S3_REGION_NAME` as-is. When it is not set, the region is no longer looked up from the bucket: boto3 falls back to `AWS_DEFAULT_REGION`, then to `us-east-1`. If you left `AWS_S3_REGION_NAME` unset, set it to your provider's region before upgrading, or providers that check the signing region will reject the transcripts, summaries and meeting metadata uploads, as well as their signed URLs.
|
||||
|
||||
Also:
|
||||
- Signed URLs to transcripts and summaries are now always path-style (`<endpoint>/<bucket>/<key>`), whereas the minio client used virtual-hosted-style URLs
|
||||
- The metadata collector now accepts `AWS_S3_ENDPOINT_URL` with or without a scheme, like the summary service: the scheme always follows `AWS_S3_SECURE_ACCESS`.
|
||||
|
||||
### Helm chart: media services default to Garage
|
||||
|
||||
The `meet` chart now defaults `serviceMedia.host` and `serviceMediaFiles.host` to `garage.meet.svc.cluster.local`, and the `upstream-vhost` annotation of `ingressMedia` and `ingressMediaFiles` to `garage.meet.svc.cluster.local:9000`. If you relied on the former `minio.meet.svc.cluster.local` defaults, set these values explicitly to your S3 service before upgrading, or recordings and files stop being served under `/media`.
|
||||
### Recording encoding settings replaced by a resolution/profile model
|
||||
|
||||
The `RECORDING_ENCODING_*` settings introduced in v1.16.0 exposed raw encoder
|
||||
values (width, height, framerate, bitrate). They are replaced by two named and configurable sets of
|
||||
dimensions, a **resolution** (default: `540p`, `720p`, `1080p`) and a **profile**
|
||||
(default: `talking_heads`, `text`, `mixed`, `full`), which are resolved to the width, height,
|
||||
fps and video bitrate.
|
||||
|
||||
**The following environment variables are no longer read. If they are still set in
|
||||
your deployment they are silently ignored, and your recordings will be encoded with
|
||||
the new defaults instead of your tuned values.**
|
||||
|
||||
| Removed variable | Replaced by |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `RECORDING_ENCODING_ENABLED` | Nothing. A default encoding is now always built (see below). **Not** `RECORDING_CUSTOM_ENCODING_ENABLED`, which gates a different feature. |
|
||||
| `RECORDING_ENCODING_WIDTH` | The `width` of the entry selected by `RECORDING_ENCODING_DEFAULT_RESOLUTION` in `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`. |
|
||||
| `RECORDING_ENCODING_HEIGHT` | The `height` of that same entry. |
|
||||
| `RECORDING_ENCODING_FRAMERATE` | The `fps` of the profile selected by `RECORDING_ENCODING_DEFAULT_PROFILE` in `RECORDING_ENCODING_AVAILABLE_PROFILES`. |
|
||||
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | That profile's `kbps`. |
|
||||
|
||||
`RECORDING_ENCODING_AUDIO_BITRATE_KBPS` and `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S`
|
||||
keep their names and meaning. The keyframe interval now defaults to `0` (unset,
|
||||
encoder's choice) instead of `4.0`.
|
||||
|
||||
#### If you never set `RECORDING_ENCODING_ENABLED=True`
|
||||
|
||||
The shipped defaults (`RECORDING_ENCODING_DEFAULT_PROFILE=full`,
|
||||
`RECORDING_ENCODING_DEFAULT_RESOLUTION=720p`) match LiveKit's built-in
|
||||
`H264_720P_30` preset: 1280×720, 30 fps, 3000 kbps H.264 MAIN, 128 kbps AAC.
|
||||
Video output is therefore unchanged.
|
||||
|
||||
Audio and keyframing may not be. These values are now sent explicitly as advanced
|
||||
`EncodingOptions` rather than relying on LiveKit's preset, so
|
||||
`RECORDING_ENCODING_AUDIO_BITRATE_KBPS` and `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S`
|
||||
now apply to every recording. They previously applied only when
|
||||
`RECORDING_ENCODING_ENABLED` was `True`. **If you set either of them while the
|
||||
feature was disabled, they had no effect and now do**; check them before upgrading.
|
||||
|
||||
If you never set them, no action is required: 128 kbps AAC is what the preset used,
|
||||
and the keyframe interval now defaults to `0`, which leaves the field unset so the
|
||||
encoder keeps picking it as before. Set `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0`
|
||||
if you want fixed 4-second keyframes (the value the setting defaulted to while it
|
||||
was gated behind `RECORDING_ENCODING_ENABLED`).
|
||||
|
||||
To keep letting LiveKit pick the encoding instead, set either default to an empty
|
||||
value:
|
||||
|
||||
```
|
||||
RECORDING_ENCODING_DEFAULT_RESOLUTION=
|
||||
RECORDING_ENCODING_DEFAULT_PROFILE=
|
||||
```
|
||||
|
||||
#### If you had tuned `RECORDING_ENCODING_*` values
|
||||
|
||||
Translate your old values into a default resolution and a default profile. Declare your own resolution and/or profile. Both maps are read from the
|
||||
environment as a single-line Python/JSON dict literal (parsed with
|
||||
`ast.literal_eval`, so use double-quoted keys and no trailing commas, and do not
|
||||
add outer quotes in `.env`-style files):
|
||||
|
||||
```bash
|
||||
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}
|
||||
RECORDING_ENCODING_AVAILABLE_PROFILES={"my_old_profile": {"fps": 15, "kbps": {"540p": 350, "720p": 600, "1080p": 1100}}}
|
||||
RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
|
||||
RECORDING_ENCODING_DEFAULT_PROFILE=my_old_profile
|
||||
```
|
||||
|
||||
Both maps are validated at startup and a malformed one raises a `ValueError`:
|
||||
|
||||
- every entry of `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS` must declare `width` and
|
||||
`height`, and every entry of `RECORDING_ENCODING_AVAILABLE_PROFILES` an `fps` and a
|
||||
`kbps` map;
|
||||
- every profile must define a `kbps` entry for **exactly** the keys of
|
||||
`RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`; overriding one of the two maps usually
|
||||
means overriding both;
|
||||
- `RECORDING_ENCODING_DEFAULT_RESOLUTION` and `RECORDING_ENCODING_DEFAULT_PROFILE`,
|
||||
when non-empty, must be keys of their respective map.
|
||||
|
||||
#### Breaking: custom worker services must accept `encoding_options`
|
||||
|
||||
Only concerns deployments pointing `RECORDING_WORKER_CLASSES` at their own worker
|
||||
class. The shipped `VideoCompositeEgressService` and `AudioCompositeEgressService`
|
||||
are already updated.
|
||||
|
||||
The `WorkerService` protocol's `start()` takes a third argument, and the mediator
|
||||
now always passes it as a keyword when the recording carries no per-recording encoding:
|
||||
|
||||
```python
|
||||
# before
|
||||
def start(self, room_id: str, recording_id: str) -> str: ...
|
||||
|
||||
# now
|
||||
def start(
|
||||
self,
|
||||
room_id: str,
|
||||
recording_id: str,
|
||||
encoding_options: Optional[Dict[str, Any]] = None,
|
||||
) -> str: ...
|
||||
```
|
||||
|
||||
#### Optional: per-recording encoding
|
||||
|
||||
`RECORDING_CUSTOM_ENCODING_ENABLED` (default `False`) toggles whether the
|
||||
start-recording API accepts an `encoding` object
|
||||
(`{"resolution": "720p", "profile": "talking_heads"}`, `profile` optional. It
|
||||
falls back to `RECORDING_ENCODING_DEFAULT_PROFILE`) that overrides the default for
|
||||
a single recording. It does not enable or disable the
|
||||
default encoding, which is built from the two `RECORDING_ENCODING_DEFAULT_*`
|
||||
settings either way. Leaving it at `False` preserves the previous behaviour, where
|
||||
every recording uses the server-side encoding: requests carrying
|
||||
`options.encoding` are rejected with a `400` before the recording is created, so
|
||||
nothing is persisted and no egress is started.
|
||||
|
||||
Before enabling it:
|
||||
|
||||
- clients can only pick keys you declared; there is no way to send a raw width or bitrate
|
||||
- as of this implementation, the frontend never sends `encoding`
|
||||
- `encoding` is accepted but ignored for `transcript` recordings, whose audio-only
|
||||
egress has no video encoding to configure.
|
||||
|
||||
See [docs/features/recording.md](docs/features/recording.md#tuning-recording-encoding)
|
||||
for the full setting reference, the shipped profile table and the tuning caveats.
|
||||
|
||||
## v1.30.0
|
||||
|
||||
### Removing S3 storage-event webhooks for recordings
|
||||
|
||||
+2
-2
@@ -104,8 +104,8 @@ k8s_yaml(secret_yaml_generic(
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev-keycloak} template .'))
|
||||
|
||||
k8s_resource('minio-bucket', resource_deps=['minio'])
|
||||
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
|
||||
k8s_resource('garage-cors', resource_deps=['garage'])
|
||||
k8s_resource('meet-backend', resource_deps=['postgresql', 'garage-cors', 'redis', 'livekit-livekit-server'])
|
||||
k8s_resource('meet-celery-backend', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
|
||||
|
||||
+9
-10
@@ -5,7 +5,7 @@ set -eo pipefail
|
||||
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
||||
UNSET_USER=0
|
||||
|
||||
COMPOSE_FILE="${REPO_DIR}/compose.yml"
|
||||
export COMPOSE_FILE="${COMPOSE_FILE:-${REPO_DIR}/compose.yml}"
|
||||
COMPOSE_PROJECT="meet"
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ function _docker_compose() {
|
||||
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
|
||||
docker compose \
|
||||
-p "${COMPOSE_PROJECT}" \
|
||||
-f "${COMPOSE_FILE}" \
|
||||
--project-directory "${REPO_DIR}" \
|
||||
"$@"
|
||||
}
|
||||
@@ -56,12 +55,12 @@ function _docker_compose() {
|
||||
function _dc_run() {
|
||||
_set_user
|
||||
|
||||
user_args="--user=$USER_ID"
|
||||
if [ -z $USER_ID ]; then
|
||||
user_args=""
|
||||
user_args=()
|
||||
if [ -n "$USER_ID" ]; then
|
||||
user_args=("--user=$USER_ID")
|
||||
fi
|
||||
|
||||
_docker_compose run --rm $user_args "$@"
|
||||
_docker_compose run --rm "${user_args[@]}" "$@"
|
||||
}
|
||||
|
||||
# _dc_exec: wrap docker compose exec command
|
||||
@@ -75,12 +74,12 @@ function _dc_exec() {
|
||||
|
||||
echo "🐳(compose) exec command: '\$@'"
|
||||
|
||||
user_args="--user=$USER_ID"
|
||||
if [ -z $USER_ID ]; then
|
||||
user_args=""
|
||||
user_args=()
|
||||
if [ -n "$USER_ID" ]; then
|
||||
user_args=("--user=$USER_ID")
|
||||
fi
|
||||
|
||||
_docker_compose exec $user_args "$@"
|
||||
_docker_compose exec "${user_args[@]}" "$@"
|
||||
}
|
||||
|
||||
# _django_manage: wrap django's manage.py command with docker compose
|
||||
|
||||
@@ -40,7 +40,7 @@ if [ -n "$CUSTOM_LOGO_URL" ]; then
|
||||
[[ "$IS_SVG" == false ]] && echo "[custom-logo] ERROR: not a valid SVG file" >&2 && exit 1
|
||||
|
||||
mv -f "$TMP_FILE" "$LOGO_FILE"
|
||||
echo "[custom-logo] INFO: Custom logo downloaded successfuly"
|
||||
echo "[custom-logo] INFO: Custom logo downloaded successfully"
|
||||
fi
|
||||
|
||||
mv src/backend/* ./
|
||||
|
||||
@@ -7,7 +7,7 @@ gunicorn -b 0.0.0.0:8000 meet.wsgi:application --log-file - &
|
||||
bin/run &
|
||||
|
||||
# if the current shell is killed, also terminate all its children
|
||||
trap "pkill SIGTERM -P $$" SIGTERM
|
||||
trap 'pkill -TERM -P $$' SIGTERM
|
||||
|
||||
# wait for a single child to finish,
|
||||
wait -n
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_docker_compose "$@"
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -o errexit
|
||||
|
||||
CURRENT_DIR=$(pwd)
|
||||
NAMESPACE=${1:-meet}
|
||||
SECRET_NAME=${2:-bitwarden-cli-meet}
|
||||
TEMP_SECRET_FILE=$(mktemp)
|
||||
@@ -30,10 +29,10 @@ check_secret_exists() {
|
||||
# Collect user input securely
|
||||
get_user_input() {
|
||||
echo "Please provide the following information:"
|
||||
read -p "Enter your Vaultwarden email login: " LOGIN
|
||||
read -s -p "Enter your Vaultwarden password: " PASSWORD
|
||||
read -r -p "Enter your Vaultwarden email login: " LOGIN
|
||||
read -r -s -p "Enter your Vaultwarden password: " PASSWORD
|
||||
echo
|
||||
read -p "Enter your Vaultwarden server url: " URL
|
||||
read -r -p "Enter your Vaultwarden server url: " URL
|
||||
}
|
||||
|
||||
# Create and apply the secret
|
||||
@@ -77,7 +76,7 @@ main() {
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e ${TEMP_SECRET_FILE}
|
||||
echo -e "${TEMP_SECRET_FILE}"
|
||||
|
||||
get_user_input
|
||||
echo -e "\nCreating Vaultwarden secret…"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -68,7 +68,7 @@ fi
|
||||
|
||||
# Ask user for release version number
|
||||
echo ""
|
||||
read -p "Enter release version number (e.g., 1.2.3): " VERSION
|
||||
read -r -p "Enter release version number (e.g., 1.2.3): " VERSION
|
||||
|
||||
# Validate version format (basic semver check)
|
||||
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
git submodule update --init --recursive
|
||||
# shellcheck disable=SC2016
|
||||
git submodule foreach 'git fetch origin; git checkout $(git rev-parse --abbrev-ref HEAD); git reset --hard origin/$(git rev-parse --abbrev-ref HEAD); git submodule update --recursive; git clean -dfx'
|
||||
|
||||
@@ -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
|
||||
|
||||
+34
-25
@@ -15,36 +15,44 @@ services:
|
||||
ports:
|
||||
- "1081:1080"
|
||||
|
||||
minio:
|
||||
garage:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: minio/minio
|
||||
image: dxflrs/garage:v2.4.1
|
||||
command: /garage server --single-node --default-bucket
|
||||
env_file:
|
||||
- env.d/development/garage
|
||||
environment:
|
||||
- MINIO_ROOT_USER=meet
|
||||
- MINIO_ROOT_PASSWORD=password
|
||||
- GARAGE_DEFAULT_ACCESS_KEY=meet-access-key
|
||||
- GARAGE_DEFAULT_SECRET_KEY=meet-secret-access-key
|
||||
- GARAGE_DEFAULT_BUCKET=meet-media-storage
|
||||
ports:
|
||||
- '9000:9000'
|
||||
- '9001:9001'
|
||||
- '127.0.0.1:9000:9000'
|
||||
healthcheck:
|
||||
test: [ "CMD", "mc", "ready", "local" ]
|
||||
test: [ "CMD", "/garage", "health" ]
|
||||
interval: 1s
|
||||
timeout: 20s
|
||||
retries: 300
|
||||
entrypoint: ""
|
||||
command: minio server --console-address :9001 /data
|
||||
volumes:
|
||||
- ./data/media:/data
|
||||
- ./docker/files/etc/garage/garage.toml:/etc/garage.toml:ro
|
||||
- ./data/media:/var/lib/garage
|
||||
|
||||
createbuckets:
|
||||
image: minio/mc
|
||||
# Garage denies cross-origin requests by default: allow the frontend to upload files
|
||||
garage-cors:
|
||||
image: amazon/aws-cli:2.37.1
|
||||
environment:
|
||||
- AWS_ACCESS_KEY_ID=meet-access-key
|
||||
- AWS_SECRET_ACCESS_KEY=meet-secret-access-key
|
||||
- AWS_DEFAULT_REGION=local
|
||||
depends_on:
|
||||
minio:
|
||||
garage:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
entrypoint: >
|
||||
sh -c "
|
||||
/usr/bin/mc alias set meet http://minio:9000 meet password && \
|
||||
/usr/bin/mc mb meet/meet-media-storage && \
|
||||
exit 0;"
|
||||
command:
|
||||
- s3api
|
||||
- put-bucket-cors
|
||||
- --endpoint-url=http://garage:9000
|
||||
- --bucket=meet-media-storage
|
||||
- '--cors-configuration={"CORSRules": [{"AllowedOrigins": ["http://localhost:3000"], "AllowedMethods": ["GET", "HEAD", "PUT"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"]}]}'
|
||||
|
||||
app-dev:
|
||||
build:
|
||||
@@ -70,7 +78,7 @@ services:
|
||||
- postgresql
|
||||
- mailcatcher
|
||||
- redis
|
||||
- createbuckets
|
||||
- garage-cors
|
||||
extra_hosts:
|
||||
- "127.0.0.1.nip.io:host-gateway"
|
||||
networks:
|
||||
@@ -110,7 +118,7 @@ services:
|
||||
- postgresql
|
||||
- redis
|
||||
- livekit
|
||||
- minio
|
||||
- garage
|
||||
|
||||
celery:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -145,6 +153,7 @@ services:
|
||||
target: frontend-production
|
||||
args:
|
||||
VITE_API_BASE_URL: "http://localhost:8071"
|
||||
VITE_MEDIA_BASE_URL: "http://localhost:8083"
|
||||
VITE_APP_TITLE: "LaSuite Meet"
|
||||
image: meet:frontend-development
|
||||
ports:
|
||||
@@ -164,7 +173,7 @@ services:
|
||||
working_dir: /app
|
||||
|
||||
node:
|
||||
image: node:22
|
||||
image: node:22-alpine
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
environment:
|
||||
HOME: /tmp
|
||||
@@ -244,7 +253,7 @@ services:
|
||||
- /app/.venv
|
||||
depends_on:
|
||||
- livekit
|
||||
- minio
|
||||
- garage
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
@@ -263,7 +272,7 @@ services:
|
||||
- /app/.venv
|
||||
|
||||
redis-summary:
|
||||
image: redis
|
||||
image: redis:5
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
@@ -297,7 +306,7 @@ services:
|
||||
depends_on:
|
||||
- redis-summary
|
||||
- app-summary-dev
|
||||
- minio
|
||||
- garage
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
@@ -317,7 +326,7 @@ services:
|
||||
depends_on:
|
||||
- redis-summary
|
||||
- app-summary-dev
|
||||
- minio
|
||||
- garage
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"nodes": {
|
||||
"devenv": {
|
||||
"locked": {
|
||||
"dir": "src/modules",
|
||||
"lastModified": 1778705847,
|
||||
"narHash": "sha256-EQnZCy7r4VMO6KDoytxHBa0mFbM1D9g1kaDfs/s0YZA=",
|
||||
"ref": "refs/tags/v2.1.2",
|
||||
"rev": "ea3d94ac9d6bf6a1313773170122ca4e2ef5a0be",
|
||||
"revCount": 6569,
|
||||
"type": "git",
|
||||
"url": "https://github.com/cachix/devenv"
|
||||
},
|
||||
"original": {
|
||||
"dir": "src/modules",
|
||||
"ref": "refs/tags/v2.1.2",
|
||||
"type": "git",
|
||||
"url": "https://github.com/cachix/devenv"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1789542786,
|
||||
"narHash": "sha256-ajQuqcxnj6RYnwCjA/9FTCnrdR2+BDzstE8UembXf60=",
|
||||
"ref": "nixos-26.05",
|
||||
"rev": "4c7870105e7f1fdf9c48688c8d7efc21abf0688a",
|
||||
"shallow": true,
|
||||
"type": "git",
|
||||
"url": "https://github.com/NixOS/nixpkgs"
|
||||
},
|
||||
"original": {
|
||||
"ref": "nixos-26.05",
|
||||
"shallow": true,
|
||||
"type": "git",
|
||||
"url": "https://github.com/NixOS/nixpkgs"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
# =============================================================================
|
||||
# devenv.nix — La Suite Meet ("Visio") developer environment
|
||||
# =============================================================================
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
python = pkgs.python313;
|
||||
nodejs = pkgs.nodejs_22;
|
||||
|
||||
backendDir = "src/backend";
|
||||
agentsDir = "src/agents";
|
||||
summaryDir = "src/summary";
|
||||
frontendDir = "src/frontend";
|
||||
|
||||
readDotEnv =
|
||||
file:
|
||||
let
|
||||
lines = lib.splitString "\n" (builtins.readFile file);
|
||||
unquote =
|
||||
v:
|
||||
let
|
||||
len = builtins.stringLength v;
|
||||
in
|
||||
if len >= 2 && lib.hasPrefix "\"" v && lib.hasSuffix "\"" v then
|
||||
builtins.substring 1 (len - 2) v
|
||||
else if len >= 2 && lib.hasPrefix "'" v && lib.hasSuffix "'" v then
|
||||
builtins.substring 1 (len - 2) v
|
||||
else
|
||||
v;
|
||||
parseLine =
|
||||
line:
|
||||
let
|
||||
m = builtins.match "[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(.*)" line;
|
||||
in
|
||||
if m == null then null else { name = builtins.elemAt m 0; value = unquote (builtins.elemAt m 1); };
|
||||
in
|
||||
builtins.listToAttrs (builtins.filter (x: x != null) (map parseLine lines));
|
||||
|
||||
# Reuse existing .env
|
||||
dotEnv =
|
||||
(readDotEnv ./env.d/development/common.dist)
|
||||
// (readDotEnv ./env.d/development/postgresql.dist);
|
||||
|
||||
sharedEnv = builtins.removeAttrs dotEnv [ "PYTHONPATH" ]; # only makes sense inside the backend container.
|
||||
in
|
||||
{
|
||||
options.meet = {
|
||||
agents.enable = lib.mkEnableOption "tooling for the LiveKit agents in src/agents";
|
||||
summary.enable = lib.mkEnableOption "tooling for the summary service in src/summary";
|
||||
k8s.enable = lib.mkEnableOption "Kubernetes dev utilities";
|
||||
};
|
||||
|
||||
config = {
|
||||
# Profile can be activated with devenv --profile <profile> shell
|
||||
profiles = {
|
||||
agents.module = {
|
||||
meet.agents.enable = true;
|
||||
};
|
||||
summary.module = {
|
||||
meet.summary.enable = true;
|
||||
};
|
||||
k8s.module = {
|
||||
meet.k8s.enable = true;
|
||||
};
|
||||
};
|
||||
languages.python = {
|
||||
enable = true;
|
||||
package = python;
|
||||
directory = backendDir;
|
||||
manylinux.enable = pkgs.stdenv.hostPlatform.isLinux;
|
||||
|
||||
libraries = [
|
||||
"${config.devenv.dotfile}/profile"
|
||||
pkgs.file
|
||||
pkgs.zlib
|
||||
pkgs.libffi
|
||||
pkgs.openssl
|
||||
];
|
||||
|
||||
uv.enable = true;
|
||||
uv.sync.enable = false;
|
||||
venv.enable = false;
|
||||
lsp.enable = true;
|
||||
};
|
||||
|
||||
languages.javascript = {
|
||||
enable = true;
|
||||
package = nodejs;
|
||||
directory = frontendDir;
|
||||
|
||||
npm.enable = true;
|
||||
yarn.enable = true;
|
||||
corepack.enable = false;
|
||||
};
|
||||
|
||||
languages.typescript.enable = false;
|
||||
languages.nix.enable = true;
|
||||
|
||||
packages =
|
||||
with pkgs;
|
||||
[
|
||||
gnumake
|
||||
file
|
||||
shared-mime-info
|
||||
gettext
|
||||
postgresql_16
|
||||
git
|
||||
curl
|
||||
jq
|
||||
podman
|
||||
podman-compose
|
||||
docker-client
|
||||
]
|
||||
|
||||
# -- LiveKit agents
|
||||
++ lib.optionals config.meet.agents.enable [
|
||||
glib
|
||||
portaudio
|
||||
livekit-cli
|
||||
]
|
||||
|
||||
# -- summary service
|
||||
++ lib.optionals config.meet.summary.enable [
|
||||
redis
|
||||
]
|
||||
|
||||
# -- Kubernetes tools
|
||||
++ lib.optionals config.meet.k8s.enable [
|
||||
kubectl
|
||||
kubernetes-helm
|
||||
helmfile
|
||||
tilt
|
||||
kind
|
||||
mkcert
|
||||
];
|
||||
|
||||
env = sharedEnv // {
|
||||
UV_LINK_MODE = "copy";
|
||||
|
||||
PYTHONDONTWRITEBYTECODE = "1";
|
||||
PYTHONUNBUFFERED = "1";
|
||||
|
||||
UV_PROJECT_ENVIRONMENT = lib.mkForce ".venv";
|
||||
|
||||
COMPOSE_PROJECT_NAME = "meet";
|
||||
|
||||
DJANGO_DATA_DIR = "${config.devenv.root}/data";
|
||||
|
||||
# Database / Pgsql
|
||||
DB_HOST = "127.0.0.1";
|
||||
DB_PORT = "15432";
|
||||
PGHOST = "127.0.0.1";
|
||||
PGPORT = "15432";
|
||||
PGDATABASE = sharedEnv.DB_NAME;
|
||||
PGUSER = sharedEnv.DB_USER;
|
||||
PGPASSWORD = sharedEnv.DB_PASSWORD;
|
||||
|
||||
REDIS_URL = "redis://127.0.0.1:6379/1";
|
||||
CELERY_BROKER_URL = "redis://127.0.0.1:6379/0";
|
||||
|
||||
# S3 / Garage
|
||||
AWS_S3_ENDPOINT_URL = "http://127.0.0.1:9000";
|
||||
|
||||
# OIDC
|
||||
OIDC_OP_JWKS_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/certs";
|
||||
OIDC_OP_TOKEN_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/token";
|
||||
OIDC_OP_USER_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/userinfo";
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/token/introspect";
|
||||
|
||||
# summary service
|
||||
SUMMARY_SERVICE_ENDPOINT = "http://127.0.0.1:8001/api/v2/async-jobs/transcribe/";
|
||||
SUMMARY_SERVICE_VERSION = "2";
|
||||
|
||||
# Mail
|
||||
DJANGO_EMAIL_HOST = "127.0.0.1";
|
||||
};
|
||||
|
||||
scripts = {
|
||||
|
||||
meet-venv = {
|
||||
description = "Create/refresh meet uv virtualenvs for backend, agents and summary";
|
||||
exec = ''
|
||||
set -euo pipefail
|
||||
cd "$DEVENV_ROOT"
|
||||
|
||||
echo "==> ${backendDir} (uv sync --locked, dependency-groups)"
|
||||
( cd "${backendDir}" && uv sync --locked --all-groups )
|
||||
|
||||
echo "==> ${agentsDir} (uv sync --locked --all-extras)"
|
||||
( cd "${agentsDir}" && uv sync --locked --all-extras )
|
||||
|
||||
echo "==> ${summaryDir} (uv sync --locked --all-extras)"
|
||||
( cd "${summaryDir}" && uv sync --locked --all-extras )
|
||||
|
||||
echo
|
||||
echo "Synced the following virtualenvs successfully:"
|
||||
echo " ${backendDir}/.venv"
|
||||
echo " ${agentsDir}/.venv"
|
||||
echo " ${summaryDir}/.venv"
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
enterShell = ''
|
||||
# Make podman socket accessible in order to launch regular docker commands.
|
||||
# Set MEET_PODMAN_SOCKET=0 to keep the DOCKER_HOST of the calling environment.
|
||||
case "''${MEET_PODMAN_SOCKET:-1}" in
|
||||
0|false|no|off) ;;
|
||||
*)
|
||||
_rundir="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||
export DOCKER_HOST="unix://$_rundir/podman/podman.sock"
|
||||
unset _rundir
|
||||
;;
|
||||
esac
|
||||
|
||||
# Compose files to merge
|
||||
_compose_dir="${config.devenv.root}/docker/compose.d"
|
||||
_compose_files="${config.devenv.root}/compose.yml"
|
||||
|
||||
export DOCKER_USER="$(id -u):$(id -g)"
|
||||
|
||||
case "''${DOCKER_HOST:-}" in
|
||||
*podman*)
|
||||
_compose_files="$_compose_files:$_compose_dir/compose.podman.yml"
|
||||
|
||||
# Build images with Podman/Buildah rather than BuildKit. `docker
|
||||
# compose build` otherwise has buildx boot a moby/buildkit container,
|
||||
# and that container lands in its own network namespace with neither
|
||||
# the proxy in its environment nor any route to it.
|
||||
# Buildah has neither problem: base images are resolved by the Podman systemd
|
||||
# service, which inherits the proxy from its systemd socket activated unit, and
|
||||
# RUN steps execute in the *host* network namespace
|
||||
|
||||
export DOCKER_BUILDKIT=0
|
||||
export COMPOSE_BAKE=false
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
# Apply Bureautix override
|
||||
if [ -n "''${http_proxy:-}" ]; then
|
||||
_compose_files="$_compose_files:$_compose_dir/compose.bureautix.yml"
|
||||
fi
|
||||
|
||||
export COMPOSE_FILE="$_compose_files"
|
||||
unset _compose_dir _compose_files
|
||||
|
||||
# Make binaries accessible
|
||||
for _d in \
|
||||
"$DEVENV_ROOT/${backendDir}/.venv/bin" \
|
||||
"$DEVENV_ROOT/${agentsDir}/.venv/bin" \
|
||||
"$DEVENV_ROOT/${summaryDir}/.venv/bin" \
|
||||
"$DEVENV_ROOT/${frontendDir}/node_modules/.bin"
|
||||
do
|
||||
[ -d "$_d" ] && export PATH="$_d:$PATH"
|
||||
done
|
||||
unset _d
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
inputs:
|
||||
nixpkgs:
|
||||
url: git+https://github.com/NixOS/nixpkgs?ref=nixos-26.05&shallow=1
|
||||
devenv:
|
||||
url: git+https://github.com/cachix/devenv?ref=refs/tags/v2.1.2&dir=src/modules
|
||||
@@ -0,0 +1,48 @@
|
||||
# Bureautix proxy overrides
|
||||
#
|
||||
# Builds submitted through the Docker API of the Podman service get none of
|
||||
# the proxy variables in their RUN steps. We need to pass the http_proxy explicitly
|
||||
# otherwise all connections fail during the build.
|
||||
|
||||
x-proxy-vars: &proxy-vars
|
||||
http_proxy: ${http_proxy:-}
|
||||
https_proxy: ${https_proxy:-}
|
||||
no_proxy: ${no_proxy:-}
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
app-dev:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
frontend:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
metadata-collector-dev:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
multi-user-transcriber-dev:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
app-summary-dev:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
celery-summary-transcribe:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
celery-summary-summarize:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
# The local proxy listens on 8080 and collides with Keycloak's published admin port.
|
||||
keycloak:
|
||||
ports: !override
|
||||
- "8081:8080"
|
||||
@@ -0,0 +1,35 @@
|
||||
# Rootless Podman override for compose.yml.
|
||||
#
|
||||
# Rootless Podman maps container UID 0 to the host user and every other
|
||||
# container UID to a subuid that owns nothing in the worktree. Meet compose.ymlruns
|
||||
# its containers as DOCKER_USER=$(id -u):$(id -g), which would land on such a
|
||||
# subuid and make every bind mount effectively read-only.
|
||||
#
|
||||
# `userns_mode: keep-id` maps the host user to the same UID and GID inside the
|
||||
# container instead, so DOCKER_USER keeps its Docker value and files written
|
||||
# through a bind mount are owned by the host user on both sides.
|
||||
#
|
||||
# Only the services that mount the worktree and run as DOCKER_USER are listed.
|
||||
|
||||
x-keep-id: &keep-id
|
||||
userns_mode: keep-id
|
||||
|
||||
services:
|
||||
app-dev:
|
||||
<<: *keep-id
|
||||
celery-dev:
|
||||
<<: *keep-id
|
||||
garage:
|
||||
<<: *keep-id
|
||||
garage-cors:
|
||||
<<: *keep-id
|
||||
node:
|
||||
<<: *keep-id
|
||||
crowdin:
|
||||
<<: *keep-id
|
||||
metadata-collector-dev:
|
||||
<<: *keep-id
|
||||
multi-user-transcriber-dev:
|
||||
<<: *keep-id
|
||||
app-summary-dev:
|
||||
<<: *keep-id
|
||||
@@ -57,7 +57,9 @@ RUN npx webpack --mode production
|
||||
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk del curl
|
||||
RUN apk upgrade --no-cache libexpat && \
|
||||
apk add --no-cache --upgrade 'pcre2>=10.49-r0' && \
|
||||
apk del curl
|
||||
USER nginx
|
||||
|
||||
USER nginx
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Garage configuration for local development only: single node, no replication.
|
||||
# See https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/
|
||||
metadata_dir = "/var/lib/garage/meta"
|
||||
data_dir = "/var/lib/garage/data"
|
||||
db_engine = "lmdb"
|
||||
|
||||
replication_factor = 1
|
||||
|
||||
rpc_bind_addr = "127.0.0.1:3901"
|
||||
rpc_public_addr = "127.0.0.1:3901"
|
||||
|
||||
[s3_api]
|
||||
api_bind_addr = "[::]:9000"
|
||||
# Clients must sign their requests for this region (AWS_S3_REGION_NAME)
|
||||
s3_region = "local"
|
||||
@@ -4,6 +4,36 @@ server {
|
||||
server_name localhost;
|
||||
charset utf-8;
|
||||
|
||||
# Proxy auth for recordings (authorized by the recordings viewset)
|
||||
location /media/recordings/ {
|
||||
auth_request /media-auth-recordings;
|
||||
auth_request_set $authHeader $upstream_http_authorization;
|
||||
auth_request_set $authDate $upstream_http_x_amz_date;
|
||||
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
|
||||
|
||||
proxy_set_header Authorization $authHeader;
|
||||
proxy_set_header X-Amz-Date $authDate;
|
||||
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
|
||||
|
||||
proxy_pass http://garage:9000/meet-media-storage/recordings/;
|
||||
proxy_set_header Host garage:9000;
|
||||
proxy_hide_header Content-Disposition;
|
||||
add_header Content-Disposition "attachment";
|
||||
}
|
||||
|
||||
location = /media-auth-recordings {
|
||||
internal;
|
||||
proxy_pass http://app-dev:8000/api/v1.0/recordings/media-auth/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Original-URL $request_uri;
|
||||
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header X-Original-Method $request_method;
|
||||
}
|
||||
|
||||
# Proxy auth for media
|
||||
location /media/ {
|
||||
# Auth request configuration
|
||||
@@ -17,9 +47,9 @@ server {
|
||||
proxy_set_header X-Amz-Date $authDate;
|
||||
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
|
||||
|
||||
# Get resource from Minio
|
||||
proxy_pass http://minio:9000/meet-media-storage/;
|
||||
proxy_set_header Host minio:9000;
|
||||
# Get resource from Garage
|
||||
proxy_pass http://garage:9000/meet-media-storage/;
|
||||
proxy_set_header Host garage:9000;
|
||||
# To use with ds_proxy
|
||||
# proxy_pass http://ds-proxy:4444/upstream/meet-media-storage/;
|
||||
# proxy_set_header Host ds-proxy:4444;
|
||||
|
||||
@@ -14,4 +14,7 @@ accesslog = "-"
|
||||
# Using '-' for the error log file makes gunicorn log errors to stderr
|
||||
errorlog = "-"
|
||||
loglevel = "info"
|
||||
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(M)s'
|
||||
access_log_format = (
|
||||
'%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(M)s'
|
||||
" rid=%({x-request-id}o)s"
|
||||
)
|
||||
|
||||
@@ -21,3 +21,10 @@ turn:
|
||||
- 192.168.0.0/16
|
||||
- 172.16.0.0/12
|
||||
|
||||
rtc:
|
||||
node_ip: 127.0.0.1
|
||||
advertise_internal_ip: true
|
||||
udp_port: 7882
|
||||
tcp_port: 7881
|
||||
use_external_ip: false
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ These components rely on a few key services:
|
||||
|
||||
- PostgreSQL for storing data (users, rooms, recordings)
|
||||
- Redis for caching and inter-service communication
|
||||
- MinIO for storing files (room recordings)
|
||||
- Garage for storing files (room recordings)
|
||||
- Celery workers for meeting transcript (optional, required for AI beta features)
|
||||
|
||||
We provide two stack options for getting Visio up and running for development:
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
# Audit logging
|
||||
|
||||
La Suite Meet emits a structured **audit log**: one JSON line per notable action, saying who did what, on behalf of
|
||||
whom, on which resource, from where, and whether it succeeded.
|
||||
|
||||
## What an event looks like
|
||||
|
||||
Events are written on the dedicated `audit` logger, one per line and look like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"@timestamp": "2026-09-15T08:41:12.345+00:00",
|
||||
"ecs": {"version": "8.11.0"},
|
||||
"log_type": "audit",
|
||||
"service": {"name": "meet", "environment": "production"},
|
||||
"event": {
|
||||
"kind": "event",
|
||||
"action": "room.create",
|
||||
"category": ["api"],
|
||||
"type": ["creation"],
|
||||
"outcome": "success"
|
||||
},
|
||||
"trace": {"id": "6f1c0d0e2a8b4c1d9e7f0a1b2c3d4e5f"},
|
||||
"client": {"ip": "1.2.3.4"},
|
||||
"source": {"ip": "1.2.3.4"},
|
||||
"http": {"request": {"method": "POST"}},
|
||||
"url": {"path": "/external-api/v1.0/rooms/"},
|
||||
"user": {"id": "beecd833-4be4-4675-b139-a196b07144a9", "sub": "0edebfa3-1355-4891-ae63-daf9fd37ac04", "domain": "gouv.fr"},
|
||||
"organization": {"id": "calendar-app"},
|
||||
"lasuite": {
|
||||
"actor": {"type": "application"},
|
||||
"auth": {"method": "application_jwt"},
|
||||
"application": {"client_id": "calendar-app"},
|
||||
"outcome": "success",
|
||||
"target": {"type": "room", "id": "3b1d…", "slug": "daily-standup", "name": "Daily standup", "access_level": "trusted"}
|
||||
},
|
||||
"log": {"level": "info", "logger": "audit"}
|
||||
}
|
||||
```
|
||||
|
||||
A refusal is recorded under the action that was attempted: the same `room.create`, with
|
||||
`"event": {"outcome": "failure", "type": ["creation", "denied"], "reason": "permission_denied"}`,
|
||||
`"lasuite": {"outcome": "denied"}`, `"http": {"response": {"status_code": 403}}` and `"error": {"message": "…"}`.
|
||||
|
||||
## Fields
|
||||
|
||||
Standard fields follow the [Elastic Common Schema](https://www.elastic.co/guide/en/ecs/current/index.html);
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `@timestamp` | ISO 8601 with millisecond precision in UTC timezone` |
|
||||
| `log_type` | Always `audit` |
|
||||
| `service.name`, `service.environment` | `AUDIT_LOG_SERVICE_NAME` and current environment |
|
||||
| `event.action` | What was attempted, from the catalogue below |
|
||||
| `event.category`, `event.type` | ECS classification (`api`, `authentication`, `iam`... / `creation`, `change`, `access`, `denied`, `user`...) |
|
||||
| `event.outcome` | ECS `success` or `failure` |
|
||||
| `event.reason` | Why it did not succeed: `authentication_failed`, `permission_denied`, `rate_limited`, `validation_error`, `not_found`, `conflict`, `internal_error` |
|
||||
| `lasuite.outcome` | `success`, `failure` or `denied` |
|
||||
| `lasuite.actor.type` | `user`, `application`, `device`, `service`, `system` or `anonymous` |
|
||||
| `lasuite.actor.name` | Name of a service actor |
|
||||
| `lasuite.auth.method` | `session`, `application_jwt`, `addons_jwt`, `resource_server`, `livekit_token`, `shared_secret`, `client_credentials`, `oidc`, `password`, `none`, or `unknown` for a class that is not registered. Requests served outside DRF, as the admin and logout are, report `session` when signed in |
|
||||
| `lasuite.application.client_id` | The external application acting, when there is one. Only set once its credentials are verified |
|
||||
| `user.id`, `user.sub`, `user.domain` | The (delegated) human user: primary key, OIDC sub when the account has one, and email domain. The email address is never recorded |
|
||||
| `user.target.id`, `user.target.sub`, `user.target.domain` | The account an IAM action was performed on, when the target is a user. `user.*` stays the actor |
|
||||
| `organization.id` | Tenant: the application client id when present, else the user's email domain |
|
||||
| `lasuite.target` | The resource acted on: `type`, `id` and a few stable fields per type |
|
||||
| `lasuite.details` | Action-specific fields (see catalogue) |
|
||||
| `client.ip`, `source.ip` | Real client address, the one DRF's throttles identify (see `NUM_PROXIES`) |
|
||||
| `http.request.method`, `url.path` | Request as received |
|
||||
| `http.response.status_code` | Set on refusals and failures |
|
||||
| `trace.id` | Request id, also echoed as the `X-Request-ID` response header and logged by Gunicorn as `rid=`. Generated by the backend unless `REQUEST_ID_TRUST_HEADER` is set |
|
||||
| `error.message` | Human-readable reason of a failure |
|
||||
| `error.type` | Class of an unhandled exception. Its message is left out, as it may carry personal data |
|
||||
| `log.level` | `info` for success, `warning` for failures and denials, `error` for internal errors |
|
||||
|
||||
An audited API action that raises an exception DRF does not handle is still recorded, as a `failure` with reason
|
||||
`internal_error`, status code `500` and `error.type`, before the exception propagates.
|
||||
|
||||
## Catalogue
|
||||
|
||||
| `event.action` | Emitted when | Notable fields |
|
||||
|---|---|---|
|
||||
| `application.token.issue` | An application requests a delegated token (`POST /external-api/v1.0/application/token/`), whether it obtains one or is refused: bad credentials, inactive application, invalid or unauthorized email domain, unknown user, provisioning conflict | On success: `user.*` = delegated user, `lasuite.target` = application, `lasuite.details.scopes`, `user_provisioned`, `expires_in`. On refusal: `event.reason`, `http.response.status_code`, `lasuite.details.requested_domain`. Until the credentials are verified, the submitted client id is only `lasuite.details.claimed_client_id`: it never sets `lasuite.application` or `organization` |
|
||||
| `user.provision` | An application creates a provisional user by email | `lasuite.target` = user |
|
||||
| `room.create` | A room is created through the external API, or the attempt fails | `lasuite.target` = room |
|
||||
| `room.update` | A room is updated through the external API, or the attempt fails | `lasuite.target` = room, refusals included, `lasuite.details.updated_fields`, `previous_access_level` |
|
||||
| `room.retrieve` | A room is read through the external API, or the attempt fails | `lasuite.target` = room |
|
||||
| `room.list` | Rooms are listed through the external API, or the attempt fails | `lasuite.details.total` |
|
||||
| `user.login` | A user logs in or a login attempt fails | `lasuite.auth.method` = `oidc` or `password`, or `unknown`: named after the backend on success, `lasuite.details.auth_backend`, and after the credentials submitted on failure (a password, or the nonce of the OIDC callback) |
|
||||
| `user.logout` | A user logs out | |
|
||||
| `admin.access` | A signed-in account without staff access reaches an admin page (always denied), once per refused page | `event.reason`, `http.response.status_code`: the redirect to the login page |
|
||||
| `admin.<target>.<verb>` | A write is made through the Django admin, see below | |
|
||||
|
||||
Actions are always dotted, lower-case, with the format `<target>.<verb>`, and name what was attempted: whether it
|
||||
succeeded is told by `event.outcome`, `lasuite.outcome` and `event.reason`, never by the action.
|
||||
|
||||
## Django admin
|
||||
|
||||
The admin is the most sensitive surface of the product, so every write made through it emits an audit event next to
|
||||
the `LogEntry` Django writes itself. Nothing is replaced and there is no extra table: the admin history keeps working.
|
||||
|
||||
The action is templated rather than listed: `admin.<target>.<verb>`, where `<target>` is the model name and `<verb>`
|
||||
one of:
|
||||
|
||||
| `<verb>` | Emitted when | Notable fields |
|
||||
|---|---|---|
|
||||
| `create` | An object is added | `lasuite.details.changed_fields`, `changes` |
|
||||
| `update` | An object is changed | `lasuite.details.changed_fields`, `changes` |
|
||||
| `delete` | An object is deleted, one event per object, once the deletion has run. A deletion that raises is a `failure` with reason `internal_error`; in a bulk deletion every selected object is then reported as failed | `error.message` on failure |
|
||||
| `action` | A bulk action runs | `lasuite.details.admin_action`, `count` |
|
||||
|
||||
So `admin.room.update`, `admin.user.delete`, `admin.recording.action`. `event.category` is `iam` for anything granting
|
||||
access to the product and `configuration` otherwise. Writes on a user or a group lead `event.type` with `user` or
|
||||
`group`, as in `["user", "change"]`.
|
||||
|
||||
`lasuite.details.changed_fields` always carries the **names** of the fields a form changed, exactly the ones Django
|
||||
reports in its own history. `lasuite.details.changes` carries their **values**, as `{"from": ..., "to": ...}`, and only
|
||||
for the fields a model explicitly allows in the `admin_values` it is registered with. Anything that
|
||||
looks like a secret is refused there whatever the allow-list says, so a password change is reported as a change to `password` and never with its value.
|
||||
A `JSONField` on the allow-list, such as a room's `configuration`, is recorded as JSON rather than stringified, and both versions are kept
|
||||
whole.
|
||||
|
||||
Objects edited through an **inline** emit their own event, joined to the parent's by `trace.id`: granting a role on a
|
||||
room produces both `admin.room.update` and `admin.resourceaccess.create`.
|
||||
|
||||
What is deliberately **not** covered:
|
||||
|
||||
- **Reads.** Opening a change list, a change form or the history page emits nothing. Django's own `LogEntry` remains
|
||||
the record of who touched what.
|
||||
- **A custom action bypassing the ORM hooks.** An action calling `queryset.update()` or `queryset.delete()` directly
|
||||
is reported as `admin.<target>.action` with its name and the number of objects, not one event per object.
|
||||
`delete_selected` is the exception: Django reports its objects through `log_deletions`, so it emits one
|
||||
`admin.<target>.delete` each and no `action` event.
|
||||
|
||||
The wiring lives in `core/audit/admin.py`: `AuditedAdminSite` mixes the auditing into every admin class at
|
||||
registration, including those declared by Django itself, and is installed through
|
||||
`core.audit.apps.AuditedAdminConfig` in `INSTALLED_APPS`. A new `ModelAdmin` is therefore covered without doing
|
||||
anything; registering its model (see below) only adds its category and its allowed values.
|
||||
|
||||
## Emitting events
|
||||
|
||||
Actions are declared once, in `core/auditing.py`, as `audit.Action` constants. An action may carry its ECS category
|
||||
and types, which then apply to every event it emits:
|
||||
|
||||
```python
|
||||
APPLICATION_TOKEN_ISSUE = audit.Action(
|
||||
"application.token.issue",
|
||||
category=EventCategory.AUTHENTICATION,
|
||||
types=(EventType.START,),
|
||||
)
|
||||
ROOM_CREATE = audit.Action("room.create")
|
||||
```
|
||||
|
||||
DRF views declare the actions they audit; everything else is derived from the response. CRUD actions are mapped in
|
||||
`audit_actions`, and an extra action names its own on its route, so that renaming its method cannot silently stop
|
||||
auditing it:
|
||||
|
||||
```python
|
||||
from core import audit, auditing
|
||||
|
||||
|
||||
class RoomViewSet(audit.AuditViewMixin, viewsets.GenericViewSet):
|
||||
audit_actions = {"create": auditing.ROOM_CREATE, "retrieve": auditing.ROOM_RETRIEVE}
|
||||
|
||||
def perform_create(self, serializer):
|
||||
self.audit_target = serializer.save()
|
||||
|
||||
@action(detail=True, methods=["post"], audit_action=auditing.ROOM_INVITE)
|
||||
def invite(self, request, pk=None): ...
|
||||
```
|
||||
|
||||
- **Views are audited by `AuditViewMixin`** from DRF's `finalize_response` hook, which runs for every response,
|
||||
successful or not. The ECS category and types come from the action, else `api` and the DRF action (`creation` for
|
||||
`create`...). The outcome, reason and status code come from the response status: 401, 403 and 429 are `denied`, other
|
||||
errors `failure`, and a 401 is always filed under `authentication`. The target is the object `get_object()` returned,
|
||||
unless the view assigns `audit_target`. A view can also assign `audit_actor` and `audit_details`, or override
|
||||
`get_audit_fields()`. `audit_actions` only accepts CRUD actions: any other key raises a `TypeError` when the class is
|
||||
defined.
|
||||
|
||||
- **Anything else calls `audit.log`** with the request at hand. A `category` or `types` given here wins over the
|
||||
action's:
|
||||
|
||||
```python
|
||||
audit.log(auditing.USER_PROVISION, request=request, target=user)
|
||||
```
|
||||
|
||||
- **Request fields are read from `request`**: the real client address and the path. The trace id is the request id,
|
||||
settled by `RequestIdHeaderMiddleware` right after dockerflow assigned it, and echoed in the
|
||||
`DOCKERFLOW_REQUEST_ID_HEADER_NAME` response header (`X-Request-ID` by default). The inbound id is kept only when
|
||||
`REQUEST_ID_TRUST_HEADER` is set; otherwise the backend generates one, so a client never picks it.
|
||||
|
||||
- **Actors are derived** from `request.user`, `request.auth` and the DRF authenticator, whose class is mapped to an
|
||||
auth method by `audit.register_auth_method` (see Configuration). It is possible to override the actor with `actor=`,
|
||||
`actor_type=`, `auth_method=` and `client_id=`.
|
||||
|
||||
- **Targets are described** by their model name, primary key and the `fields` their model is registered with. A model
|
||||
that is not registered is still identified. Extra keyword arguments land under `lasuite.details`.
|
||||
|
||||
- **Emission never raises.** A broken configuration or value is reported on the application logger (and Sentry) and the
|
||||
business operation proceeds. A registered field that cannot be read is left out of the target, and the event is still
|
||||
emitted.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `AUDIT_LOG_LEVEL` | `INFO` | Level of the `audit` logger. |
|
||||
| `AUDIT_LOG_STREAM` | `ext://sys.stdout` | Where the handler writes |
|
||||
| `AUDIT_LOG_SERVICE_NAME` | `meet` | `service.name` |
|
||||
| `NUM_PROXIES` | `1` | DRF's number of trusted proxies appending to `X-Forwarded-For`, shared with the throttles. The client is the entry that many positions from the right; anything a client injects lands further left and is ignored. `1` matches ingress-nginx defaults; use `2` behind a load balancer that also appends |
|
||||
| `REQUEST_ID_TRUST_HEADER` | `False` | Reuse the inbound request id as `trace.id`, so the ingress, Gunicorn, application logs and audit events share one id. Only set it when the ingress overwrites the header (`proxy_set_header X-Request-ID $request_id;` on ingress-nginx, which otherwise forwards the client's one): a client could else pick the id of someone else's request |
|
||||
| `DOCKERFLOW_REQUEST_ID_HEADER_NAME` | `X-Request-ID` | Header carrying that id: read on the request only when `REQUEST_ID_TRUST_HEADER` is set, always echoed on the response |
|
||||
|
||||
The project describes itself to the facility in code, from `core/auditing.py`. The audit app imports the `auditing`
|
||||
module of every installed app once it is ready:
|
||||
|
||||
- `audit.register(Model, fields=..., admin_values=..., category=...)`: the `fields` describing a model as a target,
|
||||
the `admin_values` whose before and after values may be recorded in the admin, and the `category` of its admin
|
||||
writes. A proxy model falls back to its concrete model. Registering a model twice raises `AlreadyRegistered`.
|
||||
- `audit.register_auth_method(klass, name)`: the `lasuite.auth.method` of a DRF authentication class or of a login
|
||||
backend. A DRF class inherits the name of its closest registered base, and DRF's own classes are built in. A login
|
||||
backend must be registered itself, as custom backends often subclass `ModelBackend` for its permission checks
|
||||
alone; `ModelBackend` is built in as `password`.
|
||||
|
||||
The `audit` logger does not propagate and is ignored by Sentry. Application logs keep their text format; only the audit stream is JSON.
|
||||
+41
-34
@@ -93,13 +93,13 @@ sequenceDiagram
|
||||
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
|
||||
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
|
||||
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
|
||||
| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). |
|
||||
| **RECORDING_ENCODING_WIDTH** | Integer | `1280` | Recording video width in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_HEIGHT** | Integer | `720` | Recording video height in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_FRAMERATE** | Integer | `30` | Recording video framerate (fps). Directly impacts egress worker CPU (roughly linear). Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_VIDEO_BITRATE_KBPS** | Integer | `3000` | H.264 MAIN video bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `4.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `4.0` is a standard VOD value. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_CUSTOM_ENCODING_ENABLED** | Boolean | `False` | Whether the start-recording API accepts a per-recording `encoding` object (resolution/profile) that overrides the default. When `False`, the API rejects per-recording `encoding`; when `True`, clients may pick from the available resolutions/profiles. The default encoding below is applied regardless of this flag. See [Tuning recording encoding](#tuning-recording-encoding). |
|
||||
| **RECORDING_ENCODING_AVAILABLE_RESOLUTIONS** | Dict | `{"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}` | Maps a resolution name to its `{"width", "height"}` in pixels. Both the default encoding and the per-recording start-recording API pick from these keys. |
|
||||
| **RECORDING_ENCODING_AVAILABLE_PROFILES** | Dict | `{"full": {"fps": 30, "kbps": {…}}, …}` | Maps a profile name to `{"fps", "kbps": {resolution: video_bitrate_kbps}}`. Every profile must define a bitrate for each available resolution (validated at startup). |
|
||||
| **RECORDING_ENCODING_DEFAULT_RESOLUTION** | String | `"720p"` | Resolution used by the default encoding. When set, must be a key of `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`. Leave unset (together with, or instead of, the default profile) to disable the custom default encoding and fall back to LiveKit's built-in preset (a startup warning is emitted). |
|
||||
| **RECORDING_ENCODING_DEFAULT_PROFILE** | String | `"full"` | Profile used by the default encoding. When set, must be a key of `RECORDING_ENCODING_AVAILABLE_PROFILES`. Leave unset (together with, or instead of, the default resolution) to disable the custom default encoding and fall back to LiveKit's built-in preset (a startup warning is emitted). |
|
||||
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps used in the default encoding. |
|
||||
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `0.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `0` leaves the field unset, letting the encoder pick; `4.0` is a standard VOD value. |
|
||||
|
||||
|
||||
> [!NOTE]
|
||||
@@ -130,52 +130,59 @@ This allows you to verify which recordings are in progress, troubleshoot egress
|
||||
|
||||
## Tuning recording encoding
|
||||
|
||||
By default, LiveKit Egress records with the built-in `H264_720P_30` preset: 1280×720 at 30 fps, 3000 kbps H.264 MAIN video and 128 kbps AAC audio. For a one-hour meeting this produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing.
|
||||
Every video recording is encoded from a default resolved from `RECORDING_ENCODING_DEFAULT_PROFILE` + `RECORDING_ENCODING_DEFAULT_RESOLUTION` and passed to LiveKit as advanced `EncodingOptions`. The shipped defaults (`full` profile) match LiveKit's built-in `H264_720P_30` preset. For a one-hour meeting that produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing; lowering the default profile/resolution shrinks it. If either default is left unset, no custom default encoding is built: a warning is logged at startup and LiveKit's built-in preset is used instead.
|
||||
|
||||
The `RECORDING_ENCODING_*` settings let operators override this preset without modifying the source. Values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what you set is what the encoder receives.
|
||||
Encoding is chosen from two maps: `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS` (`resolution → {"width", "height"}`) and `RECORDING_ENCODING_AVAILABLE_PROFILES` (`profile → {"fps", "kbps": {resolution: video_bitrate_kbps}}`):
|
||||
|
||||
- **Default**: `RECORDING_ENCODING_DEFAULT_PROFILE` + `RECORDING_ENCODING_DEFAULT_RESOLUTION` set the encoding used by every recording that doesn't override it. Leave either unset to fall back to LiveKit's built-in preset (a startup warning is emitted).
|
||||
- **Per recording (opt-in)**: set `RECORDING_CUSTOM_ENCODING_ENABLED=True` to let clients override the default per recording. The start-recording API then accepts an `encoding` object selecting a `resolution` (required) and `profile` (optional): a resolution-only request keeps `RECORDING_ENCODING_DEFAULT_PROFILE` for fps and bitrate, so clients can only pick from pre-defined values. When `RECORDING_CUSTOM_ENCODING_ENABLED=False`, the API rejects any per-recording `encoding` and the default is used.
|
||||
|
||||
The resolved values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what the profile/resolution resolve to is what the encoder receives.
|
||||
|
||||
### How values map to GStreamer
|
||||
|
||||
| Setting | GStreamer element | Property |
|
||||
| ------------------------------------- | ----------------- | ---------------------------------- |
|
||||
| `RECORDING_ENCODING_WIDTH/HEIGHT` | capsfilter | `video/x-raw,width=W,height=H` |
|
||||
| `RECORDING_ENCODING_FRAMERATE` | capsfilter | `framerate=F/1` |
|
||||
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | `x264enc` | `bitrate=kbps` (kilobits) |
|
||||
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
|
||||
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
|
||||
| Resolved value | GStreamer element | Property |
|
||||
| ----------------------------------------- | ----------------- | ---------------------------------- |
|
||||
| resolution `width` / `height` | capsfilter | `video/x-raw,width=W,height=H` |
|
||||
| profile `fps` | capsfilter | `framerate=F/1` |
|
||||
| profile `kbps[resolution]` | `x264enc` | `bitrate=kbps` (kilobits) |
|
||||
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
|
||||
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
|
||||
|
||||
The H.264 profile is fixed to MAIN and the x264 `speed-preset` to `veryfast` by LiveKit (real-time constraint) — lowering the framerate is therefore the main lever to save CPU, while lowering the bitrate is the main lever to shrink the output file.
|
||||
|
||||
### Reference profiles
|
||||
### Built-in profiles
|
||||
|
||||
Rough 30-minute file-size estimates assume video + audio bitrate multiplied by duration. Actual sizes vary with content (static talking heads compress better than heavy screen motion). Egress CPU figures are indicative, measured on a single Ryzen laptop core saturated by the default preset (= 100 %); scaling is roughly linear with `framerate × bitrate` but the absolute numbers depend on the host hardware.
|
||||
The default `RECORDING_ENCODING_AVAILABLE_PROFILES` ship four profiles. Framerate is fixed per profile; video bitrate (kbps) scales with resolution so quality stays consistent across sizes. File size scales roughly with `framerate × bitrate`, and so does egress CPU cost.
|
||||
|
||||
| Profile | Resolution | FPS | Video (kbps) | Audio (kbps) | Keyframe (s) | ~ size / 30 min | Egress CPU (vs. default) | Suitable for |
|
||||
| ---------------------- | ---------- | --- | ------------ | ------------ | ------------ | --------------- | ------------------------ | --------------------------------------------------- |
|
||||
| Default (preset) | 1280×720 | 30 | 3000 | 128 | 4 | **~690 MB** | 100 % | Unchanged LiveKit behaviour |
|
||||
| Balanced | 1280×720 | 20 | 1000 | 96 | 4 | ~240 MB | ~67 % | Mixed content, moderate motion |
|
||||
| **Low CPU / small file** | 1280×720 | 15 | 600 | 64 | 4 | **~150 MB** | ~50 % | Talking-head dominant meetings + occasional slides ★ |
|
||||
| Slide-heavy | 1280×720 | 15 | 900 | 64 | 4 | ~210 MB | ~55 % | Frequent dense screen sharing (decks, IDE, docs) |
|
||||
| Minimum CPU | 960×540 | 15 | 500 | 64 | 4 | ~125 MB | ~30 % | Voice-first meetings, readable text not required |
|
||||
| Audio-heavy fallback | 1280×720 | 10 | 400 | 96 | 4 | ~110 MB | ~35 % | Long webinars, low motion |
|
||||
| Profile | FPS | 540p (kbps) | 720p (kbps) | 1080p (kbps) | Suitable for |
|
||||
| --------------- | --- | ----------- | ----------- | ------------ | -------------------------------------------------- |
|
||||
| `talking_heads` | 15 | 400 | 700 | 1200 | Talking-head dominant meetings + occasional slides |
|
||||
| `text` | 15 | 600 | 1000 | 1800 | Frequent dense screen sharing (decks, IDE, docs) |
|
||||
| `mixed` | 20 | 900 | 1500 | 2500 | Mixed content, moderate motion |
|
||||
| `full` | 30 | 2000 | 3000 | 4500 | Highest fidelity; closest to the LiveKit default preset |
|
||||
|
||||
★ Recommended starting point for typical LaSuite Meet usage.
|
||||
To pick a profile per recording (requires `RECORDING_CUSTOM_ENCODING_ENABLED=True`), the client sends it in the start-recording request:
|
||||
|
||||
Environment variables for the **Low CPU / small file** profile:
|
||||
```json
|
||||
{
|
||||
"mode": "screen_recording",
|
||||
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}}
|
||||
}
|
||||
```
|
||||
|
||||
To change the default encoding applied to every recording:
|
||||
|
||||
```bash
|
||||
RECORDING_ENCODING_ENABLED=True
|
||||
RECORDING_ENCODING_WIDTH=1280
|
||||
RECORDING_ENCODING_HEIGHT=720
|
||||
RECORDING_ENCODING_FRAMERATE=15
|
||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600
|
||||
RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
|
||||
RECORDING_ENCODING_DEFAULT_PROFILE=talking_heads
|
||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
|
||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
||||
```
|
||||
|
||||
### Caveats
|
||||
|
||||
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The recommended preset (600 kbps × 15 fps) sits at exactly that threshold, comfortable for talking heads with occasional slide sharing. The same 600 kbps at 30 fps would only deliver 20 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **Slide-heavy** profile (900 kbps × 15 fps ≈ 60 kbits/frame).
|
||||
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The `talking_heads` profile (700 kbps × 15 fps) sits just above that threshold, comfortable for talking heads with occasional slide sharing. The same bitrate at 30 fps would only deliver ~23 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **`text`** profile (1000 kbps × 15 fps ≈ 67 kbits/frame).
|
||||
- **Motion handling**: the `veryfast` x264 preset is set by LiveKit and cannot be overridden here. Low-bitrate settings will therefore show more artefacts on fast motion than an offline re-encode with a slower preset would. This is the other reason FPS reduction is the safer tuning lever for meeting recordings.
|
||||
- **Audio**: AAC at 64 kbps stereo is transparent for voice but starts to compress music noticeably. Keep 128 kbps if you expect music playback in meetings.
|
||||
- **Codec choice**: H.264 MAIN is hardcoded on purpose. Switching to HEVC or VP9 would increase egress CPU cost 2×–5×, defeating the goal of this tuning.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Room purge
|
||||
|
||||
Rooms pile up over time and most of them are only used once. The `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for a configurable number of days. It is disabled by default.
|
||||
|
||||
## How it works
|
||||
|
||||
Each time LiveKit tells the backend that a room has started (`room_started` webhook), the backend records the date on the room (`last_started_at`).
|
||||
A room is inactive when:
|
||||
|
||||
- it was last started more than `ROOM_INACTIVITY_DELETION_DAYS` days ago, or
|
||||
- it was never started and was created more than `ROOM_INACTIVITY_DELETION_DAYS` days ago.
|
||||
|
||||
Rooms that existed before this feature was deployed are considered started on the day of the release, so none of them can be purged before a full inactivity period has elapsed.
|
||||
|
||||
The command is meant to run once a day. The Helm chart schedules it in `backend.cronjobs` (`purge-inactive-rooms`, 01:00); it does nothing until `ROOM_INACTIVITY_DELETION_DAYS` is set.
|
||||
|
||||
```bash
|
||||
python manage.py purge_inactive_rooms # delete the inactive rooms
|
||||
python manage.py purge_inactive_rooms --dry-run # only list the rooms that would be deleted
|
||||
```
|
||||
|
||||
## Rooms that are kept
|
||||
|
||||
A recording can only be reached through its room. An inactive room is kept as long as it holds a saved recording its users may still access:
|
||||
|
||||
- with `RECORDING_EXPIRATION_DAYS` set, a saved recording created less than that many days ago,
|
||||
- with `RECORDING_EXPIRATION_DAYS` unset, any saved recording.
|
||||
|
||||
## What happens to a purged room
|
||||
|
||||
The room is deleted from the database, along with its accesses, its telephony PIN code, and the recording entries it still holds — the expired ones and those that were never saved, since any other recording would have protected the room — together with their own accesses.
|
||||
|
||||
The recording **files in the bucket are left untouched**: the backend never deletes anything from the storage, it only drops the database entries pointing at it. Removing the files is the job of the bucket lifecycle policy, which should match `RECORDING_EXPIRATION_DAYS` (see the [recording documentation](recording.md)). When the two do not match, the purge leaves objects behind: they become unreachable, since serving a recording requires its database entry, but they keep costing storage.
|
||||
|
||||
⚠️ When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again
|
||||
and can be reused when a meeting is created from that same URL.
|
||||
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights.
|
||||
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=true`, any user can reopen the purged room by navigating to the same URL. In that case, the room is created dynamically and no corresponding room entry is persisted in the database.
|
||||
@@ -42,7 +42,7 @@ sequenceDiagram
|
||||
participant Backend as Backend API
|
||||
participant Summary as Summary Service
|
||||
participant Celery as Celery Workers (transcribe-queue)
|
||||
participant MinIO as MinIO (Object Storage)
|
||||
participant S3 as S3 (Object Storage)
|
||||
participant STT as WhisperX API
|
||||
participant Docs as LaSuite Docs
|
||||
|
||||
@@ -50,7 +50,7 @@ sequenceDiagram
|
||||
Note right of Backend: Payload contains 7 params: owner_id, filename, email, sub, room, recording_date, recording_time
|
||||
|
||||
Summary->>Celery: Register task (transcribe-queue)
|
||||
Celery->>MinIO: Fetch audio file
|
||||
Celery->>S3: Fetch audio file
|
||||
Celery->>STT: Transcribe audio (WhisperX)
|
||||
STT-->>Celery: Segmented transcript
|
||||
|
||||
@@ -72,11 +72,12 @@ sequenceDiagram
|
||||
| celery_result_backend | String | `"redis://redis/0"` | Celery result backend URL. |
|
||||
| celery_max_retries | Integer | `1` | Maximum number of retries for Celery tasks. |
|
||||
| transcribe_queue | String | `"transcribe-queue"` | Name of the Celery queue for transcription tasks. |
|
||||
| aws_storage_bucket_name | String | — | Name of the S3/MinIO bucket used for storing recordings. |
|
||||
| aws_s3_endpoint_url | String | — | Endpoint URL of the S3/MinIO storage. |
|
||||
| aws_s3_access_key_id | String | — | Access key for S3/MinIO. |
|
||||
| aws_s3_secret_access_key | Secret | — | Secret key for S3/MinIO. |
|
||||
| aws_s3_secure_access | Boolean | `True` | Use HTTPS for S3/MinIO requests. |
|
||||
| aws_storage_bucket_name | String | — | Name of the S3 bucket used for storing recordings. |
|
||||
| aws_s3_endpoint_url | String | — | Endpoint URL of the S3 storage. |
|
||||
| aws_s3_access_key_id | String | — | Access key for S3. |
|
||||
| aws_s3_secret_access_key | Secret | — | Secret key for S3. |
|
||||
| aws_s3_secure_access | Boolean | `True` | Use HTTPS for S3 requests. |
|
||||
| aws_s3_region_name | String | — | Region used to sign S3 requests, passed as-is to boto3. |
|
||||
| whisperx_api_key | Secret | — | API key for accessing WhisperX. |
|
||||
| whisperx_base_url | String | `"https://api.whisperx.com/v1"` | Base URL for the WhisperX API. |
|
||||
| whisperx_asr_model | String | `"whisper-1"` | ASR model used for transcription. |
|
||||
|
||||
@@ -14,7 +14,7 @@ All services are required to run the minimalist instance of LaSuite Meet. Click
|
||||
| **OIDC Provider** | User authentication | [Keycloak setup](../examples/compose/keycloak/README.md) |
|
||||
| **SMTP Service** | Email notifications | - |
|
||||
|
||||
> [!NOTE] Some advanced features, as Recording and transcription, require additional services (MinIO, email). See `/features` folder for details.
|
||||
> [!NOTE] Some advanced features, as Recording and transcription, require additional services (S3-compatible object storage, email). See `/features` folder for details.
|
||||
|
||||
|
||||
## Software Requirements
|
||||
|
||||
+120
-116
@@ -14,7 +14,7 @@ This document is a step-by-step guide that describes how to install LaSuite Meet
|
||||
|
||||
If you do not have a kubernetes test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script located in this repo under **bin/start-kind.sh**.
|
||||
|
||||
IMPORTANT: The kind method will only deploy meet as a local instance(127.0.0.1) that can only be accessed from the device where it has been deployed.
|
||||
IMPORTANT: The kind method will only deploy meet as a local instance(127.0.0.1) that can only be accessed from the device where it has been deployed.
|
||||
|
||||
To be able to use the script, you will need to install the following components:
|
||||
|
||||
@@ -311,119 +311,123 @@ frontend:
|
||||
|
||||
These are the environmental options available on meet backend.
|
||||
|
||||
| Option | Description | default |
|
||||
|-------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| DATA_DIR | Data directory location | /data |
|
||||
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
|
||||
| DJANGO_SECRET_KEY | Secret key used for Django security | |
|
||||
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
|
||||
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
|
||||
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
|
||||
| DB_NAME | Name of the database | meet |
|
||||
| DB_USER | User used to connect to database | dinum |
|
||||
| DB_PASSWORD | Password used to connect to the database | pass |
|
||||
| DB_HOST | Hostname of the database | localhost |
|
||||
| DB_PORT | Port to connect to database | 5432 |
|
||||
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
||||
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
|
||||
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
|
||||
| AWS_S3_SECRET_ACCESS_KEY | S3 secret key | |
|
||||
| AWS_S3_REGION_NAME | S3 region | |
|
||||
| AWS_STORAGE_BUCKET_NAME | S3 bucket name | meet-media-storage |
|
||||
| DJANGO_LANGUAGE_CODE | Default language | en-us |
|
||||
| REDIS_URL | Redis endpoint | redis://redis:6379/1 |
|
||||
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
|
||||
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
|
||||
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
|
||||
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
|
||||
| CSRF_TRUSTED_ORIGINS | CSRF trusted origins list | [] |
|
||||
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
|
||||
| FRONTEND_ANALYTICS | Analytics information | {} |
|
||||
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
|
||||
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
|
||||
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
||||
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
||||
| FRONTEND_DOCUMENTATION_URL | URL of the documentation opened from the room options menu. If unset, the documentation menu item is hidden | |
|
||||
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
|
||||
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
|
||||
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||
| DJANGO_EMAIL_HOST | Host of the email server | |
|
||||
| DJANGO_EMAIL_HOST_USER | User to connect to the email server | |
|
||||
| DJANGO_EMAIL_HOST_PASSWORD | Password to connect to the email server | |
|
||||
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
|
||||
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
|
||||
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
|
||||
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
|
||||
| EMAIL_BRAND_NAME | Email branding name | |
|
||||
| EMAIL_SUPPORT_EMAIL | Support email address | |
|
||||
| EMAIL_LOGO_IMG | Email logo image | |
|
||||
| EMAIL_DOMAIN | Email domain | |
|
||||
| EMAIL_APP_BASE_URL | Email app base URL | |
|
||||
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
|
||||
| DJANGO_CORS_ALLOWED_ORIGINS | Origins to allow (string list) | [] |
|
||||
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | Origins to allow (regex patterns) | [] |
|
||||
| SENTRY_DSN | Sentry server DSN | |
|
||||
| DJANGO_CELERY_BROKER_URL | Celery broker host | redis://redis:6379/0 |
|
||||
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} |
|
||||
| OIDC_CREATE_USER | Create OIDC user if not exists | true |
|
||||
| OIDC_VERIFY_SSL | Verify SSL for OIDC | true |
|
||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | false |
|
||||
| OIDC_RP_SIGN_ALGO | Token verification algorithm used by OIDC | RS256 |
|
||||
| OIDC_RP_CLIENT_ID | OIDC client ID | meet |
|
||||
| OIDC_RP_CLIENT_SECRET | OIDC client secret | |
|
||||
| OIDC_OP_JWKS_ENDPOINT | OIDC endpoint for JWKS | |
|
||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | OIDC endpoint for authorization | |
|
||||
| OIDC_OP_TOKEN_ENDPOINT | OIDC endpoint for token | |
|
||||
| OIDC_OP_USER_ENDPOINT | OIDC endpoint for user | |
|
||||
| OIDC_OP_USER_ENDPOINT_FORMAT | OIDC endpoint format (AUTO, JWT, JSON) | AUTO |
|
||||
| OIDC_OP_LOGOUT_ENDPOINT | OIDC endpoint for logout | |
|
||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters for OIDC request | {} |
|
||||
| OIDC_RP_SCOPES | OIDC scopes | openid email |
|
||||
| OIDC_USE_NONCE | Use nonce for OIDC | true |
|
||||
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC | false |
|
||||
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed redirect hosts for OIDC | [] |
|
||||
| OIDC_STORE_ID_TOKEN | Store OIDC ID token | true |
|
||||
| OIDC_REDIRECT_FIELD_NAME | Redirect field for OIDC | returnTo |
|
||||
| OIDC_USERINFO_FULLNAME_FIELDS | Full name claim from OIDC token | ["given_name", "usual_name"] |
|
||||
| OIDC_USERINFO_SHORTNAME_FIELD | Short name claim from OIDC token | given_name |
|
||||
| OIDC_USERINFO_ESSENTIAL_CLAIMS | Required claims from OIDC token | [] |
|
||||
| OIDC_USE_PKCE | Enable the use of PKCE (Proof Key for Code Exchange) during the OAuth 2.0 authorization code flow. Recommended for enhanced security. | False |
|
||||
| OIDC_PKCE_CODE_CHALLENGE_METHOD | Method used to generate the PKCE code challenge. Common values include S256 and plain. Refer to the mozilla-django-oidc documentation for supported options. | S256 |
|
||||
| OIDC_PKCE_CODE_VERIFIER_SIZE | Length of the random string used as the PKCE code verifier. Must be an integer between 43 and 128, inclusive. | 64 |
|
||||
| LOGIN_REDIRECT_URL | Login redirect URL | |
|
||||
| LOGIN_REDIRECT_URL_FAILURE | Login redirect URL for failure | |
|
||||
| LOGOUT_REDIRECT_URL | URL to redirect to on logout | |
|
||||
| ALLOW_LOGOUT_GET_METHOD | Allow logout through GET method | true |
|
||||
| LIVEKIT_API_KEY | LiveKit API key | |
|
||||
| LIVEKIT_API_SECRET | LiveKit API secret | |
|
||||
| LIVEKIT_API_URL | LiveKit API URL | |
|
||||
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
|
||||
| LIVEKIT_FORCE_WSS_PROTOCOL | Enables WSS protocol conversion for legacy browser compatibility (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs fail in WebSocket() constructor. | false |
|
||||
| LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND | Firefox-only connection warmup: pre-calls WebSocket endpoint (expecting 401) to initialize cache, resolving proxy/network connectivity issues. | false |
|
||||
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
|
||||
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
|
||||
| RECORDING_ENABLE | Record meeting option | false |
|
||||
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
|
||||
| Option | Description | default |
|
||||
|-------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| DATA_DIR | Data directory location | /data |
|
||||
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
|
||||
| DJANGO_SECRET_KEY | Secret key used for Django security | |
|
||||
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
|
||||
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
|
||||
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
|
||||
| DB_NAME | Name of the database | meet |
|
||||
| DB_USER | User used to connect to database | dinum |
|
||||
| DB_PASSWORD | Password used to connect to the database | pass |
|
||||
| DB_HOST | Hostname of the database | localhost |
|
||||
| DB_PORT | Port to connect to database | 5432 |
|
||||
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
||||
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
|
||||
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
|
||||
| AWS_S3_SECRET_ACCESS_KEY | S3 secret key | |
|
||||
| AWS_S3_REGION_NAME | S3 region | |
|
||||
| AWS_STORAGE_BUCKET_NAME | S3 bucket name | meet-media-storage |
|
||||
| DJANGO_LANGUAGE_CODE | Default language | en-us |
|
||||
| REDIS_URL | Redis endpoint | redis://redis:6379/1 |
|
||||
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
|
||||
| ROOM_CREATION_THROTTLE_RATES | Room creation throttle rate per authenticated user | 50/minute | 50/minute |
|
||||
| ROOM_CREATION_DAILY_THROTTLE_RATES | Daily room creation cap per authenticated user | 1000/day |
|
||||
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
|
||||
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
|
||||
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
|
||||
| CSRF_TRUSTED_ORIGINS | CSRF trusted origins list | [] |
|
||||
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
|
||||
| FRONTEND_ANALYTICS | Analytics information | {} |
|
||||
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
|
||||
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
|
||||
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
||||
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
||||
| FRONTEND_DOCUMENTATION_URL | URL of the documentation opened from the room options menu. If unset, the documentation menu item is hidden | |
|
||||
| FRONTEND_TECHNICAL_DOCUMENTATION_URL | URL of the technical documentation (network prerequisites) linked from the footer and the connection test. If unset, both links are hidden | |
|
||||
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
|
||||
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
|
||||
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||
| DJANGO_EMAIL_HOST | Host of the email server | |
|
||||
| DJANGO_EMAIL_HOST_USER | User to connect to the email server | |
|
||||
| DJANGO_EMAIL_HOST_PASSWORD | Password to connect to the email server | |
|
||||
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
|
||||
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
|
||||
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
|
||||
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
|
||||
| EMAIL_BRAND_NAME | Email branding name | |
|
||||
| EMAIL_SUPPORT_EMAIL | Support email address | |
|
||||
| EMAIL_LOGO_IMG | Email logo image | |
|
||||
| EMAIL_DOMAIN | Email domain | |
|
||||
| EMAIL_APP_BASE_URL | Email app base URL | |
|
||||
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
|
||||
| DJANGO_CORS_ALLOWED_ORIGINS | Origins to allow (string list) | [] |
|
||||
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | Origins to allow (regex patterns) | [] |
|
||||
| SENTRY_DSN | Sentry server DSN | |
|
||||
| DJANGO_CELERY_BROKER_URL | Celery broker host | redis://redis:6379/0 |
|
||||
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} |
|
||||
| OIDC_CREATE_USER | Create OIDC user if not exists | true |
|
||||
| OIDC_VERIFY_SSL | Verify SSL for OIDC | true |
|
||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | false |
|
||||
| OIDC_RP_SIGN_ALGO | Token verification algorithm used by OIDC | RS256 |
|
||||
| OIDC_RP_CLIENT_ID | OIDC client ID | meet |
|
||||
| OIDC_RP_CLIENT_SECRET | OIDC client secret | |
|
||||
| OIDC_OP_JWKS_ENDPOINT | OIDC endpoint for JWKS | |
|
||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | OIDC endpoint for authorization | |
|
||||
| OIDC_OP_TOKEN_ENDPOINT | OIDC endpoint for token | |
|
||||
| OIDC_OP_USER_ENDPOINT | OIDC endpoint for user | |
|
||||
| OIDC_OP_USER_ENDPOINT_FORMAT | OIDC endpoint format (AUTO, JWT, JSON) | AUTO |
|
||||
| OIDC_OP_LOGOUT_ENDPOINT | OIDC endpoint for logout | |
|
||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters for OIDC request | {} |
|
||||
| OIDC_RP_SCOPES | OIDC scopes | openid email |
|
||||
| OIDC_USE_NONCE | Use nonce for OIDC | true |
|
||||
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC | false |
|
||||
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed redirect hosts for OIDC | [] |
|
||||
| OIDC_STORE_ID_TOKEN | Store OIDC ID token | true |
|
||||
| OIDC_REDIRECT_FIELD_NAME | Redirect field for OIDC | returnTo |
|
||||
| OIDC_USERINFO_FULLNAME_FIELDS | Full name claim from OIDC token | ["given_name", "usual_name"] |
|
||||
| OIDC_USERINFO_SHORTNAME_FIELD | Short name claim from OIDC token | given_name |
|
||||
| OIDC_USERINFO_ESSENTIAL_CLAIMS | Required claims from OIDC token | [] |
|
||||
| OIDC_USE_PKCE | Enable the use of PKCE (Proof Key for Code Exchange) during the OAuth 2.0 authorization code flow. Recommended for enhanced security. | False |
|
||||
| OIDC_PKCE_CODE_CHALLENGE_METHOD | Method used to generate the PKCE code challenge. Common values include S256 and plain. Refer to the mozilla-django-oidc documentation for supported options. | S256 |
|
||||
| OIDC_PKCE_CODE_VERIFIER_SIZE | Length of the random string used as the PKCE code verifier. Must be an integer between 43 and 128, inclusive. | 64 |
|
||||
| LOGIN_REDIRECT_URL | Login redirect URL | |
|
||||
| LOGIN_REDIRECT_URL_FAILURE | Login redirect URL for failure | |
|
||||
| LOGOUT_REDIRECT_URL | URL to redirect to on logout | |
|
||||
| ALLOW_LOGOUT_GET_METHOD | Allow logout through GET method | true |
|
||||
| LIVEKIT_API_KEY | LiveKit API key | |
|
||||
| LIVEKIT_API_SECRET | LiveKit API secret | |
|
||||
| LIVEKIT_API_URL | LiveKit API URL | |
|
||||
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
|
||||
| LIVEKIT_FORCE_WSS_PROTOCOL | Enables WSS protocol conversion for legacy browser compatibility (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs fail in WebSocket() constructor. | false |
|
||||
| LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND | Firefox-only connection warmup: pre-calls WebSocket endpoint (expecting 401) to initialize cache, resolving proxy/network connectivity issues. | false |
|
||||
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
|
||||
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
|
||||
| ROOM_INACTIVITY_DELETION_DAYS | Days without being started after which a room is purged. Unset to never purge | |
|
||||
| RECORDING_ENABLE | Record meeting option | false |
|
||||
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
|
||||
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
||||
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
|
||||
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
|
||||
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
|
||||
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
|
||||
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
|
||||
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
|
||||
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
|
||||
| BREVO_API_KEY | Brevo API key for marketing emails | |
|
||||
| BREVO_API_CONTACT_LIST_IDS | Brevo API contact list IDs | [] |
|
||||
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | Brevo contact attributes | {"VISIO_USER": true} |
|
||||
| BREVO_API_TIMEOUT | Brevo timeout in seconds | 1 |
|
||||
| LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby |
|
||||
| LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 |
|
||||
| LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 |
|
||||
| LOBBY_ACCEPTED_TIMEOUT | Lobby accept timeout in seconds | 21600 (6 hours) |
|
||||
| LOBBY_NOTIFICATION_TYPE | Lobby notification types | participantWaiting |
|
||||
| LOBBY_COOKIE_NAME | Lobby cookie name | lobbyParticipantId |
|
||||
| ROOM_CREATION_CALLBACK_CACHE_TIMEOUT | Room creation callback cache timeout | 600 (10 minutes) |
|
||||
| ROOM_TELEPHONY_ENABLED | Enable SIP telephony feature | false |
|
||||
| ROOM_TELEPHONY_PIN_LENGTH | Telephony PIN length | 10 |
|
||||
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Telephony PIN maximum retries | 5 |
|
||||
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
|
||||
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
|
||||
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
|
||||
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
|
||||
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
|
||||
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
|
||||
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
|
||||
| BREVO_API_KEY | Brevo API key for marketing emails | |
|
||||
| BREVO_API_CONTACT_LIST_IDS | Brevo API contact list IDs | [] |
|
||||
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | Brevo contact attributes | {"VISIO_USER": true} |
|
||||
| BREVO_API_TIMEOUT | Brevo timeout in seconds | 1 |
|
||||
| LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby |
|
||||
| LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 |
|
||||
| LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 |
|
||||
| LOBBY_ACCEPTED_TIMEOUT | Lobby accept timeout in seconds | 21600 (6 hours) |
|
||||
| LOBBY_NOTIFICATION_TYPE | Lobby notification types | participantWaiting |
|
||||
| LOBBY_COOKIE_NAME | Lobby cookie name | lobbyParticipantId |
|
||||
| ROOM_CREATION_CALLBACK_CACHE_TIMEOUT | Room creation callback cache timeout | 600 (10 minutes) |
|
||||
| ROOM_TELEPHONY_ENABLED | Enable SIP telephony feature | false |
|
||||
| ROOM_TELEPHONY_PIN_LENGTH | Telephony PIN length | 10 |
|
||||
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Telephony PIN maximum retries | 5 |
|
||||
|
||||
@@ -24,9 +24,10 @@ MEET_BASE_URL="http://localhost:8072"
|
||||
# Media
|
||||
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_DOMAIN_REPLACE=http://localhost:9000
|
||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
AWS_S3_ENDPOINT_URL=http://garage:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet-access-key
|
||||
AWS_S3_SECRET_ACCESS_KEY=meet-secret-access-key
|
||||
AWS_S3_REGION_NAME=local
|
||||
MEDIA_BASE_URL=http://localhost:3000
|
||||
FILE_UPLOAD_ENABLED=True
|
||||
|
||||
@@ -63,23 +64,39 @@ ALLOW_UNREGISTERED_ROOMS=False
|
||||
|
||||
# Recording
|
||||
RECORDING_ENABLE=True
|
||||
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
|
||||
SUMMARY_SERVICE_VERSION=2
|
||||
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe
|
||||
SUMMARY_SERVICE_API_TOKEN=password
|
||||
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
|
||||
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||
|
||||
|
||||
# Recording encoding (LiveKit Egress advanced options).
|
||||
# When RECORDING_ENCODING_ENABLED is False (default), LiveKit uses its built-in
|
||||
# H264_720P_30 preset (1280x720, 30fps, 3000 kbps). Enable and tune to reduce
|
||||
# file size and CPU load on the egress worker.
|
||||
# RECORDING_ENCODING_ENABLED=False
|
||||
# RECORDING_ENCODING_WIDTH=1280
|
||||
# RECORDING_ENCODING_HEIGHT=720
|
||||
# RECORDING_ENCODING_FRAMERATE=30
|
||||
# RECORDING_ENCODING_VIDEO_BITRATE_KBPS=3000
|
||||
# Every video recording is encoded with parameters (height, width, fps, kbps) derived
|
||||
# from the pair (profile, resolution) and passed to LiveKit as advanced EncodingOptions.
|
||||
# Choose the available resolutions and profiles that default settings and users can
|
||||
# pick from. They must be defined as a single-line dict literal (parsed with
|
||||
# ast.literal_eval: double-quoted keys, no trailing comma, no outer quotes).
|
||||
# Every profile must define a kbps entry for exactly the keys of
|
||||
# RECORDING_ENCODING_AVAILABLE_RESOLUTIONS (validated at startup).
|
||||
# RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}
|
||||
# RECORDING_ENCODING_AVAILABLE_PROFILES={"talking_heads": {"fps": 15, "kbps": {"540p": 400, "720p": 700, "1080p": 1200}}, "text": {"fps": 15, "kbps": {"540p": 600, "720p": 1000, "1080p": 1800}}, "mixed": {"fps": 20, "kbps": {"540p": 900, "720p": 1500, "1080p": 2500}}, "full": {"fps": 30, "kbps": {"540p": 2000, "720p": 3000, "1080p": 4500}}}
|
||||
|
||||
|
||||
# Choose the default named resolution and profile to use by default. These values must
|
||||
# be keys of RECORDING_ENCODING_AVAILABLE_RESOLUTIONS and RECORDING_ENCODING_AVAILABLE_PROFILES.
|
||||
# RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
|
||||
# RECORDING_ENCODING_DEFAULT_PROFILE=full
|
||||
|
||||
# Default encoding values independent of resolution/profile
|
||||
# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128
|
||||
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
||||
|
||||
# Set to True to let the start-recording API override that default per recording
|
||||
# with an `encoding` object, e.g. {"resolution": "720p", "profile": "talking_heads"}.
|
||||
# RECORDING_CUSTOM_ENCODING_ENABLED=False
|
||||
|
||||
|
||||
# Telephony
|
||||
ROOM_TELEPHONY_ENABLED=True
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Filled with a random value by `make create-env-files`
|
||||
GARAGE_RPC_SECRET=
|
||||
@@ -2,8 +2,9 @@ LIVEKIT_URL=ws://livekit:7880
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_SECRET=secret
|
||||
|
||||
AWS_S3_ENDPOINT_URL=minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
AWS_S3_ENDPOINT_URL=garage:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet-access-key
|
||||
AWS_S3_SECRET_ACCESS_KEY=meet-secret-access-key
|
||||
AWS_S3_REGION_NAME=local
|
||||
AWS_STORAGE_BUCKET_NAME=meet-media-storage
|
||||
AWS_S3_SECURE_ACCESS=False
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
AWS_S3_ENDPOINT_URL=minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
AWS_S3_ENDPOINT_URL=garage:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet-access-key
|
||||
AWS_S3_SECRET_ACCESS_KEY=meet-secret-access-key
|
||||
AWS_S3_REGION_NAME=local
|
||||
|
||||
LIVEKIT_URL=ws://livekit:7880
|
||||
LIVEKIT_API_KEY=devkey
|
||||
|
||||
@@ -2,11 +2,12 @@ APP_NAME="meet-app-summary-dev"
|
||||
APP_API_TOKEN="password"
|
||||
|
||||
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
|
||||
AWS_S3_ENDPOINT_URL="minio:9000"
|
||||
AWS_S3_ENDPOINT_URL="garage:9000"
|
||||
AWS_S3_SECURE_ACCESS=false
|
||||
|
||||
AWS_S3_ACCESS_KEY_ID="meet"
|
||||
AWS_S3_SECRET_ACCESS_KEY="password"
|
||||
AWS_S3_ACCESS_KEY_ID="meet-access-key"
|
||||
AWS_S3_SECRET_ACCESS_KEY="meet-secret-access-key"
|
||||
AWS_S3_REGION_NAME="local"
|
||||
|
||||
WHISPERX_BASE_URL="https://configure-your-url.com"
|
||||
WHISPERX_ASR_MODEL="large-v2"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": ["github>numerique-gouv/renovate-configuration"],
|
||||
"extends": ["github>suitenumerique/ci//renovate/default"],
|
||||
"dependencyDashboard": true,
|
||||
"labels": ["dependencies", "noChangeLog"],
|
||||
"packageRules": [
|
||||
|
||||
Generated
+4
-4
@@ -10,7 +10,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "3.50.0",
|
||||
"i18next": "26.3.6",
|
||||
"i18next": "26.4.2",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
@@ -9367,9 +9367,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||
"version": "26.4.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
||||
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "3.50.0",
|
||||
"i18next": "26.3.6",
|
||||
"i18next": "26.4.2",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@ const { initI18n, translateUI } = require("../common/i18n");
|
||||
document.querySelector("#close-msg").style.display = "block";
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(`Error occured: ${e}`);
|
||||
console.error(`Error occurred: ${e}`);
|
||||
})
|
||||
.finally(() => {
|
||||
// NOTE: doesn't work with the desktop client — the browser considers
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
FROM python:3.14.6-slim AS base
|
||||
FROM python:3.14.7-slim AS base
|
||||
|
||||
# Install system dependencies required by LiveKit
|
||||
RUN apt-get update && apt-get install -y \
|
||||
# Install system dependencies required by LiveKit, fetching packages over HTTPS only for Bureautix proxy
|
||||
RUN sed -i "s|^URIs: http://|URIs: https://|" /etc/apt/sources.list.d/debian.sources \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
libglib2.0-0 \
|
||||
libgobject-2.0-0 \
|
||||
libpcre2-8-0 \
|
||||
libssl3t64 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import logging
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from io import BytesIO
|
||||
from typing import List, Optional
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
from botocore.exceptions import BotoCoreError, ClientError
|
||||
from dotenv import load_dotenv
|
||||
from livekit import api, rtc
|
||||
from livekit.agents import (
|
||||
@@ -28,8 +30,6 @@ from livekit.agents import (
|
||||
room_io as lk_room_io,
|
||||
)
|
||||
from livekit.plugins import silero
|
||||
from minio import Minio
|
||||
from minio.error import S3Error
|
||||
|
||||
from exceptions import MissingConfigError
|
||||
from observability import configure_sentry, set_job_context
|
||||
@@ -59,6 +59,30 @@ server = AgentServer(
|
||||
server.setup_fnc = prewarm
|
||||
|
||||
|
||||
def create_s3_client():
|
||||
"""Create an S3 client for the configured endpoint and region.
|
||||
|
||||
The endpoint may be given with or without a scheme: the scheme always
|
||||
follows AWS_S3_SECURE_ACCESS.
|
||||
"""
|
||||
endpoint = (
|
||||
os.getenv("AWS_S3_ENDPOINT_URL", "")
|
||||
.removeprefix("https://")
|
||||
.removeprefix("http://")
|
||||
.rstrip("/")
|
||||
)
|
||||
secure = os.getenv("AWS_S3_SECURE_ACCESS", "False").lower() == "true"
|
||||
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=f"{'https' if secure else 'http'}://{endpoint}",
|
||||
aws_access_key_id=os.getenv("AWS_S3_ACCESS_KEY_ID"),
|
||||
aws_secret_access_key=os.getenv("AWS_S3_SECRET_ACCESS_KEY"),
|
||||
region_name=os.getenv("AWS_S3_REGION_NAME"),
|
||||
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MetadataEvent:
|
||||
"""A single timestamped event recorded during a meeting."""
|
||||
@@ -121,18 +145,13 @@ class MetadataCollector:
|
||||
|
||||
def __init__(self, ctx: JobContext, recording_id: str):
|
||||
"""Initialize metadata agent."""
|
||||
self.minio_client = Minio(
|
||||
endpoint=os.getenv("AWS_S3_ENDPOINT_URL"),
|
||||
access_key=os.getenv("AWS_S3_ACCESS_KEY_ID"),
|
||||
secret_key=os.getenv("AWS_S3_SECRET_ACCESS_KEY"),
|
||||
secure=os.getenv("AWS_S3_SECURE_ACCESS", "False").lower() == "true",
|
||||
)
|
||||
|
||||
if (bucket_name := os.getenv("AWS_STORAGE_BUCKET_NAME")) is not None:
|
||||
self.bucket_name = bucket_name
|
||||
else:
|
||||
raise MissingConfigError
|
||||
|
||||
self.s3_client = create_s3_client()
|
||||
|
||||
self.ctx = ctx
|
||||
self._sessions: dict[str, AgentSession] = {}
|
||||
self._tasks: set[asyncio.Task] = set()
|
||||
@@ -201,20 +220,18 @@ class MetadataCollector:
|
||||
}
|
||||
|
||||
data = json.dumps(payload, indent=2).encode("utf-8")
|
||||
stream = BytesIO(data)
|
||||
|
||||
try:
|
||||
self.minio_client.put_object(
|
||||
self.bucket_name,
|
||||
self.output_filename,
|
||||
stream,
|
||||
length=len(data),
|
||||
content_type="application/json",
|
||||
self.s3_client.put_object(
|
||||
Bucket=self.bucket_name,
|
||||
Key=self.output_filename,
|
||||
Body=data,
|
||||
ContentType="application/json",
|
||||
)
|
||||
logger.info(
|
||||
"Uploaded speaker meeting metadata",
|
||||
)
|
||||
except S3Error:
|
||||
except (BotoCoreError, ClientError):
|
||||
logger.exception(
|
||||
"Failed to upload meeting metadata",
|
||||
)
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.31.0"
|
||||
version = "1.33.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.6.7",
|
||||
"livekit-plugins-deepgram==1.6.7",
|
||||
"livekit-plugins-silero==1.6.7",
|
||||
"livekit-agents==1.7.0",
|
||||
"livekit-plugins-deepgram==1.7.0",
|
||||
"livekit-plugins-silero==1.7.0",
|
||||
"livekit-plugins-kyutai-lasuite==0.0.6",
|
||||
"python-dotenv==1.2.2",
|
||||
"protobuf==6.33.6",
|
||||
"minio==7.2.20",
|
||||
"sentry-sdk==2.66.1",
|
||||
"boto3==1.43.56",
|
||||
"python-dotenv==1.2.3",
|
||||
"protobuf==7.36.0",
|
||||
"sentry-sdk==2.68.1",
|
||||
"websockets==17.1",
|
||||
"httpx==0.28.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff==0.16.0",
|
||||
"ruff==0.16.4",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
Generated
+859
-854
File diff suppressed because it is too large
Load Diff
@@ -279,9 +279,16 @@ class RoomAdmin(admin.ModelAdmin):
|
||||
|
||||
inlines = (ResourceAccessInline,)
|
||||
search_fields = ["name", "slug", "=id"]
|
||||
list_display = ["name", "slug", "access_level", "get_owner", "created_at"]
|
||||
list_filter = ["access_level", "created_at"]
|
||||
readonly_fields = ["id", "created_at", "updated_at"]
|
||||
list_display = [
|
||||
"name",
|
||||
"slug",
|
||||
"access_level",
|
||||
"get_owner",
|
||||
"created_at",
|
||||
"last_started_at",
|
||||
]
|
||||
list_filter = ["access_level", "created_at", "last_started_at"]
|
||||
readonly_fields = ["id", "created_at", "updated_at", "last_started_at"]
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
|
||||
|
||||
@@ -71,7 +71,9 @@ def get_frontend_configuration(request):
|
||||
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
|
||||
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||
"default_video_codec": settings.LIVEKIT_DEFAULT_VIDEO_CODEC,
|
||||
},
|
||||
"allow_unregistered_rooms": settings.ALLOW_UNREGISTERED_ROOMS,
|
||||
"authenticated_users_can_edit_display_name": (
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||
),
|
||||
|
||||
@@ -12,10 +12,6 @@ from ..services.participants_management import (
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
|
||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
|
||||
}
|
||||
|
||||
|
||||
class IsAuthenticated(permissions.BasePermission):
|
||||
"""
|
||||
@@ -27,15 +23,6 @@ class IsAuthenticated(permissions.BasePermission):
|
||||
return bool(request.auth) or request.user.is_authenticated
|
||||
|
||||
|
||||
class IsAuthenticatedOrSafe(IsAuthenticated):
|
||||
"""Allows access to authenticated users (or anonymous users but only on safe methods)."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
return super().has_permission(request, view)
|
||||
|
||||
|
||||
class IsSelf(IsAuthenticated):
|
||||
"""
|
||||
Allows access only to authenticated users. Alternative method checking the presence
|
||||
|
||||
@@ -13,7 +13,12 @@ from django.core.exceptions import SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django_pydantic_field.rest_framework import SchemaField
|
||||
from pydantic import BaseModel, Field, field_serializer
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Field,
|
||||
field_serializer,
|
||||
field_validator,
|
||||
)
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
@@ -244,6 +249,49 @@ class BaseValidationOnlySerializer(serializers.Serializer):
|
||||
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
||||
|
||||
|
||||
class EncodingConfig(BaseModel):
|
||||
"""Configuration options for recording encoding.
|
||||
|
||||
The allowed `resolution` and `profile` values are derived at validation time
|
||||
from ``settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`` and
|
||||
``settings.RECORDING_ENCODING_AVAILABLE_PROFILES``, so adding a resolution or profile
|
||||
to those maps is enough to make it accepted here.
|
||||
|
||||
Attributes:
|
||||
resolution: Target video resolution.
|
||||
profile: Encoding profile to fps and kbps. When `None`,
|
||||
`settings.RECORDING_ENCODING_DEFAULT_PROFILE` applies.
|
||||
"""
|
||||
|
||||
resolution: str
|
||||
profile: str | None = None
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
@field_validator("resolution")
|
||||
@classmethod
|
||||
def _validate_resolution(cls, value):
|
||||
"""Reject resolutions absent from RECORDING_ENCODING_AVAILABLE_RESOLUTIONS."""
|
||||
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
|
||||
if value not in allowed:
|
||||
raise ValueError(
|
||||
f"Invalid resolution '{value}'. Choose from {sorted(allowed)}."
|
||||
)
|
||||
return value
|
||||
|
||||
@field_validator("profile")
|
||||
@classmethod
|
||||
def _validate_profile(cls, value):
|
||||
"""Reject profiles absent from RECORDING_ENCODING_AVAILABLE_PROFILES."""
|
||||
if value is None:
|
||||
return None
|
||||
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_PROFILES)
|
||||
if value not in allowed:
|
||||
raise ValueError(
|
||||
f"Invalid profile '{value}'. Choose from {sorted(allowed)}."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class RecordingOptions(BaseModel):
|
||||
"""Configuration options for recording.
|
||||
|
||||
@@ -264,7 +312,7 @@ class RecordingOptions(BaseModel):
|
||||
transcribe: bool | None = None
|
||||
collect_metadata: bool | None = None
|
||||
original_mode: Literal["screen_recording", "transcript"] | None = None
|
||||
|
||||
encoding: EncodingConfig | None = None
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
@@ -287,6 +335,22 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
|
||||
help_text="Recording options",
|
||||
)
|
||||
|
||||
def validate_options(self, value: RecordingOptions):
|
||||
"""Validate that custom encoding is enabled if encoding options are passed."""
|
||||
if (
|
||||
value is not None
|
||||
and value.encoding is not None
|
||||
and not settings.RECORDING_CUSTOM_ENCODING_ENABLED
|
||||
):
|
||||
# Per-recording encoding selection is gated by
|
||||
# RECORDING_CUSTOM_ENCODING_ENABLED. When disabled, recordings use
|
||||
# encoding defined by RECORDING_ENCODING_DEFAULT_RESOLUTION
|
||||
# and RECORDING_ENCODING_DEFAULT_PROFILE.
|
||||
raise serializers.ValidationError(
|
||||
"Per-recording encoding selection is disabled."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class RequestEntrySerializer(BaseValidationOnlySerializer):
|
||||
"""Validate request entry data."""
|
||||
|
||||
@@ -20,6 +20,33 @@ class MonitoredUserRateThrottle(MonitoredThrottleMixin, UserRateThrottle):
|
||||
"""Throttle for the monitored scoped rate throttle."""
|
||||
|
||||
|
||||
class RoomCreationUserRateThrottle(MonitoredUserRateThrottle):
|
||||
"""Throttle room creation per authenticated user.
|
||||
|
||||
Can be declared at the viewset level: every action other than "create"
|
||||
is left unthrottled, so the same class can be reused on any viewset
|
||||
exposing a room creation endpoint.
|
||||
"""
|
||||
|
||||
scope = "room_creation"
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
"""Throttle only room creations."""
|
||||
if getattr(view, "action", None) != "create":
|
||||
return None
|
||||
return super().get_cache_key(request, view)
|
||||
|
||||
|
||||
class RoomCreationDailyUserRateThrottle(RoomCreationUserRateThrottle):
|
||||
"""Cap room creation per authenticated user over a day.
|
||||
|
||||
Complements the short-term RoomCreationUserRateThrottle, which absorbs
|
||||
bursts but lets a user steadily create rooms over hours or days.
|
||||
"""
|
||||
|
||||
scope = "room_creation_daily"
|
||||
|
||||
|
||||
class RequestEntryAuthenticatedUserRateThrottle(MonitoredUserRateThrottle):
|
||||
"""Throttle authenticated user requesting room entry"""
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ from core.recording.worker.exceptions import (
|
||||
RecordingStopError,
|
||||
)
|
||||
from core.recording.worker.factories import (
|
||||
build_encoding_options,
|
||||
get_worker_service,
|
||||
)
|
||||
from core.recording.worker.mediator import (
|
||||
@@ -95,60 +96,6 @@ from .feature_flag import FeatureFlag
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
A generic Viewset aims to be used in a nested route context.
|
||||
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
|
||||
|
||||
It allows to define all url kwargs and lookup fields to perform the lookup.
|
||||
"""
|
||||
|
||||
lookup_fields: list[str] = ["pk"]
|
||||
lookup_url_kwargs: list[str] = []
|
||||
|
||||
def __getattribute__(self, file):
|
||||
"""
|
||||
This method is overridden to allow to get the last lookup field or lookup url kwarg
|
||||
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
|
||||
to keep compatibility with all methods used by the parent class `GenericViewSet`.
|
||||
"""
|
||||
if file in ["lookup_field", "lookup_url_kwarg"]:
|
||||
return getattr(self, file + "s", [None])[-1]
|
||||
|
||||
return super().__getattribute__(file)
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Get the list of files for this view.
|
||||
|
||||
`lookup_fields` attribute is enumerated here to perform the nested lookup.
|
||||
"""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# The last lookup field is removed to perform the nested lookup as it corresponds
|
||||
# to the object pk, it is used within get_object method.
|
||||
lookup_url_kwargs = (
|
||||
self.lookup_url_kwargs[:-1]
|
||||
if self.lookup_url_kwargs
|
||||
else self.lookup_fields[:-1]
|
||||
)
|
||||
|
||||
filter_kwargs = {}
|
||||
for index, lookup_url_kwarg in enumerate(lookup_url_kwargs):
|
||||
if lookup_url_kwarg not in self.kwargs:
|
||||
raise KeyError(
|
||||
f"Expected view {self.__class__.__name__} to be called with a URL "
|
||||
f'keyword argument named "{lookup_url_kwarg}". Fix your URL conf, or '
|
||||
"set the `.lookup_fields` attribute on the view correctly."
|
||||
)
|
||||
|
||||
filter_kwargs.update(
|
||||
{self.lookup_fields[index]: self.kwargs[lookup_url_kwarg]}
|
||||
)
|
||||
|
||||
return queryset.filter(**filter_kwargs)
|
||||
|
||||
|
||||
class SerializerPerActionMixin:
|
||||
"""
|
||||
A mixin to allow to define serializer classes for each action.
|
||||
@@ -234,6 +181,10 @@ class RoomViewSet(
|
||||
permission_classes = [permissions.RoomPermissions]
|
||||
queryset = models.Room.objects.all()
|
||||
serializer_class = serializers.RoomSerializer
|
||||
throttle_classes = [
|
||||
throttling.RoomCreationUserRateThrottle,
|
||||
throttling.RoomCreationDailyUserRateThrottle,
|
||||
]
|
||||
|
||||
def get_object(self):
|
||||
"""Allow getting a room by its slug."""
|
||||
@@ -377,12 +328,20 @@ class RoomViewSet(
|
||||
options = serializer.validated_data.get("options")
|
||||
room = self.get_object()
|
||||
|
||||
options_data = options.model_dump(exclude_none=True) if options else {}
|
||||
if options is not None and options.encoding is not None:
|
||||
# Persist the resolved encoding (concrete width/height/framerate/
|
||||
# bitrate) alongside the requested resolution/profile for traceability.
|
||||
options_data["encoding"]["resolved"] = build_encoding_options(
|
||||
options.encoding.resolution, options.encoding.profile
|
||||
)
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
recording = models.Recording.objects.create(
|
||||
room=room,
|
||||
mode=mode,
|
||||
options=options.model_dump(exclude_none=True) if options else {},
|
||||
options=options_data,
|
||||
)
|
||||
models.RecordingAccess.objects.create(
|
||||
user=self.request.user,
|
||||
@@ -909,6 +868,15 @@ class RoomViewSet(
|
||||
"""Rename the current participant in the room."""
|
||||
room = self.get_object()
|
||||
|
||||
if (
|
||||
not settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||
and request.user.is_authenticated
|
||||
):
|
||||
return drf_response.Response(
|
||||
{"error": "Authenticated participants cannot edit their display name"},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
serializer = serializers.RenameParticipantSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Structured audit logging."""
|
||||
|
||||
from .actions import Action
|
||||
from .actor import email_domain
|
||||
from .drf import AuditViewMixin
|
||||
from .emitter import AUDIT_LOGGER_NAME, log
|
||||
from .enums import ActorType, EventCategory, EventType, Outcome, Reason
|
||||
from .formatter import AuditJsonFormatter
|
||||
from .registry import AlreadyRegistered, register, register_auth_method
|
||||
from .signals import LOGIN_ACTION, LOGOUT_ACTION, connect_auth_signals
|
||||
|
||||
__all__ = [
|
||||
"AUDIT_LOGGER_NAME",
|
||||
"LOGIN_ACTION",
|
||||
"LOGOUT_ACTION",
|
||||
"Action",
|
||||
"ActorType",
|
||||
"AlreadyRegistered",
|
||||
"AuditJsonFormatter",
|
||||
"AuditViewMixin",
|
||||
"EventCategory",
|
||||
"EventType",
|
||||
"Outcome",
|
||||
"Reason",
|
||||
"connect_auth_signals",
|
||||
"email_domain",
|
||||
"log",
|
||||
"register",
|
||||
"register_auth_method",
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Specs of the actions audit events are emitted for."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .enums import EventCategory, EventType
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Action:
|
||||
"""An audited action: its dotted name and its ECS classification.
|
||||
|
||||
``category`` and ``types`` are the defaults of every event of the action:
|
||||
a ``category`` or ``types`` given to ``log`` wins over them.
|
||||
"""
|
||||
|
||||
name: str
|
||||
category: EventCategory | None = None
|
||||
types: tuple[EventType, ...] = ()
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate the classification, so a bad one fails at import."""
|
||||
if self.category is not None:
|
||||
object.__setattr__(self, "category", EventCategory(self.category))
|
||||
object.__setattr__(self, "types", tuple(EventType(t) for t in self.types))
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Resolve who is acting: actor type, identifiers, auth method and tenant.
|
||||
|
||||
Personal data is kept to a minimum on purpose: a person is identified by its
|
||||
primary key, its OIDC ``sub`` when it has one and the domain of its email
|
||||
address. The address itself is never recorded.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from .enums import ActorType
|
||||
from .registry import auth_methods, dotted_path
|
||||
|
||||
AUTH_METHOD_NONE = "none"
|
||||
AUTH_METHOD_SESSION = "session"
|
||||
AUTH_METHOD_UNKNOWN = "unknown"
|
||||
|
||||
DEFAULT_AUTH_METHODS = {
|
||||
"rest_framework.authentication.SessionAuthentication": "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."""
|
||||
if not email or "@" not in str(email):
|
||||
return None
|
||||
return str(email).rpartition("@")[2].strip().lower() or 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_service(user) -> bool:
|
||||
"""Machine users have no primary key but still count as authenticated."""
|
||||
return (
|
||||
_is_authenticated(user)
|
||||
and getattr(user, "pk", None) is None
|
||||
and callable(getattr(user, "get_username", None))
|
||||
)
|
||||
|
||||
|
||||
def _default_actor_type(user, client_id) -> ActorType:
|
||||
if client_id:
|
||||
return ActorType.APPLICATION
|
||||
if _is_service(user):
|
||||
return ActorType.SERVICE
|
||||
if _is_authenticated(user):
|
||||
if getattr(user, "is_device", False):
|
||||
return ActorType.DEVICE
|
||||
return ActorType.USER
|
||||
return ActorType.ANONYMOUS
|
||||
|
||||
|
||||
def describe_user(user) -> dict[str, Any]:
|
||||
"""Return the fields identifying a person: id, OIDC sub and email domain.
|
||||
|
||||
The sub is missing for accounts that never signed in, such as provisional
|
||||
users.
|
||||
"""
|
||||
return {
|
||||
"id": str(user.pk),
|
||||
"sub": getattr(user, "sub", None) or None,
|
||||
"domain": email_domain(getattr(user, "email", None)),
|
||||
}
|
||||
|
||||
|
||||
def describe_actor(
|
||||
request,
|
||||
*,
|
||||
actor=None,
|
||||
actor_type: ActorType | str | None = None,
|
||||
client_id: str | None = None,
|
||||
auth_method: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the ECS ``user`` and ``organization`` fields and the ``lasuite`` ones.
|
||||
|
||||
Everything is read from ``request`` unless overridden. ``user`` is None
|
||||
when the actor is not a person.
|
||||
"""
|
||||
user = actor if actor is not None else getattr(request, "user", None)
|
||||
client_id = client_id or client_id_from_auth(getattr(request, "auth", None))
|
||||
|
||||
lasuite: dict[str, Any] = {
|
||||
"actor": {
|
||||
"type": str(ActorType(actor_type or _default_actor_type(user, client_id))),
|
||||
"name": user.get_username() if _is_service(user) else None,
|
||||
},
|
||||
"auth": {"method": auth_method or request_auth_method(request)},
|
||||
"application": {"client_id": client_id},
|
||||
}
|
||||
is_person = _is_authenticated(user) and not _is_service(user)
|
||||
tenant = client_id or (
|
||||
email_domain(getattr(user, "email", None)) if is_person else None
|
||||
)
|
||||
return {
|
||||
"user": describe_user(user) if is_person else None,
|
||||
"organization": {"id": tenant},
|
||||
"lasuite": lasuite,
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Audit the writes performed through the Django admin.
|
||||
|
||||
Every ``ModelAdmin`` registered on :class:`AuditedAdminSite` emits an audit
|
||||
event when an object is created, changed or deleted, and when a bulk action
|
||||
runs. Django's own ``LogEntry`` keeps being written exactly as before: this
|
||||
stream is additive.
|
||||
|
||||
Actions are named ``admin.<target>.<verb>`` where ``<target>`` is the model
|
||||
name, so ``admin.room.update`` or ``admin.user.delete``.
|
||||
Unlike the rest of the catalogue this family is templated rather than
|
||||
enumerated: it follows whatever models are registered.
|
||||
|
||||
Only writes are audited. Browsing a change list or a change form emits
|
||||
nothing.
|
||||
|
||||
Which field values may be recorded, and the event category, are registered
|
||||
per model; see ``core.audit.registry``.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from enum import StrEnum
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.admin import ModelAdmin
|
||||
from django.contrib.admin.sites import AdminSite
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
|
||||
from .actions import Action
|
||||
from .emitter import log
|
||||
from .enums import EventCategory, EventType, Outcome, Reason
|
||||
from .registry import model_options
|
||||
from .utils import render_value
|
||||
|
||||
ADMIN_ACCESS_ACTION = Action("admin.access", category=EventCategory.IAM)
|
||||
DIFF_ATTRIBUTE = "audit_admin_diff"
|
||||
PENDING_DELETIONS_ATTRIBUTE = "audit_admin_pending_deletions"
|
||||
UNAUDITED_ACTIONS = frozenset({"delete_selected"})
|
||||
|
||||
SENSITIVE_FIELD_NAMES = frozenset(
|
||||
{"api_key", "client_secret", "pin_code", "secret", "sub", "token"}
|
||||
)
|
||||
SENSITIVE_FIELD_MARKERS = ("password", "secret", "token")
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminVerb(StrEnum):
|
||||
"""What was done to an object through the admin."""
|
||||
|
||||
CREATE = "create"
|
||||
UPDATE = "update"
|
||||
DELETE = "delete"
|
||||
ACTION = "action"
|
||||
|
||||
|
||||
_VERB_TYPES: dict[AdminVerb, list[EventType]] = {
|
||||
AdminVerb.CREATE: [EventType.CREATION],
|
||||
AdminVerb.UPDATE: [EventType.CHANGE],
|
||||
AdminVerb.DELETE: [EventType.DELETION],
|
||||
AdminVerb.ACTION: [EventType.CHANGE],
|
||||
}
|
||||
|
||||
|
||||
def is_sensitive(field_name: str) -> bool:
|
||||
"""Tell whether the value of a field must never be recorded."""
|
||||
return field_name in SENSITIVE_FIELD_NAMES or any(
|
||||
marker in field_name for marker in SENSITIVE_FIELD_MARKERS
|
||||
)
|
||||
|
||||
|
||||
def value_fields_for(model: type) -> frozenset[str]:
|
||||
"""Return the fields of ``model`` whose before and after values may be recorded.
|
||||
|
||||
Anything that looks like a secret is dropped from the ``admin_values`` of
|
||||
the model here, so a mistake in the registration cannot leak one.
|
||||
"""
|
||||
names = model_options(model).admin_values
|
||||
return frozenset(name for name in names if not is_sensitive(name))
|
||||
|
||||
|
||||
def category_for(model: type) -> EventCategory:
|
||||
"""Return the registered category, else IAM for Django's auth models.
|
||||
|
||||
Anything granting access to the product is IAM, the rest configuration.
|
||||
"""
|
||||
if category := model_options(model).category:
|
||||
return category
|
||||
if model is get_user_model() or issubclass(model, (Group, Permission)):
|
||||
return EventCategory.IAM
|
||||
return EventCategory.CONFIGURATION
|
||||
|
||||
|
||||
def types_for(model: type, verb: AdminVerb) -> list[EventType]:
|
||||
"""Return the event types of ``verb`` on ``model``.
|
||||
|
||||
ECS expects ``user`` or ``group`` before the verb when one was the target.
|
||||
"""
|
||||
if issubclass(model, get_user_model()):
|
||||
return [EventType.USER, *_VERB_TYPES[verb]]
|
||||
if issubclass(model, Group):
|
||||
return [EventType.GROUP, *_VERB_TYPES[verb]]
|
||||
return _VERB_TYPES[verb]
|
||||
|
||||
|
||||
def action_name(model: type, verb: AdminVerb) -> str:
|
||||
"""Return the audit action for ``verb`` on ``model``."""
|
||||
return f"admin.{model._meta.model_name}.{verb}" # noqa: SLF001
|
||||
|
||||
|
||||
def form_diff(form, value_fields: frozenset[str]) -> dict[str, Any]:
|
||||
"""Return the names of the fields a form changed, and the allowed values.
|
||||
|
||||
Field names are always reported. Values are reported for allow-listed
|
||||
fields only, as ``{"from": ..., "to": ...}``.
|
||||
"""
|
||||
changed = sorted(form.changed_data)
|
||||
changes = {
|
||||
name: {
|
||||
"from": render_value(form.initial.get(name)),
|
||||
"to": render_value(form.cleaned_data.get(name)),
|
||||
}
|
||||
for name in changed
|
||||
if name in value_fields
|
||||
}
|
||||
return {"changed_fields": changed, "changes": changes}
|
||||
|
||||
|
||||
def related_diffs(formsets) -> list[tuple[Any, AdminVerb, dict[str, Any] | None]]:
|
||||
"""Return one ``(object, verb, diff)`` triple per inline object touched.
|
||||
|
||||
Called after ``save_related``, so the formsets already carry what they
|
||||
saved. The objects they list are the very instances their forms bound, so
|
||||
the matching form, and with it the before and after values, is found by
|
||||
identity.
|
||||
"""
|
||||
touched = []
|
||||
for formset in formsets or ():
|
||||
forms = {id(form.instance): form for form in formset.forms}
|
||||
value_fields = value_fields_for(formset.model)
|
||||
|
||||
def diff_of(obj, forms=forms, value_fields=value_fields):
|
||||
form = forms.get(id(obj))
|
||||
return form_diff(form, value_fields) if form is not None else None
|
||||
|
||||
for obj in getattr(formset, "new_objects", ()):
|
||||
touched.append((obj, AdminVerb.CREATE, diff_of(obj)))
|
||||
for obj, _fields in getattr(formset, "changed_objects", ()):
|
||||
touched.append((obj, AdminVerb.UPDATE, diff_of(obj)))
|
||||
for obj in getattr(formset, "deleted_objects", ()):
|
||||
# A deleted inline has no meaningful diff
|
||||
touched.append((obj, AdminVerb.DELETE, None))
|
||||
return touched
|
||||
|
||||
|
||||
class AuditedModelAdminMixin:
|
||||
"""Emit an audit event for every write made through this ModelAdmin."""
|
||||
|
||||
def construct_change_message(self, request, form, formsets, add=False):
|
||||
"""Stash the structured diff for the ``log_*`` hook that follows."""
|
||||
message = super().construct_change_message(request, form, formsets, add)
|
||||
try:
|
||||
diff = {
|
||||
"own": form_diff(form, value_fields_for(self.model)),
|
||||
"related": related_diffs(formsets),
|
||||
}
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
_logger.exception("Admin audit diff could not be built")
|
||||
diff = None
|
||||
setattr(request, DIFF_ATTRIBUTE, diff)
|
||||
return message
|
||||
|
||||
def log_addition(self, request, obj, message):
|
||||
"""Record the creation, and that of any inline object saved with it."""
|
||||
entry = super().log_addition(request, obj, message)
|
||||
self.audit_form_write(request, AdminVerb.CREATE, obj)
|
||||
return entry
|
||||
|
||||
def log_change(self, request, obj, message):
|
||||
"""Record the change, and that of any inline object saved with it."""
|
||||
entry = super().log_change(request, obj, message)
|
||||
self.audit_form_write(request, AdminVerb.UPDATE, obj)
|
||||
return entry
|
||||
|
||||
def log_deletions(self, request, queryset):
|
||||
"""Note the objects about to be deleted.
|
||||
|
||||
Django calls this before ``delete_model`` and ``delete_queryset``, in
|
||||
both the single and the bulk path. Those emit the events, once the
|
||||
deletion has succeeded or failed. Copies are kept because deleting an
|
||||
instance clears its primary key.
|
||||
"""
|
||||
targets = list(queryset)
|
||||
entries = super().log_deletions(request, targets)
|
||||
setattr(
|
||||
request, PENDING_DELETIONS_ATTRIBUTE, [copy.copy(obj) for obj in targets]
|
||||
)
|
||||
return entries
|
||||
|
||||
def delete_model(self, request, obj):
|
||||
"""Delete the object, then record one deletion."""
|
||||
with self.auditing_deletions(request, lambda: [copy.copy(obj)]):
|
||||
super().delete_model(request, obj)
|
||||
|
||||
def delete_queryset(self, request, queryset):
|
||||
"""Delete the objects, then record one deletion per object."""
|
||||
with self.auditing_deletions(request, lambda: list(queryset)):
|
||||
super().delete_queryset(request, queryset)
|
||||
|
||||
@contextmanager
|
||||
def auditing_deletions(self, request, default_targets):
|
||||
"""Record the deletions noted by ``log_deletions`` with their outcome.
|
||||
|
||||
``default_targets`` lists the objects when ``log_deletions`` did not
|
||||
run, as when a custom action deletes through these methods directly.
|
||||
"""
|
||||
targets = getattr(request, PENDING_DELETIONS_ATTRIBUTE, None)
|
||||
setattr(request, PENDING_DELETIONS_ATTRIBUTE, None)
|
||||
if targets is None:
|
||||
targets = default_targets()
|
||||
try:
|
||||
yield
|
||||
except Exception as error:
|
||||
for obj in targets:
|
||||
self.audit_write(request, AdminVerb.DELETE, obj, error=error)
|
||||
raise
|
||||
for obj in targets:
|
||||
self.audit_write(request, AdminVerb.DELETE, obj)
|
||||
|
||||
def get_actions(self, request):
|
||||
"""Return the available actions, each wrapped so that running it is audited."""
|
||||
return {
|
||||
name: (self.audited_action(func, name), name, description)
|
||||
for name, (func, _name, description) in super().get_actions(request).items()
|
||||
}
|
||||
|
||||
def audited_action(self, func, name):
|
||||
"""Wrap an admin action so every run emits an event, success or not."""
|
||||
if name in UNAUDITED_ACTIONS:
|
||||
return func
|
||||
|
||||
@wraps(func)
|
||||
def run(modeladmin, request, queryset):
|
||||
count = queryset.count()
|
||||
try:
|
||||
response = func(modeladmin, request, queryset)
|
||||
except Exception as error:
|
||||
modeladmin.audit_action(request, name, count, error=error)
|
||||
raise
|
||||
modeladmin.audit_action(request, name, count)
|
||||
return response
|
||||
|
||||
return run
|
||||
|
||||
def audit_form_write(self, request, verb, obj):
|
||||
"""Emit the event for a form write and for the inlines saved with it."""
|
||||
diff = getattr(request, DIFF_ATTRIBUTE, None) or {}
|
||||
setattr(request, DIFF_ATTRIBUTE, None)
|
||||
self.audit_write(request, verb, obj, diff.get("own"))
|
||||
for related_obj, related_verb, related_diff in diff.get("related", ()):
|
||||
self.audit_write(request, related_verb, related_obj, related_diff)
|
||||
|
||||
def audit_write(self, request, verb, obj, diff=None, *, error=None): # pylint: disable=too-many-arguments
|
||||
"""Emit one event for a write on ``obj``, a failed one if ``error`` is set."""
|
||||
model = obj.__class__
|
||||
log(
|
||||
action_name(model, verb),
|
||||
request=request,
|
||||
outcome=Outcome.SUCCESS if error is None else Outcome.FAILURE,
|
||||
reason=None if error is None else Reason.INTERNAL_ERROR,
|
||||
error=error,
|
||||
category=category_for(model),
|
||||
types=types_for(model, verb),
|
||||
target=obj,
|
||||
user_target=obj if isinstance(obj, get_user_model()) else None,
|
||||
**(diff or {}),
|
||||
)
|
||||
|
||||
def audit_action(self, request, name, count, error=None):
|
||||
"""Emit one event for a bulk action run on ``count`` objects."""
|
||||
log(
|
||||
action_name(self.model, AdminVerb.ACTION),
|
||||
request=request,
|
||||
outcome=Outcome.SUCCESS if error is None else Outcome.FAILURE,
|
||||
reason=None if error is None else Reason.INTERNAL_ERROR,
|
||||
category=category_for(self.model),
|
||||
types=types_for(self.model, AdminVerb.ACTION),
|
||||
error=error,
|
||||
admin_action=name,
|
||||
count=count,
|
||||
)
|
||||
|
||||
|
||||
def audited(admin_class: type) -> type:
|
||||
"""Return ``admin_class`` with the audit mixin."""
|
||||
if issubclass(admin_class, AuditedModelAdminMixin):
|
||||
return admin_class
|
||||
return type(
|
||||
f"Audited{admin_class.__name__}",
|
||||
(AuditedModelAdminMixin, admin_class),
|
||||
{"__module__": admin_class.__module__, "__doc__": admin_class.__doc__},
|
||||
)
|
||||
|
||||
|
||||
class AuditedAdminSite(AdminSite):
|
||||
"""Admin site whose model admins all emit audit events.
|
||||
|
||||
Installed through ``AdminConfig.default_site`` so that admin classes
|
||||
declared by Django itself, or by a third-party app, are covered as well as
|
||||
the project's own.
|
||||
"""
|
||||
|
||||
def register(self, model_or_iterable, admin_class=None, **options):
|
||||
"""Register the audited flavour of the given admin class."""
|
||||
super().register(
|
||||
model_or_iterable, audited(admin_class or ModelAdmin), **options
|
||||
)
|
||||
|
||||
def admin_view(self, view, cacheable=False):
|
||||
"""Record when a signed-in account without staff access tries an admin view.
|
||||
|
||||
Django asks ``has_permission`` several times per request, the login
|
||||
page included, so the refusal is recorded here instead: once per
|
||||
refused view. The answer is taken before the view runs, which may log
|
||||
the user out.
|
||||
"""
|
||||
guarded = super().admin_view(view, cacheable)
|
||||
|
||||
@wraps(guarded)
|
||||
def inner(request, *args, **kwargs):
|
||||
refused = getattr(
|
||||
request.user, "is_authenticated", False
|
||||
) and not self.has_permission(request)
|
||||
response = guarded(request, *args, **kwargs)
|
||||
if refused:
|
||||
log(
|
||||
ADMIN_ACCESS_ACTION,
|
||||
outcome=Outcome.DENIED,
|
||||
reason=Reason.PERMISSION_DENIED,
|
||||
request=request,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
return response
|
||||
|
||||
return inner
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Application configurations of the audit facility."""
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.contrib.admin.apps import AdminConfig
|
||||
from django.utils.module_loading import autodiscover_modules
|
||||
|
||||
from .signals import connect_auth_signals
|
||||
|
||||
|
||||
class AuditConfig(AppConfig):
|
||||
"""Audit Django's authentication signals and load the project's declarations."""
|
||||
|
||||
name = "core.audit"
|
||||
label = "audit"
|
||||
|
||||
def ready(self):
|
||||
"""Connect the login, failed login and logout receivers.
|
||||
|
||||
Then import the ``auditing`` module of every installed app, where the
|
||||
project registers its models and authentication classes.
|
||||
"""
|
||||
connect_auth_signals()
|
||||
autodiscover_modules("auditing")
|
||||
|
||||
|
||||
class AuditedAdminConfig(AdminConfig):
|
||||
"""Serve the admin from the site that audits every write."""
|
||||
|
||||
default_site = "core.audit.admin.AuditedAdminSite"
|
||||
@@ -0,0 +1,167 @@
|
||||
"""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.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from .actions import Action
|
||||
from .emitter import log
|
||||
from .enums import EventCategory, EventType, Outcome, Reason
|
||||
|
||||
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})
|
||||
|
||||
|
||||
def error_message(response) -> Any:
|
||||
"""Return the message of an error response, as DRF or the view wrote it."""
|
||||
data = getattr(response, "data", None)
|
||||
if isinstance(data, Mapping):
|
||||
return data.get("detail") or data.get("error")
|
||||
return None
|
||||
|
||||
|
||||
class AuditViewMixin:
|
||||
"""Emit one audit event per response of an audited action.
|
||||
|
||||
``audit_actions`` maps the CRUD actions only. An extra action is audited
|
||||
by passing ``audit_action`` to its ``@action`` decorator.
|
||||
|
||||
While handling a request, a view may *assign* ``audit_target``,
|
||||
``audit_actor`` and ``audit_details``; ``check_object_permissions`` sets
|
||||
the target on its own, before a refusal can happen.
|
||||
"""
|
||||
|
||||
audit_actions: Mapping[str, Action | str] = {}
|
||||
# Set by the router from the ``@action`` keyword arguments of the route.
|
||||
audit_action: Action | str | None = None
|
||||
audit_target: Any = None
|
||||
audit_actor: Any = None
|
||||
audit_details: Mapping[str, Any] | None = None
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
"""Refuse extra actions in ``audit_actions``, keyed by a method name."""
|
||||
super().__init_subclass__(**kwargs)
|
||||
if extra := sorted(set(cls.audit_actions) - set(ACTION_TYPES)):
|
||||
raise TypeError(
|
||||
f"{cls.__qualname__}.audit_actions only maps CRUD actions: "
|
||||
f"audit {', '.join(extra)} with @action(audit_action=...)"
|
||||
)
|
||||
|
||||
def check_object_permissions(self, request, obj):
|
||||
"""Remember the object as the target."""
|
||||
self.audit_target = obj
|
||||
super().check_object_permissions(request, obj)
|
||||
|
||||
def finalize_response(self, request, response, *args, **kwargs):
|
||||
"""Audit the response once DRF has built it."""
|
||||
response = super().finalize_response(request, response, *args, **kwargs)
|
||||
self.emit_audit_event(request, response.status_code, error_message(response))
|
||||
return response
|
||||
|
||||
def handle_exception(self, exc):
|
||||
"""Audit an exception DRF cannot turn into a response, then let it propagate.
|
||||
|
||||
Only its class is recorded since its message could carry personal data.
|
||||
"""
|
||||
try:
|
||||
return super().handle_exception(exc)
|
||||
except Exception as error:
|
||||
error_class = type(error)
|
||||
self.emit_audit_event(
|
||||
self.request,
|
||||
500,
|
||||
error_type=f"{error_class.__module__}.{error_class.__qualname__}",
|
||||
)
|
||||
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 too, for a view built
|
||||
without a router.
|
||||
"""
|
||||
name = getattr(self, "action", None)
|
||||
if name in ACTION_TYPES:
|
||||
return self.audit_actions.get(name)
|
||||
if self.audit_action is not None:
|
||||
return self.audit_action
|
||||
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."""
|
||||
action = self.get_audit_action()
|
||||
if action is not None:
|
||||
log(
|
||||
action,
|
||||
request=request,
|
||||
error_type=error_type,
|
||||
**self.get_audit_fields(status_code, error),
|
||||
)
|
||||
|
||||
def get_audit_fields(self, status_code, error=None) -> dict[str, Any]:
|
||||
"""Return the fields of the event for a response of ``status_code``.
|
||||
|
||||
The category and types of the ``Action`` win over those derived from
|
||||
the DRF action.
|
||||
"""
|
||||
action = self.get_audit_action()
|
||||
category, types = None, []
|
||||
if isinstance(action, Action):
|
||||
category, types = action.category, list(action.types)
|
||||
fields = {
|
||||
"category": category or EventCategory.API,
|
||||
"types": types
|
||||
or [ACTION_TYPES.get(getattr(self, "action", None), EventType.INFO)],
|
||||
"target": self.audit_target,
|
||||
"actor": self.audit_actor,
|
||||
**(self.audit_details or {}),
|
||||
}
|
||||
if status_code >= 400:
|
||||
fields |= {
|
||||
"outcome": (
|
||||
Outcome.DENIED
|
||||
if status_code in DENIED_STATUSES
|
||||
else Outcome.FAILURE
|
||||
),
|
||||
"reason": STATUS_REASONS.get(
|
||||
status_code, Reason.INTERNAL_ERROR if status_code >= 500 else None
|
||||
),
|
||||
"status_code": status_code,
|
||||
"error": error,
|
||||
}
|
||||
if status_code == 401:
|
||||
fields["category"] = EventCategory.AUTHENTICATION
|
||||
return fields
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Build ECS audit documents and emit them on the ``audit`` logger."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from .actions import Action
|
||||
from .actor import describe_actor, describe_user
|
||||
from .enums import ActorType, EventCategory, EventType, Outcome, Reason
|
||||
from .request import current_request_id, resolve_client_ip
|
||||
from .targets import describe_target
|
||||
from .utils import prune_empty, render_value
|
||||
|
||||
AUDIT_LOGGER_NAME = "audit"
|
||||
ECS_VERSION = "8.11.0"
|
||||
LOG_TYPE = "audit"
|
||||
|
||||
_audit_logger = logging.getLogger(AUDIT_LOGGER_NAME)
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def log(action: Action | str, **fields: Any) -> None:
|
||||
"""Emit one audit event."""
|
||||
try:
|
||||
document = build_document(action, **fields)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
_logger.exception("Audit event %r could not be built", action)
|
||||
return
|
||||
|
||||
_audit_logger.log(
|
||||
level_for(document["lasuite"]["outcome"], document["event"].get("reason")),
|
||||
str(action),
|
||||
extra={"audit": document},
|
||||
)
|
||||
|
||||
|
||||
def level_for(outcome: Outcome | str, reason: Reason | str | None) -> int:
|
||||
"""Derive the logging level so call sites never choose one."""
|
||||
if Outcome(outcome) == Outcome.SUCCESS:
|
||||
return logging.INFO
|
||||
if reason is not None and Reason(reason) == Reason.INTERNAL_ERROR:
|
||||
return logging.ERROR
|
||||
return logging.WARNING
|
||||
|
||||
|
||||
def build_document( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-locals
|
||||
action: Action | str,
|
||||
*,
|
||||
request: Any = None,
|
||||
outcome: Outcome | str = Outcome.SUCCESS,
|
||||
reason: Reason | str | None = None,
|
||||
category: EventCategory | str | None = None,
|
||||
types: list[EventType | str] | None = None,
|
||||
target: Any = None,
|
||||
user_target: Any = None,
|
||||
actor: Any = None,
|
||||
actor_type: ActorType | str | None = None,
|
||||
auth_method: str | None = None,
|
||||
client_id: str | None = None,
|
||||
status_code: int | None = None,
|
||||
error: Any = None,
|
||||
error_type: str | None = None,
|
||||
message: str | None = None,
|
||||
**details: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the ECS document of an event, pruned of empty values.
|
||||
|
||||
``action`` is what was attempted: an ``Action``, whose category and types
|
||||
apply unless given here, or a bare dotted name (``room.create``).
|
||||
The actor, auth method and network fields are read from ``request``;
|
||||
``actor``, ``actor_type``, ``auth_method`` and ``client_id`` override them.
|
||||
``target`` is the resource acted on and ``user_target`` the account an IAM
|
||||
action was performed on, reported as ``user.target``. Any other keyword
|
||||
argument lands under ``lasuite.details``.
|
||||
"""
|
||||
outcome = Outcome(outcome)
|
||||
reason = Reason(reason) if reason is not None else None
|
||||
if isinstance(action, Action):
|
||||
category = category or action.category
|
||||
types = types or list(action.types)
|
||||
client_ip = resolve_client_ip(request) if request is not None else None
|
||||
actor_fields = describe_actor(
|
||||
request,
|
||||
actor=actor,
|
||||
actor_type=actor_type,
|
||||
client_id=client_id,
|
||||
auth_method=auth_method,
|
||||
)
|
||||
|
||||
return prune_empty(
|
||||
{
|
||||
"@timestamp": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
|
||||
"ecs": {"version": ECS_VERSION},
|
||||
"log_type": LOG_TYPE,
|
||||
"message": message,
|
||||
"service": {
|
||||
"name": getattr(settings, "AUDIT_LOG_SERVICE_NAME", None),
|
||||
"environment": getattr(settings, "ENVIRONMENT", None),
|
||||
},
|
||||
"event": _event_fields(action, outcome, reason, category, types),
|
||||
"trace": {"id": current_request_id()},
|
||||
"client": {"ip": client_ip},
|
||||
"source": {"ip": client_ip},
|
||||
"http": {
|
||||
"request": {"method": getattr(request, "method", None)},
|
||||
"response": {"status_code": status_code},
|
||||
},
|
||||
"url": {"path": getattr(request, "path", None) or None},
|
||||
"user": {
|
||||
**(actor_fields["user"] or {}),
|
||||
"target": describe_user(user_target) if user_target else None,
|
||||
},
|
||||
"organization": actor_fields["organization"],
|
||||
"lasuite": {
|
||||
**actor_fields["lasuite"],
|
||||
"outcome": str(outcome),
|
||||
"target": describe_target(target) if target is not None else None,
|
||||
"details": render_value(details),
|
||||
},
|
||||
"error": {
|
||||
"message": str(error) if error is not None else None,
|
||||
"type": error_type,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _event_fields(action, outcome, reason, category, types) -> dict[str, Any]:
|
||||
type_list = [str(EventType(item)) for item in (types or [])]
|
||||
if not type_list:
|
||||
type_list = [str(_default_type(outcome))]
|
||||
if outcome == Outcome.DENIED and str(EventType.DENIED) not in type_list:
|
||||
type_list.append(str(EventType.DENIED))
|
||||
|
||||
return {
|
||||
"kind": "event",
|
||||
"action": str(action),
|
||||
"category": [str(EventCategory(category or EventCategory.WEB))],
|
||||
"type": type_list,
|
||||
"outcome": "success" if outcome == Outcome.SUCCESS else "failure",
|
||||
"reason": str(reason) if reason is not None else None,
|
||||
}
|
||||
|
||||
|
||||
def _default_type(outcome: Outcome) -> EventType:
|
||||
if outcome == Outcome.SUCCESS:
|
||||
return EventType.INFO
|
||||
if outcome == Outcome.DENIED:
|
||||
return EventType.DENIED
|
||||
return EventType.ERROR
|
||||
@@ -0,0 +1,65 @@
|
||||
"""ECS enums shared by every audit event."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class Outcome(StrEnum):
|
||||
"""Whether the audited action succeeded, failed, or was refused."""
|
||||
|
||||
SUCCESS = "success"
|
||||
FAILURE = "failure"
|
||||
DENIED = "denied"
|
||||
|
||||
|
||||
class Reason(StrEnum):
|
||||
"""Why an action did not succeed."""
|
||||
|
||||
AUTHENTICATION_FAILED = "authentication_failed"
|
||||
PERMISSION_DENIED = "permission_denied"
|
||||
RATE_LIMITED = "rate_limited"
|
||||
VALIDATION_ERROR = "validation_error"
|
||||
NOT_FOUND = "not_found"
|
||||
CONFLICT = "conflict"
|
||||
INTERNAL_ERROR = "internal_error"
|
||||
|
||||
|
||||
class ActorType(StrEnum):
|
||||
"""Kind of principal behind an action."""
|
||||
|
||||
USER = "user"
|
||||
APPLICATION = "application"
|
||||
DEVICE = "device"
|
||||
SERVICE = "service"
|
||||
SYSTEM = "system"
|
||||
ANONYMOUS = "anonymous"
|
||||
|
||||
|
||||
class EventCategory(StrEnum):
|
||||
"""Subset of the ECS ``event.category`` ."""
|
||||
|
||||
API = "api"
|
||||
AUTHENTICATION = "authentication"
|
||||
CONFIGURATION = "configuration"
|
||||
EMAIL = "email"
|
||||
FILE = "file"
|
||||
IAM = "iam"
|
||||
SESSION = "session"
|
||||
WEB = "web"
|
||||
|
||||
|
||||
class EventType(StrEnum):
|
||||
"""Subset of the ECS ``event.type``."""
|
||||
|
||||
ACCESS = "access"
|
||||
ADMIN = "admin"
|
||||
ALLOWED = "allowed"
|
||||
CHANGE = "change"
|
||||
CREATION = "creation"
|
||||
DELETION = "deletion"
|
||||
DENIED = "denied"
|
||||
END = "end"
|
||||
ERROR = "error"
|
||||
GROUP = "group"
|
||||
INFO = "info"
|
||||
START = "start"
|
||||
USER = "user"
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Render audit records as single-line ECS JSON format."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AuditJsonFormatter(logging.Formatter):
|
||||
"""Serialise the document attached to the record under ``audit`` in ECS format."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
document = getattr(record, "audit", None)
|
||||
if not isinstance(document, dict):
|
||||
document = {
|
||||
"@timestamp": datetime.fromtimestamp(
|
||||
record.created, tz=timezone.utc
|
||||
).isoformat(timespec="milliseconds"),
|
||||
"log_type": "audit",
|
||||
"message": record.getMessage(),
|
||||
"event": {"action": record.getMessage()},
|
||||
}
|
||||
|
||||
document = {
|
||||
**document,
|
||||
"log": {"level": record.levelname.lower(), "logger": record.name},
|
||||
}
|
||||
if record.exc_info:
|
||||
error: dict[str, Any] = dict(document.get("error") or {})
|
||||
error["stack_trace"] = self.formatException(record.exc_info)
|
||||
document["error"] = error
|
||||
|
||||
return json.dumps(
|
||||
document, ensure_ascii=False, default=str, separators=(",", ":")
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Declare what audit events may say about models and authentication classes.
|
||||
|
||||
The project registers them from an ``auditing`` module in one of its apps,
|
||||
imported once the audit app is ready, so models stay free of audit concerns::
|
||||
|
||||
audit.register(
|
||||
Room,
|
||||
fields=("slug", "access_level"), # describe the target
|
||||
admin_values=("name", "access_level"), # values diffed in the admin
|
||||
category=audit.EventCategory.CONFIGURATION, # ECS category of admin writes
|
||||
)
|
||||
audit.register_auth_method(ApplicationJWTAuthentication, "application_jwt")
|
||||
|
||||
A target is always identified by its model name and primary key, so a model
|
||||
that is not registered is still identifiable, just less detailed.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.db.models import Model
|
||||
|
||||
from .enums import EventCategory
|
||||
|
||||
|
||||
class AlreadyRegistered(Exception):
|
||||
"""A model or an authentication class that was registered twice."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelOptions:
|
||||
"""What audit events may say about a model.
|
||||
|
||||
``fields`` describe the model when it is the target of an event.
|
||||
``admin_values`` are the fields whose before and after values may be
|
||||
recorded when they change in the Django admin. ``category`` is the ECS
|
||||
category of admin writes: ``iam`` for anything granting access to the
|
||||
product, ``configuration`` by default.
|
||||
"""
|
||||
|
||||
fields: tuple[str, ...] = ()
|
||||
admin_values: tuple[str, ...] = ()
|
||||
category: EventCategory | None = None
|
||||
|
||||
|
||||
_models: dict[type[Model], ModelOptions] = {}
|
||||
_auth_methods: dict[str, str] = {}
|
||||
|
||||
|
||||
def register(
|
||||
model: type[Model],
|
||||
*,
|
||||
fields=(),
|
||||
admin_values=(),
|
||||
category: EventCategory | str | None = None,
|
||||
) -> None:
|
||||
"""Declare what audit events may say about ``model``."""
|
||||
if model in _models:
|
||||
raise AlreadyRegistered(f"{model._meta.label} is already registered") # noqa: SLF001
|
||||
_models[model] = ModelOptions(
|
||||
fields=tuple(fields),
|
||||
admin_values=tuple(admin_values),
|
||||
category=EventCategory(category) if category is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def unregister(model: type[Model]) -> ModelOptions | None:
|
||||
"""Forget ``model`` and return what was registered for it, if anything."""
|
||||
return _models.pop(model, None)
|
||||
|
||||
|
||||
def model_options(model: type[Model]) -> ModelOptions:
|
||||
"""Return what is registered for a model, or for its concrete model."""
|
||||
for klass in (model, model._meta.concrete_model): # noqa: SLF001
|
||||
if (options := _models.get(klass)) is not None:
|
||||
return options
|
||||
return ModelOptions()
|
||||
|
||||
|
||||
def dotted_path(klass: type) -> str:
|
||||
"""Return the dotted path Django and DRF name a class by."""
|
||||
return f"{klass.__module__}.{klass.__qualname__}"
|
||||
|
||||
|
||||
def register_auth_method(klass: type, name: str) -> None:
|
||||
"""Name the ``lasuite.auth.method`` of a DRF authentication class or a login backend.
|
||||
|
||||
A DRF class is also the default of its subclasses. A login backend must be
|
||||
registered itself: custom backends often subclass ``ModelBackend`` only for
|
||||
its permission checks, and must not pass for password logins.
|
||||
"""
|
||||
path = dotted_path(klass)
|
||||
if path in _auth_methods:
|
||||
raise AlreadyRegistered(f"{path} is already registered")
|
||||
_auth_methods[path] = name
|
||||
|
||||
|
||||
def auth_methods() -> dict[str, str]:
|
||||
"""Return the registered auth methods, keyed by dotted path."""
|
||||
return dict(_auth_methods)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Read the network fields and the request id behind an audit event."""
|
||||
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from dockerflow.logging import request_id_context
|
||||
from rest_framework.throttling import BaseThrottle
|
||||
|
||||
|
||||
def current_request_id() -> str | None:
|
||||
"""Return the id of the request being served, if any."""
|
||||
return request_id_context.get(None)
|
||||
|
||||
|
||||
def resolve_client_ip(request) -> str | None:
|
||||
"""Return the address of the real client, never the one of a proxy.
|
||||
|
||||
It reuses DRF's throttles to identify the client.
|
||||
"""
|
||||
return BaseThrottle().get_ident(request) or request.META.get("REMOTE_ADDR")
|
||||
|
||||
|
||||
class RequestIdHeaderMiddleware:
|
||||
"""Settle the request id, then echo it on the response.
|
||||
|
||||
It must come right after ``DockerflowMiddleware``, which sets the id from
|
||||
the inbound ``DOCKERFLOW_REQUEST_ID_HEADER_NAME`` header. Unless
|
||||
``REQUEST_ID_TRUST_HEADER`` says the ingress overwrites that header, the id
|
||||
is replaced by a fresh one before anything logs, so that a client, the web
|
||||
server access log and the audit events of a request can be joined on an id
|
||||
the client did not choose.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
if not settings.REQUEST_ID_TRUST_HEADER:
|
||||
request_id_context.set(str(uuid.uuid4()))
|
||||
|
||||
response = self.get_response(request)
|
||||
header = settings.DOCKERFLOW_REQUEST_ID_HEADER_NAME
|
||||
if not response.has_header(header):
|
||||
response[header] = current_request_id()
|
||||
return response
|
||||
@@ -0,0 +1,77 @@
|
||||
"""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 EventCategory, EventType, Outcome, Reason
|
||||
|
||||
LOGIN_ACTION = Action(
|
||||
"user.login", category=EventCategory.AUTHENTICATION, types=(EventType.START,)
|
||||
)
|
||||
LOGOUT_ACTION = Action(
|
||||
"user.logout", category=EventCategory.AUTHENTICATION, types=(EventType.END,)
|
||||
)
|
||||
|
||||
|
||||
def get_login_backend(request, user) -> str | None:
|
||||
"""Return the dotted path of the backend a login went through."""
|
||||
session = getattr(request, "session", None)
|
||||
from_session = session.get(BACKEND_SESSION_KEY) if session is not None else None
|
||||
return from_session or getattr(user, "backend", None)
|
||||
|
||||
|
||||
def auth_method_from_credentials(credentials) -> str:
|
||||
"""Name the mechanism of a failed login from the credentials it submitted."""
|
||||
if "password" in credentials:
|
||||
return "password"
|
||||
if "nonce" in credentials:
|
||||
return "oidc"
|
||||
return AUTH_METHOD_UNKNOWN
|
||||
|
||||
|
||||
def on_user_logged_in(sender, request, user, **kwargs): # pylint: disable=unused-argument
|
||||
"""Record a successful login."""
|
||||
backend = get_login_backend(request, user)
|
||||
log(
|
||||
LOGIN_ACTION,
|
||||
request=request,
|
||||
actor=user,
|
||||
auth_method=auth_method_for_backend(backend),
|
||||
auth_backend=backend,
|
||||
)
|
||||
|
||||
|
||||
def on_user_login_failed(sender, credentials, request, **kwargs): # pylint: disable=unused-argument
|
||||
"""Record a failed login."""
|
||||
log(
|
||||
LOGIN_ACTION,
|
||||
outcome=Outcome.FAILURE,
|
||||
reason=Reason.AUTHENTICATION_FAILED,
|
||||
request=request,
|
||||
auth_method=auth_method_from_credentials(credentials),
|
||||
)
|
||||
|
||||
|
||||
def on_user_logged_out(sender, request, user, **kwargs): # pylint: disable=unused-argument
|
||||
"""Record a logout."""
|
||||
log(
|
||||
LOGOUT_ACTION,
|
||||
request=request,
|
||||
actor=user,
|
||||
)
|
||||
|
||||
|
||||
def connect_auth_signals() -> None:
|
||||
"""Connect the receivers to authentication signal."""
|
||||
user_logged_in.connect(on_user_logged_in, dispatch_uid="audit.user_logged_in")
|
||||
user_login_failed.connect(
|
||||
on_user_login_failed, dispatch_uid="audit.user_login_failed"
|
||||
)
|
||||
user_logged_out.connect(on_user_logged_out, dispatch_uid="audit.user_logged_out")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Describe the resource an audit event is about.
|
||||
|
||||
The fields describing each model are those registered for it, see
|
||||
``core.audit.registry``. A target is always identified by its model name and
|
||||
primary key, so a model that is not registered is still identifiable, just
|
||||
less detailed.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Model
|
||||
|
||||
from .actor import describe_user
|
||||
from .registry import model_options
|
||||
from .utils import render_value
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def describe_target(obj: Any) -> dict[str, Any]:
|
||||
"""Return ``{"type": ..., "id": ..., **fields}`` for a target.
|
||||
|
||||
A user will carries its OIDC sub and its email domain.
|
||||
A registered field that cannot be read is left out and simply reported.
|
||||
"""
|
||||
if isinstance(obj, Mapping):
|
||||
return dict(obj)
|
||||
if not isinstance(obj, Model):
|
||||
return {"type": obj.__class__.__name__.lower(), "id": str(obj)}
|
||||
|
||||
meta = obj._meta # noqa: SLF001
|
||||
document: dict[str, Any] = {
|
||||
"type": meta.model_name,
|
||||
"id": str(obj.pk) if obj.pk is not None else None,
|
||||
}
|
||||
for name in model_options(meta.model).fields:
|
||||
try:
|
||||
document[name] = render_value(getattr(obj, name))
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
_logger.exception(
|
||||
"Audit field %r of %s could not be read", name, meta.label
|
||||
)
|
||||
if isinstance(obj, get_user_model()):
|
||||
document |= describe_user(obj)
|
||||
return document
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Helpers for asserting on audit events in tests."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from . import registry
|
||||
from .actions import Action
|
||||
from .emitter import AUDIT_LOGGER_NAME
|
||||
|
||||
|
||||
class _CollectingHandler(logging.Handler):
|
||||
"""Keep the documents attached to the records it receives."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(level=logging.DEBUG)
|
||||
self.documents: list[dict[str, Any]] = []
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
document = getattr(record, "audit", None)
|
||||
if not isinstance(document, dict):
|
||||
document = {"message": record.getMessage()}
|
||||
self.documents.append({**document, "log": {"level": record.levelname.lower()}})
|
||||
|
||||
|
||||
@contextmanager
|
||||
def capture_audit() -> Iterator[list[dict[str, Any]]]:
|
||||
"""Collect the audit documents emitted inside the block"""
|
||||
logger = logging.getLogger(AUDIT_LOGGER_NAME)
|
||||
handler = _CollectingHandler()
|
||||
previous_level = logger.level
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
try:
|
||||
yield handler.documents
|
||||
finally:
|
||||
logger.removeHandler(handler)
|
||||
logger.setLevel(previous_level)
|
||||
|
||||
|
||||
def find_events(
|
||||
events: list[dict[str, Any]], action: Action | str
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return the captured events whose ``event.action`` is ``action``."""
|
||||
return [
|
||||
event for event in events if event.get("event", {}).get("action") == str(action)
|
||||
]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def override_registration(model, **options) -> Iterator[None]:
|
||||
"""Register ``model`` with ``options`` inside the block, whatever it was before."""
|
||||
previous = registry.unregister(model)
|
||||
registry.register(model, **options)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
registry.unregister(model)
|
||||
if previous is not None:
|
||||
registry.register(model, **asdict(previous))
|
||||
@@ -0,0 +1,38 @@
|
||||
"""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 prune_empty(value: Any) -> Any:
|
||||
"""Drop ``None`` values and empty mappings, recursively."""
|
||||
if not isinstance(value, Mapping):
|
||||
return value
|
||||
pruned = {}
|
||||
for key, item in value.items():
|
||||
cleaned = prune_empty(item)
|
||||
if cleaned is None or (isinstance(cleaned, dict) and not cleaned):
|
||||
continue
|
||||
pruned[key] = cleaned
|
||||
return pruned
|
||||
@@ -0,0 +1,94 @@
|
||||
"""What Meet audits, and what its audit events may say.
|
||||
|
||||
Imported by the audit app once it is ready, see ``core.audit.apps``.
|
||||
"""
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
|
||||
from core import audit, models
|
||||
from core.audit import EventCategory, EventType
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.authentication.livekit import LiveKitTokenAuthentication
|
||||
from core.external_api.authentication import (
|
||||
AddonsJWTAuthentication,
|
||||
ApplicationJWTAuthentication,
|
||||
)
|
||||
from core.recording.event.authentication import HeaderBasedAuthentication
|
||||
from core.roomkit.authentication import ServerToServerAuthentication
|
||||
|
||||
# Actions. Those of CRUD views take their types from the DRF action.
|
||||
|
||||
APPLICATION_TOKEN_ISSUE = audit.Action(
|
||||
"application.token.issue",
|
||||
category=EventCategory.AUTHENTICATION,
|
||||
types=(EventType.START,),
|
||||
)
|
||||
USER_PROVISION = audit.Action(
|
||||
"user.provision",
|
||||
category=EventCategory.IAM,
|
||||
types=(EventType.USER, EventType.CREATION),
|
||||
)
|
||||
ROOM_CREATE = audit.Action("room.create")
|
||||
ROOM_LIST = audit.Action("room.list")
|
||||
ROOM_RETRIEVE = audit.Action("room.retrieve")
|
||||
ROOM_UPDATE = audit.Action("room.update")
|
||||
|
||||
# Models: the ``fields`` describing them as a target, the ``admin_values``
|
||||
# whose before and after values may be recorded in the admin, and the
|
||||
# ``category`` of their admin writes: ``iam`` for anything granting access to
|
||||
# the product, ``configuration`` by default.
|
||||
|
||||
audit.register(
|
||||
models.User,
|
||||
category=EventCategory.IAM,
|
||||
admin_values=(
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"is_device",
|
||||
"groups",
|
||||
"user_permissions",
|
||||
),
|
||||
)
|
||||
audit.register(Group, category=EventCategory.IAM, admin_values=("name", "permissions"))
|
||||
audit.register(
|
||||
models.Application,
|
||||
category=EventCategory.IAM,
|
||||
fields=("client_id", "name", "is_active", "scopes"),
|
||||
admin_values=("name", "is_active", "scopes"),
|
||||
)
|
||||
audit.register(
|
||||
models.ApplicationDomain, category=EventCategory.IAM, admin_values=("domain",)
|
||||
)
|
||||
audit.register(
|
||||
models.ResourceAccess,
|
||||
category=EventCategory.IAM,
|
||||
fields=("resource_id", "user_id", "role"),
|
||||
admin_values=("role",),
|
||||
)
|
||||
audit.register(
|
||||
models.RecordingAccess, category=EventCategory.IAM, admin_values=("role",)
|
||||
)
|
||||
audit.register(
|
||||
models.Room,
|
||||
fields=("slug", "name", "access_level"),
|
||||
admin_values=("name", "slug", "access_level", "configuration"),
|
||||
)
|
||||
audit.register(
|
||||
models.Recording,
|
||||
fields=("room_id", "status", "mode"),
|
||||
admin_values=("status", "mode"),
|
||||
)
|
||||
audit.register(models.File, admin_values=("title", "upload_state"))
|
||||
|
||||
# Authentication classes and login backends -> ``lasuite.auth.method``
|
||||
|
||||
audit.register_auth_method(OIDCAuthenticationBackend, "oidc")
|
||||
audit.register_auth_method(ApplicationJWTAuthentication, "application_jwt")
|
||||
audit.register_auth_method(AddonsJWTAuthentication, "addons_jwt")
|
||||
audit.register_auth_method(ResourceServerAuthentication, "resource_server")
|
||||
audit.register_auth_method(LiveKitTokenAuthentication, "livekit_token")
|
||||
audit.register_auth_method(HeaderBasedAuthentication, "shared_secret")
|
||||
audit.register_auth_method(ServerToServerAuthentication, "shared_secret")
|
||||
@@ -286,6 +286,10 @@ class ResourceServerBackend(LaSuiteBackend):
|
||||
if user is None and settings.OIDC_CREATE_USER:
|
||||
user = self.create_user(sub)
|
||||
|
||||
if user is not None and not user.is_active:
|
||||
logger.warning("Inactive user attempted authentication: %s", user.pk)
|
||||
raise SuspiciousOperation("User account is disabled.")
|
||||
|
||||
return user
|
||||
|
||||
def create_user(self, sub):
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""External API endpoints"""
|
||||
|
||||
import copy
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password
|
||||
@@ -23,7 +22,7 @@ from rest_framework import (
|
||||
status as drf_status,
|
||||
)
|
||||
|
||||
from core import analytics, api, models
|
||||
from core import analytics, api, audit, auditing, models
|
||||
from core.api.feature_flag import FeatureFlag
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
from core.services.room_management import RoomManagement
|
||||
@@ -35,18 +34,19 @@ from ..services.provisional_user_service import (
|
||||
)
|
||||
from . import authentication, permissions, serializers
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class ApplicationViewSet(viewsets.ViewSet):
|
||||
class ApplicationViewSet(audit.AuditViewMixin, viewsets.ViewSet):
|
||||
"""API endpoints for application authentication and token generation."""
|
||||
|
||||
audit_client_id = None
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="token",
|
||||
url_name="token",
|
||||
parser_classes=[drf_parsers.FormParser, drf_parsers.JSONParser],
|
||||
audit_action=auditing.APPLICATION_TOKEN_ISSUE,
|
||||
)
|
||||
@FeatureFlag.require("application")
|
||||
def generate_jwt_access_token(self, request, *args, **kwargs):
|
||||
@@ -68,6 +68,10 @@ class ApplicationViewSet(viewsets.ViewSet):
|
||||
|
||||
client_id = serializer.validated_data["client_id"]
|
||||
client_secret = serializer.validated_data["client_secret"]
|
||||
email = serializer.validated_data["scope"]
|
||||
|
||||
self.audit_client_id = client_id
|
||||
self.audit_details = {"requested_domain": audit.email_domain(email)}
|
||||
|
||||
try:
|
||||
application = models.Application.objects.get(client_id=client_id)
|
||||
@@ -80,7 +84,8 @@ class ApplicationViewSet(viewsets.ViewSet):
|
||||
if not application.is_active:
|
||||
raise drf_exceptions.AuthenticationFailed("Application is inactive")
|
||||
|
||||
email = serializer.validated_data["scope"]
|
||||
self.audit_target = application
|
||||
|
||||
try:
|
||||
validate_email(email)
|
||||
except ValidationError:
|
||||
@@ -92,11 +97,6 @@ class ApplicationViewSet(viewsets.ViewSet):
|
||||
)
|
||||
|
||||
if not application.can_delegate_email(email):
|
||||
logger.warning(
|
||||
"Application %s denied delegation for %s",
|
||||
application.client_id,
|
||||
email,
|
||||
)
|
||||
return drf_response.Response(
|
||||
{
|
||||
"error": "This application is not authorized for this email domain.",
|
||||
@@ -105,7 +105,7 @@ class ApplicationViewSet(viewsets.ViewSet):
|
||||
)
|
||||
|
||||
try:
|
||||
user, _ = ProvisionalUserService().get_or_create(email, client_id)
|
||||
user, created = ProvisionalUserService().get_or_create(email, client_id)
|
||||
except ProvisionalUserCreationDisabledError as not_found_error:
|
||||
raise drf_exceptions.NotFound("User not found.") from not_found_error
|
||||
except ProvisionalUserIntegrityError:
|
||||
@@ -114,6 +114,15 @@ class ApplicationViewSet(viewsets.ViewSet):
|
||||
status=drf_status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
if created:
|
||||
audit.log(
|
||||
auditing.USER_PROVISION,
|
||||
request=request,
|
||||
target=user,
|
||||
actor_type=audit.ActorType.APPLICATION,
|
||||
client_id=client_id,
|
||||
)
|
||||
|
||||
scope = " ".join(application.scopes or [])
|
||||
|
||||
token_service = JwtTokenService(
|
||||
@@ -134,13 +143,42 @@ class ApplicationViewSet(viewsets.ViewSet):
|
||||
},
|
||||
)
|
||||
|
||||
self.audit_actor = user
|
||||
self.audit_details = {
|
||||
"scopes": list(application.scopes or []),
|
||||
"user_provisioned": created,
|
||||
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
||||
}
|
||||
|
||||
return drf_response.Response(
|
||||
data,
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def get_audit_fields(self, status_code, error=None):
|
||||
"""Report the application as the actor once its credentials are verified.
|
||||
|
||||
Until then the submitted client id is only a claim: it is kept apart so
|
||||
that it never names the application or the tenant of the event.
|
||||
"""
|
||||
application = self.audit_target
|
||||
fields = {
|
||||
**super().get_audit_fields(status_code, error),
|
||||
"auth_method": "client_credentials",
|
||||
"actor_type": audit.ActorType.ANONYMOUS,
|
||||
}
|
||||
if application:
|
||||
fields |= {
|
||||
"actor_type": audit.ActorType.APPLICATION,
|
||||
"client_id": application.client_id,
|
||||
}
|
||||
else:
|
||||
fields["claimed_client_id"] = self.audit_client_id
|
||||
return fields
|
||||
|
||||
|
||||
class RoomViewSet(
|
||||
audit.AuditViewMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
@@ -163,6 +201,13 @@ class RoomViewSet(
|
||||
|
||||
http_method_names = ["get", "post", "patch", "head", "options"]
|
||||
|
||||
audit_actions = {
|
||||
"list": auditing.ROOM_LIST,
|
||||
"retrieve": auditing.ROOM_RETRIEVE,
|
||||
"create": auditing.ROOM_CREATE,
|
||||
"partial_update": auditing.ROOM_UPDATE,
|
||||
}
|
||||
|
||||
authentication_classes = [
|
||||
authentication.ApplicationJWTAuthentication,
|
||||
authentication.AddonsJWTAuthentication,
|
||||
@@ -191,29 +236,22 @@ class RoomViewSet(
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
self.audit_details = {"total": self.paginator.page.paginator.count}
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
self.audit_details = {"total": len(serializer.data)}
|
||||
return drf_response.Response(serializer.data)
|
||||
|
||||
def _track_room_event(self, room, event, **extra_properties):
|
||||
"""Log a room operation for auditing and forward it to analytics."""
|
||||
"""Add a room operation to the audit event and forward it to analytics."""
|
||||
|
||||
self.audit_target = room
|
||||
self.audit_details = extra_properties
|
||||
|
||||
auth_method = type(self.request.successful_authenticator).__name__
|
||||
client_id = (self.request.auth or {}).get("client_id", "unknown")
|
||||
|
||||
# Log for auditing
|
||||
details = "".join(f", {key}={value}" for key, value in extra_properties.items())
|
||||
logger.info(
|
||||
"Room %s via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s%s",
|
||||
event.removeprefix("room_"),
|
||||
room.id,
|
||||
self.request.user.id,
|
||||
client_id,
|
||||
auth_method,
|
||||
details,
|
||||
)
|
||||
|
||||
analytics.capture(
|
||||
self.request.user,
|
||||
event,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Logging filters for the core application."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class SilenceExpected401(logging.Filter):
|
||||
"""Drop the expected 401 from anonymous hits on the /me endpoint.
|
||||
|
||||
The frontend probes `/users/me/` to check authentication; a 401 for
|
||||
anonymous users is normal, not a warning worth logging.
|
||||
"""
|
||||
|
||||
def filter(self, record):
|
||||
"""Return False for a 401 on a silenced path, True otherwise."""
|
||||
if getattr(record, "status_code", None) != 401:
|
||||
return True
|
||||
|
||||
request = getattr(record, "request", None)
|
||||
path = getattr(request, "path", None)
|
||||
if not path:
|
||||
return True
|
||||
|
||||
return path not in settings.LOGGING_SILENCED_401_PATHS
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Purge inactive rooms."""
|
||||
|
||||
from datetime import timedelta
|
||||
from itertools import batched
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.utils import timezone
|
||||
|
||||
from core.models import Recording, RecordingStatusChoices, Room
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
CHUNK_SIZE = 500
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Delete rooms that have not been started for ROOM_INACTIVITY_DELETION_DAYS days:
|
||||
- rooms which were last started before that period
|
||||
- rooms never started and created before that period
|
||||
|
||||
Rooms holding a saved recording that has not expired are kept.
|
||||
"""
|
||||
|
||||
help = "Purge inactive rooms"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="List the rooms that would be purged without deleting them",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Browse inactive rooms and delete them chunk by chunk."""
|
||||
|
||||
if not settings.ROOM_INACTIVITY_DELETION_DAYS:
|
||||
self.stdout.write(
|
||||
"Purging inactive rooms is disabled "
|
||||
"(ROOM_INACTIVITY_DELETION_DAYS is not set)."
|
||||
)
|
||||
return
|
||||
|
||||
now = timezone.now()
|
||||
inactive_rooms = self.get_inactive_rooms(now)
|
||||
|
||||
inactive_count = inactive_rooms.count()
|
||||
if not inactive_count:
|
||||
self.stdout.write("No inactive room to purge.")
|
||||
return
|
||||
|
||||
if options["dry_run"]:
|
||||
self.stdout.write(
|
||||
f"[dry-run] {inactive_count} inactive room(s) would be purged:"
|
||||
)
|
||||
names = inactive_rooms.values_list("name", flat=True)
|
||||
for name in names.iterator(chunk_size=CHUNK_SIZE):
|
||||
self.stdout.write(f"- {name}")
|
||||
return
|
||||
|
||||
purged_count = 0
|
||||
rooms = inactive_rooms.values_list("pk", "slug").iterator(chunk_size=CHUNK_SIZE)
|
||||
for chunk in batched(rooms, CHUNK_SIZE, strict=False):
|
||||
for room_id, slug in chunk:
|
||||
logger.info("Purging inactive room %s (%s)", room_id, slug)
|
||||
|
||||
_, deleted_by_model = inactive_rooms.filter(
|
||||
pk__in=[room_id for room_id, _ in chunk]
|
||||
).delete()
|
||||
purged_count += deleted_by_model.get("core.Room", 0)
|
||||
|
||||
self.stdout.write(f"Purged {purged_count} inactive room(s).")
|
||||
|
||||
@staticmethod
|
||||
def get_inactive_rooms(now):
|
||||
"""Return the rooms inactive for too long that no recording protects."""
|
||||
|
||||
threshold = now - timedelta(days=settings.ROOM_INACTIVITY_DELETION_DAYS)
|
||||
is_inactive = Q(last_started_at__lt=threshold) | Q(
|
||||
last_started_at__isnull=True, created_at__lt=threshold
|
||||
)
|
||||
|
||||
protected_recordings = Recording.objects.filter(
|
||||
room=OuterRef("pk"), status__in=RecordingStatusChoices.saved_statuses()
|
||||
)
|
||||
if settings.RECORDING_EXPIRATION_DAYS:
|
||||
protected_recordings = protected_recordings.filter(
|
||||
created_at__gte=now - timedelta(days=settings.RECORDING_EXPIRATION_DAYS)
|
||||
)
|
||||
|
||||
return Room.objects.filter(is_inactive, ~Exists(protected_recordings))
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.16 on 2026-09-23 16:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0022_user_default_room_access_level_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='recording',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed', 'Failed'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded'), ('external_process_successful', 'External process successful'), ('external_process_failed', 'External process failed')], default='initiated', max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0023_alter_recording_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='room',
|
||||
name='last_started_at',
|
||||
field=models.DateTimeField(blank=True, default=django.utils.timezone.now, editable=False, help_text='date and time at which the room was last started', null=True, verbose_name='last started at'),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@@ -58,6 +58,7 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
STOPPED = "stopped", _("Stopped")
|
||||
SAVED = "saved", _("Saved")
|
||||
ABORTED = "aborted", _("Aborted")
|
||||
FAILED = "failed", _("Failed")
|
||||
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
||||
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
||||
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
|
||||
@@ -79,6 +80,7 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
cls.STOPPED,
|
||||
cls.SAVED,
|
||||
cls.ABORTED,
|
||||
cls.FAILED,
|
||||
cls.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||
cls.EXTERNAL_PROCESS_FAILED,
|
||||
cls.FAILED_TO_START,
|
||||
@@ -86,9 +88,15 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def is_unsuccessful(cls, status):
|
||||
"""Determine if the recording status represents an unsuccessful state."""
|
||||
return status in {cls.ABORTED, cls.FAILED_TO_START, cls.FAILED_TO_STOP}
|
||||
def saved_statuses(cls):
|
||||
"""Return the statuses of a recording whose file users can access."""
|
||||
|
||||
return {
|
||||
cls.NOTIFICATION_SUCCEEDED,
|
||||
cls.SAVED,
|
||||
cls.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||
cls.EXTERNAL_PROCESS_FAILED,
|
||||
}
|
||||
|
||||
|
||||
class RecordingModeChoices(models.TextChoices):
|
||||
@@ -429,6 +437,13 @@ class Room(Resource):
|
||||
verbose_name=_("Room PIN code"),
|
||||
help_text=_("Unique n-digit code that identifies this room in telephony mode."),
|
||||
)
|
||||
last_started_at = models.DateTimeField(
|
||||
verbose_name=_("last started at"),
|
||||
help_text=_("date and time at which the room was last started"),
|
||||
blank=True,
|
||||
null=True,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_room"
|
||||
@@ -582,6 +597,7 @@ class Recording(BaseModel):
|
||||
4. NOTIFICATION_SUCCEEDED: External service has been notified of this recording
|
||||
|
||||
Error States:
|
||||
- FAILED: Egress failed mid-recording
|
||||
- FAILED_TO_START: Worker failed to initialize recording
|
||||
- FAILED_TO_STOP: Worker failed during stop operation
|
||||
- ABORTED: Recording was terminated before completion
|
||||
@@ -684,12 +700,7 @@ class Recording(BaseModel):
|
||||
@property
|
||||
def is_saved(self) -> bool:
|
||||
"""Check if the recording is in a saved state."""
|
||||
return self.status in {
|
||||
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
|
||||
RecordingStatusChoices.SAVED,
|
||||
RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||
RecordingStatusChoices.EXTERNAL_PROCESS_FAILED,
|
||||
}
|
||||
return self.status in RecordingStatusChoices.saved_statuses()
|
||||
|
||||
@property
|
||||
def extension(self):
|
||||
|
||||
@@ -8,3 +8,50 @@ class FileExtension(Enum):
|
||||
|
||||
OGG = "ogg"
|
||||
MP4 = "mp4"
|
||||
|
||||
|
||||
class RecordingWorkerEvent(Enum):
|
||||
"""Lifecycle events a recording worker reports about a recording.
|
||||
|
||||
It is intended to be free of SFU-specific vocabulary.
|
||||
"""
|
||||
|
||||
# The worker accepted the request but is not recording yet.
|
||||
STARTING = "starting"
|
||||
# The worker is recording.
|
||||
STARTED = "started"
|
||||
# The worker stopped recording and is flushing the media file.
|
||||
SAVING = "saving"
|
||||
|
||||
# The recording ended, its media file is available.
|
||||
COMPLETED = "completed"
|
||||
# The recording ended on its configured limit, its media file is available.
|
||||
LIMIT_REACHED = "limit reached"
|
||||
# The worker stopped before it ever started recording, there is no media file.
|
||||
ABORTED = "aborted"
|
||||
# The worker hit a runtime error once recording had started; its media file
|
||||
# may be available.
|
||||
FAILED = "failed"
|
||||
|
||||
@classmethod
|
||||
def is_terminal(cls, event):
|
||||
"""Determine if the event ends the recording's lifecycle (successful or not)."""
|
||||
|
||||
return event in TERMINAL_EVENTS
|
||||
|
||||
|
||||
SUCCESSFUL_EVENTS = frozenset(
|
||||
{
|
||||
RecordingWorkerEvent.COMPLETED,
|
||||
RecordingWorkerEvent.LIMIT_REACHED,
|
||||
}
|
||||
)
|
||||
|
||||
UNSUCCESSFUL_EVENTS = frozenset(
|
||||
{
|
||||
RecordingWorkerEvent.ABORTED,
|
||||
RecordingWorkerEvent.FAILED,
|
||||
}
|
||||
)
|
||||
|
||||
TERMINAL_EVENTS = SUCCESSFUL_EVENTS | UNSUCCESSFUL_EVENTS
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Recording-related LiveKit Events Service"""
|
||||
|
||||
# pylint: disable=no-member
|
||||
"""Recording-related Events Service"""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from livekit import api
|
||||
|
||||
from core import models, utils
|
||||
from core.models import Recording
|
||||
from core.recording.enums import (
|
||||
UNSUCCESSFUL_EVENTS,
|
||||
RecordingWorkerEvent,
|
||||
)
|
||||
from core.recording.event.notification import notification_service
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
@@ -26,22 +26,110 @@ class RecordingNotSavableError(Exception):
|
||||
"""Recording cannot be saved because it is either in an error state or has already been saved"""
|
||||
|
||||
|
||||
# Notification sent to the room's participants, per event and recording mode.
|
||||
NOTIFICATION_PREFIXES = {
|
||||
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecording",
|
||||
models.RecordingModeChoices.TRANSCRIPT: "transcription",
|
||||
}
|
||||
NOTIFICATION_SUFFIXES = {
|
||||
RecordingWorkerEvent.LIMIT_REACHED: "LimitReached",
|
||||
RecordingWorkerEvent.FAILED: "Failed",
|
||||
RecordingWorkerEvent.ABORTED: "Aborted",
|
||||
}
|
||||
|
||||
|
||||
def get_notification_type(recording_mode, event):
|
||||
"""Generate corresponding notification type string."""
|
||||
try:
|
||||
return f"{NOTIFICATION_PREFIXES[recording_mode]}{NOTIFICATION_SUFFIXES[event]}"
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
# Recording status in the room's metadata, per event.
|
||||
ROOM_METADATA_RECORDING_STATUSES = {
|
||||
RecordingWorkerEvent.STARTED: "started",
|
||||
RecordingWorkerEvent.SAVING: "saving",
|
||||
}
|
||||
|
||||
|
||||
class RecordingEventsService:
|
||||
"""Handles recording-related LiveKit webhook events."""
|
||||
"""Handles recording-related worker events.
|
||||
|
||||
Two entry points: `handle_update` for the events a running recording
|
||||
reports, and `handle_terminal_event` for the one ending it.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def handle_update(recording: Recording, egress_status):
|
||||
"""Handle egress status updates and sync recording state to room metadata."""
|
||||
def log_worker_error(recording, event, error=None, error_code=None):
|
||||
"""Log FAILED at error level and expected ABORTED outcomes at info level."""
|
||||
|
||||
if event == RecordingWorkerEvent.FAILED:
|
||||
log = logger.error
|
||||
elif event == RecordingWorkerEvent.ABORTED:
|
||||
log = logger.info
|
||||
else:
|
||||
return
|
||||
|
||||
log(
|
||||
"Recording worker reported %s for recording %s (room=%s, mode=%s): %s (error_code=%s)",
|
||||
event.value,
|
||||
recording.id,
|
||||
recording.room.id,
|
||||
recording.mode,
|
||||
error or "no error reported",
|
||||
error_code or "no error_code reported",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _notify_participants(recording: Recording, event: RecordingWorkerEvent):
|
||||
"""Notify the room's participants that a recording ended on the given event."""
|
||||
recording_mode = recording.options.get("original_mode", None) or recording.mode
|
||||
|
||||
notification_type = get_notification_type(recording_mode, event)
|
||||
if not notification_type:
|
||||
logger.warning(
|
||||
"Could not find notification type for: "
|
||||
"room=%s, recording_id=%s, mode=%s, event=%s",
|
||||
recording.room.id,
|
||||
recording.id,
|
||||
recording_mode,
|
||||
event.value,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
utils.notify_participants(
|
||||
room_name=str(recording.room.id),
|
||||
notification_data={"type": notification_type},
|
||||
)
|
||||
except utils.NotificationError as e:
|
||||
raise RecordingEventsError(
|
||||
f"Failed to notify participants in room '{recording.room.id}' about "
|
||||
f"recording {event.value} (recording_id={recording.id})"
|
||||
) from e
|
||||
|
||||
@staticmethod
|
||||
def _log_notification_failure(recording, event: RecordingWorkerEvent):
|
||||
"""Log a participant notification error on an unsuccessful recording."""
|
||||
|
||||
logger.exception(
|
||||
"Failed to notify participants that recording %s %s (room=%s)",
|
||||
recording.id,
|
||||
event.value,
|
||||
recording.room.id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def handle_update(recording: Recording, event: RecordingWorkerEvent):
|
||||
"""Handle non-terminal worker events and sync recording state to room metadata.
|
||||
|
||||
Terminal events are dispatched through `handle_terminal_event` instead.
|
||||
"""
|
||||
|
||||
room_name = str(recording.room.id)
|
||||
|
||||
status_mapping = {
|
||||
api.EgressStatus.EGRESS_ACTIVE: "started",
|
||||
api.EgressStatus.EGRESS_ENDING: "saving",
|
||||
api.EgressStatus.EGRESS_ABORTED: "aborted",
|
||||
}
|
||||
|
||||
recording_status = status_mapping.get(egress_status)
|
||||
recording_status = ROOM_METADATA_RECORDING_STATUSES.get(event)
|
||||
if recording_status:
|
||||
try:
|
||||
RoomManagement.update_metadata(
|
||||
@@ -55,42 +143,113 @@ class RecordingEventsService:
|
||||
except RoomManagementException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
@staticmethod
|
||||
def handle_limit_reached(recording: Recording):
|
||||
def handle_terminal_event(self, recording: Recording, event: RecordingWorkerEvent):
|
||||
"""Run the appropriate handlers for a terminal event, given the recording's state."""
|
||||
|
||||
if not RecordingWorkerEvent.is_terminal(event):
|
||||
logger.warning(
|
||||
"Ignoring non-terminal event %s dispatched as a terminal event "
|
||||
"for recording %s.",
|
||||
event.value,
|
||||
recording.id,
|
||||
)
|
||||
return
|
||||
|
||||
if event in UNSUCCESSFUL_EVENTS:
|
||||
self._flag_unsuccessful_recording(recording, event)
|
||||
else:
|
||||
self._save_successful_recording(recording, event)
|
||||
|
||||
def _flag_unsuccessful_recording(
|
||||
self, recording: Recording, event: RecordingWorkerEvent
|
||||
):
|
||||
"""Persist the outcome of a recording the worker announced as unsuccessful."""
|
||||
|
||||
# Aborted
|
||||
if event == RecordingWorkerEvent.ABORTED:
|
||||
if recording.status == models.RecordingStatusChoices.ACTIVE:
|
||||
self._apply_outcome(recording, event, self._handle_aborted)
|
||||
return
|
||||
|
||||
# Failed
|
||||
if event == RecordingWorkerEvent.FAILED:
|
||||
if recording.is_savable():
|
||||
self._apply_outcome(recording, event, self._handle_failed)
|
||||
return
|
||||
|
||||
logger.error(
|
||||
"Unsuccessful event %s has no handler; recording %s keeps status '%s'.",
|
||||
event.value,
|
||||
recording.id,
|
||||
recording.status,
|
||||
)
|
||||
|
||||
def _save_successful_recording(
|
||||
self, recording: Recording, event: RecordingWorkerEvent
|
||||
):
|
||||
"""Save a recording whose media file the worker made available."""
|
||||
|
||||
# Limit reached
|
||||
if (
|
||||
event == RecordingWorkerEvent.LIMIT_REACHED
|
||||
and recording.status == models.RecordingStatusChoices.ACTIVE
|
||||
):
|
||||
self._apply_outcome(recording, event, self._handle_limit_reached)
|
||||
|
||||
try:
|
||||
self._handle_successful(recording)
|
||||
except RecordingNotSavableError:
|
||||
logger.warning(
|
||||
"Recording %s is not savable on a completed recording "
|
||||
"(already saved or in an error state); ignoring.",
|
||||
recording.id,
|
||||
)
|
||||
|
||||
def _apply_outcome(
|
||||
self, recording: Recording, event: RecordingWorkerEvent, handler
|
||||
):
|
||||
"""Keep notification failure non-fatal."""
|
||||
|
||||
try:
|
||||
handler(recording)
|
||||
except RecordingEventsError:
|
||||
self._log_notification_failure(recording, event)
|
||||
|
||||
@classmethod
|
||||
def _handle_limit_reached(cls, recording: Recording):
|
||||
"""Stop recording and notify participants when limit is reached."""
|
||||
|
||||
recording.status = models.RecordingStatusChoices.STOPPED
|
||||
recording.save()
|
||||
|
||||
notification_mapping = {
|
||||
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingLimitReached",
|
||||
models.RecordingModeChoices.TRANSCRIPT: "transcriptionLimitReached",
|
||||
}
|
||||
cls._notify_participants(recording, RecordingWorkerEvent.LIMIT_REACHED)
|
||||
|
||||
notification_type = notification_mapping.get(recording.mode)
|
||||
if not notification_type:
|
||||
return
|
||||
@classmethod
|
||||
def _handle_failed(cls, recording: Recording):
|
||||
"""Set recording status to failed, matching the worker event, and notify participants.
|
||||
|
||||
try:
|
||||
utils.notify_participants(
|
||||
room_name=str(recording.room.id),
|
||||
notification_data={"type": notification_type},
|
||||
)
|
||||
except utils.NotificationError as e:
|
||||
logger.exception(
|
||||
"Failed to notify participants about recording limit reached: "
|
||||
"room=%s, recording_id=%s, mode=%s",
|
||||
recording.room.id,
|
||||
recording.id,
|
||||
recording.mode,
|
||||
)
|
||||
raise RecordingEventsError(
|
||||
f"Failed to notify participants in room '{recording.room.id}' about "
|
||||
f"recording limit reached (recording_id={recording.id})"
|
||||
) from e
|
||||
FAILED: used when an actual runtime/pipeline error occurs after the
|
||||
recording has started
|
||||
"""
|
||||
recording.status = models.RecordingStatusChoices.FAILED
|
||||
recording.save()
|
||||
|
||||
cls._notify_participants(recording, RecordingWorkerEvent.FAILED)
|
||||
|
||||
@classmethod
|
||||
def _handle_aborted(cls, recording: Recording):
|
||||
"""Set recording status to aborted, matching the worker event, and notify participants.
|
||||
|
||||
ABORTED: used when the worker stops before it ever became
|
||||
active/recording
|
||||
"""
|
||||
recording.status = models.RecordingStatusChoices.ABORTED
|
||||
recording.save()
|
||||
|
||||
cls._notify_participants(recording, RecordingWorkerEvent.ABORTED)
|
||||
|
||||
@staticmethod
|
||||
def handle_complete(recording: Recording):
|
||||
def _handle_successful(recording: Recording):
|
||||
"""Notify external services and save recording."""
|
||||
|
||||
if not recording.is_savable():
|
||||
|
||||
@@ -22,6 +22,46 @@ _RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
|
||||
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
|
||||
|
||||
|
||||
def build_encoding_options(resolution, profile=None):
|
||||
"""Assemble the LiveKit ``EncodingOptions`` kwargs for a resolution/profile.
|
||||
|
||||
Single source of truth shared by the default encoding
|
||||
(``WorkerServiceConfig.from_settings``) and the per-recording encoding
|
||||
persisted by the start-recording API, so both paths always produce the
|
||||
same shape.
|
||||
|
||||
The profile-independent fields (audio bitrate, keyframe interval and the
|
||||
pinned codec / frequency constants) are always included.
|
||||
|
||||
An omitted profile falls back to RECORDING_ENCODING_DEFAULT_PROFILE.
|
||||
Framerate and bitrate are left to LiveKit only when the operator
|
||||
declared no default profile at all.
|
||||
"""
|
||||
profile = profile or settings.RECORDING_ENCODING_DEFAULT_PROFILE
|
||||
|
||||
options: Dict[str, Any] = {
|
||||
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||
"video_codec": _RECORDING_VIDEO_CODEC,
|
||||
"audio_codec": _RECORDING_AUDIO_CODEC,
|
||||
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
|
||||
}
|
||||
|
||||
if resolution:
|
||||
resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[
|
||||
resolution
|
||||
]
|
||||
options["width"] = resolution_config["width"]
|
||||
options["height"] = resolution_config["height"]
|
||||
|
||||
if resolution and profile:
|
||||
profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
|
||||
options["framerate"] = profile_config["fps"]
|
||||
options["video_bitrate"] = profile_config["kbps"][resolution]
|
||||
|
||||
return options
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerServiceConfig:
|
||||
"""Declare Worker Service common configurations"""
|
||||
@@ -38,22 +78,16 @@ class WorkerServiceConfig:
|
||||
|
||||
logger.debug("Loading WorkerServiceConfig from settings.")
|
||||
|
||||
# The default encoding is resolved from the default profile/resolution and
|
||||
# applied to every recording that carries no per-recording encoding.
|
||||
# When either default is missing, we leave this as None so LiveKit falls
|
||||
# back to its built-in preset.
|
||||
resolution = settings.RECORDING_ENCODING_DEFAULT_RESOLUTION
|
||||
profile = settings.RECORDING_ENCODING_DEFAULT_PROFILE
|
||||
|
||||
encoding_options: Optional[Dict[str, Any]] = None
|
||||
if settings.RECORDING_ENCODING_ENABLED:
|
||||
# Single source of truth for the EncodingOptions kwargs:
|
||||
# operator-tunable values live in Django settings, codec / frequency
|
||||
# are pinned constants. The services layer only unpacks this dict.
|
||||
encoding_options = {
|
||||
"width": settings.RECORDING_ENCODING_WIDTH,
|
||||
"height": settings.RECORDING_ENCODING_HEIGHT,
|
||||
"framerate": settings.RECORDING_ENCODING_FRAMERATE,
|
||||
"video_bitrate": settings.RECORDING_ENCODING_VIDEO_BITRATE_KBPS,
|
||||
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||
"video_codec": _RECORDING_VIDEO_CODEC,
|
||||
"audio_codec": _RECORDING_AUDIO_CODEC,
|
||||
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
|
||||
}
|
||||
if resolution and profile:
|
||||
encoding_options = build_encoding_options(resolution, profile)
|
||||
|
||||
return cls(
|
||||
output_folder=settings.RECORDING_OUTPUT_FOLDER,
|
||||
@@ -78,7 +112,12 @@ class WorkerService(Protocol):
|
||||
def __init__(self, config: WorkerServiceConfig):
|
||||
"""Initialize the service with the given configuration."""
|
||||
|
||||
def start(self, room_id: str, recording_id: str) -> str:
|
||||
def start(
|
||||
self,
|
||||
room_id: str,
|
||||
recording_id: str,
|
||||
encoding_options: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Start a recording for a specified room."""
|
||||
|
||||
def stop(self, worker_id: str) -> str:
|
||||
|
||||
@@ -51,8 +51,11 @@ class WorkerServiceMediator:
|
||||
raise RecordingStartError()
|
||||
|
||||
room_name = str(recording.room.id)
|
||||
encoding_options = (recording.options.get("encoding") or {}).get("resolved")
|
||||
try:
|
||||
worker_id = self._worker_service.start(room_name, recording.id)
|
||||
worker_id = self._worker_service.start(
|
||||
room_name, recording.id, encoding_options=encoding_options
|
||||
)
|
||||
except (WorkerRequestError, WorkerConnectionError, WorkerResponseError) as e:
|
||||
logger.exception(
|
||||
"Failed to start recording for room %s: %s", recording.room.slug, e
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
|
||||
# pylint: disable=no-member
|
||||
|
||||
import logging
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit import api as livekit_api
|
||||
|
||||
from ... import utils
|
||||
from ..enums import FileExtension
|
||||
from .exceptions import WorkerConnectionError, WorkerResponseError
|
||||
from .exceptions import WorkerConnectionError, WorkerRequestError, WorkerResponseError
|
||||
from .factories import WorkerServiceConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseEgressService:
|
||||
"""Base egress defining common methods to manage and interact with LiveKit egress processes."""
|
||||
@@ -20,7 +24,7 @@ class BaseEgressService:
|
||||
|
||||
def _get_filepath(self, filename: str, extension: str) -> str:
|
||||
"""Construct the file path for a given filename and extension.
|
||||
Unsecure method, doesn't handle paths robustly and securely.
|
||||
Insecure method, doesn't handle paths robustly and securely.
|
||||
"""
|
||||
return f"{self._config.output_folder}/{filename}.{extension}"
|
||||
|
||||
@@ -49,6 +53,22 @@ class BaseEgressService:
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
@staticmethod
|
||||
def _log_egress_error(response, event: str):
|
||||
"""Log the reason LiveKit reported an unsuccessful egress on stop.
|
||||
|
||||
Mirrors the logging done in the 'egress_ended' webhook. The
|
||||
StopEgress response carries the same error fields.
|
||||
"""
|
||||
logger.error(
|
||||
"Egress %s on stop (egress_id=%s, status=%s): %s (error_code=%s)",
|
||||
event,
|
||||
response.egress_id,
|
||||
livekit_api.EgressStatus.Name(response.status),
|
||||
response.error or "no error reported",
|
||||
response.error_code or "no error_code reported",
|
||||
)
|
||||
|
||||
def stop(self, worker_id: str) -> str:
|
||||
"""Stop an ongoing egress worker.
|
||||
The StopEgressRequest is shared among all types of egress,
|
||||
@@ -66,38 +86,55 @@ class BaseEgressService:
|
||||
"LiveKit response is missing the recording status."
|
||||
)
|
||||
|
||||
# To avoid exposing EgressStatus values and coupling with LiveKit outside of this class,
|
||||
# the response status is mapped to simpler "ABORTED", "STOPPED" or "FAILED_TO_STOP" strings.
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_ABORTED:
|
||||
return "ABORTED"
|
||||
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_ENDING:
|
||||
return "STOPPED"
|
||||
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_LIMIT_REACHED:
|
||||
return "STOPPED"
|
||||
|
||||
# Cases below should be very infrequent as status changes should be
|
||||
# received and processed by `handle_ended`, thus `stop` would not
|
||||
# be called (unless failure and stop are very close in time).
|
||||
# We therefore accept not to notify the user in this code branch.
|
||||
# This could be fixed in a future refactoring.
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_ABORTED:
|
||||
self._log_egress_error(response, "aborted")
|
||||
return "ABORTED"
|
||||
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_FAILED:
|
||||
self._log_egress_error(response, "failed")
|
||||
return "FAILED"
|
||||
|
||||
self._log_egress_error(response, "failed to stop")
|
||||
return "FAILED_TO_STOP"
|
||||
|
||||
def start(self, room_name, recording_id):
|
||||
def start(self, room_name, recording_id, encoding_options=None):
|
||||
"""Start the egress process for a recording (not implemented in the base class).
|
||||
Each derived class must implement this method, providing the necessary parameters for
|
||||
its specific egress type (e.g. audio_only, streaming output).
|
||||
"""
|
||||
raise NotImplementedError("Subclass must implement this method.")
|
||||
|
||||
def _build_encoding_options(self):
|
||||
"""Build a LiveKit EncodingOptions from the service config, or None.
|
||||
def _resolve_encoding_options(self, encoding_options):
|
||||
"""Build a LiveKit EncodingOptions from a resolved kwargs dict, or None.
|
||||
|
||||
``encoding_options`` is the per-recording dict persisted by the API in
|
||||
``recording.options["encoding"]["resolved"]``; it falls back to the
|
||||
default encoding carried by the service config.
|
||||
|
||||
When None is returned, the caller should omit the `advanced` field so
|
||||
LiveKit Egress falls back to its built-in preset (H264_720P_30).
|
||||
|
||||
The full EncodingOptions kwargs (operator-tunable values + pinned
|
||||
codec / frequency constants) are assembled in `WorkerServiceConfig`,
|
||||
so this method is a thin protobuf adapter.
|
||||
"""
|
||||
opts = self._config.encoding_options
|
||||
if not opts:
|
||||
encoding_options = encoding_options or self._config.encoding_options
|
||||
if not encoding_options:
|
||||
return None
|
||||
|
||||
return livekit_api.EncodingOptions(**opts)
|
||||
try:
|
||||
return livekit_api.EncodingOptions(**encoding_options)
|
||||
except (TypeError, ValueError) as e:
|
||||
# Protobuf raises TypeError on a wrongly typed value (e.g. a float
|
||||
# framerate) and ValueError on an unknown field or an out-of-range int.
|
||||
raise WorkerRequestError(f"Invalid encoding options: {e}") from e
|
||||
|
||||
|
||||
class VideoCompositeEgressService(BaseEgressService):
|
||||
@@ -105,7 +142,7 @@ class VideoCompositeEgressService(BaseEgressService):
|
||||
|
||||
hrid = "video-recording-composite-livekit-egress"
|
||||
|
||||
def start(self, room_name, recording_id):
|
||||
def start(self, room_name, recording_id, encoding_options=None):
|
||||
"""Start the video composite egress process for a recording."""
|
||||
|
||||
# Save room's recording as a mp4 video file.
|
||||
@@ -126,7 +163,7 @@ class VideoCompositeEgressService(BaseEgressService):
|
||||
"layout": "speaker-light",
|
||||
}
|
||||
|
||||
advanced = self._build_encoding_options()
|
||||
advanced = self._resolve_encoding_options(encoding_options)
|
||||
if advanced is not None:
|
||||
request_kwargs["advanced"] = advanced
|
||||
|
||||
@@ -145,8 +182,13 @@ class AudioCompositeEgressService(BaseEgressService):
|
||||
|
||||
hrid = "audio-recording-composite-livekit-egress"
|
||||
|
||||
def start(self, room_name, recording_id):
|
||||
"""Start the audio composite egress process for a recording."""
|
||||
def start(self, room_name, recording_id, encoding_options=None):
|
||||
"""Start the audio composite egress process for a recording.
|
||||
|
||||
``encoding_options`` is accepted for signature compatibility with the
|
||||
WorkerService protocol but ignored: audio-only egress has no
|
||||
encoding to configure.
|
||||
"""
|
||||
|
||||
# Save room's recording as an ogg audio file.
|
||||
file_type = livekit_api.EncodedFileType.OGG
|
||||
|
||||
@@ -8,19 +8,17 @@ from enum import Enum
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from livekit import api
|
||||
|
||||
from core import models
|
||||
from core.recording.enums import RecordingWorkerEvent
|
||||
from core.recording.services.metadata_collector import (
|
||||
MetadataCollectorException,
|
||||
MetadataCollectorService,
|
||||
)
|
||||
from core.recording.services.recording_events import (
|
||||
RecordingEventsError,
|
||||
RecordingEventsService,
|
||||
RecordingNotSavableError,
|
||||
)
|
||||
from core.recording.services.recording_events import RecordingEventsService
|
||||
|
||||
from .lobby import LobbyService
|
||||
from .presence import PresenceCache
|
||||
@@ -52,12 +50,6 @@ class InvalidPayloadError(LiveKitWebhookError):
|
||||
status_code = 400
|
||||
|
||||
|
||||
class UnsupportedEventTypeError(LiveKitWebhookError):
|
||||
"""Unsupported event type."""
|
||||
|
||||
status_code = 422
|
||||
|
||||
|
||||
class ActionFailedError(LiveKitWebhookError):
|
||||
"""Webhook action fails to process or complete."""
|
||||
|
||||
@@ -74,6 +66,7 @@ class LiveKitWebhookEventType(Enum):
|
||||
# Participant events
|
||||
PARTICIPANT_JOINED = "participant_joined"
|
||||
PARTICIPANT_LEFT = "participant_left"
|
||||
PARTICIPANT_CONNECTION_ABORTED = "participant_connection_aborted"
|
||||
|
||||
# Track events
|
||||
TRACK_PUBLISHED = "track_published"
|
||||
@@ -89,6 +82,30 @@ class LiveKitWebhookEventType(Enum):
|
||||
INGRESS_ENDED = "ingress_ended"
|
||||
|
||||
|
||||
# LiveKit egress statuses mapped to recording worker event statuses
|
||||
EGRESS_STATUS_TO_RECORDING_EVENT = {
|
||||
api.EgressStatus.EGRESS_STARTING: RecordingWorkerEvent.STARTING,
|
||||
api.EgressStatus.EGRESS_ACTIVE: RecordingWorkerEvent.STARTED,
|
||||
api.EgressStatus.EGRESS_ENDING: RecordingWorkerEvent.SAVING,
|
||||
api.EgressStatus.EGRESS_COMPLETE: RecordingWorkerEvent.COMPLETED,
|
||||
api.EgressStatus.EGRESS_LIMIT_REACHED: RecordingWorkerEvent.LIMIT_REACHED,
|
||||
api.EgressStatus.EGRESS_ABORTED: RecordingWorkerEvent.ABORTED,
|
||||
api.EgressStatus.EGRESS_FAILED: RecordingWorkerEvent.FAILED,
|
||||
}
|
||||
|
||||
|
||||
def to_recording_event(egress_status):
|
||||
"""Translate a LiveKit egress status into a recording worker event."""
|
||||
|
||||
event = EGRESS_STATUS_TO_RECORDING_EVENT.get(egress_status)
|
||||
if event is None:
|
||||
logger.warning(
|
||||
"Unmapped LiveKit egress status '%s', ignoring the event.",
|
||||
egress_status,
|
||||
)
|
||||
return event
|
||||
|
||||
|
||||
class LiveKitEventsService:
|
||||
"""Service for processing and handling LiveKit webhook events and notifications."""
|
||||
|
||||
@@ -153,10 +170,13 @@ class LiveKitEventsService:
|
||||
|
||||
try:
|
||||
webhook_type = LiveKitWebhookEventType(data.event)
|
||||
except ValueError as e:
|
||||
raise UnsupportedEventTypeError(
|
||||
f"Unknown webhook type: {data.event}"
|
||||
) from e
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Ignoring unknown LiveKit webhook event type '%s' for room '%s'",
|
||||
data.event,
|
||||
room_name,
|
||||
)
|
||||
return
|
||||
|
||||
# Handle according to received webhook type
|
||||
handler = self._webhook_handlers.get(webhook_type.value)
|
||||
@@ -175,12 +195,20 @@ class LiveKitEventsService:
|
||||
f"Recording with worker ID {egress_id} does not exist"
|
||||
) from err
|
||||
|
||||
egress_status = data.egress_info.status
|
||||
self.recording_events.handle_update(recording, egress_status)
|
||||
event = to_recording_event(data.egress_info.status)
|
||||
if event is None:
|
||||
return
|
||||
|
||||
self.recording_events.handle_update(recording, event)
|
||||
|
||||
def _handle_egress_ended(self, data):
|
||||
"""Handle 'egress_ended' event."""
|
||||
"""Handle 'egress_ended' event.
|
||||
|
||||
Egress ended is sent with one of these statuses:
|
||||
EGRESS_COMPLETE, EGRESS_FAILED, EGRESS_ABORTED, EGRESS_LIMIT_REACHED
|
||||
"""
|
||||
|
||||
# Fetch recording
|
||||
try:
|
||||
recording = models.Recording.objects.select_related("room").get(
|
||||
worker_id=data.egress_info.egress_id
|
||||
@@ -190,6 +218,17 @@ class LiveKitEventsService:
|
||||
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
|
||||
) from err
|
||||
|
||||
event = to_recording_event(data.egress_info.status)
|
||||
|
||||
# Log if/why the recording failed
|
||||
self.recording_events.log_worker_error(
|
||||
recording,
|
||||
event,
|
||||
error=data.egress_info.error,
|
||||
error_code=data.egress_info.error_code,
|
||||
)
|
||||
|
||||
# Update room
|
||||
try:
|
||||
room_name = str(recording.room.id)
|
||||
RoomManagement.update_metadata(
|
||||
@@ -203,38 +242,17 @@ class LiveKitEventsService:
|
||||
except RoomManagementException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
# Stop metadata collector
|
||||
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
||||
try:
|
||||
MetadataCollectorService().stop(recording)
|
||||
except MetadataCollectorException:
|
||||
logger.warning("Failed to stop the MetadataCollectorService")
|
||||
|
||||
if (
|
||||
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
|
||||
and recording.status == models.RecordingStatusChoices.ACTIVE
|
||||
):
|
||||
try:
|
||||
self.recording_events.handle_limit_reached(recording)
|
||||
except RecordingEventsError as e:
|
||||
raise ActionFailedError(
|
||||
f"Failed to process limit reached event for recording {recording}"
|
||||
) from e
|
||||
if event is None:
|
||||
return
|
||||
|
||||
# Finalize the recording, the egress has uploaded the file to the storage
|
||||
if data.egress_info.status in [
|
||||
api.EgressStatus.EGRESS_COMPLETE,
|
||||
api.EgressStatus.EGRESS_LIMIT_REACHED,
|
||||
]:
|
||||
try:
|
||||
self.recording_events.handle_complete(recording)
|
||||
except RecordingNotSavableError:
|
||||
logger.warning(
|
||||
"Recording %s is not savable on egress complete "
|
||||
"(already saved or in an error state); ignoring.",
|
||||
recording.id,
|
||||
)
|
||||
|
||||
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
|
||||
self.recording_events.handle_terminal_event(recording, event)
|
||||
|
||||
@staticmethod
|
||||
def _is_connection_test_room(room_name: str) -> bool:
|
||||
@@ -253,12 +271,20 @@ class LiveKitEventsService:
|
||||
)
|
||||
raise ActionFailedError("Failed to process room started event") from e
|
||||
|
||||
try:
|
||||
room = models.Room.objects.get(id=room_id)
|
||||
except models.Room.DoesNotExist as err:
|
||||
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
||||
room_updated_count = models.Room.objects.filter(pk=room_id).update(
|
||||
last_started_at=timezone.now()
|
||||
)
|
||||
if not room_updated_count:
|
||||
raise ActionFailedError(f"Room with ID {room_id} does not exist")
|
||||
|
||||
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
||||
try:
|
||||
room = models.Room.objects.get(pk=room_id)
|
||||
except models.Room.DoesNotExist as err:
|
||||
raise ActionFailedError(
|
||||
f"Room with ID {room_id} does not exist"
|
||||
) from err
|
||||
|
||||
try:
|
||||
self.sip_management.ensure_dispatch_rule(room)
|
||||
except SIPException as e:
|
||||
|
||||
@@ -87,17 +87,15 @@ class ProvisionalUserService:
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
logger.info(
|
||||
"Provisional user created via application: user_id=%s, email=%s, client_id=%s",
|
||||
"Provisional user created via application: user_id=%s, client_id=%s",
|
||||
user.id,
|
||||
email,
|
||||
client_id,
|
||||
)
|
||||
return user, True
|
||||
except (IntegrityError, ValidationError) as e:
|
||||
logger.warning(
|
||||
"Race condition on provisional user creation, fetching existing: "
|
||||
"email=%s, client_id=%s",
|
||||
email,
|
||||
"client_id=%s",
|
||||
client_id,
|
||||
)
|
||||
user = self._get_by_email(email)
|
||||
|
||||
@@ -76,10 +76,6 @@ class RoomManagement:
|
||||
|
||||
except TwirpError as e:
|
||||
if e.code == "not_found":
|
||||
logger.warning(
|
||||
"Room %s not found in LiveKit, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
raise RoomNotFoundException("Room does not exist") from e
|
||||
|
||||
logger.exception(
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the audit logging facility."""
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Tests for the audit of writes made through the Django admin."""
|
||||
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
|
||||
from core import models
|
||||
from core.audit.testing import find_events
|
||||
from core.factories import (
|
||||
FileFactory,
|
||||
RecordingFactory,
|
||||
RoomFactory,
|
||||
UserFactory,
|
||||
UserResourceAccessFactory,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
# Admin pages render static files: serve them without a manifest.
|
||||
plain_storages = override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="staff_client")
|
||||
def staff_client_fixture(client):
|
||||
"""A client signed in as a superuser, able to reach every admin page."""
|
||||
client.force_login(UserFactory(is_staff=True, is_superuser=True))
|
||||
return client
|
||||
|
||||
|
||||
def room_payload(room=None, accesses=0, **overrides):
|
||||
"""Return what the room change form expects, inline management included."""
|
||||
payload = {
|
||||
"name": room.name if room else "Weekly sync",
|
||||
"slug": room.slug if room else "weekly-sync",
|
||||
"access_level": room.access_level if room else models.RoomAccessLevel.PUBLIC,
|
||||
"configuration": "{}",
|
||||
# ``configuration`` has a callable default, so the form renders a hidden
|
||||
# ``initial-`` input. Without it the field always looks changed.
|
||||
"initial-configuration": "{}",
|
||||
"pin_code": (room.pin_code if room else None) or "",
|
||||
"accesses-TOTAL_FORMS": str(accesses),
|
||||
"accesses-INITIAL_FORMS": "0",
|
||||
"accesses-MIN_NUM_FORMS": "0",
|
||||
"accesses-MAX_NUM_FORMS": "1000",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def test_room_creation_is_audited(audit_events, staff_client):
|
||||
"""Adding a room through the admin records a creation on the room."""
|
||||
response = staff_client.post("/admin/core/room/add/", room_payload())
|
||||
|
||||
assert response.status_code == 302
|
||||
[event] = find_events(audit_events, "admin.room.create")
|
||||
assert event["event"]["category"] == ["configuration"]
|
||||
assert event["event"]["type"] == ["creation"]
|
||||
assert event["event"]["outcome"] == "success"
|
||||
assert event["lasuite"]["target"]["type"] == "room"
|
||||
assert event["lasuite"]["target"]["slug"] == "weekly-sync"
|
||||
assert event["lasuite"]["details"]["changes"]["name"] == {"to": "Weekly sync"}
|
||||
|
||||
|
||||
def test_room_change_records_field_names_and_allowed_values(audit_events, staff_client):
|
||||
"""A change reports the raw field names, and the values of allowed fields."""
|
||||
room = RoomFactory(access_level=models.RoomAccessLevel.PUBLIC)
|
||||
|
||||
response = staff_client.post(
|
||||
f"/admin/core/room/{room.pk}/change/",
|
||||
room_payload(room, access_level=models.RoomAccessLevel.RESTRICTED),
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
[event] = find_events(audit_events, "admin.room.update")
|
||||
assert event["event"]["type"] == ["change"]
|
||||
assert event["lasuite"]["details"]["changed_fields"] == ["access_level"]
|
||||
assert event["lasuite"]["details"]["changes"] == {
|
||||
"access_level": {"from": "public", "to": "restricted"}
|
||||
}
|
||||
|
||||
|
||||
def test_room_configuration_change_records_both_versions(audit_events, staff_client):
|
||||
"""The configuration is allow-listed, and kept as JSON rather than stringified.
|
||||
|
||||
It is a free-form ``JSONField``: both versions are recorded whole, there is
|
||||
no delta.
|
||||
"""
|
||||
room = RoomFactory(configuration={"a": 1})
|
||||
|
||||
response = staff_client.post(
|
||||
f"/admin/core/room/{room.pk}/change/",
|
||||
room_payload(
|
||||
room,
|
||||
configuration='{"a": 2, "b": "new"}',
|
||||
**{"initial-configuration": '{"a": 1}'},
|
||||
),
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
[event] = find_events(audit_events, "admin.room.update")
|
||||
assert event["lasuite"]["details"]["changed_fields"] == ["configuration"]
|
||||
assert event["lasuite"]["details"]["changes"]["configuration"] == {
|
||||
"from": {"a": 1},
|
||||
"to": {"a": 2, "b": "new"},
|
||||
}
|
||||
|
||||
|
||||
def test_room_deletion_is_audited(audit_events, staff_client):
|
||||
"""Deleting a room from its own page records a deletion."""
|
||||
room = RoomFactory()
|
||||
|
||||
response = staff_client.post(f"/admin/core/room/{room.pk}/delete/", {"post": "yes"})
|
||||
|
||||
assert response.status_code == 302
|
||||
[event] = find_events(audit_events, "admin.room.delete")
|
||||
assert event["event"]["type"] == ["deletion"]
|
||||
assert event["lasuite"]["target"]["id"] == str(room.pk)
|
||||
|
||||
|
||||
def test_inline_access_grant_emits_its_own_iam_event(audit_events, staff_client):
|
||||
"""A role granted through the inline is an IAM event of its own."""
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
|
||||
response = staff_client.post(
|
||||
f"/admin/core/room/{room.pk}/change/",
|
||||
room_payload(
|
||||
room,
|
||||
accesses=1,
|
||||
**{
|
||||
"accesses-0-user": str(user.pk),
|
||||
"accesses-0-role": models.RoleChoices.OWNER,
|
||||
"accesses-0-id": "",
|
||||
"accesses-0-resource": str(room.pk),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
[room_event] = find_events(audit_events, "admin.room.update")
|
||||
[access_event] = find_events(audit_events, "admin.resourceaccess.create")
|
||||
assert access_event["event"]["category"] == ["iam"]
|
||||
assert access_event["event"]["type"] == ["creation"]
|
||||
assert access_event["lasuite"]["target"]["role"] == "owner"
|
||||
assert access_event["lasuite"]["details"]["changes"]["role"] == {"to": "owner"}
|
||||
# The grant and the room change belong to the same request.
|
||||
assert access_event["trace"]["id"] == room_event["trace"]["id"]
|
||||
|
||||
|
||||
def test_user_change_targets_the_user_and_never_leaks_the_password(
|
||||
audit_events, staff_client
|
||||
):
|
||||
"""Promoting a user is an IAM event naming the account, never its secret."""
|
||||
user = UserFactory(email="promoted@example.com", is_staff=False)
|
||||
|
||||
response = staff_client.post(
|
||||
f"/admin/core/user/{user.pk}/password/",
|
||||
{
|
||||
"usable_password": "true",
|
||||
"password1": "sup3r-s3cret-value",
|
||||
"password2": "sup3r-s3cret-value",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
[event] = find_events(audit_events, "admin.user.update")
|
||||
assert event["event"]["category"] == ["iam"]
|
||||
assert event["event"]["type"] == ["user", "change"]
|
||||
assert event["user"]["target"] == {
|
||||
"id": str(user.pk),
|
||||
"sub": user.sub,
|
||||
"domain": "example.com",
|
||||
}
|
||||
# Django reports a password change as the single field ``password``.
|
||||
assert event["lasuite"]["details"]["changed_fields"] == ["password"]
|
||||
assert "changes" not in event["lasuite"]["details"]
|
||||
assert "sup3r-s3cret-value" not in json.dumps(event)
|
||||
|
||||
|
||||
def test_user_permission_change_records_the_flag_values(audit_events, staff_client):
|
||||
"""Staff and superuser flags are allow-listed, so their values are kept."""
|
||||
user = UserFactory(is_staff=False, is_superuser=False)
|
||||
|
||||
response = staff_client.post(
|
||||
f"/admin/core/user/{user.pk}/change/",
|
||||
{
|
||||
"admin_email": "",
|
||||
"language": user.language,
|
||||
"timezone": str(user.timezone),
|
||||
"is_active": "on",
|
||||
"is_staff": "on",
|
||||
"files_created-TOTAL_FORMS": "0",
|
||||
"files_created-INITIAL_FORMS": "0",
|
||||
"files_created-MIN_NUM_FORMS": "0",
|
||||
"files_created-MAX_NUM_FORMS": "1000",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
[event] = find_events(audit_events, "admin.user.update")
|
||||
assert event["lasuite"]["details"]["changes"]["is_staff"] == {
|
||||
"from": False,
|
||||
"to": True,
|
||||
}
|
||||
assert event["user"]["target"]["id"] == str(user.pk)
|
||||
|
||||
|
||||
def test_bulk_delete_audits_each_object_but_not_the_action(audit_events, staff_client):
|
||||
"""``delete_selected`` reports its objects, and nothing about itself."""
|
||||
recordings = RecordingFactory.create_batch(2)
|
||||
|
||||
response = staff_client.post(
|
||||
"/admin/core/recording/",
|
||||
{
|
||||
"action": "delete_selected",
|
||||
"_selected_action": [str(recording.pk) for recording in recordings],
|
||||
"post": "yes",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
events = find_events(audit_events, "admin.recording.delete")
|
||||
assert {event["lasuite"]["target"]["id"] for event in events} == {
|
||||
str(recording.pk) for recording in recordings
|
||||
}
|
||||
assert find_events(audit_events, "admin.recording.action") == []
|
||||
|
||||
|
||||
def test_custom_action_is_audited(audit_events, staff_client):
|
||||
"""A custom admin action reports its name and how many objects it ran on."""
|
||||
recordings = RecordingFactory.create_batch(2)
|
||||
|
||||
response = staff_client.post(
|
||||
"/admin/core/recording/",
|
||||
{
|
||||
"action": "mark_as_failed_to_stop",
|
||||
"_selected_action": [str(recording.pk) for recording in recordings],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
[event] = find_events(audit_events, "admin.recording.action")
|
||||
assert event["event"]["outcome"] == "success"
|
||||
assert event["lasuite"]["details"] == {
|
||||
"admin_action": "mark_as_failed_to_stop",
|
||||
"count": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_hard_deleted_file_is_audited_once(audit_events, staff_client):
|
||||
"""``FileAdmin`` hard deletes without going through ``Model.delete``."""
|
||||
file = FileFactory()
|
||||
|
||||
response = staff_client.post(f"/admin/core/file/{file.pk}/delete/", {"post": "yes"})
|
||||
|
||||
assert response.status_code == 302
|
||||
[event] = find_events(audit_events, "admin.file.delete")
|
||||
assert event["lasuite"]["target"]["id"] == str(file.pk)
|
||||
|
||||
|
||||
def test_failed_deletion_is_audited_as_a_failure(audit_events, staff_client):
|
||||
"""A deletion that raises is recorded as failed, never as done."""
|
||||
file = FileFactory()
|
||||
|
||||
with (
|
||||
mock.patch("core.admin.hard_delete_file", side_effect=RuntimeError("S3 down")),
|
||||
pytest.raises(RuntimeError),
|
||||
):
|
||||
staff_client.post(f"/admin/core/file/{file.pk}/delete/", {"post": "yes"})
|
||||
|
||||
[event] = find_events(audit_events, "admin.file.delete")
|
||||
assert event["event"]["type"] == ["deletion"]
|
||||
assert event["event"]["reason"] == "internal_error"
|
||||
assert event["lasuite"]["outcome"] == "failure"
|
||||
assert event["lasuite"]["target"]["id"] == str(file.pk)
|
||||
assert event["error"] == {"message": "S3 down"}
|
||||
assert event["log"]["level"] == "error"
|
||||
|
||||
|
||||
def test_failed_bulk_deletion_is_audited_as_a_failure(audit_events, staff_client):
|
||||
"""A bulk deletion that raises reports every selected object as failed."""
|
||||
files = FileFactory.create_batch(2)
|
||||
|
||||
with (
|
||||
mock.patch("core.admin.hard_delete_file", side_effect=RuntimeError("S3 down")),
|
||||
pytest.raises(RuntimeError),
|
||||
):
|
||||
staff_client.post(
|
||||
"/admin/core/file/",
|
||||
{
|
||||
"action": "delete_selected",
|
||||
"_selected_action": [str(file.pk) for file in files],
|
||||
"post": "yes",
|
||||
},
|
||||
)
|
||||
|
||||
events = find_events(audit_events, "admin.file.delete")
|
||||
assert {event["lasuite"]["target"]["id"] for event in events} == {
|
||||
str(file.pk) for file in files
|
||||
}
|
||||
assert {event["lasuite"]["outcome"] for event in events} == {"failure"}
|
||||
|
||||
|
||||
@plain_storages
|
||||
def test_reading_the_admin_emits_nothing(audit_events, staff_client):
|
||||
"""Browsing is not audited: only writes are."""
|
||||
room = RoomFactory()
|
||||
UserResourceAccessFactory(resource=room, user=UserFactory())
|
||||
|
||||
assert staff_client.get("/admin/").status_code == 200
|
||||
assert staff_client.get("/admin/core/room/").status_code == 200
|
||||
assert staff_client.get(f"/admin/core/room/{room.pk}/change/").status_code == 200
|
||||
assert staff_client.get(f"/admin/core/room/{room.pk}/history/").status_code == 200
|
||||
|
||||
assert [
|
||||
event["event"]["action"]
|
||||
for event in audit_events
|
||||
if event["event"]["action"].startswith("admin.")
|
||||
] == []
|
||||
|
||||
|
||||
def test_non_staff_user_reaching_the_admin_is_recorded(audit_events, client):
|
||||
"""A signed-in account without staff access trying the admin is a denial."""
|
||||
client.force_login(UserFactory(is_staff=False))
|
||||
|
||||
response = client.get("/admin/core/room/")
|
||||
|
||||
assert response.status_code == 302
|
||||
[event] = find_events(audit_events, "admin.access")
|
||||
assert event["event"]["category"] == ["iam"]
|
||||
assert event["event"]["reason"] == "permission_denied"
|
||||
assert event["lasuite"]["outcome"] == "denied"
|
||||
assert event["lasuite"]["auth"] == {"method": "session"}
|
||||
assert event["http"]["response"] == {"status_code": 302}
|
||||
assert event["log"]["level"] == "warning"
|
||||
|
||||
|
||||
@plain_storages
|
||||
def test_non_staff_user_is_recorded_once_per_refused_view(audit_events, client):
|
||||
"""Landing on the login page after the refusal records nothing more."""
|
||||
client.force_login(UserFactory(is_staff=False))
|
||||
|
||||
response = client.get("/admin/", follow=True)
|
||||
|
||||
assert response.redirect_chain[-1][0].startswith("/admin/login/")
|
||||
assert response.status_code == 200
|
||||
assert len(find_events(audit_events, "admin.access")) == 1
|
||||
|
||||
|
||||
def test_anonymous_visitor_is_not_recorded(audit_events, client):
|
||||
"""An anonymous hit is a redirect to the login page, not a denial worth keeping."""
|
||||
assert client.get("/admin/").status_code == 302
|
||||
|
||||
assert find_events(audit_events, "admin.access") == []
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Tests for the audit of DRF views through ``AuditViewMixin``."""
|
||||
|
||||
# The viewsets below stand in for real ones, one behaviour each.
|
||||
# pylint: disable=missing-function-docstring,unused-argument
|
||||
|
||||
from django.core.exceptions import PermissionDenied as DjangoPermissionDenied
|
||||
|
||||
import pytest
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
exceptions,
|
||||
mixins,
|
||||
permissions,
|
||||
routers,
|
||||
viewsets,
|
||||
)
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
from core import audit, models
|
||||
from core.audit.testing import find_events
|
||||
from core.authentication.backends import SessionAuthenticationWith401
|
||||
from core.factories import RoomFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
class ThingViewSet(audit.AuditViewMixin, viewsets.ViewSet):
|
||||
"""A viewset auditing ``list`` and ``create`` but not ``destroy``."""
|
||||
|
||||
# Without an authenticator to challenge with, DRF turns a 401 into a 403.
|
||||
authentication_classes = [SessionAuthenticationWith401]
|
||||
permission_classes = []
|
||||
audit_actions = {"list": "thing.list", "create": "thing.create"}
|
||||
error = None
|
||||
|
||||
def list(self, request):
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
self.audit_details = {"total": 3}
|
||||
return Response([])
|
||||
|
||||
def create(self, request):
|
||||
return Response({"error": "Already exists."}, status=409)
|
||||
|
||||
def destroy(self, request, pk=None):
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
class RoomViewSet(
|
||||
audit.AuditViewMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
|
||||
):
|
||||
"""A viewset whose target comes from ``get_object``."""
|
||||
|
||||
authentication_classes = []
|
||||
permission_classes = []
|
||||
queryset = models.Room.objects.all()
|
||||
audit_actions = {"retrieve": "room.retrieve"}
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
return type("Serializer", (), {"data": {}})()
|
||||
|
||||
|
||||
GRANT = audit.Action(
|
||||
"thing.grant",
|
||||
category=audit.EventCategory.IAM,
|
||||
types=(audit.EventType.CREATION,),
|
||||
)
|
||||
|
||||
|
||||
class GrantViewSet(audit.AuditViewMixin, viewsets.ViewSet):
|
||||
"""A viewset whose extra action declares its audit on the route."""
|
||||
|
||||
authentication_classes = [SessionAuthenticationWith401]
|
||||
permission_classes = []
|
||||
error = None
|
||||
|
||||
@decorators.action(detail=False, methods=["post"], audit_action=GRANT)
|
||||
def grant(self, request):
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return Response({})
|
||||
|
||||
@decorators.action(detail=False, methods=["post"])
|
||||
def ping(self, request):
|
||||
return Response({})
|
||||
|
||||
|
||||
class DenyObjects(permissions.BasePermission):
|
||||
"""Refuse every object, whatever the request."""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
return False
|
||||
|
||||
|
||||
def test_success_is_audited_with_the_view_details(audit_events):
|
||||
"""A successful action is recorded with its type and the view's details."""
|
||||
view = ThingViewSet.as_view({"get": "list"})
|
||||
|
||||
response = view(APIRequestFactory().get("/things/", REMOTE_ADDR="1.2.3.4"))
|
||||
|
||||
assert response.status_code == 200
|
||||
[event] = find_events(audit_events, "thing.list")
|
||||
assert event["event"]["category"] == ["api"]
|
||||
assert event["event"]["type"] == ["access"]
|
||||
assert event["event"]["outcome"] == "success"
|
||||
assert event["lasuite"]["details"] == {"total": 3}
|
||||
assert event["client"] == {"ip": "1.2.3.4"}
|
||||
assert event["url"] == {"path": "/things/"}
|
||||
assert event["http"] == {"request": {"method": "GET"}}
|
||||
|
||||
|
||||
def test_missing_credentials_are_audited_as_authentication_denial(audit_events):
|
||||
"""A 401 is a denial in the authentication category."""
|
||||
view = ThingViewSet.as_view({"get": "list"}, error=exceptions.NotAuthenticated())
|
||||
|
||||
response = view(APIRequestFactory().get("/things/"))
|
||||
|
||||
assert response.status_code == 401
|
||||
[event] = find_events(audit_events, "thing.list")
|
||||
assert event["event"]["category"] == ["authentication"]
|
||||
assert event["event"]["type"] == ["access", "denied"]
|
||||
assert event["event"]["reason"] == "authentication_failed"
|
||||
assert event["lasuite"]["outcome"] == "denied"
|
||||
assert event["lasuite"]["actor"] == {"type": "anonymous"}
|
||||
assert event["http"]["response"] == {"status_code": 401}
|
||||
assert event["error"]["message"] == "Authentication credentials were not provided."
|
||||
assert event["log"]["level"] == "warning"
|
||||
assert "details" not in event["lasuite"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error,status_code,outcome,reason",
|
||||
[
|
||||
(
|
||||
exceptions.AuthenticationFailed("bad"),
|
||||
401,
|
||||
"denied",
|
||||
"authentication_failed",
|
||||
),
|
||||
(exceptions.PermissionDenied("scope"), 403, "denied", "permission_denied"),
|
||||
(DjangoPermissionDenied("nope"), 403, "denied", "permission_denied"),
|
||||
(exceptions.Throttled(wait=10), 429, "denied", "rate_limited"),
|
||||
(
|
||||
exceptions.ValidationError({"name": ["x"]}),
|
||||
400,
|
||||
"failure",
|
||||
"validation_error",
|
||||
),
|
||||
(exceptions.NotFound(), 404, "failure", "not_found"),
|
||||
(exceptions.MethodNotAllowed("PUT"), 405, "failure", None),
|
||||
],
|
||||
)
|
||||
def test_errors_are_audited_from_the_status_code(
|
||||
audit_events, error, status_code, outcome, reason
|
||||
):
|
||||
"""The outcome and the reason are derived from the response status."""
|
||||
view = ThingViewSet.as_view({"get": "list"}, error=error)
|
||||
|
||||
response = view(APIRequestFactory().get("/things/"))
|
||||
|
||||
assert response.status_code == status_code
|
||||
[event] = find_events(audit_events, "thing.list")
|
||||
assert event["lasuite"]["outcome"] == outcome
|
||||
assert event["event"].get("reason") == reason
|
||||
assert event["http"]["response"] == {"status_code": status_code}
|
||||
|
||||
|
||||
def test_error_message_of_a_view_response(audit_events):
|
||||
"""An error response built by the view reports its ``error`` message."""
|
||||
view = ThingViewSet.as_view({"post": "create"})
|
||||
|
||||
response = view(APIRequestFactory().post("/things/"))
|
||||
|
||||
assert response.status_code == 409
|
||||
[event] = find_events(audit_events, "thing.create")
|
||||
assert event["event"]["type"] == ["creation"]
|
||||
assert event["event"]["reason"] == "conflict"
|
||||
assert event["lasuite"]["outcome"] == "failure"
|
||||
assert event["error"] == {"message": "Already exists."}
|
||||
|
||||
|
||||
def test_actions_missing_from_the_map_are_not_audited(audit_events):
|
||||
"""Only the actions listed in ``audit_actions`` emit events."""
|
||||
view = ThingViewSet.as_view({"delete": "destroy"})
|
||||
|
||||
response = view(APIRequestFactory().delete("/things/1/"), pk="1")
|
||||
|
||||
assert response.status_code == 204
|
||||
assert audit_events == []
|
||||
|
||||
|
||||
def test_unhandled_exception_is_audited_as_internal_error(audit_events):
|
||||
"""An exception DRF does not handle is recorded, by class only, then raised."""
|
||||
view = ThingViewSet.as_view(
|
||||
{"get": "list"}, error=RuntimeError("jane@example.org is broken")
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
view(APIRequestFactory().get("/things/"))
|
||||
|
||||
[event] = find_events(audit_events, "thing.list")
|
||||
assert event["event"]["type"] == ["access"]
|
||||
assert event["event"]["reason"] == "internal_error"
|
||||
assert event["lasuite"]["outcome"] == "failure"
|
||||
assert event["http"]["response"] == {"status_code": 500}
|
||||
assert event["error"] == {"type": "builtins.RuntimeError"}
|
||||
assert event["log"]["level"] == "error"
|
||||
assert "jane@example.org" not in str(event)
|
||||
|
||||
|
||||
def test_object_permission_denial_keeps_the_target(audit_events):
|
||||
"""A refusal on a detail route names the object that was refused."""
|
||||
room = RoomFactory()
|
||||
view = RoomViewSet.as_view({"get": "retrieve"}, permission_classes=[DenyObjects])
|
||||
|
||||
response = view(APIRequestFactory().get(f"/rooms/{room.pk}/"), pk=str(room.pk))
|
||||
|
||||
assert response.status_code == 403
|
||||
[event] = find_events(audit_events, "room.retrieve")
|
||||
assert event["lasuite"]["outcome"] == "denied"
|
||||
assert event["lasuite"]["target"]["id"] == str(room.pk)
|
||||
|
||||
|
||||
def test_object_of_a_detail_route_is_the_target(audit_events):
|
||||
"""The object of a detail route becomes the target of the event."""
|
||||
room = RoomFactory()
|
||||
view = RoomViewSet.as_view({"get": "retrieve"})
|
||||
|
||||
response = view(APIRequestFactory().get(f"/rooms/{room.pk}/"), pk=str(room.pk))
|
||||
|
||||
assert response.status_code == 200
|
||||
[event] = find_events(audit_events, "room.retrieve")
|
||||
assert event["lasuite"]["target"]["id"] == str(room.pk)
|
||||
assert event["lasuite"]["target"]["type"] == "room"
|
||||
|
||||
|
||||
def test_extra_actions_cannot_be_mapped_by_method_name():
|
||||
"""Renaming a method must not silently stop auditing it."""
|
||||
with pytest.raises(TypeError, match="grant"):
|
||||
|
||||
class MappedViewSet(audit.AuditViewMixin, viewsets.ViewSet): # pylint: disable=unused-variable
|
||||
"""Maps an extra action in ``audit_actions``."""
|
||||
|
||||
audit_actions = {"list": "thing.list", "grant": "thing.grant"}
|
||||
|
||||
|
||||
def test_extra_action_is_audited_from_its_route(audit_events):
|
||||
"""The ``audit_action`` of a routed ``@action`` names the event."""
|
||||
router = routers.SimpleRouter()
|
||||
router.register("things", GrantViewSet, basename="thing")
|
||||
[route] = [url for url in router.urls if url.name == "thing-grant"]
|
||||
|
||||
response = route.callback(APIRequestFactory().post("/things/grant/"))
|
||||
|
||||
assert response.status_code == 200
|
||||
[event] = find_events(audit_events, GRANT)
|
||||
assert event["event"]["category"] == ["iam"]
|
||||
assert event["event"]["type"] == ["creation"]
|
||||
|
||||
|
||||
def test_extra_action_is_audited_without_a_router(audit_events):
|
||||
"""A view built by hand reads ``audit_action`` from its handler."""
|
||||
view = GrantViewSet.as_view({"post": "grant"})
|
||||
|
||||
response = view(APIRequestFactory().post("/things/grant/"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(find_events(audit_events, GRANT)) == 1
|
||||
|
||||
|
||||
def test_extra_action_without_audit_action_is_not_audited(audit_events):
|
||||
"""An ``@action`` that does not name an audit action emits nothing."""
|
||||
view = GrantViewSet.as_view({"post": "ping"})
|
||||
|
||||
response = view(APIRequestFactory().post("/things/ping/"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert audit_events == []
|
||||
|
||||
|
||||
def test_unauthenticated_extra_action_is_an_authentication_denial(audit_events):
|
||||
"""A 401 still files the event under ``authentication``, whatever the spec."""
|
||||
view = GrantViewSet.as_view({"post": "grant"}, error=exceptions.NotAuthenticated())
|
||||
|
||||
response = view(APIRequestFactory().post("/things/grant/"))
|
||||
|
||||
assert response.status_code == 401
|
||||
[event] = find_events(audit_events, GRANT)
|
||||
assert event["event"]["category"] == ["authentication"]
|
||||
assert event["event"]["type"] == ["creation", "denied"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user