(backend) apply per-audience attributes to external api items

External services consuming the resource server API may need items they
create to carry specific attributes, typically quota_excluded so their
uploads do not consume the user quota.

The new EXTERNAL_API_AUD_ITEM_ATTRIBUTES setting maps a token audience
to the attributes applied at creation. The lookup is a hook on the item
viewset overridden only in the resource server viewset, so both the
root create and children creation paths are covered while the regular
API remains unaffected.
This commit is contained in:
Nathan Vasse
2026-08-03 10:20:45 +02:00
parent 9caa1caa3d
commit 8f47646dff
7 changed files with 157 additions and 0 deletions
+1
View File
@@ -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_URL_APP` | URL used in emails to link back to the app | `None` |
| `EMAIL_USE_SSL` | Use SSL for SMTP connection | `False` | | `EMAIL_USE_SSL` | Use SSL for SMTP connection | `False` |
| `EMAIL_USE_TLS` | Use TLS 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_ALPHA` | Enable alpha features | `False` |
| `FEATURES_INDEXED_SEARCH` | Enable the search of indexed files through the API | `True` | | `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 | | `FILE_EXTENSIONS_ALLOWED` | List of file extension allowed to be uploaded | See in the settings.py file |
+19
View File
@@ -55,6 +55,25 @@ EXTERNAL_API = {
Each endpoint has `enabled` (boolean) and `actions` (list of allowed actions). Only actions explicitly listed are accessible. 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 ## 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) 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)
+6
View File
@@ -664,6 +664,10 @@ class ItemViewSet(
item.size = len(template_content) item.size = len(template_content)
item.save(update_fields=["upload_state", "mimetype", "size", "updated_at"]) 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): def perform_create(self, serializer):
"""Set the current user as creator and owner of the newly created object.""" """Set the current user as creator and owner of the newly created object."""
extension = serializer.validated_data.pop("extension", None) extension = serializer.validated_data.pop("extension", None)
@@ -672,6 +676,7 @@ class ItemViewSet(
creator=self.request.user, creator=self.request.user,
link_reach=LinkReachChoices.RESTRICTED, link_reach=LinkReachChoices.RESTRICTED,
**serializer.validated_data, **serializer.validated_data,
**self.get_create_extra_attributes(),
) )
if extension: if extension:
self._create_file_from_template(obj, extension) self._create_file_from_template(obj, extension)
@@ -1121,6 +1126,7 @@ class ItemViewSet(
creator=request.user, creator=request.user,
parent=item, parent=item,
**serializer.validated_data, **serializer.validated_data,
**self.get_create_extra_attributes(),
) )
if extension: if extension:
@@ -45,6 +45,11 @@ class ResourceServerItemViewSet(ResourceServerRestrictionMixin, ItemViewSet):
"""Build resource_server_actions from settings.""" """Build resource_server_actions from settings."""
return self._get_resource_server_actions("items") 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): class ResourceServerUserViewSet(ResourceServerRestrictionMixin, UserViewSet):
"""Resource Server Viewset for the Drive app.""" """Resource Server Viewset for the Drive app."""
@@ -194,6 +194,100 @@ def test_api_items_upload_root_resource_server_using_access_token(
assert response.status_code == 200 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. # Non allowed actions on resource server.
@@ -59,6 +59,29 @@ def test_api_items_create_authenticated_success():
assert item.type == ItemTypeChoices.FOLDER 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(): def test_api_items_create_file_authenticated_no_filename():
""" """
Creating a file item without providing a filename should fail. Creating a file item without providing a filename should fail.
+9
View File
@@ -1296,6 +1296,15 @@ class Base(Configuration):
environ_prefix=None, 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( OIDC_RS_PRIVATE_KEY_STR = values.Value(
default=None, default=None,
environ_name="OIDC_RS_PRIVATE_KEY_STR", environ_name="OIDC_RS_PRIVATE_KEY_STR",