Merge lp:~sinzui/launchpad/private-traversal-4 into lp:launchpad

Proposed by Curtis Hovey
Status: Merged
Approved by: Curtis Hovey
Approved revision: no longer in the source branch.
Merged at revision: 14513
Proposed branch: lp:~sinzui/launchpad/private-traversal-4
Merge into: lp:launchpad
Diff against target: 978 lines (+419/-174)
16 files modified
lib/canonical/launchpad/interfaces/_schema_circular_imports.py (+6/-5)
lib/canonical/launchpad/security.py (+38/-27)
lib/lp/app/browser/launchpad.py (+2/-2)
lib/lp/app/browser/tales.py (+8/-0)
lib/lp/app/tests/test_tales.py (+16/-9)
lib/lp/bugs/browser/tests/test_bugsubscription_views.py (+1/-1)
lib/lp/code/browser/branch.py (+9/-1)
lib/lp/code/browser/branchsubscription.py (+14/-2)
lib/lp/code/browser/tests/test_branch.py (+100/-0)
lib/lp/code/browser/tests/test_branchlisting.py (+4/-3)
lib/lp/code/stories/branches/xx-subscribing-branches.txt (+14/-20)
lib/lp/registry/configure.zcml (+6/-4)
lib/lp/registry/doc/private-team-visibility.txt (+61/-0)
lib/lp/registry/interfaces/person.py (+99/-99)
lib/lp/registry/interfaces/role.py (+2/-1)
lib/lp/soyuz/tests/test_archive_subscriptions.py (+39/-0)
To merge this branch: bzr merge lp:~sinzui/launchpad/private-traversal-4
Reviewer Review Type Date Requested Status
Ian Booth (community) code Approve
Steve Kowalik (community) code Approve
Review via email: mp+85561@code.launchpad.net

Commit message

[r=stevenk,wallyworld][bug=605130,898669,903737] Allows subscribers to traverse to archives owned by private teams.

Description of the change

Allows subscribers to traverse to archives owned by private teams.

    Launchpad bug: https://bugs.launchpad.net/bugs/903737
    Pre-implementation: flacoste

The branch that fixed the security checker rules for private teams with
subscribable artefacts was rolled back because QA found traversal errors.
The test suite should not have passed. This issue was reported 12 months
ago, but the fix that was landed did not include a test for this case.

Module lp.registry.browser.person, line 447, in traverse_archive
return traverse_named_ppa(self.context.name, ppa_name)
Module lp.soyuz.browser.archive, line 196, in traverse_named_ppa
archive = person.getPPAByName(ppa_name)
Unauthorized: (<Person at ... canonical-isd (Canonical ISD)>,
               'getPPAByName', 'launchpad.View')<br />

--------------------------------------------------------------------

RULES

    * Write a test that reproduces the error seen on qastaging:
    * Update the interfaces or permissions as needed to make the
      test pass.
    * Consider previewing the branch on a server with production
      data to ensure subscribers can access private team archives.

QA

    * As an Lp engineer, visit
      https://qastaging.launchpad.net/~canonical-isd/+archive/enterprise-apps
    * Verify the page renders.
    * Verify you can expand the packages to learn the details.

LINT

    lib/canonical/launchpad/interfaces/_schema_circular_imports.py
    lib/lp/registry/interfaces/person.py
    lib/lp/soyuz/tests/test_archive_subscriptions.py

TEST

    ./bin/test -vvc lp.soyuz.tests.test_archive_subscriptions

IMPLEMENTATION

I tried the test_traverse helper, but discovered it fails because it does
not work with LaunchpadTestRequest, (the request object did not support
.features which is required Lp's base template).
I Wrote a browser test that uses traversal and supports proper view rendering.
I then moved getPPAByName() to IPersonLimitedView.
    lib/canonical/launchpad/interfaces/_schema_circular_imports.py
    lib/lp/registry/interfaces/person.py
    lib/lp/soyuz/tests/test_archive_subscriptions.py

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

Hi Steve.

Can we put this branch on dogfood or staging so that we can see how this behaves with production data?

Revision history for this message
Steve Kowalik (stevenk) wrote :

Broadly, this looks good to me. The only comment I have is, have you thought about using with person_logged_in()?

review: Approve (code)
Revision history for this message
Curtis Hovey (sinzui) wrote :

I can certainly switch to person_logged_in()

Revision history for this message
Ian Booth (wallyworld) wrote :

Looks good. Thanks for fixing.

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/interfaces/_schema_circular_imports.py'
2--- lib/canonical/launchpad/interfaces/_schema_circular_imports.py 2011-12-13 13:53:12 +0000
3+++ lib/canonical/launchpad/interfaces/_schema_circular_imports.py 2011-12-14 03:28:31 +0000
4@@ -135,7 +135,8 @@
5 )
6 from lp.registry.interfaces.person import (
7 IPerson,
8- IPersonPublic,
9+ IPersonLimitedView,
10+ IPersonViewRestricted,
11 ITeam,
12 )
13 from lp.registry.interfaces.pillar import (
14@@ -310,10 +311,10 @@
15
16 IPreviewDiff['branch_merge_proposal'].schema = IBranchMergeProposal
17
18-patch_reference_property(IPersonPublic, 'archive', IArchive)
19-patch_collection_property(IPersonPublic, 'ppas', IArchive)
20-patch_entry_return_type(IPersonPublic, 'getPPAByName', IArchive)
21-patch_entry_return_type(IPersonPublic, 'createPPA', IArchive)
22+patch_reference_property(IPersonViewRestricted, 'archive', IArchive)
23+patch_collection_property(IPersonViewRestricted, 'ppas', IArchive)
24+patch_entry_return_type(IPersonLimitedView, 'getPPAByName', IArchive)
25+patch_entry_return_type(IPersonViewRestricted, 'createPPA', IArchive)
26
27 IHasBuildRecords['getBuildRecords'].queryTaggedValue(
28 LAZR_WEBSERVICE_EXPORTED)[
29
30=== modified file 'lib/canonical/launchpad/security.py'
31--- lib/canonical/launchpad/security.py 2011-12-13 13:53:12 +0000
32+++ lib/canonical/launchpad/security.py 2011-12-14 03:28:31 +0000
33@@ -63,6 +63,7 @@
34 IBranch,
35 user_has_special_branch_access,
36 )
37+from lp.code.interfaces.branchcollection import IBranchCollection
38 from lp.code.interfaces.branchmergeproposal import IBranchMergeProposal
39 from lp.code.interfaces.branchmergequeue import IBranchMergeQueue
40 from lp.code.interfaces.codeimport import ICodeImport
41@@ -800,6 +801,35 @@
42 if (invitee.is_team and
43 invitee in user.person.getAdministratedTeams()):
44 return True
45+ return False
46+
47+
48+class PublicOrPrivateTeamsExistence(AuthorizationBase):
49+ """Restrict knowing about private teams' existence.
50+
51+ Knowing the existence of a private team allow traversing to its URL and
52+ displaying basic information like name, displayname.
53+ """
54+ permission = 'launchpad.LimitedView'
55+ usedfor = IPersonLimitedView
56+
57+ def checkUnauthenticated(self):
58+ """Unauthenticated users can only view public teams."""
59+ if self.obj.visibility == PersonVisibility.PUBLIC:
60+ return True
61+ return False
62+
63+ def checkAuthenticated(self, user):
64+ """By default, we simply perform a View permission check.
65+
66+ We also grant limited viewability to users who can see PPAs and
67+ branches owned by the team. In other scenarios, the context in which
68+ the permission is required is responsible for pre-caching the
69+ launchpad.LimitedView permission on each team which requires it.
70+ """
71+ if self.forwardCheckAuthenticated(
72+ user, self.obj, 'launchpad.View'):
73+ return True
74
75 if (self.obj.is_team
76 and self.obj.visibility == PersonVisibility.PRIVATE):
77@@ -813,33 +843,14 @@
78 ppa.id for ppa in self.obj.ppas if ppa.private)
79 if len(subscriber_archive_ids.intersection(team_ppa_ids)) > 0:
80 return True
81- return False
82-
83-
84-class PublicOrPrivateTeamsExistence(AuthorizationBase):
85- """Restrict knowing about private teams' existence.
86-
87- Knowing the existence of a private team allow traversing to its URL and
88- displaying basic information like name, displayname.
89- """
90- permission = 'launchpad.LimitedView'
91- usedfor = IPersonLimitedView
92-
93- def checkUnauthenticated(self):
94- """Unauthenticated users can only view public teams."""
95- if self.obj.visibility == PersonVisibility.PUBLIC:
96- return True
97- return False
98-
99- def checkAuthenticated(self, user):
100- """By default, we simply perform a View permission check.
101-
102- The context in which the permission is required is
103- responsible for pre-caching the launchpad.LimitedView permission on
104- each team which requires it.
105- """
106- return self.forwardCheckAuthenticated(
107- user, self.obj, 'launchpad.View')
108+
109+ # Grant visibility to people with subscriptions to branches owned
110+ # by the private team.
111+ owned_branches = getUtility(IBranchCollection).ownedBy(self.obj)
112+ if owned_branches.visibleByUser(user.person).count() > 0:
113+ return True
114+
115+ return False
116
117
118 class EditPollByTeamOwnerOrTeamAdminsOrAdmins(
119
120=== modified file 'lib/lp/app/browser/launchpad.py'
121--- lib/lp/app/browser/launchpad.py 2011-12-13 13:53:12 +0000
122+++ lib/lp/app/browser/launchpad.py 2011-12-14 03:28:31 +0000
123@@ -724,8 +724,8 @@
124 # Check to see if this is a team, and if so, whether the
125 # logged in user is allowed to view the team, by virtue of
126 # team membership or Launchpad administration.
127- if (person.is_team
128- and not check_permission('launchpad.View', person)):
129+ if (person.is_team and
130+ not check_permission('launchpad.LimitedView', person)):
131 raise NotFound(self.context, name)
132 # Only admins are permitted to see suspended users.
133 if person.account_status == AccountStatus.SUSPENDED:
134
135=== modified file 'lib/lp/app/browser/tales.py'
136--- lib/lp/app/browser/tales.py 2011-12-13 19:38:29 +0000
137+++ lib/lp/app/browser/tales.py 2011-12-14 03:28:31 +0000
138@@ -1266,6 +1266,14 @@
139 self.hidden)
140 return super(TeamFormatterAPI, self).link(view_name, rootsite)
141
142+ def icon(self, view_name):
143+ team = self._context
144+ if not check_permission('launchpad.LimitedView', team):
145+ css_class = ObjectImageDisplayAPI(team).sprite_css()
146+ return '<span class="' + css_class + '"></span>'
147+ else:
148+ return super(TeamFormatterAPI, self).icon(view_name)
149+
150 def displayname(self, view_name, rootsite=None):
151 """See `PersonFormatterAPI`."""
152 person = self._context
153
154=== modified file 'lib/lp/app/tests/test_tales.py'
155--- lib/lp/app/tests/test_tales.py 2011-12-13 13:53:12 +0000
156+++ lib/lp/app/tests/test_tales.py 2011-12-14 03:28:31 +0000
157@@ -149,18 +149,20 @@
158 self.assertEqual(expected, result)
159
160
161-class TestTalesFormatterAPI(TestCaseWithFactory):
162- """ Test permissions required to access TalesFormatterAPI methods.
163+class TestTeamFormatterAPI(TestCaseWithFactory):
164+ """ Test permissions required to access TeamFormatterAPI methods.
165
166 A user must have launchpad.LimitedView permission to use
167- TestTalesFormatterAPI with private teams.
168+ TeamFormatterAPI with private teams.
169 """
170- layer = DatabaseFunctionalLayer
171+ layer = LaunchpadFunctionalLayer
172
173 def setUp(self):
174- super(TestTalesFormatterAPI, self).setUp()
175+ super(TestTeamFormatterAPI, self).setUp()
176+ icon = self.factory.makeLibraryFileAlias(
177+ filename='smurf.png', content_type='image/png')
178 self.team = self.factory.makeTeam(
179- name='team', displayname='a team',
180+ name='team', displayname='a team', icon=icon,
181 visibility=PersonVisibility.PRIVATE)
182
183 def _make_formatter(self, cache_permission=False):
184@@ -175,10 +177,11 @@
185 request, 'launchpad.LimitedView', [self.team])
186 return formatter, request, any_person
187
188- def _tales_value(self, attr, request):
189+ def _tales_value(self, attr, request, path='fmt'):
190 # Evaluate the given formatted attribute value on team.
191- return test_tales(
192- "team/fmt:%s" % attr, team=self.team, request=request)
193+ result = test_tales(
194+ "team/%s:%s" % (path, attr), team=self.team, request=request)
195+ return result
196
197 def _test_can_view_attribute_no_login(self, attr, hidden=None):
198 # Test attribute access with no login.
199@@ -228,6 +231,10 @@
200 def test_can_view_url(self):
201 self._test_can_view_attribute('url')
202
203+ def test_can_view_icon(self):
204+ self._test_can_view_attribute(
205+ 'icon', '<span class="sprite team"></span>')
206+
207
208 class TestObjectFormatterAPI(TestCaseWithFactory):
209 """Tests for ObjectFormatterAPI"""
210
211=== modified file 'lib/lp/bugs/browser/tests/test_bugsubscription_views.py'
212--- lib/lp/bugs/browser/tests/test_bugsubscription_views.py 2011-12-13 13:53:12 +0000
213+++ lib/lp/bugs/browser/tests/test_bugsubscription_views.py 2011-12-14 03:28:31 +0000
214@@ -629,7 +629,7 @@
215 direct_subscriber = self.factory.makeTeam(
216 name='team', displayname='Team Name', owner=teamowner,
217 visibility=PersonVisibility.PRIVATE)
218- with person_logged_in(direct_subscriber.teamowner):
219+ with person_logged_in(teamowner):
220 bug.subscribe(direct_subscriber, direct_subscriber.teamowner,
221 level=BugNotificationLevel.LIFECYCLE)
222
223
224=== modified file 'lib/lp/code/browser/branch.py'
225--- lib/lp/code/browser/branch.py 2011-12-13 13:53:12 +0000
226+++ lib/lp/code/browser/branch.py 2011-12-14 03:28:31 +0000
227@@ -96,7 +96,10 @@
228 stepthrough,
229 stepto,
230 )
231-from canonical.launchpad.webapp.authorization import check_permission
232+from canonical.launchpad.webapp.authorization import (
233+ check_permission,
234+ precache_permission_for_objects,
235+ )
236 from canonical.launchpad.webapp.interfaces import ICanonicalUrlData
237 from canonical.launchpad.webapp.menu import structured
238 from lp.app.browser.launchpad import Hierarchy
239@@ -442,6 +445,11 @@
240 def initialize(self):
241 self.branch = self.context
242 self.notices = []
243+ # Cache permission so private team owner can be rendered.
244+ authorised_people = [self.branch.owner]
245+ if self.user is not None:
246+ precache_permission_for_objects(
247+ self.request, "launchpad.LimitedView", authorised_people)
248 # Replace our context with a decorated branch, if it is not already
249 # decorated.
250 if not isinstance(self.context, DecoratedBranch):
251
252=== modified file 'lib/lp/code/browser/branchsubscription.py'
253--- lib/lp/code/browser/branchsubscription.py 2011-12-13 13:53:12 +0000
254+++ lib/lp/code/browser/branchsubscription.py 2011-12-14 03:28:31 +0000
255@@ -19,7 +19,10 @@
256 canonical_url,
257 LaunchpadView,
258 )
259-from canonical.launchpad.webapp.authorization import check_permission
260+from canonical.launchpad.webapp.authorization import (
261+ check_permission,
262+ precache_permission_for_objects,
263+ )
264 from canonical.launchpad.webapp.interfaces import IPrimaryContext
265 from canonical.launchpad.webapp.menu import structured
266 from lp.app.browser.launchpadform import (
267@@ -48,9 +51,18 @@
268
269 def subscriptions(self):
270 """Return a decorated list of branch subscriptions."""
271+
272+ # Cache permissions so private subscribers can be rendered.
273+ if self.user is not None:
274+ subscribers = [
275+ subscription.person
276+ for subscription in self.context.subscriptions]
277+ precache_permission_for_objects(
278+ self.request, "launchpad.LimitedView", subscribers)
279+
280 visible_subscriptions = [
281 subscription for subscription in self.context.subscriptions
282- if check_permission('launchpad.View', subscription.person)]
283+ if check_permission('launchpad.LimitedView', subscription.person)]
284 return sorted(
285 visible_subscriptions,
286 key=lambda subscription: subscription.person.displayname)
287
288=== modified file 'lib/lp/code/browser/tests/test_branch.py'
289--- lib/lp/code/browser/tests/test_branch.py 2011-12-13 13:53:12 +0000
290+++ lib/lp/code/browser/tests/test_branch.py 2011-12-14 03:28:31 +0000
291@@ -5,12 +5,14 @@
292
293 __metaclass__ = type
294
295+from BeautifulSoup import BeautifulSoup
296 from datetime import (
297 datetime,
298 )
299 from textwrap import dedent
300
301 import pytz
302+from zope.publisher.interfaces import NotFound
303 from zope.security.proxy import removeSecurityProxy
304
305 from canonical.config import config
306@@ -26,6 +28,7 @@
307 extract_text,
308 find_tag_by_id,
309 setupBrowser,
310+ setupBrowserForUser
311 )
312 from lp.app.interfaces.headings import IRootContext
313 from lp.bugs.interfaces.bugtask import (
314@@ -50,6 +53,7 @@
315 BranchVisibilityRule,
316 )
317 from lp.code.interfaces.branchtarget import IBranchTarget
318+from lp.registry.interfaces.person import PersonVisibility
319 from lp.testing import (
320 BrowserTestCase,
321 login,
322@@ -541,6 +545,102 @@
323 self.assertEqual(linked_bug_urls[1], links[4]['href'])
324
325
326+class TestBranchViewPrivateArtifacts(BrowserTestCase):
327+ """ Tests that branches with private team artifacts can be viewed.
328+
329+ A Branch may be associated with a private team as follows:
330+ - the owner is a private team
331+ - a subscriber is a private team
332+
333+ A logged in user who is not authorised to see the private team(s) still
334+ needs to be able to view the branch. The private team will be rendered in
335+ the normal way, displaying the team name and Launchpad URL.
336+ """
337+
338+ layer = DatabaseFunctionalLayer
339+
340+ def _getBrowser(self, user=None):
341+ if user is None:
342+ browser = setupBrowser()
343+ logout()
344+ return browser
345+ else:
346+ login_person(user)
347+ return setupBrowserForUser(user=user)
348+
349+ def test_view_branch_with_private_owner(self):
350+ # A branch with a private owner is rendered.
351+ private_owner = self.factory.makeTeam(
352+ displayname="PrivateTeam", visibility=PersonVisibility.PRIVATE)
353+ branch = self.factory.makeAnyBranch(owner=private_owner)
354+ # Ensure the branch owner is rendered.
355+ url = canonical_url(branch, rootsite='code')
356+ user = self.factory.makePerson()
357+ browser = self._getBrowser(user)
358+ browser.open(url)
359+ soup = BeautifulSoup(browser.contents)
360+ self.assertIsNotNone(soup.find('a', text="PrivateTeam"))
361+
362+ def test_view_private_branch_with_private_owner(self):
363+ # A private branch with a private owner is rendered.
364+ private_owner = self.factory.makeTeam(
365+ displayname="PrivateTeam", visibility=PersonVisibility.PRIVATE)
366+ branch = self.factory.makeAnyBranch(owner=private_owner)
367+ # Ensure the branch owner is rendered.
368+ url = canonical_url(branch, rootsite='code')
369+ user = self.factory.makePerson()
370+ # Subscribe the user so they can see the branch.
371+ with person_logged_in(private_owner):
372+ self.factory.makeBranchSubscription(branch, user, private_owner)
373+ browser = self._getBrowser(user)
374+ browser.open(url)
375+ soup = BeautifulSoup(browser.contents)
376+ self.assertIsNotNone(soup.find('a', text="PrivateTeam"))
377+
378+ def test_anonymous_view_branch_with_private_owner(self):
379+ # A branch with a private owner is not rendered for anon users.
380+ private_owner = self.factory.makeTeam(
381+ visibility=PersonVisibility.PRIVATE)
382+ branch = self.factory.makeAnyBranch(owner=private_owner)
383+ # Viewing the branch results in an error.
384+ url = canonical_url(branch, rootsite='code')
385+ browser = self._getBrowser()
386+ self.assertRaises(NotFound, browser.open, url)
387+
388+ def test_view_branch_with_private_subscriber(self):
389+ # A branch with a private subscriber is rendered.
390+ private_subscriber = self.factory.makeTeam(
391+ name="privateteam", visibility=PersonVisibility.PRIVATE)
392+ branch = self.factory.makeAnyBranch()
393+ with person_logged_in(branch.owner):
394+ self.factory.makeBranchSubscription(
395+ branch, private_subscriber, branch.owner)
396+ # Ensure the branch subscriber is rendered.
397+ url = canonical_url(branch, rootsite='code')
398+ user = self.factory.makePerson()
399+ browser = self._getBrowser(user)
400+ browser.open(url)
401+ soup = BeautifulSoup(browser.contents)
402+ self.assertIsNotNone(
403+ soup.find('div', attrs={'id': 'subscriber-privateteam'}))
404+
405+ def test_anonymous_view_branch_with_private_subscriber(self):
406+ # A branch with a private subscriber is not rendered for anon users.
407+ private_subscriber = self.factory.makeTeam(
408+ name="privateteam", visibility=PersonVisibility.PRIVATE)
409+ branch = self.factory.makeAnyBranch()
410+ with person_logged_in(branch.owner):
411+ self.factory.makeBranchSubscription(
412+ branch, private_subscriber, branch.owner)
413+ # Viewing the branch results in an error.
414+ url = canonical_url(branch, rootsite='code')
415+ browser = self._getBrowser()
416+ browser.open(url)
417+ soup = BeautifulSoup(browser.contents)
418+ self.assertIsNone(
419+ soup.find('div', attrs={'id': 'subscriber-privateteam'}))
420+
421+
422 class TestBranchAddView(TestCaseWithFactory):
423 """Test the BranchAddView view."""
424
425
426=== modified file 'lib/lp/code/browser/tests/test_branchlisting.py'
427--- lib/lp/code/browser/tests/test_branchlisting.py 2011-12-13 13:53:12 +0000
428+++ lib/lp/code/browser/tests/test_branchlisting.py 2011-12-14 03:28:31 +0000
429@@ -613,13 +613,14 @@
430 layer = DatabaseFunctionalLayer
431
432 def _make_branch_for_private_team(self):
433+ owner = self.factory.makePerson()
434 private_team = self.factory.makeTeam(
435- name='shh', displayname='Shh',
436+ name='shh', displayname='Shh', owner=owner,
437 visibility=PersonVisibility.PRIVATE)
438 member = self.factory.makePerson(
439 email='member@example.com', password='test')
440- with person_logged_in(private_team.teamowner):
441- private_team.addMember(member, private_team.teamowner)
442+ with person_logged_in(owner):
443+ private_team.addMember(member, owner)
444 branch = self.factory.makeProductBranch(owner=private_team)
445 return private_team, member, branch
446
447
448=== modified file 'lib/lp/code/stories/branches/xx-subscribing-branches.txt'
449--- lib/lp/code/stories/branches/xx-subscribing-branches.txt 2011-12-13 13:53:12 +0000
450+++ lib/lp/code/stories/branches/xx-subscribing-branches.txt 2011-12-14 03:28:31 +0000
451@@ -254,15 +254,19 @@
452 Mark Shuttleworth
453
454
455-Private team exclusion
456-======================
457-
458-If a private team is subscribed to the branch, it is excluded from the list of
459-subscribers if the user is not an admin or a member of the team.
460-
461- >>> from lp.testing import ANONYMOUS, login, logout
462+Private team's in public subscriptions
463+======================================
464+
465+If a private team is subscribed to a publicthe branch, it is visible
466+to everyone.
467+
468+ >>> from lp.testing import login, logout
469+ >>> from lp.registry.interfaces.person import PersonVisibility
470+ >>> from lp.code.enums import (
471+ ... BranchSubscriptionNotificationLevel,
472+ ... CodeReviewNotificationLevel)
473+
474 >>> login('admin@canonical.com')
475- >>> from lp.registry.interfaces.person import PersonVisibility
476 >>> private_team = factory.makeTeam(
477 ... name='shh', displayname='Shh',
478 ... visibility=PersonVisibility.PRIVATE)
479@@ -271,26 +275,16 @@
480 >>> ignored = private_team.addMember(member, private_team.teamowner)
481 >>> owner = factory.makePerson(name='branch-owner')
482 >>> branch = factory.makeAnyBranch(owner=owner)
483- >>> from lp.code.enums import (
484- ... BranchSubscriptionNotificationLevel,
485- ... CodeReviewNotificationLevel)
486 >>> ignored = branch.subscribe(
487 ... private_team, BranchSubscriptionNotificationLevel.NOEMAIL, None,
488 ... CodeReviewNotificationLevel.NOEMAIL, private_team.teamowner)
489 >>> url = canonical_url(branch)
490 >>> logout()
491
492-No-priv is not a member of the private team, so the private team is not shown
493-as a subscriber.
494+No-priv is not a member of the private team, but he can see the team's
495+display name in the subscriber list.
496
497 >>> browser.open(url)
498 >>> print_subscribers(browser.contents)
499 Branch-owner
500-
501-A team member looking at the branch will see the private team subscribed.
502-
503- >>> private_browser = setupBrowser(auth='Basic shh@example.com:test')
504- >>> private_browser.open(url)
505- >>> print_subscribers(private_browser.contents)
506- Branch-owner
507 Shh
508
509=== modified file 'lib/lp/registry/configure.zcml'
510--- lib/lp/registry/configure.zcml 2011-12-13 13:53:12 +0000
511+++ lib/lp/registry/configure.zcml 2011-12-14 03:28:31 +0000
512@@ -912,8 +912,6 @@
513 <allow
514 interface="lp.registry.interfaces.person.IPersonPublic"/>
515 <allow
516- interface="lp.registry.interfaces.person.ITeamPublic"/>
517- <allow
518 interface="lp.registry.interfaces.person.IHasStanding"/>
519 <allow
520 interface="lp.registry.interfaces.person.IPersonCommAdminWriteRestricted"/>
521@@ -924,6 +922,9 @@
522 permission="launchpad.View"
523 interface="lp.registry.interfaces.person.IPersonViewRestricted"/>
524 <require
525+ permission="launchpad.View"
526+ interface="lp.registry.interfaces.person.ITeamPublic"/>
527+ <require
528 permission="launchpad.Edit"
529 interface="lp.registry.interfaces.location.ISetLocation"/>
530 <require
531@@ -931,9 +932,10 @@
532 interface="lp.registry.interfaces.person.IPersonEditRestricted"/>
533 <require
534 permission="launchpad.Edit"
535- set_schema="lp.registry.interfaces.person.IPersonPublic
536+ set_schema="lp.registry.interfaces.person.IPersonViewRestricted
537+ lp.registry.interfaces.person.IPersonPublic
538 lp.registry.interfaces.person.ITeamPublic"
539- set_attributes="displayname"/>
540+ set_attributes="displayname icon logo"/>
541 <require
542 permission="launchpad.Moderate"
543 set_attributes="name"/>
544
545=== modified file 'lib/lp/registry/doc/private-team-visibility.txt'
546--- lib/lp/registry/doc/private-team-visibility.txt 2011-12-13 13:53:12 +0000
547+++ lib/lp/registry/doc/private-team-visibility.txt 2011-12-14 03:28:31 +0000
548@@ -107,3 +107,64 @@
549 ...
550 Unauthorized: (<Person at ... priv-team (Priv Team)>,
551 'name', 'launchpad.LimitedView')
552+
553+A person with visibility to any of the branches owned by the private team will
554+be granted limited view permission on the team.
555+
556+For private branches, a user needs to be subscribed to the branch for the
557+branch (and hence team) to be visible.
558+
559+ >>> login_person(priv_owner)
560+ >>> private_team_branch = factory.makeBranch(
561+ ... owner=priv_team, private=True)
562+ >>> login_person(pub_member)
563+ >>> check_permission('launchpad.LimitedView', priv_team)
564+ False
565+
566+ >>> login_person(priv_owner)
567+ >>> sub = factory.makeBranchSubscription(
568+ ... branch=private_team_branch, person=pub_member,
569+ ... subscribed_by=priv_owner)
570+
571+ >>> login_person(pub_member)
572+ >>> check_permission('launchpad.LimitedView', priv_team)
573+ True
574+
575+Subscribers to the teams private PPA have limited view permission.
576+
577+ >>> login_person(priv_owner)
578+ >>> archive = factory.makeArchive(private=True, owner=priv_team)
579+ >>> archive_subscriber = factory.makePerson()
580+ >>> sub = archive.newSubscription(
581+ ... archive_subscriber, registrant=archive.owner)
582+
583+ >>> login_person(archive_subscriber)
584+ >>> check_permission('launchpad.LimitedView', priv_team)
585+ True
586+
587+Users with LimitedView can know identifying information like name,
588+displayname, and unique_name, but cannot know other information like
589+teamowner.
590+
591+ >>> print priv_team.name
592+ priv-team
593+
594+ >>> print priv_team.displayname
595+ Priv Team
596+
597+ >>> print priv_team.unique_displayname
598+ Priv Team (priv-team)
599+
600+ >>> print priv_team.icon
601+ None
602+
603+ >>> print priv_team.allmembers
604+ Traceback (most recent call last):
605+ ...
606+ Unauthorized: (<Person at ... priv-team (Priv Team)>,
607+ 'allmembers', 'launchpad.View')
608+
609+Anonymous users do not have permission.
610+ >>> login(ANONYMOUS)
611+ >>> check_permission('launchpad.LimitedView', priv_team)
612+ False
613
614=== modified file 'lib/lp/registry/interfaces/person.py'
615--- lib/lp/registry/interfaces/person.py 2011-12-13 13:53:12 +0000
616+++ lib/lp/registry/interfaces/person.py 2011-12-14 03:28:31 +0000
617@@ -15,7 +15,7 @@
618 'IObjectReassignment',
619 'IPerson',
620 'IPersonClaim',
621- 'IPersonPublic', # Required for a monkey patch in interfaces/archive.py
622+ 'IPersonPublic',
623 'IPersonSet',
624 'IPersonSettings',
625 'ISoftwareCenterAgentAPI',
626@@ -657,26 +657,61 @@
627 required=False, default=False)
628
629
630-class IPersonPublic(IHasBranches, IHasSpecifications,
631- IHasMergeProposals, IHasLogo, IHasMugshot, IHasIcon,
632- IHasLocation, IHasRequestedReviews, IObjectWithLocation,
633- IPrivacy, IHasBugs, IHasRecipes, IHasTranslationImports,
634- IPersonSettings, IQuestionsPerson):
635- """Public attributes for a Person."""
636+class IPersonPublic(IPrivacy):
637+ """Public attributes for a Person.
638+
639+ Very few attributes on a person can be public because private teams
640+ are also persons. The public attributes are generally information
641+ needed by the system to determine if the principal in the current
642+ interaction can work with the object.
643+ """
644
645 id = Int(title=_('ID'), required=True, readonly=True)
646- account = Object(schema=IAccount)
647- accountID = Int(title=_('Account ID'), required=True, readonly=True)
648- password = PasswordField(
649- title=_('Password'), required=True, readonly=False)
650- karma = exported(
651- Int(title=_('Karma'), readonly=True,
652- description=_('The cached total karma for this person.')))
653- homepage_content = exported(
654- Text(title=_("Homepage Content"), required=False,
655- description=_(
656- "The content of your profile page. Use plain text, "
657- "paragraphs are preserved and URLs are linked in pages.")))
658+ # This is redefined from IPrivacy.private because the attribute is
659+ # read-only. It is a summary of the team's visibility.
660+ private = exported(Bool(
661+ title=_("This team is private"),
662+ readonly=True, required=False,
663+ description=_("Private teams are visible only to "
664+ "their members.")))
665+ is_valid_person = Bool(
666+ title=_("This is an active user and not a team."), readonly=True)
667+ is_valid_person_or_team = exported(
668+ Bool(title=_("This is an active user or a team."), readonly=True),
669+ exported_as='is_valid')
670+ is_merge_pending = exported(Bool(
671+ title=_("Is this person due to be merged with another?"),
672+ required=False, default=False))
673+ is_team = exported(
674+ Bool(title=_('Is this object a team?'), readonly=True))
675+ account_status = Choice(
676+ title=_("The status of this person's account"), required=False,
677+ readonly=True, vocabulary=AccountStatus)
678+ account_status_comment = Text(
679+ title=_("Why are you deactivating your account?"), required=False,
680+ readonly=True)
681+
682+
683+class IPersonLimitedView(IHasIcon, IHasLogo):
684+ """IPerson attributes that require launchpad.LimitedView permission."""
685+
686+ name = exported(
687+ PersonNameField(
688+ title=_('Name'), required=True, readonly=False,
689+ constraint=name_validator,
690+ description=_(
691+ "A short unique name, beginning with a lower-case "
692+ "letter or number, and containing only letters, "
693+ "numbers, dots, hyphens, or plus signs.")))
694+ displayname = exported(
695+ StrippedTextLine(
696+ title=_('Display Name'), required=True, readonly=False,
697+ description=_(
698+ "Your name as you would like it displayed throughout "
699+ "Launchpad. Most people use their full name here.")),
700+ exported_as='display_name')
701+ unique_displayname = TextLine(
702+ title=_('Return a string of the form $displayname ($name).'))
703 # NB at this stage we do not allow individual people to have their own
704 # icon, only teams get that. People can however have a logo and mugshot
705 # The icon is only used for teams; that's why we use /@@/team as the
706@@ -690,7 +725,6 @@
707 "displayed whenever the team name is listed - for example "
708 "in listings of bugs or on a person's membership table."))
709 iconID = Int(title=_('Icon ID'), required=True, readonly=True)
710-
711 logo = exported(
712 LogoImageUpload(
713 title=_("Logo"), required=False,
714@@ -701,6 +735,44 @@
715 "is a logo, a small picture or a personal mascot. It should "
716 "be no bigger than 50kb in size.")))
717 logoID = Int(title=_('Logo ID'), required=True, readonly=True)
718+ # title is required for the Launchpad Page Layout main template
719+ title = Attribute('Person Page Title')
720+ is_probationary = exported(
721+ Bool(title=_("Is this a probationary user?"), readonly=True))
722+
723+ @operation_parameters(
724+ name=TextLine(required=True, constraint=name_validator))
725+ @operation_returns_entry(Interface) # Really IArchive.
726+ @export_read_operation()
727+ @operation_for_version("beta")
728+ def getPPAByName(name):
729+ """Return a PPA with the given name if it exists.
730+
731+ :param name: A string with the exact name of the ppa being looked up.
732+ :raises: `NoSuchPPA` if a suitable PPA could not be found.
733+
734+ :return: a PPA `IArchive` record corresponding to the name.
735+ """
736+
737+
738+class IPersonViewRestricted(IHasBranches, IHasSpecifications,
739+ IHasMergeProposals, IHasMugshot,
740+ IHasLocation, IHasRequestedReviews, IObjectWithLocation,
741+ IHasBugs, IHasRecipes, IHasTranslationImports,
742+ IPersonSettings, IQuestionsPerson):
743+ """IPerson attributes that require launchpad.View permission."""
744+ account = Object(schema=IAccount)
745+ accountID = Int(title=_('Account ID'), required=True, readonly=True)
746+ password = PasswordField(
747+ title=_('Password'), required=True, readonly=False)
748+ karma = exported(
749+ Int(title=_('Karma'), readonly=True,
750+ description=_('The cached total karma for this person.')))
751+ homepage_content = exported(
752+ Text(title=_("Homepage Content"), required=False,
753+ description=_(
754+ "The content of your profile page. Use plain text, "
755+ "paragraphs are preserved and URLs are linked in pages.")))
756
757 mugshot = exported(MugshotImageUpload(
758 title=_("Mugshot"), required=False,
759@@ -755,26 +827,9 @@
760 readonly=False, required=False,
761 value_type=Reference(schema=ISSHKey)))
762
763- account_status = Choice(
764- title=_("The status of this person's account"), required=False,
765- readonly=True, vocabulary=AccountStatus)
766-
767- account_status_comment = Text(
768- title=_("Why are you deactivating your account?"), required=False,
769- readonly=True)
770-
771 # Properties of the Person object.
772 karma_category_caches = Attribute(
773 'The caches of karma scores, by karma category.')
774- is_team = exported(
775- Bool(title=_('Is this object a team?'), readonly=True))
776- is_valid_person = Bool(
777- title=_("This is an active user and not a team."), readonly=True)
778- is_valid_person_or_team = exported(
779- Bool(title=_("This is an active user or a team."), readonly=True),
780- exported_as='is_valid')
781- is_probationary = exported(
782- Bool(title=_("Is this a probationary user?"), readonly=True))
783 is_ubuntu_coc_signer = exported(
784 Bool(title=_("Signed Ubuntu Code of Conduct"),
785 readonly=True))
786@@ -879,7 +934,6 @@
787 exported_as='team_owner')
788 teamownerID = Int(title=_("The Team Owner's ID or None"), required=False,
789 readonly=True)
790-
791 preferredemail = exported(
792 Reference(title=_("Preferred email address"),
793 description=_("The preferred email address for this person. "
794@@ -916,9 +970,6 @@
795 "this is set to None, then this Person has not been merged "
796 "into another and is still valid"))
797
798- # title is required for the Launchpad Page Layout main template
799- title = Attribute('Person Page Title')
800-
801 archive = exported(
802 Reference(
803 title=_("Default PPA"),
804@@ -987,18 +1038,6 @@
805 readonly=True, required=False,
806 value_type=Reference(schema=Interface))) # HWSubmission
807
808- # This is redefined from IPrivacy.private because the attribute is
809- # read-only. It is a summary of the team's visibility.
810- private = exported(Bool(
811- title=_("This team is private"),
812- readonly=True, required=False,
813- description=_("Private teams are visible only to "
814- "their members.")))
815-
816- is_merge_pending = exported(Bool(
817- title=_("Is this person due to be merged with another?"),
818- required=False, default=False))
819-
820 administrated_teams = Attribute(
821 u"the teams that this person/team is an administrator of.")
822
823@@ -1414,20 +1453,6 @@
824 """
825
826 @operation_parameters(
827- name=TextLine(required=True, constraint=name_validator))
828- @operation_returns_entry(Interface) # Really IArchive.
829- @export_read_operation()
830- @operation_for_version("beta")
831- def getPPAByName(name):
832- """Return a PPA with the given name if it exists.
833-
834- :param name: A string with the exact name of the ppa being looked up.
835- :raises: `NoSuchPPA` if a suitable PPA could not be found.
836-
837- :return: a PPA `IArchive` record corresponding to the name.
838- """
839-
840- @operation_parameters(
841 name=TextLine(required=True, constraint=name_validator),
842 displayname=TextLine(required=False),
843 description=TextLine(required=False),
844@@ -1463,32 +1488,6 @@
845 :return: a boolean.
846 """
847
848-
849-class IPersonLimitedView(Interface):
850- """IPerson attributes that require launchpad.LimitedView permission."""
851-
852- name = exported(
853- PersonNameField(
854- title=_('Name'), required=True, readonly=False,
855- constraint=name_validator,
856- description=_(
857- "A short unique name, beginning with a lower-case "
858- "letter or number, and containing only letters, "
859- "numbers, dots, hyphens, or plus signs.")))
860- displayname = exported(
861- StrippedTextLine(
862- title=_('Display Name'), required=True, readonly=False,
863- description=_(
864- "Your name as you would like it displayed throughout "
865- "Launchpad. Most people use their full name here.")),
866- exported_as='display_name')
867- unique_displayname = TextLine(
868- title=_('Return a string of the form $displayname ($name).'))
869-
870-
871-class IPersonViewRestricted(Interface):
872- """IPerson attributes that require launchpad.View permission."""
873-
874 active_member_count = Attribute(
875 "The number of real people who are members of this team.")
876 # activemembers.value_type.schema will be set to IPerson once
877@@ -2565,18 +2564,19 @@
878 ]:
879 IPersonViewRestricted[name].value_type.schema = IPerson
880
881-IPersonPublic['sub_teams'].value_type.schema = ITeam
882-IPersonPublic['super_teams'].value_type.schema = ITeam
883+IPersonViewRestricted['sub_teams'].value_type.schema = ITeam
884+IPersonViewRestricted['super_teams'].value_type.schema = ITeam
885 # XXX: salgado, 2008-08-01: Uncomment these when teams_*participated_in are
886 # exported again.
887-# IPersonPublic['teams_participated_in'].value_type.schema = ITeam
888-# IPersonPublic['teams_indirectly_participated_in'].value_type.schema = ITeam
889+# IPersonViewRestricted['teams_participated_in'].value_type.schema = ITeam
890+# IPersonViewRestricted[
891+# 'teams_indirectly_participated_in'].value_type.schema = ITeam
892
893 # Fix schema of operation parameters. We need zope.deferredimport!
894 params_to_fix = [
895 # XXX: salgado, 2008-08-01: Uncomment these when they are exported again.
896- # (IPersonPublic['findPathToTeam'], 'team'),
897- # (IPersonPublic['inTeam'], 'team'),
898+ # (IPersonViewRestricted['findPathToTeam'], 'team'),
899+ # (IPersonViewRestricted['inTeam'], 'team'),
900 (IPersonEditRestricted['join'], 'team'),
901 (IPersonEditRestricted['leave'], 'team'),
902 (IPersonEditRestricted['addMember'], 'person'),
903
904=== modified file 'lib/lp/registry/interfaces/role.py'
905--- lib/lp/registry/interfaces/role.py 2011-12-13 13:53:12 +0000
906+++ lib/lp/registry/interfaces/role.py 2011-12-14 03:28:31 +0000
907@@ -119,7 +119,8 @@
908 def inTeam(team):
909 """Is this person a member or the owner of `team`?
910
911- Passed through to the same method in 'IPersonPublic'.
912+ Passed through to the *unproxied* same method in
913+ `IPersonViewRestricted`.
914 """
915
916 def isOwner(obj):
917
918=== modified file 'lib/lp/soyuz/tests/test_archive_subscriptions.py'
919--- lib/lp/soyuz/tests/test_archive_subscriptions.py 2011-12-13 18:05:18 +0000
920+++ lib/lp/soyuz/tests/test_archive_subscriptions.py 2011-12-14 03:28:31 +0000
921@@ -5,14 +5,21 @@
922
923 from zope.security.interfaces import Unauthorized
924
925+from canonical.launchpad.testing.pages import (
926+ find_tag_by_id,
927+ setupBrowserForUser
928+ )
929+from canonical.launchpad.webapp.publisher import canonical_url
930 from canonical.testing.layers import DatabaseFunctionalLayer
931 from lp.registry.interfaces.person import PersonVisibility
932 from lp.testing import (
933 celebrity_logged_in,
934 login_person,
935+ person_logged_in,
936 TestCaseWithFactory,
937 )
938 from lp.testing.mail_helpers import pop_notifications
939+from lp.testing import BrowserTestCase
940 from lp.testing.views import create_initialized_view
941
942
943@@ -111,3 +118,35 @@
944 self.subscriber, registrant=self.archive.owner)
945
946 self.assertEqual(0, len(pop_notifications()))
947+
948+
949+class PrivateArtifactsViewTestCase(BrowserTestCase):
950+ """ Tests that private team archives can be viewed."""
951+
952+ layer = DatabaseFunctionalLayer
953+
954+ def setUp(self):
955+ """Create a test archive."""
956+ super(PrivateArtifactsViewTestCase, self).setUp()
957+ self.owner = self.factory.makePerson()
958+ self.private_team = self.factory.makeTeam(
959+ visibility=PersonVisibility.PRIVATE,
960+ name="subscribertest", owner=self.owner)
961+ with person_logged_in(self.owner):
962+ self.archive = self.factory.makeArchive(
963+ private=True, owner=self.private_team)
964+ self.subscriber = self.factory.makePerson()
965+
966+ def test_traverse_view_private_team_archive_subscriber(self):
967+ # A subscriber can traverse and view the archive.
968+ with person_logged_in(self.owner):
969+ self.archive.newSubscription(
970+ self.subscriber, registrant=self.archive.owner)
971+ with person_logged_in(self.subscriber):
972+ url = canonical_url(self.archive)
973+ browser = setupBrowserForUser(self.subscriber)
974+ browser.open(url)
975+ content = find_tag_by_id(browser.contents, 'document')
976+ self.assertIsNotNone(find_tag_by_id(content, 'ppa-install'))
977+ self.assertIsNotNone(
978+ find_tag_by_id(content, 'portlet-latest-updates'))