diff --git a/controlpanel/api/admin.py b/controlpanel/api/admin.py index c64022b71..22d6bcf05 100644 --- a/controlpanel/api/admin.py +++ b/controlpanel/api/admin.py @@ -24,8 +24,8 @@ class AppAdmin(admin.ModelAdmin): class S3Admin(admin.ModelAdmin): - list_display = ("name", "created_by", "created", "is_data_warehouse") - list_filter = ("created_by", "is_data_warehouse") + list_display = ("name", "created_by", "created", "is_data_warehouse", "is_deleted") + list_filter = ("created_by", "is_data_warehouse", "is_deleted") search_fields = ("name",) diff --git a/controlpanel/api/migrations/0031_add_soft_delete_fields.py b/controlpanel/api/migrations/0031_add_soft_delete_fields.py new file mode 100644 index 000000000..c66261360 --- /dev/null +++ b/controlpanel/api/migrations/0031_add_soft_delete_fields.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.1 on 2023-10-09 15:33 + +# Third-party +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0030_task'), + ] + + operations = [ + migrations.AddField( + model_name='s3bucket', + name='deleted_at', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='s3bucket', + name='deleted_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_s3buckets', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='s3bucket', + name='is_deleted', + field=models.BooleanField(default=False), + ), + ] diff --git a/controlpanel/api/models/policys3bucket.py b/controlpanel/api/models/policys3bucket.py index 6781f279e..feb7e4299 100644 --- a/controlpanel/api/models/policys3bucket.py +++ b/controlpanel/api/models/policys3bucket.py @@ -39,6 +39,7 @@ def grant_bucket_access(self): ) def revoke_bucket_access(self): + # TODO update to use a Task to revoke access, to match user/app access if self.s3bucket.is_folder: return cluster.RoleGroup(self.policy).revoke_folder_access( root_folder_path=self.s3bucket.name diff --git a/controlpanel/api/models/s3bucket.py b/controlpanel/api/models/s3bucket.py index 90de336f3..9d508b905 100644 --- a/controlpanel/api/models/s3bucket.py +++ b/controlpanel/api/models/s3bucket.py @@ -3,16 +3,19 @@ # Third-party from django.conf import settings +from django.contrib.auth.models import User from django.core.validators import MinLengthValidator from django.db import models from django.db.models import Q from django.db.transaction import atomic +from django.utils import timezone from django_extensions.db.models import TimeStampedModel # First-party/Local from controlpanel.api import cluster, tasks, validators from controlpanel.api.models.apps3bucket import AppS3Bucket from controlpanel.api.models.users3bucket import UserS3Bucket +from controlpanel.api.tasks.s3bucket import S3BucketRevokeAllAccess def s3bucket_console_url(name): @@ -56,6 +59,14 @@ class S3Bucket(TimeStampedModel): is_data_warehouse = models.BooleanField(default=False) # TODO remove this field - it's unused location_url = models.CharField(max_length=128, null=True) + is_deleted = models.BooleanField(default=False) + deleted_by = models.ForeignKey( + "User", + on_delete=models.SET_NULL, + null=True, + related_name="deleted_s3buckets" + ) + deleted_at = models.DateTimeField(null=True) objects = S3BucketQuerySet.as_manager() @@ -158,3 +169,17 @@ def delete(self, *args, **kwargs): if not self.is_folder: self.cluster.mark_for_archival() super().delete(*args, **kwargs) + + def soft_delete(self, deleted_by: User): + """ + Mark the object as deleted, but do not remove it from the database + """ + self.is_deleted = True + self.deleted_by = deleted_by + self.deleted_at = timezone.now() + self.save() + # TODO update to handle deleting folders + if not self.is_folder: + self.cluster.mark_for_archival() + + S3BucketRevokeAllAccess(self, self.deleted_by).create_task() diff --git a/controlpanel/api/serializers.py b/controlpanel/api/serializers.py index ac17f4114..7761379c4 100644 --- a/controlpanel/api/serializers.py +++ b/controlpanel/api/serializers.py @@ -209,12 +209,18 @@ class Meta: "created_by", "is_data_warehouse", "location_url", + "is_deleted", + "deleted_by", + "deleted_at", ) read_only_fields = ( "apps3buckets", "users3buckets", "created_by", "url", + "is_deleted", + "deleted_by", + "deleted_at", ) diff --git a/controlpanel/api/tasks/handlers/__init__.py b/controlpanel/api/tasks/handlers/__init__.py index 454fd9094..f51323c79 100644 --- a/controlpanel/api/tasks/handlers/__init__.py +++ b/controlpanel/api/tasks/handlers/__init__.py @@ -5,6 +5,7 @@ CreateS3Bucket, GrantAppS3BucketAccess, GrantUserS3BucketAccess, + S3BucketRevokeAllAccess, S3BucketRevokeAppAccess, S3BucketRevokeUserAccess, ) @@ -16,3 +17,4 @@ create_app_auth_settings = celery_app.register_task(CreateAppAuthSettings()) revoke_user_s3bucket_access = celery_app.register_task(S3BucketRevokeUserAccess()) revoke_app_s3bucket_access = celery_app.register_task(S3BucketRevokeAppAccess()) +revoke_all_access_s3bucket = celery_app.register_task(S3BucketRevokeAllAccess()) diff --git a/controlpanel/api/tasks/handlers/s3.py b/controlpanel/api/tasks/handlers/s3.py index fd8fe922e..69b7ab0d7 100644 --- a/controlpanel/api/tasks/handlers/s3.py +++ b/controlpanel/api/tasks/handlers/s3.py @@ -1,9 +1,10 @@ # Third-party -from celery import Task as CeleryTask +from django.db.models.deletion import Collector # First-party/Local from controlpanel.api import cluster from controlpanel.api.models import App, AppS3Bucket, S3Bucket, User, UserS3Bucket +from controlpanel.api.models.access_to_s3bucket import AccessToS3Bucket from controlpanel.api.tasks.handlers.base import BaseModelTaskHandler, BaseTaskHandler @@ -73,3 +74,24 @@ def handle(self, bucket_arn, app_pk): self.complete() cluster.App(app).revoke_bucket_access(bucket_arn) self.complete() + + +class S3BucketRevokeAllAccess(BaseModelTaskHandler): + model = S3Bucket + name = "s3bucket_revoke_all_access" + + def handle(self, *args, **kwargs): + """ + When an S3Bucket is soft-deleted, the related objects that handle access will + remain in place. In order to keep IAM roles updated, this task collects objects + that would have been deleted by a cascade, and revokes access to deleted bucket + """ + task_user = User.objects.filter(pk=self.task_user_pk).first() + collector = Collector(using="default") + collector.collect([self.object]) + for model, instance in collector.instances_with_model(): + if not issubclass(model, AccessToS3Bucket): + continue + + instance.current_user = task_user + instance.revoke_bucket_access() diff --git a/controlpanel/api/tasks/s3bucket.py b/controlpanel/api/tasks/s3bucket.py index c990ca7a1..4e3e8944d 100644 --- a/controlpanel/api/tasks/s3bucket.py +++ b/controlpanel/api/tasks/s3bucket.py @@ -25,6 +25,19 @@ def _get_args_list(self): ] +class S3BucketRevokeAllAccess(TaskBase): + ENTITY_CLASS = "S3Bucket" + QUEUE_NAME = settings.S3_QUEUE_NAME + + @property + def task_name(self): + return "s3bucket_revoke_all_access" + + @property + def task_description(self): + return "Revokes all access to an S3 bucket" + + class S3AccessMixin: ACTION = None ROLE = None diff --git a/controlpanel/frontend/forms.py b/controlpanel/frontend/forms.py index 9341dd333..4fe857c1c 100644 --- a/controlpanel/frontend/forms.py +++ b/controlpanel/frontend/forms.py @@ -146,7 +146,7 @@ class CreateAppForm(AppAuth0Form): required=False, ) existing_datasource_id = DatasourceChoiceField( - queryset=S3Bucket.objects.filter(is_data_warehouse=False), + queryset=S3Bucket.objects.filter(is_data_warehouse=False, is_deleted=False), empty_label="Select", required=False, ) @@ -353,9 +353,11 @@ def __init__(self, *args, **kwargs): if self.exclude_connected: self.fields["datasource"].queryset = S3Bucket.objects.exclude( id__in=[a.s3bucket_id for a in self.app.apps3buckets.all()], - ) + ).filter(is_deleted=False) else: - self.fields["datasource"].queryset = S3Bucket.objects.all() + self.fields["datasource"].queryset = S3Bucket.objects.filter( + is_deleted=False, + ) class CreateIAMManagedPolicyForm(forms.Form): diff --git a/controlpanel/frontend/jinja2/datasource-detail.html b/controlpanel/frontend/jinja2/datasource-detail.html index 0c133010a..82106174a 100644 --- a/controlpanel/frontend/jinja2/datasource-detail.html +++ b/controlpanel/frontend/jinja2/datasource-detail.html @@ -20,13 +20,25 @@
- - Open on AWS - + {% if bucket.is_deleted %} +
- {% if request.user.has_perm('api.update_users3bucket', member) %}
+ {% if request.user.has_perm('api.update_users3bucket', member) and not bucket.is_deleted %}
Edit access level
@@ -73,17 +85,19 @@ Users and groups with access | ||
{{ access_list|length }} - user{%- if access_list|length != 1 -%}s{% endif %} or group{%- if access_list|length != 1 -%}s have{% else %} has{% endif %} + user{%- if plural -%}s{% endif %} or group{%- if plural -%}s{% endif %}{% if bucket.is_deleted %} had{% elif plural %} have{% else %} has{% endif %} access to this {{ datasource_type }} data source |