Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
32aa1f4
Add disable and restore access buttons for private AWS projects.
Chrystinne May 15, 2026
2ab0e58
Add handlers for disabling and restoring AWS access.
Chrystinne May 15, 2026
5d7960f
Add support for disabling and restoring S3 access points.
Chrystinne May 15, 2026
d2d4c30
Rename 'Delete project files' button to 'Delete files'.
Chrystinne May 15, 2026
8ba9405
Update button colors for AWS cloud management actions.
Chrystinne May 15, 2026
0b5dc0a
Fix Enable AWS access button visibility when access points are disabled.
Chrystinne May 15, 2026
3614341
Add disable and restore S3 access functions.
Chrystinne May 22, 2026
33d9aa4
Add a flag to AWS model to track disabled access.
Chrystinne May 22, 2026
cb313b0
Block users from enabling AWS access when access is disabled for that…
Chrystinne May 22, 2026
6bf0ff8
Hide Enable AWS access button when access is disabled.
Chrystinne May 22, 2026
8fc4f0b
Use access_disabled flag to control disable and restore access buttons.
Chrystinne May 22, 2026
3d5d255
Add tests for disabling and restoring S3 access.
Chrystinne May 22, 2026
895bf65
Allow access_disabled to be null to avoid breaking tests.
Chrystinne May 22, 2026
87838a7
Hide aws credentials configuration when access is disabled.
Chrystinne Jun 4, 2026
00fc01b
Merge branch 'dev' into cf/disable-restore-aws-access
Chrystinne Jun 4, 2026
d6031d4
Merge migrations.
Chrystinne Jun 4, 2026
71874d3
Merge remote-tracking branch 'origin/dev' into cf/disable-restore-aws…
Chrystinne Jun 10, 2026
88f5d5e
Merge remote-tracking branch 'origin/dev' into cf/disable-restore-aws…
Chrystinne Jun 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -448,15 +448,40 @@ <h5 class="card-title mt-3 mb-1">AWS</h5>
{% endif %}
</button>
{% if project.aws.sent_files %}
<button class="btn btn-primary"
<button class="btn btn-danger"
name="aws-delete-project-files" value="{{ project.slug }}"
{% if aws_upload_pending or rw_tasks %}
disabled="disabled"
{% endif %}
type="submit">
<span class="fa fa-trash"></span>
Delete project files
Delete files
</button>
{% if project.aws.is_private %}
{% if not project.aws.access_disabled %}
{% if project.aws.access_points.exists %}
<button class="btn btn-warning"
name="aws-disable-access" value="{{ project.slug }}"
{% if aws_upload_pending or rw_tasks %}
disabled="disabled"
{% endif %}
type="submit">
<span class="fa fa-lock"></span>
Disable access
</button>
{% endif %}
{% else %}
<button class="btn btn-success"
name="aws-restore-access" value="{{ project.slug }}"
{% if aws_upload_pending or rw_tasks %}
disabled="disabled"
{% endif %}
type="submit">
<span class="fa fa-lock-open"></span>
Restore access
</button>
{% endif %}
{% endif %}
{% endif %}
</p>
</form>
Expand Down
17 changes: 17 additions & 0 deletions physionet-django/console/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down Expand Up @@ -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():
Expand Down
72 changes: 72 additions & 0 deletions physionet-django/project/cloud/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
17 changes: 17 additions & 0 deletions physionet-django/project/migrations/0097_aws_access_disabled.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
Original file line number Diff line number Diff line change
@@ -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 = []
1 change: 1 addition & 0 deletions physionet-django/project/modelcomponents/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ()
Expand Down
44 changes: 22 additions & 22 deletions physionet-django/project/templates/project/published_project.html
Original file line number Diff line number Diff line change
Expand Up @@ -340,28 +340,28 @@ <h5>Access the files</h5>
</li>
{% endif %}

{% if has_s3_credentials and project.aws.sent_files %}
{% if s3_uri %}
<li>
Download the files using AWS command line tools:
<pre class="shell-command">aws s3 sync {{ s3_uri }} DESTINATION</pre>
</li>
{% elif show_aws_configuration_link %}
<li>
To download the files using AWS command line tools, first
<a href="{% url 'edit_cloud' %}">configure your AWS credentials</a>.
</li>
{% elif not user_in_access_point_policy %}
<li>
Click to access the files using AWS command line tools:
<form method="post" action="{% url 'enable_aws_access' project.slug project.version %}">
{% csrf_token %}
<button class="btn btn-primary btn-fixed" type="submit">
Enable AWS access
</button>
</form>
</li>
{% endif %}
{% if has_s3_credentials and project.aws.sent_files and not project.aws.access_disabled %}
{% if s3_uri %}
<li>
Download the files using AWS command line tools:
<pre class="shell-command">aws s3 sync {{ s3_uri }} DESTINATION</pre>
</li>
{% elif show_aws_configuration_link %}
<li>
To download the files using AWS command line tools, first
<a href="{% url 'edit_cloud' %}">configure your AWS credentials</a>.
</li>
{% elif not user_in_access_point_policy %}
<li>
Click to access the files using AWS command line tools:
<form method="post" action="{% url 'enable_aws_access' project.slug project.version %}">
{% csrf_token %}
<button class="btn btn-primary btn-fixed" type="submit">
Enable AWS access
</button>
</form>
</li>
{% endif %}
{% endif %}

</ul>
Expand Down
106 changes: 106 additions & 0 deletions physionet-django/project/test_s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
5 changes: 5 additions & 0 deletions physionet-django/project/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 (
Expand Down
Loading