🔧(backend) make AI feature reach configurable

We want to be able to define whether AI features are available to
anonymous users who gained editor access on a document, or if we
demand that they be authenticated or even if we demand that they
gained their editor access via a specific document access.

Being authenticated is now the default value. This will change the
default behavior on your existing instance (see UPGRADE.md)
This commit is contained in:
Samuel Paccoud - DINUM
2025-02-11 08:45:21 +01:00
committed by Anthony LC
parent 5cc4b07cf6
commit 91cf5f9367
8 changed files with 165 additions and 17 deletions

View File

@@ -16,6 +16,10 @@ and this project adheres to
- ✨(frontend) cursor display on activity #609 - ✨(frontend) cursor display on activity #609
- ✨(frontend) Add export page break #623 - ✨(frontend) Add export page break #623
## Changed
- 🔧(backend) make AI feature reach configurable
## Fixed ## Fixed
🌐(CI) Fix email partially translated #616 🌐(CI) Fix email partially translated #616

View File

@@ -15,3 +15,8 @@ the following command inside your docker container:
(Note : in your development environment, you can `make migrate`.) (Note : in your development environment, you can `make migrate`.)
## [Unreleased] ## [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".

View File

@@ -629,6 +629,9 @@ class Document(MP_Node, BaseModel):
# which date to allow them anyway) # which date to allow them anyway)
# Anonymous users should also not see document accesses # Anonymous users should also not see document accesses
has_access_role = bool(roles) and not is_deleted 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 # 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 is_owner_or_admin or RoleChoices.EDITOR in roles
) and not is_deleted ) 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 { return {
"accesses_manage": is_owner_or_admin, "accesses_manage": is_owner_or_admin,
"accesses_view": has_access_role, "accesses_view": has_access_role,
"ai_transform": can_update, "ai_transform": ai_access,
"ai_translate": can_update, "ai_translate": ai_access,
"attachment_upload": can_update, "attachment_upload": can_update,
"children_list": can_get, "children_list": can_get,
"children_create": can_update and user.is_authenticated, "children_create": can_update and user.is_authenticated,

View File

@@ -2,6 +2,7 @@
Test AI transform API endpoint for users in impress's core app. Test AI transform API endpoint for users in impress's core app.
""" """
import random
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from django.core.cache import cache from django.core.cache import cache
@@ -31,6 +32,9 @@ def ai_settings():
yield yield
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"reach, role", "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") @pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create") @patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_anonymous_success(mock_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( @pytest.mark.parametrize(
"reach, role", "reach, role",
[ [

View File

@@ -2,6 +2,7 @@
Test AI translate API endpoint for users in impress's core app. Test AI translate API endpoint for users in impress's core app.
""" """
import random
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from django.core.cache import cache 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( @pytest.mark.parametrize(
"reach, role", "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") @pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create") @patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_anonymous_success(mock_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( @pytest.mark.parametrize(
"reach, role", "reach, role",
[ [

View File

@@ -28,8 +28,8 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"abilities": { "abilities": {
"accesses_manage": False, "accesses_manage": False,
"accesses_view": False, "accesses_view": False,
"ai_transform": document.link_role == "editor", "ai_transform": False,
"ai_translate": document.link_role == "editor", "ai_translate": False,
"attachment_upload": document.link_role == "editor", "attachment_upload": document.link_role == "editor",
"children_create": False, "children_create": False,
"children_list": True, "children_list": True,
@@ -84,8 +84,8 @@ def test_api_documents_retrieve_anonymous_public_parent():
"abilities": { "abilities": {
"accesses_manage": False, "accesses_manage": False,
"accesses_view": False, "accesses_view": False,
"ai_transform": grand_parent.link_role == "editor", "ai_transform": False,
"ai_translate": grand_parent.link_role == "editor", "ai_translate": False,
"attachment_upload": grand_parent.link_role == "editor", "attachment_upload": grand_parent.link_role == "editor",
"children_create": False, "children_create": False,
"children_list": True, "children_list": True,

View File

@@ -12,6 +12,7 @@ from django.core import mail
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.test.utils import override_settings
from django.utils import timezone from django.utils import timezone
import pytest import pytest
@@ -124,6 +125,9 @@ def test_models_documents_soft_delete(depth):
# get_abilities # get_abilities
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"is_authenticated,reach,role", "is_authenticated,reach,role",
[ [
@@ -175,6 +179,9 @@ def test_models_documents_get_abilities_forbidden(
assert document.get_abilities(user) == expected_abilities assert document.get_abilities(user) == expected_abilities
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"is_authenticated,reach", "is_authenticated,reach",
[ [
@@ -243,8 +250,8 @@ def test_models_documents_get_abilities_editor(
expected_abilities = { expected_abilities = {
"accesses_manage": False, "accesses_manage": False,
"accesses_view": False, "accesses_view": False,
"ai_transform": True, "ai_transform": is_authenticated,
"ai_translate": True, "ai_translate": is_authenticated,
"attachment_upload": True, "attachment_upload": True,
"children_create": is_authenticated, "children_create": is_authenticated,
"children_list": True, "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()) 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): def test_models_documents_get_abilities_owner(django_assert_num_queries):
"""Check abilities returned for the owner of a document.""" """Check abilities returned for the owner of a document."""
user = factories.UserFactory() user = factories.UserFactory()
@@ -300,12 +310,16 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
} }
with django_assert_num_queries(1): with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities assert document.get_abilities(user) == expected_abilities
document.soft_delete() document.soft_delete()
document.refresh_from_db() document.refresh_from_db()
expected_abilities["move"] = False expected_abilities["move"] = False
assert document.get_abilities(user) == expected_abilities 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): def test_models_documents_get_abilities_administrator(django_assert_num_queries):
"""Check abilities returned for the administrator of a document.""" """Check abilities returned for the administrator of a document."""
user = factories.UserFactory() user = factories.UserFactory()
@@ -335,11 +349,15 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
} }
with django_assert_num_queries(1): with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities assert document.get_abilities(user) == expected_abilities
document.soft_delete() document.soft_delete()
document.refresh_from_db() document.refresh_from_db()
assert all(value is False for value in document.get_abilities(user).values()) 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): def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"""Check abilities returned for the editor of a document.""" """Check abilities returned for the editor of a document."""
user = factories.UserFactory() 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): with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities assert document.get_abilities(user) == expected_abilities
document.soft_delete() document.soft_delete()
document.refresh_from_db() document.refresh_from_db()
assert all(value is False for value in document.get_abilities(user).values()) 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.""" """Check abilities returned for the reader of a document."""
user = factories.UserFactory() user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "reader")]) document = factories.DocumentFactory(users=[(user, "reader")])
access_from_link = ( access_from_link = (
document.link_reach != "restricted" and document.link_role == "editor" document.link_reach != "restricted" and document.link_role == "editor"
) )
expected_abilities = { expected_abilities = {
"accesses_manage": False, "accesses_manage": False,
"accesses_view": True, "accesses_view": True,
"ai_transform": access_from_link, # If you get your editor rights from the link role and not your access role
"ai_translate": access_from_link, # 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, "attachment_upload": access_from_link,
"children_create": access_from_link, "children_create": access_from_link,
"children_list": True, "children_list": True,
@@ -404,11 +430,14 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
"versions_list": True, "versions_list": True,
"versions_retrieve": True, "versions_retrieve": True,
} }
with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting):
document.soft_delete() with django_assert_num_queries(1):
document.refresh_from_db() assert document.get_abilities(user) == expected_abilities
assert all(value is False for value in document.get_abilities(user).values())
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): 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): def test_models_documents_get_versions_slice_pagination(settings):
""" """
The "get_versions_slice" method should allow navigating all versions of The "get_versions_slice" method should allow navigating all versions of

View File

@@ -516,7 +516,12 @@ class Base(Configuration):
AI_API_KEY = values.Value(None, environ_name="AI_API_KEY", environ_prefix=None) 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_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_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 = { AI_DOCUMENT_RATE_THROTTLE_RATES = {
"minute": 5, "minute": 5,
"hour": 100, "hour": 100,