-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨(backend) add url to download media attachments with access rights
We make use of nginx subrequests to block media file downloads while we check for access rights. The request is then proxied to the object storage engine and authorization is added via the "Authorization" header. This way the media urls are static and can be stored in the document's json content without compromising on security: access control is done on all requests based on the user cookie session.
- Loading branch information
1 parent
c9f1356
commit 67a20f2
Showing
12 changed files
with
514 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
"""Util to generate S3 authorization headers for object storage access control""" | ||
|
||
from django.core.files.storage import default_storage | ||
|
||
import botocore | ||
|
||
|
||
def generate_s3_authorization_headers(key): | ||
""" | ||
Generate authorization headers for an s3 object. | ||
These headers can be used as an alternative to signed urls with many benefits: | ||
- the urls of our files never expire and can be stored in our documents' content | ||
- we don't leak authorized urls that could be shared (file access can only be done | ||
with cookies) | ||
- access control is truly realtime | ||
- the object storage service does not need to be exposed on internet | ||
""" | ||
url = default_storage.unsigned_connection.meta.client.generate_presigned_url( | ||
"get_object", | ||
ExpiresIn=0, | ||
Params={"Bucket": default_storage.bucket_name, "Key": key}, | ||
) | ||
request = botocore.awsrequest.AWSRequest(method="get", url=url) | ||
|
||
s3_client = default_storage.connection.meta.client | ||
# pylint: disable=protected-access | ||
credentials = s3_client._request_signer._credentials # noqa: SLF001 | ||
frozen_credentials = credentials.get_frozen_credentials() | ||
region = s3_client.meta.region_name | ||
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region) | ||
auth.add_auth(request) | ||
|
||
return request |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
213 changes: 213 additions & 0 deletions
213
src/backend/core/tests/documents/test_api_documents_retrieve_auth.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,213 @@ | ||
""" | ||
Test file uploads API endpoint for users in impress's core app. | ||
""" | ||
|
||
import uuid | ||
from io import BytesIO | ||
from urllib.parse import urlparse | ||
|
||
from django.conf import settings | ||
from django.core.files.storage import default_storage | ||
from django.utils import timezone | ||
|
||
import pytest | ||
import requests | ||
from rest_framework.test import APIClient | ||
|
||
from core import factories | ||
from core.tests.conftest import TEAM, USER, VIA | ||
|
||
pytestmark = pytest.mark.django_db | ||
|
||
|
||
def test_api_documents_retrieve_auth_anonymous_public(): | ||
"""Anonymous users should be able to retrieve attachments linked to a public document""" | ||
document = factories.DocumentFactory(is_public=True) | ||
|
||
filename = f"{uuid.uuid4()!s}.jpg" | ||
key = f"{document.pk!s}/attachments/{filename:s}" | ||
|
||
default_storage.connection.meta.client.put_object( | ||
Bucket=default_storage.bucket_name, | ||
Key=key, | ||
Body=BytesIO(b"my prose"), | ||
ContentType="text/plain", | ||
) | ||
|
||
original_url = f"http://localhost/media/{key:s}" | ||
response = APIClient().get( | ||
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url | ||
) | ||
|
||
assert response.status_code == 200 | ||
|
||
authorization = response["Authorization"] | ||
assert "AWS4-HMAC-SHA256 Credential=" in authorization | ||
assert ( | ||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" | ||
in authorization | ||
) | ||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ") | ||
|
||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) | ||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" | ||
response = requests.get( | ||
file_url, | ||
headers={ | ||
"authorization": authorization, | ||
"x-amz-date": response["x-amz-date"], | ||
"x-amz-content-sha256": response["x-amz-content-sha256"], | ||
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}", | ||
}, | ||
timeout=1, | ||
) | ||
assert response.content.decode("utf-8") == "my prose" | ||
|
||
|
||
def test_api_documents_retrieve_auth_anonymous_not_public(): | ||
""" | ||
Anonymous users should not be allowed to retrieve attachments linked to a document | ||
that is not public. | ||
""" | ||
document = factories.DocumentFactory(is_public=False) | ||
|
||
filename = f"{uuid.uuid4()!s}.jpg" | ||
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}" | ||
|
||
response = APIClient().get( | ||
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url | ||
) | ||
|
||
assert response.status_code == 403 | ||
assert "Authorization" not in response | ||
|
||
|
||
def test_api_documents_retrieve_auth_authenticated_public(): | ||
""" | ||
Authenticated users who are not related to a document should be able to | ||
retrieve attachments linked to a public document. | ||
""" | ||
document = factories.DocumentFactory(is_public=True) | ||
|
||
user = factories.UserFactory() | ||
client = APIClient() | ||
client.force_login(user) | ||
|
||
filename = f"{uuid.uuid4()!s}.jpg" | ||
key = f"{document.pk!s}/attachments/{filename:s}" | ||
|
||
default_storage.connection.meta.client.put_object( | ||
Bucket=default_storage.bucket_name, | ||
Key=key, | ||
Body=BytesIO(b"my prose"), | ||
ContentType="text/plain", | ||
) | ||
|
||
original_url = f"http://localhost/media/{key:s}" | ||
response = APIClient().get( | ||
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url | ||
) | ||
|
||
assert response.status_code == 200 | ||
|
||
authorization = response["Authorization"] | ||
assert "AWS4-HMAC-SHA256 Credential=" in authorization | ||
assert ( | ||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" | ||
in authorization | ||
) | ||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ") | ||
|
||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) | ||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" | ||
response = requests.get( | ||
file_url, | ||
headers={ | ||
"authorization": authorization, | ||
"x-amz-date": response["x-amz-date"], | ||
"x-amz-content-sha256": response["x-amz-content-sha256"], | ||
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}", | ||
}, | ||
timeout=1, | ||
) | ||
assert response.content.decode("utf-8") == "my prose" | ||
|
||
|
||
def test_api_documents_retrieve_auth_authenticated_not_public(): | ||
""" | ||
Authenticated users who are not related to a document should not be allowed to | ||
retrieve attachments linked to a document that is not public. | ||
""" | ||
document = factories.DocumentFactory(is_public=False) | ||
|
||
user = factories.UserFactory() | ||
client = APIClient() | ||
client.force_login(user) | ||
|
||
filename = f"{uuid.uuid4()!s}.jpg" | ||
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}" | ||
|
||
response = APIClient().get( | ||
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url | ||
) | ||
|
||
assert response.status_code == 403 | ||
assert "Authorization" not in response | ||
|
||
|
||
@pytest.mark.parametrize("is_public", [True, False]) | ||
@pytest.mark.parametrize("via", VIA) | ||
def test_api_documents_retrieve_auth_related(via, is_public, mock_user_get_teams): | ||
""" | ||
Users who have a role on a document, whatever the role, should be able to | ||
retrieve related attachments. | ||
""" | ||
user = factories.UserFactory() | ||
client = APIClient() | ||
client.force_login(user) | ||
|
||
document = factories.DocumentFactory(is_public=is_public) | ||
if via == USER: | ||
factories.UserDocumentAccessFactory(document=document, user=user) | ||
elif via == TEAM: | ||
mock_user_get_teams.return_value = ["lasuite", "unknown"] | ||
factories.TeamDocumentAccessFactory(document=document, team="lasuite") | ||
|
||
filename = f"{uuid.uuid4()!s}.jpg" | ||
key = f"{document.pk!s}/attachments/{filename:s}" | ||
|
||
default_storage.connection.meta.client.put_object( | ||
Bucket=default_storage.bucket_name, | ||
Key=key, | ||
Body=BytesIO(b"my prose"), | ||
ContentType="text/plain", | ||
) | ||
|
||
original_url = f"http://localhost/media/{key:s}" | ||
response = client.get( | ||
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url | ||
) | ||
|
||
assert response.status_code == 200 | ||
|
||
authorization = response["Authorization"] | ||
assert "AWS4-HMAC-SHA256 Credential=" in authorization | ||
assert ( | ||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" | ||
in authorization | ||
) | ||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ") | ||
|
||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) | ||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" | ||
response = requests.get( | ||
file_url, | ||
headers={ | ||
"authorization": authorization, | ||
"x-amz-date": response["x-amz-date"], | ||
"x-amz-content-sha256": response["x-amz-content-sha256"], | ||
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}", | ||
}, | ||
timeout=1, | ||
) | ||
assert response.content.decode("utf-8") == "my prose" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.