Merge lp:~julian-edwards/launchpad/perms-for-changing-uploaders--bug-828894 into lp:launchpad

Proposed by Julian Edwards
Status: Merged
Approved by: Julian Edwards
Approved revision: no longer in the source branch.
Merged at revision: 13797
Proposed branch: lp:~julian-edwards/launchpad/perms-for-changing-uploaders--bug-828894
Merge into: lp:launchpad
Diff against target: 478 lines (+144/-131)
7 files modified
lib/canonical/launchpad/security.py (+16/-9)
lib/lp/soyuz/configure.zcml (+1/-25)
lib/lp/soyuz/doc/archivepermission.txt (+3/-40)
lib/lp/soyuz/interfaces/archive.py (+41/-41)
lib/lp/soyuz/interfaces/archivepermission.py (+9/-0)
lib/lp/soyuz/stories/webservice/xx-archive.txt (+67/-11)
lib/lp/soyuz/tests/test_archive.py (+7/-5)
To merge this branch: bzr merge lp:~julian-edwards/launchpad/perms-for-changing-uploaders--bug-828894
Reviewer Review Type Date Requested Status
Curtis Hovey (community) code Approve
Review via email: mp+72220@code.launchpad.net

Commit message

[r=sinzui][bug=828894] Fix the permissions required to change uploader-related permissions on a distribution.

Description of the change

= Summary =
Fix the permissions required to change uploader-related permissions
on a distribution.

== Proposed fix ==
There are three types of upload permissions for a distribution's primary
archive:
 1. component-based
 2. package-based
 3. packageset-based

Right now to change #1 and #2 you need to be in the team that owns the archive
object. To change #3 you need to be an admin or a member of Ubuntu Techboard.

These 2 rules are basically useless for anything other than Ubuntu because
 * There's no way to alter the archive owner other than via SQL
 * Derived distros don't want Ubuntu Techboard changing their permissions!

This branch fixes this situation so that the two different rules are unified
so that you need to be in the *distribution's* owning team (this is Maintainer
on the distro page) to change these permissions.

== Pre-implementation notes ==

Discussed with Gavin as I had a PEBKAC moment working out how the heck
we'd got by without an EditArchive security adapter up until now but it turns
out that it inherits the IHasOwner magic.

== Implementation details ==

Several changes were needed:

 * Add a new security adapter for IArchive/launchpad.Edit that allows
   distro main archives to be edited by the distro owner or an admin, and
   leaves PPAs editable by their archive owners.

 * Remove the EditArchivePermissionSet security adapter, it is redundant
   because it can never have enough contextual information to be useful (it
   doesn't know what the archive is). We now rely on the utility only getting
   called via IArchive code.

 * Change the zcml declaration for IArchivePermissionSet to allow any caller
   to call its methods.

 * Remove unnecessary testing in lib/lp/soyuz/doc/archivepermission.txt

 * Add a warning in lib/lp/soyuz/interfaces/archivepermission.py about never
   exporting ArchivePermissionSet on the webservice because it has no
   good security model.

 * Fix lib/lp/soyuz/stories/webservice/xx-archive.txt to handle the new
   security checks.

 * Fix lib/lp/soyuz/tests/test_archive.py to not rely on the old checks
   to alter some of the uploader permissions.

== Tests ==

bin/test -cvvt test_archive -t xx-archive.txt -t archivepermission.txt

And probably more, I've not run this through ec2 yet.

== Demo and Q/A ==

Dogfood.

To post a comment you must log in.
Revision history for this message
Curtis Hovey (sinzui) wrote :

Thank you for this fix. I believe with your change that there is exactly one call for user.in_ubuntu_techboard Which is used in security.py and defined in lp/registry/interfaces/role.py. Is there any chance you could also change EditPackagesetSet to not use that role and remove the role from code?

review: Approve (code)
Revision history for this message
Julian Edwards (julian-edwards) wrote :

Thanks Curtis. I won't do that change in this branch, but I'll file a bug and fix it soon, I'd forgotten about that.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/security.py'
2--- lib/canonical/launchpad/security.py 2011-08-24 00:47:25 +0000
3+++ lib/canonical/launchpad/security.py 2011-08-25 10:17:54 +0000
4@@ -2246,6 +2246,22 @@
5 return not self.obj.private and self.obj.enabled
6
7
8+class EditArchive(AuthorizationBase):
9+ """Restrict archive editing operations.
10+
11+ If the archive a primary archive then we check the user is in the
12+ distribution's owning team, otherwise we check the archive owner.
13+ """
14+ permission = 'launchpad.Edit'
15+ usedfor = IArchive
16+
17+ def checkAuthenticated(self, user):
18+ if self.obj.is_main:
19+ return user.isOwner(self.obj.distribution) or user.in_admin
20+
21+ return user.isOwner(self.obj) or user.in_admin
22+
23+
24 class AppendArchive(AuthorizationBase):
25 """Restrict appending (upload and copy) operations on archives.
26
27@@ -2535,15 +2551,6 @@
28 usedfor = IWikiName
29
30
31-class EditArchivePermissionSet(AuthorizationBase):
32- permission = 'launchpad.Edit'
33- usedfor = IArchivePermissionSet
34-
35- def checkAuthenticated(self, user):
36- """Users must be an admin or a member of the tech board."""
37- return user.in_admin or user.in_ubuntu_techboard
38-
39-
40 class ViewPackageset(AnonymousAuthorization):
41 """Anyone can view an IPackageset."""
42 usedfor = IPackageset
43
44=== modified file 'lib/lp/soyuz/configure.zcml'
45--- lib/lp/soyuz/configure.zcml 2011-08-24 00:47:25 +0000
46+++ lib/lp/soyuz/configure.zcml 2011-08-25 10:17:54 +0000
47@@ -471,31 +471,7 @@
48 class="lp.soyuz.model.archivepermission.ArchivePermissionSet"
49 provides="lp.soyuz.interfaces.archivepermission.IArchivePermissionSet">
50 <allow
51- attributes="
52- checkAuthenticated
53- componentsForUploader
54- uploadersForComponent
55- packagesForUploader
56- uploadersForPackage
57- queueAdminsForComponent
58- componentsForQueueAdmin
59- permissionsForPerson
60- uploadersForPackageset
61- packagesetsForUploader
62- packagesetsForSource
63- packagesetsForSourceUploader
64- isSourceUploadAllowed
65- newPackageUploader
66- newComponentUploader
67- newQueueAdmin
68- deletePackageUploader
69- deleteComponentUploader
70- deleteQueueAdmin"/>
71- <require
72- permission="launchpad.Edit"
73- attributes="
74- newPackagesetUploader
75- deletePackagesetUploader"/>
76+ interface="lp.soyuz.interfaces.archivepermission.IArchivePermissionSet"/>
77 </securedutility>
78
79 <!-- BinaryPackageBuild -->
80
81=== modified file 'lib/lp/soyuz/doc/archivepermission.txt'
82--- lib/lp/soyuz/doc/archivepermission.txt 2011-08-24 00:47:25 +0000
83+++ lib/lp/soyuz/doc/archivepermission.txt 2011-08-25 10:17:54 +0000
84@@ -266,46 +266,9 @@
85 ~~~~~~~~~~~~~~~~~~~~
86
87 There are some methods that will enable the caller to add and delete
88-PackageSet based permissions. They currently require launchpad.Edit
89-permission to use, which enforces the user to be an admin or a member
90-of the "techboard" (Ubuntu Technical Board) team.
91-
92-Set up a helper function to check access:
93-
94- >>> from zope.security.checker import canAccess
95- >>> from zope.security.proxy import removeSecurityProxy
96- >>> restricted_methods = (
97- ... 'newPackagesetUploader',
98- ... 'deletePackagesetUploader',
99- ... )
100- >>> def checkAccess(true_or_false):
101- ... for method in restricted_methods:
102- ... assert(canAccess(permission_set, method) == true_or_false)
103- >>> from lp.app.interfaces.launchpad import ILaunchpadCelebrities
104- >>> from lp.registry.interfaces.person import IPersonSet
105- >>> from lp.registry.interfaces.teammembership import (
106- ... TeamMembershipStatus)
107- >>> personset = getUtility(IPersonSet)
108- >>> techboard = getUtility(ILaunchpadCelebrities).ubuntu_techboard
109- >>> techboard = removeSecurityProxy(techboard)
110-
111-Ordinary users have no access:
112-
113- >>> login("test@canonical.com")
114- >>> checkAccess(False)
115-
116-Admins have access:
117-
118- >>> login("foo.bar@canonical.com")
119- >>> checkAccess(True)
120-
121-Now add "test@canonical.com" to the techboard team and log in as him.
122-
123- >>> person = personset.getByEmail("test@canonical.com")
124- >>> ignored = techboard.addMember(
125- ... person, reviewer=person, status=TeamMembershipStatus.APPROVED,
126- ... force_team_add=True)
127- >>> login_person(person)
128+PackageSet based permissions. They require no special permission to use
129+because these methods should only ever be called from inside other security
130+proxied objects like IArchive.
131
132 newPackageUploader() creates a permission for a person to upload to a
133 specific package:
134
135=== modified file 'lib/lp/soyuz/interfaces/archive.py'
136--- lib/lp/soyuz/interfaces/archive.py 2011-08-01 15:28:09 +0000
137+++ lib/lp/soyuz/interfaces/archive.py 2011-08-25 10:17:54 +0000
138@@ -711,29 +711,6 @@
139 """
140
141 @operation_parameters(
142- person=Reference(schema=IPerson),
143- # Really IPackageset, corrected in _schema_circular_imports to avoid
144- # circular import.
145- packageset=Reference(
146- Interface, title=_("Package set"), required=True),
147- explicit=Bool(
148- title=_("Explicit"), required=False))
149- # Really IArchivePermission, set in _schema_circular_imports to avoid
150- # circular import.
151- @export_factory_operation(Interface, [])
152- def newPackagesetUploader(person, packageset, explicit=False):
153- """Add a package set based permission for a person.
154-
155- :param person: An `IPerson` for whom you want to add permission.
156- :param packageset: An `IPackageset`.
157- :param explicit: True if the package set in question requires
158- specialist skills for proper handling.
159-
160- :return: The new `ArchivePermission`, or the existing one if it
161- already exists.
162- """
163-
164- @operation_parameters(
165 # Really IPackageset, corrected in _schema_circular_imports to avoid
166 # circular import.
167 packageset=Reference(
168@@ -757,24 +734,6 @@
169 """
170
171 @operation_parameters(
172- person=Reference(schema=IPerson),
173- # Really IPackageset, corrected in _schema_circular_imports to avoid
174- # circular import.
175- packageset=Reference(
176- Interface, title=_("Package set"), required=True),
177- explicit=Bool(
178- title=_("Explicit"), required=False))
179- @export_write_operation()
180- def deletePackagesetUploader(person, packageset, explicit=False):
181- """Revoke upload permissions for a person.
182-
183- :param person: An `IPerson` for whom you want to revoke permission.
184- :param packageset: An `IPackageset`.
185- :param explicit: The value of the 'explicit' flag for the permission
186- to be revoked.
187- """
188-
189- @operation_parameters(
190 person=Reference(schema=IPerson))
191 # Really IArchivePermission, set in _schema_circular_imports to avoid
192 # circular import.
193@@ -1490,6 +1449,29 @@
194
195 @operation_parameters(
196 person=Reference(schema=IPerson),
197+ # Really IPackageset, corrected in _schema_circular_imports to avoid
198+ # circular import.
199+ packageset=Reference(
200+ Interface, title=_("Package set"), required=True),
201+ explicit=Bool(
202+ title=_("Explicit"), required=False))
203+ # Really IArchivePermission, set in _schema_circular_imports to avoid
204+ # circular import.
205+ @export_factory_operation(Interface, [])
206+ def newPackagesetUploader(person, packageset, explicit=False):
207+ """Add a package set based permission for a person.
208+
209+ :param person: An `IPerson` for whom you want to add permission.
210+ :param packageset: An `IPackageset`.
211+ :param explicit: True if the package set in question requires
212+ specialist skills for proper handling.
213+
214+ :return: The new `ArchivePermission`, or the existing one if it
215+ already exists.
216+ """
217+
218+ @operation_parameters(
219+ person=Reference(schema=IPerson),
220 source_package_name=TextLine(
221 title=_("Source Package Name"), required=True))
222 @export_write_operation()
223@@ -1528,6 +1510,24 @@
224 :param component: An `IComponent` or textual component name.
225 """
226
227+ @operation_parameters(
228+ person=Reference(schema=IPerson),
229+ # Really IPackageset, corrected in _schema_circular_imports to avoid
230+ # circular import.
231+ packageset=Reference(
232+ Interface, title=_("Package set"), required=True),
233+ explicit=Bool(
234+ title=_("Explicit"), required=False))
235+ @export_write_operation()
236+ def deletePackagesetUploader(person, packageset, explicit=False):
237+ """Revoke upload permissions for a person.
238+
239+ :param person: An `IPerson` for whom you want to revoke permission.
240+ :param packageset: An `IPackageset`.
241+ :param explicit: The value of the 'explicit' flag for the permission
242+ to be revoked.
243+ """
244+
245 def enable():
246 """Enable the archive."""
247
248
249=== modified file 'lib/lp/soyuz/interfaces/archivepermission.py'
250--- lib/lp/soyuz/interfaces/archivepermission.py 2011-08-24 00:47:25 +0000
251+++ lib/lp/soyuz/interfaces/archivepermission.py 2011-08-25 10:17:54 +0000
252@@ -129,6 +129,15 @@
253 class IArchivePermissionSet(Interface):
254 """The interface for `ArchivePermissionSet`."""
255
256+ # Do not export this utility directly on the webservice. There is
257+ # no reasonable security model we can implement for it because it
258+ # requires the archive context to be able to make an informed
259+ # security decision.
260+ #
261+ # For this reason, the security declaration in the zcml is
262+ # deliberately permissive. We don't expect anything to access this
263+ # utility except the IArchive code, which is appropriately protected.
264+
265 def checkAuthenticated(person, archive, permission, item):
266 """The `ArchivePermission` records that authenticate the person.
267
268
269=== modified file 'lib/lp/soyuz/stories/webservice/xx-archive.txt'
270--- lib/lp/soyuz/stories/webservice/xx-archive.txt 2011-08-24 00:47:25 +0000
271+++ lib/lp/soyuz/stories/webservice/xx-archive.txt 2011-08-25 10:17:54 +0000
272@@ -1,7 +1,7 @@
273 Archives
274 ========
275
276-Representations for IArchive can be fetch via the API for PPAs and
277+Representations for IArchive can be fetched via the API for PPAs and
278 distribution archives.
279
280 >>> cprov_archive = webservice.get("/~cprov/+archive/ppa").jsonBody()
281@@ -270,24 +270,80 @@
282 >>> from canonical.launchpad.testing.pages import webservice_for_person
283 >>> from canonical.launchpad.webapp.interfaces import OAuthPermission
284 >>> from lp.registry.interfaces.distribution import IDistributionSet
285+ >>> from lp.testing.factory import LaunchpadObjectFactory
286+ >>> factory = LaunchpadObjectFactory()
287
288- >>> login(ANONYMOUS)
289+ >>> login('foo.bar@canonical.com')
290 >>> cjwatson = getUtility(IPersonSet).getByName('kamion')
291 >>> ubuntu_db = getUtility(IDistributionSet).getByName('ubuntu')
292 >>> cjwatson.inTeam(ubuntu_db.main_archive.owner)
293 True
294
295+Let's also make a new Person to own the Ubuntu distro.
296+
297+ >>> ubuntu_owner = factory.makePerson()
298+ >>> ubuntu_db.owner = ubuntu_owner
299+
300 >>> logout()
301
302 >>> cjwatson_webservice = webservice_for_person(
303 ... cjwatson, permission=OAuthPermission.WRITE_PUBLIC)
304-
305+ >>> ubuntu_owner_webservice = webservice_for_person(
306+ ... ubuntu_owner, permission=OAuthPermission.WRITE_PUBLIC)
307+ >>> name12 = webservice.get("/~name12").jsonBody()
308+
309+And here's a packageset to play with later:
310+
311+ >>> print webservice.named_post(
312+ ... '/package-sets', 'new', {},
313+ ... name=u'umbrella', description=u'Contains all source packages',
314+ ... owner=name12['self_link'])
315+ HTTP/1.1 201 Created
316+ ...
317+
318+ >>> packageset = webservice.get("/package-sets/hoary/umbrella").jsonBody()
319+
320+
321+To be able to amend any permissions on a distribution archive,
322+you need to be one of the distribution owners - not one of the archive
323+owners. Here, cjwatson cannot make a new package uploader, packageset
324+uploader or component uploader.
325+
326+ >>> response = cjwatson_webservice.named_post(
327+ ... ubuntu['main_archive_link'], 'newPackageUploader', {},
328+ ... person=name12['self_link'],
329+ ... source_package_name='mozilla-firefox')
330+ >>> print response
331+ HTTP/1.1 401 Unauthorized
332+ ...
333+ (<Archive at ...>, 'newPackageUploader', 'launchpad.Edit')
334+
335+ >>> response = cjwatson_webservice.named_post(
336+ ... ubuntu['main_archive_link'], 'newPackagesetUploader', {},
337+ ... person=name12['self_link'],
338+ ... packageset=packageset['self_link'])
339+ >>> print response
340+ HTTP/1.1 401 Unauthorized
341+ ...
342+ (<Archive at ...>, 'newPackagesetUploader', 'launchpad.Edit')
343+
344+ >>> response = cjwatson_webservice.named_post(
345+ ... ubuntu['main_archive_link'], 'newComponentUploader', {},
346+ ... person=name12['self_link'],
347+ ... component_name='restricted')
348+ >>> print response
349+ HTTP/1.1 401 Unauthorized
350+ ...
351+ (<Archive at ...>, 'newComponentUploader', 'launchpad.Edit')
352+
353+From here on we'll use ubuntu_owner, who does have permission as Ubuntu's
354+owner.
355
356 `newPackageUploader` is a factory function that adds a new permission
357 for a person to upload a package.
358
359 >>> name12 = webservice.get("/~name12").jsonBody()
360- >>> response = cjwatson_webservice.named_post(
361+ >>> response = ubuntu_owner_webservice.named_post(
362 ... ubuntu['main_archive_link'], 'newPackageUploader', {},
363 ... person=name12['self_link'],
364 ... source_package_name='mozilla-firefox')
365@@ -306,7 +362,7 @@
366
367 deletePackageUploader() removes that permission:
368
369- >>> print cjwatson_webservice.named_post(
370+ >>> print ubuntu_owner_webservice.named_post(
371 ... ubuntu['main_archive_link'], 'deletePackageUploader', {},
372 ... person=name12['self_link'],
373 ... source_package_name='mozilla-firefox')
374@@ -348,7 +404,7 @@
375 newComponentUploader adds a new permission for a person to upload to a
376 component.
377
378- >>> response = cjwatson_webservice.named_post(
379+ >>> response = ubuntu_owner_webservice.named_post(
380 ... ubuntu['main_archive_link'], 'newComponentUploader', {},
381 ... person=name12['self_link'],
382 ... component_name='restricted')
383@@ -382,7 +438,7 @@
384
385 deleteComponentUploader() removes that permission:
386
387- >>> print cjwatson_webservice.named_post(
388+ >>> print ubuntu_owner_webservice.named_post(
389 ... ubuntu['main_archive_link'], 'deleteComponentUploader', {},
390 ... person=name12['self_link'],
391 ... component_name='restricted')
392@@ -411,7 +467,7 @@
393 this distribution's primary archive. Did you mean to upload to a PPA?
394
395
396-Only the archive owners can add or remove component-uploaders.
397+For PPAs, only the archive owners can add or remove component-uploaders.
398
399 >>> no_priv = webservice.get("/~no-priv").jsonBody()
400
401@@ -479,7 +535,7 @@
402 newQueueAdmin adds a new permission for a person to administer distroseries
403 queues in a particular component.
404
405- >>> response = webservice.named_post(
406+ >>> response = ubuntu_owner_webservice.named_post(
407 ... ubuntu['main_archive_link'], 'newQueueAdmin', {},
408 ... person=name12['self_link'],
409 ... component_name='partner')
410@@ -487,7 +543,7 @@
411 HTTP/1.1 201 Created
412 ...
413
414- >>> new_permission = webservice.get(
415+ >>> new_permission = ubuntu_owner_webservice.get(
416 ... response.getHeader('Location')).jsonBody()
417 >>> print new_permission['self_link']
418 http://.../ubuntu/+archive/primary/+queue-admin/name12?type=component&item=partner
419@@ -501,7 +557,7 @@
420
421 deleteQueueAdmin removes that permission.
422
423- >>> print webservice.named_post(
424+ >>> print ubuntu_owner_webservice.named_post(
425 ... ubuntu['main_archive_link'], 'deleteQueueAdmin', {},
426 ... person=name12['self_link'],
427 ... component_name='partner')
428
429=== modified file 'lib/lp/soyuz/tests/test_archive.py'
430--- lib/lp/soyuz/tests/test_archive.py 2011-08-24 00:47:25 +0000
431+++ lib/lp/soyuz/tests/test_archive.py 2011-08-25 10:17:54 +0000
432@@ -480,6 +480,9 @@
433 def test_checkArchivePermission_distro_archive(self):
434 # Regular users can not upload to ubuntu
435 archive = self.factory.makeArchive(purpose=ArchivePurpose.PRIMARY)
436+ # The factory sets the archive owner the same as the distro owner,
437+ # change that here to ensure the security adapter checks are right.
438+ removeSecurityProxy(archive).owner = self.factory.makePerson()
439 main = getUtility(IComponentSet)["main"]
440 # A regular user doesn't have access
441 somebody = self.factory.makePerson()
442@@ -487,7 +490,7 @@
443 archive.checkArchivePermission(somebody, main))
444 # An ubuntu core developer does have access
445 coredev = self.factory.makePerson()
446- with person_logged_in(archive.owner):
447+ with person_logged_in(archive.distribution.owner):
448 archive.newComponentUploader(coredev, main.name)
449 self.assertEquals(True, archive.checkArchivePermission(coredev, main))
450
451@@ -679,8 +682,7 @@
452 packageset = self.factory.makePackageset(
453 distroseries=distroseries, packages=packages)
454 person = self.factory.makePerson()
455- techboard = getUtility(ILaunchpadCelebrities).ubuntu_techboard
456- with person_logged_in(techboard):
457+ with person_logged_in(archive.distribution.owner):
458 archive.newPackagesetUploader(person, packageset)
459 return person, packageset
460
461@@ -2137,7 +2139,7 @@
462 to_series, version) = self._setup_copy_data(
463 target_purpose=ArchivePurpose.PRIMARY)
464 person = self.factory.makePerson()
465- with person_logged_in(target_archive.owner):
466+ with person_logged_in(target_archive.distribution.owner):
467 target_archive.newComponentUploader(person, "universe")
468 target_archive.copyPackage(
469 source_name, version, source_archive, to_pocket.name,
470@@ -2244,7 +2246,7 @@
471 to_series, version) = self._setup_copy_data(
472 target_purpose=ArchivePurpose.PRIMARY)
473 person = self.factory.makePerson()
474- with person_logged_in(target_archive.owner):
475+ with person_logged_in(target_archive.distribution.owner):
476 target_archive.newComponentUploader(person, "universe")
477 target_archive.copyPackages(
478 [source_name], source_archive, to_pocket.name,