Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Build out s3 permissions in API, and button for UI to retrieve, for neuroglancer #126

Merged
merged 9 commits into from
Mar 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dandiapi/api/management/commands/create_dev_dandiset.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import hashlib
from __future__ import annotations
import hashlib

from uuid import uuid4

Expand Down
3 changes: 3 additions & 0 deletions dandiapi/api/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .dandiset import DandisetViewSet
from .dashboard import DashboardView, user_approval_view
from .info import info_view
from .private_s3_permissions import presigned_cookie_s3_cloudfront_view
from .root import root_content_view
from .stats import stats_view
from .upload import (
Expand All @@ -16,6 +17,7 @@
from .users import users_me_view, users_search_view
from .version import VersionViewSet


__all__ = [
'NestedAssetViewSet',
'AssetViewSet',
Expand All @@ -35,4 +37,5 @@
'stats_view',
'info_view',
'root_content_view',
'presigned_cookie_s3_cloudfront_view',
]
141 changes: 141 additions & 0 deletions dandiapi/api/views/private_s3_permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
from __future__ import annotations

from typing import TYPE_CHECKING

from drf_yasg.utils import swagger_auto_schema
from rest_framework.decorators import api_view, parser_classes, permission_classes
from rest_framework.parsers import JSONParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response

import time
import json
import base64
import io
import os

from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.serialization import load_pem_private_key

from dandiapi.api.storage import get_boto_client, get_storage
from django.conf import settings

def _replace_unsupported_chars(some_str):
"""Replace unsupported chars: '+=/' with '-_~'"""
return some_str.replace("+", "-") \
.replace("=", "_") \
.replace("/", "~")


def _in_an_hour():
"""Returns a UTC POSIX timestamp for one hour in the future"""
return int(time.time()) + (60*60)


def rsa_signer(message, key):
"""
Sign a message using RSA private key with PKCS#1 v1.5 padding and SHA-1 hash.

:param message: The message to sign
:param key: RSA private key in PEM format
:return: The signature
"""
private_key = load_pem_private_key(
key,
password=None
)

# Updated signing process
signature = private_key.sign(
message,
padding.PKCS1v15(),
hashes.SHA1()
)

return signature


def generate_policy_cookie(url):
"""Returns a tuple: (policy json, policy base64)"""

policy_dict = {
"Statement": [
{
"Resource": url,
"Condition": {
"DateLessThan": {
"AWS:EpochTime": _in_an_hour()
}
}
}
]
}

# Using separators=(',', ':') removes seperator whitespace
policy_json = json.dumps(policy_dict, separators=(",", ":"))

policy_64 = str(base64.b64encode(policy_json.encode("utf-8")), "utf-8")
policy_64 = _replace_unsupported_chars(policy_64)
return policy_json, policy_64


def generate_signature(policy, key):
"""Creates a signature for the policy from the key, returning a string"""
sig_bytes = rsa_signer(policy.encode("utf-8"), key)
sig_64 = _replace_unsupported_chars(str(base64.b64encode(sig_bytes), "utf-8"))
return sig_64


def generate_cookies(policy, signature):
"""Returns a dictionary for cookie values in the form 'COOKIE NAME': 'COOKIE VALUE'"""
return {
"CloudFront-Policy": policy,
"CloudFront-Signature": signature,
"CloudFront-Key-Pair-Id": os.getenv('CLOUDFRONT_PEM_KEY_ID')
}


def generate_signed_cookies(key):
policy_json, policy_64 = generate_policy_cookie(os.getenv('CLOUDFRONT_NEUROGLANCER_URL'))
signature = generate_signature(policy_json, key)
return generate_cookies(policy_64, signature)


if TYPE_CHECKING:
from django.http.response import HttpResponseBase
from rest_framework.request import Request

@swagger_auto_schema(
method='GET',
responses={200: None},
operation_summary='Provides presigned cookie for retrieving S3 assets exposed '
'via AWS CloudFront Distributions',
operation_description='',
)
@api_view(['GET'])
@parser_classes([JSONParser])
@permission_classes([IsAuthenticated])
def presigned_cookie_s3_cloudfront_view(request: Request) -> HttpResponseBase:

# Get Private PEM Key from S3
client = get_boto_client(get_storage())
private_pem_key = os.getenv('CLOUDFRONT_PRIVATE_PEM_S3_LOCATION')
response = client.get_object(Bucket=settings.DANDI_DANDISETS_BUCKET_NAME, Key=private_pem_key)
pem_content = response['Body'].read()

with io.BytesIO(pem_content) as pem_file:
cookies = generate_signed_cookies(pem_file.read())

response_data = {"message": cookies}
response = Response(response_data)
for cookie_name, cookie_value in cookies.items():
response.set_cookie(
key=cookie_name,
value=cookie_value,
secure=True,
httponly=True,
domain=f".{os.getenv('CLOUDFRONT_BASE_URL')}"
)

return response
18 changes: 14 additions & 4 deletions dandiapi/api/views/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,20 @@ class Meta(DandisetSerializer.Meta):


class DandisetQueryParameterSerializer(serializers.Serializer):
draft = serializers.BooleanField(default=True)
empty = serializers.BooleanField(default=True)
embargoed = serializers.BooleanField(default=False)
user = serializers.CharField(required=False)
draft = serializers.BooleanField(
default=True,
help_text='Whether to include dandisets that only have draft '
"versions (i.e., haven't been published yet).",
)
empty = serializers.BooleanField(default=True, help_text='Whether to include empty dandisets.')
embargoed = serializers.BooleanField(
default=False, help_text='Whether to include embargoed dandisets.'
)
user = serializers.ChoiceField(
choices=['me'],
required=False,
help_text='Set this value to "me" to only return dandisets owned by the current user.',
)


class DandisetSearchQueryParameterSerializer(DandisetQueryParameterSerializer):
Expand Down
6 changes: 5 additions & 1 deletion dandiapi/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
authorize_view,
blob_read_view,
info_view,
presigned_cookie_s3_cloudfront_view,
root_content_view,
stats_view,
upload_complete_view,
Expand All @@ -43,7 +44,7 @@
.register(
r'assets',
NestedAssetViewSet,
basename='asset',
basename='nested-asset',
parents_query_lookups=[
f'versions__dandiset__{DandisetViewSet.lookup_field}',
f'versions__{VersionViewSet.lookup_field}',
Expand All @@ -54,6 +55,8 @@
router.register('zarr', ZarrViewSet, basename='zarr')




schema_view = get_schema_view(
openapi.Info(
title='DANDI Archive',
Expand Down Expand Up @@ -100,6 +103,7 @@ def to_url(self, value):
re_path(
r'^api/users/questionnaire-form/$', user_questionnaire_form_view, name='user-questionnaire'
),
path('api/permissions/s3/', presigned_cookie_s3_cloudfront_view),
path('api/search/genotypes/', search_genotypes),
path('api/search/species/', search_species),
path('admin/', admin.site.urls),
Expand Down
3 changes: 3 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@
'requests',
's3-log-parse',
'zarr-checksum>=0.2.8',
'rsa',
'cryptography',
'httpx',
# Production-only
'django-composed-configuration[prod]>=0.23.0',
'django-s3-file-field[s3]>=1.0.0',
Expand Down
12 changes: 12 additions & 0 deletions web/src/components/AppBar/UserMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@
</v-list-item-content>
</v-list-item>
<ApiKeyItem v-if="user?.approved" />
<v-list-item @click="getNeuroglancerCookies">
<v-list-item-content>
Get Neuroglancer Cookies
</v-list-item-content>
<v-list-item-action>
<v-icon>mdi-cookie</v-icon>
</v-list-item-action>
</v-list-item>
<v-list-item @click="logout">
<v-list-item-content>
Logout
Expand Down Expand Up @@ -67,6 +75,10 @@ const userInitials = computed(() => {
return '??';
});

async function getNeuroglancerCookies() {
await dandiRest.getNeuroglancerCookies();
}

async function logout() {
await dandiRest.logout();
}
Expand Down
3 changes: 3 additions & 0 deletions web/src/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,9 @@ const dandiRest = {
const { data } = await client.get('stats/');
return data;
},
async getNeuroglancerCookies() {
await client.get('permissions/s3/');
},
assetManifestURI(identifier: string, version: string) {
return `${dandiApiRoot}dandisets/${identifier}/versions/${version}/assets/`;
},
Expand Down
Loading