Repository: pypi/warehouse

Issue:
Email all owners when a new owner/maintainer is added
To make account compromise more obvious, it would be great if all owners of a package were emailed when a new owner or maintainer was added to a package.

In addition, if a user is removed from a role, then that user should also be emailed (eg 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).



Discussion:
Thanks for the great suggestion, @edmorley, and sorry for the slow response!

This would be a useful security/audit feature, and in addition, we should also send an email notification to newly added collaborators. And now that we have better email management in Warehouse (just added in the past few weeks), it's far easier to add this feature.

Today in [our development meeting](https://wiki.python.org/psf/PackagingWG/2018-02-20-Warehouse) we discussed where this should go on [our development roadmap](https://wiki.python.org/psf/WarehouseRoadmap). The most urgent task is to improve Warehouse to the point where we can redirect pypi.python.org to pypi.org so the site is more sustainable and reliable. Since this feature isn't something that the legacy site has, I've moved it to a future milestone. But that doesn't have to stop a volunteer from working on it now; if someone wants to write up a pull request for it, go ahead!

Thanks and sorry again for the wait.
I'd be interested to work on this.
I'm not familiar with the codebase yet. Quick look at https://github.com/pypa/warehouse/blob/master/warehouse/manage/views.py#L550, perhaps around there we can send the email to owners. Am I in the right track for this?
Thanks :)
Thanks, @Mariatta! I'll defer to @di who implemented the email management.
@Mariatta Yep, that's the right spot!

I think we should break this up into two emails we send when a new collaborator is added:
* Notification to existing owners that a new collaborator has been added (we probably don't need to send this to the current user).
* Notification to the new collaborator that they have been added.

Resolving patch:
diff --git a/warehouse/email.py b/warehouse/email.py
--- a/warehouse/email.py
+++ b/warehouse/email.py
@@ -19,12 +19,13 @@
 
 
 @tasks.task(bind=True, ignore_result=True, acks_late=True)
-def send_email(task, request, body, subject, *, recipients=None):
+def send_email(task, request, body, subject, *, recipients=None, bcc=None):
 
     mailer = get_mailer(request)
     message = Message(
         body=body,
         recipients=recipients,
+        bcc=bcc,
         sender=request.registry.settings.get('mail.sender'),
         subject=subject
     )
@@ -145,3 +146,46 @@ def send_primary_email_change_email(request, user, email):
     request.task(send_email).delay(body, subject, recipients=[email])
 
     return fields
+
+
+def send_collaborator_added_email(request, user, submitter, project_name, role,
+                                  email_recipients):
+    fields = {
+        'username': user.username,
+        'project': project_name,
+        'submitter': submitter.username,
+        'role': role
+    }
+
+    subject = render(
+        'email/collaborator-added.subject.txt', fields, request=request
+    )
+
+    body = render(
+        'email/collaborator-added.body.txt', fields, request=request
+    )
+
+    request.task(send_email).delay(body, subject, bcc=email_recipients)
+
+    return fields
+
+
+def send_added_as_collaborator_email(request, submitter, project_name, role,
+                                     user_email):
+    fields = {
+        'project': project_name,
+        'submitter': submitter.username,
+        'role': role
+    }
+
+    subject = render(
+        'email/added-as-collaborator.subject.txt', fields, request=request
+    )
+
+    body = render(
+        'email/added-as-collaborator.body.txt', fields, request=request
+    )
+
+    request.task(send_email).delay(body, subject, recipients=[user_email])
+
+    return fields
diff --git a/warehouse/manage/views.py b/warehouse/manage/views.py
--- a/warehouse/manage/views.py
+++ b/warehouse/manage/views.py
@@ -22,7 +22,8 @@
 from warehouse.accounts.models import User, Email
 from warehouse.accounts.views import logout
 from warehouse.email import (
-    send_account_deletion_email, send_email_verification_email,
+    send_account_deletion_email, send_added_as_collaborator_email,
+    send_collaborator_added_email, send_email_verification_email,
     send_password_change_email, send_primary_email_change_email
 )
 from warehouse.manage.forms import (
@@ -551,6 +552,32 @@ def manage_project_roles(project, request, _form_class=CreateRoleForm):
                     submitted_from=request.remote_addr,
                 ),
             )
+
+            owners = (
+                request.db.query(Role)
+                .join(Role.user)
+                .filter(Role.role_name == 'Owner', Role.project == project)
+            )
+            owner_emails = [owner.user.email for owner in owners]
+            owner_emails.remove(request.user.email)
+
+            send_collaborator_added_email(
+                request,
+                user,
+                request.user,
+                project.name,
+                form.role_name.data,
+                owner_emails
+            )
+
+            send_added_as_collaborator_email(
+                request,
+                request.user,
+                project.name,
+                form.role_name.data,
+                user.email
+            )
+
             request.session.flash(
                 f"Added collaborator '{form.username.data}'",
                 queue="success"


Test patch:
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
@@ -1236,7 +1236,7 @@ def test_post_new_role_validation_fails(self, db_request):
             "form": form_obj,
         }
 
-    def test_post_new_role(self, db_request):
+    def test_post_new_role(self, monkeypatch, db_request):
         project = ProjectFactory.create(name="foobar")
         user = UserFactory.create(username="testuser")
 
@@ -1261,6 +1261,21 @@ def test_post_new_role(self, db_request):
             flash=pretend.call_recorder(lambda *a, **kw: None),
         )
 
+        send_collaborator_added_email = pretend.call_recorder(lambda *a: None)
+        monkeypatch.setattr(
+            views,
+            'send_collaborator_added_email',
+            send_collaborator_added_email,
+        )
+
+        send_added_as_collaborator_email = pretend.call_recorder(
+            lambda *a: None)
+        monkeypatch.setattr(
+            views,
+            'send_added_as_collaborator_email',
+            send_added_as_collaborator_email,
+        )
+
         result = views.manage_project_roles(
             project, db_request, _form_class=form_class
         )
@@ -1277,6 +1292,26 @@ def test_post_new_role(self, db_request):
             pretend.call("Added collaborator 'testuser'", queue="success"),
         ]
 
+        assert send_collaborator_added_email.calls == [
+            pretend.call(
+                db_request,
+                user,
+                db_request.user,
+                project.name,
+                form_obj.role_name.data,
+                []
+            )
+        ]
+
+        assert send_added_as_collaborator_email.calls == [
+            pretend.call(
+                db_request,
+                db_request.user,
+                project.name,
+                form_obj.role_name.data,
+                user.email)
+        ]
+
         # Only one role is created
         role = db_request.db.query(Role).one()
 
diff --git a/tests/unit/test_email.py b/tests/unit/test_email.py
--- a/tests/unit/test_email.py
+++ b/tests/unit/test_email.py
@@ -384,3 +384,126 @@ def test_primary_email_change_email(
                 recipients=['old_email'],
             ),
         ]
+
+
+class TestCollaboratorAddedEmail:
+
+    def test_collaborator_added_email(
+            self, pyramid_request, pyramid_config, monkeypatch):
+
+        stub_user = pretend.stub(
+            email='email',
+            username='username',
+        )
+        stub_submitter_user = pretend.stub(
+            email='submiteremail',
+            username='submitterusername'
+        )
+        subject_renderer = pyramid_config.testing_add_renderer(
+            'email/collaborator-added.subject.txt'
+        )
+        subject_renderer.string_response = 'Email Subject'
+        body_renderer = pyramid_config.testing_add_renderer(
+            'email/collaborator-added.body.txt'
+        )
+        body_renderer.string_response = 'Email Body'
+
+        send_email = pretend.stub(
+            delay=pretend.call_recorder(lambda *args, **kwargs: None)
+        )
+        pyramid_request.task = pretend.call_recorder(
+            lambda *args, **kwargs: send_email
+        )
+        monkeypatch.setattr(email, 'send_email', send_email)
+
+        result = email.send_collaborator_added_email(
+            pyramid_request,
+            user=stub_user,
+            submitter=stub_submitter_user,
+            project_name='test_project',
+            role='Owner',
+            email_recipients=[stub_user.email, stub_submitter_user.email]
+        )
+
+        assert result == {
+            'username': stub_user.username,
+            'project': 'test_project',
+            'role': 'Owner',
+            'submitter': stub_submitter_user.username
+        }
+        subject_renderer.assert_()
+        body_renderer.assert_(username=stub_user.username)
+        body_renderer.assert_(project='test_project')
+        body_renderer.assert_(role='Owner')
+        body_renderer.assert_(submitter=stub_submitter_user.username)
+
+        assert pyramid_request.task.calls == [
+            pretend.call(send_email),
+        ]
+        assert send_email.delay.calls == [
+            pretend.call(
+                'Email Body',
+                'Email Subject',
+                bcc=[stub_user.email, stub_submitter_user.email],
+            ),
+        ]
+
+
+class TestAddedAsCollaboratorEmail:
+
+    def test_added_as_collaborator_email(
+            self, pyramid_request, pyramid_config, monkeypatch):
+
+        stub_user = pretend.stub(
+            email='email',
+            username='username',
+        )
+        stub_submitter_user = pretend.stub(
+            email='submiteremail',
+            username='submitterusername'
+        )
+        subject_renderer = pyramid_config.testing_add_renderer(
+            'email/added-as-collaborator.subject.txt'
+        )
+        subject_renderer.string_response = 'Email Subject'
+        body_renderer = pyramid_config.testing_add_renderer(
+            'email/added-as-collaborator.body.txt'
+        )
+        body_renderer.string_response = 'Email Body'
+
+        send_email = pretend.stub(
+            delay=pretend.call_recorder(lambda *args, **kwargs: None)
+        )
+        pyramid_request.task = pretend.call_recorder(
+            lambda *args, **kwargs: send_email
+        )
+        monkeypatch.setattr(email, 'send_email', send_email)
+
+        result = email.send_added_as_collaborator_email(
+            pyramid_request,
+            submitter=stub_submitter_user,
+            project_name='test_project',
+            role='Owner',
+            user_email=stub_user.email
+        )
+
+        assert result == {
+            'project': 'test_project',
+            'role': 'Owner',
+            'submitter': stub_submitter_user.username
+        }
+        subject_renderer.assert_()
+        body_renderer.assert_(submitter=stub_submitter_user.username)
+        body_renderer.assert_(project='test_project')
+        body_renderer.assert_(role='Owner')
+
+        assert pyramid_request.task.calls == [
+            pretend.call(send_email),
+        ]
+        assert send_email.delay.calls == [
+            pretend.call(
+                'Email Body',
+                'Email Subject',
+                recipients=[stub_user.email],
+            ),
+        ]