(ci) try to pass more checks with rabbitai + remove no longer used advanced encryption client in keycloak

This commit is contained in:
Thomas Ramé
2026-05-18 11:23:49 +02:00
parent e2a3b286ca
commit a357fb04d5
11 changed files with 91 additions and 42 deletions
+1 -1
View File
@@ -117,7 +117,7 @@ We hope to see many more, here is an incomplete list of public La Suite Meet ins
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up | | [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up |
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up | | [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up |
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month | | [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. | | [mosa.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
## Contributing ## Contributing
-17
View File
@@ -845,23 +845,6 @@
"offline_access", "offline_access",
"microprofile-jwt" "microprofile-jwt"
] ]
},
{
"clientId": "encryption",
"name": "Encryption Service",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"redirectUris": [
"http://encryption.localhost:7200/auth/callback"
],
"webOrigins": [
"http://encryption.localhost:7200",
"http://data.encryption.localhost:7200"
],
"protocol": "openid-connect",
"fullScopeAllowed": true
} }
], ],
"clientScopes": [ "clientScopes": [
+13
View File
@@ -41,6 +41,19 @@ class UserSerializer(serializers.ModelSerializer):
] ]
read_only_fields = ["id", "email", "full_name", "short_name"] read_only_fields = ["id", "email", "full_name", "short_name"]
def validate_default_encryption_mode(self, value):
"""Reject a non-none default when the server has encryption disabled.
Keeps the user preference DB in sync with the deployment's posture:
if an operator flips ENCRYPTION_ENABLED off, no client should be able
to keep persisting `basic` as their default behind their back.
"""
if value != models.EncryptionMode.NONE and not settings.ENCRYPTION_ENABLED:
raise serializers.ValidationError(
_("End-to-end encryption is disabled on this server.")
)
return value
class UserLightSerializer(serializers.ModelSerializer): class UserLightSerializer(serializers.ModelSerializer):
"""Serialize users with limited fields.""" """Serialize users with limited fields."""
+40
View File
@@ -448,7 +448,12 @@ class Room(Resource):
always reject calls to them (no way to derive the key), and the always reject calls to them (no way to derive the key), and the
PIN namespace is finite (10**length): no point burning slots that PIN namespace is finite (10**length): no point burning slots that
can never be dialed. can never be dialed.
Also run `clean()` so the encryption invariants are enforced on
every save path (ORM, admin, shell), not only via the DRF
serializer.
""" """
self.clean()
if ( if (
settings.ROOM_TELEPHONY_ENABLED settings.ROOM_TELEPHONY_ENABLED
and not self.pk and not self.pk
@@ -460,6 +465,41 @@ class Room(Resource):
) )
super().save(*args, **kwargs) super().save(*args, **kwargs)
def clean(self):
"""Enforce encryption-mode invariants outside DRF.
Two rules:
- `encryption_mode` is set at creation and never mutated afterwards
(the URL-hash passphrase encodes assumptions about it).
- An encrypted room must be at the RESTRICTED access level so the
host vets joiners before they ever see the in-URL key.
"""
super().clean()
if self.pk is not None:
previous = Room.objects.filter(pk=self.pk).only("encryption_mode").first()
if (
previous is not None
and previous.encryption_mode != self.encryption_mode
):
raise ValidationError(
{
"encryption_mode": _(
"Encryption mode cannot be changed after room creation."
)
}
)
if (
self.encryption_mode != EncryptionMode.NONE
and self.access_level != RoomAccessLevel.RESTRICTED
):
raise ValidationError(
{
"access_level": _(
"Encrypted rooms must use the 'restricted' access level."
)
}
)
def clean_fields(self, exclude=None): def clean_fields(self, exclude=None):
""" """
Automatically generate the slug from the name and make sure it does not look like a UUID. Automatically generate the slug from the name and make sure it does not look like a UUID.
+9 -2
View File
@@ -147,9 +147,16 @@ class LobbyService:
# In encrypted rooms, authenticated users cannot pick an arbitrary # In encrypted rooms, authenticated users cannot pick an arbitrary
# display name — server enforces the OIDC name so a tampered client # display name — server enforces the OIDC name so a tampered client
# can't impersonate someone else with their account. # can't impersonate someone else with their account. If both
# full_name and email are absent (degenerate OIDC payload), fall
# back to a server-controlled technical name rather than trusting
# whatever the client posted.
if room.is_encrypted and request.user.is_authenticated: if room.is_encrypted and request.user.is_authenticated:
username = request.user.full_name or request.user.email or username username = (
request.user.full_name
or request.user.email
or f"noname-{request.user.id}"
)
participant_id = self._get_or_create_participant_id(request) participant_id = self._get_or_create_participant_id(request)
participant = self._get_participant(room.id, participant_id) participant = self._get_participant(room.id, participant_id)
+3 -1
View File
@@ -90,7 +90,9 @@ def generate_token(
# Local import: core.models loads core.utils mid-import (see models.py:28), # Local import: core.models loads core.utils mid-import (see models.py:28),
# so importing EncryptionMode at module top would deadlock the bootstrap. # so importing EncryptionMode at module top would deadlock the bootstrap.
from core.models import EncryptionMode # pylint: disable=import-outside-toplevel from core.models import ( # noqa: PLC0415 pylint: disable=import-outside-toplevel
EncryptionMode,
)
if is_admin_or_owner or sources is None: if is_admin_or_owner or sources is None:
sources = settings.LIVEKIT_DEFAULT_SOURCES sources = settings.LIVEKIT_DEFAULT_SOURCES
+2 -2
View File
@@ -561,12 +561,12 @@ class Base(Configuration):
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None "returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
) )
OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue( OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue(
default=["first_name", "last_name"], default=["given_name", "usual_name"],
environ_name="OIDC_USERINFO_FULLNAME_FIELDS", environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
environ_prefix=None, environ_prefix=None,
) )
OIDC_USERINFO_SHORTNAME_FIELD = values.Value( OIDC_USERINFO_SHORTNAME_FIELD = values.Value(
default="first_name", default="given_name",
environ_name="OIDC_USERINFO_SHORTNAME_FIELD", environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
environ_prefix=None, environ_prefix=None,
) )
@@ -255,7 +255,7 @@ export const Conference = ({
}, [room, isEncrypted, roomWithE2EE]) }, [room, isEncrypted, roomWithE2EE])
useEffect(() => { useEffect(() => {
if (!isEncrypted) return if (!data) return
let currentHash = getPassphraseFromHash() let currentHash = getPassphraseFromHash()
const onHashChange = () => { const onHashChange = () => {
const next = getPassphraseFromHash() const next = getPassphraseFromHash()
@@ -265,22 +265,23 @@ export const Conference = ({
} }
window.addEventListener('hashchange', onHashChange) window.addEventListener('hashchange', onHashChange)
return () => window.removeEventListener('hashchange', onHashChange) return () => window.removeEventListener('hashchange', onHashChange)
}, [isEncrypted]) }, [data])
useEffect(() => { useEffect(() => {
/** /**
* Warm up connection to LiveKit server before joining room * Warm up connection to LiveKit server before joining room.
* Use the normalized `serverUrl` (the same value the LiveKitRoom
* will connect to after `force_wss_protocol`) so the warm-up matches
* the actual connection target.
*/ */
const prepareConnection = async () => { const prepareConnection = async () => {
if (!apiConfig || isConnectionWarmedUp) return if (!apiConfig || !serverUrl || isConnectionWarmedUp) return
await room.prepareConnection(apiConfig.livekit.url) await room.prepareConnection(serverUrl)
if (isFireFox() && apiConfig.livekit.enable_firefox_proxy_workaround) { if (isFireFox() && apiConfig.livekit.enable_firefox_proxy_workaround) {
try { try {
const wssUrl = const wssUrl =
apiConfig.livekit.url serverUrl.replace('https://', 'wss://').replace(/\/$/, '') + '/rtc'
.replace('https://', 'wss://')
.replace(/\/$/, '') + '/rtc'
/** /**
* FIREFOX + PROXY WORKAROUND — see livekit-examples/meet/issues/466 * FIREFOX + PROXY WORKAROUND — see livekit-examples/meet/issues/466
@@ -295,7 +296,7 @@ export const Conference = ({
setIsConnectionWarmedUp(true) setIsConnectionWarmedUp(true)
} }
prepareConnection() prepareConnection()
}, [room, apiConfig, isConnectionWarmedUp]) }, [room, apiConfig, serverUrl, isConnectionWarmedUp])
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create') const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
const [mediaDeviceError, setMediaDeviceError] = useState<{ const [mediaDeviceError, setMediaDeviceError] = useState<{
@@ -125,7 +125,16 @@ export const Join = ({
// re-enforces this when minting the JWT. // re-enforces this when minting the JWT.
const isNameLocked = isEncryptedRoom && !!isLoggedIn const isNameLocked = isEncryptedRoom && !!isLoggedIn
const lockedName = user?.full_name || user?.email || '' const lockedName = user?.full_name || user?.email || ''
const passphrase = getPassphraseFromHash()
// Keep the passphrase in state and refresh on `hashchange` so the
// mismatch screen recovers immediately when the user pastes the
// correct hash into the address bar.
const [passphrase, setPassphrase] = useState(getPassphraseFromHash)
useEffect(() => {
const onHashChange = () => setPassphrase(getPassphraseFromHash())
window.addEventListener('hashchange', onHashChange)
return () => window.removeEventListener('hashchange', onHashChange)
}, [])
const hasValidPassphrase = isEncryptedRoom const hasValidPassphrase = isEncryptedRoom
? isValidPassphrase(passphrase) ? isValidPassphrase(passphrase)
: true : true
@@ -56,7 +56,7 @@ export const useLobby = ({
enabled: status === ApiLobbyStatus.WAITING, enabled: status === ApiLobbyStatus.WAITING,
}) })
const startWaiting = useCallback(async () => { const startWaiting = useCallback(() => {
setStatus(ApiLobbyStatus.WAITING) setStatus(ApiLobbyStatus.WAITING)
startWaitingTimeout() startWaitingTimeout()
}, [startWaitingTimeout]) }, [startWaitingTimeout])
@@ -4,13 +4,12 @@ import { Separator as RACSeparator } from 'react-aria-components'
import { RiAlertFill } from '@remixicon/react' import { RiAlertFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { usePatchRoom } from '@/features/rooms/api/patchRoom' import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
import { ApiAccessLevel, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom' import { ApiAccessLevel, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { queryClient } from '@/api/queryClient' import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys' import { keys } from '@/api/queryKeys'
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'wouter' import { useParams } from 'wouter'
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager' import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
export const Admin = () => { export const Admin = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'admin' }) const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
@@ -23,12 +22,7 @@ export const Admin = () => {
const { mutateAsync: patchRoom } = usePatchRoom() const { mutateAsync: patchRoom } = usePatchRoom()
const { data: readOnlyData } = useQuery({ const readOnlyData = useRoomData()
queryKey: [keys.room, roomId],
queryFn: () => fetchRoom({ roomId }),
retry: false,
enabled: false,
})
const { const {
toggleMicrophone, toggleMicrophone,