Merge ~pappacena/launchpad:bug-attachment-removal-restrictions into launchpad:master

Proposed by Thiago F. Pappacena
Status: Merged
Approved by: Thiago F. Pappacena
Approved revision: 0aa3b0b5c64a04589bcc4c379b82ad012578fe5d
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~pappacena/launchpad:bug-attachment-removal-restrictions
Merge into: launchpad:master
Diff against target: 482 lines (+150/-107)
9 files modified
lib/lp/bugs/browser/bugattachment.py (+7/-3)
lib/lp/bugs/browser/tests/test_bugattachment_edit_view.py (+52/-41)
lib/lp/bugs/browser/tests/test_bugattachment_file_access.py (+3/-2)
lib/lp/bugs/configure.zcml (+4/-3)
lib/lp/bugs/doc/bugattachments.txt (+7/-13)
lib/lp/bugs/interfaces/bugattachment.py (+48/-40)
lib/lp/bugs/model/bugattachment.py (+1/-1)
lib/lp/bugs/security.py (+10/-4)
lib/lp/bugs/stories/bugattachments/xx-delete-bug-attachment.txt (+18/-0)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+381537@code.launchpad.net

Commit message

Restricting bug attachments removal so that only the attachment owner, admin users or registry experts can remove attachments.

This is done by double checking permission before removing at the backend, and removing the "Delete Attachment" button from the interface for users without permission to do so.

To post a comment you must log in.
Revision history for this message
Thiago F. Pappacena (pappacena) :
Revision history for this message
Ioana Lasc (ilasc) wrote :

Code looks good to me but I would have the same question as Thiago typed himself inline: is this list what we want: attachments can be removed by admin users, launchpad developers, bug owner or by the user who uploaded the attachment?

Unit Tests also look good, one question: this test case might be in there and I might be missing this but if answer to above is yes, should we also test for LP developers being able delete ?

Revision history for this message
Colin Watson (cjwatson) wrote :

I agree with Ioana that we should add an extra test here, though (per my comments) for a registry expert rather than for an LP developer.

review: Needs Fixing
3581fef... by Thiago F. Pappacena

Using zope permission system for bug attachment edit permission

d48828a... by Thiago F. Pappacena

Minor refactoring

77ce41b... by Thiago F. Pappacena

Removing unused exception class

ec057d2... by Thiago F. Pappacena

Adjusting some tests

d7d73cd... by Thiago F. Pappacena

Minor refactoring and comment

Revision history for this message
Thiago F. Pappacena (pappacena) :
Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Changed the way we check permission to use Zope's machinery for both change and delete.

Also, changed the list of who can change/delete an attachment:
- Admin
- Registry expert
- The owner of the message with the attachment

51e3f3c... by Thiago F. Pappacena

Merge branch 'master' into bug-attachment-removal-restrictions

Revision history for this message
Colin Watson (cjwatson) wrote :

Looks good now, thanks! Just a few nits.

Also, please update the commit message of this MP, which is now out of date with the security policy you're applying.

review: Approve
0aa3b0b... by Thiago F. Pappacena

Formatting import and coding style changes

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Thanks for the review, cjwatson. The requested changes were pushed. I'll land this in some minutes.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/bugs/browser/bugattachment.py b/lib/lp/bugs/browser/bugattachment.py
2index b239c45..e96e9fe 100644
3--- a/lib/lp/bugs/browser/bugattachment.py
4+++ b/lib/lp/bugs/browser/bugattachment.py
5@@ -1,4 +1,4 @@
6-# Copyright 2010-2018 Canonical Ltd. This software is licensed under the
7+# Copyright 2010-2020 Canonical Ltd. This software is licensed under the
8 # GNU Affero General Public License version 3 (see the file LICENSE).
9
10 """Bug attachment views."""
11@@ -42,6 +42,7 @@ from lp.services.webapp import (
12 GetitemNavigation,
13 Navigation,
14 )
15+from lp.services.webapp.authorization import check_permission
16 from lp.services.webapp.escaping import structured
17 from lp.services.webapp.interfaces import (
18 ICanonicalUrlData,
19@@ -135,7 +136,10 @@ class BugAttachmentEditView(LaunchpadFormView, BugAttachmentContentCheck):
20 patch=attachment.type == BugAttachmentType.PATCH,
21 contenttype=attachment.libraryfile.mimetype)
22
23- @action('Change', name='change')
24+ def canEditAttachment(self, action):
25+ return check_permission('launchpad.Edit', self.context)
26+
27+ @action('Change', name='change', condition=canEditAttachment)
28 def change_action(self, action, data):
29 if data['patch']:
30 new_type = BugAttachmentType.PATCH
31@@ -169,7 +173,7 @@ class BugAttachmentEditView(LaunchpadFormView, BugAttachmentContentCheck):
32 ILibraryFileAliasWithParent)
33 lfa_with_parent.mimetype = data['contenttype']
34
35- @action('Delete Attachment', name='delete')
36+ @action('Delete Attachment', name='delete', condition=canEditAttachment)
37 def delete_action(self, action, data):
38 libraryfile_url = ProxiedLibraryFileAlias(
39 self.context.libraryfile, self.context).http_url
40diff --git a/lib/lp/bugs/browser/tests/test_bugattachment_edit_view.py b/lib/lp/bugs/browser/tests/test_bugattachment_edit_view.py
41index 2710d05..34d9a03 100644
42--- a/lib/lp/bugs/browser/tests/test_bugattachment_edit_view.py
43+++ b/lib/lp/bugs/browser/tests/test_bugattachment_edit_view.py
44@@ -1,11 +1,12 @@
45-# Copyright 2010 Canonical Ltd. This software is licensed under the
46+# Copyright 2010-2020 Canonical Ltd. This software is licensed under the
47 # GNU Affero General Public License version 3 (see the file LICENSE).
48
49 __metaclass__ = type
50
51 import transaction
52-from zope.security.interfaces import Unauthorized
53+from zope.component import getUtility
54
55+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
56 from lp.testing import (
57 login_person,
58 person_logged_in,
59@@ -30,6 +31,11 @@ class TestBugAttachmentEditView(TestCaseWithFactory):
60 def setUp(self):
61 super(TestBugAttachmentEditView, self).setUp()
62 self.bug_owner = self.factory.makePerson()
63+ self.registry_expert = self.factory.makePerson()
64+ registry = getUtility(ILaunchpadCelebrities).registry_experts
65+ with person_logged_in(registry.teamowner):
66+ registry.addMember(self.registry_expert, registry.teamowner)
67+
68 login_person(self.bug_owner)
69 self.bug = self.factory.makeBug(owner=self.bug_owner)
70 self.bugattachment = self.factory.makeBugAttachment(
71@@ -40,11 +46,8 @@ class TestBugAttachmentEditView(TestCaseWithFactory):
72 # we start the tests.
73 transaction.commit()
74
75- def test_change_action_public_bug(self):
76- # Properties of attachments for public bugs can be
77- # changed by every user.
78- user = self.factory.makePerson()
79- login_person(user)
80+ def test_user_changes_their_own_attachment(self):
81+ login_person(self.bugattachment.message.owner)
82 create_initialized_view(
83 self.bugattachment, name='+edit', form=self.CHANGE_FORM_DATA)
84 self.assertEqual('new description', self.bugattachment.title)
85@@ -52,14 +55,9 @@ class TestBugAttachmentEditView(TestCaseWithFactory):
86 self.assertEqual(
87 'application/whatever', self.bugattachment.libraryfile.mimetype)
88
89- def test_change_action_private_bug(self):
90- # Subscribers of a private bug can edit attachments.
91- user = self.factory.makePerson()
92- self.bug.setPrivate(True, self.bug_owner)
93- with person_logged_in(self.bug_owner):
94- self.bug.subscribe(user, self.bug_owner)
95- transaction.commit()
96- login_person(user)
97+ def test_admin_changes_any_attachment(self):
98+ admin = self.factory.makeAdministrator()
99+ login_person(admin)
100 create_initialized_view(
101 self.bugattachment, name='+edit', form=self.CHANGE_FORM_DATA)
102 self.assertEqual('new description', self.bugattachment.title)
103@@ -67,44 +65,57 @@ class TestBugAttachmentEditView(TestCaseWithFactory):
104 self.assertEqual(
105 'application/whatever', self.bugattachment.libraryfile.mimetype)
106
107- def test_change_action_private_bug_unauthorized(self):
108- # Other users cannot edit attachments of private bugs.
109- user = self.factory.makePerson()
110- self.bug.setPrivate(True, self.bug_owner)
111- transaction.commit()
112- login_person(user)
113- self.assertRaises(
114- Unauthorized, create_initialized_view, self.bugattachment,
115- name='+edit', form=self.CHANGE_FORM_DATA)
116+ def test_registry_expert_changes_any_attachment(self):
117+ login_person(self.registry_expert)
118+ create_initialized_view(
119+ self.bugattachment, name='+edit', form=self.CHANGE_FORM_DATA)
120+ self.assertEqual('new description', self.bugattachment.title)
121+ self.assertTrue(self.bugattachment.is_patch)
122+ self.assertEqual(
123+ 'application/whatever', self.bugattachment.libraryfile.mimetype)
124+
125+ def test_other_user_changes_attachment_fails(self):
126+ random_user = self.factory.makePerson()
127+ login_person(random_user)
128+ create_initialized_view(
129+ self.bugattachment, name='+edit', form=self.CHANGE_FORM_DATA)
130+ self.assertEqual('attachment description', self.bugattachment.title)
131+ self.assertFalse(self.bugattachment.is_patch)
132+ self.assertEqual('text/plain', self.bugattachment.libraryfile.mimetype)
133
134 DELETE_FORM_DATA = {
135 'field.actions.delete': 'Delete Attachment',
136 }
137
138- def test_delete_action_public_bug(self):
139- # Bug attachments can be removed from a bug.
140+ def test_delete_cannot_be_performed_by_other_users(self):
141 user = self.factory.makePerson()
142 login_person(user)
143 create_initialized_view(
144 self.bugattachment, name='+edit', form=self.DELETE_FORM_DATA)
145+ self.assertEqual(1, self.bug.attachments.count())
146+
147+ def test_admin_can_delete_any_attachment(self):
148+ admin = self.factory.makeAdministrator()
149+ login_person(admin)
150+ create_initialized_view(
151+ self.bugattachment, name='+edit', form=self.DELETE_FORM_DATA)
152 self.assertEqual(0, self.bug.attachments.count())
153
154- def test_delete_action_private_bug(self):
155- # Subscribers of a private bug can delete attachments.
156- user = self.factory.makePerson()
157- self.bug.setPrivate(True, self.bug_owner)
158- with person_logged_in(self.bug_owner):
159- self.bug.subscribe(user, self.bug_owner)
160- login_person(user)
161+ def test_registry_expert_can_delete_any_attachment(self):
162+ login_person(self.registry_expert)
163 create_initialized_view(
164 self.bugattachment, name='+edit', form=self.DELETE_FORM_DATA)
165 self.assertEqual(0, self.bug.attachments.count())
166
167- def test_delete_action_private_bug_unautorized(self):
168- # Other users cannot delete private bug attachments.
169- user = self.factory.makePerson()
170- self.bug.setPrivate(True, self.bug_owner)
171- login_person(user)
172- self.assertRaises(
173- Unauthorized, create_initialized_view, self.bugattachment,
174- name='+edit', form=self.DELETE_FORM_DATA)
175+ def test_attachment_owner_can_delete_their_own_attachment(self):
176+ bug = self.factory.makeBug(owner=self.bug_owner)
177+ another_user = self.factory.makePerson()
178+ attachment = self.factory.makeBugAttachment(
179+ bug=bug, owner=another_user, filename='foo.diff',
180+ data=b'the file content', description='some file',
181+ content_type='text/plain', is_patch=False)
182+
183+ login_person(another_user)
184+ create_initialized_view(
185+ attachment, name='+edit', form=self.DELETE_FORM_DATA)
186+ self.assertEqual(0, bug.attachments.count())
187diff --git a/lib/lp/bugs/browser/tests/test_bugattachment_file_access.py b/lib/lp/bugs/browser/tests/test_bugattachment_file_access.py
188index 2f68b38..8a967c8 100644
189--- a/lib/lp/bugs/browser/tests/test_bugattachment_file_access.py
190+++ b/lib/lp/bugs/browser/tests/test_bugattachment_file_access.py
191@@ -1,4 +1,4 @@
192-# Copyright 2010-2019 Canonical Ltd. This software is licensed under the
193+# Copyright 2010-2020 Canonical Ltd. This software is licensed under the
194 # GNU Affero General Public License version 3 (see the file LICENSE).
195
196 __metaclass__ = type
197@@ -53,7 +53,8 @@ class TestAccessToBugAttachmentFiles(TestCaseWithFactory):
198 login_person(self.bug_owner)
199 self.bug = self.factory.makeBug(owner=self.bug_owner)
200 self.bugattachment = self.factory.makeBugAttachment(
201- bug=self.bug, filename='foo.txt', data=b'file content')
202+ owner=self.bug_owner, bug=self.bug, filename='foo.txt',
203+ data=b'file content')
204
205 def test_traversal_to_lfa_of_bug_attachment(self):
206 # Traversing to the URL provided by a ProxiedLibraryFileAlias of a
207diff --git a/lib/lp/bugs/configure.zcml b/lib/lp/bugs/configure.zcml
208index f410aa8..7edae44 100644
209--- a/lib/lp/bugs/configure.zcml
210+++ b/lib/lp/bugs/configure.zcml
211@@ -1,4 +1,4 @@
212-<!-- Copyright 2009-2019 Canonical Ltd. This software is licensed under the
213+<!-- Copyright 2009-2020 Canonical Ltd. This software is licensed under the
214 GNU Affero General Public License version 3 (see the file LICENSE).
215 -->
216
217@@ -338,10 +338,11 @@
218 class="lp.bugs.model.bugattachment.BugAttachment">
219 <require
220 permission="launchpad.View"
221- interface="lp.bugs.interfaces.bugattachment.IBugAttachment"/>
222+ interface="lp.bugs.interfaces.bugattachment.IBugAttachmentView"/>
223 <require
224 permission="launchpad.Edit"
225- set_schema="lp.bugs.interfaces.bugattachment.IBugAttachment"/>
226+ interface="lp.bugs.interfaces.bugattachment.IBugAttachmentEdit"
227+ set_schema="lp.bugs.interfaces.bugattachment.IBugAttachmentView" />
228 </class>
229
230 <!-- BugAttachmentSet -->
231diff --git a/lib/lp/bugs/doc/bugattachments.txt b/lib/lp/bugs/doc/bugattachments.txt
232index 0bac3b7..977c944 100644
233--- a/lib/lp/bugs/doc/bugattachments.txt
234+++ b/lib/lp/bugs/doc/bugattachments.txt
235@@ -315,29 +315,25 @@ anonymous can read the attachment's attributes, but they can't set them:
236 >>> import transaction
237 >>> transaction.abort()
238
239-Both Sample Person and Foo Bar can access and set the attributes, though:
240-
241- >>> login('test@canonical.com')
242- >>> attachment.title
243- u'this fixes the bug'
244- >>> attachment.title = 'Better Title'
245+Attachment owner can access and set the attributes, though:
246
247 >>> login('foo.bar@canonical.com')
248 >>> attachment.title
249- u'Better Title'
250+ u'this fixes the bug'
251 >>> attachment.title = 'Even Better Title'
252
253 Now let's make the bug private instead:
254
255 >>> bug_four.setPrivate(True, getUtility(ILaunchBag).user)
256 True
257+ >>> logout()
258
259 Foo Bar isn't explicitly subscribed to the bug, BUT they are an admin, so
260-they can access and set the attachment's attributes:
261+they can access the attachment's attributes:
262
263+ >>> login('test@canonical.com')
264 >>> attachment.title
265 u'Even Better Title'
266- >>> attachment.title = 'Even Better Title'
267
268 Mr. No Privs, who is not subscribed to bug_four, cannot access or set the
269 attachments attributes:
270@@ -365,13 +361,11 @@ Of course, anonymous is also not allowed to access or set them:
271 ...
272 Unauthorized: (..., 'title',...
273
274-Sample Person is explicitly subscribed, so they can both access and set
275-the attributes:
276+Sample Person is explicitly subscribed, so they can access the attributes:
277
278 >>> login('test@canonical.com')
279 >>> attachment.title
280 u'Even Better Title'
281- >>> attachment.title = 'Better Title'
282
283
284 Let's make the bug public again:
285@@ -423,7 +417,7 @@ There are no patches attached to any bugs:
286 Let's make our attachment a patch and search again:
287
288 >>> from lp.services.database.sqlbase import flush_database_updates
289- >>> login('test@canonical.com')
290+ >>> login('foo.bar@canonical.com')
291 >>> attachment.type = BugAttachmentType.PATCH
292 >>> flush_database_updates()
293 >>> attachmenttype = BugAttachmentType.PATCH
294diff --git a/lib/lp/bugs/interfaces/bugattachment.py b/lib/lp/bugs/interfaces/bugattachment.py
295index bbfa055..7616fd6 100644
296--- a/lib/lp/bugs/interfaces/bugattachment.py
297+++ b/lib/lp/bugs/interfaces/bugattachment.py
298@@ -1,4 +1,4 @@
299-# Copyright 2009 Canonical Ltd. This software is licensed under the
300+# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
301 # GNU Affero General Public License version 3 (see the file LICENSE).
302
303 """Bug attachment interfaces."""
304@@ -64,36 +64,8 @@ class BugAttachmentType(DBEnumeratedType):
305 """)
306
307
308-class IBugAttachment(IHasBug):
309- """A file attachment to an IBug.
310-
311- Launchpadlib example of accessing content of an attachment::
312-
313- for attachment in bug.attachments:
314- buffer = attachment.data.open()
315- for line in buffer:
316- print line
317- buffer.close()
318-
319- Launchpadlib example of accessing metadata about an attachment::
320-
321- attachment = bug.attachments[0]
322- print "title:", attachment.title
323- print "ispatch:", attachment.type
324-
325- For information about the file-like object returned by
326- attachment.data.open() see lazr.restfulclient's documentation of the
327- HostedFile object.
328-
329- Details about the message associated with an attachment can be found on
330- the "message" attribute::
331-
332- message = attachment.message
333- print "subject:", message.subject.encode('utf-8')
334- print "owner:", message.owner.display_name.encode('utf-8')
335- print "created:", message.date_created
336- """
337- export_as_webservice_entry()
338+class IBugAttachmentView(IHasBug):
339+ """Interface for BugAttachment that requires launchpad.View permission."""
340
341 id = Int(title=_('ID'), required=True, readonly=True)
342 bug = exported(
343@@ -126,10 +98,16 @@ class IBugAttachment(IHasBug):
344 description=_('Is this attachment a patch?'),
345 readonly=True)
346
347- @call_with(user=REQUEST_USER)
348- @export_write_operation()
349- def removeFromBug(user):
350- """Remove the attachment from the bug."""
351+ def getFileByName(filename):
352+ """Return the `ILibraryFileAlias` for the given file name.
353+
354+ NotFoundError is raised if the given filename does not match
355+ libraryfile.filename.
356+ """
357+
358+
359+class IBugAttachmentEdit(Interface):
360+ """Interface for BugAttachment that requires launchpad.Edit permission."""
361
362 def destroySelf():
363 """Delete this record.
364@@ -137,12 +115,42 @@ class IBugAttachment(IHasBug):
365 The library file content for this attachment is set to None.
366 """
367
368- def getFileByName(filename):
369- """Return the `ILibraryFileAlias for the given file name.
370+ @call_with(user=REQUEST_USER)
371+ @export_write_operation()
372+ def removeFromBug(user):
373+ """Remove the attachment from the bug."""
374
375- NotFoundError is raised if the given filename does not match
376- libraryfile.filename.
377- """
378+
379+class IBugAttachment(IBugAttachmentView, IBugAttachmentEdit):
380+ """A file attachment to an IBug.
381+
382+ Launchpadlib example of accessing content of an attachment::
383+
384+ for attachment in bug.attachments:
385+ buffer = attachment.data.open()
386+ for line in buffer:
387+ print line
388+ buffer.close()
389+
390+ Launchpadlib example of accessing metadata about an attachment::
391+
392+ attachment = bug.attachments[0]
393+ print "title:", attachment.title
394+ print "ispatch:", attachment.type
395+
396+ For information about the file-like object returned by
397+ attachment.data.open() see lazr.restfulclient's documentation of the
398+ HostedFile object.
399+
400+ Details about the message associated with an attachment can be found on
401+ the "message" attribute::
402+
403+ message = attachment.message
404+ print "subject:", message.subject.encode('utf-8')
405+ print "owner:", message.owner.display_name.encode('utf-8')
406+ print "created:", message.date_created
407+ """
408+ export_as_webservice_entry()
409
410
411 # Need to do this here because of circular imports.
412diff --git a/lib/lp/bugs/model/bugattachment.py b/lib/lp/bugs/model/bugattachment.py
413index d514e31..e621b52 100644
414--- a/lib/lp/bugs/model/bugattachment.py
415+++ b/lib/lp/bugs/model/bugattachment.py
416@@ -1,4 +1,4 @@
417-# Copyright 2009 Canonical Ltd. This software is licensed under the
418+# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
419 # GNU Affero General Public License version 3 (see the file LICENSE).
420
421 __metaclass__ = type
422diff --git a/lib/lp/bugs/security.py b/lib/lp/bugs/security.py
423index 4e22be0..573f85a 100644
424--- a/lib/lp/bugs/security.py
425+++ b/lib/lp/bugs/security.py
426@@ -211,7 +211,7 @@ class ViewBugAttachment(DelegatedAuthorization):
427 bugattachment, bugattachment.bug)
428
429
430-class EditBugAttachment(DelegatedAuthorization):
431+class EditBugAttachment(AuthorizationBase):
432 """Security adapter for editing a bug attachment.
433
434 If the user is authorized to view the bug, they're allowed to edit the
435@@ -220,9 +220,15 @@ class EditBugAttachment(DelegatedAuthorization):
436 permission = 'launchpad.Edit'
437 usedfor = IBugAttachment
438
439- def __init__(self, bugattachment):
440- super(EditBugAttachment, self).__init__(
441- bugattachment, bugattachment.bug)
442+ def checkAuthenticated(self, user):
443+ # XXX: pappacena 2020-04-02: Maybe for bug tasks we should also allow
444+ # pillar's bug supervisor to edit the attachments.
445+ return (user.in_admin or
446+ user.in_registry_experts or
447+ user.inTeam(self.obj.message.owner))
448+
449+ def checkUnauthenticated(self):
450+ return False
451
452
453 class ViewBugActivity(DelegatedAuthorization):
454diff --git a/lib/lp/bugs/stories/bugattachments/xx-delete-bug-attachment.txt b/lib/lp/bugs/stories/bugattachments/xx-delete-bug-attachment.txt
455index 53ab490..3a45f61 100644
456--- a/lib/lp/bugs/stories/bugattachments/xx-delete-bug-attachment.txt
457+++ b/lib/lp/bugs/stories/bugattachments/xx-delete-bug-attachment.txt
458@@ -42,6 +42,24 @@ attachment.
459 >>> user_browser.getControl('Delete Attachment') is not None
460 True
461
462+But this delete option should not be shown for other users.
463+
464+ >>> from lp.testing.pages import setupBrowserForUser
465+ >>> login('foo.bar@canonical.com')
466+ >>> another_user = factory.makePerson()
467+ >>> another_browser = setupBrowserForUser(another_user)
468+ >>> logout()
469+
470+ >>> another_browser.open('http://launchpad.test/bugs/2')
471+ >>> another_browser.getLink(url=re.compile(r'.*/\+attachment/\d+$')).click()
472+ >>> print(another_browser.title)
473+ Bug #2...
474+ >>> try:
475+ ... another_browser.getControl('Delete Attachment')
476+ ... raise ValueError("'Delete Attachment' button shouldn't be here!")
477+ ... except LookupError:
478+ ... pass
479+
480 If the button is pressed, the attachment will be deleted, which means
481 that it won't show up in the attachments portlet anymore. Since there
482 arent' any other attachments, the portlet won't show up at all.