Merge ~andrey-fedoseev/launchpad:archive-subscriptions-links into launchpad:master

Proposed by Andrey Fedoseev
Status: Merged
Approved by: Andrey Fedoseev
Approved revision: c4fbaa3203b2edd29741580a7997c004eb5753c5
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~andrey-fedoseev/launchpad:archive-subscriptions-links
Merge into: launchpad:master
Diff against target: 459 lines (+275/-40)
9 files modified
lib/lp/security.py (+9/-30)
lib/lp/services/webapp/authorization.py (+4/-2)
lib/lp/services/webapp/tests/test_authorization.py (+20/-4)
lib/lp/soyuz/browser/archivesubscription.py (+23/-0)
lib/lp/soyuz/interfaces/archive.py (+18/-0)
lib/lp/soyuz/model/archive.py (+48/-0)
lib/lp/soyuz/templates/person-archive-subscriptions.pt (+6/-2)
lib/lp/soyuz/tests/test_archive.py (+138/-0)
lib/lp/soyuz/tests/test_archive_subscriptions.py (+9/-2)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+423346@code.launchpad.net

Commit message

Archive subscriptions: add links to PPAs

LP: #860268

Add `checkViewPermission` to `ArchiveSet`

Update `ViewArchive` to use `ArchiveSet.checkViewPermission`

Add `result` argument to `precache_permission_for_objects`

Description of the change

I added links to PPAs on the Archive Subscriptions page

For that, I need to check `launchpad.View` permission for each of the archives on that page.

Check permission for each individual archive may be expensive since it needs to run a few database queries per-archive. As a workaround, I added a method the `ArchiveSet.checkViewPermission` which runs the check for multiple archives at once.

This is ready for review, but I am going to add some tests for `ArchiveSet.checkViewPermission` before I merge it.

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) wrote :

Thanks for tackling this! This did indeed turn out to be a bit less trivial than I was expecting, but this is a very creditable first attempt and it should be great experience with Storm and with how authorization works in Launchpad. Let me know if you have any questions about these comments, since some of them are quite complex.

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

Colin,

I've made the requested changes, except for changing `archive.owner.id` to `archive.ownerID` (I have issues with that, see my other comment).

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

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/security.py b/lib/lp/security.py
2index c8f518f..4eeacbc 100644
3--- a/lib/lp/security.py
4+++ b/lib/lp/security.py
5@@ -232,7 +232,10 @@ from lp.snappy.interfaces.snappyseries import (
6 ISnappySeriesSet,
7 )
8 from lp.snappy.interfaces.snapsubscription import ISnapSubscription
9-from lp.soyuz.interfaces.archive import IArchive
10+from lp.soyuz.interfaces.archive import (
11+ IArchive,
12+ IArchiveSet,
13+ )
14 from lp.soyuz.interfaces.archiveauthtoken import IArchiveAuthToken
15 from lp.soyuz.interfaces.archivepermission import IArchivePermissionSet
16 from lp.soyuz.interfaces.archivesubscriber import (
17@@ -2784,35 +2787,11 @@ class ViewArchive(AuthorizationBase):
18 usedfor = IArchive
19
20 def checkAuthenticated(self, user):
21- """Verify that the user can view the archive.
22-
23- Anyone can see a public and enabled archive.
24-
25- Only Launchpad admins and uploaders can view private or disabled
26- archives.
27- """
28- # No further checks are required if the archive is public and
29- # enabled.
30- if not self.obj.private and self.obj.enabled:
31- return True
32-
33- # Administrator are allowed to view private archives.
34- if user.in_admin or user.in_commercial_admin:
35- return True
36-
37- # Registry experts are allowed to view public but disabled archives
38- # (since they are allowed to disable archives).
39- if (not self.obj.private and not self.obj.enabled and
40- user.in_registry_experts):
41- return True
42-
43- # Owners can view the PPA.
44- if user.inTeam(self.obj.owner):
45- return True
46-
47- filter = get_enabled_archive_filter(user.person)
48- return not IStore(self.obj).find(
49- Archive.id, And(Archive.id == self.obj.id, filter)).is_empty()
50+ """Verify that the user can view the archive."""
51+ archive_set = getUtility(IArchiveSet) # type: IArchiveSet
52+ return archive_set.checkViewPermission(
53+ [self.obj], user.person
54+ )[self.obj]
55
56 def checkUnauthenticated(self):
57 """Unauthenticated users can see the PPA if it's not private."""
58diff --git a/lib/lp/services/webapp/authorization.py b/lib/lp/services/webapp/authorization.py
59index 558e3fe..018baa3 100644
60--- a/lib/lp/services/webapp/authorization.py
61+++ b/lib/lp/services/webapp/authorization.py
62@@ -337,7 +337,9 @@ def iter_authorization(objecttoauthorize, permission, principal, cache,
63 yield result
64
65
66-def precache_permission_for_objects(participation, permission_name, objects):
67+def precache_permission_for_objects(
68+ participation, permission_name, objects, result=True
69+):
70 """Precaches the permission for the objects into the policy cache."""
71 if participation is None:
72 participation = getInteraction().participations[0]
73@@ -347,7 +349,7 @@ def precache_permission_for_objects(participation, permission_name, objects):
74 for obj in objects:
75 naked_obj = removeSecurityProxy(obj)
76 obj_permission_cache = permission_cache.setdefault(naked_obj, {})
77- obj_permission_cache[permission_name] = True
78+ obj_permission_cache[permission_name] = result
79
80
81 def check_permission(permission_name, context):
82diff --git a/lib/lp/services/webapp/tests/test_authorization.py b/lib/lp/services/webapp/tests/test_authorization.py
83index 5e31460..ebac5e9 100644
84--- a/lib/lp/services/webapp/tests/test_authorization.py
85+++ b/lib/lp/services/webapp/tests/test_authorization.py
86@@ -521,13 +521,29 @@ class TestPrecachePermissionForObjects(TestCase):
87 # policy cache for the permission specified.
88 class Boring:
89 """A boring, but weakref-able object."""
90- objects = [Boring(), Boring()]
91+ viewable_objects = [Boring(), Boring()]
92+ non_viewable_objects = [Boring(), Boring()]
93 request = LaunchpadTestRequest()
94 login(ANONYMOUS, request)
95- precache_permission_for_objects(request, 'launchpad.View', objects)
96+ precache_permission_for_objects(
97+ request, 'launchpad.View', viewable_objects
98+ )
99+ precache_permission_for_objects(
100+ request, 'launchpad.View', non_viewable_objects, result=False
101+ )
102 # Confirm that the objects have the permission set.
103- self.assertTrue(check_permission('launchpad.View', objects[0]))
104- self.assertTrue(check_permission('launchpad.View', objects[1]))
105+ self.assertTrue(
106+ check_permission('launchpad.View', viewable_objects[0])
107+ )
108+ self.assertTrue(
109+ check_permission('launchpad.View', viewable_objects[1])
110+ )
111+ self.assertFalse(
112+ check_permission('launchpad.View', non_viewable_objects[0])
113+ )
114+ self.assertFalse(
115+ check_permission('launchpad.View', non_viewable_objects[1])
116+ )
117
118 def test_default_request(self):
119 # If no request is provided, the current interaction is used.
120diff --git a/lib/lp/soyuz/browser/archivesubscription.py b/lib/lp/soyuz/browser/archivesubscription.py
121index 8cff69b..e472bde 100644
122--- a/lib/lp/soyuz/browser/archivesubscription.py
123+++ b/lib/lp/soyuz/browser/archivesubscription.py
124@@ -336,6 +336,29 @@ class PersonArchiveSubscriptionsView(LaunchpadView):
125 for archive in archives:
126 get_property_cache(archive)._known_subscribers = [self.context]
127
128+ # Check if the user can view the archives and cache the permission
129+ # check results
130+ viewable_archives = []
131+ non_viewable_archives = []
132+ archive_set = getUtility(IArchiveSet) # type: IArchiveSet
133+ for archive, has_view_permission in archive_set.checkViewPermission(
134+ archives, self.user
135+ ).items():
136+ if has_view_permission:
137+ viewable_archives.append(archive)
138+ else:
139+ non_viewable_archives.append(archive)
140+ precache_permission_for_objects(
141+ None,
142+ 'launchpad.View',
143+ viewable_archives, result=True
144+ )
145+ precache_permission_for_objects(
146+ None,
147+ 'launchpad.View',
148+ non_viewable_archives, result=False
149+ )
150+
151 # Turn the result set into a list of dicts so it can be easily
152 # accessed in TAL. Note that we need to ensure that only one
153 # PersonalArchiveSubscription is included for each archive,
154diff --git a/lib/lp/soyuz/interfaces/archive.py b/lib/lp/soyuz/interfaces/archive.py
155index 5ee645a..fb2ef57 100644
156--- a/lib/lp/soyuz/interfaces/archive.py
157+++ b/lib/lp/soyuz/interfaces/archive.py
158@@ -54,6 +54,7 @@ __all__ = [
159
160 import http.client
161 import re
162+import typing
163 from urllib.parse import urlparse
164
165 from lazr.restful.declarations import (
166@@ -2515,6 +2516,23 @@ class IArchiveSet(Interface):
167 that are currently published in the given archives.
168 """
169
170+ def checkViewPermission(
171+ archives: typing.List[IArchive], user: IPerson
172+ ) -> typing.Dict[IArchive, bool]:
173+ """
174+ Given a collection of archives, check if the user can view
175+ each of them.
176+
177+ Anyone can see a public and enabled archive.
178+
179+ Only Launchpad admins and uploaders can view private or disabled
180+ archives.
181+
182+ :param archives: a collection of `IArchive` objects
183+ :param user: a user (a `IPerson` object)
184+ :return: a mapping of `IArchive` -> `bool`, where the values represent
185+ the result of the permission check.
186+ """
187
188 default_name_by_purpose = {
189 ArchivePurpose.PRIMARY: 'primary',
190diff --git a/lib/lp/soyuz/model/archive.py b/lib/lp/soyuz/model/archive.py
191index 4d1ccb1..76fbadf 100644
192--- a/lib/lp/soyuz/model/archive.py
193+++ b/lib/lp/soyuz/model/archive.py
194@@ -13,6 +13,7 @@ __all__ = [
195
196 from operator import attrgetter
197 import re
198+import typing
199
200 from lazr.lifecycle.event import ObjectCreatedEvent
201 import six
202@@ -82,6 +83,7 @@ from lp.registry.interfaces.distroseries import IDistroSeriesSet
203 from lp.registry.interfaces.distroseriesparent import IDistroSeriesParentSet
204 from lp.registry.interfaces.gpg import IGPGKeySet
205 from lp.registry.interfaces.person import (
206+ IPerson,
207 IPersonSet,
208 validate_person,
209 )
210@@ -3049,6 +3051,52 @@ class ArchiveSet:
211
212 return results.order_by(SourcePackagePublishingHistory.id)
213
214+ def checkViewPermission(
215+ self, archives: typing.List[IArchive], user: IPerson
216+ ) -> typing.Dict[IArchive, bool]:
217+ """See `IArchiveSet`."""
218+ allowed_ids = set()
219+ ids_to_check_in_database = []
220+ roles = IPersonRoles(user)
221+ for archive in archives:
222+ # No further checks are required if the archive is public and
223+ # enabled.
224+ if not archive.private and archive.enabled:
225+ allowed_ids.add(archive.id)
226+
227+ # Administrators are allowed to view private archives.
228+ elif roles.in_admin or roles.in_commercial_admin:
229+ allowed_ids.add(archive.id)
230+
231+ # Registry experts are allowed to view public but disabled archives
232+ # (since they are allowed to disable archives).
233+ elif (not archive.private and not archive.enabled and
234+ roles.in_registry_experts):
235+ allowed_ids.add(archive.id)
236+
237+ # Users that are direct owners (not through a team)
238+ # can view the PPA.
239+ # This is an optimization to avoid making a database query
240+ # when a user is the direct owner of the PPA.
241+ # Team ownership is accounted for in `get_enabled_archive_filter`
242+ # below
243+ elif user.id == removeSecurityProxy(archive).ownerID:
244+ allowed_ids.add(archive.id)
245+
246+ else:
247+ ids_to_check_in_database.append(archive.id)
248+
249+ if ids_to_check_in_database:
250+ store = IStore(Archive)
251+ allowed_ids.update(
252+ store.find(
253+ Archive.id,
254+ Archive.id.is_in(ids_to_check_in_database),
255+ get_enabled_archive_filter(user),
256+ )
257+ )
258+ return {archive: archive.id in allowed_ids for archive in archives}
259+
260 def empty_list(self):
261 """See `IArchiveSet."""
262 return []
263diff --git a/lib/lp/soyuz/templates/person-archive-subscriptions.pt b/lib/lp/soyuz/templates/person-archive-subscriptions.pt
264index 8711378..19d39ef 100644
265--- a/lib/lp/soyuz/templates/person-archive-subscriptions.pt
266+++ b/lib/lp/soyuz/templates/person-archive-subscriptions.pt
267@@ -30,8 +30,12 @@
268 <tal:subscription_and_token
269 define="subscription subscription_with_token/subscription;
270 token subscription_with_token/token">
271- <td class="ppa_display_name">
272- <tal:display_name content="subscription/archive/displayname">
273+ <td class="ppa_display_name"
274+ tal:define="archive_link subscription/archive/fmt:link">
275+ <tal:link condition="archive_link" content="structure archive_link">
276+ Private PPA for Celso
277+ </tal:link>
278+ <tal:display_name condition="not: archive_link" content="subscription/archive/displayname">
279 Private PPA for Celso
280 </tal:display_name>
281 (<tal:reference content="subscription/archive/fmt:reference">
282diff --git a/lib/lp/soyuz/tests/test_archive.py b/lib/lp/soyuz/tests/test_archive.py
283index fd3d254..ae21161 100644
284--- a/lib/lp/soyuz/tests/test_archive.py
285+++ b/lib/lp/soyuz/tests/test_archive.py
286@@ -4050,6 +4050,144 @@ class TestArchiveSetGetByReference(TestCaseWithFactory):
287 self.assertLookupFails('~enoent/twonoent/threenoent/fournoent')
288
289
290+class TestArchiveSetCheckViewPermission(TestCaseWithFactory):
291+
292+ layer = DatabaseFunctionalLayer
293+
294+ def setUp(self):
295+ super().setUp()
296+ self.archive_set = getUtility(IArchiveSet)
297+ self.public_archive = self.factory.makeArchive(private=False)
298+ self.public_disabled_archive = self.factory.makeArchive(
299+ private=False, enabled=False
300+ )
301+ self.private_archive = self.factory.makeArchive(private=True)
302+
303+ def test_public_enabled_archives(self):
304+ somebody = self.factory.makePerson()
305+ with person_logged_in(somebody):
306+ results = self.archive_set.checkViewPermission(
307+ [
308+ self.public_archive,
309+ self.public_disabled_archive,
310+ self.private_archive
311+ ],
312+ somebody,
313+ )
314+ self.assertDictEqual(results, {
315+ self.public_archive: True,
316+ self.public_disabled_archive: False,
317+ self.private_archive: False,
318+ })
319+
320+ def test_admin_can_view(self):
321+ admin = self.factory.makeAdministrator()
322+ with person_logged_in(admin):
323+ results = self.archive_set.checkViewPermission(
324+ [
325+ self.public_archive,
326+ self.public_disabled_archive,
327+ self.private_archive
328+ ],
329+ admin,
330+ )
331+ self.assertDictEqual(results, {
332+ self.public_archive: True,
333+ self.public_disabled_archive: True,
334+ self.private_archive: True,
335+ })
336+ comm_admin = self.factory.makeCommercialAdmin()
337+ with person_logged_in(comm_admin):
338+ results = self.archive_set.checkViewPermission(
339+ [
340+ self.public_archive,
341+ self.public_disabled_archive,
342+ self.private_archive
343+ ],
344+ comm_admin,
345+ )
346+ self.assertDictEqual(results, {
347+ self.public_archive: True,
348+ self.public_disabled_archive: True,
349+ self.private_archive: True,
350+ })
351+
352+ def test_registry_experts(self):
353+ registry_expert = self.factory.makeRegistryExpert()
354+ with person_logged_in(registry_expert):
355+ results = self.archive_set.checkViewPermission(
356+ [
357+ self.public_archive,
358+ self.public_disabled_archive,
359+ self.private_archive
360+ ],
361+ registry_expert,
362+ )
363+ self.assertDictEqual(results, {
364+ self.public_archive: True,
365+ self.public_disabled_archive: True,
366+ self.private_archive: False,
367+ })
368+
369+ def test_owner(self):
370+ owner = self.factory.makePerson()
371+ enabled_archive = self.factory.makeArchive(
372+ owner=owner, private=False, enabled=True
373+ )
374+ disabled_archive = self.factory.makeArchive(
375+ owner=owner, private=False, enabled=False
376+ )
377+ with person_logged_in(owner):
378+ results = self.archive_set.checkViewPermission(
379+ [
380+ enabled_archive,
381+ disabled_archive,
382+ ],
383+ owner,
384+ )
385+ self.assertDictEqual(results, {
386+ enabled_archive: True,
387+ disabled_archive: True,
388+ })
389+
390+ def test_team_owner(self):
391+ team_member = self.factory.makePerson()
392+ team = self.factory.makeTeam(members=[team_member])
393+ enabled_archive = self.factory.makeArchive(
394+ owner=team, private=False, enabled=True
395+ )
396+ disabled_archive = self.factory.makeArchive(
397+ owner=team, private=False, enabled=False
398+ )
399+ with person_logged_in(team_member):
400+ results = self.archive_set.checkViewPermission(
401+ [
402+ enabled_archive,
403+ disabled_archive,
404+ ],
405+ team_member,
406+ )
407+ self.assertDictEqual(results, {
408+ enabled_archive: True,
409+ disabled_archive: True,
410+ })
411+
412+ def test_query_count(self):
413+ archives = [
414+ self.factory.makeArchive(private=False) for _ in range(10)
415+ ]
416+ somebody = self.factory.makePerson()
417+ with StormStatementRecorder() as recorder1:
418+ self.archive_set.checkViewPermission(
419+ archives[:5], somebody
420+ )
421+ with StormStatementRecorder() as recorder2:
422+ self.archive_set.checkViewPermission(
423+ archives, somebody
424+ )
425+ self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
426+
427+
428 class TestDisplayName(TestCaseWithFactory):
429
430 layer = DatabaseFunctionalLayer
431diff --git a/lib/lp/soyuz/tests/test_archive_subscriptions.py b/lib/lp/soyuz/tests/test_archive_subscriptions.py
432index 516fcd7..50c8a3b 100644
433--- a/lib/lp/soyuz/tests/test_archive_subscriptions.py
434+++ b/lib/lp/soyuz/tests/test_archive_subscriptions.py
435@@ -182,7 +182,14 @@ class PersonArchiveSubscriptions(TestCaseWithFactory):
436 def test_query_count(self):
437 subscriber = self.factory.makePerson()
438 for x in range(10):
439- archive = self.factory.makeArchive(private=True)
440+ archive_owner = self.factory.makePerson()
441+ # some archives are owned by a user, others are owned by a team
442+ archive = self.factory.makeArchive(
443+ private=True,
444+ owner=archive_owner if x < 5 else self.factory.makeTeam(
445+ members=[archive_owner]
446+ )
447+ )
448 with person_logged_in(archive.owner):
449 if x >= 5:
450 team = self.factory.makeTeam(members=[subscriber])
451@@ -196,7 +203,7 @@ class PersonArchiveSubscriptions(TestCaseWithFactory):
452 view = create_initialized_view(
453 subscriber, '+archivesubscriptions', principal=subscriber)
454 view.render()
455- self.assertThat(recorder, HasQueryCount(Equals(12)))
456+ self.assertThat(recorder, HasQueryCount(Equals(16)))
457
458 def test_getArchiveSubscriptions(self):
459 # Anyone with 'View' permission on a given person is able to

Subscribers

People subscribed via source and target branches

to status/vote changes: