diff --git a/docs/env.md b/docs/env.md index 6f106246..0827fd19 100644 --- a/docs/env.md +++ b/docs/env.md @@ -47,6 +47,7 @@ This document lists all configurable environment variables for the Drive applica | `EMAIL_URL_APP` | URL used in emails to link back to the app | `None` | | `EMAIL_USE_SSL` | Use SSL for SMTP connection | `False` | | `EMAIL_USE_TLS` | Use TLS for SMTP connection | `False` | +| `EXTERNAL_API_AUD_ITEM_ATTRIBUTES` | Extra attributes applied to items created through the external API, keyed by the token audience of the request, e.g. `{"some_audience": {"quota_excluded": true}}` | `{}` | | `FEATURES_ALPHA` | Enable alpha features | `False` | | `FEATURES_INDEXED_SEARCH` | Enable the search of indexed files through the API | `True` | | `FILE_EXTENSIONS_ALLOWED` | List of file extension allowed to be uploaded | See in the settings.py file | diff --git a/docs/resource_server.md b/docs/resource_server.md index 90516993..5c2a3546 100644 --- a/docs/resource_server.md +++ b/docs/resource_server.md @@ -55,6 +55,25 @@ EXTERNAL_API = { Each endpoint has `enabled` (boolean) and `actions` (list of allowed actions). Only actions explicitly listed are accessible. +## Customise created item attributes per audience + +Configure the `EXTERNAL_API_AUD_ITEM_ATTRIBUTES` setting to apply extra attributes to items +created through the external API, depending on the token audience of the request (the claim +configured by `OIDC_RS_AUDIENCE_CLAIM`). Set it via the `EXTERNAL_API_AUD_ITEM_ATTRIBUTES` +environment variable (as JSON) or in Django settings. + +```python +EXTERNAL_API_AUD_ITEM_ATTRIBUTES = { + "some_audience": { + "quota_excluded": True, + }, +} +``` + +With this configuration, every item created by a request authenticated with the +`some_audience` audience is excluded from its creator's storage quota computation. +Audiences without an entry keep the default item attributes. + ## Request Drive In order to request drive from an external resource provider, you need to implement the basic setup of `django-lasuite` [Using the OIDC Authentication Backend to request a resource server](https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-oidc-call-to-resource-server.md) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 0f9892c9..1eefe034 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -664,6 +664,10 @@ class ItemViewSet( item.size = len(template_content) item.save(update_fields=["upload_state", "mimetype", "size", "updated_at"]) + def get_create_extra_attributes(self): + """Extra model attributes applied to items created by this viewset (subclass hook).""" + return {} + def perform_create(self, serializer): """Set the current user as creator and owner of the newly created object.""" extension = serializer.validated_data.pop("extension", None) @@ -672,6 +676,7 @@ class ItemViewSet( creator=self.request.user, link_reach=LinkReachChoices.RESTRICTED, **serializer.validated_data, + **self.get_create_extra_attributes(), ) if extension: self._create_file_from_template(obj, extension) @@ -1121,6 +1126,7 @@ class ItemViewSet( creator=request.user, parent=item, **serializer.validated_data, + **self.get_create_extra_attributes(), ) if extension: diff --git a/src/backend/core/external_api/viewsets.py b/src/backend/core/external_api/viewsets.py index 7c7c6459..4fd6897f 100644 --- a/src/backend/core/external_api/viewsets.py +++ b/src/backend/core/external_api/viewsets.py @@ -45,6 +45,11 @@ class ResourceServerItemViewSet(ResourceServerRestrictionMixin, ItemViewSet): """Build resource_server_actions from settings.""" return self._get_resource_server_actions("items") + def get_create_extra_attributes(self): + """Apply per-audience item attributes from EXTERNAL_API_AUD_ITEM_ATTRIBUTES.""" + audience = getattr(self.request, "resource_server_token_audience", None) + return dict(settings.EXTERNAL_API_AUD_ITEM_ATTRIBUTES.get(audience) or {}) + class ResourceServerUserViewSet(ResourceServerRestrictionMixin, UserViewSet): """Resource Server Viewset for the Drive app.""" diff --git a/src/backend/core/tests/external_api/items/test_external_api_items.py b/src/backend/core/tests/external_api/items/test_external_api_items.py index 5fbba56f..25c8c7ef 100644 --- a/src/backend/core/tests/external_api/items/test_external_api_items.py +++ b/src/backend/core/tests/external_api/items/test_external_api_items.py @@ -194,6 +194,100 @@ def test_api_items_upload_root_resource_server_using_access_token( assert response.status_code == 200 +@override_settings( + EXTERNAL_API_AUD_ITEM_ATTRIBUTES={"some_service_provider": {"quota_excluded": True}} +) +def test_api_items_create_root_resource_server_applies_aud_attributes( + user_token, resource_server_backend, user_specific_sub +): + """Items created at the root should get the attributes configured for the token audience.""" + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}") + + response = client.post( + "/external_api/v1.0/items/", + { + "type": models.ItemTypeChoices.FILE, + "filename": "file.txt", + }, + ) + + assert response.status_code == 201 + child = models.Item.objects.get(id=response.json()["id"]) + assert child.quota_excluded is True + + +@override_settings( + EXTERNAL_API_AUD_ITEM_ATTRIBUTES={"some_service_provider": {"quota_excluded": True}} +) +def test_api_items_create_children_resource_server_applies_aud_attributes( + user_token, resource_server_backend, user_specific_sub +): + """Items created as children should get the attributes configured for the token audience.""" + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}") + item = factories.ItemFactory( + link_reach=models.LinkReachChoices.RESTRICTED, + type=models.ItemTypeChoices.FOLDER, + ) + factories.UserItemAccessFactory( + item=item, user=user_specific_sub, role=models.RoleChoices.OWNER + ) + + response = client.post( + f"/external_api/v1.0/items/{item.id!s}/children/", + { + "type": models.ItemTypeChoices.FILE, + "filename": "file.txt", + }, + ) + + assert response.status_code == 201 + child = models.Item.objects.get(id=response.json()["id"]) + assert child.quota_excluded is True + + +@override_settings(EXTERNAL_API_AUD_ITEM_ATTRIBUTES={"other_audience": {"quota_excluded": True}}) +def test_api_items_create_resource_server_aud_not_configured( + user_token, resource_server_backend, user_specific_sub +): + """Items created with an audience missing from the setting should keep default attributes.""" + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}") + + response = client.post( + "/external_api/v1.0/items/", + { + "type": models.ItemTypeChoices.FILE, + "filename": "file.txt", + }, + ) + + assert response.status_code == 201 + child = models.Item.objects.get(id=response.json()["id"]) + assert child.quota_excluded is False + + +def test_api_items_create_resource_server_no_aud_attributes_setting( + user_token, resource_server_backend, user_specific_sub +): + """Items created with the default empty setting should keep default attributes.""" + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}") + + response = client.post( + "/external_api/v1.0/items/", + { + "type": models.ItemTypeChoices.FILE, + "filename": "file.txt", + }, + ) + + assert response.status_code == 201 + child = models.Item.objects.get(id=response.json()["id"]) + assert child.quota_excluded is False + + # Non allowed actions on resource server. diff --git a/src/backend/core/tests/items/test_api_items_create.py b/src/backend/core/tests/items/test_api_items_create.py index 5756aae8..436d9eb9 100644 --- a/src/backend/core/tests/items/test_api_items_create.py +++ b/src/backend/core/tests/items/test_api_items_create.py @@ -59,6 +59,29 @@ def test_api_items_create_authenticated_success(): assert item.type == ItemTypeChoices.FOLDER +@override_settings( + EXTERNAL_API_AUD_ITEM_ATTRIBUTES={"some_service_provider": {"quota_excluded": True}} +) +def test_api_items_create_authenticated_ignores_aud_item_attributes(): + """Per-audience attributes only apply to the external API, never to the regular API.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.post( + "/api/v1.0/items/", + { + "title": "my item", + "type": ItemTypeChoices.FOLDER, + }, + format="json", + ) + assert response.status_code == 201 + item = Item.objects.get() + assert item.quota_excluded is False + + def test_api_items_create_file_authenticated_no_filename(): """ Creating a file item without providing a filename should fail. diff --git a/src/backend/drive/settings.py b/src/backend/drive/settings.py index c12d1c45..57c6c39c 100755 --- a/src/backend/drive/settings.py +++ b/src/backend/drive/settings.py @@ -1296,6 +1296,15 @@ class Base(Configuration): environ_prefix=None, ) + # Extra attributes applied to items created through the external API, + # keyed by the token audience of the request, + # e.g. {"some_audience": {"quota_excluded": True}} + EXTERNAL_API_AUD_ITEM_ATTRIBUTES = values.DictValue( + default={}, + environ_name="EXTERNAL_API_AUD_ITEM_ATTRIBUTES", + environ_prefix=None, + ) + OIDC_RS_PRIVATE_KEY_STR = values.Value( default=None, environ_name="OIDC_RS_PRIVATE_KEY_STR",