Merge ~lgp171188/launchpad:vulnerability-subscription-model into launchpad:master

Proposed by Guruprasad
Status: Merged
Approved by: Guruprasad
Approved revision: d83e7815319c68cc2dd74a4d9f5f404fe5f6cfc1
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~lgp171188/launchpad:vulnerability-subscription-model
Merge into: launchpad:master
Diff against target: 1926 lines (+1211/-35)
15 files modified
lib/lp/bugs/interfaces/vulnerability.py (+33/-1)
lib/lp/bugs/interfaces/vulnerabilitysubscription.py (+43/-0)
lib/lp/bugs/model/tests/test_vulnerability.py (+440/-1)
lib/lp/bugs/model/tests/test_vulnerabilitysubscription.py (+95/-0)
lib/lp/bugs/model/vulnerability.py (+229/-3)
lib/lp/bugs/model/vulnerabilitysubscription.py (+61/-0)
lib/lp/bugs/security.py (+15/-0)
lib/lp/registry/browser/pillar.py (+24/-2)
lib/lp/registry/interfaces/accesspolicy.py (+2/-1)
lib/lp/registry/interfaces/sharingservice.py (+27/-1)
lib/lp/registry/model/accesspolicy.py (+30/-7)
lib/lp/registry/personmerge.py (+12/-6)
lib/lp/registry/services/sharingservice.py (+53/-1)
lib/lp/registry/services/tests/test_sharingservice.py (+80/-12)
lib/lp/registry/tests/test_personmerge.py (+67/-0)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Andrey Fedoseev (community) Approve
Review via email: mp+427274@code.launchpad.net

Commit message

Implement the VulnerabilitySubscription model and related changes

This does not yet notify the subscribers about changes to the
vulnerabilities that they are subscribed to.

To post a comment you must log in.
Revision history for this message
Andrey Fedoseev (andrey-fedoseev) wrote :

Looks good. I left a couple of minor comments.

I think it would be nice to have type annotations added to the new `Vulnerability` methods.

review: Approve
Revision history for this message
Guruprasad (lgp171188) :
Revision history for this message
Colin Watson (cjwatson) :
review: Approve
Revision history for this message
Andrey Fedoseev (andrey-fedoseev) :
Revision history for this message
Colin Watson (cjwatson) :
Revision history for this message
Guruprasad (lgp171188) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/bugs/interfaces/vulnerability.py b/lib/lp/bugs/interfaces/vulnerability.py
2index ac60ca2..6fe9abc 100644
3--- a/lib/lp/bugs/interfaces/vulnerability.py
4+++ b/lib/lp/bugs/interfaces/vulnerability.py
5@@ -13,7 +13,7 @@ __all__ = [
6
7 from lazr.enum import DBEnumeratedType, DBItem
8 from lazr.restful.declarations import exported, exported_as_webservice_entry
9-from lazr.restful.fields import Reference
10+from lazr.restful.fields import CollectionField, Reference
11 from zope.interface import Interface
12 from zope.schema import Choice, Datetime, Int, TextLine
13
14@@ -133,6 +133,35 @@ class IVulnerabilityView(Interface):
15 ),
16 as_of="devel",
17 )
18+ subscriptions = CollectionField(
19+ title=_("VulnerabilitySubscriptions for this vulnerability."),
20+ readonly=True,
21+ value_type=Reference(Interface),
22+ )
23+
24+ subscribers = CollectionField(
25+ title=_("Persons subscribed to this vulnerability."),
26+ readonly=True,
27+ value_type=Reference(IPerson),
28+ )
29+
30+ def visibleByUser(user):
31+ """Can this user see this vulnerability?"""
32+
33+ def getSubscription(person):
34+ """Returns the person's subscription for this vulnerability."""
35+
36+ def hasSubscription(person):
37+ """Is this person subscribed to this vulnerability?"""
38+
39+ def userCanBeSubscribed(person):
40+ """Can this person be subscribed to this vulnerability?"""
41+
42+ def subscribe(person, subscribed_by):
43+ """Subscribe a person to this vulnerability."""
44+
45+ def unsubscribe(person, unsubscribed_by):
46+ """Unsubscribe a person from this vulnerability."""
47
48
49 class IVulnerabilityEditableAttributes(Interface):
50@@ -285,6 +314,9 @@ class IVulnerabilitySet(Interface):
51 :param date_made_public: The date this vulnerability was made public.
52 """
53
54+ def findByIds(vulnerability_ids, visible_by_user=None):
55+ """Returns the vulnerabilities with the given IDs."""
56+
57
58 class IVulnerabilityActivity(Interface):
59 """`IVulnerabilityActivity` attributes that require launchpad.View."""
60diff --git a/lib/lp/bugs/interfaces/vulnerabilitysubscription.py b/lib/lp/bugs/interfaces/vulnerabilitysubscription.py
61new file mode 100644
62index 0000000..9b83def
63--- /dev/null
64+++ b/lib/lp/bugs/interfaces/vulnerabilitysubscription.py
65@@ -0,0 +1,43 @@
66+# Copyright 2022 Canonical Ltd. This software is licensed under the
67+# GNU Affero General Public License version 3 (see the file LICENSE).
68+
69+"""Vulnerability subscription model."""
70+
71+__all__ = ["IVulnerabilitySubscription"]
72+
73+from lazr.restful.fields import Reference
74+from zope.interface import Interface
75+from zope.schema import Datetime, Int
76+
77+from lp import _
78+from lp.bugs.interfaces.vulnerability import IVulnerability
79+from lp.services.fields import PersonChoice
80+
81+
82+class IVulnerabilitySubscription(Interface):
83+ """A person subscription to a specific Vulnerability."""
84+
85+ id = Int(title=_("ID"), readonly=True, required=True)
86+ person = PersonChoice(
87+ title=_("Person"),
88+ required=True,
89+ vocabulary="ValidPersonOrTeam",
90+ readonly=True,
91+ description=_("The person subscribed to the related vulnerability."),
92+ )
93+ vulnerability = Reference(
94+ IVulnerability, title=_("Vulnerability"), required=True, readonly=True
95+ )
96+ subscribed_by = PersonChoice(
97+ title=("Subscribed by"),
98+ required=True,
99+ vocabulary="ValidPersonOrTeam",
100+ readonly=True,
101+ description=_("The person who created this subscription."),
102+ )
103+ date_created = Datetime(
104+ title=_("Date subscribed"), required=True, readonly=True
105+ )
106+
107+ def canBeUnsubscribedByUser(user):
108+ """Can the user unsubscribe the subscriber from the vulnerability?"""
109diff --git a/lib/lp/bugs/model/tests/test_vulnerability.py b/lib/lp/bugs/model/tests/test_vulnerability.py
110index 32a902f..138e23d 100644
111--- a/lib/lp/bugs/model/tests/test_vulnerability.py
112+++ b/lib/lp/bugs/model/tests/test_vulnerability.py
113@@ -2,16 +2,29 @@
114 # GNU Affero General Public License version 3 (see the file LICENSE).
115
116 """Tests for the vulnerability and related models."""
117+from fixtures import MockPatch
118+from storm.store import Store
119 from testtools.matchers import MatchesStructure
120 from zope.component import getUtility
121+from zope.security.proxy import removeSecurityProxy
122
123+from lp.app.enums import InformationType
124+from lp.app.errors import (
125+ SubscriptionPrivacyViolation,
126+ UserCannotUnsubscribePerson,
127+)
128+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
129+from lp.app.interfaces.services import IService
130 from lp.bugs.enums import VulnerabilityStatus
131 from lp.bugs.interfaces.buglink import IBugLinkTarget
132+from lp.bugs.interfaces.bugtask import BugTaskImportance
133 from lp.bugs.interfaces.vulnerability import (
134 IVulnerability,
135 IVulnerabilitySet,
136 VulnerabilityChange,
137 )
138+from lp.bugs.model.vulnerabilitysubscription import VulnerabilitySubscription
139+from lp.registry.enums import BugSharingPolicy, TeamMembershipPolicy
140 from lp.services.webapp.authorization import check_permission
141 from lp.testing import (
142 TestCaseWithFactory,
143@@ -23,6 +36,16 @@ from lp.testing import (
144 from lp.testing.layers import DatabaseFunctionalLayer
145
146
147+def grant_access_to_non_public_vulnerability(vulnerability, person):
148+ distribution = removeSecurityProxy(vulnerability).distribution
149+ with person_logged_in(distribution.owner):
150+ getUtility(IService, "sharing").ensureAccessGrants(
151+ [person],
152+ distribution.owner,
153+ vulnerabilities=[vulnerability],
154+ )
155+
156+
157 class TestVulnerability(TestCaseWithFactory):
158
159 layer = DatabaseFunctionalLayer
160@@ -34,6 +57,19 @@ class TestVulnerability(TestCaseWithFactory):
161 distribution=self.distribution
162 )
163
164+ def makeProprietaryDistribution(self):
165+ return self.factory.makeDistribution(
166+ bug_sharing_policy=BugSharingPolicy.PROPRIETARY
167+ )
168+
169+ def makeProprietaryVulnerability(self, distribution=None):
170+ if distribution is None:
171+ distribution = self.makeProprietaryDistribution()
172+ return self.factory.makeVulnerability(
173+ distribution=distribution,
174+ information_type=InformationType.PROPRIETARY,
175+ )
176+
177 def test_Vulnerability_implements_IVulnerability(self):
178 vulnerability = self.factory.makeVulnerability()
179 self.assertTrue(verifyObject(IVulnerability, vulnerability))
180@@ -42,6 +78,327 @@ class TestVulnerability(TestCaseWithFactory):
181 vulnerability = self.factory.makeVulnerability()
182 self.assertTrue(verifyObject(IBugLinkTarget, vulnerability))
183
184+ def test_Vulnerability_subscriptions_subscribers_empty_default(self):
185+ vulnerability = self.factory.makeVulnerability()
186+ self.assertEqual(0, vulnerability.subscribers.count())
187+ self.assertEqual(0, vulnerability.subscriptions.count())
188+
189+ def test_public_vulnerability_visibleByUser(self):
190+ vulnerability = self.factory.makeVulnerability()
191+ self.assertTrue(vulnerability.visibleByUser(None))
192+ self.assertTrue(vulnerability.visibleByUser(self.factory.makePerson()))
193+
194+ def test_non_public_vulnerability_visibleByUser(self):
195+ # XXX lgp171188 - We use the 'Proprietary' sharing policy
196+ # as an example non-public information_type and may have to
197+ # add tests for other non-public types in the future.
198+ distribution = self.makeProprietaryDistribution()
199+ vulnerability = self.makeProprietaryVulnerability(distribution)
200+ allowed_user = self.factory.makePerson()
201+ grant_access_to_non_public_vulnerability(
202+ vulnerability,
203+ allowed_user,
204+ )
205+ with person_logged_in(distribution.owner):
206+ self.assertFalse(vulnerability.visibleByUser(None))
207+ self.assertFalse(
208+ vulnerability.visibleByUser(self.factory.makePerson())
209+ )
210+ self.assertTrue(vulnerability.visibleByUser(allowed_user))
211+
212+ def test_setting_information_type_reconciles_access(self):
213+ mock_reconcile_method = self.useFixture(
214+ MockPatch(
215+ "lp.bugs.model.vulnerability.reconcile_access_for_artifacts"
216+ )
217+ ).mock
218+ vulnerability = self.factory.makeVulnerability()
219+ self.assertEqual(
220+ InformationType.PUBLIC, vulnerability.information_type
221+ )
222+ with person_logged_in(vulnerability.distribution.owner):
223+ vulnerability.information_type = InformationType.PROPRIETARY
224+ mock_reconcile_method.assert_called_with(
225+ [vulnerability],
226+ InformationType.PROPRIETARY,
227+ [vulnerability.distribution],
228+ )
229+
230+ def test_getSubscription_person_is_None(self):
231+ self.assertIsNone(
232+ self.factory.makeVulnerability().getSubscription(None)
233+ )
234+
235+ def test_getSubscription_person_is_not_subscribed(self):
236+ person = self.factory.makePerson()
237+ vulnerability = self.factory.makeVulnerability()
238+ self.assertIsNone(vulnerability.getSubscription(person))
239+
240+ def test_getSubscription_person_is_subscribed(self):
241+ person = self.factory.makePerson()
242+ vulnerability = self.factory.makeVulnerability()
243+ subscription = VulnerabilitySubscription(
244+ person=person, vulnerability=vulnerability, subscribed_by=person
245+ )
246+ self.assertEqual(subscription, vulnerability.getSubscription(person))
247+
248+ def test_hasSubscription(self):
249+ person = self.factory.makePerson()
250+ vulnerability = self.factory.makeVulnerability()
251+ self.assertFalse(vulnerability.hasSubscription(person))
252+ VulnerabilitySubscription(
253+ person=person,
254+ vulnerability=vulnerability,
255+ subscribed_by=person,
256+ )
257+ self.assertTrue(vulnerability.hasSubscription(person))
258+
259+ def test_userCanBeSubscribed_person_public_vulnerability(self):
260+ person = self.factory.makePerson()
261+ vulnerability = self.factory.makeVulnerability()
262+ self.assertTrue(vulnerability.userCanBeSubscribed(person))
263+
264+ def test_userCanBeSubscribed_person_non_public_vulnerability(self):
265+ person = self.factory.makePerson()
266+ vulnerability = removeSecurityProxy(
267+ self.makeProprietaryVulnerability(
268+ self.makeProprietaryDistribution()
269+ )
270+ )
271+ self.assertTrue(vulnerability.userCanBeSubscribed(person))
272+
273+ def test_userCanBeSubscribed_public_vulnerability_non_open_team(self):
274+ team = self.factory.makeTeam(
275+ membership_policy=TeamMembershipPolicy.RESTRICTED
276+ )
277+ self.assertFalse(team.anyone_can_join())
278+ vulnerability = self.factory.makeVulnerability()
279+ self.assertTrue(vulnerability.userCanBeSubscribed(team))
280+
281+ def test_userCanBeSubscribed_non_public_vulnerability_non_open_team(self):
282+ team = self.factory.makeTeam(
283+ membership_policy=TeamMembershipPolicy.RESTRICTED,
284+ )
285+ vulnerability = removeSecurityProxy(
286+ self.makeProprietaryVulnerability(
287+ self.makeProprietaryDistribution()
288+ )
289+ )
290+ self.assertTrue(vulnerability.userCanBeSubscribed(team))
291+
292+ def test_userCanBeSubscribed_non_public_vulnerability_open_team(self):
293+ team = removeSecurityProxy(self.factory.makeTeam())
294+ self.assertTrue(team.anyone_can_join())
295+ vulnerability = removeSecurityProxy(
296+ self.makeProprietaryVulnerability(
297+ self.makeProprietaryDistribution()
298+ )
299+ )
300+ self.assertFalse(vulnerability.userCanBeSubscribed(team))
301+
302+ def test_subscribe_person_to_vulnerability(self):
303+ person = self.factory.makePerson()
304+ vulnerability = self.factory.makeVulnerability()
305+ vulnerability.subscribe(person, vulnerability.distribution.owner)
306+ self.assertTrue(vulnerability.hasSubscription(person))
307+
308+ non_public_vulnerability = removeSecurityProxy(
309+ self.makeProprietaryVulnerability()
310+ )
311+ distribution_owner = non_public_vulnerability.distribution.owner
312+ with person_logged_in(distribution_owner):
313+ non_public_vulnerability.subscribe(
314+ person,
315+ distribution_owner,
316+ )
317+ self.assertTrue(non_public_vulnerability.hasSubscription(person))
318+
319+ def test_subscribe_open_team_non_public_vulnerability(self):
320+ open_team = self.factory.makeTeam()
321+ vulnerability = removeSecurityProxy(
322+ self.makeProprietaryVulnerability()
323+ )
324+ distribution_owner = vulnerability.distribution.owner
325+ with person_logged_in(distribution_owner):
326+ self.assertRaises(
327+ SubscriptionPrivacyViolation,
328+ vulnerability.subscribe,
329+ open_team,
330+ distribution_owner,
331+ )
332+
333+ def test_subscribe_open_team_public_vulnerability(self):
334+ open_team = self.factory.makeTeam()
335+ vulnerability = self.factory.makeVulnerability()
336+ self.assertFalse(vulnerability.hasSubscription(open_team))
337+ vulnerability.subscribe(open_team, vulnerability.distribution.owner)
338+ self.assertTrue(vulnerability.hasSubscription(open_team))
339+
340+ def test_subscribe_subscribing_a_person_with_existing_subscription(self):
341+ person = self.factory.makePerson()
342+ vulnerability = self.factory.makeVulnerability()
343+ vulnerability.subscribe(
344+ person,
345+ vulnerability.distribution.owner,
346+ )
347+ self.assertTrue(vulnerability.hasSubscription(person))
348+ self.assertEqual(
349+ 1,
350+ Store.of(vulnerability)
351+ .find(
352+ VulnerabilitySubscription,
353+ VulnerabilitySubscription.person == person,
354+ VulnerabilitySubscription.vulnerability == vulnerability,
355+ )
356+ .count(),
357+ )
358+ vulnerability.subscribe(
359+ person,
360+ vulnerability.distribution.owner,
361+ )
362+ self.assertTrue(vulnerability.hasSubscription(person))
363+ self.assertEqual(
364+ 1,
365+ Store.of(vulnerability)
366+ .find(
367+ VulnerabilitySubscription,
368+ VulnerabilitySubscription.person == person,
369+ VulnerabilitySubscription.vulnerability == vulnerability,
370+ )
371+ .count(),
372+ )
373+
374+ vulnerability2 = removeSecurityProxy(
375+ self.makeProprietaryVulnerability()
376+ )
377+ distribution_owner = vulnerability2.distribution.owner
378+ with person_logged_in(distribution_owner):
379+ vulnerability2.subscribe(person, distribution_owner)
380+ self.assertTrue(vulnerability2.hasSubscription(person))
381+ vulnerability2.subscribe(person, distribution_owner)
382+ self.assertTrue(vulnerability2.hasSubscription(person))
383+
384+ def test_subscribing_to_non_public_vulnerability_makes_it_visible(self):
385+ person = self.factory.makePerson()
386+ vulnerability = self.makeProprietaryVulnerability()
387+ distribution_owner = removeSecurityProxy(
388+ vulnerability
389+ ).distribution.owner
390+ with person_logged_in(person):
391+ self.assertFalse(check_permission("launchpad.View", vulnerability))
392+ self.assertFalse(check_permission("launchpad.Edit", vulnerability))
393+
394+ with person_logged_in(distribution_owner):
395+ vulnerability.subscribe(person, distribution_owner)
396+ with person_logged_in(person):
397+ self.assertTrue(check_permission("launchpad.View", vulnerability))
398+ self.assertFalse(check_permission("launchpad.Edit", vulnerability))
399+
400+ def test_subscribers_subscriptions(self):
401+ person1 = self.factory.makePerson()
402+ person2 = self.factory.makePerson()
403+ vulnerability = self.factory.makeVulnerability()
404+ self.assertEqual(0, vulnerability.subscriptions.count())
405+ self.assertEqual(0, vulnerability.subscribers.count())
406+ vulnerability.subscribe(person1, person1)
407+ vulnerability.subscribe(person2, person2)
408+ self.assertContentEqual({person1, person2}, vulnerability.subscribers)
409+ self.assertEqual(2, vulnerability.subscriptions.count())
410+
411+ def test_unsubscribe_user_not_subscribed(self):
412+ person = self.factory.makePerson()
413+ vulnerability = self.factory.makeVulnerability()
414+ self.assertFalse(vulnerability.hasSubscription(person))
415+ vulnerability.unsubscribe(person, person)
416+ self.assertFalse(vulnerability.hasSubscription(person))
417+
418+ def test_unsubscribe_random_user_cannot_unsubscribe_a_subscriber(self):
419+ person = self.factory.makePerson()
420+ person2 = self.factory.makePerson()
421+ vulnerability = self.factory.makeVulnerability()
422+ vulnerability.subscribe(person, person)
423+ self.assertRaises(
424+ UserCannotUnsubscribePerson,
425+ vulnerability.unsubscribe,
426+ person,
427+ person2,
428+ )
429+
430+ def test_unsubscribe_self(self):
431+ person = self.factory.makePerson()
432+ vulnerability = self.factory.makeVulnerability()
433+ vulnerability.subscribe(person, person)
434+ self.assertTrue(vulnerability.hasSubscription(person))
435+ vulnerability.unsubscribe(person, person)
436+ self.assertFalse(vulnerability.hasSubscription(person))
437+
438+ def test_vulnerability_creator_can_unsubscribe_subscribers(self):
439+ creator_member = self.factory.makePerson()
440+ person = self.factory.makePerson()
441+ creator = self.factory.makeTeam(members=[creator_member])
442+ vulnerability = self.factory.makeVulnerability(creator=creator)
443+ vulnerability.subscribe(person, person)
444+ self.assertTrue(vulnerability.hasSubscription(person))
445+ vulnerability.unsubscribe(person, creator_member)
446+ self.assertFalse(vulnerability.hasSubscription(person))
447+
448+ def test_distribution_owner_can_unsubscribe_subscribers(self):
449+ person = self.factory.makePerson()
450+ vulnerability = self.factory.makeVulnerability()
451+ vulnerability.subscribe(person, person)
452+ self.assertTrue(vulnerability.hasSubscription(person))
453+ vulnerability.unsubscribe(person, vulnerability.distribution.owner)
454+ self.assertFalse(vulnerability.hasSubscription(person))
455+
456+ def test_distribution_security_admins_can_unsubscribe_subscribers(self):
457+ person = self.factory.makePerson()
458+ security_member = self.factory.makePerson()
459+ vulnerability = self.factory.makeVulnerability()
460+ with person_logged_in(vulnerability.distribution.owner):
461+ vulnerability.distribution.security_admin = self.factory.makeTeam(
462+ members=[security_member]
463+ )
464+ vulnerability.subscribe(person, person)
465+ self.assertTrue(vulnerability.hasSubscription(person))
466+ vulnerability.unsubscribe(person, security_member)
467+ self.assertFalse(vulnerability.hasSubscription(person))
468+
469+ def test_creator_of_a_subscription_can_unsubscribe_the_subscriber(self):
470+ person = self.factory.makePerson()
471+ person2 = self.factory.makePerson()
472+ vulnerability = self.factory.makeVulnerability()
473+ vulnerability.subscribe(person2, person)
474+ self.assertTrue(vulnerability.hasSubscription(person2))
475+ vulnerability.unsubscribe(person2, person)
476+ self.assertFalse(vulnerability.hasSubscription(person2))
477+
478+ def test_admins_can_unsubscribe_subscribers(self):
479+ person = self.factory.makePerson()
480+ vulnerability = self.factory.makeVulnerability()
481+ vulnerability.subscribe(person, person)
482+ self.assertTrue(vulnerability.hasSubscription(person))
483+ vulnerability.unsubscribe(
484+ person, getUtility(ILaunchpadCelebrities).admin.teamowner
485+ )
486+ self.assertFalse(vulnerability.hasSubscription(person))
487+
488+ def test_unsubscribe_removes_visibility_of_non_public_vulnerability(self):
489+ person = self.factory.makePerson()
490+ vulnerability = removeSecurityProxy(
491+ self.makeProprietaryVulnerability()
492+ )
493+ distribution_owner = vulnerability.distribution.owner
494+ with person_logged_in(distribution_owner):
495+ vulnerability.subscribe(person, distribution_owner)
496+
497+ with person_logged_in(person):
498+ self.assertTrue(check_permission("launchpad.View", vulnerability))
499+ vulnerability.unsubscribe(person, person)
500+
501+ # Have to re-login again for the permission cache to get invalidated.
502+ with person_logged_in(person):
503+ self.assertFalse(check_permission("launchpad.View", vulnerability))
504+
505 def test_random_user_permissions(self):
506 with person_logged_in(self.factory.makePerson()):
507 self.assertTrue(
508@@ -51,6 +408,18 @@ class TestVulnerability(TestCaseWithFactory):
509 check_permission("launchpad.Edit", self.vulnerability)
510 )
511
512+ def test_random_user_permissions_non_public_vulnerability(self):
513+ vulnerability = self.makeProprietaryVulnerability()
514+ with person_logged_in(self.factory.makePerson()):
515+ self.assertFalse(check_permission("launchpad.View", vulnerability))
516+
517+ def test_user_can_view_shared_non_public_vulnerability(self):
518+ person = self.factory.makePerson()
519+ vulnerability = self.makeProprietaryVulnerability()
520+ grant_access_to_non_public_vulnerability(vulnerability, person)
521+ with person_logged_in(person):
522+ self.assertTrue(check_permission("launchpad.View", vulnerability))
523+
524 def test_admin_permissions(self):
525 with admin_logged_in():
526 self.assertTrue(
527@@ -82,12 +451,22 @@ class TestVulnerability(TestCaseWithFactory):
528
529 def test_anonymous_permissions(self):
530 with anonymous_logged_in():
531- self.assertFalse(
532+ self.assertTrue(
533 check_permission("launchpad.View", self.vulnerability)
534 )
535 self.assertFalse(
536 check_permission("launchpad.Edit", self.vulnerability)
537 )
538+ distribution = self.factory.makeDistribution(
539+ bug_sharing_policy=BugSharingPolicy.PROPRIETARY
540+ )
541+ vulnerability = self.factory.makeVulnerability(
542+ distribution=distribution,
543+ information_type=InformationType.PROPRIETARY,
544+ )
545+ with anonymous_logged_in():
546+ self.assertFalse(check_permission("launchpad.View", vulnerability))
547+ self.assertFalse(check_permission("launchpad.Edit", vulnerability))
548
549 def test_edit_vulnerability_security_admin(self):
550 person = self.factory.makePerson()
551@@ -174,3 +553,63 @@ class TestVulnerabilitySet(TestCaseWithFactory):
552 initial_number,
553 (len(vulnerability1.bugs) + len(vulnerability2.bugs)),
554 )
555+
556+ def test_access_reconciled_after_creating_a_vulnerability(self):
557+ mock_reconcile_method = self.useFixture(
558+ MockPatch(
559+ "lp.bugs.model.vulnerability.reconcile_access_for_artifacts"
560+ )
561+ ).mock
562+ distribution = self.factory.makeDistribution()
563+ creator = self.factory.makePerson()
564+ vulnerability = getUtility(IVulnerabilitySet).new(
565+ distribution=distribution,
566+ status=VulnerabilityStatus.NEEDS_TRIAGE,
567+ importance=BugTaskImportance.UNDECIDED,
568+ creator=creator,
569+ )
570+ mock_reconcile_method.assert_called_with(
571+ [vulnerability], vulnerability.information_type, [distribution]
572+ )
573+
574+ def test_findByIds(self):
575+ person = self.factory.makePerson()
576+ proprietary_distribution = self.factory.makeDistribution(
577+ bug_sharing_policy=BugSharingPolicy.PROPRIETARY,
578+ )
579+ vulnerability1 = removeSecurityProxy(self.factory.makeVulnerability())
580+ vulnerability2 = removeSecurityProxy(
581+ self.factory.makeVulnerability(
582+ distribution=proprietary_distribution,
583+ information_type=InformationType.PROPRIETARY,
584+ )
585+ )
586+ vulnerability3 = removeSecurityProxy(
587+ self.factory.makeVulnerability(
588+ distribution=proprietary_distribution,
589+ information_type=InformationType.PROPRIETARY,
590+ )
591+ )
592+ grant_access_to_non_public_vulnerability(vulnerability2, person)
593+ vulnerability_set = getUtility(IVulnerabilitySet)
594+ self.assertContentEqual(
595+ {vulnerability1, vulnerability2, vulnerability3},
596+ vulnerability_set.findByIds(
597+ [
598+ vulnerability1.id,
599+ vulnerability2.id,
600+ vulnerability3.id,
601+ ]
602+ ),
603+ )
604+ self.assertContentEqual(
605+ {vulnerability1, vulnerability2},
606+ vulnerability_set.findByIds(
607+ [
608+ vulnerability1.id,
609+ vulnerability2.id,
610+ vulnerability3.id,
611+ ],
612+ visible_by_user=person,
613+ ),
614+ )
615diff --git a/lib/lp/bugs/model/tests/test_vulnerabilitysubscription.py b/lib/lp/bugs/model/tests/test_vulnerabilitysubscription.py
616new file mode 100644
617index 0000000..6d8124c
618--- /dev/null
619+++ b/lib/lp/bugs/model/tests/test_vulnerabilitysubscription.py
620@@ -0,0 +1,95 @@
621+# Copyright 2022 Canonical Ltd. This software is licensed under the
622+# GNU Affero General Public License version 3 (see the file LICENSE).
623+
624+"""Tests for the VulnerabilitySubscription model."""
625+from zope.component import getUtility
626+
627+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
628+from lp.bugs.interfaces.vulnerabilitysubscription import (
629+ IVulnerabilitySubscription,
630+)
631+from lp.bugs.model.vulnerabilitysubscription import VulnerabilitySubscription
632+from lp.testing import TestCaseWithFactory, person_logged_in, verifyObject
633+from lp.testing.layers import DatabaseFunctionalLayer
634+
635+
636+class TestVulnerabilitySubscription(TestCaseWithFactory):
637+
638+ layer = DatabaseFunctionalLayer
639+
640+ def test_VulnerabilitySubscription_implements_its_interface(self):
641+ person = self.factory.makePerson()
642+ vulnerability = self.factory.makeVulnerability()
643+ subscription = VulnerabilitySubscription(vulnerability, person, person)
644+ self.assertTrue(verifyObject(IVulnerabilitySubscription, subscription))
645+
646+ def test_canBeUnsubscribedByUser_user_None(self):
647+ person = self.factory.makePerson()
648+ subscription = VulnerabilitySubscription(
649+ self.factory.makeVulnerability(), person, person
650+ )
651+ self.assertFalse(subscription.canBeUnsubscribedByUser(None))
652+
653+ def test_canBeUnsubscribedByUser_self(self):
654+ person = self.factory.makePerson()
655+ subscription = VulnerabilitySubscription(
656+ self.factory.makeVulnerability(), person, self.factory.makePerson()
657+ )
658+ self.assertTrue(subscription.canBeUnsubscribedByUser(person))
659+
660+ def test_canBeUnsubscribedByUser_random_user(self):
661+ person = self.factory.makePerson()
662+ subscription = VulnerabilitySubscription(
663+ self.factory.makeVulnerability(), person, person
664+ )
665+ self.assertFalse(
666+ subscription.canBeUnsubscribedByUser(self.factory.makePerson())
667+ )
668+
669+ def test_canBeUnsubscribedByUser_vulnerability_creator(self):
670+ person = self.factory.makePerson()
671+ creator_member = self.factory.makePerson()
672+ creator = self.factory.makeTeam(members=[creator_member])
673+ subscription = VulnerabilitySubscription(
674+ self.factory.makeVulnerability(creator=creator), person, person
675+ )
676+ self.assertTrue(subscription.canBeUnsubscribedByUser(creator_member))
677+
678+ def test_canBeUnsubscribedByUser_distribution_owner(self):
679+ person = self.factory.makePerson()
680+ vulnerability = self.factory.makeVulnerability()
681+ subscription = VulnerabilitySubscription(vulnerability, person, person)
682+ self.assertTrue(
683+ subscription.canBeUnsubscribedByUser(
684+ vulnerability.distribution.owner
685+ )
686+ )
687+
688+ def test_canBeUnsubscribedByUser_security_admin(self):
689+ person = self.factory.makePerson()
690+ security_admin = self.factory.makePerson()
691+ security_admins = self.factory.makeTeam(members=[security_admin])
692+ vulnerability = self.factory.makeVulnerability()
693+ with person_logged_in(vulnerability.distribution.owner):
694+ vulnerability.distribution.security_admin = security_admins
695+ subscription = VulnerabilitySubscription(vulnerability, person, person)
696+ self.assertTrue(subscription.canBeUnsubscribedByUser(security_admin))
697+
698+ def test_canBeUnsubscribedByUser_subscription_creator(self):
699+ person = self.factory.makePerson()
700+ person2 = self.factory.makePerson()
701+ subscription = VulnerabilitySubscription(
702+ self.factory.makeVulnerability(), person, person2
703+ )
704+ self.assertTrue(subscription.canBeUnsubscribedByUser(person2))
705+
706+ def test_canBeUnsubscribedByUser_admins(self):
707+ person = self.factory.makePerson()
708+ subscription = VulnerabilitySubscription(
709+ self.factory.makeVulnerability(), person, person
710+ )
711+ self.assertTrue(
712+ subscription.canBeUnsubscribedByUser(
713+ getUtility(ILaunchpadCelebrities).admin.teamowner
714+ )
715+ )
716diff --git a/lib/lp/bugs/model/vulnerability.py b/lib/lp/bugs/model/vulnerability.py
717index 25ebea9..63b6e78 100644
718--- a/lib/lp/bugs/model/vulnerability.py
719+++ b/lib/lp/bugs/model/vulnerability.py
720@@ -7,13 +7,21 @@ __all__ = [
721 ]
722
723 import operator
724+from typing import Iterable
725
726 import pytz
727+from storm.expr import SQL, Coalesce, Join, Or, Select
728 from storm.locals import DateTime, Int, Reference, Unicode
729+from storm.store import Store
730 from zope.component import getUtility
731 from zope.interface import implementer
732
733-from lp.app.enums import InformationType
734+from lp.app.enums import PUBLIC_INFORMATION_TYPES, InformationType
735+from lp.app.errors import (
736+ SubscriptionPrivacyViolation,
737+ UserCannotUnsubscribePerson,
738+)
739+from lp.app.interfaces.services import IService
740 from lp.app.model.launchpad import InformationTypeMixin
741 from lp.bugs.enums import VulnerabilityStatus
742 from lp.bugs.interfaces.buglink import IBugLinkTarget
743@@ -27,11 +35,21 @@ from lp.bugs.interfaces.vulnerability import (
744 )
745 from lp.bugs.model.bug import Bug
746 from lp.bugs.model.buglinktarget import BugLinkTargetMixin
747+from lp.bugs.model.vulnerabilitysubscription import VulnerabilitySubscription
748+from lp.registry.interfaces.accesspolicy import (
749+ IAccessArtifactGrantSource,
750+ IAccessArtifactSource,
751+)
752+from lp.registry.interfaces.role import IPersonRoles
753+from lp.registry.model.accesspolicy import reconcile_access_for_artifacts
754+from lp.registry.model.person import Person
755+from lp.registry.model.teammembership import TeamParticipation
756 from lp.services.database import bulk
757 from lp.services.database.constants import UTC_NOW
758 from lp.services.database.enumcol import DBEnum
759 from lp.services.database.interfaces import IStore
760 from lp.services.database.stormbase import StormBase
761+from lp.services.database.stormexpr import Array, ArrayAgg, ArrayIntersects
762 from lp.services.xref.interfaces import IXRefSet
763
764
765@@ -66,7 +84,7 @@ class Vulnerability(StormBase, BugLinkTargetMixin, InformationTypeMixin):
766 name="importance_explanation", allow_none=True
767 )
768
769- information_type = DBEnum(
770+ _information_type = DBEnum(
771 enum=InformationType,
772 default=InformationType.PUBLIC,
773 allow_none=False,
774@@ -103,7 +121,11 @@ class Vulnerability(StormBase, BugLinkTargetMixin, InformationTypeMixin):
775 self.cve = cve
776 self.status = status
777 self.importance = importance
778- self.information_type = information_type
779+ # Set `self._information_type` rather than `self.information_type`
780+ # to avoid the call to `self._reconcileAccess` while constructing
781+ # the instance. `VulnerabilitySet.new` deals with calling
782+ # `_reconcileAccess` once the instance has been fully constructed.
783+ self._information_type = information_type
784 self.creator = creator
785 self.description = description
786 self.notes = notes
787@@ -138,6 +160,141 @@ class Vulnerability(StormBase, BugLinkTargetMixin, InformationTypeMixin):
788 {("vulnerability", str(self.id)): [("bug", str(bug.id))]}
789 )
790
791+ @property
792+ def information_type(self):
793+ return self._information_type
794+
795+ @information_type.setter
796+ def information_type(self, information_type):
797+ if information_type != self._information_type:
798+ self._information_type = information_type
799+ self._reconcileAccess()
800+
801+ def visibleByUser(self, user: Person) -> bool:
802+ """See `IVulnerability`."""
803+ if self.information_type in PUBLIC_INFORMATION_TYPES:
804+ return True
805+ if user is None:
806+ return False
807+ return (
808+ not IStore(self)
809+ .find(
810+ Vulnerability,
811+ Vulnerability.id == self.id,
812+ get_vulnerability_privacy_filter(user),
813+ )
814+ .is_empty()
815+ )
816+
817+ def _reconcileAccess(self) -> None:
818+ """Reconcile the vulnerability's sharing information.
819+
820+ Takes the privacy and distribution and makes the related AccessArtifact
821+ and AccessPolicyArtifacts match.
822+ """
823+ reconcile_access_for_artifacts(
824+ [self], self.information_type, [self.distribution]
825+ )
826+
827+ @property
828+ def subscriptions(self):
829+ return Store.of(self).find(
830+ VulnerabilitySubscription,
831+ VulnerabilitySubscription.vulnerability == self,
832+ )
833+
834+ @property
835+ def subscribers(self):
836+ return Store.of(self).find(
837+ Person,
838+ VulnerabilitySubscription.person_id == Person.id,
839+ VulnerabilitySubscription.vulnerability == self,
840+ )
841+
842+ def getSubscription(self, person: Person) -> VulnerabilitySubscription:
843+ """Returns the person's subscription or None."""
844+ if person is None:
845+ return None
846+ return (
847+ Store.of(self)
848+ .find(
849+ VulnerabilitySubscription,
850+ VulnerabilitySubscription.person == person,
851+ VulnerabilitySubscription.vulnerability == self,
852+ )
853+ .one()
854+ )
855+
856+ def hasSubscription(self, person: Person) -> bool:
857+ """See `IVulnerability`."""
858+ return self.getSubscription(person) is not None
859+
860+ def userCanBeSubscribed(self, person: Person) -> bool:
861+ """See `IVulnerability`."""
862+ return not (
863+ self.information_type not in PUBLIC_INFORMATION_TYPES
864+ and person.is_team
865+ and person.anyone_can_join()
866+ )
867+
868+ def subscribe(
869+ self,
870+ person: Person,
871+ subscribed_by: Person,
872+ ignore_permissions: bool = False,
873+ ) -> None:
874+ """See `IVulnerability`."""
875+ if not self.userCanBeSubscribed(person):
876+ raise SubscriptionPrivacyViolation(
877+ "Open and delegated teams cannot be subscribed to private"
878+ "vulnerabilities."
879+ )
880+ if self.getSubscription(person) is None:
881+ subscription = VulnerabilitySubscription(
882+ person=person, vulnerability=self, subscribed_by=subscribed_by
883+ )
884+ Store.of(subscription).flush()
885+ service = getUtility(IService, "sharing")
886+ vulnerabilities = service.getVisibleArtifacts(
887+ person, vulnerabilities=[self], ignore_permissions=True
888+ )["vulnerabilities"]
889+ if not vulnerabilities:
890+ service.ensureAccessGrants(
891+ [person],
892+ subscribed_by,
893+ vulnerabilities=[self],
894+ ignore_permissions=ignore_permissions,
895+ )
896+
897+ def unsubscribe(
898+ self,
899+ person: Person,
900+ unsubscribed_by: Person,
901+ ignore_permissions: bool = False,
902+ ) -> None:
903+ """See `IVulnerability`."""
904+ subscription = self.getSubscription(person)
905+ if subscription is None:
906+ return
907+ if (
908+ not ignore_permissions
909+ and not subscription.canBeUnsubscribedByUser(unsubscribed_by)
910+ ):
911+ raise UserCannotUnsubscribePerson(
912+ "%s does not have permission to unsubscribe %s"
913+ % (
914+ unsubscribed_by.displayname,
915+ person.displayname,
916+ )
917+ )
918+ artifact = getUtility(IAccessArtifactSource).find([self])
919+ getUtility(IAccessArtifactGrantSource).revokeByArtifact(
920+ artifact, [person]
921+ )
922+ store = Store.of(subscription)
923+ store.remove(subscription)
924+ IStore(self).flush()
925+
926
927 @implementer(IVulnerabilitySet)
928 class VulnerabilitySet:
929@@ -171,9 +328,19 @@ class VulnerabilitySet:
930 date_made_public=date_made_public,
931 )
932 store.add(vulnerability)
933+ vulnerability._reconcileAccess()
934 store.flush()
935 return vulnerability
936
937+ def findByIds(
938+ self, vulnerability_ids: Iterable[Int], visible_by_user: bool = None
939+ ):
940+ """See `IVulnerabilitySet`."""
941+ clauses = [Vulnerability.id.is_in(vulnerability_ids)]
942+ if visible_by_user is not None:
943+ clauses.append(get_vulnerability_privacy_filter(visible_by_user))
944+ return IStore(Vulnerability).find(Vulnerability, *clauses)
945+
946
947 @implementer(IVulnerabilityActivity)
948 class VulnerabilityActivity(StormBase):
949@@ -233,3 +400,62 @@ class VulnerabilityActivitySet:
950 )
951 store.add(activity)
952 return activity
953+
954+
955+def get_vulnerability_privacy_filter(user):
956+ """Returns the filter for all vulnerabilities that the given user has
957+ access to, including private vulnerabilities where the user has proper
958+ permission.
959+
960+ :param user: An IPerson, or a class attribute tha references an IPerson
961+ in the database.
962+ :return: A Storm condition.
963+ """
964+ from lp.registry.model.accesspolicy import AccessPolicyGrant
965+
966+ public_vulnerabilities_filter = Vulnerability._information_type.is_in(
967+ PUBLIC_INFORMATION_TYPES
968+ )
969+
970+ if user is None:
971+ return [public_vulnerabilities_filter]
972+ elif IPersonRoles.providedBy(user):
973+ user = user.person
974+
975+ artifact_grant_query = Coalesce(
976+ ArrayIntersects(
977+ SQL("Vulnerability.access_grants"),
978+ Select(
979+ ArrayAgg(TeamParticipation.teamID),
980+ tables=TeamParticipation,
981+ where=(TeamParticipation.person == user),
982+ ),
983+ ),
984+ False,
985+ )
986+
987+ policy_grant_query = Coalesce(
988+ ArrayIntersects(
989+ Array(SQL("Vulnerability.access_policy")),
990+ Select(
991+ ArrayAgg(AccessPolicyGrant.policy_id),
992+ tables=(
993+ AccessPolicyGrant,
994+ Join(
995+ TeamParticipation,
996+ TeamParticipation.teamID
997+ == AccessPolicyGrant.grantee_id,
998+ ),
999+ ),
1000+ where=(TeamParticipation.person == user),
1001+ ),
1002+ ),
1003+ False,
1004+ )
1005+ return [
1006+ Or(
1007+ public_vulnerabilities_filter,
1008+ artifact_grant_query,
1009+ policy_grant_query,
1010+ )
1011+ ]
1012diff --git a/lib/lp/bugs/model/vulnerabilitysubscription.py b/lib/lp/bugs/model/vulnerabilitysubscription.py
1013new file mode 100644
1014index 0000000..e6faf0f
1015--- /dev/null
1016+++ b/lib/lp/bugs/model/vulnerabilitysubscription.py
1017@@ -0,0 +1,61 @@
1018+# Copyright 2022 Canonical Ltd. This software is licensed under the
1019+# GNU Affero General Public License version 3 (see the file LICENSE).
1020+
1021+"""Vulnerability subscription model."""
1022+
1023+__all__ = ["VulnerabilitySubscription"]
1024+
1025+import pytz
1026+from storm.properties import DateTime, Int
1027+from storm.references import Reference
1028+from zope.interface import implementer
1029+
1030+from lp.bugs.interfaces.vulnerabilitysubscription import (
1031+ IVulnerabilitySubscription,
1032+)
1033+from lp.registry.interfaces.person import validate_person
1034+from lp.registry.interfaces.role import IPersonRoles
1035+from lp.registry.model.person import Person
1036+from lp.services.database.constants import UTC_NOW
1037+from lp.services.database.stormbase import StormBase
1038+
1039+
1040+@implementer(IVulnerabilitySubscription)
1041+class VulnerabilitySubscription(StormBase):
1042+ """A relationship between a person and a vulnerability."""
1043+
1044+ __storm_table__ = "VulnerabilitySubscription"
1045+
1046+ id = Int(primary=True)
1047+
1048+ person_id = Int("person", allow_none=False, validator=validate_person)
1049+ person = Reference(person_id, "Person.id")
1050+
1051+ vulnerability_id = Int("vulnerability", allow_none=False)
1052+ vulnerability = Reference(vulnerability_id, "Vulnerability.id")
1053+
1054+ date_created = DateTime(allow_none=False, default=UTC_NOW, tzinfo=pytz.UTC)
1055+
1056+ subscribed_by_id = Int(
1057+ "subscribed_by", allow_none=False, validator=validate_person
1058+ )
1059+ subscribed_by = Reference(subscribed_by_id, "Person.id")
1060+
1061+ def __init__(self, vulnerability, person, subscribed_by):
1062+ super().__init__()
1063+ self.vulnerability = vulnerability
1064+ self.person = person
1065+ self.subscribed_by = subscribed_by
1066+
1067+ def canBeUnsubscribedByUser(self, user: Person) -> bool:
1068+ """See `IVulnerabilitySubscription`."""
1069+ if user is None:
1070+ return False
1071+ return (
1072+ user.inTeam(self.vulnerability.creator)
1073+ or user.inTeam(self.vulnerability.distribution.owner)
1074+ or user.inTeam(self.vulnerability.distribution.security_admin)
1075+ or user.inTeam(self.person)
1076+ or user.inTeam(self.subscribed_by)
1077+ or IPersonRoles(user).in_admin
1078+ )
1079diff --git a/lib/lp/bugs/security.py b/lib/lp/bugs/security.py
1080index 6141de3..11d74e9 100644
1081--- a/lib/lp/bugs/security.py
1082+++ b/lib/lp/bugs/security.py
1083@@ -417,6 +417,21 @@ class EditBugSubscriptionFilter(AuthorizationBase):
1084 return user.inTeam(self.obj.structural_subscription.subscriber)
1085
1086
1087+class ViewVulnerability(AnonymousAuthorization):
1088+ """Anyone can view public vulnerabilities, but only subscribers
1089+ can view private ones.
1090+ """
1091+
1092+ permission = "launchpad.View"
1093+ usedfor = IVulnerability
1094+
1095+ def checkUnauthenticated(self):
1096+ return self.obj.visibleByUser(None)
1097+
1098+ def checkAuthenticated(self, user):
1099+ return self.obj.visibleByUser(user.person)
1100+
1101+
1102 class EditVulnerability(DelegatedAuthorization):
1103 """The security admins of a distribution should be able to edit
1104 vulnerabilities in that distribution."""
1105diff --git a/lib/lp/registry/browser/pillar.py b/lib/lp/registry/browser/pillar.py
1106index 4f776e9..a5e5eab 100644
1107--- a/lib/lp/registry/browser/pillar.py
1108+++ b/lib/lp/registry/browser/pillar.py
1109@@ -1,4 +1,4 @@
1110-# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
1111+# Copyright 2009-2022 Canonical Ltd. This software is licensed under the
1112 # GNU Affero General Public License version 3 (see the file LICENSE).
1113
1114 """Common views for objects that implement `IPillar`."""
1115@@ -445,10 +445,13 @@ class PillarPersonSharingView(LaunchpadView):
1116 spec_data = self._build_specification_template_data(
1117 self.specifications, request
1118 )
1119- snap_data = self._build_ocirecipe_template_data(self.snaps, request)
1120+ snap_data = self._build_snap_template_data(self.snaps, request)
1121 ocirecipe_data = self._build_ocirecipe_template_data(
1122 self.ocirecipes, request
1123 )
1124+ vulnerability_data = self._build_vulnerability_template_data(
1125+ self.vulnerabilities, request
1126+ )
1127 grantee_data = {
1128 "displayname": self.person.displayname,
1129 "self_link": absoluteURL(self.person, request),
1130@@ -462,6 +465,7 @@ class PillarPersonSharingView(LaunchpadView):
1131 cache.objects["specifications"] = spec_data
1132 cache.objects["snaps"] = snap_data
1133 cache.objects["ocirecipes"] = ocirecipe_data
1134+ cache.objects["vulnerabilities"] = vulnerability_data
1135
1136 def _loadSharedArtifacts(self):
1137 # As a concrete can by linked via more than one policy, we use sets to
1138@@ -475,6 +479,7 @@ class PillarPersonSharingView(LaunchpadView):
1139 self.snaps = artifacts["snaps"]
1140 self.specifications = artifacts["specifications"]
1141 self.ocirecipes = artifacts["ocirecipes"]
1142+ self.vulnerabilities = artifacts["vulnerabilities"]
1143
1144 bug_ids = {bugtask.bug.id for bugtask in self.bugtasks}
1145 self.shared_bugs_count = len(bug_ids)
1146@@ -483,6 +488,7 @@ class PillarPersonSharingView(LaunchpadView):
1147 self.shared_snaps_count = len(self.snaps)
1148 self.shared_specifications_count = len(self.specifications)
1149 self.shared_ocirecipe_count = len(self.ocirecipes)
1150+ self.shared_vulnerabilities_count = len(self.vulnerabilities)
1151
1152 def _build_specification_template_data(self, specs, request):
1153 spec_data = []
1154@@ -574,3 +580,19 @@ class PillarPersonSharingView(LaunchpadView):
1155 )
1156 )
1157 return snap_data
1158+
1159+ def _build_vulnerability_template_data(self, vulnerabilities, request):
1160+ vulnerability_data = []
1161+ for vulnerability in vulnerabilities:
1162+ vulnerability_data.append(
1163+ dict(
1164+ self_link=absoluteURL(vulnerability, request),
1165+ web_link=canonical_url(
1166+ vulnerability, path_only_if_possible=True
1167+ ),
1168+ name=vulnerability.cve.sequence,
1169+ id=vulnerability.id,
1170+ information_type=vulnerability.information_type.title,
1171+ )
1172+ )
1173+ return vulnerability_data
1174diff --git a/lib/lp/registry/interfaces/accesspolicy.py b/lib/lp/registry/interfaces/accesspolicy.py
1175index 233cc6c..1e31ea6 100644
1176--- a/lib/lp/registry/interfaces/accesspolicy.py
1177+++ b/lib/lp/registry/interfaces/accesspolicy.py
1178@@ -1,4 +1,4 @@
1179-# Copyright 2011-2021 Canonical Ltd. This software is licensed under the
1180+# Copyright 2011-2022 Canonical Ltd. This software is licensed under the
1181 # GNU Affero General Public License version 3 (see the file LICENSE).
1182
1183 """Interfaces for pillar and artifact access policies."""
1184@@ -34,6 +34,7 @@ class IAccessArtifact(Interface):
1185 snap_id = Attribute("snap_id")
1186 specification_id = Attribute("specification_id")
1187 ocirecipe_id = Attribute("ocirecipe_id")
1188+ vulnerability_id = Attribute("vulnerability_id")
1189
1190
1191 class IAccessArtifactGrant(Interface):
1192diff --git a/lib/lp/registry/interfaces/sharingservice.py b/lib/lp/registry/interfaces/sharingservice.py
1193index ee08d9a..b5fc95b 100644
1194--- a/lib/lp/registry/interfaces/sharingservice.py
1195+++ b/lib/lp/registry/interfaces/sharingservice.py
1196@@ -1,4 +1,4 @@
1197-# Copyright 2012-2021 Canonical Ltd. This software is licensed under the
1198+# Copyright 2012-2022 Canonical Ltd. This software is licensed under the
1199 # GNU Affero General Public License version 3 (see the file LICENSE).
1200
1201 """Interfaces for sharing service."""
1202@@ -26,6 +26,7 @@ from lp.app.enums import InformationType
1203 from lp.app.interfaces.services import IService
1204 from lp.blueprints.interfaces.specification import ISpecification
1205 from lp.bugs.interfaces.bug import IBug
1206+from lp.bugs.interfaces.vulnerability import IVulnerability
1207 from lp.code.interfaces.branch import IBranch
1208 from lp.code.interfaces.gitrepository import IGitRepository
1209 from lp.oci.interfaces.ocirecipe import IOCIRecipe
1210@@ -192,6 +193,14 @@ class ISharingService(IService):
1211 :return: a collection of OCI recipes.
1212 """
1213
1214+ def getSharedVulnerabilities(pillar, person, user):
1215+ """Return the vulnerabilities shared between the pillar and person.
1216+
1217+ :param user: the user making the request. Only the vulnerabilities
1218+ visible to the user will be included in the result.
1219+ :param: a collection of vulnerabilities.
1220+ """
1221+
1222 def getVisibleArtifacts(
1223 person,
1224 bugs=None,
1225@@ -200,6 +209,7 @@ class ISharingService(IService):
1226 snaps=None,
1227 specifications=None,
1228 ocirecipes=None,
1229+ vulnerabilities=None,
1230 ):
1231 """Return the artifacts shared with person.
1232
1233@@ -216,6 +226,8 @@ class ISharingService(IService):
1234 person has access.
1235 :param ocirecipes: the OCI recipes to check for which a person
1236 has access.
1237+ :param vulnerabilities: the vulnerabilities to check for which person
1238+ has access.
1239 :return: a collection of artifacts the person can see.
1240 """
1241
1242@@ -375,6 +387,11 @@ class ISharingService(IService):
1243 title=_("OCI recipes"),
1244 required=False,
1245 ),
1246+ vulnerabilities=List(
1247+ Reference(schema=IVulnerability),
1248+ title=_("Vulnerabilities"),
1249+ required=False,
1250+ ),
1251 )
1252 @operation_for_version("devel")
1253 def revokeAccessGrants(
1254@@ -387,6 +404,7 @@ class ISharingService(IService):
1255 snaps=None,
1256 specifications=None,
1257 ocirecipes=None,
1258+ vulnerabilities=None,
1259 ):
1260 """Remove a grantee's access to the specified artifacts.
1261
1262@@ -399,6 +417,7 @@ class ISharingService(IService):
1263 :param snaps: The snap recipes for which to revoke access
1264 :param specifications: the specifications for which to revoke access
1265 :param ocirecipes: The OCI recipes for which to revoke access
1266+ :param vulnerabilities: The vulnerabilities for which to revoke access
1267 """
1268
1269 @export_write_operation()
1270@@ -423,6 +442,11 @@ class ISharingService(IService):
1271 title=_("OCI recipes"),
1272 required=False,
1273 ),
1274+ vulnerabilities=List(
1275+ Reference(schema=IVulnerability),
1276+ title=_("Vulnerabilities"),
1277+ required=False,
1278+ ),
1279 )
1280 @operation_for_version("devel")
1281 def ensureAccessGrants(
1282@@ -434,6 +458,7 @@ class ISharingService(IService):
1283 snaps=None,
1284 specifications=None,
1285 ocirecipes=None,
1286+ vulnerabilities=None,
1287 ):
1288 """Ensure a grantee has an access grant to the specified artifacts.
1289
1290@@ -445,6 +470,7 @@ class ISharingService(IService):
1291 :param snaps: the snap recipes for which to grant access
1292 :param specifications: the specifications for which to grant access
1293 :param ocirecipes: the OCI recipes for which to grant access
1294+ :param vulnerabilities: the vulnerabilities for which to grant access
1295 """
1296
1297 @export_write_operation()
1298diff --git a/lib/lp/registry/model/accesspolicy.py b/lib/lp/registry/model/accesspolicy.py
1299index cb881b9..dd47d69 100644
1300--- a/lib/lp/registry/model/accesspolicy.py
1301+++ b/lib/lp/registry/model/accesspolicy.py
1302@@ -1,4 +1,4 @@
1303-# Copyright 2011-2021 Canonical Ltd. This software is licensed under the
1304+# Copyright 2011-2022 Canonical Ltd. This software is licensed under the
1305 # GNU Affero General Public License version 3 (see the file LICENSE).
1306
1307 """Model classes for pillar and artifact access policies."""
1308@@ -95,6 +95,8 @@ class AccessArtifact(StormBase):
1309 specification = Reference(specification_id, "Specification.id")
1310 ocirecipe_id = Int(name="ocirecipe")
1311 ocirecipe = Reference(ocirecipe_id, "OCIRecipe.id")
1312+ vulnerability_id = Int(name="vulnerability")
1313+ vulnerability = Reference(vulnerability_id, "Vulnerability.id")
1314
1315 @property
1316 def concrete_artifact(self):
1317@@ -107,6 +109,7 @@ class AccessArtifact(StormBase):
1318 def _constraintForConcrete(cls, concrete_artifact):
1319 from lp.blueprints.interfaces.specification import ISpecification
1320 from lp.bugs.interfaces.bug import IBug
1321+ from lp.bugs.interfaces.vulnerability import IVulnerability
1322 from lp.code.interfaces.branch import IBranch
1323 from lp.code.interfaces.gitrepository import IGitRepository
1324 from lp.oci.interfaces.ocirecipe import IOCIRecipe
1325@@ -124,6 +127,8 @@ class AccessArtifact(StormBase):
1326 col = cls.specification
1327 elif IOCIRecipe.providedBy(concrete_artifact):
1328 col = cls.ocirecipe
1329+ elif IVulnerability.providedBy(concrete_artifact):
1330+ col = cls.vulnerability
1331 else:
1332 raise ValueError("%r is not a valid artifact" % concrete_artifact)
1333 return col == concrete_artifact
1334@@ -146,6 +151,7 @@ class AccessArtifact(StormBase):
1335 """See `IAccessArtifactSource`."""
1336 from lp.blueprints.interfaces.specification import ISpecification
1337 from lp.bugs.interfaces.bug import IBug
1338+ from lp.bugs.interfaces.vulnerability import IVulnerability
1339 from lp.code.interfaces.branch import IBranch
1340 from lp.code.interfaces.gitrepository import IGitRepository
1341 from lp.oci.interfaces.ocirecipe import IOCIRecipe
1342@@ -163,17 +169,33 @@ class AccessArtifact(StormBase):
1343 insert_values = []
1344 for concrete in needed:
1345 if IBug.providedBy(concrete):
1346- insert_values.append((concrete, None, None, None, None, None))
1347+ insert_values.append(
1348+ (concrete, None, None, None, None, None, None)
1349+ )
1350 elif IBranch.providedBy(concrete):
1351- insert_values.append((None, concrete, None, None, None, None))
1352+ insert_values.append(
1353+ (None, concrete, None, None, None, None, None)
1354+ )
1355 elif IGitRepository.providedBy(concrete):
1356- insert_values.append((None, None, concrete, None, None, None))
1357+ insert_values.append(
1358+ (None, None, concrete, None, None, None, None)
1359+ )
1360 elif ISnap.providedBy(concrete):
1361- insert_values.append((None, None, None, concrete, None, None))
1362+ insert_values.append(
1363+ (None, None, None, concrete, None, None, None)
1364+ )
1365 elif ISpecification.providedBy(concrete):
1366- insert_values.append((None, None, None, None, concrete, None))
1367+ insert_values.append(
1368+ (None, None, None, None, concrete, None, None)
1369+ )
1370 elif IOCIRecipe.providedBy(concrete):
1371- insert_values.append((None, None, None, None, None, concrete))
1372+ insert_values.append(
1373+ (None, None, None, None, None, concrete, None)
1374+ )
1375+ elif IVulnerability.providedBy(concrete):
1376+ insert_values.append(
1377+ (None, None, None, None, None, None, concrete)
1378+ )
1379 else:
1380 raise ValueError("%r is not a supported artifact" % concrete)
1381 columns = (
1382@@ -183,6 +205,7 @@ class AccessArtifact(StormBase):
1383 cls.snap,
1384 cls.specification,
1385 cls.ocirecipe,
1386+ cls.vulnerability,
1387 )
1388 new = create(columns, insert_values, get_objects=True)
1389 return list(existing) + new
1390diff --git a/lib/lp/registry/personmerge.py b/lib/lp/registry/personmerge.py
1391index 66b19b3..9de794b 100644
1392--- a/lib/lp/registry/personmerge.py
1393+++ b/lib/lp/registry/personmerge.py
1394@@ -1,4 +1,4 @@
1395-# Copyright 2009-2021 Canonical Ltd. This software is licensed under the
1396+# Copyright 2009-2022 Canonical Ltd. This software is licensed under the
1397 # GNU Affero General Public License version 3 (see the file LICENSE).
1398
1399 """Person/team merger implementation."""
1400@@ -895,7 +895,8 @@ def _mergeOCIRecipeSubscription(cur, from_id, to_id):
1401
1402 def _mergeVulnerabilitySubscription(cur, from_id, to_id):
1403 # Update only the VulnerabilitySubscription that will not conflict.
1404- cur.execute('''
1405+ cur.execute(
1406+ """
1407 UPDATE VulnerabilitySubscription
1408 SET person=%(to_id)d
1409 WHERE person=%(from_id)d AND vulnerability NOT IN
1410@@ -904,11 +905,16 @@ def _mergeVulnerabilitySubscription(cur, from_id, to_id):
1411 FROM VulnerabilitySubscription
1412 WHERE person = %(to_id)d
1413 )
1414- ''' % vars())
1415+ """
1416+ % vars()
1417+ )
1418 # and delete those left over.
1419- cur.execute('''
1420+ cur.execute(
1421+ """
1422 DELETE FROM VulnerabilitySubscription WHERE person=%(from_id)d
1423- ''' % vars())
1424+ """
1425+ % vars()
1426+ )
1427
1428
1429 def _mergeCharmRecipe(cur, from_person, to_person):
1430@@ -1181,7 +1187,7 @@ def merge_people(from_person, to_person, reviewer, delete=False):
1431 skip.append(("charmrecipe", "owner"))
1432
1433 _mergeVulnerabilitySubscription(cur, from_id, to_id)
1434- skip.append(('vulnerabilitysubscription', 'person'))
1435+ skip.append(("vulnerabilitysubscription", "person"))
1436
1437 # Sanity check. If we have a reference that participates in a
1438 # UNIQUE index, it must have already been handled by this point.
1439diff --git a/lib/lp/registry/services/sharingservice.py b/lib/lp/registry/services/sharingservice.py
1440index 9dc9bdf..5548b4a 100644
1441--- a/lib/lp/registry/services/sharingservice.py
1442+++ b/lib/lp/registry/services/sharingservice.py
1443@@ -1,4 +1,4 @@
1444-# Copyright 2012-2021 Canonical Ltd. This software is licensed under the
1445+# Copyright 2012-2022 Canonical Ltd. This software is licensed under the
1446 # GNU Affero General Public License version 3 (see the file LICENSE).
1447
1448 """Classes for pillar and artifact sharing service."""
1449@@ -35,6 +35,8 @@ from lp.app.enums import PRIVATE_INFORMATION_TYPES
1450 from lp.blueprints.model.specification import Specification
1451 from lp.bugs.interfaces.bugtask import IBugTaskSet
1452 from lp.bugs.interfaces.bugtasksearch import BugTaskSearchParams
1453+from lp.bugs.interfaces.vulnerability import IVulnerabilitySet
1454+from lp.bugs.model.vulnerability import Vulnerability
1455 from lp.code.interfaces.branchcollection import IAllBranches
1456 from lp.code.interfaces.gitcollection import IAllGitRepositories
1457 from lp.oci.interfaces.ocirecipe import IOCIRecipeSet
1458@@ -239,6 +241,7 @@ class SharingService:
1459 include_snaps=True,
1460 include_specifications=True,
1461 include_ocirecipes=True,
1462+ include_vulnerabilities=True,
1463 ):
1464 """See `ISharingService`."""
1465 bug_ids = set()
1466@@ -247,6 +250,7 @@ class SharingService:
1467 snap_ids = set()
1468 specification_ids = set()
1469 ocirecipe_ids = set()
1470+ vulnerability_ids = set()
1471 for artifact in self.getArtifactGrantsForPersonOnPillar(
1472 pillar, person
1473 ):
1474@@ -262,6 +266,8 @@ class SharingService:
1475 specification_ids.add(artifact.specification_id)
1476 elif artifact.ocirecipe_id and include_ocirecipes:
1477 ocirecipe_ids.add(artifact.ocirecipe_id)
1478+ elif artifact.vulnerability_id and include_vulnerabilities:
1479+ vulnerability_ids.add(artifact.vulnerability_id)
1480
1481 # Load the bugs.
1482 bugtasks = []
1483@@ -295,6 +301,9 @@ class SharingService:
1484 ocirecipes = []
1485 if ocirecipe_ids:
1486 ocirecipes = load(OCIRecipe, ocirecipe_ids)
1487+ vulnerabilities = []
1488+ if vulnerability_ids:
1489+ vulnerabilities = load(Vulnerability, vulnerability_ids)
1490
1491 return {
1492 "bugtasks": bugtasks,
1493@@ -303,6 +312,7 @@ class SharingService:
1494 "snaps": snaps,
1495 "specifications": specifications,
1496 "ocirecipes": ocirecipes,
1497+ "vulnerabilities": vulnerabilities,
1498 }
1499
1500 @available_with_permission("launchpad.Driver", "pillar")
1501@@ -317,6 +327,7 @@ class SharingService:
1502 include_specifications=False,
1503 include_snaps=False,
1504 include_ocirecipes=False,
1505+ include_vulnerabilities=False,
1506 )
1507 return artifacts["bugtasks"]
1508
1509@@ -332,6 +343,7 @@ class SharingService:
1510 include_specifications=False,
1511 include_snaps=False,
1512 include_ocirecipes=False,
1513+ include_vulnerabilities=False,
1514 )
1515 return artifacts["branches"]
1516
1517@@ -347,6 +359,7 @@ class SharingService:
1518 include_specifications=False,
1519 include_snaps=False,
1520 include_ocirecipes=False,
1521+ include_vulnerabilities=False,
1522 )
1523 return artifacts["gitrepositories"]
1524
1525@@ -362,6 +375,7 @@ class SharingService:
1526 include_gitrepositories=False,
1527 include_specifications=False,
1528 include_ocirecipes=False,
1529+ include_vulnerabilities=False,
1530 )
1531 return artifacts["snaps"]
1532
1533@@ -377,6 +391,7 @@ class SharingService:
1534 include_gitrepositories=False,
1535 include_snaps=False,
1536 include_ocirecipes=False,
1537+ include_vulnerabilities=False,
1538 )
1539 return artifacts["specifications"]
1540
1541@@ -392,9 +407,26 @@ class SharingService:
1542 include_gitrepositories=False,
1543 include_snaps=False,
1544 include_specifications=False,
1545+ include_vulnerabilities=False,
1546 )
1547 return artifacts["ocirecipes"]
1548
1549+ @available_with_permission("launchpad.Driver", "pillar")
1550+ def getSharedVulnerabilities(self, pillar, person, user):
1551+ """See `ISharingService`."""
1552+ artifacts = self.getSharedArtifacts(
1553+ pillar,
1554+ person,
1555+ user,
1556+ include_bugs=False,
1557+ include_branches=False,
1558+ include_gitrepositories=False,
1559+ include_snaps=False,
1560+ include_specifications=False,
1561+ include_ocirecipes=False,
1562+ )
1563+ return artifacts["vulnerabilities"]
1564+
1565 def _getVisiblePrivateSpecificationIDs(self, person, specifications):
1566 store = Store.of(specifications[0])
1567 tables = (
1568@@ -447,6 +479,7 @@ class SharingService:
1569 specifications=None,
1570 ignore_permissions=False,
1571 ocirecipes=None,
1572+ vulnerabilities=None,
1573 ):
1574 """See `ISharingService`."""
1575 bug_ids = []
1576@@ -454,6 +487,7 @@ class SharingService:
1577 gitrepository_ids = []
1578 snap_ids = []
1579 ocirecipes_ids = []
1580+ vulnerability_ids = []
1581 for bug in bugs or []:
1582 if not ignore_permissions and not check_permission(
1583 "launchpad.View", bug
1584@@ -489,6 +523,12 @@ class SharingService:
1585 ):
1586 raise Unauthorized
1587 ocirecipes_ids.append(ocirecipe.id)
1588+ for vulnerability in vulnerabilities or []:
1589+ if not ignore_permissions and not check_permission(
1590+ "launchpad.View", vulnerability
1591+ ):
1592+ raise Unauthorized
1593+ vulnerability_ids.append(vulnerability.id)
1594
1595 # Load the bugs.
1596 visible_bugs = []
1597@@ -547,6 +587,14 @@ class SharingService:
1598 )
1599 )
1600
1601+ visible_vulnerabilities = []
1602+ if vulnerabilities:
1603+ visible_vulnerabilities = list(
1604+ getUtility(IVulnerabilitySet).findByIds(
1605+ vulnerability_ids, visible_by_user=person
1606+ )
1607+ )
1608+
1609 return {
1610 "bugs": visible_bugs,
1611 "branches": visible_branches,
1612@@ -554,6 +602,7 @@ class SharingService:
1613 "snaps": visible_snaps,
1614 "specifications": visible_specs,
1615 "ocirecipes": visible_ocirecipes,
1616+ "vulnerabilities": visible_vulnerabilities,
1617 }
1618
1619 def getInvisibleArtifacts(
1620@@ -1056,6 +1105,7 @@ class SharingService:
1621 snaps=None,
1622 specifications=None,
1623 ocirecipes=None,
1624+ vulnerabilities=None,
1625 ignore_permissions=False,
1626 ):
1627 """See `ISharingService`."""
1628@@ -1073,6 +1123,8 @@ class SharingService:
1629 artifacts.extend(specifications)
1630 if ocirecipes:
1631 artifacts.extend(ocirecipes)
1632+ if vulnerabilities:
1633+ artifacts.extend(vulnerabilities)
1634 if not ignore_permissions:
1635 # The user needs to have launchpad.Edit permission on all supplied
1636 # bugs and branches or else we raise an Unauthorized exception.
1637diff --git a/lib/lp/registry/services/tests/test_sharingservice.py b/lib/lp/registry/services/tests/test_sharingservice.py
1638index 477db80..4025c1b 100644
1639--- a/lib/lp/registry/services/tests/test_sharingservice.py
1640+++ b/lib/lp/registry/services/tests/test_sharingservice.py
1641@@ -1,4 +1,4 @@
1642-# Copyright 2012-2021 Canonical Ltd. This software is licensed under the
1643+# Copyright 2012-2022 Canonical Ltd. This software is licensed under the
1644 # GNU Affero General Public License version 3 (see the file LICENSE).
1645
1646 import six
1647@@ -90,6 +90,13 @@ class PillarScenariosMixin(WithScenarios):
1648 if self.pillar_factory_name != "makeProduct":
1649 self.skipTest("Only relevant for Product.")
1650
1651+ def isPillarADistribution(self):
1652+ return self.pillar_factory_name == "makeDistribution"
1653+
1654+ def _skipUnlessDistribution(self):
1655+ if not self.isPillarADistribution():
1656+ self.skipTest("Only relevant for Distribution.")
1657+
1658 def _makePillar(self, **kwargs):
1659 return getattr(self.factory, self.pillar_factory_name)(**kwargs)
1660
1661@@ -1669,8 +1676,8 @@ class TestSharingService(
1662 self._assert_updatePillarSharingPoliciesUnauthorized(anyone)
1663
1664 def create_shared_artifacts(self, pillar, grantee, user):
1665- # Create some shared bugs, branches, Git repositories, and
1666- # specifications.
1667+ # Create some shared bugs, branches, Git repositories, snaps,
1668+ # specifications, ocirecipes, and vulnerabilities.
1669 bugs = []
1670 bug_tasks = []
1671 for x in range(0, 10):
1672@@ -1727,6 +1734,14 @@ class TestSharingService(
1673 information_type=InformationType.USERDATA,
1674 )
1675 ocirecipes.append(ocirecipe)
1676+ vulnerabilities = []
1677+ if self.isPillarADistribution():
1678+ for _ in range(10):
1679+ vulnerability = self.factory.makeVulnerability(
1680+ distribution=pillar,
1681+ information_type=InformationType.PROPRIETARY,
1682+ )
1683+ vulnerabilities.append(vulnerability)
1684
1685 # Grant access to grantee as well as the person who will be doing the
1686 # query. The person who will be doing the query is not granted access
1687@@ -1761,7 +1776,19 @@ class TestSharingService(
1688 getUtility(IService, "sharing").ensureAccessGrants(
1689 [grantee], pillar.owner, ocirecipes=ocirecipes[:9]
1690 )
1691- return bug_tasks, branches, gitrepositories, snaps, specs, ocirecipes
1692+ if vulnerabilities:
1693+ getUtility(IService, "sharing").ensureAccessGrants(
1694+ [grantee], pillar.owner, vulnerabilities=vulnerabilities[:9]
1695+ )
1696+ return (
1697+ bug_tasks,
1698+ branches,
1699+ gitrepositories,
1700+ snaps,
1701+ specs,
1702+ ocirecipes,
1703+ vulnerabilities,
1704+ )
1705
1706 def test_getSharedArtifacts(self):
1707 # Test the getSharedArtifacts method.
1708@@ -1782,6 +1809,7 @@ class TestSharingService(
1709 snaps,
1710 specs,
1711 ocirecipes,
1712+ vulnerabilities,
1713 ) = self.create_shared_artifacts(pillar, grantee, user)
1714
1715 # Check the results.
1716@@ -1792,6 +1820,7 @@ class TestSharingService(
1717 shared_snaps = artifacts["snaps"]
1718 shared_specs = artifacts["specifications"]
1719 shared_ocirecipes = artifacts["ocirecipes"]
1720+ shared_vulnerabilities = artifacts["vulnerabilities"]
1721
1722 self.assertContentEqual(bug_tasks[:9], shared_bugtasks)
1723 self.assertContentEqual(branches[:9], shared_branches)
1724@@ -1799,6 +1828,10 @@ class TestSharingService(
1725 self.assertContentEqual(snaps[:9], shared_snaps)
1726 self.assertContentEqual(specs[:9], shared_specs)
1727 self.assertContentEqual(ocirecipes[:9], shared_ocirecipes)
1728+ if self.isPillarADistribution():
1729+ self.assertContentEqual(
1730+ vulnerabilities[:9], shared_vulnerabilities
1731+ )
1732
1733 def _assert_getSharedPillars(self, pillar, who=None):
1734 # Test that 'who' can query the shared pillars for a grantee.
1735@@ -1894,7 +1927,7 @@ class TestSharingService(
1736 login_person(owner)
1737 grantee = self.factory.makePerson()
1738 user = self.factory.makePerson()
1739- bug_tasks, _, _, _, _, _ = self.create_shared_artifacts(
1740+ bug_tasks, _, _, _, _, _, _ = self.create_shared_artifacts(
1741 pillar, grantee, user
1742 )
1743
1744@@ -1914,7 +1947,7 @@ class TestSharingService(
1745 login_person(owner)
1746 grantee = self.factory.makePerson()
1747 user = self.factory.makePerson()
1748- _, branches, _, _, _, _ = self.create_shared_artifacts(
1749+ _, branches, _, _, _, _, _ = self.create_shared_artifacts(
1750 pillar, grantee, user
1751 )
1752
1753@@ -1934,7 +1967,7 @@ class TestSharingService(
1754 login_person(owner)
1755 grantee = self.factory.makePerson()
1756 user = self.factory.makePerson()
1757- _, _, gitrepositories, _, _, _ = self.create_shared_artifacts(
1758+ _, _, gitrepositories, _, _, _, _ = self.create_shared_artifacts(
1759 pillar, grantee, user
1760 )
1761
1762@@ -1957,7 +1990,7 @@ class TestSharingService(
1763 login_person(owner)
1764 grantee = self.factory.makePerson()
1765 user = self.factory.makePerson()
1766- _, _, _, snaps, _, _ = self.create_shared_artifacts(
1767+ _, _, _, snaps, _, _, _ = self.create_shared_artifacts(
1768 pillar, grantee, user
1769 )
1770
1771@@ -1977,7 +2010,7 @@ class TestSharingService(
1772 login_person(owner)
1773 grantee = self.factory.makePerson()
1774 user = self.factory.makePerson()
1775- _, _, _, _, specifications, _ = self.create_shared_artifacts(
1776+ _, _, _, _, specifications, _, _ = self.create_shared_artifacts(
1777 pillar, grantee, user
1778 )
1779
1780@@ -1999,9 +2032,15 @@ class TestSharingService(
1781 login_person(owner)
1782 grantee = self.factory.makePerson()
1783 user = self.factory.makePerson()
1784- _, _, _, _, _, ocirecipes = self.create_shared_artifacts(
1785- pillar, grantee, user
1786- )
1787+ (
1788+ _,
1789+ _,
1790+ _,
1791+ _,
1792+ _,
1793+ ocirecipes,
1794+ _,
1795+ ) = self.create_shared_artifacts(pillar, grantee, user)
1796
1797 # Check the results.
1798 shared_ocirecipes = self.service.getSharedOCIRecipes(
1799@@ -2009,6 +2048,35 @@ class TestSharingService(
1800 )
1801 self.assertContentEqual(ocirecipes[:9], shared_ocirecipes)
1802
1803+ def test_getSharedVulnerabilities(self):
1804+ # Test the getSharedVulnerabilities method.
1805+ self._skipUnlessDistribution()
1806+ owner = self.factory.makePerson()
1807+ pillar = self._makePillar(
1808+ owner=owner,
1809+ specification_sharing_policy=(
1810+ SpecificationSharingPolicy.PUBLIC_OR_PROPRIETARY
1811+ ),
1812+ )
1813+ login_person(owner)
1814+ grantee = self.factory.makePerson()
1815+ user = self.factory.makePerson()
1816+ (
1817+ _,
1818+ _,
1819+ _,
1820+ _,
1821+ _,
1822+ _,
1823+ vulnerabilities,
1824+ ) = self.create_shared_artifacts(pillar, grantee, user)
1825+
1826+ # Check the results.
1827+ shared_vulnerabilities = self.service.getSharedVulnerabilities(
1828+ pillar, grantee, user
1829+ )
1830+ self.assertContentEqual(vulnerabilities[:9], shared_vulnerabilities)
1831+
1832 def test_getPeopleWithAccessBugs(self):
1833 # Test the getPeopleWithoutAccess method with bugs.
1834 owner = self.factory.makePerson()
1835diff --git a/lib/lp/registry/tests/test_personmerge.py b/lib/lp/registry/tests/test_personmerge.py
1836index 024697f..530b30b 100644
1837--- a/lib/lp/registry/tests/test_personmerge.py
1838+++ b/lib/lp/registry/tests/test_personmerge.py
1839@@ -25,6 +25,7 @@ from lp.charms.interfaces.charmrecipe import (
1840 )
1841 from lp.code.interfaces.gitrepository import IGitRepositorySet
1842 from lp.oci.interfaces.ocirecipe import OCI_RECIPE_ALLOW_CREATE, IOCIRecipeSet
1843+from lp.registry.enums import BugSharingPolicy
1844 from lp.registry.interfaces.accesspolicy import (
1845 IAccessArtifactGrantSource,
1846 IAccessPolicyGrantSource,
1847@@ -48,6 +49,7 @@ from lp.services.identity.interfaces.emailaddress import (
1848 EmailAddressStatus,
1849 IEmailAddressSet,
1850 )
1851+from lp.services.webapp.authorization import check_permission
1852 from lp.snappy.interfaces.snap import SNAP_TESTING_FLAGS, ISnapSet
1853 from lp.soyuz.enums import ArchiveStatus
1854 from lp.soyuz.interfaces.livefs import LIVEFS_FEATURE_FLAG, ILiveFSSet
1855@@ -750,6 +752,71 @@ class TestMergePeople(TestCaseWithFactory, KarmaTestMixin):
1856 self.assertFalse(snap.visibleByUser(duplicate))
1857 self.assertIsNone(snap.getSubscription(duplicate))
1858
1859+ def test_merge_vulnerability_subscriptions(self):
1860+ # Checks that merging users moves subscriptions.
1861+ duplicate = self.factory.makePerson()
1862+ mergee = self.factory.makePerson()
1863+ vulnerability = self.factory.makeVulnerability()
1864+ vulnerability.subscribe(duplicate, duplicate)
1865+ self.assertTrue(vulnerability.hasSubscription(duplicate))
1866+ self.assertFalse(vulnerability.hasSubscription(mergee))
1867+ self._do_premerge(duplicate, mergee)
1868+ login_person(mergee)
1869+ duplicate, mergee = self._do_merge(duplicate, mergee)
1870+ self.assertFalse(vulnerability.hasSubscription(duplicate))
1871+ self.assertTrue(vulnerability.hasSubscription(mergee))
1872+
1873+ def test_merge_vulnerability_subscriptions_mergee_already_subscribed(self):
1874+ duplicate = self.factory.makePerson()
1875+ mergee = self.factory.makePerson()
1876+ vulnerability = self.factory.makeVulnerability()
1877+ vulnerability.subscribe(duplicate, duplicate)
1878+ vulnerability.subscribe(mergee, mergee)
1879+ mergee_subscription = vulnerability.getSubscription(mergee)
1880+ self.assertTrue(vulnerability.hasSubscription(duplicate))
1881+ self.assertTrue(vulnerability.hasSubscription(mergee))
1882+ self._do_premerge(duplicate, mergee)
1883+ login_person(mergee)
1884+ duplicate, mergee = self._do_merge(duplicate, mergee)
1885+ self.assertFalse(vulnerability.hasSubscription(duplicate))
1886+ self.assertTrue(vulnerability.hasSubscription(mergee))
1887+ self.assertEqual(
1888+ mergee_subscription, vulnerability.getSubscription(mergee)
1889+ )
1890+
1891+ def test_merge_vulnerability_subscriptions_non_public_vulnerability(self):
1892+ duplicate = self.factory.makePerson()
1893+ mergee = self.factory.makePerson()
1894+ distribution = self.factory.makeDistribution(
1895+ bug_sharing_policy=BugSharingPolicy.PROPRIETARY
1896+ )
1897+ vulnerability = self.factory.makeVulnerability(
1898+ distribution=distribution,
1899+ information_type=InformationType.PROPRIETARY,
1900+ )
1901+ with person_logged_in(distribution.owner):
1902+ vulnerability.subscribe(duplicate, distribution.owner)
1903+ self.assertTrue(vulnerability.hasSubscription(duplicate))
1904+ self.assertFalse(vulnerability.hasSubscription(mergee))
1905+
1906+ with person_logged_in(duplicate):
1907+ self.assertTrue(check_permission("launchpad.View", vulnerability))
1908+
1909+ with person_logged_in(mergee):
1910+ self.assertFalse(check_permission("launchpad.View", vulnerability))
1911+
1912+ self._do_premerge(duplicate, mergee)
1913+ login_person(mergee)
1914+ duplicate, mergee = self._do_merge(duplicate, mergee)
1915+ with person_logged_in(distribution.owner):
1916+ self.assertFalse(vulnerability.hasSubscription(duplicate))
1917+ self.assertTrue(vulnerability.hasSubscription(mergee))
1918+
1919+ # Cannot log in as the duplicate user any more to test that they do not
1920+ # have the permission.
1921+ with person_logged_in(mergee):
1922+ self.assertTrue(check_permission("launchpad.View", vulnerability))
1923+
1924 def test_merge_moves_oci_recipes(self):
1925 # When person/teams are merged, oci recipes owned by the from
1926 # person are moved.

Subscribers

People subscribed via source and target branches

to status/vote changes: