(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:
Fabre Florian
2025-08-13 15:20:10 +02:00
committed by Florian Fabre
parent f93515d70b
commit db5cc54f67
17 changed files with 1221 additions and 195 deletions
+1
View File
@@ -12,3 +12,4 @@ and this project adheres to
- backend application
- helm chart
- ✨(backend) New search api view (OIDC resource)
+15 -4
View File
@@ -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}
+13
View File
@@ -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"
+39 -1
View File
@@ -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
+1
View File
@@ -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'),
),
]
+9 -1
View File
@@ -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
View File
@@ -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
+213 -2
View File
@@ -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),
)
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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):
"""
+1
View File
@@ -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",