Skip to content

Commit

Permalink
[ENG-5183] Implement Auth strategy w/ OSF (#10)
Browse files Browse the repository at this point in the history
Identify the Session user and confirm access to Resources with help from OSF

---------

Co-authored-by: John Tordoff <>
Co-authored-by: Jon Walz <jon@cos.io>
  • Loading branch information
Johnetordoff and jwalz authored Feb 22, 2024
1 parent e3eddfe commit 794c660
Show file tree
Hide file tree
Showing 21 changed files with 544 additions and 240 deletions.
4 changes: 4 additions & 0 deletions addon_service/authorized_storage_account/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@ class JSONAPIMeta:
@property
def account_owner(self):
return self.external_account.owner # TODO: prefetch/select_related

@property
def owner_reference(self):
return self.external_account.owner.user_uri
3 changes: 1 addition & 2 deletions addon_service/authorized_storage_account/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@

class AccountOwnerField(ResourceRelatedField):
def to_internal_value(self, data):
user_reference, _ = UserReference.objects.get_or_create(user_uri=data["id"])
return user_reference
return UserReference.objects.get(user_uri=data["id"])


class ExternalStorageServiceField(ResourceRelatedField):
Expand Down
19 changes: 17 additions & 2 deletions addon_service/authorized_storage_account/views.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
from rest_framework_json_api.views import ModelViewSet
from addon_service.common.permissions import (
CanCreateASA,
SessionUserIsOwner,
)
from addon_service.common.viewsets import RetrieveWriteViewSet

from .models import AuthorizedStorageAccount
from .serializers import AuthorizedStorageAccountSerializer


class AuthorizedStorageAccountViewSet(ModelViewSet):
class AuthorizedStorageAccountViewSet(RetrieveWriteViewSet):
queryset = AuthorizedStorageAccount.objects.all()
serializer_class = AuthorizedStorageAccountSerializer

def get_permissions(self):
if not self.action:
return super().get_permissions()

if self.action in ["retrieve", "retrieve_related", "update", "destroy"]:
return [SessionUserIsOwner()]
elif self.action == "create":
return [CanCreateASA()]
else:
raise NotImplementedError("view action permission not implemented")
48 changes: 48 additions & 0 deletions addon_service/common/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from rest_framework import (
exceptions,
permissions,
)

from addon_service.models import UserReference
from app.authentication import authenticate_resource


class SessionUserIsOwner(permissions.BasePermission):
"""
Decorator to fetch 'user_reference_uri' from the session and pass it to the permission check function.
"""

def has_object_permission(self, request, view, obj):
session_user_uri = request.session.get("user_reference_uri")
if session_user_uri:
return session_user_uri == obj.owner_reference
return False


class SessionUserIsResourceReferenceOwner(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
resource_uri = authenticate_resource(request, obj.resource_uri, "read")
return obj.resource_uri == resource_uri


class CanCreateCSA(permissions.BasePermission):
def has_permission(self, request, view):
authorized_resource_id = request.data.get("authorized_resource", {}).get("id")
if authenticate_resource(request, authorized_resource_id, "admin"):
return True
return False


class CanCreateASA(permissions.BasePermission):
def has_permission(self, request, view):
session_user_uri = request.session.get("user_reference_uri")
request_user_uri = request.data.get("account_owner", {}).get("id")
if not session_user_uri == request_user_uri:
raise exceptions.NotAuthenticated(
"Account owner ID is missing in the request."
)
try:
UserReference.objects.get(user_uri=request_user_uri)
return True
except UserReference.DoesNotExist:
raise exceptions.NotAuthenticated("User does not exist.")
28 changes: 28 additions & 0 deletions addon_service/common/viewsets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from rest_framework import mixins as drf_mixins
from rest_framework.viewsets import GenericViewSet
from rest_framework_json_api.views import (
AutoPrefetchMixin,
PreloadIncludesMixin,
RelatedMixin,
)


class _DrfJsonApiHelpers(AutoPrefetchMixin, PreloadIncludesMixin, RelatedMixin):
pass


class RetrieveOnlyViewSet(
_DrfJsonApiHelpers, drf_mixins.RetrieveModelMixin, GenericViewSet
):
http_method_names = ["get", "head", "options"]


class RetrieveWriteViewSet(
_DrfJsonApiHelpers,
drf_mixins.CreateModelMixin,
drf_mixins.RetrieveModelMixin,
drf_mixins.UpdateModelMixin,
drf_mixins.DestroyModelMixin,
GenericViewSet,
):
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
4 changes: 4 additions & 0 deletions addon_service/configured_storage_addon/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,7 @@ class JSONAPIMeta:
@property
def account_owner(self):
return self.base_account.external_account.owner

@property
def owner_reference(self):
return self.base_account.external_account.owner.user_uri
19 changes: 17 additions & 2 deletions addon_service/configured_storage_addon/views.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
from rest_framework_json_api.views import ModelViewSet
from addon_service.common.permissions import (
CanCreateCSA,
SessionUserIsOwner,
)
from addon_service.common.viewsets import RetrieveWriteViewSet

from .models import ConfiguredStorageAddon
from .serializers import ConfiguredStorageAddonSerializer


class ConfiguredStorageAddonViewSet(ModelViewSet):
class ConfiguredStorageAddonViewSet(RetrieveWriteViewSet):
queryset = ConfiguredStorageAddon.objects.all()
serializer_class = ConfiguredStorageAddonSerializer

def get_permissions(self):
if not self.action:
return super().get_permissions()

if self.action in ["retrieve", "retrieve_related", "update", "destroy"]:
return [SessionUserIsOwner()]
elif self.action == "create":
return [CanCreateCSA()]
else:
raise NotImplementedError("view action permission not implemented")
5 changes: 2 additions & 3 deletions addon_service/external_storage_service/views.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
from rest_framework_json_api.views import ModelViewSet
from rest_framework_json_api.views import ReadOnlyModelViewSet

from .models import ExternalStorageService
from .serializers import ExternalStorageServiceSerializer


class ExternalStorageServiceViewSet(ModelViewSet):
class ExternalStorageServiceViewSet(ReadOnlyModelViewSet):
queryset = ExternalStorageService.objects.all()
serializer_class = ExternalStorageServiceSerializer
# TODO: permissions_classes
9 changes: 5 additions & 4 deletions addon_service/resource_reference/views.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from rest_framework_json_api.views import ReadOnlyModelViewSet
from addon_service.common.permissions import SessionUserIsResourceReferenceOwner
from addon_service.common.viewsets import RetrieveOnlyViewSet
from addon_service.serializers import ResourceReferenceSerializer

from .models import ResourceReference
from .serializers import ResourceReferenceSerializer


class ResourceReferenceViewSet(ReadOnlyModelViewSet):
class ResourceReferenceViewSet(RetrieveOnlyViewSet):
queryset = ResourceReference.objects.all()
serializer_class = ResourceReferenceSerializer
# TODO: permissions_classes
permission_classes = [SessionUserIsResourceReferenceOwner]
10 changes: 7 additions & 3 deletions addon_service/tests/_factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,24 @@
from factory.django import DjangoModelFactory

from addon_service import models as db
from app.settings import (
AUTH_URI_ID,
URI_ID,
)


class UserReferenceFactory(DjangoModelFactory):
class Meta:
model = db.UserReference

user_uri = factory.Sequence(lambda n: f"http://osf.example/user{n}")
user_uri = factory.Sequence(lambda n: f"{URI_ID}user{n}")


class ResourceReferenceFactory(DjangoModelFactory):
class Meta:
model = db.ResourceReference

resource_uri = factory.Sequence(lambda n: f"http://osf.example/thing{n}")
resource_uri = factory.Sequence(lambda n: f"{URI_ID}thing{n}")


class CredentialsIssuerFactory(DjangoModelFactory):
Expand Down Expand Up @@ -50,7 +54,7 @@ class Meta:

max_concurrent_downloads = factory.Faker("pyint")
max_upload_mb = factory.Faker("pyint")
auth_uri = factory.Sequence(lambda n: f"http://auth.example/{n}")
auth_uri = factory.Sequence(lambda n: f"{AUTH_URI_ID}{n}")
credentials_issuer = factory.SubFactory(CredentialsIssuerFactory)


Expand Down
59 changes: 52 additions & 7 deletions addon_service/tests/_helpers.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,57 @@
from rest_framework.test import (
APIRequestFactory,
force_authenticate,
)
from functools import wraps
from unittest.mock import patch

import httpx
from django.contrib.sessions.backends.db import SessionStore
from rest_framework.test import APIRequestFactory

def get_test_request(user=None, method="get", path=""):
from app import settings


def get_test_request(user=None, method="get", path="", cookies=None):
_factory_method = getattr(APIRequestFactory(), method)
_request = _factory_method(path) # note that path is optional for view tests
if user is not None:
force_authenticate(_request, user=user)
_request.session = SessionStore() # Add cookies if provided
if cookies:
for name, value in cookies.items():
_request.COOKIES[name] = value
return _request


def with_mocked_httpx_get(response_status=200):
"""Decorator to mock httpx.Client get requests with a customizable response status."""

def decorator(func):
@wraps(func)
def wrapper(self, *args, **kwargs):
with patch(
"httpx.Client.get",
new=lambda *args, **kwargs: mock_httpx_response(
*args, response_status=response_status
),
):
return func(self, *args, **kwargs)

return wrapper

return decorator


def mock_httpx_response(url, current_user, response_status, *args, **kwargs):
"""Generates mock httpx.Response based on the requested URL and response status."""
if response_status == 200:
if url == settings.USER_REFERENCE_LOOKUP_URL:
payload = {"data": {"links": {"iri": current_user.user_uri}}}
else:
guid = url.rstrip("/").split("/")[-1]
payload = {
"data": {
"attributes": {
"current_user_permissions": ["read", "write", "admin"]
},
"links": {"iri": f"{settings.URI_ID}{guid}"},
}
}
return httpx.Response(status_code=200, json=payload)
else: # Handles 403 and other statuses explicitly
return httpx.Response(status_code=response_status)
Loading

0 comments on commit 794c660

Please sign in to comment.