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

[Reference][WIP] Feature/institutional access #10884

Draft
wants to merge 40 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
edb3118
Add new UserMessage feature for Institutional Access
Dec 3, 2024
cbcd763
Respond to CR by changing permissions logic and removing vestigial `u…
Dec 6, 2024
ec0d147
expand tests fix typos, align email templates and improve help text
Dec 6, 2024
cd45df2
clean-up serializer/permission code to validate better, 409s instead …
Dec 6, 2024
25d7857
add code to allow cc-ing fellow institutional admins and put their o…
Dec 10, 2024
f3ab16b
add new user message oauth scope and throttling classes
Dec 10, 2024
6b8c81c
add user message read/write permissions to full
Dec 10, 2024
9831d86
Merge pull request #10824 from Johnetordoff/institutional-access-user…
Johnetordoff Dec 11, 2024
cb64b51
Merge branch 'feature/institutional_access' of https://github.com/Cen…
Dec 11, 2024
dc2331e
revert typo
Dec 11, 2024
8de394a
change to bcc the sender instead of CC-ing them
Dec 11, 2024
7068791
Merge pull request #10841 from Johnetordoff/institutional-access-user…
Johnetordoff Dec 12, 2024
b7405af
add NodeRequest improvements for Institutional Access
Johnetordoff Dec 12, 2024
b2b73c5
Merge branch 'feature/institutional_access' of https://github.com/Cen…
Dec 12, 2024
7359973
fix up tests
Dec 12, 2024
4d16ab5
clean-up docstrings and typos
Dec 13, 2024
a024264
re-introduce in email template
Dec 13, 2024
e34b928
tweak permissions for 400 error code and add test cases
Dec 13, 2024
8b5c183
fix typo
Dec 16, 2024
efc5dd2
split off node request file again, added descriptive message when nod…
Dec 16, 2024
66df96c
Merge pull request #10826 from Johnetordoff/institutional-access-node…
Johnetordoff Dec 16, 2024
8c65e13
Merge branch 'develop' of https://github.com/CenterForOpenScience/osf…
Dec 16, 2024
be8f711
allow BCC and reply emails to work with sendgrid
Dec 17, 2024
c99d066
Merge pull request #10859 from Johnetordoff/fix-sendgrid-email
Johnetordoff Dec 17, 2024
79135e4
try fixing sendgrid agian to not send 400 and fix to make 202 not go …
Dec 17, 2024
4fef0d0
Merge pull request #10860 from Johnetordoff/fix-sendgrid-email
Johnetordoff Dec 17, 2024
60eb88f
add fix to email mocking, test for catagories
Jan 6, 2025
4611112
Merge pull request #10890 from Johnetordoff/fix-mail-mocks
Johnetordoff Jan 6, 2025
d34ceb8
Merge pull request #10897 from bodintsov/develop
brianjgeiger Jan 7, 2025
06e7198
Make the requested permissions show up as defaults
bodintsov Jan 6, 2025
d5138e0
Merge pull request #10891 from bodintsov/feature/changed_to_legacy_co…
brianjgeiger Jan 8, 2025
d9540f3
Changes for "can_request_access" feature (#10877)
bodintsov Jan 9, 2025
78597c8
[ENG-6668] Add Contributor Page Improvements for Institutional Access…
Johnetordoff Jan 13, 2025
83a6049
[IAC][Bugs] Fix migrations and serializer (#10915)
Johnetordoff Jan 13, 2025
8f79cfd
fixed double mail sending
bodintsov Jan 14, 2025
d007d63
reverted changes to test_node_request_list
bodintsov Jan 14, 2025
b268003
added testcases to check email send once
bodintsov Jan 15, 2025
bb243a1
removed useless code
bodintsov Jan 15, 2025
979b157
refactored test
bodintsov Jan 15, 2025
e5cc7fe
Merge pull request #10919 from bodintsov/feature/double_email_fix
Johnetordoff Jan 15, 2025
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
1 change: 1 addition & 0 deletions api/institutions/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class InstitutionSerializer(JSONAPISerializer):
ser.CharField(read_only=True),
permission='view_institutional_metrics',
)
institutional_request_access_enabled = ser.BooleanField(read_only=True)
links = LinksField({
'self': 'get_api_url',
'html': 'get_absolute_html_url',
Expand Down
3 changes: 2 additions & 1 deletion api/nodes/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@
RegistrationSerializer,
RegistrationCreateSerializer,
)
from api.requests.permissions import NodeRequestPermission
from api.requests.permissions import NodeRequestPermission, InstitutionalAdminRequestTypePermission
from api.requests.serializers import NodeRequestSerializer, NodeRequestCreateSerializer
from api.requests.views import NodeRequestMixin
from api.resources import annotations as resource_annotations
Expand Down Expand Up @@ -2239,6 +2239,7 @@ class NodeRequestListCreate(JSONAPIBaseView, generics.ListCreateAPIView, ListFil
drf_permissions.IsAuthenticatedOrReadOnly,
base_permissions.TokenHasScope,
NodeRequestPermission,
InstitutionalAdminRequestTypePermission,
)

required_read_scopes = [CoreScopes.NODE_REQUESTS_READ]
Expand Down
46 changes: 39 additions & 7 deletions api/requests/permissions.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
from rest_framework import permissions as drf_permissions
from rest_framework import exceptions, permissions as drf_permissions

from api.base.utils import get_user_auth
from osf.models.action import NodeRequestAction, PreprintRequestAction
from osf.models import (
Node,
NodeRequestAction,
PreprintRequestAction,
Preprint,
Institution,
)
from osf.models.mixins import NodeRequestableMixin, PreprintRequestableMixin
from osf.models.node import Node
from osf.models.preprint import Preprint
from osf.utils.workflows import DefaultTriggers
from osf.utils.workflows import DefaultTriggers, NodeRequestTypes
from osf.utils import permissions as osf_permissions


Expand All @@ -32,7 +36,7 @@ def has_object_permission(self, request, view, obj):
raise ValueError(f'Not a request-related model: {obj}')

if not node.access_requests_enabled:
return False
raise exceptions.PermissionDenied(f'{node._id} does not have Access Requests enabled')

is_requester = target is not None and target.creator == auth.user or trigger == DefaultTriggers.SUBMIT.value
is_node_admin = node.has_permission(auth.user, osf_permissions.ADMIN)
Expand All @@ -52,7 +56,35 @@ def has_object_permission(self, request, view, obj):
# Requesters may not be contributors
# Requesters may edit their comment or submit their request
return is_requester and auth.user not in node.contributors
return False


class InstitutionalAdminRequestTypePermission(drf_permissions.BasePermission):
"""
Permission class for handling object permissions related to Node requests and actions.
"""

def has_permission(self, request, view):
# Skip if not institutional_request request_type
request_type = request.data.get('request_type')
if request_type != NodeRequestTypes.INSTITUTIONAL_REQUEST.value:
return True

institution_id = request.data.get('institution')
if not institution_id:
raise exceptions.ValidationError({'institution': 'Institution is required.'})

try:
institution = Institution.objects.get(_id=institution_id)
except Institution.DoesNotExist:
raise exceptions.ValidationError({'institution': 'Institution is does not exist.'})

if not institution.institutional_request_access_enabled:
raise exceptions.PermissionDenied({'institution': 'Institutional request access is not enabled.'})

if get_user_auth(request).user.is_institutional_admin_at(institution):
return True
else:
raise exceptions.PermissionDenied({'institution': 'You do not have permission to perform this action for this institution.'})


class PreprintRequestPermission(drf_permissions.BasePermission):
Expand Down
133 changes: 116 additions & 17 deletions api/requests/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,24 @@

from api.base.exceptions import Conflict
from api.base.utils import absolute_reverse, get_user_auth
from api.base.serializers import JSONAPISerializer, LinksField, VersionedDateTimeField, RelationshipField
from osf.models import NodeRequest, PreprintRequest
from osf.utils.workflows import DefaultStates, RequestTypes
from api.base.serializers import (
JSONAPISerializer,
LinksField,
VersionedDateTimeField,
RelationshipField,
)
from osf.models import (
NodeRequest,
PreprintRequest,
Institution,
OSFUser,
)
from osf.utils.workflows import DefaultStates, RequestTypes, NodeRequestTypes
from osf.utils import permissions as osf_permissions
from website import settings
from website.mails import send_mail, NODE_REQUEST_INSTITUTIONAL_ACCESS_REQUEST

from rest_framework.exceptions import PermissionDenied, ValidationError


class RequestSerializer(JSONAPISerializer):
Expand Down Expand Up @@ -56,6 +70,8 @@ def create(self, validated_data):
raise NotImplementedError()

class NodeRequestSerializer(RequestSerializer):
request_type = ser.ChoiceField(read_only=True, required=False, choices=NodeRequestTypes.choices())

class Meta:
type_ = 'node-requests'

Expand All @@ -66,6 +82,13 @@ class Meta:
source='target__guids___id',
)

requested_permissions = ser.ChoiceField(
help_text='These are the default permission suggested when the Node admin sees users '
'listed in an `Request Access` list.',
choices=osf_permissions.API_CONTRIBUTOR_PERMISSIONS,
required=False,
)

def get_target_url(self, obj):
return absolute_reverse('nodes:node-detail', kwargs={'node_id': obj.target._id, 'version': self.context['request'].parser_context['kwargs']['version']})

Expand All @@ -89,40 +112,116 @@ def get_target_url(self, obj):
},
)

class NodeRequestCreateSerializer(NodeRequestSerializer):
request_type = ser.ChoiceField(required=True, choices=RequestTypes.choices())

def create(self, validated_data):
auth = get_user_auth(self.context['request'])
if not auth.user:
raise exceptions.PermissionDenied
class NodeRequestCreateSerializer(NodeRequestSerializer):
request_type = ser.ChoiceField(read_only=False, required=False, choices=NodeRequestTypes.choices())
message_recipient = RelationshipField(
help_text='An optional user who will receive an email explaining the nature of the request.',
required=False,
related_view='users:user-detail',
related_view_kwargs={'user_id': '<user._id>'},
)
bcc_sender = ser.BooleanField(
required=False,
default=False,
help_text='If true, BCCs the sender, giving them a copy of the email message they sent.',
)
reply_to = ser.BooleanField(
default=False,
help_text='Whether to set the sender\'s username as the `Reply-To` header in the email.',
)

def to_internal_value(self, data):
"""
Retrieves the id value from `RelationshipField` fields
"""
instituion_id = data.pop('institution', None)
message_recipient_id = data.pop('message_recipient', None)
data = super().to_internal_value(data)

if instituion_id:
data['institution'] = instituion_id

if message_recipient_id:
data['message_recipient'] = message_recipient_id
return data

def get_node_and_validate_non_contributor(self, auth):
"""
Ensures request user isn't already a contributor.
"""
try:
node = self.context['view'].get_target()
return self.context['view'].get_target()
except exceptions.PermissionDenied:
node = self.context['view'].get_target(check_object_permissions=False)
if auth.user in node.contributors:
raise exceptions.PermissionDenied('You cannot request access to a node you contribute to.')
raise

comment = validated_data.pop('comment', '')
request_type = validated_data.pop('request_type', None)
def create(self, validated_data) -> NodeRequest:
auth = get_user_auth(self.context['request'])
if not auth.user:
raise exceptions.PermissionDenied

if request_type != RequestTypes.ACCESS.value:
raise exceptions.ValidationError('You must specify a valid request_type.')
node = self.get_node_and_validate_non_contributor(auth)

request_type = validated_data.get('request_type')
match request_type:
case NodeRequestTypes.ACCESS.value:
return self._create_node_request(node, validated_data)
case NodeRequestTypes.INSTITUTIONAL_REQUEST.value:
return self.make_node_institutional_access_request(node, validated_data)
case _:
raise ValidationError('You must specify a valid request_type.')

def make_node_institutional_access_request(self, node, validated_data) -> NodeRequest:
sender = self.context['request'].user
node_request = self._create_node_request(node, validated_data)
node_request.is_institutional_request = True
node_request.save()
institution = Institution.objects.get(_id=validated_data['institution'])
recipient = OSFUser.load(validated_data.get('message_recipient'))

if recipient:
if not recipient.is_affiliated_with_institution(institution):
raise PermissionDenied(f"User {recipient._id} is not affiliated with the institution.")

if validated_data['comment']:
send_mail(
to_addr=recipient.username,
mail=NODE_REQUEST_INSTITUTIONAL_ACCESS_REQUEST,
user=recipient,
sender=sender,
bcc_addr=[sender.username] if validated_data['bcc_sender'] else None,
reply_to=sender.username if validated_data['reply_to'] else None,
recipient=recipient,
comment=validated_data['comment'],
institution=institution,
osf_url=settings.DOMAIN,
node=node_request.target,
)

return node_request

def _create_node_request(self, node, validated_data) -> NodeRequest:
creator = self.context['request'].user
request_type = validated_data['request_type']
comment = validated_data.get('comment', '')
requested_permissions = validated_data.get('requested_permissions')
try:
node_request = NodeRequest.objects.create(
target=node,
creator=auth.user,
creator=creator,
comment=comment,
machine_state=DefaultStates.INITIAL.value,
request_type=request_type,
requested_permissions=requested_permissions,
)
node_request.save()
except IntegrityError:
raise Conflict(f'Users may not have more than one {request_type} request per node.')
node_request.run_submit(auth.user)
raise Conflict(f"Users may not have more than one {request_type} request per node.")

node_request.run_submit(creator)
return node_request

class PreprintRequestSerializer(RequestSerializer):
Expand Down
39 changes: 37 additions & 2 deletions api/users/permissions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from osf.models import OSFUser
from rest_framework import permissions
from rest_framework import permissions, exceptions

from osf.models import OSFUser, Institution
from osf.models.user_message import MessageTypes


class ReadOnlyOrCurrentUser(permissions.BasePermission):
Expand Down Expand Up @@ -47,3 +49,36 @@ def has_permission(self, request, view):
def has_object_permission(self, request, view, obj):
assert isinstance(obj, OSFUser), f'obj must be a User, got {obj}'
return not obj.is_registered


class UserMessagePermissions(permissions.BasePermission):
"""
Custom permission to allow only institutional admins to create certain types of UserMessages.
"""
def has_permission(self, request, view) -> bool:
"""
Validate if the user has permission to perform the requested action.
Args:
request: The HTTP request.
view: The view handling the request.
Returns:
bool: True if the user has the required permission, False otherwise.
"""
user = request.user
if not user or user.is_anonymous:
return False

institution_id = request.data.get('institution')
if not institution_id:
raise exceptions.ValidationError({'institution': 'Institution is required.'})

try:
institution = Institution.objects.get(_id=institution_id)
except Institution.DoesNotExist:
raise exceptions.ValidationError({'institution': 'Specified institution does not exist.'})

message_type = request.data.get('message_type')
if message_type == MessageTypes.INSTITUTIONAL_REQUEST:
return user.is_institutional_admin_at(institution) and institution.institutional_request_access_enabled
else:
raise exceptions.ValidationError('Not valid message type.')
Loading
Loading