mirror of
https://github.com/suitenumerique/drive.git
synced 2026-08-17 20:15:40 +02:00
✨(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:
@@ -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 |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user