← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~lgp171188/launchpad:new-security-role-permission-ui into launchpad:master

 

Guruprasad has proposed merging ~lgp171188/launchpad:new-security-role-permission-ui into launchpad:master.

Commit message:
Implement the UI for showing and setting Distribution.security_admin

Also export IDistribution.security_admin in the web service.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~lgp171188/launchpad/+git/launchpad/+merge/423670
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~lgp171188/launchpad:new-security-role-permission-ui into launchpad:master.
diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml
index bc05ae3..cd1bee9 100644
--- a/lib/lp/registry/browser/configure.zcml
+++ b/lib/lp/registry/browser/configure.zcml
@@ -1,4 +1,4 @@
-<!-- Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2009-2022 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -2332,6 +2332,13 @@
         template="../../app/templates/generic-edit.pt"
         />
     <browser:page
+        name="+select-security-admins"
+        for="lp.registry.interfaces.distribution.IDistribution"
+        class="lp.registry.browser.distribution.DistributionChangeSecurityAdminView"
+        permission="launchpad.Edit"
+        template="../../app/templates/generic-edit.pt"
+        />
+    <browser:page
         name="+pubconf"
         for="lp.registry.interfaces.distribution.IDistribution"
         class="lp.registry.browser.distribution.DistributionPublisherConfigView"
diff --git a/lib/lp/registry/browser/distribution.py b/lib/lp/registry/browser/distribution.py
index 5fccb6c..80ea77e 100644
--- a/lib/lp/registry/browser/distribution.py
+++ b/lib/lp/registry/browser/distribution.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2022 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Browser views for distributions."""
@@ -12,6 +12,7 @@ __all__ = [
     'DistributionChangeMembersView',
     'DistributionChangeMirrorAdminView',
     'DistributionChangeOCIProjectAdminView',
+    'DistributionChangeSecurityAdminView',
     'DistributionCountryArchiveMirrorsView',
     'DistributionDisabledMirrorsView',
     'DistributionEditView',
@@ -417,6 +418,7 @@ class DistributionOverviewMenu(ApplicationMenu, DistributionLinksMixin):
         'members',
         'mirror_admin',
         'oci_project_admin',
+        'security_admin',
         'reassign',
         'addseries',
         'series',
@@ -510,6 +512,11 @@ class DistributionOverviewMenu(ApplicationMenu, DistributionLinksMixin):
         text = 'Change OCI project admins'
         return Link('+select-oci-project-admins', text, icon='edit')
 
+    @enabled_with_permission('launchpad.Edit')
+    def security_admin(self):
+        text = 'Change security admins'
+        return Link('+select-security-admins', text, icon='edit')
+
     def search(self):
         text = 'Search packages'
         return Link('+search', text, icon='search')
@@ -793,6 +800,26 @@ class DistributionView(PillarViewMixin, HasAnnouncementsView, FeedsMixin):
             null_display_value=empty_value,
             step_title='Select a new OCI project administrator')
 
+    @property
+    def security_admin_widget(self):
+        if canWrite(self.context, 'security_admin'):
+            empty_value = ' Specify a security administrator'
+        else:
+            empty_value = 'None'
+
+        return InlinePersonEditPickerWidget(
+            self.context,
+            IDistribution['security_admin'],
+            format_link(
+                self.context.security_admin,
+                empty_value=empty_value,
+            ),
+            header='Change the security administrator',
+            edit_view='+select-security-admins',
+            null_display_value=empty_value,
+            step_title='Select a new security administrator'
+        )
+
     def linkedMilestonesForSeries(self, series):
         """Return a string of linkified milestones in the series."""
         # Listify to remove repeated queries.
@@ -1226,6 +1253,19 @@ class DistributionChangeOCIProjectAdminView(RegistryEditFormView):
             self.context.displayname)
 
 
+class DistributionChangeSecurityAdminView(RegistryEditFormView):
+    """A vie to change the security administrator."""
+    schema = IDistribution
+    field_names = ['security_admin']
+
+    @property
+    def label(self):
+        """See `LaunchpadFormView`."""
+        return "Change the %s security administrator" % (
+            self.context.displayname
+        )
+
+
 class DistributionChangeMembersView(RegistryEditFormView):
     """A view to change the members team."""
     schema = IDistribution
diff --git a/lib/lp/registry/browser/tests/distribution-views.txt b/lib/lp/registry/browser/tests/distribution-views.txt
index 2407a78..eadb671 100644
--- a/lib/lp/registry/browser/tests/distribution-views.txt
+++ b/lib/lp/registry/browser/tests/distribution-views.txt
@@ -261,6 +261,55 @@ Only admins and owners can access the view.
     False
 
 
+Changing a distribution security administrator
+----------------------------------------------
+
+The +select-security-admins view allows the owner or admin to change the
+security administrator.
+
+    >>> login("admin@xxxxxxxxxxxxx")
+    >>> view = create_view(distribution, '+select-security-admins')
+    >>> print(view.label)
+    Change the YoUbuntu security administrator
+
+    >>> print(view.page_title)
+    Change the YoUbuntu security administrator
+
+    >>> print(view.cancel_url)
+    http://launchpad.test/youbuntu
+
+The view accepts the security_admin field.
+
+    >>> print(distribution.security_admin)
+    None
+
+    >>> view.field_names
+    ['security_admin']
+
+    >>> form = {
+    ...     'field.security_admin': 'no-priv',
+    ...     'field.actions.change': 'Change',
+    ...     }
+    >>> view = create_initialized_view(
+    ...     distribution, '+select-security-admins', form=form)
+    >>> view.errors
+    []
+    >>> print(view.next_url)
+    http://launchpad.test/youbuntu
+
+    >>> print(distribution.security_admin.name)
+    no-priv
+
+Only admins and owners can access the view.
+
+    >>> check_permission('launchpad.Edit', view)
+    True
+
+    >>> login('no-priv@xxxxxxxxxxxxx')
+    >>> check_permission('launchpad.Edit', view)
+    False
+
+
 Changing a distribution members team
 ------------------------------------
 
diff --git a/lib/lp/registry/interfaces/distribution.py b/lib/lp/registry/interfaces/distribution.py
index 727d204..7261a85 100644
--- a/lib/lp/registry/interfaces/distribution.py
+++ b/lib/lp/registry/interfaces/distribution.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2022 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Interfaces including and related to IDistribution."""
@@ -40,6 +40,7 @@ from lazr.restful.declarations import (
 from lazr.restful.fields import (
     CollectionField,
     Reference,
+    ReferenceChoice,
     )
 from lazr.restful.interface import copy_field
 from zope.interface import (
@@ -480,10 +481,14 @@ class IDistributionView(
     has_current_commercial_subscription = Attribute(
         "Whether the distribution has a current commercial subscription.")
 
-    security_admin = Choice(
-        title=_("Security Administrator"),
-        description=_("The distribution security administrator."),
-        required=False, vocabulary='ValidPersonOrTeam')
+    security_admin = exported(
+        ReferenceChoice(
+            title=_("Security Administrator"),
+            description=_("The distribution security administrator."),
+            required=False, vocabulary='ValidPersonOrTeam',
+            schema=IPerson,
+        ),
+    )
 
     vulnerabilities = exported(
         doNotSnapshot(CollectionField(
diff --git a/lib/lp/registry/stories/webservice/xx-distribution.txt b/lib/lp/registry/stories/webservice/xx-distribution.txt
index 51eeff6..ac21891 100644
--- a/lib/lp/registry/stories/webservice/xx-distribution.txt
+++ b/lib/lp/registry/stories/webservice/xx-distribution.txt
@@ -61,6 +61,7 @@ And for every distribution we publish most of its attributes.
     redirect_release_uploads: False
     registrant_link: 'http://.../~registry'
     resource_type_link: 'http://.../#distribution'
+    security_admin_link: None
     self_link: 'http://.../ubuntu'
     series_collection_link: 'http://.../ubuntu/series'
     summary: 'Ubuntu is a new approach to Linux Distribution...'
diff --git a/lib/lp/registry/templates/distribution-details.pt b/lib/lp/registry/templates/distribution-details.pt
index 1d2b0cf..60cf1ef 100644
--- a/lib/lp/registry/templates/distribution-details.pt
+++ b/lib/lp/registry/templates/distribution-details.pt
@@ -31,6 +31,11 @@
       <dt>OCI project admins:</dt>
       <dd tal:content="structure view/oci_project_admin_widget" />
     </dl>
+
+    <dl id="security-admin">
+      <dt>Security admin:</dt>
+      <dd tal:content="structure view/security_admin_widget" />
+    </dl>
   </div>
 
   <dl id="uploaders" style="clear:left">
diff --git a/lib/lp/registry/tests/test_distribution.py b/lib/lp/registry/tests/test_distribution.py
index c44df04..9d0491d 100644
--- a/lib/lp/registry/tests/test_distribution.py
+++ b/lib/lp/registry/tests/test_distribution.py
@@ -1,9 +1,10 @@
-# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2022 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for Distribution."""
 
 import datetime
+import json
 
 from fixtures import FakeLogger
 from lazr.lifecycle.snapshot import Snapshot
@@ -1809,6 +1810,103 @@ class TestDistributionWebservice(OCIConfigHelperMixin, TestCaseWithFactory):
         self.assertTrue(len(mirrors) > 1, "Not enough mirrors")
         self.assertEqual(main_mirror, mirrors[-1])
 
+    def test_distribution_security_admin_unset(self):
+        with person_logged_in(self.person):
+            distro = self.factory.makeDistribution(owner=self.person)
+            distro_url = api_url(distro)
+
+        response = self.webservice.get(distro_url)
+        json_body = response.jsonBody()
+        self.assertEqual(200, response.status)
+        self.assertIsNone(json_body['security_admin_link'])
+
+    def test_distribution_security_admin_set(self):
+        with person_logged_in(self.person):
+            distro = self.factory.makeDistribution(owner=self.person)
+            distro_url = api_url(distro)
+            person = self.factory.makePerson()
+            distro.security_admin = person
+            person_url = 'http://api.launchpad.test/devel' + api_url(person)
+
+        response = self.webservice.get(distro_url)
+        json_body = response.jsonBody()
+        self.assertEqual(200, response.status)
+        self.assertEqual(
+            person_url,
+            json_body['security_admin_link'],
+        )
+
+    def test_admin_can_set_distribution_security_admin(self):
+        with admin_logged_in():
+            distro = self.factory.makeDistribution()
+            person = self.factory.makePerson()
+            person_url = 'http://api.launchpad.test/devel' + api_url(person)
+            self.assertIsNone(distro.security_admin)
+            admin_user = getUtility(IPersonSet).getByEmail(
+                'admin@xxxxxxxxxxxxx'
+            )
+            distro_url = api_url(distro)
+
+        webservice = webservice_for_person(
+            admin_user, permission=OAuthPermission.WRITE_PUBLIC,
+            default_api_version="devel"
+        )
+
+        response = webservice.patch(
+            distro_url,
+            "application/json",
+            json.dumps(
+                {
+                    "security_admin_link": person_url
+                }
+            )
+        )
+        self.assertEqual(209, response.status)
+        with admin_logged_in():
+            self.assertEqual(person, distro.security_admin)
+
+    def test_distribution_owner_can_set_security_admin(self):
+        with person_logged_in(self.person):
+            distro = self.factory.makeDistribution(owner=self.person)
+            self.assertIsNone(distro.security_admin)
+            distro_url = api_url(distro)
+            person_url = (
+                'http://api.launchpad.test/devel' + api_url(self.person)
+            )
+
+        response = self.webservice.patch(
+            distro_url,
+            'application/json',
+            json.dumps(
+                {
+                    'security_admin_link': person_url,
+                }
+            )
+        )
+        self.assertEqual(209, response.status)
+
+        with person_logged_in(self.person):
+            self.assertEqual(self.person, distro.security_admin)
+
+    def test_others_cannot_set_distribution_security_admin(self):
+        with person_logged_in(self.person):
+            distro = self.factory.makeDistribution()
+            distro_url = api_url(distro)
+            person_url = (
+                'http://api.launchpad.test/devel' + api_url(self.person)
+            )
+
+        response = self.webservice.patch(
+            distro_url,
+            'application/json',
+            json.dumps(
+                {
+                    'security_admin_link': person_url,
+                }
+            )
+        )
+        self.assertEqual(401, response.status)
+
 
 class TestDistributionVulnerabilities(TestCaseWithFactory):