Skip to content
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
5 changes: 5 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

We follow the CalVer (https://calver.org/) versioning scheme: YY.MINOR.MICRO.

26.21.0 (2026-09-10)
====================

- Project Creation and Project Read-only

26.20.0 (2026-09-08)
====================

Expand Down
10 changes: 10 additions & 0 deletions addons/base/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,13 +387,23 @@ def _check_resource_permissions(resource, auth, action):
if required_permission == permissions.READ:
has_resource_permissions = resource.can_view_files(auth=auth)
else:
_ensure_resource_not_read_only(resource)
has_resource_permissions = resource.can_edit(auth=auth)

if not (has_resource_permissions or _check_hierarchical_permissions(resource, auth, action)):
raise HTTPError(http_status.HTTP_403_FORBIDDEN)
return True


def _ensure_resource_not_read_only(resource):
"""Block file/folder writes via Waterbutler while the resource is in read-only mode."""
if not isinstance(resource, Node):
return

if flag_is_active(request, features.PROJECT_READ_ONLY):
raise HTTPError(http_status.HTTP_403_FORBIDDEN, message='This project is read-only; file writes are disabled.')


def _get_permission_for_action(action):
if action in _READ_ACTIONS:
return permissions.READ
Expand Down
4 changes: 4 additions & 0 deletions admin/management/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,8 @@
name='remove_orcid_from_user_social'),
re_path(r'^migrate_funder_names_to_ror', views.MigrateFunderNamesToRor.as_view(),
name='migrate_funder_names_to_ror'),
re_path(r'^reject_pending_collection_submissions', views.RejectPendingCollectionSubmissions.as_view(),
name='reject_pending_collection_submissions'),
re_path(r'^reject_pending_node_requests', views.RejectPendingNodeRequests.as_view(),
name='reject_pending_node_requests'),
]
34 changes: 34 additions & 0 deletions admin/management/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
from osf.management.commands.sync_doi_metadata import sync_doi_metadata, sync_doi_empty_metadata_dataarchive_registrations
from osf.management.commands.populate_notification_types import populate_notification_types
from osf.management.commands.remove_orcid_from_user_social import remove_orcid_from_user_social
from osf.management.commands.reject_pending_collection_submissions import reject_pending_collection_submissions
from osf.management.commands.reject_pending_node_requests import reject_pending_node_requests
from scripts.find_spammy_content import manage_spammy_content
from django.urls import reverse
from django.shortcuts import redirect
Expand Down Expand Up @@ -228,3 +230,35 @@ def post(self, request):
for _line in _out_io.getvalue().split('\n'):
messages.info(request, _line)
return redirect(reverse('management:commands'))


class RejectPendingCollectionSubmissions(ManagementCommandPermissionView):

def post(self, request):
user_guid = request.user._id
comment = request.POST.get('comment', '').strip()
if not user_guid:
messages.error(request, 'A user GUID must be provided.')
return redirect(reverse('management:commands'))
reject_pending_collection_submissions.apply_async(kwargs={
'user_guid': user_guid,
'comment': comment,
})
messages.success(request, 'Pending collection submissions have been queued for rejection.')
return redirect(reverse('management:commands'))


class RejectPendingNodeRequests(ManagementCommandPermissionView):

def post(self, request):
user_guid = request.user._id
comment = request.POST.get('comment', '').strip()
if not user_guid:
messages.error(request, 'A user GUID must be provided.')
return redirect(reverse('management:commands'))
reject_pending_node_requests.apply_async(kwargs={
'user_guid': user_guid,
'comment': comment,
})
messages.success(request, 'Pending project access requests have been queued for rejection.')
return redirect(reverse('management:commands'))
31 changes: 31 additions & 0 deletions admin/templates/management/commands.html
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,37 @@ <h4><u>Update ROR funder names to be consistent and proper.</u></h4>
<input class="btn btn-success" type="submit" value="Run" />
</nav>
</form>
</section>
<section>
<h4><u>Reject pending collection submissions</u></h4>
<p>
Use this management command to reject all collection submissions currently in the pending state.
</p>
<form method="post"
action="{% url 'management:reject_pending_collection_submissions'%}">
{% csrf_token %}
Comment: <input type="text" name="comment" style="width: 550px;"
placeholder="Leave blank for default rejection message 'This collection submission has been rejected.'" />
<nav>
<input class="btn btn-success" type="submit" value="Run" />
</nav>
</form>
</section>
<section>
<h4><u>Reject pending project access requests</u></h4>
<p>
Use this management command to reject all project access requests (including institutional
curator requests) currently in the pending state.
</p>
<form method="post"
action="{% url 'management:reject_pending_node_requests'%}">
{% csrf_token %}
Comment: <input type="text" name="comment" style="width: 550px;"
placeholder="Leave blank for default rejection message 'This project is now read-only, so this access request has been automatically rejected.'" />
<nav>
<input class="btn btn-success" type="submit" value="Run" />
</nav>
</form>
</section>
</div>
</section>
Expand Down
12 changes: 11 additions & 1 deletion api/cedar_metadata_records/permissions.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import logging

from rest_framework import permissions
import waffle
from rest_framework import exceptions, permissions

from api.base.utils import get_user_auth

from osf import features
from osf.models import BaseFileNode, CedarMetadataRecord, Node, Registration

logger = logging.getLogger(__name__)
Expand All @@ -27,3 +29,11 @@ def has_object_permission(self, request, view, obj):
return permission_source.can_edit(auth)
return permission_source.is_public or permission_source.can_view(auth)
return permission_source.can_edit(auth)


class CedarMetadataRecordsNotAllowed(permissions.BasePermission):

def has_permission(self, request, view):
if request.method in ('POST', 'PUT', 'PATCH') and waffle.flag_is_active(request, features.PROJECT_READ_ONLY):
raise exceptions.MethodNotAllowed(request.method, detail='This action is no longer available. Contact support if you have any questions.')
return True
4 changes: 3 additions & 1 deletion api/cedar_metadata_records/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
)
from api.base.versioning import PrivateVersioning
from api.base.views import JSONAPIBaseView
from api.cedar_metadata_records.permissions import CedarMetadataRecordPermission
from api.cedar_metadata_records.permissions import CedarMetadataRecordPermission, CedarMetadataRecordsNotAllowed
from api.cedar_metadata_records.serializers import (
CedarMetadataRecordsCreateSerializer,
CedarMetadataRecordsDetailSerializer,
Expand All @@ -30,6 +30,7 @@ class CedarMetadataRecordCreate(JSONAPIBaseView, CreateAPIView):
permission_classes = (
drf_permissions.IsAuthenticatedOrReadOnly,
base_permissions.TokenHasScope,
CedarMetadataRecordsNotAllowed,
)
required_read_scopes = [CoreScopes.NULL]
required_write_scopes = [CoreScopes.CEDAR_METADATA_RECORD_WRITE]
Expand All @@ -50,6 +51,7 @@ class CedarMetadataRecordDetail(JSONAPIBaseView, RetrieveUpdateDestroyAPIView):
CedarMetadataRecordPermission,
drf_permissions.IsAuthenticatedOrReadOnly,
base_permissions.TokenHasScope,
CedarMetadataRecordsNotAllowed,
)
required_read_scopes = [CoreScopes.CEDAR_METADATA_RECORD_READ]
required_write_scopes = [CoreScopes.CEDAR_METADATA_RECORD_WRITE]
Expand Down
9 changes: 9 additions & 0 deletions api/collections/permissions.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import io

import waffle
from rest_framework import permissions
from rest_framework.exceptions import NotFound, MethodNotAllowed

from api.base.exceptions import Gone
from api.base.parsers import JSONSchemaParser
from api.base.utils import get_user_auth, assert_resource_type, get_object_or_error
from osf import features
from osf.models import AbstractNode, Preprint, Collection, CollectionSubmission, CollectionProvider
from osf.utils.permissions import WRITE, ADMIN

Expand Down Expand Up @@ -47,6 +49,13 @@ def has_object_permission(self, request, view, obj):
return request.method in permissions.SAFE_METHODS
return True

class CollectionSubmissionsNotAllowed(permissions.BasePermission):
def has_permission(self, request, view):
if request.method == 'POST' and waffle.flag_is_active(request, features.PROJECT_READ_ONLY):
raise MethodNotAllowed(request.method, detail='This action is no longer available. Contact support if you have any questions.')
return True


class CanSubmitToCollectionOrPublic(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
assert isinstance(obj, (CollectionSubmission, Collection, CollectionProvider)), f'obj must be a Collection or CollectionSubmission, got {obj}'
Expand Down
2 changes: 2 additions & 0 deletions api/collections/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
CollectionWriteOrPublicForRelationshipPointers,
CanSubmitToCollectionOrPublic,
CanUpdateDeleteCollectionSubmissionOrPublic,
CollectionSubmissionsNotAllowed,
ReadOnlyIfCollectedRegistration,
)
from api.collections.serializers import (
Expand Down Expand Up @@ -324,6 +325,7 @@ def perform_destroy(self, instance):
class CollectionSubmissionList(JSONAPIBaseView, generics.ListCreateAPIView, CollectionMixin, ListFilterMixin):
permission_classes = (
drf_permissions.IsAuthenticatedOrReadOnly,
CollectionSubmissionsNotAllowed,
CanSubmitToCollectionOrPublic,
base_permissions.TokenHasScope,
)
Expand Down
21 changes: 19 additions & 2 deletions api/custom_metadata/permissions.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from rest_framework import permissions
import waffle
from rest_framework import exceptions, permissions

from api.base.utils import get_user_auth
from osf.models import GuidMetadataRecord, BaseFileNode
from osf import features
from osf.models import GuidMetadataRecord, BaseFileNode, Node


class CustomMetadataPermission(permissions.BasePermission):
Expand All @@ -17,3 +19,18 @@ def has_object_permission(self, request, view, obj):
return delegate_obj.is_public or delegate_obj.can_view(auth)
else:
return delegate_obj.can_edit(auth)


class ItemMetadataEditingNotAllowed(permissions.BasePermission):

def has_object_permission(self, request, view, obj):
assert isinstance(obj, GuidMetadataRecord)
if request.method in permissions.SAFE_METHODS:
return True
delegate_obj = obj.guid.referent
if isinstance(delegate_obj, Node) and waffle.flag_is_active(request, features.PROJECT_READ_ONLY):
raise exceptions.MethodNotAllowed(
request.method,
detail='This action is no longer available. Contact support if you have any questions.',
)
return True
3 changes: 2 additions & 1 deletion api/custom_metadata/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from api.base.views import JSONAPIBaseView

import osf.models as osfdb
from .permissions import CustomMetadataPermission
from .permissions import CustomMetadataPermission, ItemMetadataEditingNotAllowed
from .serializers import CustomFileMetadataSerializer, CustomItemMetadataSerializer


Expand Down Expand Up @@ -43,6 +43,7 @@ class CustomItemMetadataDetail(JSONAPIBaseView, rest_framework.generics.Retrieve
CustomMetadataPermission,
rest_framework.permissions.IsAuthenticatedOrReadOnly,
base_permissions.TokenHasScope,
ItemMetadataEditingNotAllowed,
)

required_read_scopes = [CoreScopes.GUIDS_READ]
Expand Down
15 changes: 15 additions & 0 deletions api/draft_registrations/permissions.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import waffle
from rest_framework import permissions
from rest_framework.exceptions import MethodNotAllowed

from api.base.exceptions import Conflict
from api.base.utils import get_user_auth, assert_resource_type
from osf import features
from osf.models import (
DraftRegistration,
AbstractNode,
Expand Down Expand Up @@ -121,3 +124,15 @@ def has_permission(self, request, view):
raise Conflict(f"Registry {provider.name} is closed for new submissions. Please start a new registration with a different registry.")

return True


class ProjectBasedDraftRegistrationNotAllowed(permissions.BasePermission):
"""
Prevent creating draft registrations branched from a project (or other node) when the
PROJECT_READ_ONLY waffle flag is active. No-project draft registrations are still allowed.
"""

def has_permission(self, request, view):
if request.method == 'POST' and request.data.get('branched_from') and waffle.flag_is_active(request, features.PROJECT_READ_ONLY):
raise MethodNotAllowed(request.method, detail='This action is no longer available. Contact support if you have any questions.')
return True
2 changes: 2 additions & 0 deletions api/draft_registrations/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
DraftRegistrationPermission,
IsAdminContributor,
CanSubmitDraftRegistrationToProvider,
ProjectBasedDraftRegistrationNotAllowed,
)
from api.draft_registrations.serializers import (
DraftRegistrationSerializer,
Expand Down Expand Up @@ -54,6 +55,7 @@ class DraftRegistrationList(NodeDraftRegistrationsList):
drf_permissions.IsAuthenticatedOrReadOnly,
base_permissions.TokenHasScope,
DraftRegistrationPermission,
ProjectBasedDraftRegistrationNotAllowed,
CanSubmitDraftRegistrationToProvider,
)

Expand Down
13 changes: 12 additions & 1 deletion api/institutions/permissions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from rest_framework import permissions
import waffle
from rest_framework import exceptions, permissions

from api.base.utils import get_user_auth
from osf import features


class UserIsAffiliated(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
Expand All @@ -11,3 +14,11 @@ def has_object_permission(self, request, view, obj):
return True
else:
return user.is_affiliated_with_institution(obj['self'])


class InstitutionNodesDeleteNotAllowed(permissions.BasePermission):

def has_permission(self, request, view):
if request.method == 'DELETE' and waffle.flag_is_active(request, features.PROJECT_READ_ONLY):
raise exceptions.MethodNotAllowed(request.method, detail='This action is no longer available. Contact support if you have any questions.')
return True
3 changes: 2 additions & 1 deletion api/institutions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
InstitutionUserMetricsSerializer,
InstitutionSummaryMetricsSerializer,
)
from api.institutions.permissions import UserIsAffiliated
from api.institutions.permissions import UserIsAffiliated, InstitutionNodesDeleteNotAllowed


class InstitutionMixin:
Expand Down Expand Up @@ -348,6 +348,7 @@ class InstitutionNodesRelationship(JSONAPIBaseView, generics.RetrieveDestroyAPIV
drf_permissions.IsAuthenticatedOrReadOnly,
base_permissions.TokenHasScope,
UserIsAffiliated,
InstitutionNodesDeleteNotAllowed,
)
required_read_scopes = [CoreScopes.NODE_BASE_READ, CoreScopes.INSTITUTION_READ]
required_write_scopes = [CoreScopes.NODE_BASE_WRITE]
Expand Down
Loading
Loading