Skip to content

✨(backend) let an operator forbid a room access level - #1619

Open
davd-gzl wants to merge 16 commits into
suitenumerique:mainfrom
davd-gzl:feat/restrict-access-levels
Open

✨(backend) let an operator forbid a room access level#1619
davd-gzl wants to merge 16 commits into
suitenumerique:mainfrom
davd-gzl:feat/restrict-access-levels

Conversation

@davd-gzl

@davd-gzl davd-gzl commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Takes over #1444, its author handed it on.

Fixes #1076

Problem

A room owner can set any meeting to open access, whatever default the operator picked.

The operator has no way to forbid a level outright.

Design

The setting

RESOURCE_ALLOWED_ACCESS_LEVELS lists the levels a room may be set to. It defaults to all three, so an upgrade changes nothing.

At boot

A deployment whose settings contradict the list does not start, rather than starting and ignoring one of them.

Unregistered meetings

ALLOW_UNREGISTERED_ROOMS hands a meeting to anyone typing a URL, with no record behind it and no lobby in front of it. There is nowhere to store a level for one, so while the list forbids open meetings those URLs answer 404 instead.

Existing meetings

The room settings panel offers only what the list holds. A meeting created before the operator narrowed the list is entered at the next stricter level the list does allow. Its host sees an empty picker and a line naming what the meeting runs as, so the choice is theirs to make. The row keeps its own value, so widening the list again restores it.

The first two commits are phanky1's, cherry-picked unchanged.

The host settings panel of an open meeting, filmed on an instance that allows all three levels, then on the same instance allowing trusted and restricted, with the room untouched in the database between the two:

the host settings panel offers three access levels with Open selected, then the same panel offers two with none selected, under a line reading that this access type is no longer allowed and the meeting runs as Open to trusted people until you choose a new one

Glossary

operator: whoever runs the instance and sets its environment variables.

room owner, administrator: whoever can open a meeting's settings and change who may join.

open, trusted, restricted: the three access levels, in that order anyone with the link, anyone signed in, invited people only. Everyone else asks to join.

the allow-list: RESOURCE_ALLOWED_ACCESS_LEVELS, the setting this pull request adds.

Add RESOURCE_ALLOWED_ACCESS_LEVELS so instance operators can limit which
room access levels users may set, including room owners and admins.

Enforce the allow-list through the room serializer on create and update,
and fail fast when RESOURCE_DEFAULT_ACCESS_LEVEL is outside the allowed
set to avoid invalid default-room creation.
Only show the access levels the instance allows in the room access picker,
instead of always listing all of them.
"access_level" not in serializer.validated_data
and user.default_room_access_level not in (None, "")
and user.default_room_access_level
in settings.RESOURCE_ALLOWED_ACCESS_LEVELS

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This value goes straight into save(), where no serializer check sees it. Without the line, a default saved before the list narrowed still creates rooms at a forbidden level.

The list was checked in one serializer, so the external room API and the
user's saved default still created rooms the instance forbids, and a
meeting created before the list narrowed kept its level.

Every path checks it now, both defaults are checked at boot, and a
meeting the list no longer holds is entered at the next stricter level it
does allow. The row keeps its own value.
The room settings panel filtered the levels while the SDK popup and the
room defaults tab offered every one, each from its own copy of the list.
One hook builds it for the three, and the panel shows the level the
meeting runs at when the instance no longer allows the saved one.
@davd-gzl
davd-gzl force-pushed the feat/restrict-access-levels branch from 2d6a0c8 to 18c7420 Compare August 20, 2026 13:25
The panel selected the enforced level for them, so a host whose saved
level was dropped saw a settled picker and no reason to touch it. Nothing
is selected now, and the message names what the meeting runs as.
A room the instance no longer allows was entered one level stricter and
said nothing, so its owner had no reason to move it. The room now carries
access_level_needs_choice, which the settings panel reads to clear the
picker and ask.

A level this version does not know at all falls to the strictest allowed
instead of raising on every read of the room.
The ordering was stated twice, and the panel's comment explained the
backend rule rather than the line under it.
# front of it, so a forbidden level cannot be enforced on one.
if (
not settings.ALLOW_UNREGISTERED_ROOMS
or RoomAccessLevel.PUBLIC not in settings.RESOURCE_ALLOWED_ACCESS_LEVELS

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ALLOW_UNREGISTERED_ROOMS mints a meeting for any slug, with no row behind it. There is nowhere to put a level, and no lobby stands in front of it, so it is an open meeting or it is nothing. Without this, an operator who forbids open meetings still hands one to anyone who types a URL.

@davd-gzl
davd-gzl marked this pull request as ready for review August 21, 2026 16:21
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Allow operators to restrict which room access levels can be selected

✨ Enhancement 🐞 Bug fix 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Add an operator allow-list for room access levels, enforced across all room creation/update paths.
• Enter rooms at an enforced (stricter) effective level when a stored level becomes disallowed.
• Update frontend pickers and UI hints to reflect allowed levels and enforced behavior.
Diagram

graph TD
  A{{"Operator env"}} --> B["Settings boot guards"] --> C[("Meet settings")]
  C --> D["Room model"] --> E["API serializers/viewsets"] --> F[["LiveKit metadata"]]
  E --> G["/api/config"] --> H["Frontend useConfig"] --> I["Access-level UI"]
  subgraph Legend
    direction LR
    _ext{{"External"}} ~~~ _svc["Service/Module"] ~~~ _db[("Data/State")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. One-time DB migration to rewrite disallowed room access levels
  • ➕ Eliminates the need for dual fields (stored vs effective) in API payloads
  • ➕ Simplifies frontend state (no 'needs choice' state needed)
  • ➖ Irreversibly loses the original value (widening the allow-list can't restore it)
  • ➖ Harder to explain/audit why a room changed (policy vs user intent)
2. Enforce only at join/lobby time (leave create/update unchanged)
  • ➕ Minimal changes to serializers and external API behavior
  • ➕ Avoids rejecting writes when policies change
  • ➖ Rooms could remain set to forbidden values indefinitely
  • ➖ Inconsistent UX (settings show one thing, runtime behaves differently)
  • ➖ Doesn't address external API/default-room creation gaps
3. Role-based allow-list (operator policy differs for owners/admins/API)
  • ➕ More flexible policy controls for operators
  • ➕ Can keep external API capabilities separate from interactive UI
  • ➖ Significantly higher complexity in policy rules and documentation
  • ➖ More testing surface and higher risk of inconsistent enforcement

Recommendation: Keep the PR’s approach: a single operator-defined allow-list enforced at all creation/update boundaries, plus a computed effective_access_level to handle existing rooms without destructive migrations. This preserves reversibility (widening the allow-list restores prior choices) while ensuring runtime enforcement and UI clarity.

Files changed (31) +617 / -72

Enhancement (10) +170 / -60
__init__.pyExpose allowed_access_levels via frontend config endpoint +1/-0

Expose allowed_access_levels via frontend config endpoint

• Extends /api/v1.0/config payload to include resource.allowed_access_levels so the frontend can filter access-level choices consistently.

src/backend/core/api/init.py

serializers.pyCentralize allow-list validation and publish effective access fields +33/-3

Centralize allow-list validation and publish effective access fields

• Introduces check_access_level_allowed validator and applies it to Room access_level and User default_room_access_level. Extends RoomSerializer to return effective_access_level and access_level_needs_choice, and uses effective_access_level for access checks.

src/backend/core/api/serializers.py

models.pyAdd strictness ordering and compute effective_access_level +38/-1

Add strictness ordering and compute effective_access_level

• Defines a strictness-ordered access level list and adds Room.access_level_needs_choice and Room.effective_access_level. Updates is_public to reflect effective access rather than stored access.

src/backend/core/models.py

useConfig.tsType frontend config to include allowed access levels +1/-0

Type frontend config to include allowed access levels

• Extends ApiConfig typing with resource.allowed_access_levels so UI hooks can filter access-level choices using server configuration.

src/frontend/src/api/useConfig.ts

ApiRoom.tsAdd effective_access_level fields and helper for backward compatibility +11/-0

Add effective_access_level fields and helper for backward compatibility

• Extends ApiRoom with effective_access_level and access_level_needs_choice, and adds effectiveAccessLevel() to fall back to access_level for older payloads/metadata.

src/frontend/src/features/rooms/api/ApiRoom.ts

useAccessLevelItems.tsCentralize allowed access-level radio items derived from /api/config +24/-0

Centralize allowed access-level radio items derived from /api/config

• Adds a hook that builds access-level picker items and filters them based on config.resource.allowed_access_levels, enabling consistent behavior across panels/popups.

src/frontend/src/features/rooms/hooks/useAccessLevelItems.ts

Admin.tsxFilter access-level picker and show enforced-level warning in admin panel +26/-19

Filter access-level picker and show enforced-level warning in admin panel

• Replaces hardcoded access-level items with useAccessLevelItems(). When a stored access level is disallowed, shows a warning and leaves the picker unselected to force an explicit choice.

src/frontend/src/features/rooms/livekit/components/Admin.tsx

useSyncLiveKitMetadata.tsSync effective_access_level from LiveKit metadata into room state +6/-1

Sync effective_access_level from LiveKit metadata into room state

• Extends parsed LiveKit metadata to include effective_access_level and updates change detection/state updates accordingly.

src/frontend/src/features/rooms/livekit/hooks/useSyncLiveKitMetadata.ts

SettingsPopup.tsxFilter access-level picker and show enforced-level warning in SDK settings +26/-19

Filter access-level picker and show enforced-level warning in SDK settings

• Uses useAccessLevelItems() for allowed choices, supports unselected state when a stored level is disallowed, and displays a warning indicating the enforced effective access level.

src/frontend/src/features/sdk/routes/SettingsPopup.tsx

RoomsTab.tsxFilter default access-level choices using instance allow-list +4/-17

Filter default access-level choices using instance allow-list

• Replaces the hardcoded access level list with useAccessLevelItems() so user defaults can only be set to levels permitted by the instance configuration.

src/frontend/src/features/settings/components/tabs/RoomsTab.tsx

Bug fix (6) +36 / -12
viewsets.pyEnforce allow-list for unregistered rooms and user defaults; propagate effective level +13/-2

Enforce allow-list for unregistered rooms and user defaults; propagate effective level

• Refuses unregistered rooms when public access is not allowed. Ensures a saved user default only applies if still allowed, and includes effective_access_level in LiveKit metadata updates.

src/backend/core/api/viewsets.py

serializers.pyApply access-level allow-list to external room API writes +6/-1

Apply access-level allow-list to external room API writes

• Reuses the shared check_access_level_allowed validator so external API room creation cannot bypass the operator allow-list.

src/backend/core/external_api/serializers.py

lobby.pyEnforce effective access level in lobby bypass rules +4/-3

Enforce effective access level in lobby bypass rules

• Updates lobby bypass logic to use room.effective_access_level so runtime access matches operator policy even for legacy/disallowed stored values.

src/backend/core/services/lobby.py

LaterMeetingDialog.tsxUse effective access level when deciding public-meeting UI +6/-2

Use effective access level when deciding public-meeting UI

• Switches checks from room.access_level to effectiveAccessLevel(room) so UI reflects enforced access when stored levels become disallowed.

src/frontend/src/features/home/components/LaterMeetingDialog.tsx

InviteDialog.tsxUse effective access level for public invite UI hints +5/-2

Use effective access level for public invite UI hints

• Updates public-access conditional UI to use effectiveAccessLevel(roomData) rather than the stored access level.

src/frontend/src/features/rooms/components/InviteDialog.tsx

Lobby.tsxUse effective access level for lobby login hint behavior +2/-2

Use effective access level for lobby login hint behavior

• Determines whether to prompt a login hint based on effectiveAccessLevel(data), aligning user guidance with enforced access rules.

src/frontend/src/features/rooms/components/Lobby.tsx

Tests (8) +376 / -0
test_api_rooms_create.pyAdd room-create API tests for allow-list and narrowed user defaults +61/-0

Add room-create API tests for allow-list and narrowed user defaults

• Covers rejection of disallowed access_level, acceptance of allowed values, and fallback to instance default when a user’s saved default becomes disallowed.

src/backend/core/tests/rooms/test_api_rooms_create.py

test_api_rooms_retrieve.pyExtend room-retrieve API tests for effective level and unregistered refusal +42/-0

Extend room-retrieve API tests for effective level and unregistered refusal

• Updates expected payloads to include effective_access_level and access_level_needs_choice. Adds coverage ensuring unregistered rooms return 404 when public is forbidden.

src/backend/core/tests/rooms/test_api_rooms_retrieve.py

test_api_rooms_update.pyAdd update API tests for disallowed levels and LiveKit metadata +124/-0

Add update API tests for disallowed levels and LiveKit metadata

• Ensures disallowed access_level updates are rejected, legacy rooms are enforced at a stricter effective level without mutating stored value, and LiveKit metadata includes both access_level and effective_access_level.

src/backend/core/tests/rooms/test_api_rooms_update.py

test_api_config.pyTest that /api/config publishes allowed_access_levels +19/-0

Test that /api/config publishes allowed_access_levels

• Adds a new test verifying the frontend configuration endpoint returns the operator’s allowed access levels.

src/backend/core/tests/test_api_config.py

test_api_users_room_preferences.pyReject forbidden user default_room_access_level values +19/-0

Reject forbidden user default_room_access_level values

• Adds coverage ensuring users cannot save a default room access level that the instance forbids, since it affects room creation.

src/backend/core/tests/test_api_users_room_preferences.py

test_external_api_rooms.pyEnsure external API cannot create rooms outside instance allow-list +25/-0

Ensure external API cannot create rooms outside instance allow-list

• Adds a test showing RESOURCE_ALLOWED_ACCESS_LEVELS overrides the external API’s own public-access setting when creating rooms.

src/backend/core/tests/test_external_api_rooms.py

test_models_rooms.pyTest effective_access_level strictness behavior and legacy values +59/-0

Test effective_access_level strictness behavior and legacy values

• Adds model-level tests for effective_access_level transitions, skipping removed middle levels, never loosening access, and handling unknown/legacy stored values.

src/backend/core/tests/test_models_rooms.py

test_settings_access_levels.pyAdd boot-time settings guard tests for defaults vs allow-list +27/-0

Add boot-time settings guard tests for defaults vs allow-list

• Introduces tests verifying boot validation rejects RESOURCE_DEFAULT_ACCESS_LEVEL and EXTERNAL_API_DEFAULT_ACCESS_LEVEL when they fall outside RESOURCE_ALLOWED_ACCESS_LEVELS.

src/backend/core/tests/test_settings_access_levels.py

Documentation (6) +13 / -0
CHANGELOG.mdDocument access-level allow-list and UI enforcement behavior +8/-0

Document access-level allow-list and UI enforcement behavior

• Adds Unreleased changelog entries for backend allow-list enforcement, frontend filtering, and unregistered-room refusal when public is forbidden.

CHANGELOG.md

kubernetes.mdAdd RESOURCE_ALLOWED_ACCESS_LEVELS to Kubernetes env var reference +1/-0

Add RESOURCE_ALLOWED_ACCESS_LEVELS to Kubernetes env var reference

• Documents the new RESOURCE_ALLOWED_ACCESS_LEVELS setting, including how existing/unregistered rooms behave when levels are removed from the allow-list.

docs/installation/kubernetes.md

rooms.jsonAdd German translation for enforced access-level warning +1/-0

Add German translation for enforced access-level warning

• Adds the admin.access.enforced message explaining the stored access type is no longer allowed and the meeting runs at an enforced level until changed.

src/frontend/src/locales/de/rooms.json

rooms.jsonAdd English translation for enforced access-level warning +1/-0

Add English translation for enforced access-level warning

• Adds the admin.access.enforced string used when a meeting’s stored access level is disallowed by operator policy.

src/frontend/src/locales/en/rooms.json

rooms.jsonAdd French translation for enforced access-level warning +1/-0

Add French translation for enforced access-level warning

• Adds the admin.access.enforced message for the enforced access-level UI state.

src/frontend/src/locales/fr/rooms.json

rooms.jsonAdd Dutch translation for enforced access-level warning +1/-0

Add Dutch translation for enforced access-level warning

• Adds the admin.access.enforced message used to communicate enforced effective access level until the owner chooses a new value.

src/frontend/src/locales/nl/rooms.json

Other (1) +22 / -0
settings.pyAdd RESOURCE_ALLOWED_ACCESS_LEVELS and validate defaults at startup +22/-0

Add RESOURCE_ALLOWED_ACCESS_LEVELS and validate defaults at startup

• Adds the RESOURCE_ALLOWED_ACCESS_LEVELS setting, introduces validate_access_level_settings(), and calls it during post_setup to fail fast on contradictory deployment configuration.

src/backend/meet/settings.py

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 21, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Allow-list values not validated ✓ Resolved 🐞 Bug ☼ Reliability
Description
validate_access_level_settings only checks that the two defaults are contained in
RESOURCE_ALLOWED_ACCESS_LEVELS, but it never validates that the allow-list itself contains only
supported access levels (or is non-empty). This can let a deployment boot with an invalid allow-list
and then fail at runtime when room/user access levels are validated against model choices.
Code

src/backend/meet/settings.py[R43-46]

+    for name, level in defaults.items():
+        if level not in allowed_levels:
+            raise ValueError(f"{name} must be one of RESOURCE_ALLOWED_ACCESS_LEVELS")
+
Evidence
Boot validation currently only checks membership of the defaults in the allow-list; it never
validates allow-list contents. Meanwhile the actual data model constrains access levels to
RoomAccessLevel.choices, so an invalid allow-list can still let the instance start but later cause
validation failures and inconsistent behavior.

src/backend/meet/settings.py[37-46]
src/backend/meet/settings.py[710-717]
src/backend/core/models.py[104-113]
src/backend/core/models.py[427-431]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`validate_access_level_settings` ensures defaults are *in* `RESOURCE_ALLOWED_ACCESS_LEVELS`, but it does not ensure `RESOURCE_ALLOWED_ACCESS_LEVELS` contains only supported access levels (public/trusted/restricted) and is non-empty. This allows bad env values (e.g. `RESOURCE_ALLOWED_ACCESS_LEVELS=foo,bar`) to pass boot validation if defaults match, but then break API/model validation and access-level logic later.
### Issue Context
The Room/User fields use `choices=RoomAccessLevel.choices`, so unknown values will fail validation on write paths even though settings booted successfully.
### Fix Focus Areas
- src/backend/meet/settings.py[37-47]
- src/backend/meet/settings.py[710-717]
### Suggested fix
- Add validation that `allowed_levels`:
- is not empty
- contains only the supported values ("public", "trusted", "restricted")
- (optionally) contains no duplicates
- Also validate that `default_level` and `external_default` are among the supported values (in addition to being in `allowed_levels`) to produce clearer boot errors.
- Keep this validation self-contained in settings (avoid importing Django models if that risks import-order/circular issues); define a local constant set of supported strings.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. LiveKit metadata can be stale ✓ Resolved 🐞 Bug ≡ Correctness
Description
effective_access_level is pushed to LiveKit room metadata only when configuration or
access_level changes, but effective_access_level can change when
RESOURCE_ALLOWED_ACCESS_LEVELS changes across deployments without any room update. During a
meeting, the frontend patches its cached room data from LiveKit metadata, so stale metadata can
overwrite the API-fetched effective level and mislead in-meeting UI.
Code

src/backend/core/api/viewsets.py[R383-386]

       metadata = {
           "configuration": room.configuration,
           "access_level": room.access_level,
+            "effective_access_level": room.effective_access_level,
Evidence
Backend computes effective_access_level from settings and now includes it in LiveKit metadata, but
only syncs metadata on access/config changes. The frontend explicitly consumes LiveKit metadata to
overwrite cached effective_access_level, so stale LiveKit metadata can surface in the UI after
allow-list changes.

src/backend/core/api/viewsets.py[372-387]
src/backend/core/models.py[499-513]
src/backend/meet/settings.py[710-717]
src/frontend/src/features/rooms/livekit/hooks/useSyncLiveKitMetadata.ts[52-75]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`perform_update` now includes `effective_access_level` in the metadata sent to LiveKit, but it still skips `RoomManagement.update_metadata()` when `configuration` and `access_level` are unchanged. Since `Room.effective_access_level` depends on `settings.RESOURCE_ALLOWED_ACCESS_LEVELS`, changing the allow-list (and redeploying) can change `effective_access_level` for existing rooms without any DB change, leaving LiveKit metadata stale.
The frontend’s `useSyncLiveKitMetadata` patches the React Query cache from LiveKit room metadata (including `effective_access_level`), which means stale LiveKit metadata can overwrite fresh API data during an ongoing meeting.
### Issue Context
The metadata early-return is an optimization, but after introducing `effective_access_level` it can prevent the system from converging when settings change.
### Fix Focus Areas
- src/backend/core/api/viewsets.py[369-387]
- src/backend/core/models.py[499-513]
- src/frontend/src/features/rooms/livekit/hooks/useSyncLiveKitMetadata.ts[52-75]
### Suggested fix
Pick one:
1) Remove the early return and always call `RoomManagement().update_metadata(...)` after any successful room update (even name-only), so LiveKit eventually reflects the current effective level under the current settings.
2) Alternatively, extend the early-return condition to also consider `effective_access_level` *and* ensure LiveKit metadata is updated at least once after upgrade/boot when the allow-list changes (e.g., a periodic/background resync, or a forced sync when `access_level_needs_choice` is true).
(Option 1 is the simplest and guarantees convergence; if LiveKit calls are expensive, consider adding a lightweight check inside `RoomManagement.update_metadata` to no-op when the merged metadata is unchanged.)

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can tweak Display preferences with a live preview to see your comment before it ships

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/backend/meet/settings.py
Comment thread src/backend/core/api/viewsets.py Outdated
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ability to restrict access levels

2 participants