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

Proposed by Guruprasad
Status: Merged
Approved by: Guruprasad
Approved revision: 25b9c52ae3f37c65395659884189fa5d29c63aaa
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~lgp171188/launchpad:new-security-role-permission
Merge into: launchpad:master
Diff against target: 562 lines (+312/-42)
9 files modified
lib/lp/bugs/model/tests/test_vulnerability.py (+40/-4)
lib/lp/bugs/security.py (+11/-0)
lib/lp/bugs/tests/test_vulnerability.py (+63/-0)
lib/lp/permissions.zcml (+4/-0)
lib/lp/registry/configure.zcml (+4/-0)
lib/lp/registry/interfaces/distribution.py (+13/-4)
lib/lp/registry/model/distribution.py (+3/-0)
lib/lp/registry/tests/test_distribution.py (+159/-22)
lib/lp/security.py (+15/-12)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Andrey Fedoseev (community) Approve
Review via email: mp+423363@code.launchpad.net

Commit message

Add a new distribution security admin role

And restrict the permissions on creating and editing vulnerabilities
to it.

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

This looks good.

I have doubts about the the permission name: `SecurityAdmin`. I think there's a convention to use a verb in the permission names. `SecurityAdmin` sounds more like a role than a permission.

Could it be `ManageVulnerabilties` or `AdministerVulnerabilities` instead?

Revision history for this message
Guruprasad (lgp171188) wrote :

> I have doubts about the the permission name: `SecurityAdmin`. I think there's a convention to use a verb in the permission names. `SecurityAdmin` sounds more like a role than a permission.
>
> Could it be `ManageVulnerabilties` or `AdministerVulnerabilities` instead?

I thought about it when I was about to name it and found that there are some existing permissions that do not fit in this mould - `launchpad.AnyAllowedPerson`, `launchpad.AnyLegitimatePerson`, `launchpad.TranslationsAdmin`, `launchpad.LanguagePacksAdmin` etc. That is why I named it as `launchpad.SecurityAdmin`.

Revision history for this message
Andrey Fedoseev (andrey-fedoseev) wrote :

Approved

review: Approve
Revision history for this message
Colin Watson (cjwatson) :
review: Needs Fixing
Revision history for this message
Colin Watson (cjwatson) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/bugs/model/tests/test_vulnerability.py b/lib/lp/bugs/model/tests/test_vulnerability.py
2index f2cac72..f470e40 100644
3--- a/lib/lp/bugs/model/tests/test_vulnerability.py
4+++ b/lib/lp/bugs/model/tests/test_vulnerability.py
5@@ -2,8 +2,10 @@
6 # GNU Affero General Public License version 3 (see the file LICENSE).
7
8 """Tests for the vulnerability and related models."""
9+from testtools.matchers import MatchesStructure
10 from zope.component import getUtility
11
12+from lp.bugs.enums import VulnerabilityStatus
13 from lp.bugs.interfaces.buglink import IBugLinkTarget
14 from lp.bugs.interfaces.vulnerability import (
15 IVulnerability,
16@@ -39,34 +41,68 @@ class TestVulnerability(TestCaseWithFactory):
17 vulnerability = self.factory.makeVulnerability()
18 self.assertTrue(verifyObject(IBugLinkTarget, vulnerability))
19
20- def test_random_user(self):
21+ def test_random_user_permissions(self):
22 with person_logged_in(self.factory.makePerson()):
23 self.assertTrue(
24 check_permission("launchpad.View", self.vulnerability))
25 self.assertFalse(
26 check_permission("launchpad.Edit", self.vulnerability))
27
28- def test_admin(self):
29+ def test_admin_permissions(self):
30 with admin_logged_in():
31 self.assertTrue(
32 check_permission("launchpad.View", self.vulnerability))
33 self.assertTrue(
34 check_permission("launchpad.Edit", self.vulnerability))
35
36- def test_non_admin(self):
37+ def test_distribution_owner_permissions(self):
38 with person_logged_in(self.distribution.owner):
39 self.assertTrue(
40 check_permission("launchpad.View", self.vulnerability))
41 self.assertTrue(
42 check_permission("launchpad.Edit", self.vulnerability))
43
44- def test_anonymous(self):
45+ def test_security_admin_permissions(self):
46+ person = self.factory.makePerson()
47+ security_team = self.factory.makeTeam(members=[person])
48+ with person_logged_in(self.distribution.owner):
49+ self.distribution.security_admin = security_team
50+
51+ with person_logged_in(person):
52+ self.assertTrue(
53+ check_permission(
54+ "launchpad.Edit", self.vulnerability
55+ )
56+ )
57+
58+ def test_anonymous_permissions(self):
59 with anonymous_logged_in():
60 self.assertFalse(
61 check_permission("launchpad.View", self.vulnerability))
62 self.assertFalse(
63 check_permission("launchpad.Edit", self.vulnerability))
64
65+ def test_edit_vulnerability_security_admin(self):
66+ person = self.factory.makePerson()
67+ security_team = self.factory.makeTeam(members=[person])
68+ vulnerability = self.factory.makeVulnerability(
69+ distribution=self.distribution
70+ )
71+ cve = self.factory.makeCVE('2022-1234')
72+ with person_logged_in(self.distribution.owner):
73+ self.distribution.security_admin = security_team
74+
75+ with person_logged_in(security_team):
76+ vulnerability.cve = cve
77+ vulnerability.status = VulnerabilityStatus.ACTIVE
78+ self.assertThat(
79+ vulnerability,
80+ MatchesStructure.byEquality(
81+ cve=cve,
82+ status=VulnerabilityStatus.ACTIVE
83+ )
84+ )
85+
86
87 class TestVulnerabilityActivity(TestCaseWithFactory):
88
89diff --git a/lib/lp/bugs/security.py b/lib/lp/bugs/security.py
90index 5e40c90..ce1829a 100644
91--- a/lib/lp/bugs/security.py
92+++ b/lib/lp/bugs/security.py
93@@ -23,6 +23,7 @@ from lp.bugs.interfaces.bugtracker import IBugTracker
94 from lp.bugs.interfaces.bugwatch import IBugWatch
95 from lp.bugs.interfaces.hasbug import IHasBug
96 from lp.bugs.interfaces.structuralsubscription import IStructuralSubscription
97+from lp.bugs.interfaces.vulnerability import IVulnerability
98 from lp.registry.interfaces.role import (
99 IHasAppointedDriver,
100 IHasOwner,
101@@ -384,3 +385,13 @@ class EditBugSubscriptionFilter(AuthorizationBase):
102
103 def checkAuthenticated(self, user):
104 return user.inTeam(self.obj.structural_subscription.subscriber)
105+
106+
107+class EditVulnerability(DelegatedAuthorization):
108+ """The security admins of a distribution should be able to edit
109+ vulnerabilities in that distribution."""
110+ permission = 'launchpad.Edit'
111+ usedfor = IVulnerability
112+
113+ def __init__(self, obj):
114+ super().__init__(obj, obj.distribution, 'launchpad.SecurityAdmin')
115diff --git a/lib/lp/bugs/tests/test_vulnerability.py b/lib/lp/bugs/tests/test_vulnerability.py
116index be3457a..7590be2 100644
117--- a/lib/lp/bugs/tests/test_vulnerability.py
118+++ b/lib/lp/bugs/tests/test_vulnerability.py
119@@ -16,6 +16,7 @@ from lp.bugs.interfaces.bugtask import BugTaskImportance
120 from lp.services.webapp.interfaces import OAuthPermission
121 from lp.testing import (
122 api_url,
123+ person_logged_in,
124 TestCaseWithFactory,
125 )
126 from lp.testing.layers import DatabaseFunctionalLayer
127@@ -195,6 +196,68 @@ class TestVulnerabilityWebService(TestCaseWithFactory):
128 self.assertEqual(209, response.status)
129 self.assertEqual(vulnerability.cve, another_cve)
130
131+ def test_security_admin_can_edit_vulnerability(self):
132+ vulnerability = removeSecurityProxy(
133+ self.factory.makeVulnerability()
134+ )
135+ owner = vulnerability.distribution.owner
136+ person = self.factory.makePerson()
137+ security_team = self.factory.makeTeam(members=[person])
138+
139+ with person_logged_in(owner):
140+ vulnerability.distribution.security_admin = security_team
141+
142+ self.assertThat(
143+ vulnerability,
144+ MatchesStructure.byEquality(
145+ status=VulnerabilityStatus.NEEDS_TRIAGE,
146+ importance=BugTaskImportance.UNDECIDED,
147+ information_type=InformationType.PUBLIC,
148+ cve=None,
149+ description=None,
150+ notes=None,
151+ mitigation=None,
152+ date_made_public=None,
153+ )
154+ )
155+ vulnerability_url = api_url(vulnerability)
156+ webservice = webservice_for_person(
157+ person,
158+ permission=OAuthPermission.WRITE_PRIVATE,
159+ default_api_version="devel"
160+ )
161+ now = datetime.datetime.now(pytz.UTC)
162+
163+ response = webservice.patch(
164+ vulnerability_url,
165+ "application/json",
166+ json.dumps(
167+ {
168+ "status": "Active",
169+ "information_type": "Private",
170+ "importance": "Critical",
171+ "description": "Foo bar",
172+ "notes": "lgp171188> Foo bar",
173+ "mitigation": "Foo bar baz",
174+ "importance_explanation": "Foo bar bazz",
175+ "date_made_public": now.isoformat(),
176+ }
177+ )
178+ )
179+ self.assertEqual(209, response.status)
180+ self.assertThat(
181+ vulnerability,
182+ MatchesStructure.byEquality(
183+ status=VulnerabilityStatus.ACTIVE,
184+ importance=BugTaskImportance.CRITICAL,
185+ description="Foo bar",
186+ notes="lgp171188> Foo bar",
187+ mitigation="Foo bar baz",
188+ importance_explanation="Foo bar bazz",
189+ date_made_public=now,
190+ )
191+ )
192+
193 def test_user_without_edit_permissions_cannot_edit_vulnerability(self):
194 vulnerability = removeSecurityProxy(
195 self.factory.makeVulnerability()
196diff --git a/lib/lp/permissions.zcml b/lib/lp/permissions.zcml
197index 646d9ca..dd5a177 100644
198--- a/lib/lp/permissions.zcml
199+++ b/lib/lp/permissions.zcml
200@@ -70,6 +70,10 @@
201 id="launchpad.LanguagePacksAdmin" title="Administer Language Packs."
202 access_level="write" />
203
204+ <permission
205+ id="launchpad.SecurityAdmin" title="Administer vulnerabilities."
206+ access_level="write" />
207+
208 <!-- XXX: GuilhermeSalgado 2005-08-25
209 To be removed soon, this is only needed by the page to upload SSH
210 keys, cause we decided to not allow admins to upload keys in behalf of other
211diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml
212index 10d677f..eefe667 100644
213--- a/lib/lp/registry/configure.zcml
214+++ b/lib/lp/registry/configure.zcml
215@@ -1834,6 +1834,9 @@
216 interface="lp.registry.interfaces.distribution.IDistributionEditRestricted"
217 set_schema="lp.app.interfaces.informationtype.IInformationType"/>
218 <require
219+ permission="launchpad.SecurityAdmin"
220+ interface="lp.registry.interfaces.distribution.IDistributionSecurityAdminRestricted" />
221+ <require
222 permission="launchpad.Edit"
223 set_attributes="answers_usage blueprints_usage codehosting_usage
224 bug_tracking_usage uses_launchpad"/>
225@@ -1866,6 +1869,7 @@
226 package_derivatives_email
227 redirect_default_traversal
228 redirect_release_uploads
229+ security_admin
230 summary
231 vcs
232 "/>
233diff --git a/lib/lp/registry/interfaces/distribution.py b/lib/lp/registry/interfaces/distribution.py
234index 58e10fc..727d204 100644
235--- a/lib/lp/registry/interfaces/distribution.py
236+++ b/lib/lp/registry/interfaces/distribution.py
237@@ -480,6 +480,11 @@ class IDistributionView(
238 has_current_commercial_subscription = Attribute(
239 "Whether the distribution has a current commercial subscription.")
240
241+ security_admin = Choice(
242+ title=_("Security Administrator"),
243+ description=_("The distribution security administrator."),
244+ required=False, vocabulary='ValidPersonOrTeam')
245+
246 vulnerabilities = exported(
247 doNotSnapshot(CollectionField(
248 description=_("Vulnerabilities in this distribution."),
249@@ -889,6 +894,10 @@ class IDistributionEditRestricted(IOfficialBugTagTargetRestricted):
250 def deleteOCICredentials():
251 """Delete any existing OCI credentials for the distribution."""
252
253+
254+class IDistributionSecurityAdminRestricted(Interface):
255+ """IDistribution properties requiring launchpad.SecurityAdmin permission"""
256+
257 @call_with(creator=REQUEST_USER)
258 @operation_parameters(
259 status=Choice(
260@@ -954,10 +963,10 @@ class IDistributionEditRestricted(IOfficialBugTagTargetRestricted):
261
262 @exported_as_webservice_entry(as_of="beta")
263 class IDistribution(
264- IDistributionEditRestricted, IDistributionPublic,
265- IDistributionLimitedView, IDistributionView, IHasBugSupervisor,
266- IFAQTarget, IQuestionTarget, IStructuralSubscriptionTarget,
267- IInformationType):
268+ IDistributionEditRestricted, IDistributionSecurityAdminRestricted,
269+ IDistributionPublic,IDistributionLimitedView, IDistributionView,
270+ IHasBugSupervisor, IFAQTarget, IQuestionTarget,
271+ IStructuralSubscriptionTarget, IInformationType):
272 """An operating system distribution.
273
274 Launchpadlib example: retrieving the current version of a package in a
275diff --git a/lib/lp/registry/model/distribution.py b/lib/lp/registry/model/distribution.py
276index 97e51c0..e9903ab 100644
277--- a/lib/lp/registry/model/distribution.py
278+++ b/lib/lp/registry/model/distribution.py
279@@ -685,6 +685,9 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
280 language_pack_admin = ForeignKey(
281 dbName='language_pack_admin', foreignKey='Person',
282 storm_validator=validate_public_person, notNull=False, default=None)
283+ security_admin = ForeignKey(
284+ dbName='security_admin', foreignKey='Person',
285+ storm_validator=validate_public_person, notNull=False, default=None)
286
287 @cachedproperty
288 def main_archive(self):
289diff --git a/lib/lp/registry/tests/test_distribution.py b/lib/lp/registry/tests/test_distribution.py
290index ee458ca..c44df04 100644
291--- a/lib/lp/registry/tests/test_distribution.py
292+++ b/lib/lp/registry/tests/test_distribution.py
293@@ -86,6 +86,7 @@ from lp.soyuz.interfaces.distributionsourcepackagerelease import (
294 )
295 from lp.testing import (
296 admin_logged_in,
297+ anonymous_logged_in,
298 api_url,
299 celebrity_logged_in,
300 login,
301@@ -1813,6 +1814,33 @@ class TestDistributionVulnerabilities(TestCaseWithFactory):
302
303 layer = DatabaseFunctionalLayer
304
305+ def assert_newVulnerability_only_the_required_params(
306+ self,
307+ distribution,
308+ creator
309+ ):
310+ vulnerability = distribution.newVulnerability(
311+ status=VulnerabilityStatus.NEEDS_TRIAGE,
312+ information_type=InformationType.PUBLIC,
313+ creator=creator,
314+ )
315+
316+ self.assertThat(
317+ vulnerability,
318+ MatchesStructure.byEquality(
319+ status=VulnerabilityStatus.NEEDS_TRIAGE,
320+ creator=creator,
321+ importance=BugTaskImportance.UNDECIDED,
322+ information_type=InformationType.PUBLIC,
323+ cve=None,
324+ description=None,
325+ notes=None,
326+ mitigation=None,
327+ importance_explanation=None,
328+ date_made_public=None
329+ )
330+ )
331+
332 def test_vulnerabilities_no_vulnerability_present(self):
333 distribution = self.factory.makeDistribution()
334 self.assertEqual(0, distribution.vulnerabilities.count())
335@@ -1830,33 +1858,34 @@ class TestDistributionVulnerabilities(TestCaseWithFactory):
336 set(distribution.vulnerabilities),
337 )
338
339+ def test_set_security_admin_permissions(self):
340+ distribution = self.factory.makeDistribution()
341+ person = self.factory.makePerson()
342+ person2 = self.factory.makePerson()
343+ security_team = self.factory.makeTeam(members=[person])
344+
345+ with person_logged_in(distribution.owner):
346+ distribution.security_admin = security_team
347+ self.assertEqual(security_team, distribution.security_admin)
348+
349+ with admin_logged_in():
350+ distribution.security_admin = None
351+ self.assertIsNone(distribution.security_admin)
352+
353+ with person_logged_in(person2), ExpectedException(Unauthorized):
354+ distribution.security_admin = security_team
355+
356+ with anonymous_logged_in(), ExpectedException(Unauthorized):
357+ distribution.security_admin = security_team
358+
359 def test_newVulnerability_default_arguments(self):
360 distribution = self.factory.makeDistribution()
361 owner = distribution.owner
362
363 with person_logged_in(owner):
364- # The distribution owner can create a new vulnerability in
365- # the distribution.
366- vulnerability = distribution.newVulnerability(
367- status=VulnerabilityStatus.NEEDS_TRIAGE,
368- information_type=InformationType.PUBLIC,
369- creator=owner,
370- )
371-
372- self.assertThat(
373- vulnerability,
374- MatchesStructure.byEquality(
375- status=VulnerabilityStatus.NEEDS_TRIAGE,
376- creator=owner,
377- importance=BugTaskImportance.UNDECIDED,
378- information_type=InformationType.PUBLIC,
379- cve=None,
380- description=None,
381- notes=None,
382- mitigation=None,
383- importance_explanation=None,
384- date_made_public=None
385- )
386+ self.assert_newVulnerability_only_the_required_params(
387+ distribution,
388+ creator=owner
389 )
390
391 def test_newVulnerability_all_parameters(self):
392@@ -1896,6 +1925,54 @@ class TestDistributionVulnerabilities(TestCaseWithFactory):
393 )
394 )
395
396+ def test_newVulnerability_permissions(self):
397+ person = self.factory.makePerson()
398+ distribution = self.factory.makeDistribution()
399+ security_team = self.factory.makeTeam(members=[person])
400+
401+ # The distribution owner, admin, can create a new vulnerability
402+ # in the distribution.
403+ with person_logged_in(distribution.owner):
404+ self.assert_newVulnerability_only_the_required_params(
405+ distribution,
406+ creator=distribution.owner
407+ )
408+
409+ with admin_logged_in():
410+ self.assert_newVulnerability_only_the_required_params(
411+ distribution,
412+ creator=person
413+ )
414+ self.factory.makeCommercialSubscription(pillar=distribution)
415+
416+ with celebrity_logged_in('commercial_admin'):
417+ self.assert_newVulnerability_only_the_required_params(
418+ distribution,
419+ creator=person
420+ )
421+
422+ with person_logged_in(distribution.owner):
423+ distribution.security_admin = security_team
424+
425+ # When the security admin is set for the distribution,
426+ # users in that team can create a new vulnerability in the
427+ # distribution.
428+ with person_logged_in(person):
429+ self.assert_newVulnerability_only_the_required_params(
430+ distribution,
431+ creator=person
432+ )
433+
434+ def test_newVulnerability_cannot_be_called_by_unprivileged_users(self):
435+ person = self.factory.makePerson()
436+ distribution = self.factory.makeDistribution()
437+ with person_logged_in(person), ExpectedException(Unauthorized):
438+ distribution.newVulnerability(
439+ status=VulnerabilityStatus.NEEDS_TRIAGE,
440+ information_type=InformationType.PUBLIC,
441+ creator=person,
442+ )
443+
444 def test_getVulnerability_non_existent_id(self):
445 distribution = self.factory.makeDistribution()
446 vulnerability = distribution.getVulnerability(9999999)
447@@ -2146,6 +2223,66 @@ class TestDistributionVulnerabilitiesWebService(TestCaseWithFactory):
448 "date_made_public": Is(None),
449 }))
450
451+ def test_newVulnerability_security_admin(self):
452+ distribution = self.factory.makeDistribution()
453+ person = self.factory.makePerson()
454+ security_team = self.factory.makeTeam(members=[person])
455+ api_base = "http://api.launchpad.test/devel"
456+ distribution_url = api_base + api_url(distribution)
457+ owner_url = api_base + api_url(person)
458+
459+ with person_logged_in(distribution.owner):
460+ distribution.security_admin = security_team
461+
462+ webservice = webservice_for_person(
463+ person,
464+ permission=OAuthPermission.WRITE_PRIVATE,
465+ default_api_version="devel"
466+ )
467+ response = webservice.named_post(
468+ distribution_url,
469+ 'newVulnerability',
470+ status='Needs triage',
471+ information_type='Public',
472+ creator=owner_url,
473+ )
474+ self.assertEqual(200, response.status)
475+ self.assertThat(response.jsonBody(), ContainsDict({
476+ "distribution_link": Equals(distribution_url),
477+ "id": IsInstance(int),
478+ "cve_link": Is(None),
479+ "creator_link": Equals(owner_url),
480+ "status": Equals("Needs triage"),
481+ "description": Is(None),
482+ "notes": Is(None),
483+ "mitigation": Is(None),
484+ "importance": Equals("Undecided"),
485+ "importance_explanation": Is(None),
486+ "information_type": Equals("Public"),
487+ "date_made_public": Is(None),
488+ }))
489+
490+ def test_newVulnerability_unauthorized_users(self):
491+ distribution = self.factory.makeDistribution()
492+ person = self.factory.makePerson()
493+ api_base = "http://api.launchpad.test/devel"
494+ distribution_url = api_base + api_url(distribution)
495+ owner_url = api_base + api_url(person)
496+
497+ webservice = webservice_for_person(
498+ person,
499+ permission=OAuthPermission.WRITE_PRIVATE,
500+ default_api_version="devel"
501+ )
502+ response = webservice.named_post(
503+ distribution_url,
504+ 'newVulnerability',
505+ status='Needs triage',
506+ information_type='Public',
507+ creator=owner_url,
508+ )
509+ self.assertEqual(401, response.status)
510+
511 def test_newVulnerability_all_parameters(self):
512 distribution = self.factory.makeDistribution()
513 owner = distribution.owner
514diff --git a/lib/lp/security.py b/lib/lp/security.py
515index 4eeacbc..2ab931d 100644
516--- a/lib/lp/security.py
517+++ b/lib/lp/security.py
518@@ -55,7 +55,6 @@ from lp.blueprints.model.specificationsubscription import (
519 )
520 from lp.bugs.interfaces.bugtarget import IOfficialBugTagTargetRestricted
521 from lp.bugs.interfaces.structuralsubscription import IStructuralSubscription
522-from lp.bugs.interfaces.vulnerability import IVulnerability
523 from lp.bugs.model.bugsubscription import BugSubscription
524 from lp.bugs.model.bugtaskflat import BugTaskFlat
525 from lp.bugs.model.bugtasksearch import get_bug_privacy_filter
526@@ -1293,6 +1292,21 @@ class EditDistributionByDistroOwnersOrAdmins(AuthorizationBase):
527 or user.in_admin)
528
529
530+class SecurityAdminDistribution(AuthorizationBase):
531+ """The security admins of a distribution should be able to create
532+ and edit vulnerabilities in the distribution."""
533+ permission = 'launchpad.SecurityAdmin'
534+ usedfor = IDistribution
535+
536+ def checkAuthenticated(self, user):
537+ return (
538+ user.isOwner(self.obj)
539+ or is_commercial_case(self.obj, user)
540+ or user.in_admin
541+ or user.inTeam(self.obj.security_admin)
542+ )
543+
544+
545 class ModerateDistributionByDriversOrOwnersOrAdmins(AuthorizationBase):
546 """Distribution drivers, owners, and admins may plan releases.
547
548@@ -3780,14 +3794,3 @@ class EditCIBuild(AdminByBuilddAdmin):
549 if auth_repository.checkAuthenticated(user):
550 return True
551 return super().checkAuthenticated(user)
552-
553-
554-class EditVulnerability(AuthorizationBase):
555- permission = 'launchpad.Edit'
556- usedfor = IVulnerability
557-
558- def checkAuthenticated(self, user):
559- return (user.in_commercial_admin or user.in_admin or
560- user.isOwner(self.obj.distribution) or
561- user.isDriver(self.obj.distribution) or
562- user.isBugSupervisor(self.obj.distribution))

Subscribers

People subscribed via source and target branches

to status/vote changes: