From 4e6de07ebb4d040ff93eef91a2e1f0ffcf6796d7 Mon Sep 17 00:00:00 2001
From: Michael Collins <15347726+michaeljcollinsuk@users.noreply.github.com>
Date: Fri, 13 Oct 2023 14:43:42 +0100
Subject: [PATCH] Add task to revoke all access to a S3bucket when it is soft
deleted
---
controlpanel/api/models/s3bucket.py | 3 +++
controlpanel/api/tasks/handlers/__init__.py | 2 ++
controlpanel/api/tasks/handlers/s3.py | 21 +++++++++++++++++-
controlpanel/api/tasks/s3bucket.py | 13 +++++++++++
.../frontend/jinja2/datasource-detail.html | 18 ++++++++++-----
tests/api/models/test_s3bucket.py | 7 +++++-
tests/api/tasks/test_s3.py | 22 ++++++++++++++++++-
tests/frontend/views/test_datasource.py | 14 ++++++++++--
8 files changed, 89 insertions(+), 11 deletions(-)
diff --git a/controlpanel/api/models/s3bucket.py b/controlpanel/api/models/s3bucket.py
index 8d72ed467..9d508b905 100644
--- a/controlpanel/api/models/s3bucket.py
+++ b/controlpanel/api/models/s3bucket.py
@@ -15,6 +15,7 @@
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):
@@ -180,3 +181,5 @@ def soft_delete(self, deleted_by: User):
# 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/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..f37e90b19 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,21 @@ 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
+ """
+ collector = Collector(using="default")
+ collector.collect([self.object])
+ for model, instance in collector.instances_with_model():
+ if not issubclass(model, AccessToS3Bucket):
+ continue
+ 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/jinja2/datasource-detail.html b/controlpanel/frontend/jinja2/datasource-detail.html
index d7ac893f2..24c34a158 100644
--- a/controlpanel/frontend/jinja2/datasource-detail.html
+++ b/controlpanel/frontend/jinja2/datasource-detail.html
@@ -21,9 +21,15 @@
{{ page_title }}
{% if bucket.is_deleted %}
-
- This bucket was deleted by {{ user_name(bucket.deleted_by) }} on {{ bucket.deleted_at.strftime("%Y/%m/%d %H:%M:%S") }}.
-
+
+
!
+
+ Warning
+ This bucket was deleted by
+ {{ user_name(bucket.deleted_by) }} on {{ bucket.deleted_at.strftime("%Y/%m/%d %H:%M:%S") }}.
+ All access listed below has been revoked in IAM.
+
+
{% else %}
Open on AWS
@@ -69,7 +75,7 @@ Users and groups with access
{%- endif %}
- {% 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
@@ -89,7 +95,7 @@ Users and groups with access
- {% if request.user.has_perm('api.create_users3bucket', bucket) and users_options|length %}
+ {% if request.user.has_perm('api.create_users3bucket', bucket) and users_options|length and not bucket.is_deleted %}
Grant user access
@@ -97,7 +103,7 @@ Users and groups with access
{% endif %}
{% if request.user.has_perm('api.manage_groups') %}
- {% if request.user.has_perm('api.create_policys3bucket', bucket) and policies_options|length %}
+ {% if request.user.has_perm('api.create_policys3bucket', bucket) and policies_options|length and not bucket.is_deleted %}
Grant group access
diff --git a/tests/api/models/test_s3bucket.py b/tests/api/models/test_s3bucket.py
index deb17e0aa..67dcd6db3 100644
--- a/tests/api/models/test_s3bucket.py
+++ b/tests/api/models/test_s3bucket.py
@@ -98,7 +98,7 @@ def test_cluster(name, expected):
assert isinstance(S3Bucket(name=name).cluster, expected)
-def test_soft_delete(bucket, users):
+def test_soft_delete(bucket, users, sqs, helpers):
user = users["superuser"]
assert bucket.is_deleted is False
@@ -109,3 +109,8 @@ def test_soft_delete(bucket, users):
assert bucket.deleted_by == user
assert bucket.deleted_at is not None
archive.assert_called_once()
+
+ messages = helpers.retrieve_messages(sqs, queue_name=settings.S3_QUEUE_NAME)
+ helpers.validate_task_with_sqs_messages(
+ messages, S3Bucket.__name__, bucket.id, queue_name=settings.S3_QUEUE_NAME,
+ )
diff --git a/tests/api/tasks/test_s3.py b/tests/api/tasks/test_s3.py
index dc275accf..88b329dd4 100644
--- a/tests/api/tasks/test_s3.py
+++ b/tests/api/tasks/test_s3.py
@@ -1,6 +1,8 @@
+# Standard library
+from unittest.mock import MagicMock, patch
+
# Third-party
import pytest
-from mock import patch
from model_mommy import mommy
# First-party/Local
@@ -9,6 +11,7 @@
create_s3bucket,
grant_app_s3bucket_access,
grant_user_s3bucket_access,
+ revoke_all_access_s3bucket,
revoke_app_s3bucket_access,
revoke_user_s3bucket_access,
)
@@ -112,3 +115,20 @@ def test_revoke_app_access(cluster, complete):
app_bucket_access.s3bucket.arn
)
complete.assert_called_once()
+
+
+@pytest.mark.django_db
+@patch("controlpanel.api.models.UserS3Bucket.revoke_bucket_access", new=MagicMock())
+@patch("controlpanel.api.models.AppS3Bucket.revoke_bucket_access", new=MagicMock())
+@patch("controlpanel.api.models.PolicyS3Bucket.revoke_bucket_access", new=MagicMock())
+def test_revoke_all_access():
+ bucket = mommy.make("api.S3Bucket")
+ user_access = mommy.make("api.UserS3Bucket", s3bucket=bucket)
+ app_access = mommy.make("api.AppS3Bucket", s3bucket=bucket)
+ policy_access = mommy.make("api.PolicyS3Bucket", s3bucket=bucket)
+
+ revoke_all_access_s3bucket(bucket.pk, bucket.created_by)
+
+ user_access.revoke_bucket_access.assert_called_once()
+ app_access.revoke_bucket_access.assert_called_once()
+ policy_access.revoke_bucket_access.assert_called_once()
diff --git a/tests/frontend/views/test_datasource.py b/tests/frontend/views/test_datasource.py
index 464f3ece9..2f4a892ac 100644
--- a/tests/frontend/views/test_datasource.py
+++ b/tests/frontend/views/test_datasource.py
@@ -3,6 +3,7 @@
# Third-party
import pytest
+from django.conf import settings
from django.urls import reverse, reverse_lazy
from model_mommy import mommy
from rest_framework import status
@@ -404,7 +405,9 @@ def test_grant_access_invalid_form(client, users3buckets, users, kwargs):
("app_data1", reverse_lazy("list-webapp-datasources")),
]
)
-def test_delete_calls_soft_delete(client, buckets, users, bucket, success_url):
+def test_delete_calls_soft_delete(
+ client, buckets, users, bucket, success_url, sqs, helpers,
+):
admin = users["bucket_admin"]
bucket = buckets[bucket]
@@ -418,6 +421,11 @@ def test_delete_calls_soft_delete(client, buckets, users, bucket, success_url):
assert bucket.deleted_at is not None
assert response.url == success_url
+ messages = helpers.retrieve_messages(sqs, queue_name=settings.S3_QUEUE_NAME)
+ helpers.validate_task_with_sqs_messages(
+ messages, S3Bucket.__name__, bucket.id, queue_name=settings.S3_QUEUE_NAME,
+ )
+
@pytest.mark.parametrize(
"user, bucket, expected_status",
@@ -440,7 +448,9 @@ def test_delete_calls_soft_delete(client, buckets, users, bucket, success_url):
]
)
-def test_detail_for_deleted_datasouce(client, buckets, users, user, bucket, expected_status):
+def test_detail_for_deleted_datasource(
+ client, buckets, users, user, bucket, expected_status
+):
user = users[user]
bucket = buckets[bucket]
bucket.soft_delete(deleted_by=user)
|