diff --git a/CHANGELOG.md b/CHANGELOG.md index 79c5a72b..881b4274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ and this project adheres to - ✨(frontend) cursor display on activity #609 - ✨(frontend) Add export page break #623 +## Changed + +- 🔧(backend) make AI feature reach configurable + ## Fixed 🌐(CI) Fix email partially translated #616 diff --git a/UPGRADE.md b/UPGRADE.md index a905f77a..5283dd97 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -15,3 +15,8 @@ the following command inside your docker container: (Note : in your development environment, you can `make migrate`.) ## [Unreleased] + +- AI features are now limited to users who are authenticated. Before this release, even anonymous + users who gained editor access on a document with link reach used to get AI feature. + IF you want anonymous users to keep access on AI features, you must now define the + `AI_ALLOW_REACH_FROM` setting to "public". diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 4aa4de87..7ff3daba 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -629,6 +629,9 @@ class Document(MP_Node, BaseModel): # which date to allow them anyway) # Anonymous users should also not see document accesses has_access_role = bool(roles) and not is_deleted + can_update_from_access = ( + is_owner_or_admin or RoleChoices.EDITOR in roles + ) and not is_deleted # Add roles provided by the document link, taking into account its ancestors @@ -647,11 +650,23 @@ class Document(MP_Node, BaseModel): is_owner_or_admin or RoleChoices.EDITOR in roles ) and not is_deleted + ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM + ai_access = any( + [ + ai_allow_reach_from == LinkReachChoices.PUBLIC and can_update, + ai_allow_reach_from == LinkReachChoices.AUTHENTICATED + and user.is_authenticated + and can_update, + ai_allow_reach_from == LinkReachChoices.RESTRICTED + and can_update_from_access, + ] + ) + return { "accesses_manage": is_owner_or_admin, "accesses_view": has_access_role, - "ai_transform": can_update, - "ai_translate": can_update, + "ai_transform": ai_access, + "ai_translate": ai_access, "attachment_upload": can_update, "children_list": can_get, "children_create": can_update and user.is_authenticated, diff --git a/src/backend/core/tests/documents/test_api_documents_ai_transform.py b/src/backend/core/tests/documents/test_api_documents_ai_transform.py index 91e16e4a..44a3b9cc 100644 --- a/src/backend/core/tests/documents/test_api_documents_ai_transform.py +++ b/src/backend/core/tests/documents/test_api_documents_ai_transform.py @@ -2,6 +2,7 @@ Test AI transform API endpoint for users in impress's core app. """ +import random from unittest.mock import MagicMock, patch from django.core.cache import cache @@ -31,6 +32,9 @@ def ai_settings(): yield +@override_settings( + AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"]) +) @pytest.mark.parametrize( "reach, role", [ @@ -57,6 +61,7 @@ def test_api_documents_ai_transform_anonymous_forbidden(reach, role): } +@override_settings(AI_ALLOW_REACH_FROM="public") @pytest.mark.usefixtures("ai_settings") @patch("openai.resources.chat.completions.Completions.create") def test_api_documents_ai_transform_anonymous_success(mock_create): @@ -93,6 +98,27 @@ def test_api_documents_ai_transform_anonymous_success(mock_create): ) +@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"])) +@pytest.mark.usefixtures("ai_settings") +@patch("openai.resources.chat.completions.Completions.create") +def test_api_documents_ai_transform_anonymous_limited_by_setting(mock_create): + """ + Anonymous users should be able to request AI transform to a document + if the link reach and role permit it. + """ + document = factories.DocumentFactory(link_reach="public", link_role="editor") + + answer = '{"answer": "Salut"}' + mock_create.return_value = MagicMock( + choices=[MagicMock(message=MagicMock(content=answer))] + ) + + url = f"/api/v1.0/documents/{document.id!s}/ai-transform/" + response = APIClient().post(url, {"text": "Hello", "action": "summarize"}) + + assert response.status_code == 401 + + @pytest.mark.parametrize( "reach, role", [ diff --git a/src/backend/core/tests/documents/test_api_documents_ai_translate.py b/src/backend/core/tests/documents/test_api_documents_ai_translate.py index 21547e7a..55a084a4 100644 --- a/src/backend/core/tests/documents/test_api_documents_ai_translate.py +++ b/src/backend/core/tests/documents/test_api_documents_ai_translate.py @@ -2,6 +2,7 @@ Test AI translate API endpoint for users in impress's core app. """ +import random from unittest.mock import MagicMock, patch from django.core.cache import cache @@ -51,6 +52,9 @@ def test_api_documents_ai_translate_viewset_options_metadata(): } +@override_settings( + AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"]) +) @pytest.mark.parametrize( "reach, role", [ @@ -77,6 +81,7 @@ def test_api_documents_ai_translate_anonymous_forbidden(reach, role): } +@override_settings(AI_ALLOW_REACH_FROM="public") @pytest.mark.usefixtures("ai_settings") @patch("openai.resources.chat.completions.Completions.create") def test_api_documents_ai_translate_anonymous_success(mock_create): @@ -113,6 +118,27 @@ def test_api_documents_ai_translate_anonymous_success(mock_create): ) +@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"])) +@pytest.mark.usefixtures("ai_settings") +@patch("openai.resources.chat.completions.Completions.create") +def test_api_documents_ai_translate_anonymous_limited_by_setting(mock_create): + """ + Anonymous users should be able to request AI translate to a document + if the link reach and role permit it. + """ + document = factories.DocumentFactory(link_reach="public", link_role="editor") + + answer = '{"answer": "Salut"}' + mock_create.return_value = MagicMock( + choices=[MagicMock(message=MagicMock(content=answer))] + ) + + url = f"/api/v1.0/documents/{document.id!s}/ai-translate/" + response = APIClient().post(url, {"text": "Hello", "language": "es"}) + + assert response.status_code == 401 + + @pytest.mark.parametrize( "reach, role", [ diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py index 215164df..cc7ebfe5 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -28,8 +28,8 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "abilities": { "accesses_manage": False, "accesses_view": False, - "ai_transform": document.link_role == "editor", - "ai_translate": document.link_role == "editor", + "ai_transform": False, + "ai_translate": False, "attachment_upload": document.link_role == "editor", "children_create": False, "children_list": True, @@ -84,8 +84,8 @@ def test_api_documents_retrieve_anonymous_public_parent(): "abilities": { "accesses_manage": False, "accesses_view": False, - "ai_transform": grand_parent.link_role == "editor", - "ai_translate": grand_parent.link_role == "editor", + "ai_transform": False, + "ai_translate": False, "attachment_upload": grand_parent.link_role == "editor", "children_create": False, "children_list": True, diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index 028740cb..ca885256 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -12,6 +12,7 @@ from django.core import mail from django.core.cache import cache from django.core.exceptions import ValidationError from django.core.files.storage import default_storage +from django.test.utils import override_settings from django.utils import timezone import pytest @@ -124,6 +125,9 @@ def test_models_documents_soft_delete(depth): # get_abilities +@override_settings( + AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"]) +) @pytest.mark.parametrize( "is_authenticated,reach,role", [ @@ -175,6 +179,9 @@ def test_models_documents_get_abilities_forbidden( assert document.get_abilities(user) == expected_abilities +@override_settings( + AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"]) +) @pytest.mark.parametrize( "is_authenticated,reach", [ @@ -243,8 +250,8 @@ def test_models_documents_get_abilities_editor( expected_abilities = { "accesses_manage": False, "accesses_view": False, - "ai_transform": True, - "ai_translate": True, + "ai_transform": is_authenticated, + "ai_translate": is_authenticated, "attachment_upload": True, "children_create": is_authenticated, "children_list": True, @@ -271,6 +278,9 @@ def test_models_documents_get_abilities_editor( assert all(value is False for value in document.get_abilities(user).values()) +@override_settings( + AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"]) +) def test_models_documents_get_abilities_owner(django_assert_num_queries): """Check abilities returned for the owner of a document.""" user = factories.UserFactory() @@ -300,12 +310,16 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): } with django_assert_num_queries(1): assert document.get_abilities(user) == expected_abilities + document.soft_delete() document.refresh_from_db() expected_abilities["move"] = False assert document.get_abilities(user) == expected_abilities +@override_settings( + AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"]) +) def test_models_documents_get_abilities_administrator(django_assert_num_queries): """Check abilities returned for the administrator of a document.""" user = factories.UserFactory() @@ -335,11 +349,15 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries) } with django_assert_num_queries(1): assert document.get_abilities(user) == expected_abilities + document.soft_delete() document.refresh_from_db() assert all(value is False for value in document.get_abilities(user).values()) +@override_settings( + AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"]) +) def test_models_documents_get_abilities_editor_user(django_assert_num_queries): """Check abilities returned for the editor of a document.""" user = factories.UserFactory() @@ -369,23 +387,31 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): } with django_assert_num_queries(1): assert document.get_abilities(user) == expected_abilities + document.soft_delete() document.refresh_from_db() assert all(value is False for value in document.get_abilities(user).values()) -def test_models_documents_get_abilities_reader_user(django_assert_num_queries): +@pytest.mark.parametrize("ai_access_setting", ["public", "authenticated", "restricted"]) +def test_models_documents_get_abilities_reader_user( + ai_access_setting, django_assert_num_queries +): """Check abilities returned for the reader of a document.""" user = factories.UserFactory() document = factories.DocumentFactory(users=[(user, "reader")]) + access_from_link = ( document.link_reach != "restricted" and document.link_role == "editor" ) + expected_abilities = { "accesses_manage": False, "accesses_view": True, - "ai_transform": access_from_link, - "ai_translate": access_from_link, + # If you get your editor rights from the link role and not your access role + # You should not access AI if it's restricted to users with specific access + "ai_transform": access_from_link and ai_access_setting != "restricted", + "ai_translate": access_from_link and ai_access_setting != "restricted", "attachment_upload": access_from_link, "children_create": access_from_link, "children_list": True, @@ -404,11 +430,14 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries): "versions_list": True, "versions_retrieve": True, } - with django_assert_num_queries(1): - assert document.get_abilities(user) == expected_abilities - document.soft_delete() - document.refresh_from_db() - assert all(value is False for value in document.get_abilities(user).values()) + + with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting): + with django_assert_num_queries(1): + assert document.get_abilities(user) == expected_abilities + + document.soft_delete() + document.refresh_from_db() + assert all(value is False for value in document.get_abilities(user).values()) def test_models_documents_get_abilities_preset_role(django_assert_num_queries): @@ -446,6 +475,44 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): } +@override_settings(AI_ALLOW_REACH_FROM="public") +@pytest.mark.parametrize( + "is_authenticated,reach", + [ + (True, "public"), + (False, "public"), + (True, "authenticated"), + ], +) +def test_models_document_get_abilities_ai_access_authenticated(is_authenticated, reach): + """Validate AI abilities when AI is available to any anonymous user with editor rights.""" + user = factories.UserFactory() if is_authenticated else AnonymousUser() + document = factories.DocumentFactory(link_reach=reach, link_role="editor") + + abilities = document.get_abilities(user) + assert abilities["ai_transform"] is True + assert abilities["ai_translate"] is True + + +@override_settings(AI_ALLOW_REACH_FROM="authenticated") +@pytest.mark.parametrize( + "is_authenticated,reach", + [ + (True, "public"), + (False, "public"), + (True, "authenticated"), + ], +) +def test_models_document_get_abilities_ai_access_public(is_authenticated, reach): + """Validate AI abilities when AI is available only to authenticated users with editor rights.""" + user = factories.UserFactory() if is_authenticated else AnonymousUser() + document = factories.DocumentFactory(link_reach=reach, link_role="editor") + + abilities = document.get_abilities(user) + assert abilities["ai_transform"] == is_authenticated + assert abilities["ai_translate"] == is_authenticated + + def test_models_documents_get_versions_slice_pagination(settings): """ The "get_versions_slice" method should allow navigating all versions of diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 8f470ad9..50fbba39 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -516,7 +516,12 @@ class Base(Configuration): AI_API_KEY = values.Value(None, environ_name="AI_API_KEY", environ_prefix=None) AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None) AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None) - + AI_ALLOW_REACH_FROM = values.Value( + choices=("public", "authenticated", "restricted"), + default="authenticated", + environ_name="AI_ALLOW_REACH_FROM", + environ_prefix=None, + ) AI_DOCUMENT_RATE_THROTTLE_RATES = { "minute": 5, "hour": 100,