← Back to team overview

launchpad-reviewers team mailing list archive

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

 

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

Commit message:
Add a new security admin role and restrict vulnerability permissions to it

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~lgp171188/launchpad/+git/launchpad/+merge/423363
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~lgp171188/launchpad:new-security-role-permission into launchpad:master.
diff --git a/lib/lp/bugs/configure.zcml b/lib/lp/bugs/configure.zcml
index 895b8e9..4960677 100644
--- a/lib/lp/bugs/configure.zcml
+++ b/lib/lp/bugs/configure.zcml
@@ -604,7 +604,7 @@
         interface="lp.bugs.interfaces.vulnerability.IVulnerabilityView
                    lp.bugs.interfaces.vulnerability.IVulnerabilityEditableAttributes" />
       <require
-        permission="launchpad.Edit"
+        permission="launchpad.SecurityAdmin"
         interface="lp.bugs.interfaces.vulnerability.IVulnerabilityEdit"
         set_schema="lp.bugs.interfaces.vulnerability.IVulnerabilityEditableAttributes" />
 
diff --git a/lib/lp/bugs/model/tests/test_vulnerability.py b/lib/lp/bugs/model/tests/test_vulnerability.py
index f2cac72..ea0c036 100644
--- a/lib/lp/bugs/model/tests/test_vulnerability.py
+++ b/lib/lp/bugs/model/tests/test_vulnerability.py
@@ -2,8 +2,10 @@
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for the vulnerability and related models."""
+from testtools.matchers import MatchesStructure
 from zope.component import getUtility
 
+from lp.bugs.enums import VulnerabilityStatus
 from lp.bugs.interfaces.buglink import IBugLinkTarget
 from lp.bugs.interfaces.vulnerability import (
     IVulnerability,
@@ -39,33 +41,87 @@ class TestVulnerability(TestCaseWithFactory):
         vulnerability = self.factory.makeVulnerability()
         self.assertTrue(verifyObject(IBugLinkTarget, vulnerability))
 
-    def test_random_user(self):
+    def test_random_user_permissions(self):
         with person_logged_in(self.factory.makePerson()):
             self.assertTrue(
                 check_permission("launchpad.View", self.vulnerability))
             self.assertFalse(
                 check_permission("launchpad.Edit", self.vulnerability))
+            self.assertFalse(
+                check_permission(
+                    "launchpad.SecurityAdmin", self.vulnerability
+                )
+            )
 
-    def test_admin(self):
+    def test_admin_permissions(self):
         with admin_logged_in():
             self.assertTrue(
                 check_permission("launchpad.View", self.vulnerability))
             self.assertTrue(
                 check_permission("launchpad.Edit", self.vulnerability))
+            self.assertTrue(
+                check_permission(
+                    "launchpad.SecurityAdmin", self.vulnerability
+                )
+            )
 
-    def test_non_admin(self):
+    def test_distribution_owner_permissions(self):
         with person_logged_in(self.distribution.owner):
             self.assertTrue(
                 check_permission("launchpad.View", self.vulnerability))
             self.assertTrue(
                 check_permission("launchpad.Edit", self.vulnerability))
+            self.assertTrue(
+                check_permission(
+                    "launchpad.SecurityAdmin", self.vulnerability
+                )
+            )
+
+    def test_security_admin_permissions(self):
+        person = self.factory.makePerson()
+        security_team = self.factory.makeTeam(members=[person])
+        with person_logged_in(self.distribution.owner):
+            self.distribution.security_admin = security_team
 
-    def test_anonymous(self):
+        with person_logged_in(person):
+            self.assertTrue(
+                check_permission(
+                    "launchpad.SecurityAdmin", self.vulnerability
+                )
+            )
+
+    def test_anonymous_permissions(self):
         with anonymous_logged_in():
             self.assertFalse(
                 check_permission("launchpad.View", self.vulnerability))
             self.assertFalse(
                 check_permission("launchpad.Edit", self.vulnerability))
+            self.assertFalse(
+                check_permission(
+                    "launchpad.SecurityAdmin", self.vulnerability
+                )
+            )
+
+    def test_edit_vulnerability_security_admin(self):
+        person = self.factory.makePerson()
+        security_team = self.factory.makeTeam(members=[person])
+        vulnerability = self.factory.makeVulnerability(
+            distribution=self.distribution
+        )
+        cve = self.factory.makeCVE('2022-1234')
+        with person_logged_in(self.distribution.owner):
+            self.distribution.security_admin = security_team
+
+        with person_logged_in(security_team):
+            vulnerability.cve = cve
+            vulnerability.status = VulnerabilityStatus.ACTIVE
+            self.assertThat(
+                vulnerability,
+                MatchesStructure.byEquality(
+                    cve=cve,
+                    status=VulnerabilityStatus.ACTIVE
+                )
+            )
 
 
 class TestVulnerabilityActivity(TestCaseWithFactory):
diff --git a/lib/lp/bugs/security.py b/lib/lp/bugs/security.py
index 5e40c90..8ea8185 100644
--- a/lib/lp/bugs/security.py
+++ b/lib/lp/bugs/security.py
@@ -23,10 +23,12 @@ from lp.bugs.interfaces.bugtracker import IBugTracker
 from lp.bugs.interfaces.bugwatch import IBugWatch
 from lp.bugs.interfaces.hasbug import IHasBug
 from lp.bugs.interfaces.structuralsubscription import IStructuralSubscription
+from lp.bugs.interfaces.vulnerability import IVulnerability
 from lp.registry.interfaces.role import (
     IHasAppointedDriver,
     IHasOwner,
     )
+from lp.security import is_commercial_case
 from lp.services.messages.interfaces.message import IMessage
 
 
@@ -384,3 +386,18 @@ class EditBugSubscriptionFilter(AuthorizationBase):
 
     def checkAuthenticated(self, user):
         return user.inTeam(self.obj.structural_subscription.subscriber)
+
+
+class EditVulnerabilities(AuthorizationBase):
+    """The security admins of a distribution should be able to edit
+    vulnerabilities in that distribution."""
+    permission = 'launchpad.SecurityAdmin'
+    usedfor = IVulnerability
+
+    def checkAuthenticated(self, user):
+        return (
+            user.isOwner(self.obj.distribution)
+            or is_commercial_case(self.obj.distribution, user)
+            or user.in_admin
+            or user.inTeam(self.obj.distribution.security_admin)
+        )
diff --git a/lib/lp/bugs/tests/test_vulnerability.py b/lib/lp/bugs/tests/test_vulnerability.py
index be3457a..add4320 100644
--- a/lib/lp/bugs/tests/test_vulnerability.py
+++ b/lib/lp/bugs/tests/test_vulnerability.py
@@ -16,6 +16,7 @@ from lp.bugs.interfaces.bugtask import BugTaskImportance
 from lp.services.webapp.interfaces import OAuthPermission
 from lp.testing import (
     api_url,
+    person_logged_in,
     TestCaseWithFactory,
     )
 from lp.testing.layers import DatabaseFunctionalLayer
@@ -195,6 +196,68 @@ class TestVulnerabilityWebService(TestCaseWithFactory):
         self.assertEqual(209, response.status)
         self.assertEqual(vulnerability.cve, another_cve)
 
+    def test_security_admin_can_edit_vulnerability(self):
+        vulnerability = removeSecurityProxy(
+            self.factory.makeVulnerability()
+        )
+        owner = vulnerability.distribution.owner
+        person = self.factory.makePerson()
+        security_team = self.factory.makeTeam(members=[person])
+
+        with person_logged_in(owner):
+            vulnerability.distribution.security_admin = security_team
+
+        self.assertThat(
+            vulnerability,
+            MatchesStructure.byEquality(
+                status=VulnerabilityStatus.NEEDS_TRIAGE,
+                importance=BugTaskImportance.UNDECIDED,
+                information_type=InformationType.PUBLIC,
+                cve=None,
+                description=None,
+                notes=None,
+                mitigation=None,
+                date_made_public=None,
+            )
+        )
+        vulnerability_url = api_url(vulnerability)
+        webservice = webservice_for_person(
+            person,
+            permission=OAuthPermission.WRITE_PRIVATE,
+            default_api_version="devel"
+        )
+        now = datetime.datetime.now(pytz.UTC)
+
+        response = webservice.patch(
+            vulnerability_url,
+            "application/json",
+            json.dumps(
+                {
+                    "status": "Active",
+                    "information_type": "Private",
+                    "importance": "Critical",
+                    "description": "Foo bar",
+                    "notes": "lgp171188> Foo bar",
+                    "mitigation": "Foo bar baz",
+                    "importance_explanation": "Foo bar bazz",
+                    "date_made_public": now.isoformat(),
+                }
+            )
+        )
+        self.assertEqual(209, response.status)
+        self.assertThat(
+            vulnerability,
+            MatchesStructure.byEquality(
+                status=VulnerabilityStatus.ACTIVE,
+                importance=BugTaskImportance.CRITICAL,
+                description="Foo bar",
+                notes="lgp171188> Foo bar",
+                mitigation="Foo bar baz",
+                importance_explanation="Foo bar bazz",
+                date_made_public=now,
+            )
+        )
+
     def test_user_without_edit_permissions_cannot_edit_vulnerability(self):
         vulnerability = removeSecurityProxy(
             self.factory.makeVulnerability()
@@ -226,4 +289,4 @@ class TestVulnerabilityWebService(TestCaseWithFactory):
             )
         )
         self.assertEqual(401, response.status)
-        self.assertIn('launchpad.Edit', response.body.decode())
+        self.assertIn('launchpad.SecurityAdmin', response.body.decode())
diff --git a/lib/lp/permissions.zcml b/lib/lp/permissions.zcml
index 646d9ca..dd5a177 100644
--- a/lib/lp/permissions.zcml
+++ b/lib/lp/permissions.zcml
@@ -70,6 +70,10 @@
     id="launchpad.LanguagePacksAdmin" title="Administer Language Packs."
     access_level="write" />
 
+  <permission
+    id="launchpad.SecurityAdmin" title="Administer vulnerabilities."
+    access_level="write" />
+
   <!-- XXX: GuilhermeSalgado 2005-08-25
   To be removed soon, this is only needed by the page to upload SSH
   keys, cause we decided to not allow admins to upload keys in behalf of other
diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml
index 10d677f..eefe667 100644
--- a/lib/lp/registry/configure.zcml
+++ b/lib/lp/registry/configure.zcml
@@ -1834,6 +1834,9 @@
             interface="lp.registry.interfaces.distribution.IDistributionEditRestricted"
             set_schema="lp.app.interfaces.informationtype.IInformationType"/>
         <require
+            permission="launchpad.SecurityAdmin"
+            interface="lp.registry.interfaces.distribution.IDistributionSecurityAdminRestricted" />
+        <require
             permission="launchpad.Edit"
             set_attributes="answers_usage blueprints_usage codehosting_usage
                             bug_tracking_usage uses_launchpad"/>
@@ -1866,6 +1869,7 @@
                 package_derivatives_email
                 redirect_default_traversal
                 redirect_release_uploads
+                security_admin
                 summary
                 vcs
                 "/>
diff --git a/lib/lp/registry/interfaces/distribution.py b/lib/lp/registry/interfaces/distribution.py
index 58e10fc..727d204 100644
--- a/lib/lp/registry/interfaces/distribution.py
+++ b/lib/lp/registry/interfaces/distribution.py
@@ -480,6 +480,11 @@ 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')
+
     vulnerabilities = exported(
         doNotSnapshot(CollectionField(
             description=_("Vulnerabilities in this distribution."),
@@ -889,6 +894,10 @@ class IDistributionEditRestricted(IOfficialBugTagTargetRestricted):
     def deleteOCICredentials():
         """Delete any existing OCI credentials for the distribution."""
 
+
+class IDistributionSecurityAdminRestricted(Interface):
+    """IDistribution properties requiring launchpad.SecurityAdmin permission"""
+
     @call_with(creator=REQUEST_USER)
     @operation_parameters(
         status=Choice(
@@ -954,10 +963,10 @@ class IDistributionEditRestricted(IOfficialBugTagTargetRestricted):
 
 @exported_as_webservice_entry(as_of="beta")
 class IDistribution(
-        IDistributionEditRestricted, IDistributionPublic,
-        IDistributionLimitedView, IDistributionView, IHasBugSupervisor,
-        IFAQTarget, IQuestionTarget, IStructuralSubscriptionTarget,
-        IInformationType):
+        IDistributionEditRestricted, IDistributionSecurityAdminRestricted,
+        IDistributionPublic,IDistributionLimitedView, IDistributionView,
+        IHasBugSupervisor, IFAQTarget, IQuestionTarget,
+        IStructuralSubscriptionTarget, IInformationType):
     """An operating system distribution.
 
     Launchpadlib example: retrieving the current version of a package in a
diff --git a/lib/lp/registry/model/distribution.py b/lib/lp/registry/model/distribution.py
index 3abe8fb..4606d82 100644
--- a/lib/lp/registry/model/distribution.py
+++ b/lib/lp/registry/model/distribution.py
@@ -685,6 +685,9 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
     language_pack_admin = ForeignKey(
         dbName='language_pack_admin', foreignKey='Person',
         storm_validator=validate_public_person, notNull=False, default=None)
+    security_admin = ForeignKey(
+        dbName='security_admin', foreignKey='Person',
+        storm_validator=validate_public_person, notNull=False, default=None)
 
     @cachedproperty
     def main_archive(self):
diff --git a/lib/lp/registry/tests/test_distribution.py b/lib/lp/registry/tests/test_distribution.py
index b6d6ad6..8c80631 100644
--- a/lib/lp/registry/tests/test_distribution.py
+++ b/lib/lp/registry/tests/test_distribution.py
@@ -87,6 +87,7 @@ from lp.soyuz.interfaces.distributionsourcepackagerelease import (
     )
 from lp.testing import (
     admin_logged_in,
+    anonymous_logged_in,
     api_url,
     celebrity_logged_in,
     login,
@@ -1826,6 +1827,33 @@ class TestDistributionVulnerabilities(TestCaseWithFactory):
 
     layer = DatabaseFunctionalLayer
 
+    def assert_newVulnerability_only_the_required_params(
+            self,
+            distribution,
+            creator
+    ):
+        vulnerability = distribution.newVulnerability(
+            status=VulnerabilityStatus.NEEDS_TRIAGE,
+            information_type=InformationType.PUBLIC,
+            creator=creator,
+        )
+
+        self.assertThat(
+            vulnerability,
+            MatchesStructure.byEquality(
+                status=VulnerabilityStatus.NEEDS_TRIAGE,
+                creator=creator,
+                importance=BugTaskImportance.UNDECIDED,
+                information_type=InformationType.PUBLIC,
+                cve=None,
+                description=None,
+                notes=None,
+                mitigation=None,
+                importance_explanation=None,
+                date_made_public=None
+            )
+        )
+
     def test_vulnerabilities_no_vulnerability_present(self):
         distribution = self.factory.makeDistribution()
         self.assertEqual(0, distribution.vulnerabilities.count())
@@ -1843,33 +1871,34 @@ class TestDistributionVulnerabilities(TestCaseWithFactory):
             set(distribution.vulnerabilities),
         )
 
+    def test_set_security_admin_permissions(self):
+        distribution = self.factory.makeDistribution()
+        person = self.factory.makePerson()
+        person2 = self.factory.makePerson()
+        security_team = self.factory.makeTeam(members=[person])
+
+        with person_logged_in(distribution.owner):
+            distribution.security_admin = security_team
+            self.assertEqual(security_team, distribution.security_admin)
+
+        with admin_logged_in():
+            distribution.security_admin = None
+            self.assertIsNone(distribution.security_admin)
+
+        with person_logged_in(person2), ExpectedException(Unauthorized):
+            distribution.security_admin = security_team
+
+        with anonymous_logged_in(), ExpectedException(Unauthorized):
+            distribution.security_admin = security_team
+
     def test_newVulnerability_default_arguments(self):
         distribution = self.factory.makeDistribution()
         owner = distribution.owner
 
         with person_logged_in(owner):
-            # The distribution owner can create a new vulnerability in
-            # the distribution.
-            vulnerability = distribution.newVulnerability(
-                status=VulnerabilityStatus.NEEDS_TRIAGE,
-                information_type=InformationType.PUBLIC,
-                creator=owner,
-            )
-
-            self.assertThat(
-                vulnerability,
-                MatchesStructure.byEquality(
-                    status=VulnerabilityStatus.NEEDS_TRIAGE,
-                    creator=owner,
-                    importance=BugTaskImportance.UNDECIDED,
-                    information_type=InformationType.PUBLIC,
-                    cve=None,
-                    description=None,
-                    notes=None,
-                    mitigation=None,
-                    importance_explanation=None,
-                    date_made_public=None
-                )
+            self.assert_newVulnerability_only_the_required_params(
+                distribution,
+                creator=owner
             )
 
     def test_newVulnerability_all_parameters(self):
@@ -1909,6 +1938,54 @@ class TestDistributionVulnerabilities(TestCaseWithFactory):
                 )
             )
 
+    def test_newVulnerability_permissions(self):
+        person = self.factory.makePerson()
+        distribution = self.factory.makeDistribution()
+        security_team = self.factory.makeTeam(members=[person])
+
+        # The distribution owner, admin, can create a new vulnerability
+        # in the distribution.
+        with person_logged_in(distribution.owner):
+            self.assert_newVulnerability_only_the_required_params(
+                distribution,
+                creator=distribution.owner
+            )
+
+        with admin_logged_in():
+            self.assert_newVulnerability_only_the_required_params(
+                distribution,
+                creator=person
+            )
+        self.factory.makeCommercialSubscription(pillar=distribution)
+
+        with celebrity_logged_in('commercial_admin'):
+            self.assert_newVulnerability_only_the_required_params(
+                distribution,
+                creator=person
+            )
+
+        with person_logged_in(distribution.owner):
+            distribution.security_admin = security_team
+
+        # When the security admin is set for the distribution,
+        # users in that team can create a new vulnerability in the
+        # distribution.
+        with person_logged_in(person):
+            self.assert_newVulnerability_only_the_required_params(
+                distribution,
+                creator=person
+            )
+
+    def test_newVulnerability_cannot_be_called_by_unprivileged_users(self):
+        person = self.factory.makePerson()
+        distribution = self.factory.makeDistribution()
+        with person_logged_in(person), ExpectedException(Unauthorized):
+            distribution.newVulnerability(
+                status=VulnerabilityStatus.NEEDS_TRIAGE,
+                information_type=InformationType.PUBLIC,
+                creator=person,
+            )
+
     def test_getVulnerability_non_existent_id(self):
         distribution = self.factory.makeDistribution()
         vulnerability = distribution.getVulnerability(9999999)
@@ -2159,6 +2236,66 @@ class TestDistributionVulnerabilitiesWebService(TestCaseWithFactory):
             "date_made_public": Is(None),
         }))
 
+    def test_newVulnerability_security_admin(self):
+        distribution = self.factory.makeDistribution()
+        person = self.factory.makePerson()
+        security_team = self.factory.makeTeam(members=[person])
+        api_base = "http://api.launchpad.test/devel";
+        distribution_url = api_base + api_url(distribution)
+        owner_url = api_base + api_url(person)
+
+        with person_logged_in(distribution.owner):
+            distribution.security_admin = security_team
+
+        webservice = webservice_for_person(
+            person,
+            permission=OAuthPermission.WRITE_PRIVATE,
+            default_api_version="devel"
+        )
+        response = webservice.named_post(
+            distribution_url,
+            'newVulnerability',
+            status='Needs triage',
+            information_type='Public',
+            creator=owner_url,
+        )
+        self.assertEqual(200, response.status)
+        self.assertThat(response.jsonBody(), ContainsDict({
+            "distribution_link": Equals(distribution_url),
+            "id": IsInstance(int),
+            "cve_link": Is(None),
+            "creator_link": Equals(owner_url),
+            "status": Equals("Needs triage"),
+            "description": Is(None),
+            "notes": Is(None),
+            "mitigation": Is(None),
+            "importance": Equals("Undecided"),
+            "importance_explanation": Is(None),
+            "information_type": Equals("Public"),
+            "date_made_public": Is(None),
+        }))
+
+    def test_newVulnerability_unauthorized_users(self):
+        distribution = self.factory.makeDistribution()
+        person = self.factory.makePerson()
+        api_base = "http://api.launchpad.test/devel";
+        distribution_url = api_base + api_url(distribution)
+        owner_url = api_base + api_url(person)
+
+        webservice = webservice_for_person(
+            person,
+            permission=OAuthPermission.WRITE_PRIVATE,
+            default_api_version="devel"
+        )
+        response = webservice.named_post(
+            distribution_url,
+            'newVulnerability',
+            status='Needs triage',
+            information_type='Public',
+            creator=owner_url,
+        )
+        self.assertEqual(401, response.status)
+
     def test_newVulnerability_all_parameters(self):
         distribution = self.factory.makeDistribution()
         owner = distribution.owner
diff --git a/lib/lp/security.py b/lib/lp/security.py
index c8f518f..2076988 100644
--- a/lib/lp/security.py
+++ b/lib/lp/security.py
@@ -5,6 +5,7 @@
 
 __all__ = [
     'BugTargetOwnerOrBugSupervisorOrAdmins',
+    'is_commercial_case',
     'ModerateByRegistryExpertsOrAdmins',
     ]
 
@@ -1290,6 +1291,21 @@ class EditDistributionByDistroOwnersOrAdmins(AuthorizationBase):
             or user.in_admin)
 
 
+class EditDistributionVulnerabilities(AuthorizationBase):
+    """The security admins of a distribution should be able to create
+    and edit vulnerabilities in the distribution."""
+    permission = 'launchpad.SecurityAdmin'
+    usedfor = IDistribution
+
+    def checkAuthenticated(self, user):
+        return (
+            user.isOwner(self.obj)
+            or is_commercial_case(self.obj, user)
+            or user.in_admin
+            or user.inTeam(self.obj.security_admin)
+        )
+
+
 class ModerateDistributionByDriversOrOwnersOrAdmins(AuthorizationBase):
     """Distribution drivers, owners, and admins may plan releases.
 

Follow ups