Repository: pypi/warehouse

Issue:
Email notification for demoted/removed user
Followup to #1000: we should email the user (and possibly all the other owners/maintainers of a project as well) when they were an owner/maintainer of a project but have just been demoted or removed from the project.

> if a user is removed from a [maintainer or owner] role, then that user should also be emailed (e.g., to prevent an attacker who has compromised one owner, from silently removing other owners of a package, prior to uploading a new malicious package, thereby circumventing #997).

-- @edmorley


Discussion:
To do this the right way, we should wait till we have #5863 implemented, so we can draw on the event logging and use it to trigger this notification.
I finally got the code in #3853 rebased on master, and am looking at driving the notifications from the event logging.  I don't see any triggers or signal plumbing in #6339.  When you say the event logging should trigger the notification, are you talking about calling these notifications directly from `Project.record_event` and `User.record_event`?  Or are you looking for us to use the db signals to watch for `UserEvent` objects being created?
I defer to @di and @ewdurbin and @yeraydiazdiaz on this.
@di do you have some feedback on @rascalking's question?
I think either could work. I think it would be probably more straightforward to just do this in `*.record_event`, and probably more thorough to hook into `ProjectEvent`/`UserEvent` object creation.

I think what would be even more powerful is if every `Event` was published to our task queue, and for every individual type of event, there was a way to resolve asynchronous actions that should happen as a result of that event, like sending an email. That's a bit more than what we're asking for here right now though.
@rascalking Any updates here? Are you still working on this?

Resolving patch:
diff --git a/warehouse/email/__init__.py b/warehouse/email/__init__.py
--- a/warehouse/email/__init__.py
+++ b/warehouse/email/__init__.py
@@ -224,6 +224,48 @@ def send_added_as_collaborator_email(request, user, *, submitter, project_name,
     return {"project": project_name, "submitter": submitter.username, "role": role}
 
 
+@_email("collaborator-removed")
+def send_collaborator_removed_email(
+    request, email_recipients, *, user, submitter, project_name
+):
+    return {
+        "username": user.username,
+        "project": project_name,
+        "submitter": submitter.username,
+    }
+
+
+@_email("removed-as-collaborator")
+def send_removed_as_collaborator_email(request, user, *, submitter, project_name):
+    return {
+        "project": project_name,
+        "submitter": submitter.username,
+    }
+
+
+@_email("collaborator-role-changed")
+def send_collaborator_role_changed_email(
+    request, recipients, *, user, submitter, project_name, role
+):
+    return {
+        "username": user.username,
+        "project": project_name,
+        "submitter": submitter.username,
+        "role": role,
+    }
+
+
+@_email("role-changed-as-collaborator")
+def send_role_changed_as_collaborator_email(
+    request, user, *, submitter, project_name, role
+):
+    return {
+        "project": project_name,
+        "submitter": submitter.username,
+        "role": role,
+    }
+
+
 @_email("two-factor-added")
 def send_two_factor_added_email(request, user, method):
     pretty_methods = {"totp": "TOTP", "webauthn": "WebAuthn"}
diff --git a/warehouse/manage/views.py b/warehouse/manage/views.py
--- a/warehouse/manage/views.py
+++ b/warehouse/manage/views.py
@@ -33,12 +33,16 @@
     send_account_deletion_email,
     send_added_as_collaborator_email,
     send_collaborator_added_email,
+    send_collaborator_removed_email,
+    send_collaborator_role_changed_email,
     send_email_verification_email,
     send_password_change_email,
     send_primary_email_change_email,
+    send_removed_as_collaborator_email,
     send_removed_project_email,
     send_removed_project_release_email,
     send_removed_project_release_file_email,
+    send_role_changed_as_collaborator_email,
     send_two_factor_added_email,
     send_two_factor_removed_email,
     send_unyanked_project_release_email,
@@ -1585,6 +1589,34 @@ def change_project_role(project, request, _form_class=ChangeRoleForm):
                         "target_user": role.user.username,
                     },
                 )
+
+                owner_roles = (
+                    request.db.query(Role)
+                    .filter(Role.project == project)
+                    .filter(Role.role_name == "Owner")
+                    .all()
+                )
+                owner_users = {owner.user for owner in owner_roles}
+                # Don't send owner notification email to new user
+                # if they are now an owner
+                owner_users.discard(role.user)
+                send_collaborator_role_changed_email(
+                    request,
+                    owner_users,
+                    user=role.user,
+                    submitter=request.user,
+                    project_name=project.name,
+                    role=role.role_name,
+                )
+
+                send_role_changed_as_collaborator_email(
+                    request,
+                    role.user,
+                    submitter=request.user,
+                    project_name=project.name,
+                    role=role.role_name,
+                )
+
                 request.session.flash("Changed role", queue="success")
         except NoResultFound:
             request.session.flash("Could not find role", queue="error")
@@ -1633,6 +1665,29 @@ def delete_project_role(project, request):
                     "target_user": role.user.username,
                 },
             )
+
+            owner_roles = (
+                request.db.query(Role)
+                .filter(Role.project == project)
+                .filter(Role.role_name == "Owner")
+                .all()
+            )
+            owner_users = {owner.user for owner in owner_roles}
+            # Don't send owner notification email to new user
+            # if they are now an owner
+            owner_users.discard(role.user)
+            send_collaborator_removed_email(
+                request,
+                owner_users,
+                user=role.user,
+                submitter=request.user,
+                project_name=project.name,
+            )
+
+            send_removed_as_collaborator_email(
+                request, role.user, submitter=request.user, project_name=project.name,
+            )
+
             request.session.flash("Removed role", queue="success")
     except NoResultFound:
         request.session.flash("Could not find role", queue="error")


Test patch:
diff --git a/tests/unit/email/test_init.py b/tests/unit/email/test_init.py
--- a/tests/unit/email/test_init.py
+++ b/tests/unit/email/test_init.py
@@ -22,6 +22,8 @@
 from warehouse.email.interfaces import IEmailSender
 from warehouse.email.services import EmailMessage
 
+from ...common.db.accounts import EmailFactory, UserFactory
+
 
 @pytest.mark.parametrize(
     ("user", "address", "expected"),
@@ -1500,6 +1502,384 @@ def test_added_as_collaborator_email_unverified(
         assert send_email.delay.calls == []
 
 
+class TestCollaboratorRemovedEmail:
+    def test_collaborator_removed_email(self, db_request, pyramid_config, monkeypatch):
+        removed_user = UserFactory.create()
+        EmailFactory.create(primary=True, verified=True, public=True, user=removed_user)
+        submitter_user = UserFactory.create()
+        EmailFactory.create(
+            primary=True, verified=True, public=True, user=submitter_user
+        )
+        db_request.user = submitter_user
+        db_request.remote_addr = "0.0.0.0"
+
+        subject_renderer = pyramid_config.testing_add_renderer(
+            "email/collaborator-removed/subject.txt"
+        )
+        subject_renderer.string_response = "Email Subject"
+        body_renderer = pyramid_config.testing_add_renderer(
+            "email/collaborator-removed/body.txt"
+        )
+        body_renderer.string_response = "Email Body"
+        html_renderer = pyramid_config.testing_add_renderer(
+            "email/collaborator-removed/body.html"
+        )
+        html_renderer.string_response = "Email HTML Body"
+
+        send_email = pretend.stub(
+            delay=pretend.call_recorder(lambda *args, **kwargs: None)
+        )
+        db_request.task = pretend.call_recorder(lambda *args, **kwargs: send_email)
+        monkeypatch.setattr(email, "send_email", send_email)
+
+        result = email.send_collaborator_removed_email(
+            db_request,
+            [removed_user, submitter_user],
+            user=removed_user,
+            submitter=submitter_user,
+            project_name="test_project",
+        )
+
+        assert result == {
+            "username": removed_user.username,
+            "project": "test_project",
+            "submitter": submitter_user.username,
+        }
+        subject_renderer.assert_()
+        body_renderer.assert_(username=removed_user.username)
+        body_renderer.assert_(project="test_project")
+        body_renderer.assert_(submitter=submitter_user.username)
+        html_renderer.assert_(username=removed_user.username)
+        html_renderer.assert_(project="test_project")
+        html_renderer.assert_(submitter=submitter_user.username)
+
+        assert db_request.task.calls == [
+            pretend.call(send_email),
+            pretend.call(send_email),
+        ]
+        assert send_email.delay.calls == [
+            pretend.call(
+                f"{ removed_user.name } <{ removed_user.primary_email.email }>",
+                attr.asdict(
+                    EmailMessage(
+                        subject="Email Subject",
+                        body_text="Email Body",
+                        body_html=(
+                            "<html>\n<head></head>\n"
+                            "<body><p>Email HTML Body</p></body>\n</html>\n"
+                        ),
+                    )
+                ),
+                {
+                    "tag": "account:email:sent",
+                    "user_id": removed_user.id,
+                    "ip_address": db_request.remote_addr,
+                    "additional": {
+                        "from_": None,
+                        "to": removed_user.primary_email.email,
+                        "subject": "Email Subject",
+                        "redact_ip": True,
+                    },
+                },
+            ),
+            pretend.call(
+                f"{ submitter_user.name } <{ submitter_user.primary_email.email }>",
+                attr.asdict(
+                    EmailMessage(
+                        subject="Email Subject",
+                        body_text="Email Body",
+                        body_html=(
+                            "<html>\n<head></head>\n"
+                            "<body><p>Email HTML Body</p></body>\n</html>\n"
+                        ),
+                    )
+                ),
+                {
+                    "tag": "account:email:sent",
+                    "user_id": submitter_user.id,
+                    "ip_address": db_request.remote_addr,
+                    "additional": {
+                        "from_": None,
+                        "to": submitter_user.primary_email.email,
+                        "subject": "Email Subject",
+                        "redact_ip": False,
+                    },
+                },
+            ),
+        ]
+
+
+class TestRemovedAsCollaboratorEmail:
+    def test_removed_as_collaborator_email(
+        self, db_request, pyramid_config, monkeypatch
+    ):
+        removed_user = UserFactory.create()
+        EmailFactory.create(primary=True, verified=True, public=True, user=removed_user)
+        submitter_user = UserFactory.create()
+        EmailFactory.create(
+            primary=True, verified=True, public=True, user=submitter_user
+        )
+        db_request.user = submitter_user
+        db_request.remote_addr = "0.0.0.0"
+
+        subject_renderer = pyramid_config.testing_add_renderer(
+            "email/removed-as-collaborator/subject.txt"
+        )
+        subject_renderer.string_response = "Email Subject"
+        body_renderer = pyramid_config.testing_add_renderer(
+            "email/removed-as-collaborator/body.txt"
+        )
+        body_renderer.string_response = "Email Body"
+        html_renderer = pyramid_config.testing_add_renderer(
+            "email/removed-as-collaborator/body.html"
+        )
+        html_renderer.string_response = "Email HTML Body"
+
+        send_email = pretend.stub(
+            delay=pretend.call_recorder(lambda *args, **kwargs: None)
+        )
+        db_request.task = pretend.call_recorder(lambda *args, **kwargs: send_email)
+        monkeypatch.setattr(email, "send_email", send_email)
+
+        result = email.send_removed_as_collaborator_email(
+            db_request,
+            removed_user,
+            submitter=submitter_user,
+            project_name="test_project",
+        )
+
+        assert result == {
+            "project": "test_project",
+            "submitter": submitter_user.username,
+        }
+        subject_renderer.assert_()
+        body_renderer.assert_(submitter=submitter_user.username)
+        body_renderer.assert_(project="test_project")
+        html_renderer.assert_(submitter=submitter_user.username)
+        html_renderer.assert_(project="test_project")
+
+        assert db_request.task.calls == [pretend.call(send_email)]
+        assert send_email.delay.calls == [
+            pretend.call(
+                f"{ removed_user.name } <{ removed_user.primary_email.email }>",
+                attr.asdict(
+                    EmailMessage(
+                        subject="Email Subject",
+                        body_text="Email Body",
+                        body_html=(
+                            "<html>\n<head></head>\n"
+                            "<body><p>Email HTML Body</p></body>\n</html>\n"
+                        ),
+                    )
+                ),
+                {
+                    "tag": "account:email:sent",
+                    "user_id": removed_user.id,
+                    "ip_address": db_request.remote_addr,
+                    "additional": {
+                        "from_": None,
+                        "to": removed_user.primary_email.email,
+                        "subject": "Email Subject",
+                        "redact_ip": True,
+                    },
+                },
+            )
+        ]
+
+
+class TestRoleChangedEmail:
+    def test_role_changed_email(self, db_request, pyramid_config, monkeypatch):
+        changed_user = UserFactory.create()
+        EmailFactory.create(primary=True, verified=True, public=True, user=changed_user)
+        submitter_user = UserFactory.create()
+        EmailFactory.create(
+            primary=True, verified=True, public=True, user=submitter_user
+        )
+        db_request.user = submitter_user
+        db_request.remote_addr = "0.0.0.0"
+
+        subject_renderer = pyramid_config.testing_add_renderer(
+            "email/collaborator-role-changed/subject.txt"
+        )
+        subject_renderer.string_response = "Email Subject"
+        body_renderer = pyramid_config.testing_add_renderer(
+            "email/collaborator-role-changed/body.txt"
+        )
+        body_renderer.string_response = "Email Body"
+        html_renderer = pyramid_config.testing_add_renderer(
+            "email/collaborator-role-changed/body.html"
+        )
+        html_renderer.string_response = "Email HTML Body"
+
+        send_email = pretend.stub(
+            delay=pretend.call_recorder(lambda *args, **kwargs: None)
+        )
+        db_request.task = pretend.call_recorder(lambda *args, **kwargs: send_email)
+        monkeypatch.setattr(email, "send_email", send_email)
+
+        result = email.send_collaborator_role_changed_email(
+            db_request,
+            [changed_user, submitter_user],
+            user=changed_user,
+            submitter=submitter_user,
+            project_name="test_project",
+            role="Owner",
+        )
+
+        assert result == {
+            "username": changed_user.username,
+            "project": "test_project",
+            "role": "Owner",
+            "submitter": submitter_user.username,
+        }
+        subject_renderer.assert_()
+        body_renderer.assert_(username=changed_user.username)
+        body_renderer.assert_(project="test_project")
+        body_renderer.assert_(role="Owner")
+        body_renderer.assert_(submitter=submitter_user.username)
+        html_renderer.assert_(username=changed_user.username)
+        html_renderer.assert_(project="test_project")
+        html_renderer.assert_(role="Owner")
+        html_renderer.assert_(submitter=submitter_user.username)
+
+        assert db_request.task.calls == [
+            pretend.call(send_email),
+            pretend.call(send_email),
+        ]
+        assert send_email.delay.calls == [
+            pretend.call(
+                f"{ changed_user.name } <{ changed_user.primary_email.email }>",
+                attr.asdict(
+                    EmailMessage(
+                        subject="Email Subject",
+                        body_text="Email Body",
+                        body_html=(
+                            "<html>\n<head></head>\n"
+                            "<body><p>Email HTML Body</p></body>\n</html>\n"
+                        ),
+                    )
+                ),
+                {
+                    "tag": "account:email:sent",
+                    "user_id": changed_user.id,
+                    "ip_address": db_request.remote_addr,
+                    "additional": {
+                        "from_": None,
+                        "to": changed_user.primary_email.email,
+                        "subject": "Email Subject",
+                        "redact_ip": True,
+                    },
+                },
+            ),
+            pretend.call(
+                f"{ submitter_user.name } <{ submitter_user.primary_email.email }>",
+                attr.asdict(
+                    EmailMessage(
+                        subject="Email Subject",
+                        body_text="Email Body",
+                        body_html=(
+                            "<html>\n<head></head>\n"
+                            "<body><p>Email HTML Body</p></body>\n</html>\n"
+                        ),
+                    )
+                ),
+                {
+                    "tag": "account:email:sent",
+                    "user_id": submitter_user.id,
+                    "ip_address": db_request.remote_addr,
+                    "additional": {
+                        "from_": None,
+                        "to": submitter_user.primary_email.email,
+                        "subject": "Email Subject",
+                        "redact_ip": False,
+                    },
+                },
+            ),
+        ]
+
+
+class TestRoleChangedAsCollaboratorEmail:
+    def test_role_changed_as_collaborator_email(
+        self, db_request, pyramid_config, monkeypatch
+    ):
+        changed_user = UserFactory.create()
+        EmailFactory.create(primary=True, verified=True, public=True, user=changed_user)
+        submitter_user = UserFactory.create()
+        EmailFactory.create(
+            primary=True, verified=True, public=True, user=submitter_user
+        )
+        db_request.user = submitter_user
+        db_request.remote_addr = "0.0.0.0"
+
+        subject_renderer = pyramid_config.testing_add_renderer(
+            "email/role-changed-as-collaborator/subject.txt"
+        )
+        subject_renderer.string_response = "Email Subject"
+        body_renderer = pyramid_config.testing_add_renderer(
+            "email/role-changed-as-collaborator/body.txt"
+        )
+        body_renderer.string_response = "Email Body"
+        html_renderer = pyramid_config.testing_add_renderer(
+            "email/role-changed-as-collaborator/body.html"
+        )
+        html_renderer.string_response = "Email HTML Body"
+
+        send_email = pretend.stub(
+            delay=pretend.call_recorder(lambda *args, **kwargs: None)
+        )
+        db_request.task = pretend.call_recorder(lambda *args, **kwargs: send_email)
+        monkeypatch.setattr(email, "send_email", send_email)
+
+        result = email.send_role_changed_as_collaborator_email(
+            db_request,
+            changed_user,
+            submitter=submitter_user,
+            project_name="test_project",
+            role="Owner",
+        )
+
+        assert result == {
+            "project": "test_project",
+            "role": "Owner",
+            "submitter": submitter_user.username,
+        }
+        subject_renderer.assert_()
+        body_renderer.assert_(submitter=submitter_user.username)
+        body_renderer.assert_(project="test_project")
+        body_renderer.assert_(role="Owner")
+        html_renderer.assert_(submitter=submitter_user.username)
+        html_renderer.assert_(project="test_project")
+        html_renderer.assert_(role="Owner")
+
+        assert db_request.task.calls == [pretend.call(send_email)]
+        assert send_email.delay.calls == [
+            pretend.call(
+                f"{ changed_user.name } <{ changed_user.primary_email.email }>",
+                attr.asdict(
+                    EmailMessage(
+                        subject="Email Subject",
+                        body_text="Email Body",
+                        body_html=(
+                            "<html>\n<head></head>\n"
+                            "<body><p>Email HTML Body</p></body>\n</html>\n"
+                        ),
+                    )
+                ),
+                {
+                    "tag": "account:email:sent",
+                    "user_id": changed_user.id,
+                    "ip_address": db_request.remote_addr,
+                    "additional": {
+                        "from_": None,
+                        "to": changed_user.primary_email.email,
+                        "subject": "Email Subject",
+                        "redact_ip": True,
+                    },
+                },
+            ),
+        ]
+
+
 class TestRemovedProjectEmail:
     def test_removed_project_email_to_maintainer(
         self, pyramid_request, pyramid_config, monkeypatch
diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py
--- a/tests/unit/manage/test_views.py
+++ b/tests/unit/manage/test_views.py
@@ -3590,14 +3590,16 @@ def test_post_unverified_email(self, db_request, with_email):
 
 
 class TestChangeProjectRoles:
-    def test_change_role(self, db_request):
+    def test_change_role(self, db_request, monkeypatch):
         project = ProjectFactory.create(name="foobar")
         user = UserFactory.create(username="testuser")
         role = RoleFactory.create(user=user, project=project, role_name="Owner")
         new_role_name = "Maintainer"
 
+        user_2 = UserFactory.create()
+
         db_request.method = "POST"
-        db_request.user = UserFactory.create()
+        db_request.user = user_2
         db_request.remote_addr = "10.10.10.10"
         db_request.POST = MultiDict({"role_id": role.id, "role_name": new_role_name})
         db_request.session = pretend.stub(
@@ -3605,12 +3607,48 @@ def test_change_role(self, db_request):
         )
         db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
 
+        send_collaborator_role_changed_email = pretend.call_recorder(
+            lambda *a, **kw: None
+        )
+        monkeypatch.setattr(
+            views,
+            "send_collaborator_role_changed_email",
+            send_collaborator_role_changed_email,
+        )
+        send_role_changed_as_collaborator_email = pretend.call_recorder(
+            lambda *a, **kw: None
+        )
+        monkeypatch.setattr(
+            views,
+            "send_role_changed_as_collaborator_email",
+            send_role_changed_as_collaborator_email,
+        )
+
         result = views.change_project_role(project, db_request)
 
         assert role.role_name == new_role_name
         assert db_request.route_path.calls == [
             pretend.call("manage.project.roles", project_name=project.name)
         ]
+        assert send_collaborator_role_changed_email.calls == [
+            pretend.call(
+                db_request,
+                set(),
+                user=user,
+                submitter=user_2,
+                project_name="foobar",
+                role=new_role_name,
+            )
+        ]
+        assert send_role_changed_as_collaborator_email.calls == [
+            pretend.call(
+                db_request,
+                user,
+                submitter=user_2,
+                project_name="foobar",
+                role=new_role_name,
+            )
+        ]
         assert db_request.session.flash.calls == [
             pretend.call("Changed role", queue="success")
         ]
@@ -3688,13 +3726,14 @@ def test_change_own_owner_role(self, db_request):
 
 
 class TestDeleteProjectRoles:
-    def test_delete_role(self, db_request):
+    def test_delete_role(self, db_request, monkeypatch):
         project = ProjectFactory.create(name="foobar")
         user = UserFactory.create(username="testuser")
         role = RoleFactory.create(user=user, project=project, role_name="Owner")
+        user_2 = UserFactory.create()
 
         db_request.method = "POST"
-        db_request.user = UserFactory.create()
+        db_request.user = user_2
         db_request.remote_addr = "10.10.10.10"
         db_request.POST = MultiDict({"role_id": role.id})
         db_request.session = pretend.stub(
@@ -3702,12 +3741,33 @@ def test_delete_role(self, db_request):
         )
         db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
 
+        send_collaborator_removed_email = pretend.call_recorder(lambda *a, **kw: None)
+        monkeypatch.setattr(
+            views, "send_collaborator_removed_email", send_collaborator_removed_email
+        )
+        send_removed_as_collaborator_email = pretend.call_recorder(
+            lambda *a, **kw: None
+        )
+        monkeypatch.setattr(
+            views,
+            "send_removed_as_collaborator_email",
+            send_removed_as_collaborator_email,
+        )
+
         result = views.delete_project_role(project, db_request)
 
         assert db_request.route_path.calls == [
             pretend.call("manage.project.roles", project_name=project.name)
         ]
         assert db_request.db.query(Role).all() == []
+        assert send_collaborator_removed_email.calls == [
+            pretend.call(
+                db_request, set(), user=user, submitter=user_2, project_name="foobar",
+            )
+        ]
+        assert send_removed_as_collaborator_email.calls == [
+            pretend.call(db_request, user, submitter=user_2, project_name="foobar",)
+        ]
         assert db_request.session.flash.calls == [
             pretend.call("Removed role", queue="success")
         ]