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
diff --git a/lib/lp/security.py b/lib/lp/security.py
index c8f518f..4eeacbc 100644
--- a/lib/lp/security.py
+++ b/lib/lp/security.py
@@ -232,7 +232,10 @@ from lp.snappy.interfaces.snappyseries import (
232 ISnappySeriesSet,232 ISnappySeriesSet,
233 )233 )
234from lp.snappy.interfaces.snapsubscription import ISnapSubscription234from lp.snappy.interfaces.snapsubscription import ISnapSubscription
235from lp.soyuz.interfaces.archive import IArchive235from lp.soyuz.interfaces.archive import (
236 IArchive,
237 IArchiveSet,
238 )
236from lp.soyuz.interfaces.archiveauthtoken import IArchiveAuthToken239from lp.soyuz.interfaces.archiveauthtoken import IArchiveAuthToken
237from lp.soyuz.interfaces.archivepermission import IArchivePermissionSet240from lp.soyuz.interfaces.archivepermission import IArchivePermissionSet
238from lp.soyuz.interfaces.archivesubscriber import (241from lp.soyuz.interfaces.archivesubscriber import (
@@ -2784,35 +2787,11 @@ class ViewArchive(AuthorizationBase):
2784 usedfor = IArchive2787 usedfor = IArchive
27852788
2786 def checkAuthenticated(self, user):2789 def checkAuthenticated(self, user):
2787 """Verify that the user can view the archive.2790 """Verify that the user can view the archive."""
27882791 archive_set = getUtility(IArchiveSet) # type: IArchiveSet
2789 Anyone can see a public and enabled archive.2792 return archive_set.checkViewPermission(
27902793 [self.obj], user.person
2791 Only Launchpad admins and uploaders can view private or disabled2794 )[self.obj]
2792 archives.
2793 """
2794 # No further checks are required if the archive is public and
2795 # enabled.
2796 if not self.obj.private and self.obj.enabled:
2797 return True
2798
2799 # Administrator are allowed to view private archives.
2800 if user.in_admin or user.in_commercial_admin:
2801 return True
2802
2803 # Registry experts are allowed to view public but disabled archives
2804 # (since they are allowed to disable archives).
2805 if (not self.obj.private and not self.obj.enabled and
2806 user.in_registry_experts):
2807 return True
2808
2809 # Owners can view the PPA.
2810 if user.inTeam(self.obj.owner):
2811 return True
2812
2813 filter = get_enabled_archive_filter(user.person)
2814 return not IStore(self.obj).find(
2815 Archive.id, And(Archive.id == self.obj.id, filter)).is_empty()
28162795
2817 def checkUnauthenticated(self):2796 def checkUnauthenticated(self):
2818 """Unauthenticated users can see the PPA if it's not private."""2797 """Unauthenticated users can see the PPA if it's not private."""
diff --git a/lib/lp/services/webapp/authorization.py b/lib/lp/services/webapp/authorization.py
index 558e3fe..018baa3 100644
--- a/lib/lp/services/webapp/authorization.py
+++ b/lib/lp/services/webapp/authorization.py
@@ -337,7 +337,9 @@ def iter_authorization(objecttoauthorize, permission, principal, cache,
337 yield result337 yield result
338338
339339
340def precache_permission_for_objects(participation, permission_name, objects):340def precache_permission_for_objects(
341 participation, permission_name, objects, result=True
342):
341 """Precaches the permission for the objects into the policy cache."""343 """Precaches the permission for the objects into the policy cache."""
342 if participation is None:344 if participation is None:
343 participation = getInteraction().participations[0]345 participation = getInteraction().participations[0]
@@ -347,7 +349,7 @@ def precache_permission_for_objects(participation, permission_name, objects):
347 for obj in objects:349 for obj in objects:
348 naked_obj = removeSecurityProxy(obj)350 naked_obj = removeSecurityProxy(obj)
349 obj_permission_cache = permission_cache.setdefault(naked_obj, {})351 obj_permission_cache = permission_cache.setdefault(naked_obj, {})
350 obj_permission_cache[permission_name] = True352 obj_permission_cache[permission_name] = result
351353
352354
353def check_permission(permission_name, context):355def check_permission(permission_name, context):
diff --git a/lib/lp/services/webapp/tests/test_authorization.py b/lib/lp/services/webapp/tests/test_authorization.py
index 5e31460..ebac5e9 100644
--- a/lib/lp/services/webapp/tests/test_authorization.py
+++ b/lib/lp/services/webapp/tests/test_authorization.py
@@ -521,13 +521,29 @@ class TestPrecachePermissionForObjects(TestCase):
521 # policy cache for the permission specified.521 # policy cache for the permission specified.
522 class Boring:522 class Boring:
523 """A boring, but weakref-able object."""523 """A boring, but weakref-able object."""
524 objects = [Boring(), Boring()]524 viewable_objects = [Boring(), Boring()]
525 non_viewable_objects = [Boring(), Boring()]
525 request = LaunchpadTestRequest()526 request = LaunchpadTestRequest()
526 login(ANONYMOUS, request)527 login(ANONYMOUS, request)
527 precache_permission_for_objects(request, 'launchpad.View', objects)528 precache_permission_for_objects(
529 request, 'launchpad.View', viewable_objects
530 )
531 precache_permission_for_objects(
532 request, 'launchpad.View', non_viewable_objects, result=False
533 )
528 # Confirm that the objects have the permission set.534 # Confirm that the objects have the permission set.
529 self.assertTrue(check_permission('launchpad.View', objects[0]))535 self.assertTrue(
530 self.assertTrue(check_permission('launchpad.View', objects[1]))536 check_permission('launchpad.View', viewable_objects[0])
537 )
538 self.assertTrue(
539 check_permission('launchpad.View', viewable_objects[1])
540 )
541 self.assertFalse(
542 check_permission('launchpad.View', non_viewable_objects[0])
543 )
544 self.assertFalse(
545 check_permission('launchpad.View', non_viewable_objects[1])
546 )
531547
532 def test_default_request(self):548 def test_default_request(self):
533 # If no request is provided, the current interaction is used.549 # If no request is provided, the current interaction is used.
diff --git a/lib/lp/soyuz/browser/archivesubscription.py b/lib/lp/soyuz/browser/archivesubscription.py
index 8cff69b..e472bde 100644
--- a/lib/lp/soyuz/browser/archivesubscription.py
+++ b/lib/lp/soyuz/browser/archivesubscription.py
@@ -336,6 +336,29 @@ class PersonArchiveSubscriptionsView(LaunchpadView):
336 for archive in archives:336 for archive in archives:
337 get_property_cache(archive)._known_subscribers = [self.context]337 get_property_cache(archive)._known_subscribers = [self.context]
338338
339 # Check if the user can view the archives and cache the permission
340 # check results
341 viewable_archives = []
342 non_viewable_archives = []
343 archive_set = getUtility(IArchiveSet) # type: IArchiveSet
344 for archive, has_view_permission in archive_set.checkViewPermission(
345 archives, self.user
346 ).items():
347 if has_view_permission:
348 viewable_archives.append(archive)
349 else:
350 non_viewable_archives.append(archive)
351 precache_permission_for_objects(
352 None,
353 'launchpad.View',
354 viewable_archives, result=True
355 )
356 precache_permission_for_objects(
357 None,
358 'launchpad.View',
359 non_viewable_archives, result=False
360 )
361
339 # Turn the result set into a list of dicts so it can be easily362 # Turn the result set into a list of dicts so it can be easily
340 # accessed in TAL. Note that we need to ensure that only one363 # accessed in TAL. Note that we need to ensure that only one
341 # PersonalArchiveSubscription is included for each archive,364 # PersonalArchiveSubscription is included for each archive,
diff --git a/lib/lp/soyuz/interfaces/archive.py b/lib/lp/soyuz/interfaces/archive.py
index 5ee645a..fb2ef57 100644
--- a/lib/lp/soyuz/interfaces/archive.py
+++ b/lib/lp/soyuz/interfaces/archive.py
@@ -54,6 +54,7 @@ __all__ = [
5454
55import http.client55import http.client
56import re56import re
57import typing
57from urllib.parse import urlparse58from urllib.parse import urlparse
5859
59from lazr.restful.declarations import (60from lazr.restful.declarations import (
@@ -2515,6 +2516,23 @@ class IArchiveSet(Interface):
2515 that are currently published in the given archives.2516 that are currently published in the given archives.
2516 """2517 """
25172518
2519 def checkViewPermission(
2520 archives: typing.List[IArchive], user: IPerson
2521 ) -> typing.Dict[IArchive, bool]:
2522 """
2523 Given a collection of archives, check if the user can view
2524 each of them.
2525
2526 Anyone can see a public and enabled archive.
2527
2528 Only Launchpad admins and uploaders can view private or disabled
2529 archives.
2530
2531 :param archives: a collection of `IArchive` objects
2532 :param user: a user (a `IPerson` object)
2533 :return: a mapping of `IArchive` -> `bool`, where the values represent
2534 the result of the permission check.
2535 """
25182536
2519default_name_by_purpose = {2537default_name_by_purpose = {
2520 ArchivePurpose.PRIMARY: 'primary',2538 ArchivePurpose.PRIMARY: 'primary',
diff --git a/lib/lp/soyuz/model/archive.py b/lib/lp/soyuz/model/archive.py
index 4d1ccb1..76fbadf 100644
--- a/lib/lp/soyuz/model/archive.py
+++ b/lib/lp/soyuz/model/archive.py
@@ -13,6 +13,7 @@ __all__ = [
1313
14from operator import attrgetter14from operator import attrgetter
15import re15import re
16import typing
1617
17from lazr.lifecycle.event import ObjectCreatedEvent18from lazr.lifecycle.event import ObjectCreatedEvent
18import six19import six
@@ -82,6 +83,7 @@ from lp.registry.interfaces.distroseries import IDistroSeriesSet
82from lp.registry.interfaces.distroseriesparent import IDistroSeriesParentSet83from lp.registry.interfaces.distroseriesparent import IDistroSeriesParentSet
83from lp.registry.interfaces.gpg import IGPGKeySet84from lp.registry.interfaces.gpg import IGPGKeySet
84from lp.registry.interfaces.person import (85from lp.registry.interfaces.person import (
86 IPerson,
85 IPersonSet,87 IPersonSet,
86 validate_person,88 validate_person,
87 )89 )
@@ -3049,6 +3051,52 @@ class ArchiveSet:
30493051
3050 return results.order_by(SourcePackagePublishingHistory.id)3052 return results.order_by(SourcePackagePublishingHistory.id)
30513053
3054 def checkViewPermission(
3055 self, archives: typing.List[IArchive], user: IPerson
3056 ) -> typing.Dict[IArchive, bool]:
3057 """See `IArchiveSet`."""
3058 allowed_ids = set()
3059 ids_to_check_in_database = []
3060 roles = IPersonRoles(user)
3061 for archive in archives:
3062 # No further checks are required if the archive is public and
3063 # enabled.
3064 if not archive.private and archive.enabled:
3065 allowed_ids.add(archive.id)
3066
3067 # Administrators are allowed to view private archives.
3068 elif roles.in_admin or roles.in_commercial_admin:
3069 allowed_ids.add(archive.id)
3070
3071 # Registry experts are allowed to view public but disabled archives
3072 # (since they are allowed to disable archives).
3073 elif (not archive.private and not archive.enabled and
3074 roles.in_registry_experts):
3075 allowed_ids.add(archive.id)
3076
3077 # Users that are direct owners (not through a team)
3078 # can view the PPA.
3079 # This is an optimization to avoid making a database query
3080 # when a user is the direct owner of the PPA.
3081 # Team ownership is accounted for in `get_enabled_archive_filter`
3082 # below
3083 elif user.id == removeSecurityProxy(archive).ownerID:
3084 allowed_ids.add(archive.id)
3085
3086 else:
3087 ids_to_check_in_database.append(archive.id)
3088
3089 if ids_to_check_in_database:
3090 store = IStore(Archive)
3091 allowed_ids.update(
3092 store.find(
3093 Archive.id,
3094 Archive.id.is_in(ids_to_check_in_database),
3095 get_enabled_archive_filter(user),
3096 )
3097 )
3098 return {archive: archive.id in allowed_ids for archive in archives}
3099
3052 def empty_list(self):3100 def empty_list(self):
3053 """See `IArchiveSet."""3101 """See `IArchiveSet."""
3054 return []3102 return []
diff --git a/lib/lp/soyuz/templates/person-archive-subscriptions.pt b/lib/lp/soyuz/templates/person-archive-subscriptions.pt
index 8711378..19d39ef 100644
--- a/lib/lp/soyuz/templates/person-archive-subscriptions.pt
+++ b/lib/lp/soyuz/templates/person-archive-subscriptions.pt
@@ -30,8 +30,12 @@
30 <tal:subscription_and_token30 <tal:subscription_and_token
31 define="subscription subscription_with_token/subscription;31 define="subscription subscription_with_token/subscription;
32 token subscription_with_token/token">32 token subscription_with_token/token">
33 <td class="ppa_display_name">33 <td class="ppa_display_name"
34 <tal:display_name content="subscription/archive/displayname">34 tal:define="archive_link subscription/archive/fmt:link">
35 <tal:link condition="archive_link" content="structure archive_link">
36 Private PPA for Celso
37 </tal:link>
38 <tal:display_name condition="not: archive_link" content="subscription/archive/displayname">
35 Private PPA for Celso39 Private PPA for Celso
36 </tal:display_name>40 </tal:display_name>
37 (<tal:reference content="subscription/archive/fmt:reference">41 (<tal:reference content="subscription/archive/fmt:reference">
diff --git a/lib/lp/soyuz/tests/test_archive.py b/lib/lp/soyuz/tests/test_archive.py
index fd3d254..ae21161 100644
--- a/lib/lp/soyuz/tests/test_archive.py
+++ b/lib/lp/soyuz/tests/test_archive.py
@@ -4050,6 +4050,144 @@ class TestArchiveSetGetByReference(TestCaseWithFactory):
4050 self.assertLookupFails('~enoent/twonoent/threenoent/fournoent')4050 self.assertLookupFails('~enoent/twonoent/threenoent/fournoent')
40514051
40524052
4053class TestArchiveSetCheckViewPermission(TestCaseWithFactory):
4054
4055 layer = DatabaseFunctionalLayer
4056
4057 def setUp(self):
4058 super().setUp()
4059 self.archive_set = getUtility(IArchiveSet)
4060 self.public_archive = self.factory.makeArchive(private=False)
4061 self.public_disabled_archive = self.factory.makeArchive(
4062 private=False, enabled=False
4063 )
4064 self.private_archive = self.factory.makeArchive(private=True)
4065
4066 def test_public_enabled_archives(self):
4067 somebody = self.factory.makePerson()
4068 with person_logged_in(somebody):
4069 results = self.archive_set.checkViewPermission(
4070 [
4071 self.public_archive,
4072 self.public_disabled_archive,
4073 self.private_archive
4074 ],
4075 somebody,
4076 )
4077 self.assertDictEqual(results, {
4078 self.public_archive: True,
4079 self.public_disabled_archive: False,
4080 self.private_archive: False,
4081 })
4082
4083 def test_admin_can_view(self):
4084 admin = self.factory.makeAdministrator()
4085 with person_logged_in(admin):
4086 results = self.archive_set.checkViewPermission(
4087 [
4088 self.public_archive,
4089 self.public_disabled_archive,
4090 self.private_archive
4091 ],
4092 admin,
4093 )
4094 self.assertDictEqual(results, {
4095 self.public_archive: True,
4096 self.public_disabled_archive: True,
4097 self.private_archive: True,
4098 })
4099 comm_admin = self.factory.makeCommercialAdmin()
4100 with person_logged_in(comm_admin):
4101 results = self.archive_set.checkViewPermission(
4102 [
4103 self.public_archive,
4104 self.public_disabled_archive,
4105 self.private_archive
4106 ],
4107 comm_admin,
4108 )
4109 self.assertDictEqual(results, {
4110 self.public_archive: True,
4111 self.public_disabled_archive: True,
4112 self.private_archive: True,
4113 })
4114
4115 def test_registry_experts(self):
4116 registry_expert = self.factory.makeRegistryExpert()
4117 with person_logged_in(registry_expert):
4118 results = self.archive_set.checkViewPermission(
4119 [
4120 self.public_archive,
4121 self.public_disabled_archive,
4122 self.private_archive
4123 ],
4124 registry_expert,
4125 )
4126 self.assertDictEqual(results, {
4127 self.public_archive: True,
4128 self.public_disabled_archive: True,
4129 self.private_archive: False,
4130 })
4131
4132 def test_owner(self):
4133 owner = self.factory.makePerson()
4134 enabled_archive = self.factory.makeArchive(
4135 owner=owner, private=False, enabled=True
4136 )
4137 disabled_archive = self.factory.makeArchive(
4138 owner=owner, private=False, enabled=False
4139 )
4140 with person_logged_in(owner):
4141 results = self.archive_set.checkViewPermission(
4142 [
4143 enabled_archive,
4144 disabled_archive,
4145 ],
4146 owner,
4147 )
4148 self.assertDictEqual(results, {
4149 enabled_archive: True,
4150 disabled_archive: True,
4151 })
4152
4153 def test_team_owner(self):
4154 team_member = self.factory.makePerson()
4155 team = self.factory.makeTeam(members=[team_member])
4156 enabled_archive = self.factory.makeArchive(
4157 owner=team, private=False, enabled=True
4158 )
4159 disabled_archive = self.factory.makeArchive(
4160 owner=team, private=False, enabled=False
4161 )
4162 with person_logged_in(team_member):
4163 results = self.archive_set.checkViewPermission(
4164 [
4165 enabled_archive,
4166 disabled_archive,
4167 ],
4168 team_member,
4169 )
4170 self.assertDictEqual(results, {
4171 enabled_archive: True,
4172 disabled_archive: True,
4173 })
4174
4175 def test_query_count(self):
4176 archives = [
4177 self.factory.makeArchive(private=False) for _ in range(10)
4178 ]
4179 somebody = self.factory.makePerson()
4180 with StormStatementRecorder() as recorder1:
4181 self.archive_set.checkViewPermission(
4182 archives[:5], somebody
4183 )
4184 with StormStatementRecorder() as recorder2:
4185 self.archive_set.checkViewPermission(
4186 archives, somebody
4187 )
4188 self.assertThat(recorder2, HasQueryCount.byEquality(recorder1))
4189
4190
4053class TestDisplayName(TestCaseWithFactory):4191class TestDisplayName(TestCaseWithFactory):
40544192
4055 layer = DatabaseFunctionalLayer4193 layer = DatabaseFunctionalLayer
diff --git a/lib/lp/soyuz/tests/test_archive_subscriptions.py b/lib/lp/soyuz/tests/test_archive_subscriptions.py
index 516fcd7..50c8a3b 100644
--- a/lib/lp/soyuz/tests/test_archive_subscriptions.py
+++ b/lib/lp/soyuz/tests/test_archive_subscriptions.py
@@ -182,7 +182,14 @@ class PersonArchiveSubscriptions(TestCaseWithFactory):
182 def test_query_count(self):182 def test_query_count(self):
183 subscriber = self.factory.makePerson()183 subscriber = self.factory.makePerson()
184 for x in range(10):184 for x in range(10):
185 archive = self.factory.makeArchive(private=True)185 archive_owner = self.factory.makePerson()
186 # some archives are owned by a user, others are owned by a team
187 archive = self.factory.makeArchive(
188 private=True,
189 owner=archive_owner if x < 5 else self.factory.makeTeam(
190 members=[archive_owner]
191 )
192 )
186 with person_logged_in(archive.owner):193 with person_logged_in(archive.owner):
187 if x >= 5:194 if x >= 5:
188 team = self.factory.makeTeam(members=[subscriber])195 team = self.factory.makeTeam(members=[subscriber])
@@ -196,7 +203,7 @@ class PersonArchiveSubscriptions(TestCaseWithFactory):
196 view = create_initialized_view(203 view = create_initialized_view(
197 subscriber, '+archivesubscriptions', principal=subscriber)204 subscriber, '+archivesubscriptions', principal=subscriber)
198 view.render()205 view.render()
199 self.assertThat(recorder, HasQueryCount(Equals(12)))206 self.assertThat(recorder, HasQueryCount(Equals(16)))
200207
201 def test_getArchiveSubscriptions(self):208 def test_getArchiveSubscriptions(self):
202 # Anyone with 'View' permission on a given person is able to209 # Anyone with 'View' permission on a given person is able to

Subscribers

People subscribed via source and target branches

to status/vote changes: