✨(backend) setup search api view as OIDC resource server
New fulltext search view for indexed documents with OIDC authentication Extract token information through introspection to get the audience & user info Limit access to documents : - public & authenticated with linkreach to the user - owned by the user Check intersection between the allowed services linked to the audience/client_id and the requested ones. Signed-off-by: Fabre Florian <ffabre@hybird.org>
This commit is contained in:
committed by
Florian Fabre
parent
f93515d70b
commit
db5cc54f67
@@ -12,3 +12,4 @@ and this project adheres to
|
||||
|
||||
- backend application
|
||||
- helm chart
|
||||
- ✨(backend) New search api view (OIDC resource)
|
||||
|
||||
+15
-4
@@ -22,6 +22,12 @@ services:
|
||||
ports:
|
||||
- "9200:9200"
|
||||
- "9600:9600"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--head", "-fsS", "http://localhost:9200/"]
|
||||
start_period: 5s
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 10
|
||||
|
||||
opensearch-dashboards:
|
||||
image: opensearchproject/opensearch-dashboards:latest
|
||||
@@ -31,7 +37,9 @@ services:
|
||||
OPENSEARCH_HOSTS: '["http://opensearch:9200"]'
|
||||
DISABLE_SECURITY_DASHBOARDS_PLUGIN: "true"
|
||||
depends_on:
|
||||
- opensearch
|
||||
opensearch:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
redis:
|
||||
image: redis:5
|
||||
@@ -61,9 +69,12 @@ services:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
depends_on:
|
||||
- postgresql
|
||||
- opensearch
|
||||
- redis
|
||||
postgresql:
|
||||
condition: service_started
|
||||
redis:
|
||||
condition: service_started
|
||||
opensearch:
|
||||
condition: service_healthy
|
||||
|
||||
celery:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
|
||||
@@ -21,6 +21,8 @@ OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/find/protocol/openid-connect/cert
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/find/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/find/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/find/protocol/openid-connect/userinfo
|
||||
OIDC_OP_URL=http://nginx:8083/realms/find
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/realms/find/protocol/openid-connect/token/introspect
|
||||
|
||||
OIDC_RP_CLIENT_ID=find
|
||||
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
@@ -29,3 +31,14 @@ OIDC_RP_SCOPES="openid email"
|
||||
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
# OIDC Resource server
|
||||
OIDC_DRF_AUTH_BACKEND="lasuite.oidc_login.backends.OIDCAuthenticationBackend"
|
||||
|
||||
OIDC_RS_SCOPES="openid"
|
||||
OIDC_RS_CLIENT_ID=impress
|
||||
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RS_SIGN_ALGO=RS256
|
||||
|
||||
OIDC_RS_BACKEND_CLASS="core.authentication.FinderResourceServerBackend"
|
||||
OIDC_RS_ENCRYPTION_KEY_TYPE="RSA"
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
"""Token authentication."""
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
import logging
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
from lasuite.oidc_resource_server.backend import ResourceServerBackend
|
||||
from rest_framework import authentication, exceptions
|
||||
|
||||
from .models import Service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ServiceTokenAuthentication(authentication.BaseAuthentication):
|
||||
"""A custom authentication looking for valid tokens among registered services"""
|
||||
@@ -31,3 +37,35 @@ class ServiceTokenAuthentication(authentication.BaseAuthentication):
|
||||
|
||||
# We don't associate tokens with a user
|
||||
return AnonymousUser(), service
|
||||
|
||||
|
||||
class ResourceUserManager:
|
||||
"""Fake manager that simply returns an instance of user object with the right sub"""
|
||||
|
||||
def get(self, sub):
|
||||
"""Returns a ResourceUser for this sub"""
|
||||
return ResourceUser(sub=sub)
|
||||
|
||||
|
||||
class ResourceUser:
|
||||
"""Fake user model for the ResourceServerBackend.get_object() method"""
|
||||
|
||||
DoesNotExist = ObjectDoesNotExist
|
||||
|
||||
objects = ResourceUserManager()
|
||||
|
||||
def __init__(self, sub: str):
|
||||
self.sub = sub
|
||||
self.pk = None
|
||||
self.is_authenticated = True
|
||||
|
||||
|
||||
class FinderResourceServerBackend(ResourceServerBackend):
|
||||
"""
|
||||
Custom resource server that uses a ResourceUser object instead of
|
||||
a user model from the database
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.UserModel = ResourceUser
|
||||
|
||||
@@ -52,6 +52,7 @@ class ServiceFactory(factory.django.DjangoModelFactory):
|
||||
name = factory.Sequence(lambda n: f"test-index-{n!s}")
|
||||
created_at = factory.Faker("date_time_this_year", tzinfo=None)
|
||||
is_active = True
|
||||
client_id = "some_client_id"
|
||||
|
||||
class Meta:
|
||||
model = models.Service
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.4 on 2025-09-05 04:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='service',
|
||||
name='client_id',
|
||||
field=models.CharField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='service',
|
||||
name='services',
|
||||
field=models.ManyToManyField(blank=True, to='core.service', verbose_name='Allowed services for search'),
|
||||
),
|
||||
]
|
||||
@@ -24,6 +24,12 @@ class Service(models.Model):
|
||||
token = models.CharField(max_length=TOKEN_LENGTH)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
client_id = models.CharField(blank=True, null=True)
|
||||
services = models.ManyToManyField(
|
||||
"self",
|
||||
verbose_name=_("Allowed services for search"),
|
||||
blank=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "find_service"
|
||||
@@ -50,6 +56,8 @@ class Service(models.Model):
|
||||
@staticmethod
|
||||
def generate_secure_token():
|
||||
"""Generate a secure token with with Python secret module"""
|
||||
characters = string.ascii_letters + string.digits + string.punctuation
|
||||
characters = (
|
||||
string.ascii_letters + string.digits + r"""!#%&'()*+,-./:;<=>?@[\]^_`{|}~"""
|
||||
)
|
||||
token = "".join(secrets.choice(characters) for _ in range(TOKEN_LENGTH))
|
||||
return token
|
||||
|
||||
+25
-33
@@ -1,6 +1,6 @@
|
||||
"""Pydantic model to validate documents before indexation."""
|
||||
|
||||
from typing import Annotated, List, Literal, Optional, Union
|
||||
from typing import Annotated, List, Literal, Optional
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
@@ -9,6 +9,7 @@ from pydantic import (
|
||||
UUID4,
|
||||
AwareDatetime,
|
||||
BaseModel,
|
||||
BeforeValidator,
|
||||
ConfigDict,
|
||||
Field,
|
||||
conint,
|
||||
@@ -58,8 +59,9 @@ class DocumentSchema(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_empty_content(self):
|
||||
"""Validate that either `title` or `content` are not empty."""
|
||||
if not self.title and not self.content:
|
||||
raise ValueError('Either title or content should have at least 1 character')
|
||||
raise ValueError("Either title or content should have at least 1 character")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
@@ -84,43 +86,33 @@ class DocumentSchema(BaseModel):
|
||||
return validated_groups
|
||||
|
||||
|
||||
def cleanlist(value):
|
||||
"""Build a list of strings from a string, None (empty list) or a list of objects."""
|
||||
if isinstance(value, str):
|
||||
# Convert comma-separated strings to list
|
||||
return [s.strip() for s in value.split(",") if s.strip()]
|
||||
|
||||
if isinstance(value, list):
|
||||
# Clean up list of strings
|
||||
return [str(s).strip() for s in value if str(s).strip()]
|
||||
|
||||
if value is None:
|
||||
return []
|
||||
|
||||
raise ValueError()
|
||||
|
||||
|
||||
StringListParameter = Annotated[List[str], BeforeValidator(cleanlist)]
|
||||
|
||||
|
||||
class SearchQueryParametersSchema(BaseModel):
|
||||
"""Schema for validating the querystring on the search API endpoint"""
|
||||
|
||||
q: str
|
||||
services: Union[str, List[str], None] = Field(default_factory=list)
|
||||
visited: Union[List[str], None] = Field(default_factory=list)
|
||||
services: StringListParameter = Field(default_factory=list)
|
||||
visited: StringListParameter = Field(default_factory=list)
|
||||
reach: Optional[enums.ReachEnum] = None
|
||||
order_by: Optional[Literal[enums.ORDER_BY_OPTIONS]] = Field(default=enums.RELEVANCE)
|
||||
order_direction: Optional[Literal["asc", "desc"]] = Field(default="desc")
|
||||
page_number: Optional[conint(ge=1)] = Field(default=1)
|
||||
page_size: Optional[conint(ge=1, le=100)] = Field(default=50)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@staticmethod
|
||||
def handle_lists(values):
|
||||
"""
|
||||
Ensure 'services' is always a list of strings, even if a single string was provided.
|
||||
Ignore multiple values for other parameters.
|
||||
"""
|
||||
services = values.get("services")
|
||||
if isinstance(services, str):
|
||||
# Convert comma-separated strings to list
|
||||
values["services"] = [s.strip() for s in services.split(",") if s.strip()]
|
||||
elif isinstance(services, list):
|
||||
# Clean up list of strings
|
||||
values["services"] = [str(s).strip() for s in services if str(s).strip()]
|
||||
elif services is None:
|
||||
values["services"] = []
|
||||
else:
|
||||
# Unexpected type — convert to list of one
|
||||
values["services"] = [str(services).strip()]
|
||||
|
||||
for key, value in values.items():
|
||||
if isinstance(value, list):
|
||||
if key == "services":
|
||||
continue
|
||||
# Take the first item if it's a list
|
||||
values[key] = value[0] if value else None
|
||||
|
||||
return values
|
||||
|
||||
@@ -58,6 +58,34 @@ def test_api_documents_index_bulk_success():
|
||||
assert result["status"] == "success"
|
||||
|
||||
|
||||
def test_api_documents_index_bulk_ensure_index():
|
||||
"""A registered service should be create the opensearch index if need."""
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
documents = factories.DocumentSchemaFactory.build_batch(3)
|
||||
|
||||
# Delete the index
|
||||
opensearch.client.indices.delete(index="*test*")
|
||||
|
||||
with pytest.raises(opensearch.NotFoundError):
|
||||
opensearch.client.indices.get(index="test-service")
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/index/",
|
||||
documents,
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 207
|
||||
responses = response.json()
|
||||
assert len(responses) == 3
|
||||
for result in response.json():
|
||||
assert result["status"] == "success"
|
||||
|
||||
# The index has been rebuilt
|
||||
opensearch.client.indices.get(index="test-service")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field, invalid_value, error_type, error_message",
|
||||
[
|
||||
@@ -341,8 +369,8 @@ def test_api_documents_index_empty_content_check():
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
documents = factories.DocumentSchemaFactory.build_batch(3)
|
||||
|
||||
documents[0]['content'] = ''
|
||||
documents[0]['title'] = ''
|
||||
documents[0]["content"] = ""
|
||||
documents[0]["title"] = ""
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/index/",
|
||||
|
||||
@@ -55,6 +55,31 @@ def test_api_documents_index_single_success():
|
||||
assert response.json()["_id"] == str(document["id"])
|
||||
|
||||
|
||||
def test_api_documents_index_bulk_ensure_index():
|
||||
"""A registered service should be create the opensearch index if need."""
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
document = factories.DocumentSchemaFactory.build()
|
||||
|
||||
# Delete the index
|
||||
opensearch.client.indices.delete(index="*test*")
|
||||
|
||||
with pytest.raises(opensearch.NotFoundError):
|
||||
opensearch.client.indices.get(index="test-service")
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/index/",
|
||||
document,
|
||||
HTTP_AUTHORIZATION=f"Bearer {service.token:s}",
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["_id"] == str(document["id"])
|
||||
|
||||
# The index has been rebuilt
|
||||
opensearch.client.indices.get(index="test-service")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field, invalid_value, error_type, error_message",
|
||||
[
|
||||
@@ -305,8 +330,8 @@ def test_api_documents_index_empty_content_check():
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
document = factories.DocumentSchemaFactory.build()
|
||||
|
||||
document['content'] = ''
|
||||
document['title'] = ''
|
||||
document["content"] = ""
|
||||
document["title"] = ""
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/index/",
|
||||
@@ -316,5 +341,8 @@ def test_api_documents_index_empty_content_check():
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.data[0]["msg"] == "Value error, Either title or content should have at least 1 character"
|
||||
assert (
|
||||
response.data[0]["msg"]
|
||||
== "Value error, Either title or content should have at least 1 character"
|
||||
)
|
||||
assert response.data[0]["type"] == "value_error"
|
||||
|
||||
@@ -9,17 +9,120 @@ import operator
|
||||
import random
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import enums, factories
|
||||
|
||||
from .utils import prepare_index
|
||||
from .utils import build_authorization_bearer, prepare_index, setup_oicd_resource_server
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_documents_search_query_title():
|
||||
@responses.activate
|
||||
def test_api_documents_search_auth_invalid_parameters(settings):
|
||||
"""Invalid service parameters should result in a 401 error"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub")
|
||||
|
||||
settings.OIDC_RS_CLIENT_ID = None
|
||||
settings.OIDC_RS_CLIENT_SECRET = None
|
||||
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
prepare_index(service.name, [])
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "a quick fox"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer unknown",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Resource Server is improperly configured"}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_query_unknown_user(settings):
|
||||
"""Searching a document without an existing user should result in a 401 error"""
|
||||
setup_oicd_resource_server(
|
||||
responses,
|
||||
settings,
|
||||
sub="unknown",
|
||||
introspect=lambda request, user_info: (404, {}, ""),
|
||||
)
|
||||
|
||||
token = build_authorization_bearer()
|
||||
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
prepare_index(service.name, [])
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "a quick fox"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Login failed"}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_services_invalid_parameters(settings):
|
||||
"""Invalid pagination parameters should result in a 400 error"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub")
|
||||
token = build_authorization_bearer()
|
||||
|
||||
factories.ServiceFactory(name="test-service")
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "a quick fox", "services": {}},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == [
|
||||
{
|
||||
"loc": ["services"],
|
||||
"msg": "Value error, ",
|
||||
"type": "value_error",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_reached_docs_invalid_parameters(settings):
|
||||
"""Invalid pagination parameters should result in a 400 error"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub")
|
||||
token = build_authorization_bearer()
|
||||
|
||||
factories.ServiceFactory(name="test-service")
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "a quick fox", "visited": {}},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == [
|
||||
{
|
||||
"loc": ["visited"],
|
||||
"msg": "Value error, ",
|
||||
"type": "value_error",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_query_title(settings):
|
||||
"""Searching a document by its title should work as expected"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub")
|
||||
token = build_authorization_bearer()
|
||||
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
document = factories.DocumentSchemaFactory.build(
|
||||
title="The quick brown fox",
|
||||
@@ -43,9 +146,9 @@ def test_api_documents_search_query_title():
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "a quick fox"},
|
||||
{"q": "a quick fox", "visited": [doc["id"] for doc in documents]},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -88,8 +191,12 @@ def test_api_documents_search_query_title():
|
||||
assert other_fox_data["fields"] == {"number_of_users": [3], "number_of_groups": [3]}
|
||||
|
||||
|
||||
def test_api_documents_search_query_content():
|
||||
@responses.activate
|
||||
def test_api_documents_search_query_content(settings):
|
||||
"""Searching a document by its content should work as expected"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub")
|
||||
token = build_authorization_bearer()
|
||||
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
document = factories.DocumentSchemaFactory.build(
|
||||
title="the wolf",
|
||||
@@ -108,13 +215,15 @@ def test_api_documents_search_query_content():
|
||||
content="The brown goat",
|
||||
reach=random.choice(["public", "authenticated"]),
|
||||
)
|
||||
prepare_index(service.name, [document, other_fox_document, no_fox_document])
|
||||
|
||||
documents = [document, other_fox_document, no_fox_document]
|
||||
prepare_index(service.name, documents)
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "a quick fox"},
|
||||
{"q": "a quick fox", "visited": [doc["id"] for doc in documents]},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -157,8 +266,12 @@ def test_api_documents_search_query_content():
|
||||
assert other_fox_data["fields"] == {"number_of_users": [3], "number_of_groups": [3]}
|
||||
|
||||
|
||||
def test_api_documents_search_ordering_by_fields():
|
||||
@responses.activate
|
||||
def test_api_documents_search_ordering_by_fields(settings):
|
||||
"""It should be possible to order by several fields"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub")
|
||||
token = build_authorization_bearer()
|
||||
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
documents = factories.DocumentSchemaFactory.build_batch(
|
||||
4, reach=random.choice(["public", "authenticated"])
|
||||
@@ -181,25 +294,32 @@ def test_api_documents_search_ordering_by_fields():
|
||||
for field, direction in parameters:
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "order_by": field, "order_direction": direction},
|
||||
{
|
||||
"q": "*",
|
||||
"order_by": field,
|
||||
"order_direction": direction,
|
||||
"visited": [doc["id"] for doc in documents],
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
responses = response.json()
|
||||
assert len(responses) == 4
|
||||
data = response.json()
|
||||
assert len(data) == 4
|
||||
|
||||
# Check that results are sorted by the field as expected
|
||||
compare = operator.le if direction == "asc" else operator.ge
|
||||
for i in range(len(responses) - 1):
|
||||
assert compare(
|
||||
responses[i]["_source"][field], responses[i + 1]["_source"][field]
|
||||
)
|
||||
for i in range(len(data) - 1):
|
||||
assert compare(data[i]["_source"][field], data[i + 1]["_source"][field])
|
||||
|
||||
|
||||
def test_api_documents_search_ordering_by_relevance():
|
||||
@responses.activate
|
||||
def test_api_documents_search_ordering_by_relevance(settings):
|
||||
"""It should be possible to order by relevance (score)"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub")
|
||||
token = build_authorization_bearer()
|
||||
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
documents = factories.DocumentSchemaFactory.build_batch(
|
||||
4, reach=random.choice(["public", "authenticated"])
|
||||
@@ -209,23 +329,32 @@ def test_api_documents_search_ordering_by_relevance():
|
||||
for direction in ["asc", "desc"]:
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "order_by": "relevance", "order_direction": direction},
|
||||
{
|
||||
"q": "*",
|
||||
"order_by": "relevance",
|
||||
"order_direction": direction,
|
||||
"visited": [doc["id"] for doc in documents],
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
responses = response.json()
|
||||
assert len(responses) == 4
|
||||
data = response.json()
|
||||
assert len(data) == 4
|
||||
|
||||
# Check that results are sorted by score as expected
|
||||
compare = operator.le if direction == "asc" else operator.ge
|
||||
for i in range(len(responses) - 1):
|
||||
assert compare(responses[i]["_score"], responses[i + 1]["_score"])
|
||||
for i in range(len(data) - 1):
|
||||
assert compare(data[i]["_score"], data[i + 1]["_score"])
|
||||
|
||||
|
||||
def test_api_documents_search_ordering_by_unknown_field():
|
||||
@responses.activate
|
||||
def test_api_documents_search_ordering_by_unknown_field(settings):
|
||||
"""Trying to sort by an unknown field should return a 400 error"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub")
|
||||
token = build_authorization_bearer()
|
||||
|
||||
# Setup: Initialize the service and documents only once
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
documents = factories.DocumentSchemaFactory.build_batch(
|
||||
@@ -240,9 +369,14 @@ def test_api_documents_search_ordering_by_unknown_field():
|
||||
for direction in directions:
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "order_by": "unknown", "order_direction": direction},
|
||||
{
|
||||
"q": "*",
|
||||
"order_by": "unknown",
|
||||
"order_direction": direction,
|
||||
"visited": [doc["id"] for doc in documents],
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
@@ -258,8 +392,12 @@ def test_api_documents_search_ordering_by_unknown_field():
|
||||
]
|
||||
|
||||
|
||||
def test_api_documents_search_ordering_by_unknown_direction():
|
||||
@responses.activate
|
||||
def test_api_documents_search_ordering_by_unknown_direction(settings):
|
||||
"""Trying to sort with an unknown direction should return a 400 error"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub")
|
||||
token = build_authorization_bearer()
|
||||
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
documents = factories.DocumentSchemaFactory.build_batch(
|
||||
2, reach=random.choice(["public", "authenticated"])
|
||||
@@ -269,9 +407,14 @@ def test_api_documents_search_ordering_by_unknown_direction():
|
||||
for field in enums.ORDER_BY_OPTIONS:
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "order_by": field, "order_direction": "unknown"},
|
||||
{
|
||||
"q": "*",
|
||||
"order_by": field,
|
||||
"order_direction": "unknown",
|
||||
"visited": [doc["id"] for doc in documents],
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
@@ -284,8 +427,12 @@ def test_api_documents_search_ordering_by_unknown_direction():
|
||||
]
|
||||
|
||||
|
||||
def test_api_documents_search_filtering_by_reach():
|
||||
@responses.activate
|
||||
def test_api_documents_search_filtering_by_reach(settings):
|
||||
"""It should be possible to filter results by their reach"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub")
|
||||
token = build_authorization_bearer()
|
||||
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
documents = factories.DocumentSchemaFactory.build_batch(
|
||||
4, reach=random.choice(["public", "authenticated"])
|
||||
@@ -295,23 +442,31 @@ def test_api_documents_search_filtering_by_reach():
|
||||
for reach in enums.ReachEnum:
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "reach": reach.value},
|
||||
{
|
||||
"q": "*",
|
||||
"reach": reach.value,
|
||||
"visited": [doc["id"] for doc in documents],
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
responses = response.json()
|
||||
data = response.json()
|
||||
|
||||
for result in responses:
|
||||
for result in data:
|
||||
assert reach == result["_source"]["reach"]
|
||||
|
||||
|
||||
# Pagination
|
||||
|
||||
|
||||
def test_api_documents_search_pagination_basic():
|
||||
@responses.activate
|
||||
def test_api_documents_search_pagination_basic(settings):
|
||||
"""Pagination should correctly return documents for the specified page and page size"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub")
|
||||
token = build_authorization_bearer()
|
||||
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
documents = factories.DocumentSchemaFactory.build_batch(
|
||||
9, reach=random.choice(["public", "authenticated"])
|
||||
@@ -322,44 +477,63 @@ def test_api_documents_search_pagination_basic():
|
||||
# Request the first page with a page size of 3
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "page_number": 1, "page_size": 3},
|
||||
{
|
||||
"q": "*",
|
||||
"page_number": 1,
|
||||
"page_size": 3,
|
||||
"visited": [doc["id"] for doc in documents],
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
responses = response.json()
|
||||
assert len(responses) == 3 # Page size is 3
|
||||
assert [r["_id"] for r in responses] == ids[0:3]
|
||||
data = response.json()
|
||||
assert len(data) == 3 # Page size is 3
|
||||
assert [r["_id"] for r in data] == ids[0:3]
|
||||
|
||||
# Request the second page with a page size of 3
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "page_number": 2, "page_size": 3},
|
||||
{
|
||||
"q": "*",
|
||||
"page_number": 2,
|
||||
"page_size": 3,
|
||||
"visited": [doc["id"] for doc in documents],
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
responses = response.json()
|
||||
assert len(responses) == 3
|
||||
assert [r["_id"] for r in responses] == ids[3:6]
|
||||
data = response.json()
|
||||
assert len(data) == 3
|
||||
assert [r["_id"] for r in data] == ids[3:6]
|
||||
|
||||
# Request the third page with a page size of 5 (should contain the remaining 3 documents)
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "page_number": 3, "page_size": 3},
|
||||
{
|
||||
"q": "*",
|
||||
"page_number": 3,
|
||||
"page_size": 3,
|
||||
"visited": [doc["id"] for doc in documents],
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
responses = response.json()
|
||||
assert len(responses) == 3
|
||||
assert [r["_id"] for r in responses] == ids[6:9]
|
||||
data = response.json()
|
||||
assert len(data) == 3
|
||||
assert [r["_id"] for r in data] == ids[6:9]
|
||||
|
||||
|
||||
def test_api_documents_search_pagination_last_page_edge_case():
|
||||
@responses.activate
|
||||
def test_api_documents_search_pagination_last_page_edge_case(settings):
|
||||
"""Requesting the last page should return the correct number of remaining documents"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub")
|
||||
token = build_authorization_bearer()
|
||||
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
documents = factories.DocumentSchemaFactory.build_batch(
|
||||
8, reach=random.choice(["public", "authenticated"])
|
||||
@@ -370,9 +544,14 @@ def test_api_documents_search_pagination_last_page_edge_case():
|
||||
# Request the first page with a page size of 3
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "page_number": 1, "page_size": 3},
|
||||
{
|
||||
"q": "*",
|
||||
"page_number": 1,
|
||||
"page_size": 3,
|
||||
"visited": [doc["id"] for doc in documents],
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -382,9 +561,14 @@ def test_api_documents_search_pagination_last_page_edge_case():
|
||||
# Request the third page with a page size of 3 (should contain the last 1 document)
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "page_number": 3, "page_size": 3},
|
||||
{
|
||||
"q": "*",
|
||||
"page_number": 3,
|
||||
"page_size": 3,
|
||||
"visited": [doc["id"] for doc in documents],
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -392,10 +576,14 @@ def test_api_documents_search_pagination_last_page_edge_case():
|
||||
assert [r["_id"] for r in response.json()] == ids[6:]
|
||||
|
||||
|
||||
def test_api_documents_search_pagination_out_of_bounds():
|
||||
@responses.activate
|
||||
def test_api_documents_search_pagination_out_of_bounds(settings):
|
||||
"""
|
||||
Requesting a page number that exceeds the total number of pages should return an empty list
|
||||
"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub")
|
||||
token = build_authorization_bearer()
|
||||
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
documents = factories.DocumentSchemaFactory.build_batch(
|
||||
4, reach=random.choice(["public", "authenticated"])
|
||||
@@ -405,17 +593,26 @@ def test_api_documents_search_pagination_out_of_bounds():
|
||||
# Request the fourth page with a page size of 2 (there are only 2 pages)
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "page_number": 4, "page_size": 2},
|
||||
{
|
||||
"q": "*",
|
||||
"page_number": 4,
|
||||
"page_size": 2,
|
||||
"visited": [doc["id"] for doc in documents],
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 0 # No documents should be returned
|
||||
|
||||
|
||||
def test_api_documents_search_pagination_invalid_parameters():
|
||||
@responses.activate
|
||||
def test_api_documents_search_pagination_invalid_parameters(settings):
|
||||
"""Invalid pagination parameters should result in a 400 error"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub")
|
||||
token = build_authorization_bearer()
|
||||
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
documents = factories.DocumentSchemaFactory.build_batch(
|
||||
4, reach=random.choice(["public", "authenticated"])
|
||||
@@ -446,7 +643,7 @@ def test_api_documents_search_pagination_invalid_parameters():
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "page_number": page_number, "page_size": page_size},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
@@ -454,8 +651,12 @@ def test_api_documents_search_pagination_invalid_parameters():
|
||||
assert response.data[0]["type"] == error_type
|
||||
|
||||
|
||||
def test_api_documents_search_pagination_with_filtering():
|
||||
@responses.activate
|
||||
def test_api_documents_search_pagination_with_filtering(settings):
|
||||
"""Pagination should work correctly when combined with filtering by reach"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub")
|
||||
token = build_authorization_bearer()
|
||||
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
public_documents = factories.DocumentSchemaFactory.build_batch(3, reach="public")
|
||||
public_ids = [str(doc["id"]) for doc in public_documents]
|
||||
@@ -467,9 +668,15 @@ def test_api_documents_search_pagination_with_filtering():
|
||||
# Filter by public documents, request first page
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "reach": "public", "page_number": 1, "page_size": 2},
|
||||
{
|
||||
"q": "*",
|
||||
"reach": "public",
|
||||
"page_number": 1,
|
||||
"page_size": 2,
|
||||
"visited": public_ids,
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 2
|
||||
@@ -478,9 +685,15 @@ def test_api_documents_search_pagination_with_filtering():
|
||||
# Request second page for public documents (remaining 1 document)
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "reach": "public", "page_number": 2, "page_size": 2},
|
||||
{
|
||||
"q": "*",
|
||||
"reach": "public",
|
||||
"page_number": 2,
|
||||
"page_size": 2,
|
||||
"visited": public_ids,
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -6,11 +6,17 @@ of documents is slow and better done only once.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import enums, factories
|
||||
|
||||
from .utils import prepare_index
|
||||
from .utils import (
|
||||
build_authorization_bearer,
|
||||
delete_test_indices,
|
||||
prepare_index,
|
||||
setup_oicd_resource_server,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -25,23 +31,28 @@ def test_api_documents_search_access_control_anonymous():
|
||||
|
||||
response = APIClient().post("/api/v1.0/documents/search/?q=*")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_documents_search_access_control():
|
||||
@responses.activate
|
||||
def test_api_documents_search_access_control(settings):
|
||||
"""
|
||||
Authenticated users should only see documents:
|
||||
- for which they are listed in the "users" field
|
||||
- that have a reach set to "authenticated" or "public"
|
||||
- only configured services providers are allowed (e.g docs)
|
||||
(groups is not yet implemnted)
|
||||
"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub")
|
||||
token = build_authorization_bearer()
|
||||
|
||||
service = factories.ServiceFactory(name="test-service")
|
||||
documents_reach = factories.DocumentSchemaFactory.build_batch(6)
|
||||
documents_open = [
|
||||
doc for doc in documents_reach if doc["reach"] in ["authenticated", "public"]
|
||||
]
|
||||
documents_user = factories.DocumentSchemaFactory.build_batch(
|
||||
6, users=["123456", "654321"]
|
||||
6, users=["user_sub", "user_sub2"]
|
||||
)
|
||||
expected_ids = [doc["id"] for doc in documents_open + documents_user]
|
||||
|
||||
@@ -51,7 +62,358 @@ def test_api_documents_search_access_control():
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer 123456",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
for result in response.json():
|
||||
assert result["_id"] in expected_ids
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize(
|
||||
"doc_ids,visited,expected",
|
||||
[
|
||||
(["a", "b"], [], []),
|
||||
(["a", "b"], "", []),
|
||||
(["a", "b"], None, []),
|
||||
(["a", "b"], ["other"], []),
|
||||
([], ["a"], []),
|
||||
(["a", "b"], ["a"], ["a"]),
|
||||
(["a", "b"], ["a", "b", "c"], ["a", "b"]),
|
||||
(["a", "b"], "a,b,c", ["a", "b"]),
|
||||
],
|
||||
)
|
||||
def test_api_documents_search_access__only_visited_public(
|
||||
doc_ids, visited, expected, settings
|
||||
):
|
||||
"""
|
||||
Authenticated users should only see documents with reach="public"
|
||||
that are in "visited" list.
|
||||
"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub", audience="docs")
|
||||
token = build_authorization_bearer()
|
||||
|
||||
service = factories.ServiceFactory(name="test-service", client_id="docs")
|
||||
|
||||
docs = [
|
||||
factories.DocumentSchemaFactory(
|
||||
reach=[enums.ReachEnum.PUBLIC, enums.ReachEnum.AUTHENTICATED], id=doc_id
|
||||
)
|
||||
for doc_id in doc_ids
|
||||
]
|
||||
|
||||
prepare_index(service.name, docs)
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "visited": visited},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.json()
|
||||
for result in response.json():
|
||||
assert result["_id"] in expected
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_access__any_owner_public(settings):
|
||||
"""
|
||||
Authenticated users should only see documents with reach="public"
|
||||
that are in "visited" list.
|
||||
"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub", audience="docs")
|
||||
token = build_authorization_bearer()
|
||||
|
||||
service = factories.ServiceFactory(name="test-service", client_id="docs")
|
||||
|
||||
docs = factories.DocumentSchemaFactory.build_batch(
|
||||
6,
|
||||
reach=[enums.ReachEnum.PUBLIC, enums.ReachEnum.AUTHENTICATED],
|
||||
users=["user_sub"],
|
||||
)
|
||||
|
||||
other_docs = factories.DocumentSchemaFactory.build_batch(
|
||||
6,
|
||||
reach=[enums.ReachEnum.PUBLIC, enums.ReachEnum.AUTHENTICATED],
|
||||
users=["other_sub"],
|
||||
)
|
||||
|
||||
prepare_index(service.name, docs + other_docs)
|
||||
|
||||
expected = [d["id"] for d in docs]
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "visited": []},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.json()
|
||||
for result in response.json():
|
||||
assert result["_id"] in expected
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_access__services(settings):
|
||||
"""
|
||||
Authenticated users should only see documents of audience
|
||||
service providers (e.g docs)
|
||||
"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub", audience="a-client")
|
||||
token = build_authorization_bearer()
|
||||
|
||||
service_a = factories.ServiceFactory(name="test-index-a", client_id="a-client")
|
||||
service_b = factories.ServiceFactory(name="test-index-b", client_id="b-client")
|
||||
|
||||
service_a_docs = factories.DocumentSchemaFactory.build_batch(
|
||||
3, reach=[enums.ReachEnum.AUTHENTICATED], users=["user_sub"]
|
||||
)
|
||||
service_b_docs = factories.DocumentSchemaFactory.build_batch(
|
||||
3, reach=[enums.ReachEnum.AUTHENTICATED], users=["user_sub"]
|
||||
)
|
||||
|
||||
expected_ids = [doc["id"] for doc in service_a_docs]
|
||||
|
||||
prepare_index(service_a.name, service_a_docs)
|
||||
prepare_index(service_b.name, service_b_docs, cleanup=False)
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
for result in response.json():
|
||||
assert result["_id"] in expected_ids
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_access__missing_index(settings):
|
||||
"""
|
||||
When the service has no opensearch index, returns an empty list.
|
||||
"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub", audience="a-client")
|
||||
token = build_authorization_bearer()
|
||||
factories.ServiceFactory(name="test-index-a", client_id="a-client")
|
||||
|
||||
delete_test_indices()
|
||||
|
||||
# a-client has no index. ignore it.
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_access__related_services(settings):
|
||||
"""
|
||||
Authenticated users should only see documents of audience
|
||||
service providers and its related services (e.g drive + docs)
|
||||
"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub", audience="c-client")
|
||||
token = build_authorization_bearer()
|
||||
|
||||
service_a = factories.ServiceFactory(name="test-index-a", client_id="a-client")
|
||||
service_b = factories.ServiceFactory(name="test-index-b", client_id="b-client")
|
||||
service_c = factories.ServiceFactory(name="test-index-c", client_id="c-client")
|
||||
service_c.services.set([service_a])
|
||||
service_c.save()
|
||||
|
||||
service_a_docs = factories.DocumentSchemaFactory.build_batch(
|
||||
3, reach=[enums.ReachEnum.AUTHENTICATED], users=["user_sub"]
|
||||
)
|
||||
service_b_docs = factories.DocumentSchemaFactory.build_batch(
|
||||
3, reach=[enums.ReachEnum.AUTHENTICATED], users=["user_sub"]
|
||||
)
|
||||
service_c_docs = factories.DocumentSchemaFactory.build_batch(
|
||||
3, reach=[enums.ReachEnum.AUTHENTICATED], users=["user_sub"]
|
||||
)
|
||||
|
||||
expected_ids = [doc["id"] for doc in service_a_docs + service_c_docs]
|
||||
|
||||
prepare_index(service_a.name, service_a_docs)
|
||||
prepare_index(service_b.name, service_b_docs, cleanup=False)
|
||||
prepare_index(service_c.name, service_c_docs, cleanup=False)
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
for result in response.json():
|
||||
assert result["_id"] in expected_ids
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_access__related_missing_index(settings):
|
||||
"""
|
||||
When the service has no opensearch index, returns the related services data.
|
||||
"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub", audience="a-client")
|
||||
token = build_authorization_bearer()
|
||||
|
||||
service_a = factories.ServiceFactory(name="test-index-a", client_id="a-client")
|
||||
service_b = factories.ServiceFactory(name="test-index-b", client_id="b-client")
|
||||
service_c = factories.ServiceFactory(name="test-index-c", client_id="c-client")
|
||||
service_c.services.set([service_a])
|
||||
service_c.save()
|
||||
|
||||
service_b_docs = factories.DocumentSchemaFactory.build_batch(
|
||||
3, reach=[enums.ReachEnum.AUTHENTICATED], users=["user_sub"]
|
||||
)
|
||||
service_c_docs = factories.DocumentSchemaFactory.build_batch(
|
||||
3, reach=[enums.ReachEnum.AUTHENTICATED], users=["user_sub"]
|
||||
)
|
||||
|
||||
expected_ids = [doc["id"] for doc in service_c_docs]
|
||||
|
||||
prepare_index(service_b.name, service_b_docs)
|
||||
prepare_index(service_c.name, service_c_docs, cleanup=False)
|
||||
|
||||
# a-client has no index. ignore it.
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
for result in response.json():
|
||||
assert result["_id"] in expected_ids
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_access__request_services(settings):
|
||||
"""
|
||||
Authenticated users should only see documents of audience
|
||||
from requested services : 'services' parameter.
|
||||
Raise 400 error if not all requested services are authorized.
|
||||
"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub", audience="c-client")
|
||||
token = build_authorization_bearer()
|
||||
|
||||
service_a = factories.ServiceFactory(name="test-index-a", client_id="a-client")
|
||||
service_b = factories.ServiceFactory(name="test-index-b", client_id="b-client")
|
||||
service_c = factories.ServiceFactory(name="test-index-c", client_id="c-client")
|
||||
|
||||
service_a_docs = factories.DocumentSchemaFactory.build_batch(
|
||||
3, reach=[enums.ReachEnum.AUTHENTICATED], users=["user_sub"]
|
||||
)
|
||||
service_b_docs = factories.DocumentSchemaFactory.build_batch(
|
||||
3, reach=[enums.ReachEnum.AUTHENTICATED], users=["user_sub"]
|
||||
)
|
||||
service_c_docs = factories.DocumentSchemaFactory.build_batch(
|
||||
3, reach=[enums.ReachEnum.AUTHENTICATED], users=["user_sub"]
|
||||
)
|
||||
|
||||
expected_ids = [doc["id"] for doc in service_c_docs]
|
||||
|
||||
prepare_index(service_a.name, service_a_docs)
|
||||
prepare_index(service_b.name, service_b_docs, cleanup=False)
|
||||
prepare_index(service_c.name, service_c_docs, cleanup=False)
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "services": ["test-index-c"]},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
for result in response.json():
|
||||
assert result["_id"] in expected_ids
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "services": ["test-index-c", "test-index-b"]},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Some requested services are not available"}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_access__request_inactive_services(settings):
|
||||
"""
|
||||
Authenticated users should only see documents of audience
|
||||
from requested services : 'services' parameter.
|
||||
Raise 400 error if not all requested services are active.
|
||||
"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub", audience="client")
|
||||
token = build_authorization_bearer()
|
||||
|
||||
factories.ServiceFactory(name="test-index", client_id="client", is_active=False)
|
||||
factories.ServiceFactory(name="test-index-b", client_id="b-client")
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*", "services": ["test-index"]},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Service is not available"}
|
||||
|
||||
# Event without explicit argument, the client service from the request is not active
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Service is not available"}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_access__authenticated(settings):
|
||||
"""
|
||||
Authenticated users should only see documents
|
||||
- for which they are listed in the "users" field
|
||||
- that have a reach set to "authenticated" or "public"
|
||||
- only configured services providers are allowed (e.g docs)
|
||||
(groups is not yet implemnted)
|
||||
"""
|
||||
setup_oicd_resource_server(responses, settings, sub="user_sub", audience="docs")
|
||||
token = build_authorization_bearer()
|
||||
|
||||
service = factories.ServiceFactory(name="test-service", client_id="docs")
|
||||
|
||||
documents_reach = factories.DocumentSchemaFactory.build_batch(6)
|
||||
documents_open = [
|
||||
doc for doc in documents_reach if doc["reach"] in ["authenticated", "public"]
|
||||
]
|
||||
documents_user = factories.DocumentSchemaFactory.build_batch(
|
||||
6, users=["user_sub", "user_sub2"]
|
||||
)
|
||||
expected_ids = [doc["id"] for doc in documents_open + documents_user]
|
||||
|
||||
prepare_index(service.name, documents_user + documents_reach)
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/search/",
|
||||
{"q": "*"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -1,13 +1,50 @@
|
||||
"""Tests Service model for find's core app."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
from functools import partial
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from joserfc import jwe as jose_jwe
|
||||
from joserfc import jwt as jose_jwt
|
||||
from joserfc.jwk import RSAKey
|
||||
from jwt.utils import to_base64url_uint
|
||||
from lasuite.oidc_resource_server.authentication import (
|
||||
get_resource_server_backend,
|
||||
)
|
||||
from opensearchpy.helpers import bulk
|
||||
|
||||
from core import opensearch
|
||||
|
||||
|
||||
def prepare_index(index_name, documents):
|
||||
"""Prepare the search index before testing a query on it."""
|
||||
@pytest.fixture(name="jwt_rs_backend")
|
||||
def jwt_resource_server_backend_fixture(settings):
|
||||
"""Fixture to switch the backend to the JWTResourceServerBackend."""
|
||||
_original_backend = str(settings.OIDC_RS_BACKEND_CLASS)
|
||||
|
||||
settings.OIDC_RS_BACKEND_CLASS = (
|
||||
"lasuite.oidc_resource_server.backend.JWTResourceServerBackend"
|
||||
)
|
||||
get_resource_server_backend.cache_clear()
|
||||
|
||||
yield
|
||||
|
||||
settings.OIDC_RS_BACKEND_CLASS = _original_backend
|
||||
get_resource_server_backend.cache_clear()
|
||||
|
||||
|
||||
def delete_test_indices():
|
||||
"""Drop all search index containing the 'test' word"""
|
||||
opensearch.client.indices.delete(index="*test*")
|
||||
|
||||
|
||||
def prepare_index(index_name, documents, cleanup=True):
|
||||
"""Prepare the search index before testing a query on it."""
|
||||
if cleanup:
|
||||
delete_test_indices()
|
||||
|
||||
opensearch.ensure_index_exists(index_name)
|
||||
|
||||
# Index new documents
|
||||
@@ -28,3 +65,177 @@ def prepare_index(index_name, documents):
|
||||
|
||||
count = opensearch.client.count(index=index_name)["count"]
|
||||
assert count == len(documents), f"Expected {len(documents)}, got {count}"
|
||||
|
||||
|
||||
def build_authorization_bearer(token="some_token"):
|
||||
"""
|
||||
Build an Authorization Bearer header value from a token.
|
||||
|
||||
This can be used like this:
|
||||
client.post(
|
||||
...
|
||||
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer('some_token')}",
|
||||
)
|
||||
"""
|
||||
return base64.b64encode(token.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def setup_oicd_jwt_resource_server(
|
||||
responses,
|
||||
settings,
|
||||
sub="some_sub",
|
||||
audience="some_client_id",
|
||||
):
|
||||
"""
|
||||
Setup settings for a resource server with JWT backend.
|
||||
Simulate an encrypted token introspection.
|
||||
NOTE : Use it with @responses.activate or the fake introspection view will not work.
|
||||
"""
|
||||
token_data = {
|
||||
"sub": sub,
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": audience,
|
||||
"client_id": "some_service_provider",
|
||||
"scope": "docs",
|
||||
"active": True,
|
||||
}
|
||||
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
unencrypted_pem_private_key = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
|
||||
pem_public_key = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
|
||||
settings.OIDC_RS_PRIVATE_KEY_STR = unencrypted_pem_private_key.decode("utf-8")
|
||||
settings.OIDC_RS_ENCRYPTION_KEY_TYPE = "RSA"
|
||||
settings.OIDC_RS_ENCRYPTION_ENCODING = "A256GCM"
|
||||
settings.OIDC_RS_ENCRYPTION_ALGO = "RSA-OAEP"
|
||||
settings.OIDC_RS_SIGNING_ALGO = "RS256"
|
||||
settings.OIDC_RS_CLIENT_ID = audience
|
||||
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||
settings.OIDC_RS_SCOPES = ["openid", "docs", "email"]
|
||||
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
|
||||
settings.OIDC_VERIFY_SSL = False
|
||||
settings.OIDC_TIMEOUT = 5
|
||||
settings.OIDC_PROXY = None
|
||||
settings.OIDC_CREATE_USER = False
|
||||
|
||||
# Mock the JWKS endpoint
|
||||
public_numbers = private_key.public_key().public_numbers()
|
||||
responses.add(
|
||||
responses.GET,
|
||||
settings.OIDC_OP_JWKS_ENDPOINT,
|
||||
body=json.dumps(
|
||||
{
|
||||
"keys": [
|
||||
{
|
||||
"kty": settings.OIDC_RS_ENCRYPTION_KEY_TYPE,
|
||||
"alg": settings.OIDC_RS_SIGNING_ALGO,
|
||||
"use": "sig",
|
||||
"kid": "1234567890",
|
||||
"n": to_base64url_uint(public_numbers.n).decode("ascii"),
|
||||
"e": to_base64url_uint(public_numbers.e).decode("ascii"),
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def encrypt_jwt(json_data):
|
||||
"""Encrypt the JWT token for the backend to decrypt."""
|
||||
token = jose_jwt.encode(
|
||||
{
|
||||
"kid": "1234567890",
|
||||
"alg": settings.OIDC_RS_SIGNING_ALGO,
|
||||
},
|
||||
json_data,
|
||||
RSAKey.import_key(unencrypted_pem_private_key),
|
||||
algorithms=[settings.OIDC_RS_SIGNING_ALGO],
|
||||
)
|
||||
|
||||
return jose_jwe.encrypt_compact(
|
||||
protected={
|
||||
"alg": settings.OIDC_RS_ENCRYPTION_ALGO,
|
||||
"enc": settings.OIDC_RS_ENCRYPTION_ENCODING,
|
||||
},
|
||||
plaintext=token,
|
||||
public_key=RSAKey.import_key(pem_public_key),
|
||||
algorithms=[
|
||||
settings.OIDC_RS_ENCRYPTION_ALGO,
|
||||
settings.OIDC_RS_ENCRYPTION_ENCODING,
|
||||
],
|
||||
)
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
body=encrypt_jwt(
|
||||
{
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": audience, # settings.OIDC_RS_CLIENT_ID
|
||||
"token_introspection": token_data,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def setup_oicd_resource_server(
|
||||
responses,
|
||||
settings,
|
||||
sub="some_sub",
|
||||
audience="some_client_id",
|
||||
introspect=None,
|
||||
): # pylint: disable=too-many-arguments
|
||||
"""
|
||||
Setup settings for a resource server.
|
||||
Simulate a token introspection.
|
||||
NOTE : Use it with @responses.activate or the fake introspection view will not work.
|
||||
"""
|
||||
token_data = {
|
||||
"sub": sub,
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": audience,
|
||||
"client_id": audience,
|
||||
"scope": "docs",
|
||||
"active": True,
|
||||
}
|
||||
|
||||
settings.OIDC_RS_ENCRYPTION_KEY_TYPE = "RSA"
|
||||
settings.OIDC_RS_ENCRYPTION_ENCODING = "A256GCM"
|
||||
settings.OIDC_RS_ENCRYPTION_ALGO = "RSA-OAEP"
|
||||
settings.OIDC_RS_SIGNING_ALGO = "RS256"
|
||||
settings.OIDC_RS_CLIENT_ID = audience
|
||||
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||
settings.OIDC_RS_SCOPES = ["openid", "docs", "email"]
|
||||
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
|
||||
settings.OIDC_VERIFY_SSL = False
|
||||
settings.OIDC_TIMEOUT = 5
|
||||
settings.OIDC_PROXY = None
|
||||
settings.OIDC_CREATE_USER = False
|
||||
|
||||
if callable(introspect):
|
||||
responses.add_callback(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
callback=partial(introspect, user_info=token_data),
|
||||
)
|
||||
else:
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
body=json.dumps(token_data),
|
||||
)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""URL configuration for find's core app."""
|
||||
|
||||
from django.urls import path
|
||||
from django.urls import include, path
|
||||
|
||||
from .views import IndexDocumentView, SearchDocumentView
|
||||
|
||||
urlpatterns = [
|
||||
path("documents/index/", IndexDocumentView.as_view(), name="document"),
|
||||
path("documents/search/", SearchDocumentView.as_view(), name="document"),
|
||||
path("", include("lasuite.oidc_resource_server.urls")),
|
||||
]
|
||||
|
||||
+71
-41
@@ -1,17 +1,24 @@
|
||||
"""Views for find's core app."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
from lasuite.oidc_resource_server.mixins import ResourceServerMixin
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
from rest_framework import status, views
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from urllib3.exceptions import ReadTimeoutError
|
||||
|
||||
from . import enums, schemas
|
||||
from .authentication import ServiceTokenAuthentication
|
||||
from .models import Service
|
||||
from .opensearch import client, ensure_index_exists
|
||||
from .permissions import IsAuthAuthenticated
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IndexDocumentView(views.APIView):
|
||||
"""
|
||||
@@ -97,10 +104,13 @@ class IndexDocumentView(views.APIView):
|
||||
actions.append({"index": {"_id": _id}})
|
||||
actions.append(document_dict)
|
||||
results.append({"index": i, "_id": _id, "status": "valid"})
|
||||
|
||||
if has_errors:
|
||||
return Response(results, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Build index if needed.
|
||||
ensure_index_exists(index_name)
|
||||
|
||||
response = client.bulk(index=index_name, body=actions)
|
||||
for i, item in enumerate(response["items"]):
|
||||
if item["index"]["status"] != 201:
|
||||
@@ -117,10 +127,13 @@ class IndexDocumentView(views.APIView):
|
||||
document = schemas.DocumentSchema(**request.data)
|
||||
document_dict = document.model_dump()
|
||||
_id = document_dict.pop("id")
|
||||
|
||||
# Build index if needed.
|
||||
ensure_index_exists(index_name)
|
||||
|
||||
try:
|
||||
client.index(index=index_name, body=document_dict, id=_id)
|
||||
except ReadTimeoutError:
|
||||
ensure_index_exists(index_name)
|
||||
client.index(index=index_name, body=document_dict, id=_id)
|
||||
|
||||
return Response(
|
||||
@@ -128,7 +141,7 @@ class IndexDocumentView(views.APIView):
|
||||
)
|
||||
|
||||
|
||||
class SearchDocumentView(views.APIView):
|
||||
class SearchDocumentView(ResourceServerMixin, views.APIView):
|
||||
"""
|
||||
API view for searching documents in OpenSearch.
|
||||
- Enables searching through indexed documents with support for various filters
|
||||
@@ -136,8 +149,28 @@ class SearchDocumentView(views.APIView):
|
||||
- The search results can be sorted or filtered via querystring parameters.
|
||||
"""
|
||||
|
||||
authentication_classes = []
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = [ResourceServerAuthentication]
|
||||
permission_classes = [IsAuthAuthenticated]
|
||||
|
||||
def _get_opensearch_indices(self, audience, params):
|
||||
# Get request user service
|
||||
try:
|
||||
user_service = Service.objects.get(client_id=audience, is_active=True)
|
||||
except Service.DoesNotExist as e:
|
||||
logger.warning("Login failed: No service %s found", audience)
|
||||
raise SuspiciousOperation("Service is not available") from e
|
||||
|
||||
# Find allowed sub-services for this service
|
||||
allowed_services = set(user_service.services.values_list("name", flat=True))
|
||||
allowed_services.add(user_service.name)
|
||||
|
||||
if params.services:
|
||||
services = set(params.services).intersection(allowed_services)
|
||||
|
||||
if len(services) < len(params.services):
|
||||
raise SuspiciousOperation("Some requested services are not available")
|
||||
|
||||
return allowed_services
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""
|
||||
@@ -170,9 +203,10 @@ class SearchDocumentView(views.APIView):
|
||||
Defaults to 50 if not specified.
|
||||
services: List[str], optional
|
||||
List of services on which we intend to run the query (current service if left empty)
|
||||
visited: List[uuid]
|
||||
visited: List[sub], optional
|
||||
List of public/authenticated documents the user has visited to limit
|
||||
the document returned to the ones the current user has seen.
|
||||
Built from linkreach list of a document in docs app.
|
||||
|
||||
Returns:
|
||||
--------
|
||||
@@ -180,24 +214,10 @@ class SearchDocumentView(views.APIView):
|
||||
- 200 OK: Returns a list of search results matching the query.
|
||||
- 400 Bad Request: If the query parameter 'q' is not provided or invalid.
|
||||
"""
|
||||
# TODO resource server:
|
||||
# - Replace authentication by resource server
|
||||
# - Introspect access token and get user sub from access token
|
||||
# - Get authorized index names for access token service ID
|
||||
# - Check intersection between params.services and authorized index names
|
||||
# - Raise 400 error if not all requested services are authorized
|
||||
# - Implement filtering to limit response to only "visited" documents among those accessible with "public" and "authenticated" reach
|
||||
# - V2: Get list of groups related to the user from SCIM provider (consider caching result)
|
||||
|
||||
# //////////////////////////////
|
||||
# // Make it work temporarily //
|
||||
# //////////////////////////////
|
||||
authorization_header = request.headers.get("Authorization")
|
||||
try:
|
||||
user_sub = authorization_header.split(" ")[1]
|
||||
except (IndexError, AttributeError) as err:
|
||||
raise AuthenticationFailed("Invalid Authorization header format") from err
|
||||
|
||||
# pylint: disable=fixme
|
||||
# TODO : Get list of groups related to the user from SCIM provider (consider caching result)
|
||||
audience = self._get_service_provider_audience()
|
||||
user_sub = self.request.user.sub
|
||||
groups = []
|
||||
# //////////////////////////////////////////////////
|
||||
|
||||
@@ -208,6 +228,12 @@ class SearchDocumentView(views.APIView):
|
||||
from_value = (params.page_number - 1) * params.page_size
|
||||
size_value = params.page_size
|
||||
|
||||
# Get index list for search query
|
||||
try:
|
||||
search_indices = self._get_opensearch_indices(audience, params)
|
||||
except SuspiciousOperation as e:
|
||||
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Prepare the search query
|
||||
search_body = {
|
||||
"_source": enums.SOURCE_FIELDS, # limit the fields to return
|
||||
@@ -248,26 +274,23 @@ class SearchDocumentView(views.APIView):
|
||||
{
|
||||
"bool": {
|
||||
"should": [
|
||||
# Access control on public & authenticated reach
|
||||
{
|
||||
"bool": {
|
||||
"must_not": {
|
||||
"term": {enums.REACH: enums.ReachEnum.RESTRICTED}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"bool": {
|
||||
"must": {
|
||||
"term": {enums.REACH: enums.ReachEnum.RESTRICTED}
|
||||
"term": {enums.REACH: enums.ReachEnum.RESTRICTED},
|
||||
},
|
||||
"should": [
|
||||
{"term": {enums.USERS: user_sub}},
|
||||
{"terms": {enums.GROUPS: groups}},
|
||||
],
|
||||
# At least one of the 2 optional should clauses must apply
|
||||
"minimum_should_match": 1,
|
||||
}
|
||||
# Limit search to already visited documents.
|
||||
"must": {
|
||||
"terms": {
|
||||
"_id": sorted(params.visited),
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
# Access control on restricted search : either user or group should match
|
||||
{"term": {enums.USERS: user_sub}},
|
||||
{"terms": {enums.GROUPS: groups}},
|
||||
],
|
||||
# At least one of the 2 optional should clauses must apply
|
||||
"minimum_should_match": 1,
|
||||
@@ -284,5 +307,12 @@ class SearchDocumentView(views.APIView):
|
||||
# Always filter out inactive documents
|
||||
search_body["query"]["bool"]["filter"].append({"term": {"is_active": True}})
|
||||
|
||||
response = client.search(index=",".join(params.services), body=search_body)
|
||||
response = client.search( # pylint: disable=unexpected-keyword-arg
|
||||
index=",".join(search_indices),
|
||||
body=search_body,
|
||||
# Argument added by the query_params() decorator of opensearch and
|
||||
# not in the method declaration.
|
||||
ignore_unavailable=True,
|
||||
)
|
||||
|
||||
return Response(response["hits"]["hits"], status=status.HTTP_200_OK)
|
||||
|
||||
+101
-36
@@ -296,9 +296,13 @@ class Base(Configuration):
|
||||
environ_name="OIDC_RP_CLIENT_SECRET",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_OP_URL = values.Value(None, environ_name="OIDC_OP_URL", environ_prefix=None)
|
||||
OIDC_OP_JWKS_ENDPOINT = values.Value(
|
||||
environ_name="OIDC_OP_JWKS_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT = values.Value(
|
||||
environ_name="OIDC_OP_INTROSPECTION_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT = values.Value(
|
||||
environ_name="OIDC_OP_AUTHORIZATION_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
@@ -317,6 +321,65 @@ class Base(Configuration):
|
||||
OIDC_RP_SCOPES = values.Value(
|
||||
"openid email", environ_name="OIDC_RP_SCOPES", environ_prefix=None
|
||||
)
|
||||
|
||||
# OIDC - Resource server
|
||||
OIDC_DRF_AUTH_BACKEND = values.Value(
|
||||
"lasuite.oidc_login.backends.OIDCAuthenticationBackend",
|
||||
environ_name="OIDC_DRF_AUTH_BACKEND",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
OIDC_RS_BACKEND_CLASS = values.Value(
|
||||
"core.authentication.FinderResourceServerBackend",
|
||||
environ_name="OIDC_RS_BACKEND_CLASS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_RS_AUDIENCE_CLAIM = values.Value(
|
||||
"client_id",
|
||||
environ_name="OIDC_RS_AUDIENCE_CLAIM",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_RS_CLIENT_ID = values.Value(
|
||||
None, environ_name="OIDC_RS_CLIENT_ID", environ_prefix=None
|
||||
)
|
||||
OIDC_RS_CLIENT_SECRET = values.Value(
|
||||
None,
|
||||
environ_name="OIDC_RS_CLIENT_SECRET",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_RS_SIGNING_ALGO = values.Value(
|
||||
default="ES256", environ_name="OIDC_RS_SIGNING_ALGO", environ_prefix=None
|
||||
)
|
||||
OIDC_RS_SCOPES = values.ListValue(
|
||||
["groups"], environ_name="OIDC_RS_SCOPES", environ_prefix=None
|
||||
)
|
||||
OIDC_RS_PRIVATE_KEY_STR = values.Value(
|
||||
default=None,
|
||||
environ_name="OIDC_RS_PRIVATE_KEY_STR",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_RS_ENCRYPTION_KEY_TYPE = values.Value(
|
||||
default="RSA",
|
||||
environ_name="OIDC_RS_ENCRYPTION_KEY_TYPE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_RS_ENCRYPTION_ALGO = values.Value(
|
||||
default="RSA-OAEP",
|
||||
environ_name="OIDC_RS_ENCRYPTION_ALGO",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_RS_ENCRYPTION_ENCODING = values.Value(
|
||||
default="A256GCM",
|
||||
environ_name="OIDC_RS_ENCRYPTION_ENCODING",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
OIDC_VERIFY_SSL = values.BooleanValue(
|
||||
True, environ_name="OIDC_VERIFY_SSL", environ_prefix=None
|
||||
)
|
||||
OIDC_TIMEOUT = values.Value(None, environ_name="OIDC_TIMEOUT", environ_prefix=None)
|
||||
OIDC_PROXY = values.Value(None, environ_name="OIDC_PROXY", environ_prefix=None)
|
||||
|
||||
LOGIN_REDIRECT_URL = values.Value(
|
||||
None, environ_name="LOGIN_REDIRECT_URL", environ_prefix=None
|
||||
)
|
||||
@@ -342,6 +405,44 @@ class Base(Configuration):
|
||||
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
|
||||
)
|
||||
|
||||
# Logging
|
||||
# We want to make it easy to log to console but by default we log production
|
||||
# to Sentry and don't want to log to console.
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"simple": {
|
||||
"format": "{asctime} {name} {levelname} {message}",
|
||||
"style": "{",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "simple",
|
||||
},
|
||||
},
|
||||
# Override root logger to send it to console
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": values.Value(
|
||||
"INFO", environ_name="LOGGING_LEVEL_LOGGERS_ROOT", environ_prefix=None
|
||||
),
|
||||
},
|
||||
"loggers": {
|
||||
"find": {
|
||||
"handlers": ["console"],
|
||||
"level": values.Value(
|
||||
"INFO",
|
||||
environ_name="LOGGING_LEVEL_LOGGERS_APP",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"propagate": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
@@ -423,23 +524,6 @@ class Development(Base):
|
||||
class Test(Base):
|
||||
"""Test environment settings"""
|
||||
|
||||
LOGGING = values.DictValue(
|
||||
{
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"find": {
|
||||
"handlers": ["console"],
|
||||
"level": "DEBUG",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
PASSWORD_HASHERS = [
|
||||
"django.contrib.auth.hashers.MD5PasswordHasher",
|
||||
]
|
||||
@@ -511,25 +595,6 @@ class Production(Base):
|
||||
},
|
||||
}
|
||||
|
||||
LOGGING = values.DictValue(
|
||||
{
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"find": {
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
"propagate": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class Feature(Production):
|
||||
"""
|
||||
|
||||
@@ -31,6 +31,7 @@ dependencies = [
|
||||
"redis==5.2.1",
|
||||
"django-redis==5.4.0",
|
||||
"django==5.1.4",
|
||||
"django-lasuite[all]==0.0.11",
|
||||
"djangorestframework==3.15.2",
|
||||
"drf_spectacular==0.28.0",
|
||||
"dockerflow==2024.4.2",
|
||||
|
||||
Reference in New Issue
Block a user