Merge ~lgp171188/launchpad:vulnerability-subscription-model into launchpad:master
- Git
- lp:~lgp171188/launchpad
- vulnerability-subscription-model
- Merge into 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) |
Related bugs: |
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 VulnerabilitySu
This does not yet notify the subscribers about changes to the
vulnerabilities that they are subscribed to.
Description of the change
To post a comment you must log in.
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
1 | diff --git a/lib/lp/bugs/interfaces/vulnerability.py b/lib/lp/bugs/interfaces/vulnerability.py |
2 | index 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.""" |
60 | diff --git a/lib/lp/bugs/interfaces/vulnerabilitysubscription.py b/lib/lp/bugs/interfaces/vulnerabilitysubscription.py |
61 | new file mode 100644 |
62 | index 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?""" |
109 | diff --git a/lib/lp/bugs/model/tests/test_vulnerability.py b/lib/lp/bugs/model/tests/test_vulnerability.py |
110 | index 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 | + ) |
615 | diff --git a/lib/lp/bugs/model/tests/test_vulnerabilitysubscription.py b/lib/lp/bugs/model/tests/test_vulnerabilitysubscription.py |
616 | new file mode 100644 |
617 | index 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 | + ) |
716 | diff --git a/lib/lp/bugs/model/vulnerability.py b/lib/lp/bugs/model/vulnerability.py |
717 | index 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 | + ] |
1012 | diff --git a/lib/lp/bugs/model/vulnerabilitysubscription.py b/lib/lp/bugs/model/vulnerabilitysubscription.py |
1013 | new file mode 100644 |
1014 | index 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 | + ) |
1079 | diff --git a/lib/lp/bugs/security.py b/lib/lp/bugs/security.py |
1080 | index 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.""" |
1105 | diff --git a/lib/lp/registry/browser/pillar.py b/lib/lp/registry/browser/pillar.py |
1106 | index 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 |
1174 | diff --git a/lib/lp/registry/interfaces/accesspolicy.py b/lib/lp/registry/interfaces/accesspolicy.py |
1175 | index 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): |
1192 | diff --git a/lib/lp/registry/interfaces/sharingservice.py b/lib/lp/registry/interfaces/sharingservice.py |
1193 | index 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() |
1298 | diff --git a/lib/lp/registry/model/accesspolicy.py b/lib/lp/registry/model/accesspolicy.py |
1299 | index 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 |
1390 | diff --git a/lib/lp/registry/personmerge.py b/lib/lp/registry/personmerge.py |
1391 | index 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. |
1439 | diff --git a/lib/lp/registry/services/sharingservice.py b/lib/lp/registry/services/sharingservice.py |
1440 | index 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. |
1637 | diff --git a/lib/lp/registry/services/tests/test_sharingservice.py b/lib/lp/registry/services/tests/test_sharingservice.py |
1638 | index 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() |
1835 | diff --git a/lib/lp/registry/tests/test_personmerge.py b/lib/lp/registry/tests/test_personmerge.py |
1836 | index 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. |
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.