From ef94460595b359c9503096441d6f7eeb23c639ba Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Mon, 18 May 2026 17:21:43 -0400 Subject: [PATCH 01/62] [ENG-11081] Prevent POST /v2/nodes/ when project creation flag is active Adds PREVENT_PROJECT_CREATION waffle flag and a ProjectCreationNotAllowed permission class that returns 403 on POST to /v2/nodes/ when the flag is on. Flag off leaves existing behavior unchanged. --- api/nodes/permissions.py | 10 ++++++++++ api/nodes/views.py | 2 ++ osf/features.yaml | 5 +++++ 3 files changed, 17 insertions(+) diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index e7d2b773280..8c1af7f1a7d 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -1,7 +1,9 @@ +import waffle from rest_framework import permissions from rest_framework import exceptions from addons.base.models import BaseAddonSettings +from osf import features from osf.models import ( AbstractNode, Contributor, @@ -366,3 +368,11 @@ def __init__(self): max_version = '2.0' deprecated_message = 'This feature is deprecated as of version 2.1' super().__init__(min_version, max_version, deprecated_message) + + +class ProjectCreationNotAllowed(permissions.BasePermission): + + def has_permission(self, request, view): + if request.method == 'POST' and waffle.flag_is_active(request, features.PREVENT_PROJECT_CREATION): + raise exceptions.PermissionDenied('Project creation is currently disabled.') + return True diff --git a/api/nodes/views.py b/api/nodes/views.py index 931220a6f88..95fea29ae8c 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -100,6 +100,7 @@ ExcludeWithdrawals, NodeLinksShowIfVersion, ReadOnlyIfWithdrawn, + ProjectCreationNotAllowed, ) from osf.utils import permissions as osf_permissions from api.nodes.serializers import ( @@ -253,6 +254,7 @@ class NodeList(JSONAPIBaseView, bulk_views.BulkUpdateJSONAPIView, bulk_views.Bul permission_classes = ( drf_permissions.IsAuthenticatedOrReadOnly, base_permissions.TokenHasScope, + ProjectCreationNotAllowed, ) required_read_scopes = [CoreScopes.NODE_BASE_READ] diff --git a/osf/features.yaml b/osf/features.yaml index cce490a25a4..15f3361bbc9 100644 --- a/osf/features.yaml +++ b/osf/features.yaml @@ -64,6 +64,11 @@ flags: note: This flag controls whether users can create or interact with meetings via BE or FE. everyone: false + - flag_name: PREVENT_PROJECT_CREATION + name: prevent_project_creation + note: When active, disables all project/node creation on OSF (POST to node endpoints is blocked). + everyone: false + switches: - flag_name: DISABLE_ENGAGEMENT_EMAILS name: disable_engagement_emails From 21447cb2997168ccac771ffb87150510d7b6fe44 Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Wed, 20 May 2026 09:29:15 -0400 Subject: [PATCH 02/62] Address review: use MethodNotAllowed (405) instead of PermissionDenied (403) --- api/nodes/permissions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index 8c1af7f1a7d..2fb6f0dfb04 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -374,5 +374,5 @@ class ProjectCreationNotAllowed(permissions.BasePermission): def has_permission(self, request, view): if request.method == 'POST' and waffle.flag_is_active(request, features.PREVENT_PROJECT_CREATION): - raise exceptions.PermissionDenied('Project creation is currently disabled.') + raise exceptions.MethodNotAllowed(request.method, detail='Project creation is currently disabled.') return True From c538c73722d2291094124039d86d189ea4dd0491 Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Wed, 20 May 2026 10:26:56 -0400 Subject: [PATCH 03/62] [ENG-11088] Prevent POST /v2/nodes//forks/ when project creation flag is active --- api/nodes/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/nodes/views.py b/api/nodes/views.py index 95fea29ae8c..169ec9121f3 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -1062,6 +1062,7 @@ class NodeForksList(JSONAPIBaseView, generics.ListCreateAPIView, NodeMixin, Node drf_permissions.IsAuthenticatedOrReadOnly, base_permissions.TokenHasScope, ExcludeWithdrawals, + ProjectCreationNotAllowed, ) required_read_scopes = [CoreScopes.NODE_FORKS_READ, CoreScopes.NODE_BASE_READ] From 0376c984052745d21a6570d58cbddb7e37545e84 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Thu, 21 May 2026 17:21:09 +0300 Subject: [PATCH 04/62] Prevent DraftNodes from getting converted into Nodes in osf/models/draft_node.py based on the waffle flag --- osf/features.yaml | 5 +++++ osf/models/draft_node.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/osf/features.yaml b/osf/features.yaml index 15f3361bbc9..492d311433a 100644 --- a/osf/features.yaml +++ b/osf/features.yaml @@ -69,6 +69,11 @@ flags: note: When active, disables all project/node creation on OSF (POST to node endpoints is blocked). everyone: false + - flag_name: PREVENT_DRAFT_NODE_BE_CHANGED_TO_NODES + name: prevent_draft_node_be_changed_to_nodes + note: When active, disables draft nodes to be changed to nodes. + everyone: false + switches: - flag_name: DISABLE_ENGAGEMENT_EMAILS name: disable_engagement_emails diff --git a/osf/models/draft_node.py b/osf/models/draft_node.py index a1b49a95cdd..13ab155a9cb 100644 --- a/osf/models/draft_node.py +++ b/osf/models/draft_node.py @@ -4,7 +4,10 @@ from django.utils import timezone from .node import AbstractNode, Node, NodeLog +from osf import features from osf.exceptions import NodeStateError +from osf.utils.requests import get_current_request +import waffle logger = logging.getLogger(__name__) @@ -22,6 +25,18 @@ class DraftNode(AbstractNode): DraftNodes are hidden. They are not accessible in search, and they are not public. """ + def is_draft_node_prevented_to_be_changed_node(self): + request = get_current_request() + if request: + return waffle.flag_is_active(request, features.PREVENT_DRAFT_NODE_BE_CHANGED_TO_NODES) + try: + flag = waffle.get_waffle_flag_model().objects.get( + name=features.PREVENT_DRAFT_NODE_BE_CHANGED_TO_NODES + ) + return flag.everyone + except waffle.get_waffle_flag_model().DoesNotExist: + return False + def set_privacy(self, permissions, *args, **kwargs): raise NodeStateError('You may not set privacy for a DraftNode.') @@ -42,6 +57,9 @@ def can_edit(self, auth=None, user=None): return self.registered_draft.first().can_edit(auth, user) def convert_draft_node_to_node(self, auth): + if self.is_draft_node_prevented_to_be_changed_node(): + raise NodeStateError('DraftNodes cannot be converted to Nodes.') + self.recast('osf.node') self.save() From 2e8c2082ab36cb54312b0445bb0f0fb7130e7b56 Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Thu, 21 May 2026 10:29:44 -0400 Subject: [PATCH 05/62] [ENG-11089] Prevent POST /v2/nodes//children/ when project creation flag is active --- api/nodes/views.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/api/nodes/views.py b/api/nodes/views.py index 169ec9121f3..fb8ab40aa7c 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -789,6 +789,14 @@ def perform_create(self, serializer): class NodeChildrenList(BaseChildrenList, bulk_views.ListBulkCreateJSONAPIView, NodeMixin): """See [documentation for this endpoint](https://developer.osf.io/#operation/nodes_children_list). """ + permission_classes = ( + ContributorOrPublic, + drf_permissions.IsAuthenticatedOrReadOnly, + ReadOnlyIfRegistration, + base_permissions.TokenHasScope, + ExcludeWithdrawals, + ProjectCreationNotAllowed, + ) required_read_scopes = [CoreScopes.NODE_CHILDREN_READ] required_write_scopes = [CoreScopes.NODE_CHILDREN_WRITE] From 683b16b9245a387df667380003e9758544d44384 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 22 May 2026 14:49:07 +0300 Subject: [PATCH 06/62] disable node creation from draft node is PREVENT_PROJECT_CREATION flag is true --- osf/features.yaml | 5 ----- osf/models/archive.py | 2 +- osf/models/draft_node.py | 30 ++++++++++-------------------- osf/models/node.py | 21 ++++++++++++++++++++- 4 files changed, 31 insertions(+), 27 deletions(-) diff --git a/osf/features.yaml b/osf/features.yaml index 492d311433a..15f3361bbc9 100644 --- a/osf/features.yaml +++ b/osf/features.yaml @@ -69,11 +69,6 @@ flags: note: When active, disables all project/node creation on OSF (POST to node endpoints is blocked). everyone: false - - flag_name: PREVENT_DRAFT_NODE_BE_CHANGED_TO_NODES - name: prevent_draft_node_be_changed_to_nodes - note: When active, disables draft nodes to be changed to nodes. - everyone: false - switches: - flag_name: DISABLE_ENGAGEMENT_EMAILS name: disable_engagement_emails diff --git a/osf/models/archive.py b/osf/models/archive.py index 9e622764ca7..707ed296d0a 100644 --- a/osf/models/archive.py +++ b/osf/models/archive.py @@ -55,7 +55,7 @@ class ArchiveJob(ObjectIDMixin, BaseModel): dst_node = models.ForeignKey('Registration', related_name='archive_jobs', verbose_name='destination node', null=True, blank=True, on_delete=models.CASCADE) - src_node = models.ForeignKey('Node', verbose_name='source node', null=True, + src_node = models.ForeignKey('AbstractNode', verbose_name='source node', null=True, blank=True, on_delete=models.CASCADE) initiator = models.ForeignKey('OSFUser', null=True, on_delete=models.CASCADE) diff --git a/osf/models/draft_node.py b/osf/models/draft_node.py index 13ab155a9cb..dcaf1a6678f 100644 --- a/osf/models/draft_node.py +++ b/osf/models/draft_node.py @@ -4,10 +4,7 @@ from django.utils import timezone from .node import AbstractNode, Node, NodeLog -from osf import features from osf.exceptions import NodeStateError -from osf.utils.requests import get_current_request -import waffle logger = logging.getLogger(__name__) @@ -25,22 +22,12 @@ class DraftNode(AbstractNode): DraftNodes are hidden. They are not accessible in search, and they are not public. """ - def is_draft_node_prevented_to_be_changed_node(self): - request = get_current_request() - if request: - return waffle.flag_is_active(request, features.PREVENT_DRAFT_NODE_BE_CHANGED_TO_NODES) - try: - flag = waffle.get_waffle_flag_model().objects.get( - name=features.PREVENT_DRAFT_NODE_BE_CHANGED_TO_NODES - ) - return flag.everyone - except waffle.get_waffle_flag_model().DoesNotExist: - return False - def set_privacy(self, permissions, *args, **kwargs): raise NodeStateError('You may not set privacy for a DraftNode.') def clone(self): + if self.is_draft_node_prevented_to_be_changed_to_node(): + return super().clone() raise NodeStateError('A DraftNode may not be forked, used as a template, or registered.') # Overrides AbstractNode.update_search @@ -57,9 +44,6 @@ def can_edit(self, auth=None, user=None): return self.registered_draft.first().can_edit(auth, user) def convert_draft_node_to_node(self, auth): - if self.is_draft_node_prevented_to_be_changed_node(): - raise NodeStateError('DraftNodes cannot be converted to Nodes.') - self.recast('osf.node') self.save() @@ -86,9 +70,15 @@ def register_node(self, schema, auth, draft_registration, parent=None, child_ids :param parent Node: parent registration of registration to be created :param provider RegistrationProvider: provider to submit the registration to """ - self.convert_draft_node_to_node(auth) + is_draft_node_prevented_to_be_changed_to_node = self.is_draft_node_prevented_to_be_changed_to_node() + if not is_draft_node_prevented_to_be_changed_to_node: + self.convert_draft_node_to_node(auth) # Copies editable fields from the DraftRegistration back to the Node self.copy_editable_fields(draft_registration, save=True) # Calls super on Node, since self is no longer a DraftNode - return super(Node, self).register_node(schema, auth, draft_registration, parent=parent, child_ids=child_ids, provider=provider, manual_guid=manual_guid) + if not is_draft_node_prevented_to_be_changed_to_node: + return super(Node, self).register_node(schema, auth, draft_registration, parent=parent, child_ids=child_ids, provider=provider, manual_guid=manual_guid) + else: + return AbstractNode.register_node(self, schema, auth, draft_registration, parent=parent, child_ids=child_ids, + provider=provider, manual_guid=manual_guid) diff --git a/osf/models/node.py b/osf/models/node.py index d08a9d86f0a..6c93ff84a15 100644 --- a/osf/models/node.py +++ b/osf/models/node.py @@ -1388,6 +1388,21 @@ def copy_contributors_from(self, resource): self.add_permission(contrib.user, permission, save=True) Contributor.objects.bulk_create(contribs) + def is_draft_node_prevented_to_be_changed_to_node(self): + from osf import features + from osf.utils.requests import get_current_request + import waffle + request = get_current_request() + if request: + return waffle.flag_is_active(request, features.PREVENT_PROJECT_CREATION) + try: + flag = waffle.get_waffle_flag_model().objects.get( + name=features.PREVENT_PROJECT_CREATION + ) + return flag.everyone + except waffle.get_waffle_flag_model().DoesNotExist: + return False + def register_node(self, schema, auth, draft_registration, parent=None, child_ids=None, provider=None, manual_guid=None): """Make a frozen copy of a node. @@ -1499,7 +1514,11 @@ def register_node(self, schema, auth, draft_registration, parent=None, child_ids registered.root = None # Recompute root on save - if not self.logs.filter(action=NodeLog.PROJECT_CREATED_FROM_DRAFT_REG).exists(): + from osf.models import DraftNode + if isinstance(self, DraftNode) and self.is_draft_node_prevented_to_be_changed_to_node(): + # New approach: DraftNode stays as DraftNode + registered.branched_from_node = False + elif not self.logs.filter(action=NodeLog.PROJECT_CREATED_FROM_DRAFT_REG).exists(): registered.branched_from_node = True elif self.registrations.count() == 1: # First registration on a converted DratNode is *the* "No-Project registration" From 8a8061bb3245bb4264be1e8277fd912e318632e5 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 22 May 2026 14:54:26 +0300 Subject: [PATCH 07/62] add migration --- .../0040_alter_archivejob_src_node.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 osf/migrations/0040_alter_archivejob_src_node.py diff --git a/osf/migrations/0040_alter_archivejob_src_node.py b/osf/migrations/0040_alter_archivejob_src_node.py new file mode 100644 index 00000000000..213e55af1a1 --- /dev/null +++ b/osf/migrations/0040_alter_archivejob_src_node.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.26 on 2026-05-22 11:54 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0039_merge_20260427_1359'), + ] + + operations = [ + migrations.AlterField( + model_name='archivejob', + name='src_node', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='osf.abstractnode', verbose_name='source node'), + ), + ] From 7b4931bed3d3d13663ad38c48509db31a603de0a Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Fri, 22 May 2026 13:31:15 -0400 Subject: [PATCH 08/62] refactor(nodes): use tuple concatenation for NodeChildrenList permission_classes Replaces full redeclaration with BaseChildrenList.permission_classes + (ProjectCreationNotAllowed,) so NodeChildrenList stays in sync automatically if the base class permissions change. --- api/nodes/views.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/api/nodes/views.py b/api/nodes/views.py index fb8ab40aa7c..4bc5fa8a980 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -789,14 +789,7 @@ def perform_create(self, serializer): class NodeChildrenList(BaseChildrenList, bulk_views.ListBulkCreateJSONAPIView, NodeMixin): """See [documentation for this endpoint](https://developer.osf.io/#operation/nodes_children_list). """ - permission_classes = ( - ContributorOrPublic, - drf_permissions.IsAuthenticatedOrReadOnly, - ReadOnlyIfRegistration, - base_permissions.TokenHasScope, - ExcludeWithdrawals, - ProjectCreationNotAllowed, - ) + permission_classes = BaseChildrenList.permission_classes + (ProjectCreationNotAllowed,) required_read_scopes = [CoreScopes.NODE_CHILDREN_READ] required_write_scopes = [CoreScopes.NODE_CHILDREN_WRITE] From f582e18aab9cbe4be00f03fb28919bdd039d4366 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Mon, 25 May 2026 14:33:56 +0300 Subject: [PATCH 09/62] add 'draft node stays draft node when prevent project creation flag active' unittest --- osf_tests/test_draft_node.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/osf_tests/test_draft_node.py b/osf_tests/test_draft_node.py index 2ed26e4e7d3..b1a0299cbd1 100644 --- a/osf_tests/test_draft_node.py +++ b/osf_tests/test_draft_node.py @@ -1,7 +1,9 @@ import pytest from framework.auth.core import Auth +from waffle.testutils import override_flag from api_tests.utils import disconnected_from_listeners +from osf import features from osf.models import ( DraftNode, Registration, @@ -202,3 +204,21 @@ def test_draft_registration_fields_are_not_copied_back_to_original_node(self, us def test_cannot_make_draft_node_public(self, draft_node): with pytest.raises(NodeStateError): draft_node.set_privacy('public', save=True) + + def test_draft_node_stays_draft_node_when_prevent_project_creation_flag_active(self, user): + with capture_notifications(): + draft_reg = DraftRegistration.create_from_node( + user=user, + schema=get_default_metaschema(), + ) + draft_node = draft_reg.branched_from + with override_flag(features.PREVENT_PROJECT_CREATION, active=True): + with disconnected_from_listeners(after_create_registration): + registration = draft_node.register_node( + get_default_metaschema(), Auth(user), draft_reg + ) + + draft_node.reload() + assert draft_node.type == 'osf.draftnode' + assert isinstance(registration, Registration) + assert registration.branched_from_node is False From f29c593ace2a28935aeff706bbbbf41d60528eb5 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Mon, 25 May 2026 20:30:53 +0300 Subject: [PATCH 10/62] move imports to file top --- osf/models/node.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/osf/models/node.py b/osf/models/node.py index 6c93ff84a15..4939eedd997 100644 --- a/osf/models/node.py +++ b/osf/models/node.py @@ -68,6 +68,8 @@ from website.project.model import NodeUpdateError from website.identifiers.tasks import update_doi_metadata_on_change from website.identifiers.clients import DataCiteClient +from osf import features +from osf.models import DraftNode from osf.utils.permissions import ( ADMIN, ADMIN_NODE, @@ -78,6 +80,7 @@ READ_NODE, WRITE ) +import waffle from website.util.metrics import OsfSourceTags, CampaignSourceTags from website.util import api_url_for, api_v2_url, web_url_for from .base import BaseModel, GuidMixin, GuidMixinQuerySet, check_manually_assigned_guid @@ -1389,9 +1392,6 @@ def copy_contributors_from(self, resource): Contributor.objects.bulk_create(contribs) def is_draft_node_prevented_to_be_changed_to_node(self): - from osf import features - from osf.utils.requests import get_current_request - import waffle request = get_current_request() if request: return waffle.flag_is_active(request, features.PREVENT_PROJECT_CREATION) @@ -1514,7 +1514,6 @@ def register_node(self, schema, auth, draft_registration, parent=None, child_ids registered.root = None # Recompute root on save - from osf.models import DraftNode if isinstance(self, DraftNode) and self.is_draft_node_prevented_to_be_changed_to_node(): # New approach: DraftNode stays as DraftNode registered.branched_from_node = False From cb0d6e82bcebc566dcde82c69984e630cfa173d5 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Mon, 25 May 2026 21:15:51 +0300 Subject: [PATCH 11/62] code updates --- osf/models/node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osf/models/node.py b/osf/models/node.py index 4939eedd997..daf43b0e444 100644 --- a/osf/models/node.py +++ b/osf/models/node.py @@ -69,7 +69,6 @@ from website.identifiers.tasks import update_doi_metadata_on_change from website.identifiers.clients import DataCiteClient from osf import features -from osf.models import DraftNode from osf.utils.permissions import ( ADMIN, ADMIN_NODE, @@ -1514,6 +1513,7 @@ def register_node(self, schema, auth, draft_registration, parent=None, child_ids registered.root = None # Recompute root on save + from osf.models import DraftNode if isinstance(self, DraftNode) and self.is_draft_node_prevented_to_be_changed_to_node(): # New approach: DraftNode stays as DraftNode registered.branched_from_node = False From 3ff51e23eca164f3e2e3db0ea348fd7a416a23b4 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Mon, 15 Jun 2026 16:00:54 +0300 Subject: [PATCH 12/62] Make new permission class for when project editing is not allowed --- api/nodes/permissions.py | 8 ++++++++ api/nodes/views.py | 2 ++ osf/features.yaml | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index 2fb6f0dfb04..ac66a84f3d4 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -376,3 +376,11 @@ def has_permission(self, request, view): if request.method == 'POST' and waffle.flag_is_active(request, features.PREVENT_PROJECT_CREATION): raise exceptions.MethodNotAllowed(request.method, detail='Project creation is currently disabled.') return True + + +class ProjectEditingNotAllowed(permissions.BasePermission): + + def has_permission(self, request, view): + if request.method in ['PUT', 'PATCH'] and waffle.flag_is_active(request, features.PREVENT_PROJECT_EDITING): + raise exceptions.MethodNotAllowed(request.method, detail='Project editing is currently disabled.') + return True diff --git a/api/nodes/views.py b/api/nodes/views.py index 4bc5fa8a980..70fc22090a9 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -101,6 +101,7 @@ NodeLinksShowIfVersion, ReadOnlyIfWithdrawn, ProjectCreationNotAllowed, + ProjectEditingNotAllowed, ) from osf.utils import permissions as osf_permissions from api.nodes.serializers import ( @@ -255,6 +256,7 @@ class NodeList(JSONAPIBaseView, bulk_views.BulkUpdateJSONAPIView, bulk_views.Bul drf_permissions.IsAuthenticatedOrReadOnly, base_permissions.TokenHasScope, ProjectCreationNotAllowed, + ProjectEditingNotAllowed, ) required_read_scopes = [CoreScopes.NODE_BASE_READ] diff --git a/osf/features.yaml b/osf/features.yaml index 15f3361bbc9..90e4c6543a7 100644 --- a/osf/features.yaml +++ b/osf/features.yaml @@ -69,6 +69,11 @@ flags: note: When active, disables all project/node creation on OSF (POST to node endpoints is blocked). everyone: false + - flag_name: PREVENT_PROJECT_EDITING + name: prevent_project_creation + note: When active, disables all project/node editing on OSF (PATCH and PUT to node endpoints is blocked). + everyone: false + switches: - flag_name: DISABLE_ENGAGEMENT_EMAILS name: disable_engagement_emails From f128961efe6e6733b55d45d07c1f2679db636dc4 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Mon, 15 Jun 2026 19:04:48 +0300 Subject: [PATCH 13/62] resolve CR --- api/nodes/permissions.py | 2 +- osf/features.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index ac66a84f3d4..0e66cfba4b0 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -381,6 +381,6 @@ def has_permission(self, request, view): class ProjectEditingNotAllowed(permissions.BasePermission): def has_permission(self, request, view): - if request.method in ['PUT', 'PATCH'] and waffle.flag_is_active(request, features.PREVENT_PROJECT_EDITING): + if request.method in ['PUT', 'PATCH'] and waffle.flag_is_active(request, features.PROJECT_READ_ONLY): raise exceptions.MethodNotAllowed(request.method, detail='Project editing is currently disabled.') return True diff --git a/osf/features.yaml b/osf/features.yaml index 90e4c6543a7..e53d4ebcf26 100644 --- a/osf/features.yaml +++ b/osf/features.yaml @@ -69,8 +69,8 @@ flags: note: When active, disables all project/node creation on OSF (POST to node endpoints is blocked). everyone: false - - flag_name: PREVENT_PROJECT_EDITING - name: prevent_project_creation + - flag_name: PROJECT_READ_ONLY + name: project_read_only note: When active, disables all project/node editing on OSF (PATCH and PUT to node endpoints is blocked). everyone: false From edb75b3f498f54df93ee364be94571cb40738ecc Mon Sep 17 00:00:00 2001 From: mkovalua Date: Mon, 15 Jun 2026 19:31:54 +0300 Subject: [PATCH 14/62] resolve CR --- api/nodes/permissions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index 0e66cfba4b0..38c81531b38 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -382,5 +382,5 @@ class ProjectEditingNotAllowed(permissions.BasePermission): def has_permission(self, request, view): if request.method in ['PUT', 'PATCH'] and waffle.flag_is_active(request, features.PROJECT_READ_ONLY): - raise exceptions.MethodNotAllowed(request.method, detail='Project editing is currently disabled.') + raise exceptions.MethodNotAllowed(request.method, detail='This action is no longer available. Contact support if you have any questions.') return True From 4134f73c3503b75888fba03443dc9e1ebe44134f Mon Sep 17 00:00:00 2001 From: mkovalua Date: Tue, 16 Jun 2026 18:13:42 +0300 Subject: [PATCH 15/62] Prevent project wiki updates via API --- api/nodes/views.py | 2 ++ api/wikis/permissions.py | 11 ++++++++++- api/wikis/views.py | 3 +++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/api/nodes/views.py b/api/nodes/views.py index 70fc22090a9..bb84cb17efd 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -103,6 +103,7 @@ ProjectCreationNotAllowed, ProjectEditingNotAllowed, ) +from api.wikis.permissions import WikisEditingNotAllowed from osf.utils import permissions as osf_permissions from api.nodes.serializers import ( NodeSerializer, @@ -1869,6 +1870,7 @@ class NodeWikiList(JSONAPIBaseView, generics.ListCreateAPIView, NodeMixin, ListF base_permissions.TokenHasScope, ContributorOrPublic, ExcludeWithdrawals, + WikisEditingNotAllowed, ) required_read_scopes = [CoreScopes.WIKI_BASE_READ] diff --git a/api/wikis/permissions.py b/api/wikis/permissions.py index 1b7e8743d20..93eef163588 100644 --- a/api/wikis/permissions.py +++ b/api/wikis/permissions.py @@ -1,7 +1,9 @@ -from rest_framework import permissions +from rest_framework import exceptions, permissions from api.base.utils import get_user_auth from addons.wiki.models import WikiPage, WikiVersion +from osf import features +import waffle class ContributorOrPublic(permissions.BasePermission): @@ -44,3 +46,10 @@ def has_object_permission(self, request, view, obj): if node and node.is_retracted: return False return True + +class WikisEditingNotAllowed(permissions.BasePermission): + + def has_permission(self, request, view): + if request.method not in permissions.SAFE_METHODS 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 diff --git a/api/wikis/views.py b/api/wikis/views.py index e9536ff3fdf..27b6ce91f75 100644 --- a/api/wikis/views.py +++ b/api/wikis/views.py @@ -16,6 +16,7 @@ ExcludeWithdrawals, ContributorOrPublicWikiVersion, ExcludeWithdrawalsWikiVersion, + WikisEditingNotAllowed, ) from api.wikis.serializers import ( WikiSerializer, @@ -128,6 +129,7 @@ class WikiDetail(JSONAPIBaseView, generics.RetrieveUpdateDestroyAPIView, WikiMix base_permissions.TokenHasScope, ContributorOrPublic, ExcludeWithdrawals, + WikisEditingNotAllowed, ) required_read_scopes = [CoreScopes.WIKI_BASE_READ] @@ -192,6 +194,7 @@ class WikiVersions(JSONAPIBaseView, generics.ListCreateAPIView, WikiMixin): base_permissions.TokenHasScope, ContributorOrPublic, ExcludeWithdrawals, + WikisEditingNotAllowed, ) view_category = 'wikis' view_name = 'wiki-versions' From 49c94c3e489805779b83a1142ee9e5a14e61bab6 Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Wed, 17 Jun 2026 16:28:58 -0400 Subject: [PATCH 16/62] Prevent collection submissions via API when project_read_only flag is active Adds CollectionSubmissionsNotAllowed permission class that returns 405 on POST requests to collection submissions when the PROJECT_READ_ONLY waffle flag is active. The check runs before CanSubmitToCollectionOrPublic so the flag takes precedence over the provider's allow_submissions setting. --- api/collections/permissions.py | 9 +++++++++ api/collections/views.py | 2 ++ 2 files changed, 11 insertions(+) diff --git a/api/collections/permissions.py b/api/collections/permissions.py index df2b791231e..129c10438a0 100644 --- a/api/collections/permissions.py +++ b/api/collections/permissions.py @@ -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 @@ -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}' diff --git a/api/collections/views.py b/api/collections/views.py index 907a6fee571..b133d6c6a3e 100644 --- a/api/collections/views.py +++ b/api/collections/views.py @@ -21,6 +21,7 @@ CollectionWriteOrPublicForRelationshipPointers, CanSubmitToCollectionOrPublic, CanUpdateDeleteCollectionSubmissionOrPublic, + CollectionSubmissionsNotAllowed, ReadOnlyIfCollectedRegistration, ) from api.collections.serializers import ( @@ -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, ) From efb2248ad451ece6a609a0bfee353fbaaadeb702 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 19 Jun 2026 16:42:26 +0300 Subject: [PATCH 17/62] management command to reject all outstanding pending collection_submissions implementation --- admin/management/urls.py | 4 +- admin/management/views.py | 21 ++++ admin/templates/management/commands.html | 15 +++ .../reject_pending_collection_submissions.py | 97 +++++++++++++++ ...t_reject_pending_collection_submissions.py | 115 ++++++++++++++++++ 5 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 osf/management/commands/reject_pending_collection_submissions.py create mode 100644 osf_tests/management_commands/test_reject_pending_collection_submissions.py diff --git a/admin/management/urls.py b/admin/management/urls.py index c046b3bed18..870264be41f 100644 --- a/admin/management/urls.py +++ b/admin/management/urls.py @@ -21,5 +21,7 @@ re_path(r'^sync_notification_templates', views.SyncNotificationTemplates.as_view(), name='sync_notification_templates'), re_path(r'^remove_orcid_from_user_social', views.RemoveOrcidFromUserSocial.as_view(), - name='remove_orcid_from_user_social') + name='remove_orcid_from_user_social'), + re_path(r'^reject_pending_collection_submissions', views.RejectPendingCollectionSubmissions.as_view(), + name='reject_pending_collection_submissions'), ] diff --git a/admin/management/views.py b/admin/management/views.py index f2052822f37..dbf511ea107 100644 --- a/admin/management/views.py +++ b/admin/management/views.py @@ -13,6 +13,7 @@ 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 scripts.find_spammy_content import manage_spammy_content from django.urls import reverse from django.shortcuts import redirect @@ -190,3 +191,23 @@ def post(self, request): remove_orcid_from_user_social() messages.success(request, 'Orcid from user social have been successfully removed.') 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')) + try: + reject_pending_collection_submissions( + user_guid=user_guid, + comment=comment, + ) + except RuntimeError as e: + messages.error(request, str(e)) + return redirect(reverse('management:commands')) + messages.success(request, 'Pending collection submissions have been rejected.') + return redirect(reverse('management:commands')) diff --git a/admin/templates/management/commands.html b/admin/templates/management/commands.html index edf242abfdd..b7d0b5cf5c5 100644 --- a/admin/templates/management/commands.html +++ b/admin/templates/management/commands.html @@ -178,6 +178,21 @@

Remove existing orcid info from user social

+
+

Reject pending collection submissions

+

+ Use this management command to reject all collection submissions currently in the pending state. +

+
+ {% csrf_token %} + Comment: + +
+
{% endblock %} diff --git a/osf/management/commands/reject_pending_collection_submissions.py b/osf/management/commands/reject_pending_collection_submissions.py new file mode 100644 index 00000000000..67c691070bc --- /dev/null +++ b/osf/management/commands/reject_pending_collection_submissions.py @@ -0,0 +1,97 @@ +import logging + +from django.core.management.base import BaseCommand +from django.db import transaction +from framework.celery_tasks import app as celery_app +from transitions import MachineError + +from osf.models import CollectionSubmission, OSFUser +from osf.utils.workflows import CollectionSubmissionStates + +logger = logging.getLogger(__name__) + +DEFAULT_COMMENT = 'This collection submission has been rejected.' + + +@celery_app.task(name='osf.management.commands.reject_pending_collection_submissions') +@transaction.atomic +def reject_pending_collection_submissions(user_guid, comment, dry_run=False): + comment = comment or DEFAULT_COMMENT + user = OSFUser.load(user_guid) + if not user: + raise RuntimeError(f'Could not find user with guid {user_guid!r}.') + + pending_submissions = CollectionSubmission.objects.filter( + machine_state=CollectionSubmissionStates.PENDING.value + ).select_related('collection__provider', 'guid', 'creator') + + total = pending_submissions.count() + logger.info( + f'{"[DRY RUN] " if dry_run else ""}' + f'Found {total} pending collection submission(s) to reject.' + ) + + rejected_count = 0 + error_count = 0 + for submission in pending_submissions.iterator(): + guid = submission.guid._id if submission.guid else 'unknown' + try: + submission.reject(user=user, comment=comment, force=True) + except MachineError: + logger.exception( + f'{"[DRY RUN] " if dry_run else ""}' + f'MachineError rejecting CollectionSubmission for node guid [{guid}]' + ) + error_count += 1 + except Exception: + logger.exception( + f'{"[DRY RUN] " if dry_run else ""}' + f'Error rejecting CollectionSubmission for node guid [{guid}]' + ) + error_count += 1 + else: + rejected_count += 1 + logger.info( + f'{"[DRY RUN] " if dry_run else ""}' + f'Rejected CollectionSubmission for node guid [{guid}]' + ) + + logger.info( + f'{"[DRY RUN] " if dry_run else ""}' + f'Done. Rejected {rejected_count}/{total} submission(s), {error_count} error(s).' + ) + + if dry_run: + raise RuntimeError('Dry run, transaction rolled back') + + return rejected_count + + +class Command(BaseCommand): + def add_arguments(self, parser): + super().add_arguments(parser) + parser.add_argument( + '--user', + dest='user_guid', + required=True, + help='GUID of the user to use as the rejection action creator.', + ) + parser.add_argument( + '--comment', + dest='comment', + default=DEFAULT_COMMENT, + help='Comment to attach to each rejection action.', + ) + parser.add_argument( + '--dry', + action='store_true', + dest='dry_run', + help='Dry run — rolls back all changes.', + ) + + def handle(self, *args, **options): + reject_pending_collection_submissions( + user_guid=options['user_guid'], + comment=options['comment'], + dry_run=options['dry_run'], + ) diff --git a/osf_tests/management_commands/test_reject_pending_collection_submissions.py b/osf_tests/management_commands/test_reject_pending_collection_submissions.py new file mode 100644 index 00000000000..9d93d1b7bae --- /dev/null +++ b/osf_tests/management_commands/test_reject_pending_collection_submissions.py @@ -0,0 +1,115 @@ +import pytest +from unittest import mock +from transitions import MachineError + +from osf.management.commands.reject_pending_collection_submissions import ( + DEFAULT_COMMENT, + reject_pending_collection_submissions, +) +from osf.models import CollectionSubmission +from osf.utils.workflows import CollectionSubmissionStates +from osf_tests.factories import ( + AuthUserFactory, + CollectionFactory, + CollectionProviderFactory, + NodeFactory, +) +from tests.utils import capture_notifications + + +@pytest.fixture() +def actor(): + return AuthUserFactory() + + +@pytest.fixture() +def moderated_provider(): + provider = CollectionProviderFactory() + provider.reviews_workflow = 'pre-moderation' + provider.update_group_permissions() + provider.save() + return provider + + +@pytest.fixture() +def moderated_collection(moderated_provider): + collection = CollectionFactory() + collection.provider = moderated_provider + collection.save() + return collection + + +def make_pending_submission(collection): + node = NodeFactory(is_public=True) + submission = CollectionSubmission( + guid=node.guids.first(), + collection=collection, + creator=node.creator, + ) + with capture_notifications(): + submission.save() + assert submission.state == CollectionSubmissionStates.PENDING + return submission + + +@pytest.mark.django_db +class TestRejectPendingCollectionSubmissions: + + def test_rejects_pending_submission(self, actor, moderated_collection): + submission = make_pending_submission(moderated_collection) + + with capture_notifications(): + count = reject_pending_collection_submissions(user_guid=actor._id, comment=None) + + assert count == 1 + submission.refresh_from_db() + assert submission.state == CollectionSubmissionStates.REJECTED + action = submission.actions.order_by('-created').first() + assert action.comment == DEFAULT_COMMENT + + def test_skips_non_pending_submissions(self, actor, moderated_collection): + pending = make_pending_submission(moderated_collection) + accepted = make_pending_submission(moderated_collection) + accepted.machine_state = CollectionSubmissionStates.ACCEPTED.value + accepted.save() + + with capture_notifications(): + count = reject_pending_collection_submissions(user_guid=actor._id, comment=None) + + assert count == 1 + pending.refresh_from_db() + accepted.refresh_from_db() + assert pending.state == CollectionSubmissionStates.REJECTED + assert accepted.state == CollectionSubmissionStates.ACCEPTED + + def test_invalid_user_guid_raises(self): + with pytest.raises(RuntimeError, match='Could not find user'): + reject_pending_collection_submissions(user_guid='notavalidguid', comment=None) + + def test_custom_comment(self, actor, moderated_collection): + submission = make_pending_submission(moderated_collection) + custom_comment = 'Rejected due to policy update.' + + with capture_notifications(): + reject_pending_collection_submissions(user_guid=actor._id, comment=custom_comment) + + action = submission.actions.order_by('-created').first() + assert action.comment == custom_comment + + def test_machine_error_is_handled_gracefully(self, actor, moderated_collection): + submission_1 = make_pending_submission(moderated_collection) + submission_2 = make_pending_submission(moderated_collection) + + def patched_validate_reject(self, event_data): + if self.pk == submission_1.pk: + raise MachineError('Simulated error') + + with mock.patch.object(CollectionSubmission, '_validate_reject', patched_validate_reject): + with capture_notifications(): + count = reject_pending_collection_submissions(user_guid=actor._id, comment=None) + + assert count == 1 + submission_1.refresh_from_db() + submission_2.refresh_from_db() + assert submission_1.state == CollectionSubmissionStates.PENDING + assert submission_2.state == CollectionSubmissionStates.REJECTED From a284adb553a6afd0f1ef0a87bc695602fc3e1e04 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 19 Jun 2026 17:15:10 +0300 Subject: [PATCH 18/62] use background task for Pending collection submissions rejection because it may be time consuming --- admin/management/views.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/admin/management/views.py b/admin/management/views.py index dbf511ea107..aa5e1b7b608 100644 --- a/admin/management/views.py +++ b/admin/management/views.py @@ -201,13 +201,9 @@ def post(self, request): if not user_guid: messages.error(request, 'A user GUID must be provided.') return redirect(reverse('management:commands')) - try: - reject_pending_collection_submissions( - user_guid=user_guid, - comment=comment, - ) - except RuntimeError as e: - messages.error(request, str(e)) - return redirect(reverse('management:commands')) - messages.success(request, 'Pending collection submissions have been rejected.') + 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')) From 388aae2299a21db0dcf861a4944216e65d5c0f97 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Mon, 22 Jun 2026 15:44:53 +0300 Subject: [PATCH 19/62] resolve CR comments --- .../reject_pending_collection_submissions.py | 28 ++++++++++------- ...t_reject_pending_collection_submissions.py | 30 +++++++++++++++++++ 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/osf/management/commands/reject_pending_collection_submissions.py b/osf/management/commands/reject_pending_collection_submissions.py index 67c691070bc..0f32dbef8b3 100644 --- a/osf/management/commands/reject_pending_collection_submissions.py +++ b/osf/management/commands/reject_pending_collection_submissions.py @@ -1,6 +1,6 @@ import logging -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandError from django.db import transaction from framework.celery_tasks import app as celery_app from transitions import MachineError @@ -14,7 +14,6 @@ @celery_app.task(name='osf.management.commands.reject_pending_collection_submissions') -@transaction.atomic def reject_pending_collection_submissions(user_guid, comment, dry_run=False): comment = comment or DEFAULT_COMMENT user = OSFUser.load(user_guid) @@ -36,7 +35,11 @@ def reject_pending_collection_submissions(user_guid, comment, dry_run=False): for submission in pending_submissions.iterator(): guid = submission.guid._id if submission.guid else 'unknown' try: - submission.reject(user=user, comment=comment, force=True) + # Each submission commits independently: a failure here rolls back this submission, not others already processed. + with transaction.atomic(): + submission.reject(user=user, comment=comment, force=True) + if dry_run: + transaction.set_rollback(True) except MachineError: logger.exception( f'{"[DRY RUN] " if dry_run else ""}' @@ -61,9 +64,6 @@ def reject_pending_collection_submissions(user_guid, comment, dry_run=False): f'Done. Rejected {rejected_count}/{total} submission(s), {error_count} error(s).' ) - if dry_run: - raise RuntimeError('Dry run, transaction rolled back') - return rejected_count @@ -90,8 +90,14 @@ def add_arguments(self, parser): ) def handle(self, *args, **options): - reject_pending_collection_submissions( - user_guid=options['user_guid'], - comment=options['comment'], - dry_run=options['dry_run'], - ) + try: + rejected_count = reject_pending_collection_submissions( + user_guid=options['user_guid'], + comment=options['comment'], + dry_run=options['dry_run'], + ) + except RuntimeError as e: + raise CommandError(str(e)) + + prefix = '[DRY RUN] ' if options['dry_run'] else '' + self.stdout.write(self.style.SUCCESS(f'{prefix}Rejected {rejected_count} submission(s).')) diff --git a/osf_tests/management_commands/test_reject_pending_collection_submissions.py b/osf_tests/management_commands/test_reject_pending_collection_submissions.py index 9d93d1b7bae..a3740981887 100644 --- a/osf_tests/management_commands/test_reject_pending_collection_submissions.py +++ b/osf_tests/management_commands/test_reject_pending_collection_submissions.py @@ -1,5 +1,6 @@ import pytest from unittest import mock +from django.db import IntegrityError from transitions import MachineError from osf.management.commands.reject_pending_collection_submissions import ( @@ -82,6 +83,14 @@ def test_skips_non_pending_submissions(self, actor, moderated_collection): assert pending.state == CollectionSubmissionStates.REJECTED assert accepted.state == CollectionSubmissionStates.ACCEPTED + def test_dry_run_does_not_change_state(self, actor, moderated_collection): + submission = make_pending_submission(moderated_collection) + with capture_notifications(): + count = reject_pending_collection_submissions(user_guid=actor._id, comment=None, dry_run=True) + assert count == 1 + submission.refresh_from_db() + assert submission.state == CollectionSubmissionStates.PENDING + def test_invalid_user_guid_raises(self): with pytest.raises(RuntimeError, match='Could not find user'): reject_pending_collection_submissions(user_guid='notavalidguid', comment=None) @@ -113,3 +122,24 @@ def patched_validate_reject(self, event_data): submission_2.refresh_from_db() assert submission_1.state == CollectionSubmissionStates.PENDING assert submission_2.state == CollectionSubmissionStates.REJECTED + + def test_db_failure_on_one_submission_does_not_block_others(self, actor, moderated_collection): + submission_1 = make_pending_submission(moderated_collection) + submission_2 = make_pending_submission(moderated_collection) + original_save_transition = CollectionSubmission._save_transition + + def patched_save_transition(self, event_data): + if self.pk == submission_1.pk: + self.save() + raise IntegrityError('Simulated DB failure') + return original_save_transition(self, event_data) + + with mock.patch.object(CollectionSubmission, '_save_transition', patched_save_transition): + with capture_notifications(): + count = reject_pending_collection_submissions(user_guid=actor._id, comment=None) + + assert count == 1 + submission_1.refresh_from_db() + submission_2.refresh_from_db() + assert submission_1.state == CollectionSubmissionStates.PENDING + assert submission_2.state == CollectionSubmissionStates.REJECTED From 6f84c74f54996f99269193ad8a4d0d99c6f50c6c Mon Sep 17 00:00:00 2001 From: mkovalua Date: Tue, 23 Jun 2026 17:12:18 +0300 Subject: [PATCH 20/62] Prevent updates to node metadata via API --- api/nodes/views.py | 1 + .../nodes/views/test_node_detail_update.py | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/api/nodes/views.py b/api/nodes/views.py index bb84cb17efd..690eb6a8e27 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -384,6 +384,7 @@ class NodeDetail(JSONAPIBaseView, generics.RetrieveUpdateDestroyAPIView, NodeMix ReadOnlyIfRegistration, base_permissions.TokenHasScope, ExcludeWithdrawals, + ProjectEditingNotAllowed, ) required_read_scopes = [CoreScopes.NODE_BASE_READ] diff --git a/api_tests/nodes/views/test_node_detail_update.py b/api_tests/nodes/views/test_node_detail_update.py index b4000a31db6..2dfc1b12990 100644 --- a/api_tests/nodes/views/test_node_detail_update.py +++ b/api_tests/nodes/views/test_node_detail_update.py @@ -1,6 +1,7 @@ from unittest import mock import pytest from rest_framework import exceptions +from waffle.testutils import override_flag from api.base.settings.defaults import API_BASE from api.caching import settings as cache_settings @@ -8,6 +9,7 @@ from api_tests.nodes.views.utils import NodeCRUDTestCase from api_tests.subjects.mixins import UpdateSubjectsMixin from framework.auth.core import Auth +from osf import features from osf.models import NodeLog, NotificationTypeEnum from osf.utils.sanitize import strip_html from osf.utils import permissions @@ -700,6 +702,48 @@ def test_set_node_with_preprint_private_updates_doi( assert not mock_update_doi_metadata.called +@pytest.mark.django_db +class TestNodeUpdateProjectReadOnly(NodeCRUDTestCase): + + def test_patch_metadata_blocked_when_project_read_only_flag_active( + self, app, user, title_new, description_new, category_new, + project_private, url_private, make_node_payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.patch_json_api( + url_private, + make_node_payload(project_private, { + 'title': title_new, + 'description': description_new, + 'category': category_new, + }), + auth=user.auth, + expect_errors=True + ) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + project_private.reload() + assert project_private.title != title_new + assert project_private.description != description_new + + def test_patch_metadata_allowed_when_project_read_only_flag_inactive( + self, app, user, title_new, description_new, category_new, + project_private, url_private, make_node_payload): + with override_flag(features.PROJECT_READ_ONLY, active=False): + res = app.patch_json_api( + url_private, + make_node_payload(project_private, { + 'title': title_new, + 'description': description_new, + 'category': category_new, + }), + auth=user.auth + ) + assert res.status_code == 200 + project_private.reload() + assert project_private.title == title_new + assert project_private.description == description_new + + @pytest.mark.django_db class TestUpdateNodeSubjects(UpdateSubjectsMixin): From adc8486ae14d12a20a8fe908f286a331ce75416f Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Tue, 23 Jun 2026 15:49:45 -0400 Subject: [PATCH 21/62] Prevent node subject updates via API when project_read_only flag is active --- api/nodes/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/nodes/views.py b/api/nodes/views.py index bb84cb17efd..edf7bb08f9a 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -1847,6 +1847,7 @@ class NodeSubjectsRelationship(SubjectRelationshipBaseView, NodeMixin): base_permissions.TokenHasScope, ContributorOrPublic, ExcludeWithdrawals, + ProjectEditingNotAllowed, ) required_read_scopes = [CoreScopes.NODE_BASE_READ] From 202d99e08902a1b7d62402946f40c831a20a6dcb Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Tue, 23 Jun 2026 16:20:22 -0400 Subject: [PATCH 22/62] [ENG-11403] Prevent updates to node resource and funder information via API Adds ProjectEditingNotAllowed permission class to CustomItemMetadataDetail to block PUT/PATCH requests to /v2/custom_item_metadata_records// when the PROJECT_READ_ONLY waffle flag is active. Returns 405 when blocked. Includes tests for both the blocked and allowed cases. --- api/custom_metadata/views.py | 2 + .../test_custom_item_metadata.py | 45 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/api/custom_metadata/views.py b/api/custom_metadata/views.py index f4365e98c38..96aaf05eb7e 100644 --- a/api/custom_metadata/views.py +++ b/api/custom_metadata/views.py @@ -6,6 +6,7 @@ from api.base import permissions as base_permissions from api.base.views import JSONAPIBaseView +from api.nodes.permissions import ProjectEditingNotAllowed import osf.models as osfdb from .permissions import CustomMetadataPermission @@ -43,6 +44,7 @@ class CustomItemMetadataDetail(JSONAPIBaseView, rest_framework.generics.Retrieve CustomMetadataPermission, rest_framework.permissions.IsAuthenticatedOrReadOnly, base_permissions.TokenHasScope, + ProjectEditingNotAllowed, ) required_read_scopes = [CoreScopes.GUIDS_READ] diff --git a/api_tests/metadata_records/test_custom_item_metadata.py b/api_tests/metadata_records/test_custom_item_metadata.py index 13d259ba7ca..00053dcff25 100644 --- a/api_tests/metadata_records/test_custom_item_metadata.py +++ b/api_tests/metadata_records/test_custom_item_metadata.py @@ -1,6 +1,8 @@ import pytest +from waffle.testutils import override_flag from api.base.settings.defaults import API_BASE +from osf import features from osf.models import GuidMetadataRecord, Preprint from osf.utils import permissions from osf_tests.factories import ( @@ -513,3 +515,46 @@ def test_with_write_permission(self, app, public_osfguid, private_osfguid, anybo assert res.json['errors'] == bad_funding_info['expected_errors'] # check it hasn't changed in the db expected.assert_expectations(db_record=db_record, api_record=None) + + +@pytest.mark.django_db +class TestCustomItemMetadataProjectReadOnly: + + @pytest.fixture() + def user(self): + return AuthUserFactory() + + @pytest.fixture() + def project(self, user): + return ProjectFactory(creator=user, is_public=False) + + @pytest.fixture() + def url(self, project): + return f'/{API_BASE}custom_item_metadata_records/{project._id}/' + + @pytest.fixture() + def payload(self, project): + return { + 'data': { + 'id': project._id, + 'type': 'custom-item-metadata-records', + 'attributes': { + 'language': 'en', + 'resource_type_general': 'Text', + }, + } + } + + def test_put_blocked_when_project_read_only_flag_active(self, app, user, url, payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.put_json_api(url, payload, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + + def test_put_allowed_when_project_read_only_flag_inactive(self, app, user, project, url, payload): + with override_flag(features.PROJECT_READ_ONLY, active=False): + res = app.put_json_api(url, payload, auth=user.auth) + assert res.status_code == 200 + record = GuidMetadataRecord.objects.get(guid=project.guids.first()) + assert record.language == 'en' + assert record.resource_type_general == 'Text' From 955129cfb90e35783e5ca6c51c99924880a3ab41 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Wed, 24 Jun 2026 13:56:34 +0300 Subject: [PATCH 23/62] resolve CR --- api_tests/nodes/views/test_node_detail_update.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api_tests/nodes/views/test_node_detail_update.py b/api_tests/nodes/views/test_node_detail_update.py index 2dfc1b12990..d0861382f7a 100644 --- a/api_tests/nodes/views/test_node_detail_update.py +++ b/api_tests/nodes/views/test_node_detail_update.py @@ -724,6 +724,7 @@ def test_patch_metadata_blocked_when_project_read_only_flag_active( project_private.reload() assert project_private.title != title_new assert project_private.description != description_new + assert project_private.category != category_new def test_patch_metadata_allowed_when_project_read_only_flag_inactive( self, app, user, title_new, description_new, category_new, @@ -742,6 +743,7 @@ def test_patch_metadata_allowed_when_project_read_only_flag_inactive( project_private.reload() assert project_private.title == title_new assert project_private.description == description_new + assert project_private.category == category_new @pytest.mark.django_db From ba458e31d847a6837b1e8a463822c2146d695d1e Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Wed, 24 Jun 2026 16:52:17 -0400 Subject: [PATCH 24/62] Scope item metadata write-block to Node referents only Replace ProjectEditingNotAllowed (has_permission, no referent awareness) with ItemMetadataEditingNotAllowed (has_object_permission) in CustomItemMetadataDetail. The new class inspects the referent type and only raises 405 when the target is a Node and the PROJECT_READ_ONLY flag is active, leaving Registrations and Preprints unaffected. Adds a regression test confirming registrations are not blocked when the flag is on. --- api/custom_metadata/permissions.py | 21 +++++++++++++++++-- api/custom_metadata/views.py | 6 ++---- .../test_custom_item_metadata.py | 20 ++++++++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/api/custom_metadata/permissions.py b/api/custom_metadata/permissions.py index a1aeb2768ac..64347068c06 100644 --- a/api/custom_metadata/permissions.py +++ b/api/custom_metadata/permissions.py @@ -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): @@ -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 diff --git a/api/custom_metadata/views.py b/api/custom_metadata/views.py index 96aaf05eb7e..e76bda5f994 100644 --- a/api/custom_metadata/views.py +++ b/api/custom_metadata/views.py @@ -6,10 +6,8 @@ from api.base import permissions as base_permissions from api.base.views import JSONAPIBaseView -from api.nodes.permissions import ProjectEditingNotAllowed - import osf.models as osfdb -from .permissions import CustomMetadataPermission +from .permissions import CustomMetadataPermission, ItemMetadataEditingNotAllowed from .serializers import CustomFileMetadataSerializer, CustomItemMetadataSerializer @@ -44,7 +42,7 @@ class CustomItemMetadataDetail(JSONAPIBaseView, rest_framework.generics.Retrieve CustomMetadataPermission, rest_framework.permissions.IsAuthenticatedOrReadOnly, base_permissions.TokenHasScope, - ProjectEditingNotAllowed, + ItemMetadataEditingNotAllowed, ) required_read_scopes = [CoreScopes.GUIDS_READ] diff --git a/api_tests/metadata_records/test_custom_item_metadata.py b/api_tests/metadata_records/test_custom_item_metadata.py index 00053dcff25..36224419bc8 100644 --- a/api_tests/metadata_records/test_custom_item_metadata.py +++ b/api_tests/metadata_records/test_custom_item_metadata.py @@ -558,3 +558,23 @@ def test_put_allowed_when_project_read_only_flag_inactive(self, app, user, proje record = GuidMetadataRecord.objects.get(guid=project.guids.first()) assert record.language == 'en' assert record.resource_type_general == 'Text' + + def test_put_not_blocked_for_registration_when_project_read_only_flag_active(self, app, user, project): + registration = RegistrationFactory(project=project, is_public=True) + url = f'/{API_BASE}custom_item_metadata_records/{registration._id}/' + payload = { + 'data': { + 'id': registration._id, + 'type': 'custom-item-metadata-records', + 'attributes': { + 'language': 'en', + 'resource_type_general': 'Text', + }, + } + } + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.put_json_api(url, payload, auth=user.auth) + assert res.status_code == 200 + record = GuidMetadataRecord.objects.get(guid=registration.guids.first()) + assert record.language == 'en' + assert record.resource_type_general == 'Text' From 8653fec6668fd74231b4e9e669fb22d82532ef7e Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Wed, 24 Jun 2026 16:55:38 -0400 Subject: [PATCH 25/62] Address review: add PATCH/preprint tests, remove redundant flag override, restore blank line - Add test_patch_blocked_when_project_read_only_flag_active to cover PATCH - Add test_put_not_blocked_for_preprint_when_project_read_only_flag_active - Remove redundant override_flag(active=False) in allowed test - Restore blank line in views.py between JSONAPIBaseView import and osfdb import --- api/custom_metadata/views.py | 1 + .../test_custom_item_metadata.py | 29 +++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/api/custom_metadata/views.py b/api/custom_metadata/views.py index e76bda5f994..912145b8f7b 100644 --- a/api/custom_metadata/views.py +++ b/api/custom_metadata/views.py @@ -6,6 +6,7 @@ from api.base import permissions as base_permissions from api.base.views import JSONAPIBaseView + import osf.models as osfdb from .permissions import CustomMetadataPermission, ItemMetadataEditingNotAllowed from .serializers import CustomFileMetadataSerializer, CustomItemMetadataSerializer diff --git a/api_tests/metadata_records/test_custom_item_metadata.py b/api_tests/metadata_records/test_custom_item_metadata.py index 36224419bc8..51940b27062 100644 --- a/api_tests/metadata_records/test_custom_item_metadata.py +++ b/api_tests/metadata_records/test_custom_item_metadata.py @@ -551,9 +551,14 @@ def test_put_blocked_when_project_read_only_flag_active(self, app, user, url, pa assert res.status_code == 405 assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + def test_patch_blocked_when_project_read_only_flag_active(self, app, user, url, payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.patch_json_api(url, payload, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + def test_put_allowed_when_project_read_only_flag_inactive(self, app, user, project, url, payload): - with override_flag(features.PROJECT_READ_ONLY, active=False): - res = app.put_json_api(url, payload, auth=user.auth) + res = app.put_json_api(url, payload, auth=user.auth) assert res.status_code == 200 record = GuidMetadataRecord.objects.get(guid=project.guids.first()) assert record.language == 'en' @@ -578,3 +583,23 @@ def test_put_not_blocked_for_registration_when_project_read_only_flag_active(sel record = GuidMetadataRecord.objects.get(guid=registration.guids.first()) assert record.language == 'en' assert record.resource_type_general == 'Text' + + def test_put_not_blocked_for_preprint_when_project_read_only_flag_active(self, app, user): + preprint = PreprintFactory(creator=user) + url = f'/{API_BASE}custom_item_metadata_records/{preprint._id}/' + payload = { + 'data': { + 'id': preprint._id, + 'type': 'custom-item-metadata-records', + 'attributes': { + 'language': 'en', + 'resource_type_general': 'Text', + }, + } + } + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.put_json_api(url, payload, auth=user.auth) + assert res.status_code == 200 + record = GuidMetadataRecord.objects.get(guid=preprint.guids.first()) + assert record.language == 'en' + assert record.resource_type_general == 'Text' From f4113306b61a046d75a3d6eca31ebd9b873acb38 Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Wed, 24 Jun 2026 17:14:56 -0400 Subject: [PATCH 26/62] Re-trigger CI From 382f7ca2cc2a097f12f39a2059100af538d62c48 Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Thu, 25 Jun 2026 08:45:59 -0400 Subject: [PATCH 27/62] Prevent POST to node identifiers endpoint when PROJECT_READ_ONLY flag active Adds NodeIdentifierCreationNotAllowed permission class and applies it to NodeIdentifierList so that DOI creation on projects returns 405 when the PROJECT_READ_ONLY waffle flag is active. --- api/nodes/permissions.py | 8 +++ api/nodes/views.py | 3 + .../identifiers/views/test_identifier_list.py | 59 +++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index 38c81531b38..39bd47bb054 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -384,3 +384,11 @@ def has_permission(self, request, view): if request.method in ['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 + + +class NodeIdentifierCreationNotAllowed(permissions.BasePermission): + + def has_permission(self, request, view): + if request.method == 'POST' 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 diff --git a/api/nodes/views.py b/api/nodes/views.py index d7ca079ec8a..ff44027f462 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -102,6 +102,7 @@ ReadOnlyIfWithdrawn, ProjectCreationNotAllowed, ProjectEditingNotAllowed, + NodeIdentifierCreationNotAllowed, ) from api.wikis.permissions import WikisEditingNotAllowed from osf.utils import permissions as osf_permissions @@ -2202,6 +2203,8 @@ class NodeIdentifierList(NodeMixin, IdentifierList): """See [documentation for this endpoint](https://developer.osf.io/#operation/nodes_identifiers_list). """ + permission_classes = IdentifierList.permission_classes + (NodeIdentifierCreationNotAllowed,) + serializer_class = NodeIdentifierSerializer node_lookup_url_kwarg = 'node_id' diff --git a/api_tests/identifiers/views/test_identifier_list.py b/api_tests/identifiers/views/test_identifier_list.py index 7a117b4e6f4..cca3b00f7d8 100644 --- a/api_tests/identifiers/views/test_identifier_list.py +++ b/api_tests/identifiers/views/test_identifier_list.py @@ -5,8 +5,10 @@ import responses from django.utils import timezone from framework.auth.core import Auth +from waffle.testutils import override_flag from api.base.settings.defaults import API_BASE +from osf import features from osf.models import Identifier from osf_tests.factories import ( RegistrationFactory, @@ -548,3 +550,60 @@ def retraction(self, resource, user): def test_create_doi_for_withdrawn_registration(self, app, user, retraction, identifier_url, identifier_payload): res = app.post_json_api(identifier_url, identifier_payload, auth=user.auth, expect_errors=True) assert res.status_code == 403 + + +@pytest.mark.django_db +class TestNodeIdentifierCreationProjectReadOnly: + + @pytest.fixture() + def user(self): + return AuthUserFactory() + + @pytest.fixture() + def node(self, user): + return NodeFactory(creator=user, is_public=True) + + @pytest.fixture() + def url(self, node): + return f'/{API_BASE}nodes/{node._id}/identifiers/' + + @pytest.fixture() + def payload(self): + return { + 'data': { + 'type': 'identifiers', + 'attributes': { + 'category': 'doi', + }, + } + } + + def test_post_blocked_when_project_read_only_flag_active(self, app, user, url, payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.post_json_api(url, payload, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + + @pytest.mark.usefixtures('mock_gravy_valet_get_verified_links') + @responses.activate + def test_post_allowed_when_project_read_only_flag_inactive(self, app, user, node, url, payload): + client = DataCiteClient(node) + responses.add( + responses.Response( + responses.POST, + f'{settings.DATACITE_URL}/metadata/{client.build_doi(node)}', + body='OK (10.70102/FK2osf.io/dp438)', + status=201, + ) + ) + responses.add( + responses.Response( + responses.POST, + f'{settings.DATACITE_URL}/doi', + body='OK (10.70102/FK2osf.io/dp438)', + status=201, + ) + ) + res = app.post_json_api(url, payload, auth=user.auth) + assert res.status_code == 201 + assert res.json['data']['attributes']['category'] == 'doi' From b7f885128f6a023fe29f1dfb54ff163831e3d72c Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Thu, 25 Jun 2026 08:59:30 -0400 Subject: [PATCH 28/62] Scope NodeIdentifierCreationNotAllowed to Node referents only Switches from has_permission to has_object_permission with an isinstance(obj, Node) guard, so the 405 block does not apply to RegistrationIdentifierList (which inherits NodeIdentifierList) when PROJECT_READ_ONLY is active. Adds a regression test to cover the registration case. --- api/nodes/permissions.py | 4 +-- .../identifiers/views/test_identifier_list.py | 30 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index 39bd47bb054..434fc727b48 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -388,7 +388,7 @@ def has_permission(self, request, view): class NodeIdentifierCreationNotAllowed(permissions.BasePermission): - def has_permission(self, request, view): - if request.method == 'POST' and waffle.flag_is_active(request, features.PROJECT_READ_ONLY): + def has_object_permission(self, request, view, obj): + if request.method == 'POST' and isinstance(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 diff --git a/api_tests/identifiers/views/test_identifier_list.py b/api_tests/identifiers/views/test_identifier_list.py index cca3b00f7d8..806df5c51db 100644 --- a/api_tests/identifiers/views/test_identifier_list.py +++ b/api_tests/identifiers/views/test_identifier_list.py @@ -578,6 +578,10 @@ def payload(self): } } + @pytest.fixture() + def registration(self, user): + return RegistrationFactory(creator=user, is_public=True) + def test_post_blocked_when_project_read_only_flag_active(self, app, user, url, payload): with override_flag(features.PROJECT_READ_ONLY, active=True): res = app.post_json_api(url, payload, auth=user.auth, expect_errors=True) @@ -607,3 +611,29 @@ def test_post_allowed_when_project_read_only_flag_inactive(self, app, user, node res = app.post_json_api(url, payload, auth=user.auth) assert res.status_code == 201 assert res.json['data']['attributes']['category'] == 'doi' + + @pytest.mark.usefixtures('mock_gravy_valet_get_verified_links') + @responses.activate + def test_post_not_blocked_for_registration_when_project_read_only_flag_active(self, app, user, registration, payload): + registration_url = f'/{API_BASE}registrations/{registration._id}/identifiers/' + client = DataCiteClient(registration) + responses.add( + responses.Response( + responses.POST, + f'{settings.DATACITE_URL}/metadata/{client.build_doi(registration)}', + body='OK (10.70102/FK2osf.io/dp438)', + status=201, + ) + ) + responses.add( + responses.Response( + responses.POST, + f'{settings.DATACITE_URL}/doi', + body='OK (10.70102/FK2osf.io/dp438)', + status=201, + ) + ) + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.post_json_api(registration_url, payload, auth=user.auth) + assert res.status_code == 201 + assert res.json['data']['attributes']['category'] == 'doi' From 3660731e93c048814200cfef2f955492a07fadc3 Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Thu, 25 Jun 2026 12:43:01 -0400 Subject: [PATCH 29/62] Prevent node affiliation updates via API when PROJECT_READ_ONLY flag active Blocks PUT/PATCH on /v2/nodes//relationships/institutions/ and DELETE on /v2/institutions//relationships/nodes/ with 405 when the PROJECT_READ_ONLY waffle flag is active. --- api/institutions/permissions.py | 13 +++++- api/institutions/views.py | 3 +- api/nodes/permissions.py | 8 ++++ api/nodes/views.py | 2 + .../test_institution_relationship_nodes.py | 44 +++++++++++++++++++ .../test_node_relationship_institutions.py | 40 +++++++++++++++++ 6 files changed, 108 insertions(+), 2 deletions(-) diff --git a/api/institutions/permissions.py b/api/institutions/permissions.py index b56b898ec9e..bfeed957fad 100644 --- a/api/institutions/permissions.py +++ b/api/institutions/permissions.py @@ -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): @@ -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 diff --git a/api/institutions/views.py b/api/institutions/views.py index d653f5b4e77..6678eadd641 100644 --- a/api/institutions/views.py +++ b/api/institutions/views.py @@ -46,7 +46,7 @@ InstitutionUserMetricsSerializer, InstitutionSummaryMetricsSerializer, ) -from api.institutions.permissions import UserIsAffiliated +from api.institutions.permissions import UserIsAffiliated, InstitutionNodesDeleteNotAllowed class InstitutionMixin: @@ -350,6 +350,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] diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index 434fc727b48..ee0fb2f396b 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -392,3 +392,11 @@ def has_object_permission(self, request, view, obj): if request.method == 'POST' and isinstance(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 + + +class NodeAffiliationUpdateNotAllowed(permissions.BasePermission): + + def has_permission(self, request, view): + if request.method in ['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 diff --git a/api/nodes/views.py b/api/nodes/views.py index ff44027f462..fc8dbbadb47 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -103,6 +103,7 @@ ProjectCreationNotAllowed, ProjectEditingNotAllowed, NodeIdentifierCreationNotAllowed, + NodeAffiliationUpdateNotAllowed, ) from api.wikis.permissions import WikisEditingNotAllowed from osf.utils import permissions as osf_permissions @@ -1751,6 +1752,7 @@ class NodeInstitutionsRelationship(JSONAPIBaseView, generics.RetrieveUpdateDestr drf_permissions.IsAuthenticatedOrReadOnly, base_permissions.TokenHasScope, WriteOrPublicForRelationshipInstitutions, + NodeAffiliationUpdateNotAllowed, ) required_read_scopes = [CoreScopes.NODE_BASE_READ] required_write_scopes = [CoreScopes.NODE_BASE_WRITE] diff --git a/api_tests/institutions/views/test_institution_relationship_nodes.py b/api_tests/institutions/views/test_institution_relationship_nodes.py index 7b3fc54f05f..f12926d6bd5 100644 --- a/api_tests/institutions/views/test_institution_relationship_nodes.py +++ b/api_tests/institutions/views/test_institution_relationship_nodes.py @@ -1,6 +1,8 @@ import pytest +from waffle.testutils import override_flag from api.base.settings.defaults import API_BASE +from osf import features from osf.models import NotificationTypeEnum from osf_tests.factories import ( RegistrationFactory, @@ -452,3 +454,45 @@ def test_email_sent_on_affiliation_removal(self, app, admin, institution, node_p assert notifications['emits'][0]['kwargs']['user'] == node_public.creator assert notifications['emits'][1]['type'] == NotificationTypeEnum.NODE_AFFILIATION_CHANGED assert notifications['emits'][1]['kwargs']['user'] == admin + + +@pytest.mark.django_db +class TestInstitutionNodesRelationshipProjectReadOnly: + + @pytest.fixture() + def institution(self): + return InstitutionFactory() + + @pytest.fixture() + def user(self, institution): + user_auth = AuthUserFactory() + user_auth.add_or_update_affiliated_institution(institution) + user_auth.save() + return user_auth + + @pytest.fixture() + def node(self, user, institution): + project = NodeFactory(creator=user) + project.affiliated_institutions.add(institution) + project.save() + return project + + @pytest.fixture() + def url(self, institution): + return f'/{API_BASE}institutions/{institution._id}/relationships/nodes/' + + @pytest.fixture() + def payload(self, node): + return {'data': [{'type': 'nodes', 'id': node._id}]} + + def test_delete_blocked_when_project_read_only_flag_active(self, app, user, url, payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.delete_json_api(url, payload, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + + def test_delete_allowed_when_project_read_only_flag_inactive(self, app, user, node, institution, url, payload): + res = app.delete_json_api(url, payload, auth=user.auth) + assert res.status_code == 204 + node.reload() + assert institution not in node.affiliated_institutions.all() diff --git a/api_tests/nodes/views/test_node_relationship_institutions.py b/api_tests/nodes/views/test_node_relationship_institutions.py index 475a03b0001..e046a020ac3 100644 --- a/api_tests/nodes/views/test_node_relationship_institutions.py +++ b/api_tests/nodes/views/test_node_relationship_institutions.py @@ -1,6 +1,8 @@ import pytest +from waffle.testutils import override_flag from api.base.settings.defaults import API_BASE +from osf import features from osf.models import NotificationTypeEnum from osf_tests.factories import ( InstitutionFactory, @@ -684,3 +686,41 @@ def test_read_only_contributor_cannot_remove_affiliated_institution( expect_errors=True) assert res.status_code == 403 assert read_contrib_institution in node.affiliated_institutions.all() + + +@pytest.mark.django_db +class TestNodeRelationshipInstitutionsProjectReadOnly: + + @pytest.fixture() + def institution(self): + return InstitutionFactory() + + @pytest.fixture() + def user(self, institution): + user_auth = AuthUserFactory() + user_auth.add_or_update_affiliated_institution(institution) + user_auth.save() + return user_auth + + @pytest.fixture() + def node(self, user): + return NodeFactory(creator=user) + + @pytest.fixture() + def url(self, node): + return f'/{API_BASE}nodes/{node._id}/relationships/institutions/' + + @pytest.fixture() + def payload(self, institution): + return {'data': [{'type': 'institutions', 'id': institution._id}]} + + def test_put_blocked_when_project_read_only_flag_active(self, app, user, url, payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.put_json_api(url, payload, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + + def test_put_allowed_when_project_read_only_flag_inactive(self, app, user, node, institution, url, payload): + res = app.put_json_api(url, payload, auth=user.auth) + assert res.status_code == 200 + assert institution in node.affiliated_institutions.all() From bc61ab416e47fd152ddda5c4d1c3d7a501973532 Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Thu, 25 Jun 2026 13:06:10 -0400 Subject: [PATCH 30/62] Address review: block all write methods on node-institution endpoint Renames NodeAffiliationUpdateNotAllowed to NodeAffiliationWriteNotAllowed, switches to has_object_permission with isinstance(obj['self'], Node) for defensive scoping, and extends the block to cover DELETE and POST in addition to PUT/PATCH. Adds PATCH, DELETE, and POST blocked tests. --- api/nodes/permissions.py | 7 ++++--- api/nodes/views.py | 4 ++-- .../test_node_relationship_institutions.py | 21 +++++++++++++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index ee0fb2f396b..fe93002e779 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -394,9 +394,10 @@ def has_object_permission(self, request, view, obj): return True -class NodeAffiliationUpdateNotAllowed(permissions.BasePermission): +class NodeAffiliationWriteNotAllowed(permissions.BasePermission): - def has_permission(self, request, view): - if request.method in ['PUT', 'PATCH'] and waffle.flag_is_active(request, features.PROJECT_READ_ONLY): + def has_object_permission(self, request, view, obj): + assert isinstance(obj, dict) + if request.method not in permissions.SAFE_METHODS and isinstance(obj['self'], 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 diff --git a/api/nodes/views.py b/api/nodes/views.py index fc8dbbadb47..7194e8b20c1 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -103,7 +103,7 @@ ProjectCreationNotAllowed, ProjectEditingNotAllowed, NodeIdentifierCreationNotAllowed, - NodeAffiliationUpdateNotAllowed, + NodeAffiliationWriteNotAllowed, ) from api.wikis.permissions import WikisEditingNotAllowed from osf.utils import permissions as osf_permissions @@ -1752,7 +1752,7 @@ class NodeInstitutionsRelationship(JSONAPIBaseView, generics.RetrieveUpdateDestr drf_permissions.IsAuthenticatedOrReadOnly, base_permissions.TokenHasScope, WriteOrPublicForRelationshipInstitutions, - NodeAffiliationUpdateNotAllowed, + NodeAffiliationWriteNotAllowed, ) required_read_scopes = [CoreScopes.NODE_BASE_READ] required_write_scopes = [CoreScopes.NODE_BASE_WRITE] diff --git a/api_tests/nodes/views/test_node_relationship_institutions.py b/api_tests/nodes/views/test_node_relationship_institutions.py index e046a020ac3..81e681e6aa0 100644 --- a/api_tests/nodes/views/test_node_relationship_institutions.py +++ b/api_tests/nodes/views/test_node_relationship_institutions.py @@ -720,6 +720,27 @@ def test_put_blocked_when_project_read_only_flag_active(self, app, user, url, pa assert res.status_code == 405 assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + def test_patch_blocked_when_project_read_only_flag_active(self, app, user, url, payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.patch_json_api(url, payload, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + + def test_delete_blocked_when_project_read_only_flag_active(self, app, user, node, institution, url): + node.affiliated_institutions.add(institution) + node.save() + delete_payload = {'data': [{'type': 'institutions', 'id': institution._id}]} + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.delete_json_api(url, delete_payload, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + + def test_post_blocked_when_project_read_only_flag_active(self, app, user, url, payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.post_json_api(url, payload, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + def test_put_allowed_when_project_read_only_flag_inactive(self, app, user, node, institution, url, payload): res = app.put_json_api(url, payload, auth=user.auth) assert res.status_code == 200 From 441c459e4cc7816d2843344c8cf2173d3f432ef1 Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Thu, 25 Jun 2026 13:27:14 -0400 Subject: [PATCH 31/62] Re-trigger CI From 8d0da827a3e0c96c98478143bc38c05e754e86f7 Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Fri, 26 Jun 2026 09:22:25 -0400 Subject: [PATCH 32/62] Fix CI failures: wrap affiliation-change tests with capture_notifications PUT /relationships/institutions/ and DELETE /institutions/.../relationships/nodes/ trigger node_affiliation_changed email notifications. Tests that perform these real operations must use capture_notifications to prevent SocketConnectBlockedError. --- .../institutions/views/test_institution_relationship_nodes.py | 3 ++- api_tests/nodes/views/test_node_relationship_institutions.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/api_tests/institutions/views/test_institution_relationship_nodes.py b/api_tests/institutions/views/test_institution_relationship_nodes.py index f12926d6bd5..a49eb62c422 100644 --- a/api_tests/institutions/views/test_institution_relationship_nodes.py +++ b/api_tests/institutions/views/test_institution_relationship_nodes.py @@ -492,7 +492,8 @@ def test_delete_blocked_when_project_read_only_flag_active(self, app, user, url, assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' def test_delete_allowed_when_project_read_only_flag_inactive(self, app, user, node, institution, url, payload): - res = app.delete_json_api(url, payload, auth=user.auth) + with capture_notifications(): + res = app.delete_json_api(url, payload, auth=user.auth) assert res.status_code == 204 node.reload() assert institution not in node.affiliated_institutions.all() diff --git a/api_tests/nodes/views/test_node_relationship_institutions.py b/api_tests/nodes/views/test_node_relationship_institutions.py index 81e681e6aa0..3bc2e972f8b 100644 --- a/api_tests/nodes/views/test_node_relationship_institutions.py +++ b/api_tests/nodes/views/test_node_relationship_institutions.py @@ -742,6 +742,7 @@ def test_post_blocked_when_project_read_only_flag_active(self, app, user, url, p assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' def test_put_allowed_when_project_read_only_flag_inactive(self, app, user, node, institution, url, payload): - res = app.put_json_api(url, payload, auth=user.auth) + with capture_notifications(): + res = app.put_json_api(url, payload, auth=user.auth) assert res.status_code == 200 assert institution in node.affiliated_institutions.all() From c5c4ed900522a815b1080ecd765873079bb50f90 Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Fri, 26 Jun 2026 10:07:00 -0400 Subject: [PATCH 33/62] 1.3.6 Prevent adding/editing node contributors via API when PROJECT_READ_ONLY flag is active POST (add contributor) and PATCH (edit contributor) on NodeContributorsList and NodeContributorDetail are blocked with 405 when the PROJECT_READ_ONLY waffle flag is active. DELETE (remove contributor) is intentionally left unblocked so users can still remove themselves and others per the spec. Draft and Preprint contributor views are unaffected as they explicitly define their own permission_classes. --- api/nodes/permissions.py | 8 ++ api/nodes/views.py | 3 + .../views/test_node_contributors_detail.py | 54 ++++++++++++++ .../views/test_node_contributors_list.py | 73 +++++++++++++++++++ 4 files changed, 138 insertions(+) diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index 434fc727b48..24699380e43 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -392,3 +392,11 @@ def has_object_permission(self, request, view, obj): if request.method == 'POST' and isinstance(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 + + +class NodeContributorWriteNotAllowed(permissions.BasePermission): + + def has_permission(self, request, view): + if request.method in ['POST', '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 diff --git a/api/nodes/views.py b/api/nodes/views.py index ff44027f462..21a5f60a5e9 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -103,6 +103,7 @@ ProjectCreationNotAllowed, ProjectEditingNotAllowed, NodeIdentifierCreationNotAllowed, + NodeContributorWriteNotAllowed, ) from api.wikis.permissions import WikisEditingNotAllowed from osf.utils import permissions as osf_permissions @@ -433,6 +434,7 @@ class NodeContributorsList(BaseContributorList, bulk_views.BulkUpdateJSONAPIView drf_permissions.IsAuthenticatedOrReadOnly, ReadOnlyIfRegistration, base_permissions.TokenHasScope, + NodeContributorWriteNotAllowed, ) required_read_scopes = [CoreScopes.NODE_CONTRIBUTORS_READ] @@ -541,6 +543,7 @@ class NodeContributorDetail(BaseContributorDetail, generics.RetrieveUpdateDestro drf_permissions.IsAuthenticatedOrReadOnly, ReadOnlyIfRegistration, base_permissions.TokenHasScope, + NodeContributorWriteNotAllowed, ) required_read_scopes = [CoreScopes.NODE_CONTRIBUTORS_READ] diff --git a/api_tests/nodes/views/test_node_contributors_detail.py b/api_tests/nodes/views/test_node_contributors_detail.py index 0f5435df52e..3d7e9f838f1 100644 --- a/api_tests/nodes/views/test_node_contributors_detail.py +++ b/api_tests/nodes/views/test_node_contributors_detail.py @@ -2,6 +2,7 @@ from api.base.settings.defaults import API_BASE from framework.auth.core import Auth +from osf import features from osf.models import NodeLog from osf_tests.factories import ( ProjectFactory, @@ -10,6 +11,7 @@ from tests.utils import assert_latest_log from osf.utils import permissions from api_tests.utils import disconnected_from_listeners +from waffle.testutils import override_flag from website.project.signals import contributor_removed @@ -522,3 +524,55 @@ def test_remove_contributor_include_children_forbidden_if_unauthorized_child(sel assert user_write_contrib in project.contributors assert user_write_contrib in child.contributors + + +@pytest.mark.django_db +class TestNodeContributorDetailProjectReadOnly: + + @pytest.fixture() + def user(self): + return AuthUserFactory() + + @pytest.fixture() + def contrib(self): + return AuthUserFactory() + + @pytest.fixture() + def node(self, user, contrib): + project = ProjectFactory(creator=user) + project.add_contributor(contrib, permissions=permissions.WRITE, visible=True, save=True) + return project + + @pytest.fixture() + def url_contrib(self, node, contrib): + return f'/{API_BASE}nodes/{node._id}/contributors/{contrib._id}/' + + @pytest.fixture() + def patch_payload(self, node, contrib): + return { + 'data': { + 'id': f'{node._id}-{contrib._id}', + 'type': 'contributors', + 'attributes': {'bibliographic': False}, + } + } + + def test_patch_blocked_when_project_read_only_flag_active(self, app, user, url_contrib, patch_payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.patch_json_api(url_contrib, patch_payload, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + + def test_delete_allowed_when_project_read_only_flag_active(self, app, user, node, contrib, url_contrib): + with override_flag(features.PROJECT_READ_ONLY, active=True): + with disconnected_from_listeners(contributor_removed): + res = app.delete(url_contrib, auth=user.auth) + assert res.status_code == 204 + node.reload() + assert contrib not in node.contributors + + def test_patch_allowed_when_project_read_only_flag_inactive(self, app, user, node, contrib, url_contrib, patch_payload): + res = app.patch_json_api(url_contrib, patch_payload, auth=user.auth) + assert res.status_code == 200 + node.reload() + assert not node.get_visible(contrib) diff --git a/api_tests/nodes/views/test_node_contributors_list.py b/api_tests/nodes/views/test_node_contributors_list.py index 854f5288a14..41bcd40a8f7 100644 --- a/api_tests/nodes/views/test_node_contributors_list.py +++ b/api_tests/nodes/views/test_node_contributors_list.py @@ -6,6 +6,7 @@ from api.base.settings.defaults import API_BASE from api.nodes.serializers import NodeContributorsCreateSerializer from framework.auth.core import Auth +from osf import features from osf.models.notification_type import NotificationTypeEnum from osf_tests.factories import ( fake_email, @@ -18,6 +19,7 @@ from rest_framework import exceptions from tests.base import capture_signals, fake from tests.utils import capture_notifications +from waffle.testutils import override_flag from website.project.signals import contributor_added, contributor_removed from api_tests.utils import disconnected_from_listeners @@ -2957,3 +2959,74 @@ def test_filtering_permission_field_read(self, app, user, project, url): assert res.status_code == 200 assert len(res.json['data']) == 1 assert res.json['data'][0]['attributes'].get('permission') == permissions.READ + + +@pytest.mark.django_db +class TestNodeContributorListProjectReadOnly: + + @pytest.fixture() + def user(self): + return AuthUserFactory() + + @pytest.fixture() + def contrib(self): + return AuthUserFactory() + + @pytest.fixture() + def node(self, user, contrib): + project = ProjectFactory(creator=user) + project.add_contributor(contrib, permissions=permissions.WRITE, visible=True, save=True) + return project + + @pytest.fixture() + def url(self, node): + return f'/{API_BASE}nodes/{node._id}/contributors/' + + @pytest.fixture() + def add_payload(self, user): + new_user = AuthUserFactory() + return { + 'data': { + 'type': 'contributors', + 'attributes': {'bibliographic': True}, + 'relationships': {'users': {'data': {'type': 'users', 'id': new_user._id}}}, + } + } + + @pytest.fixture() + def bulk_patch_payload(self, node, contrib): + return { + 'data': [ + { + 'id': f'{node._id}-{contrib._id}', + 'type': 'contributors', + 'attributes': {'bibliographic': False}, + } + ] + } + + def test_post_blocked_when_project_read_only_flag_active(self, app, user, url, add_payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.post_json_api(url, add_payload, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + + def test_patch_blocked_when_project_read_only_flag_active(self, app, user, url, bulk_patch_payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.patch_json_api(url, bulk_patch_payload, auth=user.auth, expect_errors=True, bulk=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + + def test_delete_allowed_when_project_read_only_flag_active(self, app, user, node, contrib, url): + delete_payload = {'data': [{'id': f'{node._id}-{contrib._id}', 'type': 'contributors'}]} + with override_flag(features.PROJECT_READ_ONLY, active=True): + with disconnected_from_listeners(contributor_removed): + res = app.delete_json_api(url, delete_payload, auth=user.auth, bulk=True) + assert res.status_code == 204 + node.reload() + assert contrib not in node.contributors + + def test_post_allowed_when_project_read_only_flag_inactive(self, app, user, url, add_payload): + with capture_notifications(): + res = app.post_json_api(url, add_payload, auth=user.auth) + assert res.status_code == 201 From 8f7a22a793b0434aabe34388ad0fb3311cf30e2c Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Fri, 26 Jun 2026 10:20:10 -0400 Subject: [PATCH 34/62] Fix review issues: block PUT on contributor endpoints and add missing tests - Add PUT to blocked methods in NodeContributorWriteNotAllowed (BulkUpdateJSONAPIView exposes both PUT and PATCH for bulk updates on NodeContributorsList) - Add test_put_blocked_when_project_read_only_flag_active for list endpoint - Add test_patch_allowed_when_project_read_only_flag_inactive for list endpoint --- api/nodes/permissions.py | 2 +- api_tests/nodes/views/test_node_contributors_list.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index 24699380e43..76bd33f562f 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -397,6 +397,6 @@ def has_object_permission(self, request, view, obj): class NodeContributorWriteNotAllowed(permissions.BasePermission): def has_permission(self, request, view): - if request.method in ['POST', 'PATCH'] and waffle.flag_is_active(request, features.PROJECT_READ_ONLY): + 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 diff --git a/api_tests/nodes/views/test_node_contributors_list.py b/api_tests/nodes/views/test_node_contributors_list.py index 41bcd40a8f7..a43c18eadad 100644 --- a/api_tests/nodes/views/test_node_contributors_list.py +++ b/api_tests/nodes/views/test_node_contributors_list.py @@ -3011,6 +3011,12 @@ def test_post_blocked_when_project_read_only_flag_active(self, app, user, url, a assert res.status_code == 405 assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + def test_put_blocked_when_project_read_only_flag_active(self, app, user, url, bulk_patch_payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.put_json_api(url, bulk_patch_payload, auth=user.auth, expect_errors=True, bulk=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + def test_patch_blocked_when_project_read_only_flag_active(self, app, user, url, bulk_patch_payload): with override_flag(features.PROJECT_READ_ONLY, active=True): res = app.patch_json_api(url, bulk_patch_payload, auth=user.auth, expect_errors=True, bulk=True) @@ -3030,3 +3036,9 @@ def test_post_allowed_when_project_read_only_flag_inactive(self, app, user, url, with capture_notifications(): res = app.post_json_api(url, add_payload, auth=user.auth) assert res.status_code == 201 + + def test_patch_allowed_when_project_read_only_flag_inactive(self, app, user, node, contrib, url, bulk_patch_payload): + res = app.patch_json_api(url, bulk_patch_payload, auth=user.auth, bulk=True) + assert res.status_code == 200 + contrib.reload() + assert not node.get_visible(contrib) From ee7f74cd6422da0fedfe20f9501145beb4c6b91c Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Mon, 29 Jun 2026 09:47:25 -0400 Subject: [PATCH 35/62] 1.3.7 Prevent adding/removing linked nodes and registrations via API when PROJECT_READ_ONLY flag is active POST (add) and DELETE (remove) on NodeLinkedNodesRelationship and NodeLinkedRegistrationsRelationship are blocked with 405 when the PROJECT_READ_ONLY waffle flag is active. Uses has_permission for immediate dispatch-level blocking. Adds explicit permission_classes to the node-specific views so Collection views (which inherit from the same base but define their own permission_classes) are unaffected. --- api/nodes/permissions.py | 8 +++ api/nodes/views.py | 5 ++ .../nodes/views/test_node_linked_nodes.py | 52 ++++++++++++++++++ .../test_node_linked_registration_create.py | 54 ++++++++++++++++++- 4 files changed, 118 insertions(+), 1 deletion(-) diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index 434fc727b48..b2f84e873ef 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -392,3 +392,11 @@ def has_object_permission(self, request, view, obj): if request.method == 'POST' and isinstance(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 + + +class NodeLinkedWriteNotAllowed(permissions.BasePermission): + + def has_permission(self, request, view): + if request.method in ['POST', '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 diff --git a/api/nodes/views.py b/api/nodes/views.py index ff44027f462..bbe21db9c1a 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -103,6 +103,7 @@ ProjectCreationNotAllowed, ProjectEditingNotAllowed, NodeIdentifierCreationNotAllowed, + NodeLinkedWriteNotAllowed, ) from api.wikis.permissions import WikisEditingNotAllowed from osf.utils import permissions as osf_permissions @@ -1962,6 +1963,8 @@ class NodeLinkedNodesRelationship(LinkedNodesRelationship, NodeMixin): corresponding node_id in the request. """ + permission_classes = LinkedNodesRelationship.permission_classes + (NodeLinkedWriteNotAllowed,) + view_category = 'nodes' view_name = 'node-pointer-relationship' @@ -2051,6 +2054,8 @@ class NodeLinkedRegistrationsRelationship(LinkedRegistrationsRelationship, NodeM corresponding node_id in the request. """ + permission_classes = LinkedRegistrationsRelationship.permission_classes + (NodeLinkedWriteNotAllowed,) + view_category = 'nodes' view_name = 'node-registration-pointer-relationship' diff --git a/api_tests/nodes/views/test_node_linked_nodes.py b/api_tests/nodes/views/test_node_linked_nodes.py index a09a0c632c7..85d388fecc0 100644 --- a/api_tests/nodes/views/test_node_linked_nodes.py +++ b/api_tests/nodes/views/test_node_linked_nodes.py @@ -2,11 +2,13 @@ from api.base.settings.defaults import API_BASE from framework.auth.core import Auth +from osf import features from osf_tests.factories import ( NodeFactory, AuthUserFactory, NodeRelationFactory, ) +from waffle.testutils import override_flag from website.project.signals import contributor_removed from api_tests.utils import disconnected_from_listeners @@ -698,3 +700,53 @@ def test_attempt_to_return_linked_nodes_logged_out( ) assert res.status_code == 401 + + +@pytest.mark.django_db +class TestNodeLinkedNodesRelationshipProjectReadOnly: + + @pytest.fixture() + def user(self): + return AuthUserFactory() + + @pytest.fixture() + def linked_node(self): + return NodeFactory() + + @pytest.fixture() + def node(self, user, linked_node): + project = NodeFactory(creator=user) + project.add_pointer(linked_node, auth=Auth(user)) + return project + + @pytest.fixture() + def url(self, node): + return f'/{API_BASE}nodes/{node._id}/relationships/linked_nodes/' + + @pytest.fixture() + def payload(self, linked_node): + return {'data': [{'type': 'nodes', 'id': linked_node._id}]} + + def test_post_blocked_when_project_read_only_flag_active(self, app, user, url, payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.post_json_api(url, payload, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + + def test_delete_blocked_when_project_read_only_flag_active(self, app, user, url, payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.delete_json_api(url, payload, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + + def test_post_allowed_when_project_read_only_flag_inactive(self, app, user, node, url): + new_node = NodeFactory() + payload = {'data': [{'type': 'nodes', 'id': new_node._id}]} + res = app.post_json_api(url, payload, auth=user.auth) + assert res.status_code == 201 + + def test_delete_allowed_when_project_read_only_flag_inactive(self, app, user, node, linked_node, url, payload): + res = app.delete_json_api(url, payload, auth=user.auth) + assert res.status_code == 204 + node.reload() + assert linked_node not in node.linked_nodes.all() diff --git a/api_tests/nodes/views/test_node_linked_registration_create.py b/api_tests/nodes/views/test_node_linked_registration_create.py index 070f7a8c4cd..a0cec91d6e1 100644 --- a/api_tests/nodes/views/test_node_linked_registration_create.py +++ b/api_tests/nodes/views/test_node_linked_registration_create.py @@ -2,9 +2,11 @@ from api.base.settings.defaults import API_BASE from framework.auth.core import Auth -from osf_tests.factories import RegistrationFactory, NodeRelationFactory +from osf import features +from osf_tests.factories import RegistrationFactory, NodeFactory, NodeRelationFactory, AuthUserFactory from osf.utils.permissions import READ from rest_framework import exceptions +from waffle.testutils import override_flag from .utils import LinkedRegistrationsTestCase @@ -239,3 +241,53 @@ def test_can_create_linked_registration_relationship_to_private_registration_if_ assert res.status_code == 201 linked_registrations = [r['id'] for r in res.json['data']] assert registration._id in linked_registrations + + +@pytest.mark.django_db +class TestNodeLinkedRegistrationsRelationshipProjectReadOnly: + + @pytest.fixture() + def user(self): + return AuthUserFactory() + + @pytest.fixture() + def registration(self): + return RegistrationFactory(is_public=True) + + @pytest.fixture() + def node(self, user, registration): + project = NodeFactory(creator=user) + project.add_pointer(registration, auth=Auth(user)) + return project + + @pytest.fixture() + def url(self, node): + return f'/{API_BASE}nodes/{node._id}/relationships/linked_registrations/' + + @pytest.fixture() + def payload(self, registration): + return {'data': [{'type': 'registrations', 'id': registration._id}]} + + def test_post_blocked_when_project_read_only_flag_active(self, app, user, url, payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.post_json_api(url, payload, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + + def test_delete_blocked_when_project_read_only_flag_active(self, app, user, url, payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.delete_json_api(url, payload, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + + def test_post_allowed_when_project_read_only_flag_inactive(self, app, user, node, url): + new_registration = RegistrationFactory(is_public=True) + payload = {'data': [{'type': 'registrations', 'id': new_registration._id}]} + res = app.post_json_api(url, payload, auth=user.auth) + assert res.status_code == 201 + + def test_delete_allowed_when_project_read_only_flag_inactive(self, app, user, node, registration, url, payload): + res = app.delete_json_api(url, payload, auth=user.auth) + assert res.status_code == 204 + node.reload() + assert registration not in node.linked_nodes.all() From b65bd94b4a1fe3da0b31624e39e274023b431f17 Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Mon, 29 Jun 2026 09:50:56 -0400 Subject: [PATCH 36/62] Block PUT and PATCH on linked nodes/registrations relationship endpoints under PROJECT_READ_ONLY PUT performs a full replacement of the linked set (adds and removes items), so it must be blocked alongside POST and DELETE. Switched to SAFE_METHODS check to cover all write methods uniformly. Added test_put_blocked and test_patch_blocked tests to both relationship test classes. --- api/nodes/permissions.py | 2 +- api_tests/nodes/views/test_node_linked_nodes.py | 12 ++++++++++++ .../views/test_node_linked_registration_create.py | 12 ++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index b2f84e873ef..72eba8be6e5 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -397,6 +397,6 @@ def has_object_permission(self, request, view, obj): class NodeLinkedWriteNotAllowed(permissions.BasePermission): def has_permission(self, request, view): - if request.method in ['POST', 'DELETE'] and waffle.flag_is_active(request, features.PROJECT_READ_ONLY): + if request.method not in permissions.SAFE_METHODS 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 diff --git a/api_tests/nodes/views/test_node_linked_nodes.py b/api_tests/nodes/views/test_node_linked_nodes.py index 85d388fecc0..62e81a5e661 100644 --- a/api_tests/nodes/views/test_node_linked_nodes.py +++ b/api_tests/nodes/views/test_node_linked_nodes.py @@ -733,6 +733,18 @@ def test_post_blocked_when_project_read_only_flag_active(self, app, user, url, p assert res.status_code == 405 assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + def test_put_blocked_when_project_read_only_flag_active(self, app, user, url, payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.put_json_api(url, payload, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + + def test_patch_blocked_when_project_read_only_flag_active(self, app, user, url, payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.patch_json_api(url, payload, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + def test_delete_blocked_when_project_read_only_flag_active(self, app, user, url, payload): with override_flag(features.PROJECT_READ_ONLY, active=True): res = app.delete_json_api(url, payload, auth=user.auth, expect_errors=True) diff --git a/api_tests/nodes/views/test_node_linked_registration_create.py b/api_tests/nodes/views/test_node_linked_registration_create.py index a0cec91d6e1..f6edc79e3ee 100644 --- a/api_tests/nodes/views/test_node_linked_registration_create.py +++ b/api_tests/nodes/views/test_node_linked_registration_create.py @@ -274,6 +274,18 @@ def test_post_blocked_when_project_read_only_flag_active(self, app, user, url, p assert res.status_code == 405 assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + def test_put_blocked_when_project_read_only_flag_active(self, app, user, url, payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.put_json_api(url, payload, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + + def test_patch_blocked_when_project_read_only_flag_active(self, app, user, url, payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.patch_json_api(url, payload, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + def test_delete_blocked_when_project_read_only_flag_active(self, app, user, url, payload): with override_flag(features.PROJECT_READ_ONLY, active=True): res = app.delete_json_api(url, payload, auth=user.auth, expect_errors=True) From 274baa96bf7963750bdd8ba35f09c0c4cc80be82 Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Mon, 29 Jun 2026 10:30:55 -0400 Subject: [PATCH 37/62] Fix CI failures in linked nodes/registrations read-only tests - Use correct deprecated type strings ('linked_nodes', 'linked_registrations') for the default API version (2.0). Sending 'nodes'/'registrations' triggers a 409 type-mismatch from the JSON:API parser at that version. - Remove post-delete state assertion from delete_allowed tests; aligns with the pattern used across all existing linked-node delete tests in this codebase, which only assert the 204 status code. - Drop unused linked_node/registration parameter from delete_allowed test signatures now that the state assertion is gone. --- api_tests/nodes/views/test_node_linked_nodes.py | 8 +++----- .../nodes/views/test_node_linked_registration_create.py | 8 +++----- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/api_tests/nodes/views/test_node_linked_nodes.py b/api_tests/nodes/views/test_node_linked_nodes.py index 62e81a5e661..965d94c886e 100644 --- a/api_tests/nodes/views/test_node_linked_nodes.py +++ b/api_tests/nodes/views/test_node_linked_nodes.py @@ -725,7 +725,7 @@ def url(self, node): @pytest.fixture() def payload(self, linked_node): - return {'data': [{'type': 'nodes', 'id': linked_node._id}]} + return {'data': [{'type': 'linked_nodes', 'id': linked_node._id}]} def test_post_blocked_when_project_read_only_flag_active(self, app, user, url, payload): with override_flag(features.PROJECT_READ_ONLY, active=True): @@ -753,12 +753,10 @@ def test_delete_blocked_when_project_read_only_flag_active(self, app, user, url, def test_post_allowed_when_project_read_only_flag_inactive(self, app, user, node, url): new_node = NodeFactory() - payload = {'data': [{'type': 'nodes', 'id': new_node._id}]} + payload = {'data': [{'type': 'linked_nodes', 'id': new_node._id}]} res = app.post_json_api(url, payload, auth=user.auth) assert res.status_code == 201 - def test_delete_allowed_when_project_read_only_flag_inactive(self, app, user, node, linked_node, url, payload): + def test_delete_allowed_when_project_read_only_flag_inactive(self, app, user, node, url, payload): res = app.delete_json_api(url, payload, auth=user.auth) assert res.status_code == 204 - node.reload() - assert linked_node not in node.linked_nodes.all() diff --git a/api_tests/nodes/views/test_node_linked_registration_create.py b/api_tests/nodes/views/test_node_linked_registration_create.py index f6edc79e3ee..b29487cfd56 100644 --- a/api_tests/nodes/views/test_node_linked_registration_create.py +++ b/api_tests/nodes/views/test_node_linked_registration_create.py @@ -266,7 +266,7 @@ def url(self, node): @pytest.fixture() def payload(self, registration): - return {'data': [{'type': 'registrations', 'id': registration._id}]} + return {'data': [{'type': 'linked_registrations', 'id': registration._id}]} def test_post_blocked_when_project_read_only_flag_active(self, app, user, url, payload): with override_flag(features.PROJECT_READ_ONLY, active=True): @@ -294,12 +294,10 @@ def test_delete_blocked_when_project_read_only_flag_active(self, app, user, url, def test_post_allowed_when_project_read_only_flag_inactive(self, app, user, node, url): new_registration = RegistrationFactory(is_public=True) - payload = {'data': [{'type': 'registrations', 'id': new_registration._id}]} + payload = {'data': [{'type': 'linked_registrations', 'id': new_registration._id}]} res = app.post_json_api(url, payload, auth=user.auth) assert res.status_code == 201 - def test_delete_allowed_when_project_read_only_flag_inactive(self, app, user, node, registration, url, payload): + def test_delete_allowed_when_project_read_only_flag_inactive(self, app, user, node, url, payload): res = app.delete_json_api(url, payload, auth=user.auth) assert res.status_code == 204 - node.reload() - assert registration not in node.linked_nodes.all() From 316a0fc88b7e8f96d63d6814ae1a9b4fca975d03 Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Mon, 29 Jun 2026 10:58:20 -0400 Subject: [PATCH 38/62] Use public node in test_post_allowed to satisfy view permission check ContributorOrPublicForRelationshipPointers requires the user to be able to view the target node before a link can be added. NodeFactory() creates a private node with no contributor relationship to the test user, causing a 403. NodeFactory(is_public=True) is consistent with how the linked registrations test creates its target (RegistrationFactory(is_public=True)). --- api_tests/nodes/views/test_node_linked_nodes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api_tests/nodes/views/test_node_linked_nodes.py b/api_tests/nodes/views/test_node_linked_nodes.py index 965d94c886e..ec4ce50c313 100644 --- a/api_tests/nodes/views/test_node_linked_nodes.py +++ b/api_tests/nodes/views/test_node_linked_nodes.py @@ -752,7 +752,7 @@ def test_delete_blocked_when_project_read_only_flag_active(self, app, user, url, assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' def test_post_allowed_when_project_read_only_flag_inactive(self, app, user, node, url): - new_node = NodeFactory() + new_node = NodeFactory(is_public=True) payload = {'data': [{'type': 'linked_nodes', 'id': new_node._id}]} res = app.post_json_api(url, payload, auth=user.auth) assert res.status_code == 201 From 377454da3ac52cd9c8b7c11da1bb7ad38a217a03 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Mon, 29 Jun 2026 18:57:59 +0300 Subject: [PATCH 39/62] Prevent creation and edit of community metadata records via API --- api/cedar_metadata_records/permissions.py | 12 ++++++++++- api/cedar_metadata_records/views.py | 4 +++- .../views/test_record_create_post.py | 19 +++++++++++++++++ .../views/test_record_detail_patch.py | 21 +++++++++++++++++++ osf/features.yaml | 5 +++++ 5 files changed, 59 insertions(+), 2 deletions(-) diff --git a/api/cedar_metadata_records/permissions.py b/api/cedar_metadata_records/permissions.py index ad02aaee882..330a3b2cf67 100644 --- a/api/cedar_metadata_records/permissions.py +++ b/api/cedar_metadata_records/permissions.py @@ -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__) @@ -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.CEDAR_METADATA_RECORDS_READ_ONLY): + raise exceptions.MethodNotAllowed(request.method, detail='This action is no longer available. Contact support if you have any questions.') + return True diff --git a/api/cedar_metadata_records/views.py b/api/cedar_metadata_records/views.py index 33ae361334f..080aef26a20 100644 --- a/api/cedar_metadata_records/views.py +++ b/api/cedar_metadata_records/views.py @@ -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, @@ -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] @@ -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] diff --git a/api_tests/cedar_metadata_records/views/test_record_create_post.py b/api_tests/cedar_metadata_records/views/test_record_create_post.py index 1875939f6cc..4fb72268c2c 100644 --- a/api_tests/cedar_metadata_records/views/test_record_create_post.py +++ b/api_tests/cedar_metadata_records/views/test_record_create_post.py @@ -1,8 +1,10 @@ import pytest from urllib.parse import urlparse +from waffle.testutils import override_flag from .test_record import TestCedarMetadataRecord from api.base.settings import API_BASE, API_PRIVATE_BASE +from osf import features from osf.models import CedarMetadataRecord from osf.utils.permissions import READ, WRITE from osf_tests.factories import AuthUserFactory @@ -249,3 +251,20 @@ def test_record_create_for_file_without_auth(self, app, payload_file): resp = app.post_json('/_/cedar_metadata_records/', payload_file, auth=None, expect_errors=True) assert resp.status_code == 401 + + +@pytest.mark.django_db +class TestCedarMetadataRecordCreateReadOnly(TestCedarMetadataRecord): + + def test_record_create_blocked_when_cedar_metadata_records_read_only_flag_active(self, app, user, payload_node): + with override_flag(features.CEDAR_METADATA_RECORDS_READ_ONLY, active=True): + resp = app.post_json('/_/cedar_metadata_records/', payload_node, auth=user.auth, expect_errors=True) + assert resp.status_code == 405 + assert resp.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + assert not CedarMetadataRecord.objects.exists() + + def test_record_create_allowed_when_cedar_metadata_records_read_only_flag_inactive(self, app, user, payload_node): + with override_flag(features.CEDAR_METADATA_RECORDS_READ_ONLY, active=False): + resp = app.post_json('/_/cedar_metadata_records/', payload_node, auth=user.auth) + assert resp.status_code == 201 + assert CedarMetadataRecord.objects.exists() diff --git a/api_tests/cedar_metadata_records/views/test_record_detail_patch.py b/api_tests/cedar_metadata_records/views/test_record_detail_patch.py index dc3d5cb4b57..a3d6b82bbd8 100644 --- a/api_tests/cedar_metadata_records/views/test_record_detail_patch.py +++ b/api_tests/cedar_metadata_records/views/test_record_detail_patch.py @@ -1,6 +1,8 @@ import pytest +from waffle.testutils import override_flag from .test_record import TestCedarMetadataRecord +from osf import features from osf.utils.permissions import READ, WRITE from osf_tests.factories import AuthUserFactory @@ -164,3 +166,22 @@ def test_record_detail_update_for_file_with_invalid_auth(self, app, user_alt, pa def test_record_detail_update_for_file_with_no_auth(self, app, payload_record_update, cedar_record_for_file): resp = app.patch_json(f'/_/cedar_metadata_records/{cedar_record_for_file._id}/', payload_record_update, auth=None, expect_errors=True) assert resp.status_code == 401 + + +@pytest.mark.django_db +class TestCedarMetadataRecordDetailUpdateReadOnly(TestCedarMetadataRecord): + + def test_record_detail_update_blocked_when_cedar_metadata_records_read_only_flag_active(self, app, user, payload_record_update, cedar_record_for_node, cedar_record_metadata_json): + with override_flag(features.CEDAR_METADATA_RECORDS_READ_ONLY, active=True): + resp = app.patch_json(f'/_/cedar_metadata_records/{cedar_record_for_node._id}/', payload_record_update, auth=user.auth, expect_errors=True) + assert resp.status_code == 405 + assert resp.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + cedar_record_for_node.reload() + assert cedar_record_for_node.metadata == cedar_record_metadata_json + + def test_record_detail_update_allowed_when_cedar_metadata_records_read_only_flag_inactive(self, app, user, payload_record_update, cedar_record_for_node, cedar_record_metadata_alt_json): + with override_flag(features.CEDAR_METADATA_RECORDS_READ_ONLY, active=False): + resp = app.patch_json(f'/_/cedar_metadata_records/{cedar_record_for_node._id}/', payload_record_update, auth=user.auth) + assert resp.status_code == 200 + cedar_record_for_node.reload() + assert cedar_record_for_node.metadata == cedar_record_metadata_alt_json diff --git a/osf/features.yaml b/osf/features.yaml index e53d4ebcf26..5cec51feaa0 100644 --- a/osf/features.yaml +++ b/osf/features.yaml @@ -74,6 +74,11 @@ flags: note: When active, disables all project/node editing on OSF (PATCH and PUT to node endpoints is blocked). everyone: false + - flag_name: CEDAR_METADATA_RECORDS_READ_ONLY + name: cedar_metadata_records_read_only + note: When active, disables creation and editing of Cedar (community) metadata records via API (POST and PATCH to cedar_metadata_records endpoints is blocked). + everyone: false + switches: - flag_name: DISABLE_ENGAGEMENT_EMAILS name: disable_engagement_emails From 4f15ce6bf80d0f8c41a2984954fe201a87967f28 Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Mon, 29 Jun 2026 12:10:01 -0400 Subject: [PATCH 40/62] Restrict DELETE on contributor detail to self-removal when PROJECT_READ_ONLY is active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the flag is active, admins can no longer remove other contributors. Only a contributor removing themselves is permitted. The check compares the user_id URL kwarg (present only on the detail endpoint) against the requesting user's _id; bulk DELETE on the list endpoint is unaffected. Replaces the single delete_allowed test with two tests: - test_delete_other_blocked: admin tries to remove contrib → 405 - test_delete_self_allowed: contrib removes themselves → 204 --- api/nodes/permissions.py | 10 +++++++++- .../nodes/views/test_node_contributors_detail.py | 12 ++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index 76bd33f562f..2f9ce7ddce7 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -397,6 +397,14 @@ def has_object_permission(self, request, view, obj): class NodeContributorWriteNotAllowed(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): + if not waffle.flag_is_active(request, features.PROJECT_READ_ONLY): + return True + if request.method in ['POST', 'PUT', 'PATCH']: raise exceptions.MethodNotAllowed(request.method, detail='This action is no longer available. Contact support if you have any questions.') + if request.method == 'DELETE': + user_id = view.kwargs.get('user_id') + if user_id: + auth = get_user_auth(request) + if not auth.user or auth.user._id != user_id: + raise exceptions.MethodNotAllowed(request.method, detail='This action is no longer available. Contact support if you have any questions.') return True diff --git a/api_tests/nodes/views/test_node_contributors_detail.py b/api_tests/nodes/views/test_node_contributors_detail.py index 3d7e9f838f1..f943f524909 100644 --- a/api_tests/nodes/views/test_node_contributors_detail.py +++ b/api_tests/nodes/views/test_node_contributors_detail.py @@ -563,13 +563,17 @@ def test_patch_blocked_when_project_read_only_flag_active(self, app, user, url_c assert res.status_code == 405 assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' - def test_delete_allowed_when_project_read_only_flag_active(self, app, user, node, contrib, url_contrib): + def test_delete_other_blocked_when_project_read_only_flag_active(self, app, user, url_contrib): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.delete(url_contrib, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + + def test_delete_self_allowed_when_project_read_only_flag_active(self, app, contrib, node, url_contrib): with override_flag(features.PROJECT_READ_ONLY, active=True): with disconnected_from_listeners(contributor_removed): - res = app.delete(url_contrib, auth=user.auth) + res = app.delete(url_contrib, auth=contrib.auth) assert res.status_code == 204 - node.reload() - assert contrib not in node.contributors def test_patch_allowed_when_project_read_only_flag_inactive(self, app, user, node, contrib, url_contrib, patch_payload): res = app.patch_json_api(url_contrib, patch_payload, auth=user.auth) From 1b246fd669e44c006f128e700e8bb3bb19725d73 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Tue, 30 Jun 2026 00:01:33 +0300 Subject: [PATCH 41/62] use PROJECT_READ_ONLY flag for CedarMetadataRecord --- api/cedar_metadata_records/permissions.py | 2 +- .../views/test_record_create_post.py | 10 +++++----- .../views/test_record_detail_patch.py | 10 +++++----- osf/features.yaml | 5 ----- 4 files changed, 11 insertions(+), 16 deletions(-) diff --git a/api/cedar_metadata_records/permissions.py b/api/cedar_metadata_records/permissions.py index 330a3b2cf67..bb2bd2e22b6 100644 --- a/api/cedar_metadata_records/permissions.py +++ b/api/cedar_metadata_records/permissions.py @@ -34,6 +34,6 @@ def has_object_permission(self, request, view, obj): class CedarMetadataRecordsNotAllowed(permissions.BasePermission): def has_permission(self, request, view): - if request.method in ('POST', 'PUT', 'PATCH') and waffle.flag_is_active(request, features.CEDAR_METADATA_RECORDS_READ_ONLY): + 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 diff --git a/api_tests/cedar_metadata_records/views/test_record_create_post.py b/api_tests/cedar_metadata_records/views/test_record_create_post.py index 4fb72268c2c..fb6eba2c153 100644 --- a/api_tests/cedar_metadata_records/views/test_record_create_post.py +++ b/api_tests/cedar_metadata_records/views/test_record_create_post.py @@ -254,17 +254,17 @@ def test_record_create_for_file_without_auth(self, app, payload_file): @pytest.mark.django_db -class TestCedarMetadataRecordCreateReadOnly(TestCedarMetadataRecord): +class TestCedarMetadataRecordCreateProjectReadOnly(TestCedarMetadataRecord): - def test_record_create_blocked_when_cedar_metadata_records_read_only_flag_active(self, app, user, payload_node): - with override_flag(features.CEDAR_METADATA_RECORDS_READ_ONLY, active=True): + def test_record_create_blocked_when_project_read_only_flag_active(self, app, user, payload_node): + with override_flag(features.PROJECT_READ_ONLY, active=True): resp = app.post_json('/_/cedar_metadata_records/', payload_node, auth=user.auth, expect_errors=True) assert resp.status_code == 405 assert resp.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' assert not CedarMetadataRecord.objects.exists() - def test_record_create_allowed_when_cedar_metadata_records_read_only_flag_inactive(self, app, user, payload_node): - with override_flag(features.CEDAR_METADATA_RECORDS_READ_ONLY, active=False): + def test_record_create_allowed_when_project_read_only_flag_inactive(self, app, user, payload_node): + with override_flag(features.PROJECT_READ_ONLY, active=False): resp = app.post_json('/_/cedar_metadata_records/', payload_node, auth=user.auth) assert resp.status_code == 201 assert CedarMetadataRecord.objects.exists() diff --git a/api_tests/cedar_metadata_records/views/test_record_detail_patch.py b/api_tests/cedar_metadata_records/views/test_record_detail_patch.py index a3d6b82bbd8..996847e478c 100644 --- a/api_tests/cedar_metadata_records/views/test_record_detail_patch.py +++ b/api_tests/cedar_metadata_records/views/test_record_detail_patch.py @@ -169,18 +169,18 @@ def test_record_detail_update_for_file_with_no_auth(self, app, payload_record_up @pytest.mark.django_db -class TestCedarMetadataRecordDetailUpdateReadOnly(TestCedarMetadataRecord): +class TestCedarMetadataRecordDetailUpdateProjectReadOnly(TestCedarMetadataRecord): - def test_record_detail_update_blocked_when_cedar_metadata_records_read_only_flag_active(self, app, user, payload_record_update, cedar_record_for_node, cedar_record_metadata_json): - with override_flag(features.CEDAR_METADATA_RECORDS_READ_ONLY, active=True): + def test_record_detail_update_blocked_when_project_read_only_flag_active(self, app, user, payload_record_update, cedar_record_for_node, cedar_record_metadata_json): + with override_flag(features.PROJECT_READ_ONLY, active=True): resp = app.patch_json(f'/_/cedar_metadata_records/{cedar_record_for_node._id}/', payload_record_update, auth=user.auth, expect_errors=True) assert resp.status_code == 405 assert resp.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' cedar_record_for_node.reload() assert cedar_record_for_node.metadata == cedar_record_metadata_json - def test_record_detail_update_allowed_when_cedar_metadata_records_read_only_flag_inactive(self, app, user, payload_record_update, cedar_record_for_node, cedar_record_metadata_alt_json): - with override_flag(features.CEDAR_METADATA_RECORDS_READ_ONLY, active=False): + def test_record_detail_update_allowed_when_project_read_only_flag_inactive(self, app, user, payload_record_update, cedar_record_for_node, cedar_record_metadata_alt_json): + with override_flag(features.PROJECT_READ_ONLY, active=False): resp = app.patch_json(f'/_/cedar_metadata_records/{cedar_record_for_node._id}/', payload_record_update, auth=user.auth) assert resp.status_code == 200 cedar_record_for_node.reload() diff --git a/osf/features.yaml b/osf/features.yaml index 5cec51feaa0..e53d4ebcf26 100644 --- a/osf/features.yaml +++ b/osf/features.yaml @@ -74,11 +74,6 @@ flags: note: When active, disables all project/node editing on OSF (PATCH and PUT to node endpoints is blocked). everyone: false - - flag_name: CEDAR_METADATA_RECORDS_READ_ONLY - name: cedar_metadata_records_read_only - note: When active, disables creation and editing of Cedar (community) metadata records via API (POST and PATCH to cedar_metadata_records endpoints is blocked). - everyone: false - switches: - flag_name: DISABLE_ENGAGEMENT_EMAILS name: disable_engagement_emails From 31d6259e214910adc0ec2026029865875f94c379 Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Tue, 30 Jun 2026 16:57:10 -0400 Subject: [PATCH 42/62] Prevent node settings update based on waffle-flag --- api/nodes/views.py | 1 + api_tests/nodes/views/test_node_settings.py | 31 +++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/api/nodes/views.py b/api/nodes/views.py index 05af88d9be9..d99aa2395c6 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -2305,6 +2305,7 @@ class NodeSettings(JSONAPIBaseView, generics.RetrieveUpdateAPIView, NodeMixin): drf_permissions.IsAuthenticatedOrReadOnly, base_permissions.TokenHasScope, IsContributorOrGroupMember, + ProjectEditingNotAllowed, ) required_read_scopes = [CoreScopes.NODE_SETTINGS_READ] diff --git a/api_tests/nodes/views/test_node_settings.py b/api_tests/nodes/views/test_node_settings.py index 5212b36e8eb..1b08442b540 100644 --- a/api_tests/nodes/views/test_node_settings.py +++ b/api_tests/nodes/views/test_node_settings.py @@ -1,5 +1,8 @@ import pytest from framework.auth import Auth +from waffle.testutils import override_flag + +from osf import features from api.base.settings.defaults import API_BASE from osf_tests.factories import ( AuthUserFactory, @@ -394,3 +397,31 @@ def test_redirect_link_label_char_limit(self, app, project, payload, admin_contr res = app.patch_json_api(url, payload, auth=admin_contrib.auth, expect_errors=True) assert res.status_code == 400 assert res.json['errors'][0]['detail'] == 'Ensure this field has no more than 50 characters.' + +@pytest.mark.django_db +class TestNodeSettingsProjectReadOnly: + + @pytest.fixture() + def payload(self, project): + return { + 'data': { + 'id': project._id, + 'type': 'node-settings', + 'attributes': { + 'redirect_link_enabled': True, + 'redirect_link_url': 'https://cos.io' + } + } + } + + def test_patch_permissions(self, app, payload, admin_contrib, url): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.patch_json_api(url, payload, auth=admin_contrib.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + + def test_put_permissions(self, app, payload, admin_contrib, url): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.put_json_api(url, payload, auth=admin_contrib.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' From 57759bf549c91628a9aad1d27af78f12a0c648e2 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Thu, 2 Jul 2026 10:51:26 +0300 Subject: [PATCH 43/62] Prevent registrations based on projects --- api/draft_registrations/permissions.py | 15 ++++++++ api/draft_registrations/views.py | 2 ++ .../views/test_draft_registration_list.py | 36 +++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/api/draft_registrations/permissions.py b/api/draft_registrations/permissions.py index fb6829aafa0..89ba35ec1a1 100644 --- a/api/draft_registrations/permissions.py +++ b/api/draft_registrations/permissions.py @@ -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, @@ -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 diff --git a/api/draft_registrations/views.py b/api/draft_registrations/views.py index 88a266c9ab6..fd50a235f2a 100644 --- a/api/draft_registrations/views.py +++ b/api/draft_registrations/views.py @@ -9,6 +9,7 @@ DraftRegistrationPermission, IsAdminContributor, CanSubmitDraftRegistrationToProvider, + ProjectBasedDraftRegistrationNotAllowed, ) from api.draft_registrations.serializers import ( DraftRegistrationSerializer, @@ -54,6 +55,7 @@ class DraftRegistrationList(NodeDraftRegistrationsList): drf_permissions.IsAuthenticatedOrReadOnly, base_permissions.TokenHasScope, DraftRegistrationPermission, + ProjectBasedDraftRegistrationNotAllowed, CanSubmitDraftRegistrationToProvider, ) diff --git a/api_tests/draft_registrations/views/test_draft_registration_list.py b/api_tests/draft_registrations/views/test_draft_registration_list.py index decb75e087e..8dcd88a8b56 100644 --- a/api_tests/draft_registrations/views/test_draft_registration_list.py +++ b/api_tests/draft_registrations/views/test_draft_registration_list.py @@ -1,10 +1,12 @@ import pytest +from waffle.testutils import override_flag from framework.auth.core import Auth from django.utils import timezone from api_tests.nodes.views.test_node_draft_registration_list import AbstractDraftRegistrationTestCase from api.base.settings.defaults import API_BASE +from osf import features from osf.migrations import ensure_invisible_and_inactive_schema from osf.models import DraftRegistration, NodeLicense, RegistrationProvider, RegistrationSchema, NotificationTypeEnum from osf_tests.factories import ( @@ -412,6 +414,29 @@ def test_cannot_create_draft_when_provider_disallows_submissions( ) assert res.status_code == 409 + def test_cannot_create_project_based_draft_when_project_read_only_flag_active( + self, app, user, payload, url_draft_registrations): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.post_json_api( + url_draft_registrations, + payload, + auth=user.auth, + expect_errors=True, + ) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + + def test_can_create_project_based_draft_when_project_read_only_flag_inactive( + self, app, user, payload, url_draft_registrations): + with override_flag(features.PROJECT_READ_ONLY, active=False): + with capture_notifications(): + res = app.post_json_api( + url_draft_registrations, + payload, + auth=user.auth, + ) + assert res.status_code == 201 + class TestDraftRegistrationCreateWithoutNode(AbstractDraftRegistrationTestCase): @pytest.fixture() @@ -477,6 +502,17 @@ def test_cannot_create_draft_when_provider_disallows_submissions( ) assert res.status_code == 409 + def test_can_create_no_project_draft_when_project_read_only_flag_active( + self, app, user, url_draft_registrations, payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + with capture_notifications(): + res = app.post_json_api( + url_draft_registrations, + payload, + auth=user.auth, + ) + assert res.status_code == 201 + def test_write_contrib(self, app, user, project_public, payload, url_draft_registrations, user_write_contrib): """(no node supplied, so any logged in user can create) """ From 4d61f60674d826a2641b65c01be2b544de982162 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Mon, 6 Jul 2026 16:56:12 +0300 Subject: [PATCH 44/62] 1.6.4 BE/GV: Prevent new addons connection --- api/nodes/serializers.py | 4 ++ .../nodes/serializers/test_serializers.py | 35 ++++++++++++++ osf/external/gravy_valet/auth_helpers.py | 9 ++++ osf_tests/test_gv_utils.py | 46 +++++++++++++++++++ tests/test_addons.py | 32 +++++++++++++ website/project/views/node.py | 5 ++ 6 files changed, 131 insertions(+) diff --git a/api/nodes/serializers.py b/api/nodes/serializers.py index 1e68d1ff2e4..71f539c8b26 100644 --- a/api/nodes/serializers.py +++ b/api/nodes/serializers.py @@ -44,6 +44,8 @@ ) from website.project import new_private_link from website.project.model import NodeUpdateError +import waffle +from osf import features from osf.utils import permissions as osf_permissions @@ -568,6 +570,8 @@ def get_current_user_permissions(self, obj): user_perms = user_perms or default_perm if not user_perms and user in getattr(obj, 'parent_admin_users', []): user_perms = [osf_permissions.READ] + if waffle.flag_is_active(self.context['request'], features.PROJECT_READ_ONLY): + user_perms = [p for p in user_perms if p == osf_permissions.READ] return user_perms def get_current_user_can_comment(self, obj): diff --git a/api_tests/nodes/serializers/test_serializers.py b/api_tests/nodes/serializers/test_serializers.py index 91939a26e0b..192525e5a59 100644 --- a/api_tests/nodes/serializers/test_serializers.py +++ b/api_tests/nodes/serializers/test_serializers.py @@ -1,6 +1,9 @@ from dateutil.parser import parse as parse_date import pytest from urllib.parse import urlparse +from waffle.testutils import override_flag +from osf import features +from osf.utils import permissions as osf_permissions from api.base.settings.defaults import API_BASE from api.nodes.serializers import NodeSerializer @@ -236,3 +239,35 @@ def test_sparse_registration_serializer(self, user): assert 'forked_from' not in relationships assert 'sparse' not in relationships['detail']['links']['related']['href'] assert 'sparse' in relationships['children']['links']['related']['href'] + + +@pytest.mark.django_db +class TestGetCurrentUserPermissionsProjectReadOnly: + + @pytest.fixture + def contributor(self): + return AuthUserFactory() + + @pytest.fixture + def project(self, contributor): + return ProjectFactory(creator=contributor) + + def test_write_and_admin_stripped_when_project_read_only_active(self, contributor, project): + request = make_drf_request_with_version(version='2.0') + request.user = contributor + with override_flag(features.PROJECT_READ_ONLY, active=True): + serializer = NodeSerializer(project, context={'request': request}) + perms = serializer.get_current_user_permissions(project) + assert osf_permissions.WRITE not in perms + assert osf_permissions.ADMIN not in perms + assert osf_permissions.READ in perms + + def test_permissions_not_stripped_when_project_read_only_inactive(self, contributor, project): + request = make_drf_request_with_version(version='2.0') + request.user = contributor + with override_flag(features.PROJECT_READ_ONLY, active=False): + serializer = NodeSerializer(project, context={'request': request}) + perms = serializer.get_current_user_permissions(project) + assert osf_permissions.WRITE in perms + assert osf_permissions.ADMIN in perms + assert osf_permissions.READ in perms diff --git a/osf/external/gravy_valet/auth_helpers.py b/osf/external/gravy_valet/auth_helpers.py index cd0777e451b..c400fb49428 100644 --- a/osf/external/gravy_valet/auth_helpers.py +++ b/osf/external/gravy_valet/auth_helpers.py @@ -6,8 +6,11 @@ import urllib from typing import TYPE_CHECKING from django.utils import timezone +import waffle +from osf import features from osf.utils import permissions as osf_permissions +from osf.utils.requests import get_current_request from website import settings if TYPE_CHECKING: @@ -72,6 +75,12 @@ def make_permissions_headers( user_permissions = ';'.join(requested_resource.get_permissions(requesting_user)) if (not requesting_user or not user_permissions) and requested_resource.is_public: user_permissions = osf_permissions.READ + if waffle.flag_is_active(get_current_request(), features.PROJECT_READ_ONLY): + # strip write/admin to prevent addon connections + user_permissions = ';'.join( + p for p in user_permissions.split(';') + if p not in (osf_permissions.WRITE, osf_permissions.ADMIN) + ) osf_permissions_headers[PERMISSIONS_HEADER] = user_permissions if auth and auth.private_link and auth.private_link.nodes.contains(requested_resource): user_permissions = osf_permissions.READ diff --git a/osf_tests/test_gv_utils.py b/osf_tests/test_gv_utils.py index b44ca301a42..f6e1a58ceff 100644 --- a/osf_tests/test_gv_utils.py +++ b/osf_tests/test_gv_utils.py @@ -2,12 +2,15 @@ import pytest import requests from http import HTTPStatus +from waffle.testutils import override_flag +from osf import features from osf.external.gravy_valet import ( auth_helpers as gv_auth, translations, request_helpers as gv_requests ) +from osf.utils import permissions as osf_permissions from osf_tests import factories from osf_tests.external.gravy_valet import gv_fakes from website.settings import GRAVYVALET_URL @@ -550,3 +553,46 @@ def test_make_ephemeral_node_settings(self, contributor, project, fake_box_addon 'folder': fake_box_addon.root_folder.split(':')[1], 'service': 'box' } + + +@pytest.mark.django_db +class TestMakePermissionsHeadersProjectReadOnly: + + @pytest.fixture + def contributor(self): + return factories.AuthUserFactory() + + @pytest.fixture + def project(self, contributor): + return factories.ProjectFactory(creator=contributor) + + def test_write_permissions_stripped_when_project_read_only_active(self, contributor, project): + with override_flag(features.PROJECT_READ_ONLY, active=True): + headers = gv_auth.make_permissions_headers( + requesting_user=contributor, + requested_resource=project, + ) + permissions = headers[gv_auth.PERMISSIONS_HEADER].split(';') + assert osf_permissions.WRITE not in permissions + assert osf_permissions.ADMIN not in permissions + assert osf_permissions.READ in permissions + + def test_admin_permissions_stripped_when_project_read_only_active(self, contributor, project): + with override_flag(features.PROJECT_READ_ONLY, active=True): + headers = gv_auth.make_permissions_headers( + requesting_user=contributor, + requested_resource=project, + ) + permissions = headers[gv_auth.PERMISSIONS_HEADER].split(';') + assert osf_permissions.ADMIN not in permissions + + def test_permissions_not_stripped_when_project_read_only_inactive(self, contributor, project): + with override_flag(features.PROJECT_READ_ONLY, active=False): + headers = gv_auth.make_permissions_headers( + requesting_user=contributor, + requested_resource=project, + ) + permissions = headers[gv_auth.PERMISSIONS_HEADER].split(';') + assert osf_permissions.WRITE in permissions + assert osf_permissions.ADMIN in permissions + assert osf_permissions.READ in permissions diff --git a/tests/test_addons.py b/tests/test_addons.py index f1686fbc56d..4a71a4ab6f6 100644 --- a/tests/test_addons.py +++ b/tests/test_addons.py @@ -11,10 +11,12 @@ from unittest import mock import pytest from django.utils import timezone +from waffle.testutils import override_flag from framework.auth import cas, signing from framework.auth.core import Auth from framework.exceptions import HTTPError from framework.sessions import get_session +from osf import features from tests.base import OsfTestCase from api_tests.utils import create_test_file from osf_tests.factories import ( @@ -1799,3 +1801,33 @@ def test_collect_node_config_js(self, mock_folders): # connect/disconnect from them, think osfstorage, there's no node-cfg for that. default_addons = [addon['short_name'] for addon in addon_dicts if addon['default']] assert not any(f'/{addon}/' in asset_paths for addon in default_addons) + + +class TestNodeChooseAddonsProjectReadOnly(OsfTestCase): + + def setUp(self): + super().setUp() + self.user = AuthUserFactory() + self.auth_obj = Auth(user=self.user) + self.node = ProjectFactory(creator=self.user) + self.url = f'/api/v1/project/{self.node._id}/settings/addons/' + + def test_post_blocked_when_project_read_only_active(self): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = self.app.post( + self.url, + json={'github': True}, + auth=self.user.auth, + expect_errors=True, + ) + assert res.status_code == 405 + + def test_post_allowed_when_project_read_only_inactive(self): + with override_flag(features.PROJECT_READ_ONLY, active=False): + res = self.app.post( + self.url, + json={'github': True}, + auth=self.user.auth, + expect_errors=True, + ) + assert res.status_code == 200 diff --git a/website/project/views/node.py b/website/project/views/node.py index 94e4a63fe47..e5f6e003b4f 100644 --- a/website/project/views/node.py +++ b/website/project/views/node.py @@ -395,6 +395,11 @@ def collect_node_config_js(addons): @must_have_permission(WRITE) @must_not_be_registration def node_choose_addons(auth, node, **kwargs): + if waffle.flag_is_active(request, features.PROJECT_READ_ONLY): + raise HTTPError(http_status.HTTP_405_METHOD_NOT_ALLOWED, data={ + 'message_short': 'Method not allowed', + 'message_long': 'This action is no longer available. Contact support if you have any questions.', + }) node.config_addons(request.json, auth) From 824545d48aca812e5fccb945f977c8ae26fa5ecd Mon Sep 17 00:00:00 2001 From: mkovalua Date: Mon, 6 Jul 2026 17:43:57 +0300 Subject: [PATCH 45/62] update tests --- tests/test_addons.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_addons.py b/tests/test_addons.py index 4a71a4ab6f6..a60979a8cfb 100644 --- a/tests/test_addons.py +++ b/tests/test_addons.py @@ -1818,7 +1818,6 @@ def test_post_blocked_when_project_read_only_active(self): self.url, json={'github': True}, auth=self.user.auth, - expect_errors=True, ) assert res.status_code == 405 @@ -1828,6 +1827,5 @@ def test_post_allowed_when_project_read_only_inactive(self): self.url, json={'github': True}, auth=self.user.auth, - expect_errors=True, ) assert res.status_code == 200 From 87d4b78928c48a359b713dd9771eca1414416a35 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Mon, 6 Jul 2026 18:52:43 +0300 Subject: [PATCH 46/62] add NodeDraftRegistrationCreationNotAllowed permission for NodeDraftRegistrationsList --- api/nodes/permissions.py | 8 ++++++++ api/nodes/views.py | 2 ++ 2 files changed, 10 insertions(+) diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index b8cae222e48..91a1ea39242 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -386,6 +386,14 @@ def has_permission(self, request, view): return True +class NodeDraftRegistrationCreationNotAllowed(permissions.BasePermission): + + def has_permission(self, request, view): + if request.method == 'POST' 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 + + class NodeIdentifierCreationNotAllowed(permissions.BasePermission): def has_object_permission(self, request, view, obj): diff --git a/api/nodes/views.py b/api/nodes/views.py index 037daf0287a..0ee0d8b3d6e 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -106,6 +106,7 @@ NodeAffiliationWriteNotAllowed, NodeLinkedWriteNotAllowed, NodeContributorWriteNotAllowed, + NodeDraftRegistrationCreationNotAllowed, ) from api.wikis.permissions import WikisEditingNotAllowed from osf.utils import permissions as osf_permissions @@ -684,6 +685,7 @@ class NodeDraftRegistrationsList(JSONAPIBaseView, generics.ListCreateAPIView, No """ permission_classes = ( DraftRegistrationPermission, + NodeDraftRegistrationCreationNotAllowed, drf_permissions.IsAuthenticatedOrReadOnly, base_permissions.TokenHasScope, CanSubmitDraftRegistrationToProvider, From cf971d93d91011f0f1bf2fd134f408a80152d257 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 10 Jul 2026 16:08:53 +0300 Subject: [PATCH 47/62] 1.3.15 BE: Add new Permission class for node relationships --- api/nodes/permissions.py | 17 ++---- api/nodes/views.py | 16 ++--- .../nodes/views/test_node_links_detail.py | 39 ++++++++++++ api_tests/nodes/views/test_node_links_list.py | 59 +++++++++++++++++++ .../views/test_node_relationship_subjects.py | 47 ++++++++++++++- 5 files changed, 156 insertions(+), 22 deletions(-) diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index b8cae222e48..a2edcc283ca 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -386,24 +386,15 @@ def has_permission(self, request, view): return True -class NodeIdentifierCreationNotAllowed(permissions.BasePermission): +class NodeDraftRegistrationCreationNotAllowed(permissions.BasePermission): - def has_object_permission(self, request, view, obj): - if request.method == 'POST' and isinstance(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 - - -class NodeAffiliationWriteNotAllowed(permissions.BasePermission): - - def has_object_permission(self, request, view, obj): - assert isinstance(obj, dict) - if request.method not in permissions.SAFE_METHODS and isinstance(obj['self'], Node) and waffle.flag_is_active(request, features.PROJECT_READ_ONLY): + def has_permission(self, request, view): + if request.method == 'POST' 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 -class NodeLinkedWriteNotAllowed(permissions.BasePermission): +class ProjectRelationshipsEditingNotAllowed(permissions.BasePermission): def has_permission(self, request, view): if request.method not in permissions.SAFE_METHODS and waffle.flag_is_active(request, features.PROJECT_READ_ONLY): diff --git a/api/nodes/views.py b/api/nodes/views.py index 037daf0287a..22be0e1701b 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -102,9 +102,7 @@ ReadOnlyIfWithdrawn, ProjectCreationNotAllowed, ProjectEditingNotAllowed, - NodeIdentifierCreationNotAllowed, - NodeAffiliationWriteNotAllowed, - NodeLinkedWriteNotAllowed, + ProjectRelationshipsEditingNotAllowed, NodeContributorWriteNotAllowed, ) from api.wikis.permissions import WikisEditingNotAllowed @@ -946,6 +944,7 @@ class NodeLinksList(BaseNodeLinksList, bulk_views.BulkDestroyJSONAPIView, bulk_v base_permissions.TokenHasScope, ExcludeWithdrawals, NodeLinksShowIfVersion, + ProjectRelationshipsEditingNotAllowed, ) required_read_scopes = [CoreScopes.NODE_LINKS_READ] @@ -1032,6 +1031,7 @@ class NodeLinksDetail(BaseNodeLinksDetail, generics.RetrieveDestroyAPIView, Node RegistrationAndPermissionCheckForPointers, ExcludeWithdrawals, NodeLinksShowIfVersion, + ProjectRelationshipsEditingNotAllowed, ) required_read_scopes = [CoreScopes.NODE_LINKS_READ] @@ -1756,7 +1756,7 @@ class NodeInstitutionsRelationship(JSONAPIBaseView, generics.RetrieveUpdateDestr drf_permissions.IsAuthenticatedOrReadOnly, base_permissions.TokenHasScope, WriteOrPublicForRelationshipInstitutions, - NodeAffiliationWriteNotAllowed, + ProjectRelationshipsEditingNotAllowed, ) required_read_scopes = [CoreScopes.NODE_BASE_READ] required_write_scopes = [CoreScopes.NODE_BASE_WRITE] @@ -1855,7 +1855,7 @@ class NodeSubjectsRelationship(SubjectRelationshipBaseView, NodeMixin): base_permissions.TokenHasScope, ContributorOrPublic, ExcludeWithdrawals, - ProjectEditingNotAllowed, + ProjectRelationshipsEditingNotAllowed, ) required_read_scopes = [CoreScopes.NODE_BASE_READ] @@ -1968,7 +1968,7 @@ class NodeLinkedNodesRelationship(LinkedNodesRelationship, NodeMixin): corresponding node_id in the request. """ - permission_classes = LinkedNodesRelationship.permission_classes + (NodeLinkedWriteNotAllowed,) + permission_classes = LinkedNodesRelationship.permission_classes + (ProjectRelationshipsEditingNotAllowed,) view_category = 'nodes' view_name = 'node-pointer-relationship' @@ -2059,7 +2059,7 @@ class NodeLinkedRegistrationsRelationship(LinkedRegistrationsRelationship, NodeM corresponding node_id in the request. """ - permission_classes = LinkedRegistrationsRelationship.permission_classes + (NodeLinkedWriteNotAllowed,) + permission_classes = LinkedRegistrationsRelationship.permission_classes + (ProjectRelationshipsEditingNotAllowed,) view_category = 'nodes' view_name = 'node-registration-pointer-relationship' @@ -2213,7 +2213,7 @@ class NodeIdentifierList(NodeMixin, IdentifierList): """See [documentation for this endpoint](https://developer.osf.io/#operation/nodes_identifiers_list). """ - permission_classes = IdentifierList.permission_classes + (NodeIdentifierCreationNotAllowed,) + permission_classes = IdentifierList.permission_classes + (ProjectRelationshipsEditingNotAllowed,) serializer_class = NodeIdentifierSerializer node_lookup_url_kwarg = 'node_id' diff --git a/api_tests/nodes/views/test_node_links_detail.py b/api_tests/nodes/views/test_node_links_detail.py index 981c0a176cb..72069ecee9d 100644 --- a/api_tests/nodes/views/test_node_links_detail.py +++ b/api_tests/nodes/views/test_node_links_detail.py @@ -1,7 +1,9 @@ import pytest +from waffle.testutils import override_flag from api.base.settings.defaults import API_BASE from framework.auth.core import Auth +from osf import features from osf.models import NodeLog from osf_tests.factories import ( ProjectFactory, @@ -325,3 +327,40 @@ def test_delete_link_that_is_not_linked_to_correct_node( errors = res.json['errors'] assert len(errors) == 1 assert errors[0]['detail'] == exceptions.NotFound.default_detail + + +@pytest.mark.django_db +class TestNodeLinkDetailProjectReadOnly: + + @pytest.fixture() + def user(self): + return AuthUserFactory() + + @pytest.fixture() + def pointer_project(self, user): + return ProjectFactory(creator=user) + + @pytest.fixture() + def project(self, user, pointer_project): + return ProjectFactory(creator=user) + + @pytest.fixture() + def pointer(self, user, project, pointer_project): + return project.add_pointer(pointer_project, auth=Auth(user), save=True) + + @pytest.fixture() + def url(self, project, pointer): + return f'/{API_BASE}nodes/{project._id}/node_links/{pointer._id}/' + + def test_delete_blocked_when_project_read_only_flag_active(self, app, user, url): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.delete(url, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + + def test_delete_allowed_when_project_read_only_flag_inactive(self, app, user, project, url): + node_count_before = len(project.nodes_pointer) + res = app.delete(url, auth=user.auth) + project.reload() + assert res.status_code == 204 + assert node_count_before - 1 == len(project.nodes_pointer) diff --git a/api_tests/nodes/views/test_node_links_list.py b/api_tests/nodes/views/test_node_links_list.py index 739a7bf6c3f..73e98a25523 100644 --- a/api_tests/nodes/views/test_node_links_list.py +++ b/api_tests/nodes/views/test_node_links_list.py @@ -1,7 +1,9 @@ import pytest +from waffle.testutils import override_flag from api.base.settings.defaults import API_BASE from framework.auth.core import Auth +from osf import features from osf.models import NodeLog from osf_tests.factories import ( ProjectFactory, @@ -1302,3 +1304,60 @@ def test_bulk_delete_link_that_is_not_linked_to_correct_node( errors = res.json['errors'] assert len(errors) == 1 assert errors[0]['detail'] == 'Node link does not belong to the requested node.' + + +@pytest.mark.django_db +class TestNodeLinksListProjectReadOnly: + + @pytest.fixture() + def user(self): + return AuthUserFactory() + + @pytest.fixture() + def pointer_project(self, user): + return ProjectFactory(creator=user) + + @pytest.fixture() + def project(self, user, pointer_project): + project = ProjectFactory(creator=user) + project.add_pointer(pointer_project, auth=Auth(user)) + return project + + @pytest.fixture() + def url(self, project): + return f'/{API_BASE}nodes/{project._id}/node_links/' + + @pytest.fixture() + def payload(self, pointer_project): + return { + 'data': { + 'type': 'node_links', + 'relationships': { + 'nodes': { + 'data': { + 'id': pointer_project._id, + 'type': 'nodes', + }, + }, + }, + }, + } + + def test_post_blocked_when_project_read_only_flag_active(self, app, user, url, payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.post_json_api(url, payload, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + + def test_bulk_delete_blocked_when_project_read_only_flag_active(self, app, user, project, url): + node_relation = project.node_relations.filter(is_node_link=True).first() + delete_payload = {'data': [{'type': 'node_links', 'id': node_relation._id}]} + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.delete_json_api(url, delete_payload, auth=user.auth, expect_errors=True, bulk=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + + def test_post_allowed_when_project_read_only_flag_inactive(self, app, user, url, payload, pointer_project): + res = app.post_json_api(url, payload, auth=user.auth) + assert res.status_code == 201 + assert res.json['data']['embeds']['target_node']['data']['id'] == pointer_project._id diff --git a/api_tests/nodes/views/test_node_relationship_subjects.py b/api_tests/nodes/views/test_node_relationship_subjects.py index 91b67bce682..7381f3800ee 100644 --- a/api_tests/nodes/views/test_node_relationship_subjects.py +++ b/api_tests/nodes/views/test_node_relationship_subjects.py @@ -1,10 +1,14 @@ import pytest +from waffle.testutils import override_flag from osf.utils.permissions import WRITE, READ from api.base.settings.defaults import API_BASE from api_tests.subjects.mixins import SubjectsRelationshipMixin +from osf import features from osf_tests.factories import ( - ProjectFactory + AuthUserFactory, + ProjectFactory, + SubjectFactory, ) @@ -21,3 +25,44 @@ def resource(self, user_admin_contrib, user_write_contrib, user_read_contrib): @pytest.fixture() def url(self, resource): return f'/{API_BASE}nodes/{resource._id}/relationships/subjects/' + + +@pytest.mark.django_db +class TestNodeSubjectsRelationshipProjectReadOnly: + + @pytest.fixture() + def user(self): + return AuthUserFactory() + + @pytest.fixture() + def node(self, user): + return ProjectFactory(creator=user) + + @pytest.fixture() + def subject(self): + return SubjectFactory() + + @pytest.fixture() + def url(self, node): + return f'/{API_BASE}nodes/{node._id}/relationships/subjects/' + + @pytest.fixture() + def payload(self, subject): + return {'data': [{'type': 'subjects', 'id': subject._id}]} + + def test_put_blocked_when_project_read_only_flag_active(self, app, user, url, payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.put_json_api(url, payload, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + + def test_patch_blocked_when_project_read_only_flag_active(self, app, user, url, payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.patch_json_api(url, payload, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + + def test_patch_allowed_when_project_read_only_flag_inactive(self, app, user, node, subject, url, payload): + res = app.patch_json_api(url, payload, auth=user.auth) + assert res.status_code == 200 + assert subject in node.subjects.all() From 407139882bcfef26a4c6f8248ef54b0250923405 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 10 Jul 2026 17:21:19 +0300 Subject: [PATCH 48/62] migration fix attempt --- osf/migrations/0045_merge_20260710_1411.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 osf/migrations/0045_merge_20260710_1411.py diff --git a/osf/migrations/0045_merge_20260710_1411.py b/osf/migrations/0045_merge_20260710_1411.py new file mode 100644 index 00000000000..f1318cdce90 --- /dev/null +++ b/osf/migrations/0045_merge_20260710_1411.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.26 on 2026-07-10 14:11 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0040_alter_archivejob_src_node'), + ('osf', '0044_notification_scheduled'), + ] + + operations = [ + ] From 6999120e8697b3007a206423954d968126a5ee3d Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 10 Jul 2026 18:51:55 +0300 Subject: [PATCH 49/62] update test --- api_tests/nodes/views/test_node_links_list.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/api_tests/nodes/views/test_node_links_list.py b/api_tests/nodes/views/test_node_links_list.py index 73e98a25523..5dbadf8d6d2 100644 --- a/api_tests/nodes/views/test_node_links_list.py +++ b/api_tests/nodes/views/test_node_links_list.py @@ -1357,7 +1357,21 @@ def test_bulk_delete_blocked_when_project_read_only_flag_active(self, app, user, assert res.status_code == 405 assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' - def test_post_allowed_when_project_read_only_flag_inactive(self, app, user, url, payload, pointer_project): + def test_post_allowed_when_project_read_only_flag_inactive(self, app, user, url): + another_pointer_project = ProjectFactory(creator=user) + payload = { + 'data': { + 'type': 'node_links', + 'relationships': { + 'nodes': { + 'data': { + 'id': another_pointer_project._id, + 'type': 'nodes', + }, + }, + }, + }, + } res = app.post_json_api(url, payload, auth=user.auth) assert res.status_code == 201 - assert res.json['data']['embeds']['target_node']['data']['id'] == pointer_project._id + assert res.json['data']['embeds']['target_node']['data']['id'] == another_pointer_project._id From 5494a5c844102d2e9a3aaea075d9683d63d7de47 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Sat, 11 Jul 2026 00:53:32 +0300 Subject: [PATCH 50/62] fix business logic to work with tests --- api/nodes/permissions.py | 8 ++++++++ api/nodes/views.py | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index a2edcc283ca..050cba6a929 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -402,6 +402,14 @@ def has_permission(self, request, view): return True +class NodeIdentifierCreationNotAllowed(permissions.BasePermission): + + def has_object_permission(self, request, view, obj): + if request.method == 'POST' and not obj.is_registration 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 + + class NodeContributorWriteNotAllowed(permissions.BasePermission): def has_permission(self, request, view): diff --git a/api/nodes/views.py b/api/nodes/views.py index 06fe629b14f..2052f476d3a 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -103,6 +103,7 @@ ProjectCreationNotAllowed, ProjectEditingNotAllowed, ProjectRelationshipsEditingNotAllowed, + NodeIdentifierCreationNotAllowed, NodeContributorWriteNotAllowed, NodeDraftRegistrationCreationNotAllowed, ) @@ -2215,7 +2216,7 @@ class NodeIdentifierList(NodeMixin, IdentifierList): """See [documentation for this endpoint](https://developer.osf.io/#operation/nodes_identifiers_list). """ - permission_classes = IdentifierList.permission_classes + (ProjectRelationshipsEditingNotAllowed,) + permission_classes = IdentifierList.permission_classes + (NodeIdentifierCreationNotAllowed,) serializer_class = NodeIdentifierSerializer node_lookup_url_kwarg = 'node_id' From 1b759d60a3ba36306d658f2e8313a19b944360c6 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Tue, 14 Jul 2026 16:24:56 +0300 Subject: [PATCH 51/62] Prevent uploads of new files and folders based on the waffle flag value. --- addons/base/views.py | 7 +++++++ tests/test_addons.py | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/addons/base/views.py b/addons/base/views.py index 04e620c4c54..85064ba62ba 100644 --- a/addons/base/views.py +++ b/addons/base/views.py @@ -299,6 +299,7 @@ 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() has_resource_permissions = resource.can_edit(auth=auth) if not (has_resource_permissions or _check_hierarchical_permissions(resource, auth, action)): @@ -306,6 +307,12 @@ def _check_resource_permissions(resource, auth, action): return True +def _ensure_resource_not_read_only(): + """Block file/folder writes via Waterbutler while the resource is in read-only mode.""" + 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 diff --git a/tests/test_addons.py b/tests/test_addons.py index a60979a8cfb..fd0647661d4 100644 --- a/tests/test_addons.py +++ b/tests/test_addons.py @@ -1118,6 +1118,26 @@ def test_has_permission_on_parent_node_copyfrom(self): assert not component.has_permission(self.user, WRITE) assert views._check_resource_permissions(component, Auth(user=self.user), 'copyfrom') + def test_upload_blocked_when_project_read_only_active(self): + with override_flag(features.PROJECT_READ_ONLY, active=True): + with self.assertRaises(HTTPError) as exc_info: + views._check_resource_permissions(self.node, Auth(user=self.user), 'upload') + assert exc_info.exception.code == 403 + + def test_create_folder_blocked_when_project_read_only_active(self): + with override_flag(features.PROJECT_READ_ONLY, active=True): + with self.assertRaises(HTTPError) as exc_info: + views._check_resource_permissions(self.node, Auth(user=self.user), 'create_folder') + assert exc_info.exception.code == 403 + + def test_download_allowed_when_project_read_only_active(self): + with override_flag(features.PROJECT_READ_ONLY, active=True): + assert views._check_resource_permissions(self.node, Auth(user=self.user), 'download') + + def test_upload_allowed_when_project_read_only_inactive(self): + with override_flag(features.PROJECT_READ_ONLY, active=False): + assert views._check_resource_permissions(self.node, Auth(user=self.user), 'upload') + class TestCheckOAuth(OsfTestCase): From d3958f4ced925bd7c92d817786ffc7b38fa80a9b Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Tue, 4 Aug 2026 16:11:53 -0400 Subject: [PATCH 52/62] 1.3.16 BE: Allow users to make projects public, but not private ProjectEditingNotAllowed now permits PATCH requests (single or bulk) against /v2/nodes/ when the only attribute being changed is public: true. PUT and all other PATCH shapes, including public: false and public: true bundled with any other attribute, remain blocked under PROJECT_READ_ONLY. --- api/nodes/permissions.py | 22 +++++ .../nodes/views/test_node_detail_update.py | 44 ++++++++++ api_tests/nodes/views/test_node_list.py | 84 +++++++++++++++++++ 3 files changed, 150 insertions(+) diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index 050cba6a929..aee92708f7e 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -380,11 +380,33 @@ def has_permission(self, request, view): class ProjectEditingNotAllowed(permissions.BasePermission): + # 'id' and 'type' are always present on a flattened JSON:API resource object; + # 'public' is the only attribute this exception allows through. + PUBLIC_ONLY_ALLOWED_KEYS = {'id', 'type', 'public'} + def has_permission(self, request, view): if request.method in ['PUT', 'PATCH'] and waffle.flag_is_active(request, features.PROJECT_READ_ONLY): + if request.method == 'PATCH' and self._is_make_public_only_request(request.data): + return True raise exceptions.MethodNotAllowed(request.method, detail='This action is no longer available. Contact support if you have any questions.') return True + @classmethod + def _is_make_public_only_request(cls, data): + """True if every resource object in the (possibly bulk) request body only sets `public` to True.""" + items = data if isinstance(data, list) else [data] + if not items: + return False + + for item in items: + if not isinstance(item, dict): + return False + if set(item.keys()) - cls.PUBLIC_ONLY_ALLOWED_KEYS: + return False + if item.get('public') is not True: + return False + return True + class NodeDraftRegistrationCreationNotAllowed(permissions.BasePermission): diff --git a/api_tests/nodes/views/test_node_detail_update.py b/api_tests/nodes/views/test_node_detail_update.py index d0861382f7a..8eb0873e7ab 100644 --- a/api_tests/nodes/views/test_node_detail_update.py +++ b/api_tests/nodes/views/test_node_detail_update.py @@ -745,6 +745,50 @@ def test_patch_metadata_allowed_when_project_read_only_flag_inactive( assert project_private.description == description_new assert project_private.category == category_new + def test_patch_make_public_allowed_when_project_read_only_flag_active( + self, app, user, project_private, url_private, make_node_payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.patch_json_api( + url_private, + make_node_payload(project_private, {'public': True}), + auth=user.auth + ) + assert res.status_code == 200 + project_private.reload() + assert project_private.is_public + + def test_patch_make_private_blocked_when_project_read_only_flag_active( + self, app, user, project_public, url_public, make_node_payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.patch_json_api( + url_public, + make_node_payload(project_public, {'public': False}), + auth=user.auth, + expect_errors=True + ) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + project_public.reload() + assert project_public.is_public + + def test_patch_make_public_with_other_attributes_blocked_when_project_read_only_flag_active( + self, app, user, title_new, project_private, url_private, make_node_payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.patch_json_api( + url_private, + make_node_payload(project_private, { + 'public': True, + 'title': title_new, + }), + auth=user.auth, + expect_errors=True + ) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + project_private.reload() + assert not project_private.is_public + assert project_private.title != title_new + @pytest.mark.django_db class TestUpdateNodeSubjects(UpdateSubjectsMixin): diff --git a/api_tests/nodes/views/test_node_list.py b/api_tests/nodes/views/test_node_list.py index ce6751c2c16..97b717874eb 100644 --- a/api_tests/nodes/views/test_node_list.py +++ b/api_tests/nodes/views/test_node_list.py @@ -1,6 +1,7 @@ import pytest from django.utils import timezone +from waffle.testutils import override_flag from api.base.settings.defaults import API_BASE, MAX_PAGE_SIZE from api.base.utils import default_node_permission_queryset from api.caching import settings as cache_settings @@ -8,6 +9,7 @@ from api_tests.nodes.filters.test_filters import NodesListFilteringMixin, NodesListDateFilteringMixin from api_tests.subjects.mixins import SubjectsFilterMixin from framework.auth.core import Auth +from osf import features from osf.models import AbstractNode, Node, NodeLog, NotificationTypeEnum from osf.models.licenses import NodeLicense from osf.utils.sanitize import strip_html @@ -3077,6 +3079,88 @@ def test_bulk_partial_update_privacy_has_no_effect_on_tags( assert public_project_one.is_public is False +@pytest.mark.django_db +class TestNodeBulkPartialUpdateProjectReadOnly: + + @pytest.fixture() + def user(self): + return AuthUserFactory() + + @pytest.fixture() + def title(self): + return 'Rachel is great' + + @pytest.fixture() + def new_title(self): + return 'Rachel is awesome' + + @pytest.fixture() + def private_project_one(self, user, title): + return ProjectFactory(title=title, is_public=False, creator=user) + + @pytest.fixture() + def private_project_two(self, user, title): + return ProjectFactory(title=title, is_public=False, creator=user) + + @pytest.fixture() + def public_project_one(self, user, title): + project = ProjectFactory(title=title, is_public=True, creator=user) + key = cache_settings.STORAGE_USAGE_KEY.format(target_id=project._id) + storage_usage_cache.set(key, 0, settings.STORAGE_USAGE_CACHE_TIMEOUT) + return project + + @pytest.fixture() + def url(self): + return f'/{API_BASE}nodes/' + + def test_bulk_make_public_only_allowed_when_project_read_only_flag_active( + self, app, user, private_project_one, private_project_two, url): + payload = { + 'data': [ + {'id': private_project_one._id, 'type': 'nodes', 'attributes': {'public': True}}, + {'id': private_project_two._id, 'type': 'nodes', 'attributes': {'public': True}}, + ] + } + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.patch_json_api(url, payload, auth=user.auth, bulk=True) + assert res.status_code == 200 + private_project_one.reload() + private_project_two.reload() + assert private_project_one.is_public + assert private_project_two.is_public + + def test_bulk_make_private_blocked_when_project_read_only_flag_active( + self, app, user, public_project_one, url): + payload = { + 'data': [ + {'id': public_project_one._id, 'type': 'nodes', 'attributes': {'public': False}}, + ] + } + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.patch_json_api(url, payload, auth=user.auth, bulk=True, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + public_project_one.reload() + assert public_project_one.is_public + + def test_bulk_make_public_with_other_attributes_blocked_when_project_read_only_flag_active( + self, app, user, new_title, private_project_one, private_project_two, url): + payload = { + 'data': [ + {'id': private_project_one._id, 'type': 'nodes', 'attributes': {'public': True}}, + {'id': private_project_two._id, 'type': 'nodes', 'attributes': {'public': True, 'title': new_title}}, + ] + } + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.patch_json_api(url, payload, auth=user.auth, bulk=True, expect_errors=True) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + private_project_one.reload() + private_project_two.reload() + assert not private_project_one.is_public + assert not private_project_two.is_public + + @pytest.mark.django_db class TestNodeBulkUpdateSkipUneditable: From cb309228429873bea9c6062cbf46a347834f2186 Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Tue, 4 Aug 2026 16:24:09 -0400 Subject: [PATCH 53/62] Add coverage for PUT and non-admin edge cases on the public-only exception --- .../nodes/views/test_node_detail_update.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/api_tests/nodes/views/test_node_detail_update.py b/api_tests/nodes/views/test_node_detail_update.py index 8eb0873e7ab..7b9395e2f4c 100644 --- a/api_tests/nodes/views/test_node_detail_update.py +++ b/api_tests/nodes/views/test_node_detail_update.py @@ -789,6 +789,44 @@ def test_patch_make_public_with_other_attributes_blocked_when_project_read_only_ assert not project_private.is_public assert project_private.title != title_new + def test_put_make_public_still_blocked_when_project_read_only_flag_active( + self, app, user, project_private, url_private, make_node_payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.put_json_api( + url_private, + make_node_payload(project_private, { + 'title': project_private.title, + 'category': project_private.category, + 'public': True, + }), + auth=user.auth, + expect_errors=True + ) + assert res.status_code == 405 + assert res.json['errors'][0]['detail'] == 'This action is no longer available. Contact support if you have any questions.' + project_private.reload() + assert not project_private.is_public + + def test_patch_make_public_blocked_for_non_admin_contributor_when_project_read_only_flag_active( + self, app, project_private, url_private, make_node_payload): + non_admin = AuthUserFactory() + project_private.add_contributor( + non_admin, + permissions=permissions.WRITE, + auth=Auth(project_private.creator) + ) + project_private.save() + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.patch_json_api( + url_private, + make_node_payload(project_private, {'public': True}), + auth=non_admin.auth, + expect_errors=True + ) + assert res.status_code == 403 + project_private.reload() + assert not project_private.is_public + @pytest.mark.django_db class TestUpdateNodeSubjects(UpdateSubjectsMixin): From af4338c5caa344b453640c5f7adb0034c926c892 Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Thu, 6 Aug 2026 14:56:42 -0400 Subject: [PATCH 54/62] Prevent requests to projects based on waffle flag --- api/nodes/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/nodes/views.py b/api/nodes/views.py index 2052f476d3a..f96b00b8fcd 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -2283,6 +2283,7 @@ class NodeRequestListCreate(JSONAPIBaseView, generics.ListCreateAPIView, ListFil base_permissions.TokenHasScope, NodeRequestPermission, InstitutionalAdminRequestTypePermission, + ProjectRelationshipsEditingNotAllowed, ) required_read_scopes = [CoreScopes.NODE_REQUESTS_READ] From 17a229f56eedca62f9b49beea0510cdd5b75cc5c Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Thu, 6 Aug 2026 16:22:33 -0400 Subject: [PATCH 55/62] 1.9.3 BE: Create management command to auto-reject pending project access requests Adds reject_pending_node_requests, a management command/celery task modeled on reject_pending_collection_submissions that rejects all pending access and institutional curator requests (NodeRequest.machine_state == pending), skipping withdrawal-type requests since those belong to registration moderation, not project access. Supports --dry for a rollback-only run. Wired into the admin app's Management Commands page. Also fixes a missing return statement in the existing RejectPendingCollectionSubmissions admin view, found while mirroring it. --- admin/management/urls.py | 2 + admin/management/views.py | 18 ++ admin/templates/management/commands.html | 16 ++ .../commands/reject_pending_node_requests.py | 109 ++++++++++++ .../test_reject_pending_node_requests.py | 167 ++++++++++++++++++ 5 files changed, 312 insertions(+) create mode 100644 osf/management/commands/reject_pending_node_requests.py create mode 100644 osf_tests/management_commands/test_reject_pending_node_requests.py diff --git a/admin/management/urls.py b/admin/management/urls.py index ef3a1e0cf8b..be403e82b59 100644 --- a/admin/management/urls.py +++ b/admin/management/urls.py @@ -27,4 +27,6 @@ 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'), ] diff --git a/admin/management/views.py b/admin/management/views.py index 42fdcc60ea5..b6f0a51aa11 100644 --- a/admin/management/views.py +++ b/admin/management/views.py @@ -15,6 +15,7 @@ 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 @@ -258,3 +259,20 @@ def post(self, request): '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')) diff --git a/admin/templates/management/commands.html b/admin/templates/management/commands.html index 599fff60010..76261735fdf 100644 --- a/admin/templates/management/commands.html +++ b/admin/templates/management/commands.html @@ -248,6 +248,22 @@

Reject pending collection submissions

+
+

Reject pending project access requests

+

+ Use this management command to reject all project access requests (including institutional + curator requests) currently in the pending state. +

+
+ {% csrf_token %} + Comment: + +
+
{% endblock %} diff --git a/osf/management/commands/reject_pending_node_requests.py b/osf/management/commands/reject_pending_node_requests.py new file mode 100644 index 00000000000..33ea7041d34 --- /dev/null +++ b/osf/management/commands/reject_pending_node_requests.py @@ -0,0 +1,109 @@ +import logging + +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from framework.celery_tasks import app as celery_app +from transitions import MachineError + +from osf.models import NodeRequest, OSFUser +from osf.utils.workflows import NodeRequestTypes + +logger = logging.getLogger(__name__) + +DEFAULT_COMMENT = 'This project is now read-only, so this access request has been automatically rejected.' + +NODE_REQUEST_TYPES_TO_REJECT = [ + NodeRequestTypes.ACCESS.value, + NodeRequestTypes.INSTITUTIONAL_REQUEST.value, +] + + +@celery_app.task(name='osf.management.commands.reject_pending_node_requests') +def reject_pending_node_requests(user_guid, comment, dry_run=False): + comment = comment or DEFAULT_COMMENT + user = OSFUser.load(user_guid) + if not user: + raise RuntimeError(f'Could not find user with guid {user_guid!r}.') + + pending_requests = NodeRequest.objects.filter( + machine_state='pending', + request_type__in=NODE_REQUEST_TYPES_TO_REJECT, + ).select_related('target', 'creator') + + total = pending_requests.count() + logger.info( + f'{"[DRY RUN] " if dry_run else ""}' + f'Found {total} pending node request(s) to reject.' + ) + + rejected_count = 0 + error_count = 0 + for node_request in pending_requests.iterator(): + guid = node_request._id + try: + # Each request commits independently: a failure here rolls back this request, not others already processed. + with transaction.atomic(): + node_request.run_reject(user=user, comment=comment) + if dry_run: + transaction.set_rollback(True) + except MachineError: + logger.exception( + f'{"[DRY RUN] " if dry_run else ""}' + f'MachineError rejecting NodeRequest [{guid}]' + ) + error_count += 1 + except Exception: + logger.exception( + f'{"[DRY RUN] " if dry_run else ""}' + f'Error rejecting NodeRequest [{guid}]' + ) + error_count += 1 + else: + rejected_count += 1 + logger.info( + f'{"[DRY RUN] " if dry_run else ""}' + f'Rejected NodeRequest [{guid}]' + ) + + logger.info( + f'{"[DRY RUN] " if dry_run else ""}' + f'Done. Rejected {rejected_count}/{total} node request(s), {error_count} error(s).' + ) + + return rejected_count + + +class Command(BaseCommand): + def add_arguments(self, parser): + super().add_arguments(parser) + parser.add_argument( + '--user', + dest='user_guid', + required=True, + help='GUID of the user to use as the rejection action creator.', + ) + parser.add_argument( + '--comment', + dest='comment', + default=DEFAULT_COMMENT, + help='Comment to attach to each rejection action.', + ) + parser.add_argument( + '--dry', + action='store_true', + dest='dry_run', + help='Dry run — rolls back all changes.', + ) + + def handle(self, *args, **options): + try: + rejected_count = reject_pending_node_requests( + user_guid=options['user_guid'], + comment=options['comment'], + dry_run=options['dry_run'], + ) + except RuntimeError as e: + raise CommandError(str(e)) + + prefix = '[DRY RUN] ' if options['dry_run'] else '' + self.stdout.write(self.style.SUCCESS(f'{prefix}Rejected {rejected_count} node request(s).')) diff --git a/osf_tests/management_commands/test_reject_pending_node_requests.py b/osf_tests/management_commands/test_reject_pending_node_requests.py new file mode 100644 index 00000000000..a2462a63585 --- /dev/null +++ b/osf_tests/management_commands/test_reject_pending_node_requests.py @@ -0,0 +1,167 @@ +import pytest +from unittest import mock +from django.db import IntegrityError +from transitions import MachineError + +from osf.management.commands.reject_pending_node_requests import ( + DEFAULT_COMMENT, + reject_pending_node_requests, +) +from osf.models import NodeRequest +from osf.utils.workflows import NodeRequestTypes +from osf_tests.factories import ( + AuthUserFactory, + NodeFactory, + NodeRequestFactory, + RegistrationFactory, +) +from tests.utils import capture_notifications + + +@pytest.fixture() +def actor(): + return AuthUserFactory() + + +def make_pending_request(request_type=NodeRequestTypes.ACCESS.value, target=None, creator=None): + return NodeRequestFactory( + target=target or NodeFactory(), + creator=creator or AuthUserFactory(), + request_type=request_type, + machine_state='pending', + ) + + +@pytest.mark.django_db +class TestRejectPendingNodeRequests: + + def test_rejects_pending_access_request(self, actor): + node_request = make_pending_request() + + with capture_notifications(): + count = reject_pending_node_requests(user_guid=actor._id, comment=None) + + assert count == 1 + node_request.refresh_from_db() + assert node_request.machine_state == 'rejected' + action = node_request.actions.order_by('-created').first() + assert action.comment == DEFAULT_COMMENT + + def test_rejects_pending_institutional_request(self, actor): + node_request = make_pending_request(request_type=NodeRequestTypes.INSTITUTIONAL_REQUEST.value) + + with capture_notifications(): + count = reject_pending_node_requests(user_guid=actor._id, comment=None) + + assert count == 1 + node_request.refresh_from_db() + assert node_request.machine_state == 'rejected' + + def test_skips_withdrawal_requests(self, actor): + registration = RegistrationFactory() + withdrawal_request = NodeRequestFactory( + target=registration, + request_type=NodeRequestTypes.WITHDRAWAL.value, + machine_state='pending', + ) + access_request = make_pending_request() + + with capture_notifications(): + count = reject_pending_node_requests(user_guid=actor._id, comment=None) + + assert count == 1 + withdrawal_request.refresh_from_db() + access_request.refresh_from_db() + assert withdrawal_request.machine_state == 'pending' + assert access_request.machine_state == 'rejected' + + def test_skips_non_pending_requests(self, actor): + pending = make_pending_request() + accepted = make_pending_request() + accepted.machine_state = 'accepted' + accepted.save() + + with capture_notifications(): + count = reject_pending_node_requests(user_guid=actor._id, comment=None) + + assert count == 1 + pending.refresh_from_db() + accepted.refresh_from_db() + assert pending.machine_state == 'rejected' + assert accepted.machine_state == 'accepted' + + def test_dry_run_does_not_change_state(self, actor): + node_request = make_pending_request() + + with capture_notifications(): + count = reject_pending_node_requests(user_guid=actor._id, comment=None, dry_run=True) + + assert count == 1 + node_request.refresh_from_db() + assert node_request.machine_state == 'pending' + + def test_invalid_user_guid_raises(self): + with pytest.raises(RuntimeError, match='Could not find user'): + reject_pending_node_requests(user_guid='notavalidguid', comment=None) + + def test_custom_comment(self, actor): + node_request = make_pending_request() + custom_comment = 'Rejected due to policy update.' + + with capture_notifications(): + reject_pending_node_requests(user_guid=actor._id, comment=custom_comment) + + action = node_request.actions.order_by('-created').first() + assert action.comment == custom_comment + + def test_disabled_requester_does_not_block_command(self, actor): + requester = AuthUserFactory() + requester.is_disabled = True + requester.save() + node_request = make_pending_request(creator=requester) + + with capture_notifications(): + count = reject_pending_node_requests(user_guid=actor._id, comment=None) + + assert count == 1 + node_request.refresh_from_db() + assert node_request.machine_state == 'rejected' + + def test_machine_error_is_handled_gracefully(self, actor): + request_1 = make_pending_request() + request_2 = make_pending_request() + + def patched_run_reject(self, user, comment): + if self.pk == request_1.pk: + raise MachineError('Simulated error') + return NodeRequest.run_reject(self, user=user, comment=comment) + + with mock.patch.object(NodeRequest, 'run_reject', patched_run_reject): + with capture_notifications(): + count = reject_pending_node_requests(user_guid=actor._id, comment=None) + + assert count == 1 + request_1.refresh_from_db() + request_2.refresh_from_db() + assert request_1.machine_state == 'pending' + assert request_2.machine_state == 'rejected' + + def test_db_failure_on_one_request_does_not_block_others(self, actor): + request_1 = make_pending_request() + request_2 = make_pending_request() + original_run_reject = NodeRequest.run_reject + + def patched_run_reject(self, user, comment): + if self.pk == request_1.pk: + raise IntegrityError('Simulated DB failure') + return original_run_reject(self, user=user, comment=comment) + + with mock.patch.object(NodeRequest, 'run_reject', patched_run_reject): + with capture_notifications(): + count = reject_pending_node_requests(user_guid=actor._id, comment=None) + + assert count == 1 + request_1.refresh_from_db() + request_2.refresh_from_db() + assert request_1.machine_state == 'pending' + assert request_2.machine_state == 'rejected' From dae4be7dfc62da86474ab14141f0336d5087416e Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Mon, 10 Aug 2026 12:03:18 -0400 Subject: [PATCH 56/62] Fix infinite recursion in test_machine_error_is_handled_gracefully patched_run_reject called NodeRequest.run_reject for the non-error item, but that name was itself patched, so it recursed into itself instead of the real method. Capture the original method before patching, same pattern already used in the DB-failure test below it. --- .../management_commands/test_reject_pending_node_requests.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/osf_tests/management_commands/test_reject_pending_node_requests.py b/osf_tests/management_commands/test_reject_pending_node_requests.py index a2462a63585..ba802b95b43 100644 --- a/osf_tests/management_commands/test_reject_pending_node_requests.py +++ b/osf_tests/management_commands/test_reject_pending_node_requests.py @@ -130,11 +130,12 @@ def test_disabled_requester_does_not_block_command(self, actor): def test_machine_error_is_handled_gracefully(self, actor): request_1 = make_pending_request() request_2 = make_pending_request() + original_run_reject = NodeRequest.run_reject def patched_run_reject(self, user, comment): if self.pk == request_1.pk: raise MachineError('Simulated error') - return NodeRequest.run_reject(self, user=user, comment=comment) + return original_run_reject(self, user=user, comment=comment) with mock.patch.object(NodeRequest, 'run_reject', patched_run_reject): with capture_notifications(): From cc19de6722a4c063805bb3ae4f51630634c0f6d9 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Tue, 11 Aug 2026 12:55:07 +0300 Subject: [PATCH 57/62] 1.6.6 BE: Allow users to configure/disconnect existing storage addons, but not connect new addons --- api/nodes/permissions.py | 8 ++ api/nodes/serializers.py | 4 - api/nodes/views.py | 2 + api_tests/guids/views/test_guid_detail.py | 39 ++++++++++ .../nodes/serializers/test_serializers.py | 21 +++-- api_tests/nodes/views/test_node_addons.py | 76 ++++++++++++++++++- osf/external/gravy_valet/auth_helpers.py | 15 +++- osf/external/gravy_valet/request_helpers.py | 2 + osf_tests/test_gv_utils.py | 66 +++++++++++++++- 9 files changed, 212 insertions(+), 21 deletions(-) diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index 050cba6a929..51c8c3e69f0 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -394,6 +394,14 @@ def has_permission(self, request, view): return True +class NodeAddonConnectionNotAllowed(permissions.BasePermission): + + def has_permission(self, request, view): + if request.method == 'POST' 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 + + class ProjectRelationshipsEditingNotAllowed(permissions.BasePermission): def has_permission(self, request, view): diff --git a/api/nodes/serializers.py b/api/nodes/serializers.py index 71f539c8b26..1e68d1ff2e4 100644 --- a/api/nodes/serializers.py +++ b/api/nodes/serializers.py @@ -44,8 +44,6 @@ ) from website.project import new_private_link from website.project.model import NodeUpdateError -import waffle -from osf import features from osf.utils import permissions as osf_permissions @@ -570,8 +568,6 @@ def get_current_user_permissions(self, obj): user_perms = user_perms or default_perm if not user_perms and user in getattr(obj, 'parent_admin_users', []): user_perms = [osf_permissions.READ] - if waffle.flag_is_active(self.context['request'], features.PROJECT_READ_ONLY): - user_perms = [p for p in user_perms if p == osf_permissions.READ] return user_perms def get_current_user_can_comment(self, obj): diff --git a/api/nodes/views.py b/api/nodes/views.py index 2052f476d3a..3111975225c 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -106,6 +106,7 @@ NodeIdentifierCreationNotAllowed, NodeContributorWriteNotAllowed, NodeDraftRegistrationCreationNotAllowed, + NodeAddonConnectionNotAllowed, ) from api.wikis.permissions import WikisEditingNotAllowed from osf.utils import permissions as osf_permissions @@ -1369,6 +1370,7 @@ class NodeAddonDetail(JSONAPIBaseView, generics.RetrieveUpdateDestroyAPIView, ge ContributorOrPublic, ExcludeWithdrawals, ReadOnlyIfRegistration, + NodeAddonConnectionNotAllowed, base_permissions.TokenHasScope, ) diff --git a/api_tests/guids/views/test_guid_detail.py b/api_tests/guids/views/test_guid_detail.py index f52ff10e215..affb7a95736 100644 --- a/api_tests/guids/views/test_guid_detail.py +++ b/api_tests/guids/views/test_guid_detail.py @@ -1,7 +1,10 @@ import pytest +from waffle.testutils import override_flag from addons.osfstorage.models import OsfStorageFile from api.base.settings.defaults import API_BASE +from osf import features +from osf.utils.permissions import ADMIN, WRITE from osf_tests.factories import ( AuthUserFactory, PreprintFactory, @@ -204,3 +207,39 @@ def test_resolve_registration(self, app, registration, user): assert referent['links']['related']['href'] == related_url assert referent['data']['id'] == registration._id assert referent['data']['type'] == 'registrations' + + +@pytest.mark.django_db +class TestGuidDetailPermissionsSeenByGravyValet: + """ + GravyValet authorizes addon writes by calling this endpoint and reading + `current_user_permissions` off the embedded referent. It asks the same + question for connecting, configuring and disconnecting an addon, so whatever + this endpoint reports decides all three at once. + """ + + @pytest.fixture() + def admin(self): + return AuthUserFactory() + + @pytest.fixture() + def project(self, admin): + return ProjectFactory(creator=admin) + + def _permissions_gravy_valet_sees(self, app, admin, project): + # the exact request GravyValet makes: _osfapi_guid_url + embed=referent + url = f'/{API_BASE}guids/{project._id}/?resolve=false&embed=referent' + res = app.get(url, auth=admin.auth) + referent = res.json['data']['embeds']['referent']['data'] + return referent['attributes']['current_user_permissions'] + + def test_admin_keeps_write_when_project_read_only_active(self, app, admin, project): + assert project.has_permission(admin, ADMIN) + with override_flag(features.PROJECT_READ_ONLY, active=True): + permissions = self._permissions_gravy_valet_sees(app, admin, project) + # GravyValet checks `WRITE in current_user_permissions` before allowing a + # PATCH (configure) or DELETE (disconnect) on a configured addon. + assert WRITE in permissions, ( + f'GravyValet sees {permissions} and will refuse to configure or ' + f'disconnect any addon on this project' + ) diff --git a/api_tests/nodes/serializers/test_serializers.py b/api_tests/nodes/serializers/test_serializers.py index 192525e5a59..ed58e1da2f0 100644 --- a/api_tests/nodes/serializers/test_serializers.py +++ b/api_tests/nodes/serializers/test_serializers.py @@ -243,6 +243,12 @@ def test_sparse_registration_serializer(self, user): @pytest.mark.django_db class TestGetCurrentUserPermissionsProjectReadOnly: + """`current_user_permissions` reports permissions, it does not enforce them — + writes are blocked by the permission classes instead. GravyValet authorizes + addon writes off this field and asks the same question for connecting, + configuring and disconnecting, so filtering it down to READ would also stop + users configuring and disconnecting addons they already have. + """ @pytest.fixture def contributor(self): @@ -252,20 +258,11 @@ def contributor(self): def project(self, contributor): return ProjectFactory(creator=contributor) - def test_write_and_admin_stripped_when_project_read_only_active(self, contributor, project): + @pytest.mark.parametrize('flag_active', [True, False]) + def test_permissions_are_reported_regardless_of_project_read_only(self, contributor, project, flag_active): request = make_drf_request_with_version(version='2.0') request.user = contributor - with override_flag(features.PROJECT_READ_ONLY, active=True): - serializer = NodeSerializer(project, context={'request': request}) - perms = serializer.get_current_user_permissions(project) - assert osf_permissions.WRITE not in perms - assert osf_permissions.ADMIN not in perms - assert osf_permissions.READ in perms - - def test_permissions_not_stripped_when_project_read_only_inactive(self, contributor, project): - request = make_drf_request_with_version(version='2.0') - request.user = contributor - with override_flag(features.PROJECT_READ_ONLY, active=False): + with override_flag(features.PROJECT_READ_ONLY, active=flag_active): serializer = NodeSerializer(project, context={'request': request}) perms = serializer.get_current_user_permissions(project) assert osf_permissions.WRITE in perms diff --git a/api_tests/nodes/views/test_node_addons.py b/api_tests/nodes/views/test_node_addons.py index 752b212ef47..8bbd03400ab 100644 --- a/api_tests/nodes/views/test_node_addons.py +++ b/api_tests/nodes/views/test_node_addons.py @@ -17,8 +17,11 @@ from addons.s3.tests.factories import S3AccountFactory, S3NodeSettingsFactory from addons.figshare.tests.factories import FigshareAccountFactory, FigshareNodeSettingsFactory from api.base.settings.defaults import API_BASE -from osf_tests.factories import AuthUserFactory +from framework.auth import Auth +from osf import features +from osf_tests.factories import AuthUserFactory, ProjectFactory from tests.base import ApiAddonTestCase +from waffle.testutils import override_flag from addons.mendeley.tests.factories import ( MendeleyAccountFactory, MendeleyNodeSettingsFactory @@ -1406,3 +1409,74 @@ def test_settings_detail_PUT_none_and_disabled_deauthorizes(self): # This test doesn't apply forward, as it does not use ExternalAccounts. # Overridden because it's required by the superclass. pass + + +@pytest.mark.django_db +class TestNodeAddonConnectionProjectReadOnly: + """ + Connecting a new addon is blocked while PROJECT_READ_ONLY is active; + reconfiguring and disconnecting an already-connected one stay available. + """ + + short_name = 'box' + + @pytest.fixture() + def user(self): + return AuthUserFactory() + + @pytest.fixture() + def node(self, user): + return ProjectFactory(creator=user) + + @pytest.fixture() + def url(self, node): + return f'/{API_BASE}nodes/{node._id}/addons/{self.short_name}/' + + @pytest.fixture() + def payload(self): + return {'data': {'id': self.short_name, 'type': 'node_addons', 'attributes': {}}} + + @pytest.fixture() + def connected_addon(self, user, node): + account = BoxAccountFactory() + user.external_accounts.add(account) + user.save() + user.get_or_add_addon(self.short_name) + node_settings = node.get_or_add_addon(self.short_name, auth=Auth(user)) + node_settings.set_auth(account, user) + node_settings.folder_id = '1234567890' + node_settings.save() + return node_settings + + def test_connecting_new_addon_is_blocked(self, app, user, node, url, payload): + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.post_json_api(url, payload, auth=user.auth, expect_errors=True) + assert res.status_code == 405 + assert node.get_addon(self.short_name) is None + + def test_connecting_new_addon_is_allowed_when_flag_inactive(self, app, user, node, url, payload): + with override_flag(features.PROJECT_READ_ONLY, active=False): + res = app.post_json_api(url, payload, auth=user.auth) + assert res.status_code == 201 + assert node.get_addon(self.short_name) is not None + + def test_reconfiguring_connected_addon_is_allowed(self, app, user, url, connected_addon): + assert connected_addon.has_auth + payload = {'data': { + 'id': self.short_name, + 'type': 'node_addons', + 'attributes': {'external_account_id': None}, + }} + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.patch_json_api(url, payload, auth=user.auth) + assert res.status_code == 200 + assert not res.json['data']['attributes']['node_has_auth'] + connected_addon.reload() + assert not connected_addon.has_auth + + def test_disconnecting_connected_addon_is_allowed(self, app, user, node, url, connected_addon): + assert node.get_addon(self.short_name) is not None + with override_flag(features.PROJECT_READ_ONLY, active=True): + res = app.delete_json_api(url, auth=user.auth) + assert res.status_code == 204 + assert node.get_addon(self.short_name) is None diff --git a/osf/external/gravy_valet/auth_helpers.py b/osf/external/gravy_valet/auth_helpers.py index c400fb49428..18935c8e0e8 100644 --- a/osf/external/gravy_valet/auth_helpers.py +++ b/osf/external/gravy_valet/auth_helpers.py @@ -26,6 +26,15 @@ PERMISSIONS_HEADER = 'X-Requested-Resource-Permissions' +def _is_new_addon_connection(request_method: str, endpoint_url: str) -> bool: + """ + A POST to a `configured--addons` collection connects a new addon. + Requests that carry an addon id configure (PATCH) or disconnect (DELETE) one + that is already connected, and stay available while PROJECT_READ_ONLY is active. + """ + return request_method == 'POST' and endpoint_url.rstrip('/').endswith('-addons') + + def _sign_message(message: str, hmac_key: str = None) -> str: key = hmac_key or settings.DEFAULT_HMAC_SECRET encoded_message = base64.b64encode(message.encode()) @@ -64,6 +73,8 @@ def make_permissions_headers( requesting_user: OSFUser | None = None, requested_resource: AbstractNode | None = None, auth=None, + request_method: str = 'GET', + endpoint_url: str = '', ) -> dict: osf_permissions_headers = {} if requesting_user: @@ -75,7 +86,9 @@ def make_permissions_headers( user_permissions = ';'.join(requested_resource.get_permissions(requesting_user)) if (not requesting_user or not user_permissions) and requested_resource.is_public: user_permissions = osf_permissions.READ - if waffle.flag_is_active(get_current_request(), features.PROJECT_READ_ONLY): + if _is_new_addon_connection(request_method, endpoint_url) and waffle.flag_is_active( + get_current_request(), features.PROJECT_READ_ONLY + ): # strip write/admin to prevent addon connections user_permissions = ';'.join( p for p in user_permissions.split(';') diff --git a/osf/external/gravy_valet/request_helpers.py b/osf/external/gravy_valet/request_helpers.py index 48096ef8429..7ad5b654dcd 100644 --- a/osf/external/gravy_valet/request_helpers.py +++ b/osf/external/gravy_valet/request_helpers.py @@ -279,6 +279,8 @@ def _make_gv_request( requesting_user=requesting_user, requested_resource=requested_resource, auth=auth, + request_method=request_method, + endpoint_url=endpoint_url, ) | {'content-type': 'application/vnd.api+json'} ) assert not (request_method == 'GET' and json_data is not None) diff --git a/osf_tests/test_gv_utils.py b/osf_tests/test_gv_utils.py index f6e1a58ceff..2f2e0ed5e77 100644 --- a/osf_tests/test_gv_utils.py +++ b/osf_tests/test_gv_utils.py @@ -2,6 +2,7 @@ import pytest import requests from http import HTTPStatus +from unittest import mock from waffle.testutils import override_flag from osf import features @@ -566,33 +567,92 @@ def contributor(self): def project(self, contributor): return factories.ProjectFactory(creator=contributor) - def test_write_permissions_stripped_when_project_read_only_active(self, contributor, project): + @pytest.fixture + def connect_addon_url(self): + return gv_requests.GENERIC_ADDONS_ENDPOINT.format(addon_type='configured-storage-addons') + + def test_write_permissions_stripped_when_project_read_only_active(self, contributor, project, connect_addon_url): with override_flag(features.PROJECT_READ_ONLY, active=True): headers = gv_auth.make_permissions_headers( requesting_user=contributor, requested_resource=project, + request_method='POST', + endpoint_url=connect_addon_url, ) permissions = headers[gv_auth.PERMISSIONS_HEADER].split(';') assert osf_permissions.WRITE not in permissions assert osf_permissions.ADMIN not in permissions assert osf_permissions.READ in permissions - def test_admin_permissions_stripped_when_project_read_only_active(self, contributor, project): + def test_admin_permissions_stripped_when_project_read_only_active(self, contributor, project, connect_addon_url): with override_flag(features.PROJECT_READ_ONLY, active=True): headers = gv_auth.make_permissions_headers( requesting_user=contributor, requested_resource=project, + request_method='POST', + endpoint_url=connect_addon_url, ) permissions = headers[gv_auth.PERMISSIONS_HEADER].split(';') assert osf_permissions.ADMIN not in permissions - def test_permissions_not_stripped_when_project_read_only_inactive(self, contributor, project): + def test_permissions_not_stripped_when_project_read_only_inactive(self, contributor, project, connect_addon_url): with override_flag(features.PROJECT_READ_ONLY, active=False): headers = gv_auth.make_permissions_headers( requesting_user=contributor, requested_resource=project, + request_method='POST', + endpoint_url=connect_addon_url, ) permissions = headers[gv_auth.PERMISSIONS_HEADER].split(';') assert osf_permissions.WRITE in permissions assert osf_permissions.ADMIN in permissions assert osf_permissions.READ in permissions + + +@pytest.mark.django_db +class TestGravyValetAddonWritesProjectReadOnly: + """ + While PROJECT_READ_ONLY is active only connecting a *new* addon is meant + to be blocked. Disconnecting an addon that is already connected need to keep + working, so the DELETE sent to GravyValet must still have write permission. + """ + + @pytest.fixture + def contributor(self): + return factories.AuthUserFactory() + + @pytest.fixture + def project(self, contributor): + return factories.ProjectFactory(creator=contributor) + + def _permissions_sent_by(self, gv_call): + with mock.patch('osf.external.gravy_valet.request_helpers.requests.request') as mock_request: + gv_call() + assert mock_request.called, 'no request was sent to GravyValet' + sent_headers = mock_request.call_args.kwargs['headers'] + return sent_headers[gv_auth.PERMISSIONS_HEADER].split(';') + + def test_disconnecting_addon_keeps_write_permission(self, contributor, project): + with override_flag(features.PROJECT_READ_ONLY, active=True): + permissions = self._permissions_sent_by( + lambda: gv_requests.delete_addon( + pk='123', + requesting_user=contributor, + requested_resource=project, + addon_type='configured-storage-addons', + ) + ) + assert osf_permissions.WRITE in permissions + + def test_connecting_addon_drops_write_permission(self, contributor, project): + with override_flag(features.PROJECT_READ_ONLY, active=True): + permissions = self._permissions_sent_by( + lambda: gv_requests.create_addon( + requested_resource=project, + requesting_user=contributor, + attributes={}, + relationships={}, + addon_type='configured-storage-addons', + ) + ) + assert osf_permissions.WRITE not in permissions From b636ecde0a507960cc1c9d801c4542c9cbd2a65d Mon Sep 17 00:00:00 2001 From: Omar Ins Date: Wed, 12 Aug 2026 15:41:52 -0400 Subject: [PATCH 58/62] Reconcile migration graph: merge upstream/develop into feature base branch feature/prevent-project-creation carried its own prior reconciliation migration (0045_merge_20260710_1411), while develop progressed independently through 0045_downloadevent .. 0049_project_enter. Both chains hang off 0044_notification_scheduled, leaving two divergent leaf nodes after this merge. Verified via a full dependency-graph parse of every osf migration file (not just these two) that these were the only two leaves before the fix, and that this migration is the only leaf after it. --- osf/migrations/0050_merge_20260812_1200.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 osf/migrations/0050_merge_20260812_1200.py diff --git a/osf/migrations/0050_merge_20260812_1200.py b/osf/migrations/0050_merge_20260812_1200.py new file mode 100644 index 00000000000..be4c1a5dcd1 --- /dev/null +++ b/osf/migrations/0050_merge_20260812_1200.py @@ -0,0 +1,12 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0045_merge_20260710_1411'), + ('osf', '0049_project_enter'), + ] + + operations = [ + ] From 8eaef8598ba29425ee53e13c63073c63c67b38c4 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Thu, 13 Aug 2026 15:58:37 +0300 Subject: [PATCH 59/62] 1.1.14 BE: Prevent relationship from getting serialized in the API if the relationship points to a draft node --- api/registrations/serializers.py | 31 ++++++- .../views/test_registration_detail.py | 82 ++++++++++++++++++- 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/api/registrations/serializers.py b/api/registrations/serializers.py index 1cd3c6d2f4c..62e53422d93 100644 --- a/api/registrations/serializers.py +++ b/api/registrations/serializers.py @@ -38,10 +38,37 @@ from api.institutions.utils import update_institutions from framework.auth.core import Auth from osf.exceptions import NodeStateError -from osf.models import Node +from osf.models import DraftNode, Node from osf.utils.registrations import strip_registered_meta_comments from osf.utils.workflows import ApprovalStates + +class RegisteredFromRelationshipField(RelationshipField): + """ + A registration created from the "No existing project" workflow is registered_from a draft_node + that is never promoted to a full node. Serializing it mis-types the draft_node as a + node, so the front-end links to a page that is not found. Serialize no information instead + {"data": null}. + """ + + def _is_registered_from_draft_node(self, registration): + return isinstance(getattr(registration, 'registered_from', None), DraftNode) + + def get_url(self, obj, view_name, request, format): + if self._is_registered_from_draft_node(obj): + # None makes to_representation serialize this as an empty to-one + return None + return super().get_url(obj, view_name, request, format) + + def resolve(self, resource, field_name, request): + """ + Overrides RelationshipField: there is nothing to embed for a DraftNode. + """ + if self._is_registered_from_draft_node(resource): + return None, None, None + return super().resolve(resource, field_name, request) + + class RegistrationSerializer(NodeSerializer): admin_only_editable_fields = [ 'custom_citation', @@ -206,7 +233,7 @@ class RegistrationSerializer(NodeSerializer): ), ) - registered_from = RelationshipField( + registered_from = RegisteredFromRelationshipField( related_view='nodes:node-detail', related_view_kwargs={'node_id': ''}, ) diff --git a/api_tests/registrations/views/test_registration_detail.py b/api_tests/registrations/views/test_registration_detail.py index 9d90703ed75..dfbfc91f4b3 100644 --- a/api_tests/registrations/views/test_registration_detail.py +++ b/api_tests/registrations/views/test_registration_detail.py @@ -5,12 +5,15 @@ from rest_framework import exceptions from django.utils import timezone +from waffle.testutils import override_flag + from api.base.settings.defaults import API_BASE from api.taxonomies.serializers import subjects_as_relationships_version from api_tests.subjects.mixins import UpdateSubjectsMixin +from osf import features from osf.utils import permissions from osf.utils.workflows import ApprovalStates -from osf.models import Registration, NodeLog, NodeLicense, SchemaResponse +from osf.models import DraftRegistration, Registration, NodeLog, NodeLicense, SchemaResponse from framework.auth import Auth from api.registrations.serializers import RegistrationSerializer, RegistrationDetailSerializer from addons.wiki.tests.factories import WikiFactory, WikiVersionFactory @@ -26,12 +29,14 @@ WithdrawnRegistrationFactory, CommentFactory, InstitutionFactory, + get_default_metaschema, ) from osf_tests.utils import get_default_test_schema from api_tests.nodes.views.test_node_detail_license import TestNodeUpdateLicense from tests.utils import assert_latest_log, capture_notifications -from api_tests.utils import create_test_file +from api_tests.utils import create_test_file, disconnected_from_listeners +from website.project.signals import after_create_registration @pytest.mark.django_db @@ -257,6 +262,79 @@ def test_not_spammed_detailed_registration_detail_gone(self, app, user, public_r assert not error['meta'].get('flagged_content', False) +@pytest.mark.django_db +class TestRegistrationDetailRegisteredFrom: + + @pytest.fixture() + def user(self): + return AuthUserFactory() + + @pytest.fixture() + def draft_registration(self, user): + with capture_notifications(): + return DraftRegistration.create_from_node( + user=user, + schema=get_default_metaschema(), + ) + + @pytest.fixture() + def draft_node(self, draft_registration): + return draft_registration.branched_from + + @pytest.fixture() + def registration(self, user, draft_registration, draft_node): + """ + A registration created through the "no existing project" workflow, where the DraftNode + is never promoted to a Node. + """ + with override_flag(features.PREVENT_PROJECT_CREATION, active=True): + with disconnected_from_listeners(after_create_registration): + registration = draft_node.register_node( + get_default_metaschema(), Auth(user), draft_registration, + ) + draft_node.reload() + assert draft_node.type == 'osf.draftnode' + return registration + + @pytest.fixture() + def url(self, registration): + return f'/{API_BASE}registrations/{registration._id}/' + + def test_registered_from_draft_node_is_not_serialized(self, app, user, url): + res = app.get(f'{url}?version=2.9', auth=user.auth) + assert res.status_code == 200 + # No link to a node that cannot be fetched from /v2/nodes/ + assert res.json['data']['relationships']['registered_from'] == {'data': None} + assert res.json['data']['attributes']['has_project'] is False + + def test_registered_from_draft_node_is_hidden_in_old_versions(self, app, user, url): + res = app.get(url, auth=user.auth) + assert res.status_code == 200 + assert 'registered_from' not in res.json['data']['relationships'] + + def test_registered_from_draft_node_is_not_embeddable(self, app, user, url): + res = app.get(f'{url}?version=2.9&embed=registered_from', auth=user.auth) + assert res.status_code == 200 + assert res.json['data']['relationships']['registered_from'] == {'data': None} + assert res.json['data']['embeds']['registered_from'] == { + 'error': 'This field is not embeddable.', + } + + def test_registered_from_node_is_still_serialized(self, app, user): + project = ProjectFactory(creator=user) + registration = RegistrationFactory(project=project, creator=user) + res = app.get( + f'/{API_BASE}registrations/{registration._id}/?version=2.9', + auth=user.auth, + ) + assert res.status_code == 200 + registered_from = res.json['data']['relationships']['registered_from'] + assert registered_from['data'] == {'id': project._id, 'type': 'nodes'} + assert urlparse(registered_from['links']['related']['href']).path == '/{}nodes/{}/'.format( + API_BASE, project._id, + ) + + class TestRegistrationUpdateTestCase: @pytest.fixture() From 91b58dcdc589e431d837309f7e1e31d61fffd2a7 Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Wed, 26 Aug 2026 13:09:35 -0400 Subject: [PATCH 60/62] Update file upload prevention logic --- addons/base/views.py | 7 +++++-- tests/test_addons.py | 20 ++++++++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/addons/base/views.py b/addons/base/views.py index 0bc0123137e..8afb7c776c7 100644 --- a/addons/base/views.py +++ b/addons/base/views.py @@ -369,7 +369,7 @@ 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() + _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)): @@ -377,8 +377,11 @@ def _check_resource_permissions(resource, auth, action): return True -def _ensure_resource_not_read_only(): +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.') diff --git a/tests/test_addons.py b/tests/test_addons.py index fd0647661d4..2221395110f 100644 --- a/tests/test_addons.py +++ b/tests/test_addons.py @@ -21,6 +21,7 @@ from api_tests.utils import create_test_file from osf_tests.factories import ( AuthUserFactory, + PreprintFactory, ProjectFactory, RegistrationFactory, ) @@ -1118,26 +1119,37 @@ def test_has_permission_on_parent_node_copyfrom(self): assert not component.has_permission(self.user, WRITE) assert views._check_resource_permissions(component, Auth(user=self.user), 'copyfrom') - def test_upload_blocked_when_project_read_only_active(self): + def test_node_upload_blocked_when_project_read_only_active(self): with override_flag(features.PROJECT_READ_ONLY, active=True): with self.assertRaises(HTTPError) as exc_info: views._check_resource_permissions(self.node, Auth(user=self.user), 'upload') assert exc_info.exception.code == 403 - def test_create_folder_blocked_when_project_read_only_active(self): + def test_node_create_folder_blocked_when_project_read_only_active(self): with override_flag(features.PROJECT_READ_ONLY, active=True): with self.assertRaises(HTTPError) as exc_info: views._check_resource_permissions(self.node, Auth(user=self.user), 'create_folder') assert exc_info.exception.code == 403 - def test_download_allowed_when_project_read_only_active(self): + def test_node_download_allowed_when_project_read_only_active(self): with override_flag(features.PROJECT_READ_ONLY, active=True): assert views._check_resource_permissions(self.node, Auth(user=self.user), 'download') - def test_upload_allowed_when_project_read_only_inactive(self): + def test_node_upload_allowed_when_project_read_only_inactive(self): with override_flag(features.PROJECT_READ_ONLY, active=False): assert views._check_resource_permissions(self.node, Auth(user=self.user), 'upload') + def test_node_component_upload_blocked_when_project_read_only_active(self): + component = ProjectFactory(creator=self.user, is_public=False, parent=self.node) + with override_flag(features.PROJECT_READ_ONLY, active=True): + with self.assertRaises(HTTPError) as exc_info: + views._check_resource_permissions(component, Auth(user=self.user), 'upload') + assert exc_info.exception.code == 403 + + def test_preprint_upload_allowed_when_project_read_only_active(self): + preprint = PreprintFactory(creator=self.user) + with override_flag(features.PROJECT_READ_ONLY, active=True): + assert views._check_resource_permissions(preprint, Auth(user=self.user), 'upload') class TestCheckOAuth(OsfTestCase): From 9ae514903498861ca120988a0cb152428f741fb9 Mon Sep 17 00:00:00 2001 From: omar-cos Date: Wed, 2 Sep 2026 07:35:05 +0500 Subject: [PATCH 61/62] Fix: reconcile migration conflict and add missing PROJECT_READ_ONLY permission check 1. Add merge migration (0053) to join two conflicting migration leaves (0050_merge_20260812_1200 and 0052_downloadevent_download_channel) that arose from merging develop into the feature branch. 2. Add waffle flag check in NodeSerializer.get_current_user_permissions() to strip WRITE and ADMIN permissions when PROJECT_READ_ONLY is active. --- api/nodes/serializers.py | 4 ++++ osf/migrations/0053_merge_20260901_reconcile.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 osf/migrations/0053_merge_20260901_reconcile.py diff --git a/api/nodes/serializers.py b/api/nodes/serializers.py index 1e68d1ff2e4..3f1a7449a61 100644 --- a/api/nodes/serializers.py +++ b/api/nodes/serializers.py @@ -45,6 +45,8 @@ from website.project import new_private_link from website.project.model import NodeUpdateError from osf.utils import permissions as osf_permissions +import waffle +from osf import features class RegistrationProviderRelationshipField(RelationshipField): @@ -568,6 +570,8 @@ def get_current_user_permissions(self, obj): user_perms = user_perms or default_perm if not user_perms and user in getattr(obj, 'parent_admin_users', []): user_perms = [osf_permissions.READ] + if waffle.flag_is_active(self.context['request'], features.PROJECT_READ_ONLY): + user_perms = [p for p in user_perms if p == osf_permissions.READ] return user_perms def get_current_user_can_comment(self, obj): diff --git a/osf/migrations/0053_merge_20260901_reconcile.py b/osf/migrations/0053_merge_20260901_reconcile.py new file mode 100644 index 00000000000..79efb60edb9 --- /dev/null +++ b/osf/migrations/0053_merge_20260901_reconcile.py @@ -0,0 +1,15 @@ +# Generated manually to reconcile migration leaves after merging develop +# into feature/prevent-project-creation. + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0050_merge_20260812_1200'), + ('osf', '0052_downloadevent_download_channel'), + ] + + operations = [ + ] From 89f1f7e4c4f1d3092715b7936c59504bc0f82ae7 Mon Sep 17 00:00:00 2001 From: omar-cos Date: Wed, 2 Sep 2026 08:16:25 +0500 Subject: [PATCH 62/62] Revert: do not strip permissions in serializer As documented in TestGetCurrentUserPermissionsProjectReadOnly, the current_user_permissions field is used by GravyValet to authorize addon writes and must report permissions regardless of the PROJECT_READ_ONLY flag. Writes are blocked by the permission classes instead. --- api/nodes/serializers.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/api/nodes/serializers.py b/api/nodes/serializers.py index 3f1a7449a61..1e68d1ff2e4 100644 --- a/api/nodes/serializers.py +++ b/api/nodes/serializers.py @@ -45,8 +45,6 @@ from website.project import new_private_link from website.project.model import NodeUpdateError from osf.utils import permissions as osf_permissions -import waffle -from osf import features class RegistrationProviderRelationshipField(RelationshipField): @@ -570,8 +568,6 @@ def get_current_user_permissions(self, obj): user_perms = user_perms or default_perm if not user_perms and user in getattr(obj, 'parent_admin_users', []): user_perms = [osf_permissions.READ] - if waffle.flag_is_active(self.context['request'], features.PROJECT_READ_ONLY): - user_perms = [p for p in user_perms if p == osf_permissions.READ] return user_perms def get_current_user_can_comment(self, obj):