diff --git a/physionet-django/console/templates/console/manage_published_project.html b/physionet-django/console/templates/console/manage_published_project.html index eee35cf0e..a1d1ad0a5 100644 --- a/physionet-django/console/templates/console/manage_published_project.html +++ b/physionet-django/console/templates/console/manage_published_project.html @@ -448,15 +448,40 @@
AWS
{% endif %} {% if project.aws.sent_files %} - + {% if project.aws.is_private %} + {% if not project.aws.access_disabled %} + {% if project.aws.access_points.exists %} + + {% endif %} + {% else %} + + {% endif %} + {% endif %} {% endif %}

diff --git a/physionet-django/console/views.py b/physionet-django/console/views.py index e75ed7580..3fdb11f7d 100644 --- a/physionet-django/console/views.py +++ b/physionet-django/console/views.py @@ -91,6 +91,8 @@ check_s3_bucket_exists, has_s3_credentials, delete_project_files_from_s3, + disable_project_access_in_s3, + restore_project_access_in_s3, create_s3_resource, ) @@ -1228,6 +1230,21 @@ def manage_published_project(request, project_slug, version): # Redirect is required after deletion to avoid rendering the page with # a stale AWS instance that no longer has a primary key in the database. return redirect('manage_published_project', project_slug=project_slug, version=version) + elif 'aws-disable-access' in request.POST and has_s3_credentials(): + if any(get_associated_tasks(project, read_only=False)): + messages.error(request, 'Project has tasks pending.') + else: + disable_project_access_in_s3(project) + messages.success(request, 'Access to project files has been disabled.') + # Redirect required to avoid rendering with stale AWS instance + return redirect('manage_published_project', project_slug=project_slug, version=version) + elif 'aws-restore-access' in request.POST and has_s3_credentials(): + if any(get_associated_tasks(project, read_only=False)): + messages.error(request, 'Project has tasks pending.') + else: + restore_project_access_in_s3(project) + messages.success(request, 'Access to project files has been restored.') + return redirect('manage_published_project', project_slug=project_slug, version=version) elif 'platform' in request.POST: data_access_form = forms.DataAccessForm(project=project, data=request.POST) if data_access_form.is_valid(): diff --git a/physionet-django/project/cloud/s3.py b/physionet-django/project/cloud/s3.py index 545c8bdb2..41cc843e7 100644 --- a/physionet-django/project/cloud/s3.py +++ b/physionet-django/project/cloud/s3.py @@ -1601,3 +1601,75 @@ def delete_project_files_from_s3(project): project.id, verbose_name='Delete S3 files - {}'.format(project) ) + + +def delete_project_access_points(project): + """ + Delete all S3 access points associated with a project and remove + the corresponding AWS records from the database. + + Args: + project (PublishedProject): The project whose access points + will be deleted. + """ + s3control = create_s3_control_client() + + for ap in project.aws.access_points.all(): + s3control.delete_access_point( + AccountId=settings.AWS_ACCOUNT_ID, + Name=ap.name + ) + + # Deletes AWS, AWSAccessPoint, and AWSAccessPointUser + # from our database as well via CASCADE + project.aws.delete() + + +def disable_project_access_in_s3(project): + """ + Temporarily disable access to a private project by deleting its S3 access points + without removing the project files from the bucket. Sets + access_disabled=True to prevent users from re-enabling access. + + For public projects, per-project access cannot be disabled without deleting + the files, since the bucket policy applies to the entire bucket. + + Args: + project (PublishedProject): The project whose access will be disabled. + """ + if not project.aws.is_private: + return + if not check_s3_bucket_exists(project): + return + + s3control = create_s3_control_client() + for ap in project.aws.access_points.all(): + s3control.delete_access_point( + AccountId=settings.AWS_ACCOUNT_ID, + Name=ap.name + ) + # Only delete AWSAccessPoint records, not the AWS instance itself + project.aws.access_points.all().delete() + + # Set explicit flag to prevent users from re-enabling access + project.aws.access_disabled = True + project.aws.save() + + +def restore_project_access_in_s3(project): + """ + Restore access to a private project by recreating its S3 access points + and clearing the access_disabled flag. + + Args: + project (PublishedProject): The project whose access will be restored. + """ + if not project.aws.is_private: + return + if not check_s3_bucket_exists(project): + return + + initialize_access_points(project) + + project.aws.access_disabled = False + project.aws.save() diff --git a/physionet-django/project/migrations/0097_aws_access_disabled.py b/physionet-django/project/migrations/0097_aws_access_disabled.py new file mode 100644 index 000000000..8d6a20104 --- /dev/null +++ b/physionet-django/project/migrations/0097_aws_access_disabled.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.20 on 2026-05-22 19:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("project", "0096_activeproject_checksums_2"), + ] + + operations = [ + migrations.AddField( + model_name="aws", + name="access_disabled", + field=models.BooleanField(default=False, null=True), + ), + ] diff --git a/physionet-django/project/migrations/0099_merge_0097_aws_access_disabled_0098_add_review_models.py b/physionet-django/project/migrations/0099_merge_0097_aws_access_disabled_0098_add_review_models.py new file mode 100644 index 000000000..90f39cbc9 --- /dev/null +++ b/physionet-django/project/migrations/0099_merge_0097_aws_access_disabled_0098_add_review_models.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.20 on 2026-06-04 20:25 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("project", "0097_aws_access_disabled"), + ("project", "0098_add_review_models"), + ] + + operations = [] diff --git a/physionet-django/project/modelcomponents/storage.py b/physionet-django/project/modelcomponents/storage.py index 6391e60a7..1d9c5398e 100644 --- a/physionet-django/project/modelcomponents/storage.py +++ b/physionet-django/project/modelcomponents/storage.py @@ -61,6 +61,7 @@ class AWS(models.Model): sent_files = models.BooleanField(default=False) creation_datetime = models.DateTimeField(auto_now_add=True) finished_datetime = models.DateTimeField(null=True) + access_disabled = models.BooleanField(default=False, null=True) class Meta: default_permissions = () diff --git a/physionet-django/project/templates/project/published_project.html b/physionet-django/project/templates/project/published_project.html index 194ff335c..7305c0539 100644 --- a/physionet-django/project/templates/project/published_project.html +++ b/physionet-django/project/templates/project/published_project.html @@ -340,28 +340,28 @@
Access the files
{% endif %} - {% if has_s3_credentials and project.aws.sent_files %} - {% if s3_uri %} -
  • - Download the files using AWS command line tools: -
    aws s3 sync {{ s3_uri }} DESTINATION
    -
  • - {% elif show_aws_configuration_link %} -
  • - To download the files using AWS command line tools, first - configure your AWS credentials. -
  • - {% elif not user_in_access_point_policy %} -
  • - Click to access the files using AWS command line tools: -
    - {% csrf_token %} - -
    -
  • - {% endif %} + {% if has_s3_credentials and project.aws.sent_files and not project.aws.access_disabled %} + {% if s3_uri %} +
  • + Download the files using AWS command line tools: +
    aws s3 sync {{ s3_uri }} DESTINATION
    +
  • + {% elif show_aws_configuration_link %} +
  • + To download the files using AWS command line tools, first + configure your AWS credentials. +
  • + {% elif not user_in_access_point_policy %} +
  • + Click to access the files using AWS command line tools: +
    + {% csrf_token %} + +
    +
  • + {% endif %} {% endif %} diff --git a/physionet-django/project/test_s3.py b/physionet-django/project/test_s3.py index 0a45cba63..7539382ff 100644 --- a/physionet-django/project/test_s3.py +++ b/physionet-django/project/test_s3.py @@ -20,6 +20,8 @@ get_bucket_name, has_s3_credentials, upload_project_to_S3, + disable_project_access_in_s3, + restore_project_access_in_s3, ) from project.models import ( AWS, @@ -408,3 +410,107 @@ def create_example_users(self, project, count, aws_verified, signed_dua): user.dua_signatures.create(project=project) return users + + def test_disable_project_access(self): + """ + Test that disabling access deletes access points from S3 and + sets access_disabled=True without deleting project files. + """ + create_s3_server_access_log_bucket() + + project = PublishedProject.objects.get(slug='demoeicu', version='2.0.0') + + # Create users and upload project + _ = self.create_example_users( + project=project, count=5, aws_verified=True, signed_dua=True + ) + aws = AWS.objects.create( + project=project, + bucket_name=get_bucket_name(project), + is_private=True, + ) + upload_project_to_S3(project) + aws.sent_files = True + aws.save() + + self.assertGreater(aws.access_points.count(), 0) + self.assertFalse(aws.access_disabled) + + # Disable access + disable_project_access_in_s3(project) + + aws.refresh_from_db() + self.assertEqual(aws.access_points.count(), 0) + self.assertTrue(aws.access_disabled) + + # Files should still exist in S3 + self.assertTrue(check_s3_bucket_exists(project)) + self.assert_project_files_uploaded([project]) + + def test_restore_project_access(self): + """ + Test that restoring access recreates access points and clears access_disabled. + """ + create_s3_server_access_log_bucket() + + project = PublishedProject.objects.get(slug='demoeicu', version='2.0.0') + + users = self.create_example_users( + project=project, count=5, aws_verified=True, signed_dua=True + ) + aws = AWS.objects.create( + project=project, + bucket_name=get_bucket_name(project), + is_private=True, + ) + upload_project_to_S3(project) + aws.sent_files = True + aws.save() + + # Disable then restore + disable_project_access_in_s3(project) + + aws.refresh_from_db() + self.assertTrue(aws.access_disabled) + self.assertEqual(aws.access_points.count(), 0) + + restore_project_access_in_s3(project) + + aws.refresh_from_db() + self.assertFalse(aws.access_disabled) + self.assertGreater(aws.access_points.count(), 0) + self.assert_project_users_authorized(project, users) + + def test_disable_access_blocks_enable_aws_access(self): + """ + Test that a user cannot enable AWS access when access_disabled=True. + """ + create_s3_server_access_log_bucket() + + project = PublishedProject.objects.get(slug='demoeicu', version='2.0.0') + + users = self.create_example_users( + project=project, count=2, aws_verified=True, signed_dua=True + ) + aws = AWS.objects.create( + project=project, + bucket_name=get_bucket_name(project), + is_private=True, + ) + upload_project_to_S3(project) + aws.sent_files = True + aws.save() + + disable_project_access_in_s3(project) + + # User tries to enable AWS access — should be blocked + user = users[0] + self.client.force_login(user) + self.client.post( + reverse('enable_aws_access', args=(project.slug, project.version)) + ) + + aws.refresh_from_db() + # Access points should still be empty + self.assertEqual(aws.access_points.count(), 0) + self.assertTrue(aws.access_disabled) diff --git a/physionet-django/project/views.py b/physionet-django/project/views.py index c1bbbf0ae..501432765 100644 --- a/physionet-django/project/views.py +++ b/physionet-django/project/views.py @@ -2205,6 +2205,7 @@ def sign_dua(request, project_slug, version): hasattr(user, 'cloud_information') and user.cloud_information is not None and user.cloud_information.aws_verification_datetime is not None + and not project.aws.access_disabled ): add_user_to_access_point_policy(project, user) @@ -2381,6 +2382,10 @@ def enable_aws_access(request, project_slug, version): messages.error(request, 'You do not have permission to access this project.') return redirect('published_project', project_slug=project_slug, version=version) + # Block if access has been explicitly disabled by an admin + if hasattr(project, 'aws') and project.aws.access_disabled: + return redirect('published_project', project_slug=project_slug, version=version) + if request.method == 'POST': if has_s3_credentials() and files_sent_to_S3(project): if (