Merge lp:~sinzui/launchpad/closed-teams-1 into lp:launchpad

Proposed by Curtis Hovey
Status: Merged
Merged at revision: 12046
Proposed branch: lp:~sinzui/launchpad/closed-teams-1
Merge into: lp:launchpad
Diff against target: 330 lines (+241/-3)
3 files modified
lib/lp/registry/errors.py (+26/-0)
lib/lp/registry/interfaces/person.py (+80/-2)
lib/lp/registry/tests/test_team.py (+135/-1)
To merge this branch: bzr merge lp:~sinzui/launchpad/closed-teams-1
Reviewer Review Type Date Requested Status
Brad Crittenden (community) Approve
Review via email: mp+43375@code.launchpad.net

Description of the change

Do not permit teams to be compromised by changes to the subscription policy.

    Launchpad bug:
        https://bugs.launchpad.net/bugs/662844
        https://bugs.launchpad.net/bugs/654476
    Pre-implementation: jcsacket, gary, benji, flacoste, bac, bigjools
    Test command: ./bin/test -vv -t TestTeamSubscriptionPolicy

A restricted or moderated team is effectively an open team if it has an open
team as a member.

The branch that updated the picker fixed the first issue in this bug -- closed
teams cannot add an open team as a member or make an open team team the owner.
The remaining issue is to guard changes to the subscription policy:

  * A closed team cannot become open if it is a member of another closed team
  * An open team cannot become closed if it has an open team as a member

The guard only needs to check the immediate super teams or sub teams, since
the deeper teams in the hierarchy must also conform to these rules.

ADDENDUM

Open teams cannot have PPAs, only vetted members may upload to the archive.

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

RULES

    * Add TeamSubscriptionPolicyError when a subscription policy change is
      not permitted
    * Add a validator to check that changes to a team's subscription policies
      are enforced.
      1. Closed teams cannot become open if they are members of closed team.
         Rule 1 means a closed team cannot become open if it is a member of
         any team
      2. Open teams cannot become closed if it has any direct or indirect
         open teams as members.
    * The validator can raise TeamSubscriptionPolicyError when the state is
      invalid. it is also: webservice_error(httplib.FORBIDDEN)
    * Use the validator when subscriptionpolicy is changed.
      * A field validator is needed to ensure api changes are sane
    * Add a guard to prevent closed teams with PPAs from becoming open.

QA

    * Try to change ~launchpad to open (rule 1)
    * Try to change an open team with open teams to closed (rule 2)
    * Try to change a team with a PPA to an open team.

LINT

    lib/lp/registry/errors.py
    lib/lp/registry/interfaces/person.py
    lib/lp/registry/tests/test_team.py

IMPLEMENTATION

Added TeamSubscriptionPolicyError that will show the specific error message
in the UI. Created TeamSubsciptionPolicyChoice that enforces both the
vocabulary and the constraints imposed by team memberships and ppas. The
contraint work is actually done by a helper function called
team_subscription_policy_can_transition.
    lib/lp/registry/errors.py
    lib/lp/registry/interfaces/person.py
    lib/lp/registry/tests/test_team.py

To post a comment you must log in.
Revision history for this message
Brad Crittenden (bac) wrote :

Hi Curtis,

27 + The error can be raised because a super team or member team prevents
28 + the this team from setting a specific policy. The error can also be
29 + raised if the team has an active PPA.

s/the this/this/

There is a logic problem we discussed on IRC
elif policy != TeamSubscriptionPolicy.OPEN:
should be
elif team.subscriptionpolicy != TeamSubscriptionPolicy.OPEN:

In your tests, I think it would be clearer if your two teams were parent_team and child_team. I find other_team switching roles based on the test to be confusing.

Otherwise it looks good. I'll mark it approved since we discussed the changes you'll make.

Thanks for the fix and killing two bugs at once.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/registry/errors.py'
2--- lib/lp/registry/errors.py 2010-11-11 11:55:53 +0000
3+++ lib/lp/registry/errors.py 2010-12-10 20:58:39 +0000
4@@ -18,6 +18,7 @@
5 'PPACreationError',
6 'PrivatePersonLinkageError',
7 'TeamMembershipTransitionError',
8+ 'TeamSubscriptionPolicyError',
9 'UserCannotChangeMembershipSilently',
10 'UserCannotSubscribePerson',
11 ]
12@@ -25,6 +26,7 @@
13 import httplib
14
15 from lazr.restful.declarations import webservice_error
16+from zope.schema.interfaces import ConstraintNotSatisfied
17 from zope.security.interfaces import Unauthorized
18
19 from lp.app.errors import NameLookupFailed
20@@ -132,6 +134,30 @@
21 webservice_error(httplib.BAD_REQUEST)
22
23
24+class TeamSubscriptionPolicyError(ConstraintNotSatisfied):
25+ """The team cannot have the specified TeamSubscriptionPolicy.
26+
27+ The error can be raised because a super team or member team prevents
28+ this team from setting a specific policy. The error can also be
29+ raised if the team has an active PPA.
30+ """
31+ webservice_error(httplib.BAD_REQUEST)
32+
33+ _default_message = "Team Subscription Policy Error"
34+
35+ def __init__(self, message=None):
36+ if message is None:
37+ message = self._default_message
38+ self.message = message
39+
40+ def doc(self):
41+ """See `Invalid`."""
42+ return self.message
43+
44+ def __str__(self):
45+ return self.message
46+
47+
48 class JoinNotAllowed(Exception):
49 """User is not allowed to join a given team."""
50 webservice_error(httplib.BAD_REQUEST)
51
52=== modified file 'lib/lp/registry/interfaces/person.py'
53--- lib/lp/registry/interfaces/person.py 2010-12-10 07:52:21 +0000
54+++ lib/lp/registry/interfaces/person.py 2010-12-10 20:58:39 +0000
55@@ -114,7 +114,10 @@
56 IHasRequestedReviews,
57 )
58 from lp.code.interfaces.hasrecipes import IHasRecipes
59-from lp.registry.errors import PrivatePersonLinkageError
60+from lp.registry.errors import (
61+ PrivatePersonLinkageError,
62+ TeamSubscriptionPolicyError,
63+ )
64 from lp.registry.interfaces.gpg import IGPGKey
65 from lp.registry.interfaces.irc import IIrcID
66 from lp.registry.interfaces.jabber import IJabberID
67@@ -146,6 +149,7 @@
68 StrippedTextLine,
69 )
70 from lp.services.worlddata.interfaces.language import ILanguage
71+from lp.soyuz.enums import ArchiveStatus
72 from lp.translations.interfaces.hastranslationimports import (
73 IHasTranslationImports,
74 )
75@@ -483,6 +487,80 @@
76 super(PersonNameField, self)._validate(input)
77
78
79+def team_subscription_policy_can_transition(team, policy):
80+ """Can the team can change its subscription policy
81+
82+ Returns True when the policy can change. or raises an error. OPEN teams
83+ cannot be members of MODERATED or RESTRICTED teams. OPEN teams
84+ cannot have PPAs. Changes from between OPEN and the two closed states
85+ can be blocked by team membership and team artifacts.
86+
87+ :param team: The team to change.
88+ :param policy: The TeamSubsciptionPolicy to change to.
89+ :raises TeamSubsciptionPolicyError: Raised when a membership constrain
90+ or a team artifact prevents the policy from being set.
91+ """
92+ if team is None or policy == team.subscriptionpolicy:
93+ # The team is being initialized or the policy is not changing.
94+ return True
95+ elif policy == TeamSubscriptionPolicy.OPEN:
96+ # The team can be open if its super teams are open.
97+ for team in team.super_teams:
98+ if team.subscriptionpolicy != TeamSubscriptionPolicy.OPEN:
99+ raise TeamSubscriptionPolicyError(
100+ "The team subscription policy cannot be %s because one "
101+ "or more if its super teams are not open." % policy)
102+ # The team can be open if it has PPAs.
103+ for ppa in team.ppas:
104+ if ppa.status != ArchiveStatus.DELETED:
105+ raise TeamSubscriptionPolicyError(
106+ "The team subscription policy cannot be %s because it "
107+ "has one or more active PPAs." % policy)
108+ elif team.subscriptionpolicy == TeamSubscriptionPolicy.OPEN:
109+ # The team can become MODERATED or RESTRICTED if its member teams
110+ # are not OPEN.
111+ for member in team.activemembers:
112+ if member.subscriptionpolicy == TeamSubscriptionPolicy.OPEN:
113+ raise TeamSubscriptionPolicyError(
114+ "The team subscription policy cannot be %s because one "
115+ "or more if its member teams are Open." % policy)
116+ else:
117+ # The policy change is between MODERATED and RESTRICTED.
118+ pass
119+ return True
120+
121+
122+class TeamSubsciptionPolicyChoice(Choice):
123+ """A valid team subscription policy."""
124+
125+ def _getTeam(self):
126+ """Return the context if it is a team or None."""
127+ if IPerson.providedBy(self.context):
128+ return self.context
129+ else:
130+ return None
131+
132+ def constraint(self, value):
133+ """See `IField`."""
134+ team = self._getTeam()
135+ policy = value
136+ try:
137+ return team_subscription_policy_can_transition(team, policy)
138+ except TeamSubscriptionPolicyError:
139+ return False
140+
141+ def _validate(self, value):
142+ """Ensure the TeamSubsciptionPolicy is valid for state of the team.
143+
144+ Returns True if the team can change its subscription policy to the
145+ `TeamSubscriptionPolicy`, otherwise raise TeamSubscriptionPolicyError.
146+ """
147+ team = self._getTeam()
148+ policy = value
149+ team_subscription_policy_can_transition(team, policy)
150+ super(TeamSubsciptionPolicyChoice, self)._validate(value)
151+
152+
153 class IPersonClaim(Interface):
154 """The schema used by IPerson's +claim form."""
155
156@@ -1688,7 +1766,7 @@
157 exported_as='team_description')
158
159 subscriptionpolicy = exported(
160- Choice(title=_('Subscription policy'),
161+ TeamSubsciptionPolicyChoice(title=_('Subscription policy'),
162 vocabulary=TeamSubscriptionPolicy,
163 default=TeamSubscriptionPolicy.MODERATED, required=True,
164 description=_(
165
166=== modified file 'lib/lp/registry/tests/test_team.py'
167--- lib/lp/registry/tests/test_team.py 2010-12-01 17:07:49 +0000
168+++ lib/lp/registry/tests/test_team.py 2010-12-10 20:58:39 +0000
169@@ -8,19 +8,27 @@
170 import transaction
171 from zope.component import getUtility
172 from zope.interface.exceptions import Invalid
173+from zope.security.proxy import removeSecurityProxy
174
175 from canonical.launchpad.database.emailaddress import EmailAddress
176 from canonical.launchpad.interfaces.emailaddress import IEmailAddressSet
177 from canonical.launchpad.interfaces.lpstorm import IMasterStore
178-from canonical.testing.layers import DatabaseFunctionalLayer
179+from canonical.testing.layers import (
180+ DatabaseFunctionalLayer,
181+ FunctionalLayer,
182+ )
183 from lp.registry.enum import PersonTransferJobType
184+from lp.registry.errors import TeamSubscriptionPolicyError
185 from lp.registry.interfaces.mailinglist import MailingListStatus
186 from lp.registry.interfaces.person import (
187+ IPersonSet,
188 ITeamPublic,
189 PersonVisibility,
190 TeamMembershipRenewalPolicy,
191+ TeamSubscriptionPolicy,
192 )
193 from lp.registry.model.persontransferjob import PersonTransferJob
194+from lp.soyuz.enums import ArchiveStatus
195 from lp.testing import (
196 login_celebrity,
197 login_person,
198@@ -191,6 +199,132 @@
199 ITeamPublic['defaultmembershipperiod'].validate(3650)
200
201
202+class TestTeamSubscriptionPolicyError(TestCaseWithFactory):
203+ """Test `TeamSubscriptionPolicyError` messages."""
204+
205+ layer = FunctionalLayer
206+
207+ def test_default_message(self):
208+ error = TeamSubscriptionPolicyError()
209+ self.assertEqual('Team Subscription Policy Error', error.message)
210+
211+ def test_str(self):
212+ # The string is the error message.
213+ error = TeamSubscriptionPolicyError('a message')
214+ self.assertEqual('a message', str(error))
215+
216+ def test_doc(self):
217+ # The doc() method returns the message. It is called when rendering
218+ # an error in the UI. eg structure error.
219+ error = TeamSubscriptionPolicyError('a message')
220+ self.assertEqual('a message', error.doc())
221+
222+
223+class TestTeamSubscriptionPolicyChoice(TestCaseWithFactory):
224+ """Test `TeamSubsciptionPolicyChoice` constraints."""
225+
226+ layer = DatabaseFunctionalLayer
227+
228+ def setUpTeams(self, policy, other_policy=None):
229+ if other_policy is None:
230+ other_policy = policy
231+ self.team = self.factory.makeTeam(subscription_policy=policy)
232+ self.other_team = self.factory.makeTeam(
233+ subscription_policy=other_policy, owner=self.team.teamowner)
234+ self.field = ITeamPublic['subscriptionpolicy'].bind(self.team)
235+ login_person(self.team.teamowner)
236+
237+ def test___getTeam_with_team(self):
238+ # _getTeam returns the context team for team updates.
239+ self.setUpTeams(TeamSubscriptionPolicy.MODERATED)
240+ self.assertEqual(self.team, self.field._getTeam())
241+
242+ def test___getTeam_with_person_set(self):
243+ # _getTeam returns the context person set for team creation.
244+ person_set = getUtility(IPersonSet)
245+ field = ITeamPublic['subscriptionpolicy'].bind(person_set)
246+ self.assertEqual(None, field._getTeam())
247+
248+ def test_closed_team_with_closed_super_team_cannot_become_open(self):
249+ # The team cannot compromise the membership of the super team
250+ # by becoming open. The user must remove his team from the super team
251+ # first.
252+ self.setUpTeams(TeamSubscriptionPolicy.MODERATED)
253+ self.other_team.addMember(self.team, self.team.teamowner)
254+ self.assertFalse(
255+ self.field.constraint(TeamSubscriptionPolicy.OPEN))
256+ self.assertRaises(
257+ TeamSubscriptionPolicyError, self.field.validate,
258+ TeamSubscriptionPolicy.OPEN)
259+
260+ def test_closed_team_with_open_super_team_can_become_open(self):
261+ # The team can become open if its super teams are open.
262+ self.setUpTeams(
263+ TeamSubscriptionPolicy.MODERATED, TeamSubscriptionPolicy.OPEN)
264+ self.other_team.addMember(self.team, self.team.teamowner)
265+ self.assertTrue(
266+ self.field.constraint(TeamSubscriptionPolicy.OPEN))
267+ self.assertEqual(
268+ None, self.field.validate(TeamSubscriptionPolicy.OPEN))
269+
270+ def test_open_team_with_open_sub_team_cannot_become_closed(self):
271+ # The team cannot become closed if its membership will be
272+ # compromised by an open subteam. The user must remove the subteam
273+ # first
274+ self.setUpTeams(TeamSubscriptionPolicy.OPEN)
275+ self.team.addMember(self.other_team, self.team.teamowner)
276+ self.assertFalse(
277+ self.field.constraint(TeamSubscriptionPolicy.MODERATED))
278+ self.assertRaises(
279+ TeamSubscriptionPolicyError, self.field.validate,
280+ TeamSubscriptionPolicy.MODERATED)
281+
282+ def test_closed_team_can_change_to_another_closed_policy(self):
283+ # A closed team can change between the two closed polcies.
284+ self.setUpTeams(TeamSubscriptionPolicy.MODERATED)
285+ self.team.addMember(self.other_team, self.team.teamowner)
286+ super_team = self.factory.makeTeam(
287+ subscription_policy=TeamSubscriptionPolicy.MODERATED,
288+ owner=self.team.teamowner)
289+ super_team.addMember(self.team, self.team.teamowner)
290+ self.assertTrue(
291+ self.field.constraint(TeamSubscriptionPolicy.RESTRICTED))
292+ self.assertEqual(
293+ None, self.field.validate(TeamSubscriptionPolicy.RESTRICTED))
294+
295+ def test_open_team_with_closed_sub_team_can_become_closed(self):
296+ # The team can become closed.
297+ self.setUpTeams(
298+ TeamSubscriptionPolicy.OPEN, TeamSubscriptionPolicy.MODERATED)
299+ self.team.addMember(self.other_team, self.team.teamowner)
300+ self.assertTrue(
301+ self.field.constraint(TeamSubscriptionPolicy.MODERATED))
302+ self.assertEqual(
303+ None, self.field.validate(TeamSubscriptionPolicy.MODERATED))
304+
305+ def test_closed_team_with_active_ppas_cannot_become_open(self):
306+ # The team cannot become open if it has PPA because it compromises the
307+ # the control of who can upload.
308+ self.setUpTeams(TeamSubscriptionPolicy.MODERATED)
309+ self.team.createPPA()
310+ self.assertFalse(
311+ self.field.constraint(TeamSubscriptionPolicy.OPEN))
312+ self.assertRaises(
313+ TeamSubscriptionPolicyError, self.field.validate,
314+ TeamSubscriptionPolicy.OPEN)
315+
316+ def test_closed_team_without_active_ppas_can_become_open(self):
317+ # The team can become if it has deleted PPAs.
318+ self.setUpTeams(TeamSubscriptionPolicy.MODERATED)
319+ ppa = self.team.createPPA()
320+ ppa.delete(self.team.teamowner)
321+ removeSecurityProxy(ppa).status = ArchiveStatus.DELETED
322+ self.assertTrue(
323+ self.field.constraint(TeamSubscriptionPolicy.OPEN))
324+ self.assertEqual(
325+ None, self.field.validate(TeamSubscriptionPolicy.OPEN))
326+
327+
328 class TestVisibilityConsistencyWarning(TestCaseWithFactory):
329
330 layer = DatabaseFunctionalLayer