From f490b095d8f7a789bb9ebc0a6c3dc52d32104440 Mon Sep 17 00:00:00 2001 From: rahul Date: Sun, 19 Apr 2026 07:32:25 +0530 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20support=20config=20and=20a?= =?UTF-8?q?ccess=20level=20in=20external=20API=20room=20creation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow passing configuration and access level when creating a room through the external API. Also add a few guardrails: * control whether public rooms are accepted on the external API * set the default access level when creating a new room Ensure the new room configuration and access level are returned by the serializer when listing rooms. --- CHANGELOG.md | 1 + src/backend/core/external_api/serializers.py | 39 ++- .../core/tests/test_external_api_rooms.py | 282 ++++++++++++++++-- src/backend/meet/settings.py | 12 + 4 files changed, 311 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f51832d..eb4adcf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to - ✨(backend) expose room configuration to all API consumers - 🩹(frontend) improve reaction toolbar centering with dynamic positioning - 🚀 (paas) remove buildpack requirements.txt to use the new uv.lock #1349 +- ✨(backend) allow room configuration and access level via external api #1260 ## [1.16.0] - 2026-05-13 diff --git a/src/backend/core/external_api/serializers.py b/src/backend/core/external_api/serializers.py index 205691d6..03f152d9 100644 --- a/src/backend/core/external_api/serializers.py +++ b/src/backend/core/external_api/serializers.py @@ -4,10 +4,11 @@ from django.conf import settings +from pydantic import ValidationError from rest_framework import serializers from core import models, utils -from core.api.serializers import BaseValidationOnlySerializer +from core.api.serializers import BaseValidationOnlySerializer, RoomConfiguration OAUTH2_GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials" @@ -34,10 +35,37 @@ class RoomSerializer(serializers.ModelSerializer): following the principle of least privilege. """ + configuration = serializers.JSONField(required=False) + class Meta: model = models.Room - fields = ["id", "name", "slug", "pin_code", "access_level"] - read_only_fields = ["id", "name", "slug", "pin_code", "access_level"] + fields = ["id", "name", "slug", "pin_code", "access_level", "configuration"] + read_only_fields = ["id", "name", "slug", "pin_code"] + + def validate_configuration(self, value): + """Validate room configuration against the RoomConfiguration schema.""" + if value is None or value == {}: + return value + try: + RoomConfiguration.model_validate(value) + except ValidationError as e: + raise serializers.ValidationError(e.errors()) from e + return value + + def validate_access_level(self, access_level): + """Reject public access_level unless explicitly allowed or the default is already public.""" + + if settings.EXTERNAL_API_DEFAULT_ACCESS_LEVEL == models.RoomAccessLevel.PUBLIC: + return access_level + + if ( + access_level == models.RoomAccessLevel.PUBLIC + and not settings.EXTERNAL_API_ALLOW_PUBLIC_ACCESS + ): + raise serializers.ValidationError( + "Public rooms are disabled for the external API." + ) + return access_level def to_representation(self, instance): """Enrich response with application-specific computed fields.""" @@ -68,6 +96,9 @@ class RoomSerializer(serializers.ModelSerializer): # Set secure defaults validated_data["name"] = utils.generate_room_slug() - validated_data["access_level"] = models.RoomAccessLevel.TRUSTED + validated_data.setdefault( + "access_level", settings.EXTERNAL_API_DEFAULT_ACCESS_LEVEL + ) + validated_data.setdefault("configuration", {}) return super().create(validated_data) diff --git a/src/backend/core/tests/test_external_api_rooms.py b/src/backend/core/tests/test_external_api_rooms.py index 901411cc..a4653c12 100644 --- a/src/backend/core/tests/test_external_api_rooms.py +++ b/src/backend/core/tests/test_external_api_rooms.py @@ -250,6 +250,112 @@ def test_api_rooms_list_filters_by_user(): assert str(room2.id) not in returned_ids +def test_api_rooms_list_access_level_in_results(): + """Rooms should include the correct access_level for each room.""" + user = UserFactory() + room_trusted = RoomFactory( + users=[(user, RoleChoices.OWNER)], access_level=RoomAccessLevel.TRUSTED + ) + room_restricted = RoomFactory( + users=[(user, RoleChoices.OWNER)], access_level=RoomAccessLevel.RESTRICTED + ) + + token = generate_test_token(user, [ApplicationScope.ROOMS_LIST]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.get("/external-api/v1.0/rooms/") + + assert response.status_code == 200 + results = {r["id"]: r for r in response.data["results"]} + assert results[str(room_trusted.id)]["access_level"] == RoomAccessLevel.TRUSTED + assert ( + results[str(room_restricted.id)]["access_level"] == RoomAccessLevel.RESTRICTED + ) + + +def test_api_rooms_list_does_not_expose_sensitive_fields(): + """Rooms should not expose pin_code or accesses.""" + user = UserFactory() + RoomFactory(users=[(user, RoleChoices.OWNER)]) + + token = generate_test_token(user, [ApplicationScope.ROOMS_LIST]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.get("/external-api/v1.0/rooms/") + + assert response.status_code == 200 + result = response.data["results"][0] + assert "pin_code" not in result + assert "accesses" not in result + assert "livekit" not in result + + +def test_api_rooms_list_expected_fields(settings): + """Rooms should expose exactly the expected fields.""" + + settings.APPLICATION_BASE_URL = "https://example.com" + settings.ROOM_TELEPHONY_ENABLED = True + + user = UserFactory() + RoomFactory(users=[(user, RoleChoices.OWNER)]) + + token = generate_test_token(user, [ApplicationScope.ROOMS_LIST]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.get("/external-api/v1.0/rooms/") + + assert response.status_code == 200 + assert set(response.data["results"][0].keys()) == { + "id", + "name", + "slug", + "access_level", + "configuration", + "telephony", + "url", + } + + +def test_api_rooms_list_expected_fields_without_telephony(settings): + """Rooms shouldn't expose telephony related fields when disabled.""" + + settings.APPLICATION_BASE_URL = "https://example.com" + settings.ROOM_TELEPHONY_ENABLED = False + + user = UserFactory() + RoomFactory(users=[(user, RoleChoices.OWNER)]) + + token = generate_test_token(user, [ApplicationScope.ROOMS_LIST]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.get("/external-api/v1.0/rooms/") + + assert response.status_code == 200 + assert "telephony" not in set(response.data["results"][0].keys()) + + +def test_api_rooms_list_expected_fields_missing_base_url(settings): + """Rooms shouldn't expose URL field when the application base url is missing.""" + + settings.APPLICATION_BASE_URL = None + + user = UserFactory() + RoomFactory(users=[(user, RoleChoices.OWNER)]) + + token = generate_test_token(user, [ApplicationScope.ROOMS_LIST]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.get("/external-api/v1.0/rooms/") + + assert response.status_code == 200 + assert "url" not in set(response.data["results"][0].keys()) + + def test_api_rooms_retrieve_requires_authentication(): """Retrieving rooms without authentication should return 401.""" @@ -383,6 +489,7 @@ def test_api_rooms_retrieve_success(settings): "name": room.name, "slug": room.slug, "access_level": str(room.access_level), + "configuration": room.configuration, "url": f"http://your-application.com/{room.slug}", "telephony": { "enabled": True, @@ -565,11 +672,40 @@ def test_api_rooms_create_success(): assert "slug" in response.data assert "name" in response.data assert response.data["name"] == response.data["slug"] + assert response.data["configuration"] == {} # Verify room was created with user as owner room = Room.objects.get(id=response.data["id"]) assert room.get_role(user) == RoleChoices.OWNER assert room.access_level == "trusted" + assert room.configuration == {} + + +def test_api_rooms_create_with_configuration_success(): + """Creating a room with a validated configuration should succeed.""" + + user = UserFactory() + + token = generate_test_token( + user, [ApplicationScope.ROOMS_CREATE, ApplicationScope.ROOMS_LIST] + ) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.post( + "/external-api/v1.0/rooms/", + { + "access_level": RoomAccessLevel.RESTRICTED, + "configuration": {"can_publish_sources": ["camera"]}, + }, + format="json", + ) + + assert response.status_code == 201 + room = Room.objects.get(id=response.data["id"]) + assert room.access_level == RoomAccessLevel.RESTRICTED + assert room.configuration == {"can_publish_sources": ["camera"]} + assert response.data["configuration"] == {"can_publish_sources": ["camera"]} def test_api_rooms_create_readonly_enforcement(): @@ -587,7 +723,6 @@ def test_api_rooms_create_readonly_enforcement(): "id": "fake-id", "slug": "fake-slug", "name": "fake-name", - "access_level": "public", }, format="json", ) @@ -599,41 +734,150 @@ def test_api_rooms_create_readonly_enforcement(): assert response.data["slug"] != "fake-slug" assert "id" in response.data assert response.data["name"] != "fake-name" + assert response.data["configuration"] == {} # Verify room was created with user as owner room = Room.objects.get(id=response.data["id"]) assert room.get_role(user) == RoleChoices.OWNER assert room.access_level == "trusted" + assert room.configuration == {} -def test_api_rooms_unknown_actions(): - """Updating or deleting a room are not supported yet.""" +def test_api_rooms_create_rejects_invalid_configuration(): + """Creating a room with unsupported configuration keys should fail.""" user = UserFactory() - room = RoomFactory(users=[(user, RoleChoices.OWNER)]) + token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE]) - token = generate_test_token( - user, - [ - ApplicationScope.ROOMS_RETRIEVE, - ApplicationScope.ROOMS_DELETE, - ApplicationScope.ROOMS_UPDATE, - ], + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.post( + "/external-api/v1.0/rooms/", + { + "configuration": { + "unsupported_flag": True, + } + }, + format="json", ) - client = APIClient() - client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") - response = client.delete(f"/external-api/v1.0/rooms/{room.id}/") + assert response.status_code == 400 + assert "extra inputs are not permitted" in str(response.data).lower() - assert response.status_code == 405 - assert 'method "delete" not allowed.' in str(response.data).lower() + +@pytest.mark.parametrize( + "invalid_configuration", + [ + {"can_publish_sources": ["invalid-source"]}, + {"everyone_can_mute": "invalid-value"}, + ], +) +def test_api_rooms_create_rejects_invalid_configuration_values(invalid_configuration): + """Creating a room with invalid configuration values should fail.""" + + user = UserFactory() + token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE]) client = APIClient() client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") - response = client.patch(f"/external-api/v1.0/rooms/{room.id}/") + response = client.post( + "/external-api/v1.0/rooms/", + {"configuration": invalid_configuration}, + format="json", + ) - assert response.status_code == 405 - assert 'method "patch" not allowed.' in str(response.data).lower() + assert response.status_code == 400 + + +def test_api_rooms_create_public_access_disabled_by_default(): + """Public rooms should be disabled for the external API by default.""" + + user = UserFactory() + token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.post( + "/external-api/v1.0/rooms/", + {"access_level": RoomAccessLevel.PUBLIC}, + format="json", + ) + + assert response.status_code == 400 + assert "public rooms are disabled" in str(response.data).lower() + + +def test_api_rooms_create_public_access_enabled_with_settings(settings): + """Public rooms should be creatable when explicitly enabled.""" + + settings.EXTERNAL_API_ALLOW_PUBLIC_ACCESS = True + + user = UserFactory() + token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.post( + "/external-api/v1.0/rooms/", + {"access_level": RoomAccessLevel.PUBLIC}, + format="json", + ) + + assert response.status_code == 201 + room = Room.objects.get(id=response.data["id"]) + assert room.access_level == RoomAccessLevel.PUBLIC + assert response.data["access_level"] == RoomAccessLevel.PUBLIC + + +def test_api_rooms_create_default_access_level_respects_settings(settings): + """Room creation should reflect the EXTERNAL_API_DEFAULT_ACCESS_LEVEL setting.""" + + user = UserFactory() + token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.post( + "/external-api/v1.0/rooms/", + format="json", + ) + assert response.status_code == 201 + assert response.data["access_level"] == RoomAccessLevel.TRUSTED + + settings.EXTERNAL_API_DEFAULT_ACCESS_LEVEL = "public" + + response = client.post( + "/external-api/v1.0/rooms/", + format="json", + ) + assert response.status_code == 201 + assert response.data["access_level"] == RoomAccessLevel.PUBLIC + + +def test_api_rooms_create_public_access_level_when_default_is_public(settings): + """Explicit public access_level is accepted when the default is already public.""" + settings.EXTERNAL_API_ALLOW_PUBLIC_ACCESS = False + settings.EXTERNAL_API_DEFAULT_ACCESS_LEVEL = "public" + + user = UserFactory() + token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + + # No access_level in body — default kicks in, public room is created. + response = client.post("/external-api/v1.0/rooms/", {}, format="json") + assert response.status_code == 201 + assert response.data["access_level"] == RoomAccessLevel.PUBLIC + + # Explicit access_level=public in body — still rejected. + response = client.post( + "/external-api/v1.0/rooms/", + {"access_level": RoomAccessLevel.PUBLIC}, + format="json", + ) + assert response.status_code == 201 + assert response.data["access_level"] == RoomAccessLevel.PUBLIC def test_api_rooms_response_no_url(settings): diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index a855a20d..bc76b45f 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -908,6 +908,18 @@ class Base(Configuration): environ_name="APPLICATION_BASE_URL", environ_prefix=None, ) + # Warning: EXTERNAL_API_ALLOW_PUBLIC_ACCESS is ignored when + # EXTERNAL_API_DEFAULT_ACCESS_LEVEL=public. + EXTERNAL_API_ALLOW_PUBLIC_ACCESS = values.BooleanValue( + False, + environ_name="EXTERNAL_API_ALLOW_PUBLIC_ACCESS", + environ_prefix=None, + ) + EXTERNAL_API_DEFAULT_ACCESS_LEVEL = values.Value( + "trusted", + environ_name="EXTERNAL_API_DEFAULT_ACCESS_LEVEL", + environ_prefix=None, + ) # Allows third-party platforms to create users with email-only identification. # Required for external integrations, but fragile due to deferred user reconciliation # on sub. Enable it with care /!\