Skip to content

Commit

Permalink
Feature/institutional access (CenterForOpenScience#10884)
Browse files Browse the repository at this point in the history
Description:
Update role permissions to enable institutional administrators to make metadata revisions. This is critical to enabling increased control for institutional admins, supporting researchers in metadata curation and meeting data sharing policies.
Enable reviews workflow that allows institutional admins to add or remove affiliations from OSF content if appropriate. This is critical to enabling increased control for institutional admins as they monitor data sharing compliance.

Impact:
OSFI members are facing new requirements on data sharing and need workflows to support compliance. Renewals could decline as other tools enable these solutions and OSF doesn’t; new revenue potential with solutions that meet data sharing compliance needs.

Commitment:
We have 50+ OSFI members that haven’t had new features on OSF since joining, we need to listen to their needs and help make it easy and possible for them to enact open scholarship practices with their researchers. 

[ENG-4980]
  • Loading branch information
Johnetordoff authored Jan 21, 2025
1 parent 6a226ac commit 94be963
Show file tree
Hide file tree
Showing 39 changed files with 1,931 additions and 207 deletions.
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
"""
institution_id = data.pop('institution', None)
message_recipient_id = data.pop('message_recipient', None)
data = super().to_internal_value(data)

if institution_id:
data['institution'] = institution_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

0 comments on commit 94be963

Please sign in to comment.