🐛(backend) allow using search endpoint without refresh token enabled
The search endpoint was using the refresh_roken method decorator. This decorator force having a valid refresh token stored in the session for the entire viewset. The search endpoint still allow having the legacy search behavior and for this we don't need to configure at all the OIDC refrsh mechanism.
This commit is contained in:
@@ -21,6 +21,10 @@ and this project adheres to
|
|||||||
|
|
||||||
- ♿️(frontend) fix aria-labels for table of contents #2065
|
- ♿️(frontend) fix aria-labels for table of contents #2065
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(backend) allow using search endpoint without refresh token enabled #2097
|
||||||
|
|
||||||
## [v4.8.2] - 2026-03-19
|
## [v4.8.2] - 2026-03-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ OIDC_RS_AUDIENCE_CLAIM="client_id" # The claim used to identify the audience
|
|||||||
OIDC_RS_ALLOWED_AUDIENCES=""
|
OIDC_RS_ALLOWED_AUDIENCES=""
|
||||||
|
|
||||||
# Store OIDC tokens in the session. Needed by search/ endpoint.
|
# Store OIDC tokens in the session. Needed by search/ endpoint.
|
||||||
OIDC_STORE_ACCESS_TOKEN=True
|
# OIDC_STORE_ACCESS_TOKEN=True
|
||||||
OIDC_STORE_REFRESH_TOKEN=True # Store the encrypted refresh token in the session.
|
# OIDC_STORE_REFRESH_TOKEN=True # Store the encrypted refresh token in the session.
|
||||||
|
|
||||||
# Must be a valid Fernet key (32 url-safe base64-encoded bytes)
|
# Must be a valid Fernet key (32 url-safe base64-encoded bytes)
|
||||||
# To create one, use the bin/fernetkey command.
|
# To create one, use the bin/fernetkey command.
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ from abc import ABC, abstractmethod
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
|
||||||
import botocore
|
import botocore
|
||||||
|
from lasuite.oidc_login.decorators import refresh_oidc_access_token
|
||||||
from rest_framework.throttling import BaseThrottle
|
from rest_framework.throttling import BaseThrottle
|
||||||
|
|
||||||
|
|
||||||
@@ -91,6 +93,19 @@ def generate_s3_authorization_headers(key):
|
|||||||
return request
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
def conditional_refresh_oidc_token(func):
|
||||||
|
"""
|
||||||
|
Conditionally apply refresh_oidc_access_token decorator.
|
||||||
|
|
||||||
|
The decorator is only applied if OIDC_STORE_REFRESH_TOKEN is True, meaning
|
||||||
|
we can actually refresh something. Broader settings checks are done in settings.py.
|
||||||
|
"""
|
||||||
|
if settings.OIDC_STORE_REFRESH_TOKEN:
|
||||||
|
return method_decorator(refresh_oidc_access_token)(func)
|
||||||
|
|
||||||
|
return func
|
||||||
|
|
||||||
|
|
||||||
class AIBaseRateThrottle(BaseThrottle, ABC):
|
class AIBaseRateThrottle(BaseThrottle, ABC):
|
||||||
"""Base throttle class for AI-related rate limiting with backoff."""
|
"""Base throttle class for AI-related rate limiting with backoff."""
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ from django.db.models.functions import Greatest, Left, Length
|
|||||||
from django.http import Http404, StreamingHttpResponse
|
from django.http import Http404, StreamingHttpResponse
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.decorators import method_decorator
|
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.http import content_disposition_header
|
from django.utils.http import content_disposition_header
|
||||||
from django.utils.text import capfirst, slugify
|
from django.utils.text import capfirst, slugify
|
||||||
@@ -38,7 +37,6 @@ from botocore.exceptions import ClientError
|
|||||||
from csp.constants import NONE
|
from csp.constants import NONE
|
||||||
from csp.decorators import csp_update
|
from csp.decorators import csp_update
|
||||||
from lasuite.malware_detection import malware_detection
|
from lasuite.malware_detection import malware_detection
|
||||||
from lasuite.oidc_login.decorators import refresh_oidc_access_token
|
|
||||||
from lasuite.tools.email import get_domain_from_email
|
from lasuite.tools.email import get_domain_from_email
|
||||||
from pydantic import ValidationError as PydanticValidationError
|
from pydantic import ValidationError as PydanticValidationError
|
||||||
from rest_framework import filters, status, viewsets
|
from rest_framework import filters, status, viewsets
|
||||||
@@ -1415,7 +1413,7 @@ class DocumentViewSet(
|
|||||||
return duplicated_document
|
return duplicated_document
|
||||||
|
|
||||||
@drf.decorators.action(detail=False, methods=["get"], url_path="search")
|
@drf.decorators.action(detail=False, methods=["get"], url_path="search")
|
||||||
@method_decorator(refresh_oidc_access_token)
|
@utils.conditional_refresh_oidc_token
|
||||||
def search(self, request, *args, **kwargs):
|
def search(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Returns an ordered list of documents best matching the search query parameter 'q'.
|
Returns an ordered list of documents best matching the search query parameter 'q'.
|
||||||
@@ -1426,7 +1424,6 @@ class DocumentViewSet(
|
|||||||
params = serializers.SearchDocumentSerializer(data=request.query_params)
|
params = serializers.SearchDocumentSerializer(data=request.query_params)
|
||||||
params.is_valid(raise_exception=True)
|
params.is_valid(raise_exception=True)
|
||||||
search_type = self._get_search_type()
|
search_type = self._get_search_type()
|
||||||
|
|
||||||
if search_type == SearchType.TITLE:
|
if search_type == SearchType.TITLE:
|
||||||
return self._title_search(request, params.validated_data, *args, **kwargs)
|
return self._title_search(request, params.validated_data, *args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -70,17 +70,20 @@ def test_api_documents_search_anonymous(search_query, indexer_settings):
|
|||||||
|
|
||||||
|
|
||||||
@mock.patch("core.api.viewsets.DocumentViewSet.list")
|
@mock.patch("core.api.viewsets.DocumentViewSet.list")
|
||||||
def test_api_documents_search_fall_back_on_search_list(mock_list, indexer_settings):
|
def test_api_documents_search_fall_back_on_search_list(mock_list, settings):
|
||||||
"""
|
"""
|
||||||
When indexer is not configured and no path is provided,
|
When indexer is not configured and no path is provided,
|
||||||
should fall back on list method
|
should fall back on list method
|
||||||
"""
|
"""
|
||||||
indexer_settings.SEARCH_URL = None
|
|
||||||
assert get_document_indexer() is None
|
assert get_document_indexer() is None
|
||||||
|
assert settings.OIDC_STORE_REFRESH_TOKEN is False
|
||||||
|
assert settings.OIDC_STORE_ACCESS_TOKEN is False
|
||||||
|
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(
|
||||||
|
user, backend="core.authentication.backends.OIDCAuthenticationBackend"
|
||||||
|
)
|
||||||
|
|
||||||
mocked_response = {
|
mocked_response = {
|
||||||
"count": 0,
|
"count": 0,
|
||||||
@@ -93,6 +96,8 @@ def test_api_documents_search_fall_back_on_search_list(mock_list, indexer_settin
|
|||||||
q = "alpha"
|
q = "alpha"
|
||||||
response = client.get("/api/v1.0/documents/search/", data={"q": q})
|
response = client.get("/api/v1.0/documents/search/", data={"q": q})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
assert mock_list.call_count == 1
|
assert mock_list.call_count == 1
|
||||||
assert mock_list.call_args[0][0].GET.get("q") == q
|
assert mock_list.call_args[0][0].GET.get("q") == q
|
||||||
assert response.json() == mocked_response
|
assert response.json() == mocked_response
|
||||||
@@ -100,18 +105,21 @@ def test_api_documents_search_fall_back_on_search_list(mock_list, indexer_settin
|
|||||||
|
|
||||||
@mock.patch("core.api.viewsets.DocumentViewSet._list_descendants")
|
@mock.patch("core.api.viewsets.DocumentViewSet._list_descendants")
|
||||||
def test_api_documents_search_fallback_on_search_list_sub_docs(
|
def test_api_documents_search_fallback_on_search_list_sub_docs(
|
||||||
mock_list_descendants, indexer_settings
|
mock_list_descendants, settings
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
When indexer is not configured and path parameter is provided,
|
When indexer is not configured and path parameter is provided,
|
||||||
should call _list_descendants() method
|
should call _list_descendants() method
|
||||||
"""
|
"""
|
||||||
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
|
assert get_document_indexer() is None
|
||||||
assert get_document_indexer() is not None
|
assert settings.OIDC_STORE_REFRESH_TOKEN is False
|
||||||
|
assert settings.OIDC_STORE_ACCESS_TOKEN is False
|
||||||
|
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(
|
||||||
|
user, backend="core.authentication.backends.OIDCAuthenticationBackend"
|
||||||
|
)
|
||||||
|
|
||||||
parent = factories.DocumentFactory(title="parent", users=[user])
|
parent = factories.DocumentFactory(title="parent", users=[user])
|
||||||
|
|
||||||
@@ -128,9 +136,9 @@ def test_api_documents_search_fallback_on_search_list_sub_docs(
|
|||||||
"/api/v1.0/documents/search/", data={"q": q, "path": parent.path}
|
"/api/v1.0/documents/search/", data={"q": q, "path": parent.path}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert mock_list_descendants.call_count == 1
|
mock_list_descendants.assert_called_with(
|
||||||
assert mock_list_descendants.call_args[0][0].GET.get("q") == q
|
mock.ANY, {"q": "alpha", "path": parent.path}
|
||||||
assert mock_list_descendants.call_args[0][0].GET.get("path") == parent.path
|
)
|
||||||
assert response.json() == mocked_response
|
assert response.json() == mocked_response
|
||||||
|
|
||||||
|
|
||||||
@@ -152,7 +160,9 @@ def test_api_documents_search_indexer_crashes(mock_title_search, indexer_setting
|
|||||||
|
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(
|
||||||
|
user, backend="core.authentication.backends.OIDCAuthenticationBackend"
|
||||||
|
)
|
||||||
|
|
||||||
mocked_response = {
|
mocked_response = {
|
||||||
"count": 0,
|
"count": 0,
|
||||||
@@ -185,7 +195,9 @@ def test_api_documents_search_invalid_params(indexer_settings):
|
|||||||
|
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(
|
||||||
|
user, backend="core.authentication.backends.OIDCAuthenticationBackend"
|
||||||
|
)
|
||||||
|
|
||||||
response = client.get("/api/v1.0/documents/search/")
|
response = client.get("/api/v1.0/documents/search/")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""module testing the conditional_refresh_oidc_token utils."""
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from core.api import utils
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_oidc_access_token_storing_refresh_token_disabled(settings):
|
||||||
|
"""The method_decorator must not be called when OIDC_STORE_REFRESH_TOKEN is False."""
|
||||||
|
|
||||||
|
settings.OIDC_STORE_REFRESH_TOKEN = False
|
||||||
|
|
||||||
|
callback = mock.MagicMock()
|
||||||
|
|
||||||
|
with mock.patch.object(utils, "method_decorator") as mock_method_decorator:
|
||||||
|
result = utils.conditional_refresh_oidc_token(callback)
|
||||||
|
|
||||||
|
mock_method_decorator.assert_not_called()
|
||||||
|
assert result == callback
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_oidc_access_token_storing_refresh_token_enabled(settings):
|
||||||
|
"""The method_decorator must not be called when OIDC_STORE_REFRESH_TOKEN is False."""
|
||||||
|
|
||||||
|
settings.OIDC_STORE_REFRESH_TOKEN = True
|
||||||
|
|
||||||
|
callback = mock.MagicMock()
|
||||||
|
|
||||||
|
with mock.patch.object(utils, "method_decorator") as mock_method_decorator:
|
||||||
|
utils.conditional_refresh_oidc_token(callback)
|
||||||
|
|
||||||
|
mock_method_decorator.assert_called_with(utils.refresh_oidc_access_token)
|
||||||
Reference in New Issue
Block a user