Skip to content

Commit 91cf5f9

Browse files
sampaccoudAntoLC
authored andcommitted
🔧(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)
1 parent 5cc4b07 commit 91cf5f9

File tree

8 files changed

+165
-17
lines changed

8 files changed

+165
-17
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ and this project adheres to
1616
- ✨(frontend) cursor display on activity #609
1717
- ✨(frontend) Add export page break #623
1818

19+
## Changed
20+
21+
- 🔧(backend) make AI feature reach configurable
22+
1923
## Fixed
2024

2125
🌐(CI) Fix email partially translated #616

UPGRADE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,8 @@ the following command inside your docker container:
1515
(Note : in your development environment, you can `make migrate`.)
1616

1717
## [Unreleased]
18+
19+
- AI features are now limited to users who are authenticated. Before this release, even anonymous
20+
users who gained editor access on a document with link reach used to get AI feature.
21+
IF you want anonymous users to keep access on AI features, you must now define the
22+
`AI_ALLOW_REACH_FROM` setting to "public".

src/backend/core/models.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,9 @@ def get_abilities(self, user):
629629
# which date to allow them anyway)
630630
# Anonymous users should also not see document accesses
631631
has_access_role = bool(roles) and not is_deleted
632+
can_update_from_access = (
633+
is_owner_or_admin or RoleChoices.EDITOR in roles
634+
) and not is_deleted
632635

633636
# Add roles provided by the document link, taking into account its ancestors
634637

@@ -647,11 +650,23 @@ def get_abilities(self, user):
647650
is_owner_or_admin or RoleChoices.EDITOR in roles
648651
) and not is_deleted
649652

653+
ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM
654+
ai_access = any(
655+
[
656+
ai_allow_reach_from == LinkReachChoices.PUBLIC and can_update,
657+
ai_allow_reach_from == LinkReachChoices.AUTHENTICATED
658+
and user.is_authenticated
659+
and can_update,
660+
ai_allow_reach_from == LinkReachChoices.RESTRICTED
661+
and can_update_from_access,
662+
]
663+
)
664+
650665
return {
651666
"accesses_manage": is_owner_or_admin,
652667
"accesses_view": has_access_role,
653-
"ai_transform": can_update,
654-
"ai_translate": can_update,
668+
"ai_transform": ai_access,
669+
"ai_translate": ai_access,
655670
"attachment_upload": can_update,
656671
"children_list": can_get,
657672
"children_create": can_update and user.is_authenticated,

src/backend/core/tests/documents/test_api_documents_ai_transform.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Test AI transform API endpoint for users in impress's core app.
33
"""
44

5+
import random
56
from unittest.mock import MagicMock, patch
67

78
from django.core.cache import cache
@@ -31,6 +32,9 @@ def ai_settings():
3132
yield
3233

3334

35+
@override_settings(
36+
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
37+
)
3438
@pytest.mark.parametrize(
3539
"reach, role",
3640
[
@@ -57,6 +61,7 @@ def test_api_documents_ai_transform_anonymous_forbidden(reach, role):
5761
}
5862

5963

64+
@override_settings(AI_ALLOW_REACH_FROM="public")
6065
@pytest.mark.usefixtures("ai_settings")
6166
@patch("openai.resources.chat.completions.Completions.create")
6267
def test_api_documents_ai_transform_anonymous_success(mock_create):
@@ -93,6 +98,27 @@ def test_api_documents_ai_transform_anonymous_success(mock_create):
9398
)
9499

95100

101+
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
102+
@pytest.mark.usefixtures("ai_settings")
103+
@patch("openai.resources.chat.completions.Completions.create")
104+
def test_api_documents_ai_transform_anonymous_limited_by_setting(mock_create):
105+
"""
106+
Anonymous users should be able to request AI transform to a document
107+
if the link reach and role permit it.
108+
"""
109+
document = factories.DocumentFactory(link_reach="public", link_role="editor")
110+
111+
answer = '{"answer": "Salut"}'
112+
mock_create.return_value = MagicMock(
113+
choices=[MagicMock(message=MagicMock(content=answer))]
114+
)
115+
116+
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
117+
response = APIClient().post(url, {"text": "Hello", "action": "summarize"})
118+
119+
assert response.status_code == 401
120+
121+
96122
@pytest.mark.parametrize(
97123
"reach, role",
98124
[

src/backend/core/tests/documents/test_api_documents_ai_translate.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Test AI translate API endpoint for users in impress's core app.
33
"""
44

5+
import random
56
from unittest.mock import MagicMock, patch
67

78
from django.core.cache import cache
@@ -51,6 +52,9 @@ def test_api_documents_ai_translate_viewset_options_metadata():
5152
}
5253

5354

55+
@override_settings(
56+
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
57+
)
5458
@pytest.mark.parametrize(
5559
"reach, role",
5660
[
@@ -77,6 +81,7 @@ def test_api_documents_ai_translate_anonymous_forbidden(reach, role):
7781
}
7882

7983

84+
@override_settings(AI_ALLOW_REACH_FROM="public")
8085
@pytest.mark.usefixtures("ai_settings")
8186
@patch("openai.resources.chat.completions.Completions.create")
8287
def test_api_documents_ai_translate_anonymous_success(mock_create):
@@ -113,6 +118,27 @@ def test_api_documents_ai_translate_anonymous_success(mock_create):
113118
)
114119

115120

121+
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
122+
@pytest.mark.usefixtures("ai_settings")
123+
@patch("openai.resources.chat.completions.Completions.create")
124+
def test_api_documents_ai_translate_anonymous_limited_by_setting(mock_create):
125+
"""
126+
Anonymous users should be able to request AI translate to a document
127+
if the link reach and role permit it.
128+
"""
129+
document = factories.DocumentFactory(link_reach="public", link_role="editor")
130+
131+
answer = '{"answer": "Salut"}'
132+
mock_create.return_value = MagicMock(
133+
choices=[MagicMock(message=MagicMock(content=answer))]
134+
)
135+
136+
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
137+
response = APIClient().post(url, {"text": "Hello", "language": "es"})
138+
139+
assert response.status_code == 401
140+
141+
116142
@pytest.mark.parametrize(
117143
"reach, role",
118144
[

src/backend/core/tests/documents/test_api_documents_retrieve.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ def test_api_documents_retrieve_anonymous_public_standalone():
2828
"abilities": {
2929
"accesses_manage": False,
3030
"accesses_view": False,
31-
"ai_transform": document.link_role == "editor",
32-
"ai_translate": document.link_role == "editor",
31+
"ai_transform": False,
32+
"ai_translate": False,
3333
"attachment_upload": document.link_role == "editor",
3434
"children_create": False,
3535
"children_list": True,
@@ -84,8 +84,8 @@ def test_api_documents_retrieve_anonymous_public_parent():
8484
"abilities": {
8585
"accesses_manage": False,
8686
"accesses_view": False,
87-
"ai_transform": grand_parent.link_role == "editor",
88-
"ai_translate": grand_parent.link_role == "editor",
87+
"ai_transform": False,
88+
"ai_translate": False,
8989
"attachment_upload": grand_parent.link_role == "editor",
9090
"children_create": False,
9191
"children_list": True,

src/backend/core/tests/test_models_documents.py

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from django.core.cache import cache
1313
from django.core.exceptions import ValidationError
1414
from django.core.files.storage import default_storage
15+
from django.test.utils import override_settings
1516
from django.utils import timezone
1617

1718
import pytest
@@ -124,6 +125,9 @@ def test_models_documents_soft_delete(depth):
124125
# get_abilities
125126

126127

128+
@override_settings(
129+
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
130+
)
127131
@pytest.mark.parametrize(
128132
"is_authenticated,reach,role",
129133
[
@@ -175,6 +179,9 @@ def test_models_documents_get_abilities_forbidden(
175179
assert document.get_abilities(user) == expected_abilities
176180

177181

182+
@override_settings(
183+
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
184+
)
178185
@pytest.mark.parametrize(
179186
"is_authenticated,reach",
180187
[
@@ -243,8 +250,8 @@ def test_models_documents_get_abilities_editor(
243250
expected_abilities = {
244251
"accesses_manage": False,
245252
"accesses_view": False,
246-
"ai_transform": True,
247-
"ai_translate": True,
253+
"ai_transform": is_authenticated,
254+
"ai_translate": is_authenticated,
248255
"attachment_upload": True,
249256
"children_create": is_authenticated,
250257
"children_list": True,
@@ -271,6 +278,9 @@ def test_models_documents_get_abilities_editor(
271278
assert all(value is False for value in document.get_abilities(user).values())
272279

273280

281+
@override_settings(
282+
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
283+
)
274284
def test_models_documents_get_abilities_owner(django_assert_num_queries):
275285
"""Check abilities returned for the owner of a document."""
276286
user = factories.UserFactory()
@@ -300,12 +310,16 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
300310
}
301311
with django_assert_num_queries(1):
302312
assert document.get_abilities(user) == expected_abilities
313+
303314
document.soft_delete()
304315
document.refresh_from_db()
305316
expected_abilities["move"] = False
306317
assert document.get_abilities(user) == expected_abilities
307318

308319

320+
@override_settings(
321+
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
322+
)
309323
def test_models_documents_get_abilities_administrator(django_assert_num_queries):
310324
"""Check abilities returned for the administrator of a document."""
311325
user = factories.UserFactory()
@@ -335,11 +349,15 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
335349
}
336350
with django_assert_num_queries(1):
337351
assert document.get_abilities(user) == expected_abilities
352+
338353
document.soft_delete()
339354
document.refresh_from_db()
340355
assert all(value is False for value in document.get_abilities(user).values())
341356

342357

358+
@override_settings(
359+
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
360+
)
343361
def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
344362
"""Check abilities returned for the editor of a document."""
345363
user = factories.UserFactory()
@@ -369,23 +387,31 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
369387
}
370388
with django_assert_num_queries(1):
371389
assert document.get_abilities(user) == expected_abilities
390+
372391
document.soft_delete()
373392
document.refresh_from_db()
374393
assert all(value is False for value in document.get_abilities(user).values())
375394

376395

377-
def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
396+
@pytest.mark.parametrize("ai_access_setting", ["public", "authenticated", "restricted"])
397+
def test_models_documents_get_abilities_reader_user(
398+
ai_access_setting, django_assert_num_queries
399+
):
378400
"""Check abilities returned for the reader of a document."""
379401
user = factories.UserFactory()
380402
document = factories.DocumentFactory(users=[(user, "reader")])
403+
381404
access_from_link = (
382405
document.link_reach != "restricted" and document.link_role == "editor"
383406
)
407+
384408
expected_abilities = {
385409
"accesses_manage": False,
386410
"accesses_view": True,
387-
"ai_transform": access_from_link,
388-
"ai_translate": access_from_link,
411+
# If you get your editor rights from the link role and not your access role
412+
# You should not access AI if it's restricted to users with specific access
413+
"ai_transform": access_from_link and ai_access_setting != "restricted",
414+
"ai_translate": access_from_link and ai_access_setting != "restricted",
389415
"attachment_upload": access_from_link,
390416
"children_create": access_from_link,
391417
"children_list": True,
@@ -404,11 +430,14 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
404430
"versions_list": True,
405431
"versions_retrieve": True,
406432
}
407-
with django_assert_num_queries(1):
408-
assert document.get_abilities(user) == expected_abilities
409-
document.soft_delete()
410-
document.refresh_from_db()
411-
assert all(value is False for value in document.get_abilities(user).values())
433+
434+
with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting):
435+
with django_assert_num_queries(1):
436+
assert document.get_abilities(user) == expected_abilities
437+
438+
document.soft_delete()
439+
document.refresh_from_db()
440+
assert all(value is False for value in document.get_abilities(user).values())
412441

413442

414443
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):
446475
}
447476

448477

478+
@override_settings(AI_ALLOW_REACH_FROM="public")
479+
@pytest.mark.parametrize(
480+
"is_authenticated,reach",
481+
[
482+
(True, "public"),
483+
(False, "public"),
484+
(True, "authenticated"),
485+
],
486+
)
487+
def test_models_document_get_abilities_ai_access_authenticated(is_authenticated, reach):
488+
"""Validate AI abilities when AI is available to any anonymous user with editor rights."""
489+
user = factories.UserFactory() if is_authenticated else AnonymousUser()
490+
document = factories.DocumentFactory(link_reach=reach, link_role="editor")
491+
492+
abilities = document.get_abilities(user)
493+
assert abilities["ai_transform"] is True
494+
assert abilities["ai_translate"] is True
495+
496+
497+
@override_settings(AI_ALLOW_REACH_FROM="authenticated")
498+
@pytest.mark.parametrize(
499+
"is_authenticated,reach",
500+
[
501+
(True, "public"),
502+
(False, "public"),
503+
(True, "authenticated"),
504+
],
505+
)
506+
def test_models_document_get_abilities_ai_access_public(is_authenticated, reach):
507+
"""Validate AI abilities when AI is available only to authenticated users with editor rights."""
508+
user = factories.UserFactory() if is_authenticated else AnonymousUser()
509+
document = factories.DocumentFactory(link_reach=reach, link_role="editor")
510+
511+
abilities = document.get_abilities(user)
512+
assert abilities["ai_transform"] == is_authenticated
513+
assert abilities["ai_translate"] == is_authenticated
514+
515+
449516
def test_models_documents_get_versions_slice_pagination(settings):
450517
"""
451518
The "get_versions_slice" method should allow navigating all versions of

src/backend/impress/settings.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,12 @@ class Base(Configuration):
516516
AI_API_KEY = values.Value(None, environ_name="AI_API_KEY", environ_prefix=None)
517517
AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None)
518518
AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None)
519-
519+
AI_ALLOW_REACH_FROM = values.Value(
520+
choices=("public", "authenticated", "restricted"),
521+
default="authenticated",
522+
environ_name="AI_ALLOW_REACH_FROM",
523+
environ_prefix=None,
524+
)
520525
AI_DOCUMENT_RATE_THROTTLE_RATES = {
521526
"minute": 5,
522527
"hour": 100,

0 commit comments

Comments
 (0)