Merge lp:~leonardr/launchpad/automatically-calculate-request-token-expire-time into lp:launchpad/db-devel

Proposed by Leonard Richardson
Status: Merged
Approved by: Leonard Richardson
Approved revision: no longer in the source branch.
Merge reported by: Leonard Richardson
Merged at revision: not available
Proposed branch: lp:~leonardr/launchpad/automatically-calculate-request-token-expire-time
Merge into: lp:launchpad/db-devel
Diff against target: 1405 lines (+672/-275)
17 files modified
lib/canonical/launchpad/browser/oauth.py (+106/-27)
lib/canonical/launchpad/database/oauth.py (+79/-10)
lib/canonical/launchpad/doc/oauth.txt (+39/-0)
lib/canonical/launchpad/doc/webapp-authorization.txt (+5/-13)
lib/canonical/launchpad/interfaces/oauth.py (+44/-9)
lib/canonical/launchpad/pagetests/oauth/authorize-token.txt (+146/-60)
lib/canonical/launchpad/templates/oauth-authorize.pt (+63/-22)
lib/canonical/launchpad/tests/test_oauth_tokens.py (+47/-0)
lib/canonical/launchpad/webapp/authorization.py (+4/-2)
lib/canonical/launchpad/webapp/interfaces.py (+6/-7)
lib/canonical/launchpad/webapp/servers.py (+1/-3)
lib/lp/archiveuploader/nascentuploadfile.py (+7/-33)
lib/lp/archiveuploader/tests/nascentupload-epoch-handling.txt (+8/-3)
lib/lp/archiveuploader/tests/nascentupload.txt (+11/-85)
lib/lp/archiveuploader/tests/test_buildduploads.py (+8/-0)
lib/lp/archiveuploader/tests/test_nascentuploadfile.py (+69/-1)
lib/lp/testing/factory.py (+29/-0)
To merge this branch: bzr merge lp:~leonardr/launchpad/automatically-calculate-request-token-expire-time
Reviewer Review Type Date Requested Status
Paul Hummer (community) Approve
Review via email: mp+38423@code.launchpad.net

Description of the change

Our use of the 'date_expires' field in IOAuthToken is a mess, but it didn't matter much up to this point, because we never really used it. I'm about to start using it, so this branch is cleanup to make it something I can use.

I plan to move the doctests in lib/canonical/launchpad/doc/oauth.txt into unit tests in the new test_oauth.py, but since this branch is subtle on its own I would rather do that in a separate branch.

The design behind this branch is salgado-approved.

Here are the problems with our current use of date_expires:

1. Request tokens always expire after REQUEST_TOKEN_VALIDITY hours. This is a site-wide constant, so there's no need to manually set the date that a request token expires--you can calculate it from REQUEST_TOKEN_VALIDITY and date_created.

2. Request tokens may pass their expiration date, but we never do anything to expired tokens, or check whether they have expired. So, effectively, request tokens never expire. A request token can be three years old and you'll still be able to review it and exchange it for an access token. This opens up our users to attacks when an intermediary acquires the key to a reviewed and forgotten request token.

3. Before a request token is exchanged for an access token, there is no way to distinguish between the date on which a request token expires, and the date on which the end-user wants the not-yet-created access token to expire. This isn't a problem now because the end-user is never given the choice of creating an access token that will expire. But I'm about to give them that choice.

I also noticed a smaller, less serious problem:

4. REQUEST_TOKEN_VALIDITY is twelve hours. That's way too long. Users will either exchange a request token for an access token within a few minutes, or forget about it altogether.

My solution to problem #4 is to reduce REQUEST_TOKEN_VALIDITY to two hours. My solution to the first three problems is a little more complex.

First, I am no longer storing the expiration date of a request token in date_expires. I am calculating it from date_created and REQUEST_TOKEN_VALIDITY. I have introduced an is_expired attribute to IOAuthToken, and separate implementations for OAuthRequestToken and OAuthAccessToken. I check token.is_expired whenever an access token is used to sign a request, and whenever an attempt is made to review a request token or exchange one for an access token.

This is the code tested by my unit tests, and this means that request tokens will in fact become unusable after REQUEST_TOKEN_VALIDITY hours. I do not do anything special to clean up expired tokens, but we weren't doing anything special before.

Second, I am taking date_expires out of IOAuthToken, and introducing it separately into the subclasses IOAuthRequestToken and IOAuthAccessToken. IOAuthRequestToken.date_expires is now the expiration date of the access token that will eventually be created from the request token--not anything to do with the request token itself. When the request token is exchanged for an access token, the IOAuthRequestToken.date_expires field is copied into IOAuthAccessToken.date_expires.

This is by direct analogy to IOAuthRequestToken.permission, which is the access level of the access token that will eventually be created from the request token--not anything to do with the request token itself. When a request token is exchanged for an access token, IOAuthRequestToken.permission becomes IOAuthAccessToken.permission.

I also split out IOAuthToken.date_created into the IOAuthRequestToken and IOAuthAccessToken, though the only difference is the description. It looked a little awkward to have the .date_created description say that .date_created is the date that a request token was created *or* the date an access token was created from some preexisting request token. It's easier to explain that separately for request and access tokens.

To post a comment you must log in.
Revision history for this message
Paul Hummer (rockstar) wrote :

37 @@ -254,10 +257,19 @@
38 else:
39 return None
40
41 + @property
42 + def is_expired(self):
43 + now = datetime.now(pytz.timezone('UTC'))
44 + expires = self.date_created + timedelta(hours=REQUEST_TOKEN_VALIDITY)
45 + return expires <= now
46 +
47 def review(self, user, permission, context=None):
48 """See `IOAuthRequestToken`."""
49 assert not self.is_reviewed, (
50 "Request tokens can be reviewed only once.")
51 + assert not self.is_expired, (
52 + 'This request token has expired and can no longer be reviewed.'
53 + )
54 self.date_reviewed = datetime.now(pytz.timezone('UTC'))
55 self.person = user
56 self.permission = permission
57 @@ -279,6 +291,11 @@
58 'Cannot create an access token from an unreviewed request token.')
59 assert self.permission != OAuthPermission.UNAUTHORIZED, (
60 'The user did not grant access to this consumer.')
61 + assert not self.is_expired, (
62 + 'This request token has expired and can no longer be exchanged '
63 + 'for an access token.'
64 + )
65 +

I think it's better to raise AssertionError with your text than it is to use the assert command. At least, that's the policy we've been trying to enforce.

Other than that, I think this branch is good.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/browser/oauth.py'
2--- lib/canonical/launchpad/browser/oauth.py 2010-09-20 16:45:03 +0000
3+++ lib/canonical/launchpad/browser/oauth.py 2010-10-19 14:17:46 +0000
4@@ -16,6 +16,7 @@
5 Action,
6 Actions,
7 )
8+from zope.security.interfaces import Unauthorized
9
10 from canonical.launchpad.interfaces.oauth import (
11 IOAuthConsumerSet,
12@@ -88,11 +89,13 @@
13
14 token = consumer.newRequestToken()
15 if self.request.headers.get('Accept') == HTTPResource.JSON_TYPE:
16- # Don't show the client the GRANT_PERMISSIONS access
17+ # Don't show the client the DESKTOP_INTEGRATION access
18 # level. If they have a legitimate need to use it, they'll
19 # already know about it.
20- permissions = [permission for permission in OAuthPermission.items
21- if permission != OAuthPermission.GRANT_PERMISSIONS]
22+ permissions = [
23+ permission for permission in OAuthPermission.items
24+ if (permission != OAuthPermission.DESKTOP_INTEGRATION)
25+ ]
26 return self.getJSONRepresentation(
27 permissions, token, include_secret=True)
28 return u'oauth_token=%s&oauth_token_secret=%s' % (
29@@ -102,26 +105,36 @@
30 return form.token is not None and not form.token.is_reviewed
31
32
33+def token_review_success(form, action, data):
34+ """The success callback for a button to approve a token."""
35+ form.reviewToken(action.permission)
36+
37+
38 def create_oauth_permission_actions():
39- """Return a list of `Action`s for each possible `OAuthPermission`."""
40- actions = Actions()
41- actions_excluding_grant_permissions = Actions()
42- def success(form, action, data):
43- form.reviewToken(action.permission)
44+ """Return two `Actions` objects containing each possible `OAuthPermission`.
45+
46+ The first `Actions` object contains every action supported by the
47+ OAuthAuthorizeTokenView. The second list contains a good default
48+ set of actions, omitting special permissions like DESKTOP_INTEGRATION.
49+ """
50+ all_actions = Actions()
51+ ordinary_actions = Actions()
52 for permission in OAuthPermission.items:
53 action = Action(
54- permission.title, name=permission.name, success=success,
55+ permission.title, name=permission.name,
56+ success=token_review_success,
57 condition=token_exists_and_is_not_reviewed)
58 action.permission = permission
59- actions.append(action)
60- if permission != OAuthPermission.GRANT_PERMISSIONS:
61- actions_excluding_grant_permissions.append(action)
62- return actions, actions_excluding_grant_permissions
63+ all_actions.append(action)
64+ if permission != OAuthPermission.DESKTOP_INTEGRATION:
65+ ordinary_actions.append(action)
66+ return all_actions, ordinary_actions
67+
68
69 class OAuthAuthorizeTokenView(LaunchpadFormView, JSONTokenMixin):
70 """Where users authorize consumers to access Launchpad on their behalf."""
71
72- actions, actions_excluding_grant_permissions = (
73+ actions, actions_excluding_special_permissions = (
74 create_oauth_permission_actions())
75 label = "Authorize application to access Launchpad on your behalf"
76 schema = IOAuthRequestToken
77@@ -130,7 +143,7 @@
78
79 @property
80 def visible_actions(self):
81- """Restrict the actions to the subset the client can make use of.
82+ """Restrict the actions to a subset to be presented to the client.
83
84 Not all client programs can function with all levels of
85 access. For instance, a client that needs to modify the
86@@ -150,7 +163,7 @@
87
88 allowed_permissions = self.request.form_ng.getAll('allow_permission')
89 if len(allowed_permissions) == 0:
90- return self.actions_excluding_grant_permissions
91+ return self.actions_excluding_special_permissions
92 actions = Actions()
93
94 # UNAUTHORIZED is always one of the options. If the client
95@@ -159,18 +172,59 @@
96 if OAuthPermission.UNAUTHORIZED.name in allowed_permissions:
97 allowed_permissions.remove(OAuthPermission.UNAUTHORIZED.name)
98
99- # GRANT_PERMISSIONS cannot be requested as one of several
100+ # DESKTOP_INTEGRATION cannot be requested as one of several
101 # options--it must be the only option (other than
102- # UNAUTHORIZED). If GRANT_PERMISSIONS is one of several
103+ # UNAUTHORIZED). If DESKTOP_INTEGRATION is one of several
104 # options, remove it from the list.
105- if (OAuthPermission.GRANT_PERMISSIONS.name in allowed_permissions
106+ desktop_permission = OAuthPermission.DESKTOP_INTEGRATION
107+ if (desktop_permission.name in allowed_permissions
108 and len(allowed_permissions) > 1):
109- allowed_permissions.remove(OAuthPermission.GRANT_PERMISSIONS.name)
110-
111- for action in self.actions:
112- if (action.permission.name in allowed_permissions
113- or action.permission is OAuthPermission.UNAUTHORIZED):
114- actions.append(action)
115+ allowed_permissions.remove(desktop_permission.name)
116+
117+ if desktop_permission.name in allowed_permissions:
118+ if not self.token.consumer.is_integrated_desktop:
119+ # Consumers may only ask for desktop integration if
120+ # they give a desktop type (eg. "Ubuntu") and a
121+ # user-recognizable desktop name (eg. the hostname).
122+ raise Unauthorized(
123+ ('Consumer "%s" asked for desktop integration, '
124+ "but didn't say what kind of desktop it is, or name "
125+ "the computer being integrated."
126+ % self.token.consumer.key))
127+
128+ # We're going for desktop integration. The only two
129+ # possibilities are "allow" and "deny". We'll customize
130+ # the "allow" message using the hostname provided by the
131+ # desktop.
132+ #
133+ # Since self.actions is a descriptor that returns copies
134+ # of Action objects, we can modify the actions we get
135+ # in-place without ruining the Action objects for everyone
136+ # else.
137+ desktop_name = self.token.consumer.integrated_desktop_name
138+ label = (
139+ 'Give all programs running on &quot;%s&quot; access '
140+ 'to my Launchpad account.')
141+ allow_action = [
142+ action for action in self.actions
143+ if action.name == desktop_permission.name][0]
144+ allow_action.label = label % desktop_name
145+ actions.append(allow_action)
146+
147+ # We'll customize the "deny" message as well.
148+ label = "No, thanks, I don't trust &quot;%s&quot;."
149+ deny_action = [
150+ action for action in self.actions
151+ if action.name == OAuthPermission.UNAUTHORIZED.name][0]
152+ deny_action.label = label % desktop_name
153+ actions.append(deny_action)
154+
155+ else:
156+ # We're going for web-based integration.
157+ for action in self.actions_excluding_special_permissions:
158+ if (action.permission.name in allowed_permissions
159+ or action.permission is OAuthPermission.UNAUTHORIZED):
160+ actions.append(action)
161
162 if len(list(actions)) == 1:
163 # The only visible action is UNAUTHORIZED. That means the
164@@ -179,8 +233,8 @@
165 # UNAUTHORIZED). Rather than present the end-user with an
166 # impossible situation where their only option is to deny
167 # access, we'll present the full range of actions (except
168- # for GRANT_PERMISSIONS).
169- return self.actions_excluding_grant_permissions
170+ # for special permissions like DESKTOP_INTEGRATION).
171+ return self.actions_excluding_special_permissions
172 return actions
173
174 def initialize(self):
175@@ -189,6 +243,31 @@
176 key = form.get('oauth_token')
177 if key:
178 self.token = getUtility(IOAuthRequestTokenSet).getByKey(key)
179+
180+
181+ callback = self.request.form.get('oauth_callback')
182+ if (self.token is not None
183+ and self.token.consumer.is_integrated_desktop):
184+ # Nip problems in the bud by appling special rules about
185+ # what desktop integrations are allowed to do.
186+ if callback is not None:
187+ # A desktop integration is not allowed to specify a callback.
188+ raise Unauthorized(
189+ "A desktop integration may not specify an "
190+ "OAuth callback URL.")
191+ # A desktop integration token can only have one of two
192+ # permission levels: "Desktop Integration" and
193+ # "Unauthorized". It shouldn't even be able to ask for any
194+ # other level.
195+ for action in self.visible_actions:
196+ if action.permission not in (
197+ OAuthPermission.DESKTOP_INTEGRATION,
198+ OAuthPermission.UNAUTHORIZED):
199+ raise Unauthorized(
200+ ("Desktop integration token requested a permission "
201+ '("%s") not supported for desktop-wide use.')
202+ % action.label)
203+
204 super(OAuthAuthorizeTokenView, self).initialize()
205
206 def render(self):
207
208=== modified file 'lib/canonical/launchpad/database/oauth.py'
209--- lib/canonical/launchpad/database/oauth.py 2010-10-03 15:30:06 +0000
210+++ lib/canonical/launchpad/database/oauth.py 2010-10-19 14:17:46 +0000
211@@ -15,6 +15,7 @@
212 timedelta,
213 )
214
215+import re
216 import pytz
217 from sqlobject import (
218 BoolCol,
219@@ -59,7 +60,7 @@
220 from lp.registry.interfaces.projectgroup import IProjectGroup
221
222 # How many hours should a request token be valid for?
223-REQUEST_TOKEN_VALIDITY = 12
224+REQUEST_TOKEN_VALIDITY = 2
225 # The OAuth Core 1.0 spec (http://oauth.net/core/1.0/#nonce) says that a
226 # timestamp "MUST be equal or greater than the timestamp used in previous
227 # requests," but this is likely to cause problems if the client does request
228@@ -93,6 +94,7 @@
229
230 getStore = _get_store
231
232+
233 class OAuthConsumer(OAuthBase):
234 """See `IOAuthConsumer`."""
235 implements(IOAuthConsumer)
236@@ -102,13 +104,56 @@
237 key = StringCol(notNull=True)
238 secret = StringCol(notNull=False, default='')
239
240+ # This regular expression singles out a consumer key that
241+ # represents any and all apps running on a specific computer
242+ # (usually a desktop). For instance:
243+ #
244+ # System-wide: Ubuntu desktop (hostname1)
245+ # - An Ubuntu desktop called "hostname1"
246+ # System-wide: Windows desktop (Computer Name)
247+ # - A Windows desktop called "Computer Name"
248+ # System-wide: Mac OS desktop (hostname2)
249+ # - A Macintosh desktop called "hostname2"
250+ # System-wide Android phone (Bob's Phone)
251+ # - An Android phone called "Bob's Phone"
252+ integrated_desktop_re = re.compile("^System-wide: (.*) \(([^)]*)\)$")
253+
254+ def _integrated_desktop_match_group(self, position):
255+ """Return information about a desktop integration token.
256+
257+ A convenience method that runs the desktop integration regular
258+ expression against the consumer key.
259+
260+ :param position: The match group to return if the regular
261+ expression matches.
262+
263+ :return: The value of one of the match groups, or None.
264+ """
265+ match = self.integrated_desktop_re.match(self.key)
266+ if match is None:
267+ return None
268+ return match.groups()[position]
269+
270+ @property
271+ def is_integrated_desktop(self):
272+ """See `IOAuthConsumer`."""
273+ return self.integrated_desktop_re.match(self.key) is not None
274+
275+ @property
276+ def integrated_desktop_type(self):
277+ """See `IOAuthConsumer`."""
278+ return self._integrated_desktop_match_group(0)
279+
280+ @property
281+ def integrated_desktop_name(self):
282+ """See `IOAuthConsumer`."""
283+ return self._integrated_desktop_match_group(1)
284+
285 def newRequestToken(self):
286 """See `IOAuthConsumer`."""
287 key, secret = create_token_key_and_secret(table=OAuthRequestToken)
288- date_expires = (datetime.now(pytz.timezone('UTC'))
289- + timedelta(hours=REQUEST_TOKEN_VALIDITY))
290 return OAuthRequestToken(
291- consumer=self, key=key, secret=secret, date_expires=date_expires)
292+ consumer=self, key=key, secret=secret)
293
294 def getAccessToken(self, key):
295 """See `IOAuthConsumer`."""
296@@ -177,6 +222,11 @@
297 else:
298 return None
299
300+ @property
301+ def is_expired(self):
302+ now = datetime.now(pytz.timezone('UTC'))
303+ return self.date_expires is not None and self.date_expires <= now
304+
305 def checkNonceAndTimestamp(self, nonce, timestamp):
306 """See `IOAuthAccessToken`."""
307 timestamp = float(timestamp)
308@@ -254,10 +304,21 @@
309 else:
310 return None
311
312+ @property
313+ def is_expired(self):
314+ now = datetime.now(pytz.timezone('UTC'))
315+ expires = self.date_created + timedelta(hours=REQUEST_TOKEN_VALIDITY)
316+ return expires <= now
317+
318 def review(self, user, permission, context=None):
319 """See `IOAuthRequestToken`."""
320- assert not self.is_reviewed, (
321- "Request tokens can be reviewed only once.")
322+ if self.is_reviewed:
323+ raise AssertionError(
324+ "Request tokens can be reviewed only once.")
325+ if self.is_expired:
326+ raise AssertionError(
327+ 'This request token has expired and can no longer be '
328+ 'reviewed.')
329 self.date_reviewed = datetime.now(pytz.timezone('UTC'))
330 self.person = user
331 self.permission = permission
332@@ -275,10 +336,18 @@
333
334 def createAccessToken(self):
335 """See `IOAuthRequestToken`."""
336- assert self.is_reviewed, (
337- 'Cannot create an access token from an unreviewed request token.')
338- assert self.permission != OAuthPermission.UNAUTHORIZED, (
339- 'The user did not grant access to this consumer.')
340+ if not self.is_reviewed:
341+ raise AssertionError(
342+ 'Cannot create an access token from an unreviewed request '
343+ 'token.')
344+ if self.permission == OAuthPermission.UNAUTHORIZED:
345+ raise AssertionError(
346+ 'The user did not grant access to this consumer.')
347+ if self.is_expired:
348+ raise AssertionError(
349+ 'This request token has expired and can no longer be '
350+ 'exchanged for an access token.')
351+
352 key, secret = create_token_key_and_secret(table=OAuthAccessToken)
353 access_level = AccessLevel.items[self.permission.name]
354 access_token = OAuthAccessToken(
355
356=== modified file 'lib/canonical/launchpad/doc/oauth.txt'
357--- lib/canonical/launchpad/doc/oauth.txt 2010-10-09 16:36:22 +0000
358+++ lib/canonical/launchpad/doc/oauth.txt 2010-10-19 14:17:46 +0000
359@@ -38,6 +38,45 @@
360 ...
361 AssertionError: ...
362
363+Desktop consumers
364+=================
365+
366+In a web context, each application is represented by a unique consumer
367+key. But a typical user sitting at a typical desktop (or other
368+personal computer), using multiple desktop applications that integrate
369+with Launchpad, is represented by a single consumer key. The user's
370+session as a whole is a single "consumer", and the consumer key is
371+expected to contain structured information: the type of system
372+(usually the operating system plus the word "desktop") and a string
373+that the end-user would recognize as identifying their computer.
374+
375+ >>> desktop_key = consumer_set.new(
376+ ... "System-wide: Ubuntu desktop (hostname)")
377+ >>> desktop_key.is_integrated_desktop
378+ True
379+ >>> print desktop_key.integrated_desktop_type
380+ Ubuntu desktop
381+ >>> print desktop_key.integrated_desktop_name
382+ hostname
383+
384+ >>> desktop_key = consumer_set.new(
385+ ... "System-wide: Android phone (My Phone)")
386+ >>> desktop_key.is_integrated_desktop
387+ True
388+ >>> print desktop_key.integrated_desktop_type
389+ Android phone
390+ >>> print desktop_key.integrated_desktop_name
391+ My Phone
392+
393+A normal OAuth consumer does not have this information.
394+
395+ >>> ordinary_key = consumer_set.new("Not a desktop at all.")
396+ >>> ordinary_key.is_integrated_desktop
397+ False
398+ >>> print ordinary_key.integrated_desktop_type
399+ None
400+ >>> print ordinary_key.integrated_desktop_name
401+ None
402
403 Request tokens
404 ==============
405
406=== modified file 'lib/canonical/launchpad/doc/webapp-authorization.txt'
407--- lib/canonical/launchpad/doc/webapp-authorization.txt 2010-10-09 16:36:22 +0000
408+++ lib/canonical/launchpad/doc/webapp-authorization.txt 2010-10-19 14:17:46 +0000
409@@ -79,24 +79,16 @@
410 >>> check_permission('launchpad.View', bug_1)
411 False
412
413-Now consider a principal authorized to create OAuth tokens. Whenever
414-it's not creating OAuth tokens, it has a level of permission
415-equivalent to READ_PUBLIC.
416+A token used for desktop integration has a level of permission
417+equivalent to WRITE_PUBLIC.
418
419- >>> principal.access_level = AccessLevel.GRANT_PERMISSIONS
420+ >>> principal.access_level = AccessLevel.DESKTOP_INTEGRATION
421 >>> setupInteraction(principal)
422 >>> check_permission('launchpad.View', bug_1)
423- False
424+ True
425
426 >>> check_permission('launchpad.Edit', sample_person)
427- False
428-
429-This may seem useless from a security standpoint, since once a
430-malicious client is authorized to create OAuth tokens, it can escalate
431-its privileges at any time by creating a new token for itself. The
432-security benefit is more subtle: by discouraging feature creep in
433-clients that have this super-access level, we reduce the risk that a
434-bug in a _trusted_ client will enable privilege escalation attacks.
435+ True
436
437 Users logged in through the web application have full access, which
438 means they can read/change any object they have access to.
439
440=== modified file 'lib/canonical/launchpad/interfaces/oauth.py'
441--- lib/canonical/launchpad/interfaces/oauth.py 2010-08-20 20:31:18 +0000
442+++ lib/canonical/launchpad/interfaces/oauth.py 2010-10-19 14:17:46 +0000
443@@ -64,12 +64,26 @@
444 description=_('The secret which, if not empty, should be used by the '
445 'consumer to sign its requests.'))
446
447+ is_integrated_desktop = Attribute(
448+ """This attribute is true if the consumer corresponds to a
449+ user account on a personal computer or similar device.""")
450+
451+ integrated_desktop_name = Attribute(
452+ """If the consumer corresponds to a user account on a personal
453+ computer or similar device, this is the self-reported name of
454+ the computer. If the consumer is a specific web or desktop
455+ application, this is None.""")
456+
457+ integrated_desktop_type = Attribute(
458+ """If the consumer corresponds to a user account on a personal
459+ computer or similar device, this is the self-reported type of
460+ that computer (usually the operating system plus the word
461+ "desktop"). If the consumer is a specific web or desktop
462+ application, this is None.""")
463+
464 def newRequestToken():
465 """Return a new `IOAuthRequestToken` with a random key and secret.
466
467- Also sets the token's date_expires to `REQUEST_TOKEN_VALIDITY` hours
468- from the creation date (now).
469-
470 The other attributes of the token are supposed to be set whenever the
471 user logs into Launchpad and grants (or not) access to this consumer.
472 """
473@@ -131,12 +145,6 @@
474 person = Object(
475 schema=IPerson, title=_('Person'), required=False, readonly=False,
476 description=_('The user on whose behalf the consumer is accessing.'))
477- date_created = Datetime(
478- title=_('Date created'), required=True, readonly=True)
479- date_expires = Datetime(
480- title=_('Date expires'), required=False, readonly=False,
481- description=_('From this date onwards this token can not be used '
482- 'by the consumer to access protected resources.'))
483 key = TextLine(
484 title=_('Key'), required=True, readonly=True,
485 description=_('The key used to identify this token. It is included '
486@@ -154,6 +162,12 @@
487 title=_("Distribution"), required=False, vocabulary='Distribution')
488 context = Attribute("FIXME")
489
490+ is_expired = Bool(
491+ title=_("Whether or not this token has expired."),
492+ required=False, readonly=True,
493+ description=_("A token may only be usable for a limited time, "
494+ "after which it will expire."))
495+
496
497 class IOAuthAccessToken(IOAuthToken):
498 """A token used by a consumer to access protected resources in LP.
499@@ -168,6 +182,16 @@
500 description=_('The level of access given to the application acting '
501 'on your behalf.'))
502
503+ date_created = Datetime(
504+ title=_('Date created'), required=True, readonly=True,
505+ description=_('The date some request token was exchanged for '
506+ 'this token.'))
507+
508+ date_expires = Datetime(
509+ title=_('Date expires'), required=False, readonly=False,
510+ description=_('From this date onwards this token can not be used '
511+ 'by the consumer to access protected resources.'))
512+
513 def checkNonceAndTimestamp(nonce, timestamp):
514 """Verify the nonce and timestamp.
515
516@@ -202,11 +226,22 @@
517 vocabulary=OAuthPermission,
518 description=_('The permission you give to the application which may '
519 'act on your behalf.'))
520+ date_created = Datetime(
521+ title=_('Date created'), required=True, readonly=True,
522+ description=_('The date the token was created. The request token '
523+ 'will be good for a limited time after this date.'))
524+
525+ date_expires = Datetime(
526+ title=_('Date expires'), required=False, readonly=False,
527+ description=_('The expiration date for the permission you give to '
528+ 'the application which may act on your behalf.'))
529+
530 date_reviewed = Datetime(
531 title=_('Date reviewed'), required=True, readonly=True,
532 description=_('The date in which the user authorized (or not) the '
533 'consumer to access his protected resources on '
534 'Launchpad.'))
535+
536 is_reviewed = Bool(
537 title=_('Has this token been reviewed?'),
538 required=False, readonly=True,
539
540=== modified file 'lib/canonical/launchpad/pagetests/oauth/authorize-token.txt'
541--- lib/canonical/launchpad/pagetests/oauth/authorize-token.txt 2010-10-09 16:36:22 +0000
542+++ lib/canonical/launchpad/pagetests/oauth/authorize-token.txt 2010-10-19 14:17:46 +0000
543@@ -39,32 +39,29 @@
544
545 >>> main_content = find_tag_by_id(browser.contents, 'maincontent')
546 >>> print extract_text(main_content)
547+ Integrating foobar123451432 into your Launchpad account
548 The application identified as foobar123451432 wants to access Launchpad on
549 your behalf. What level of access do you want to grant?
550 ...
551 See all applications authorized to access Launchpad on your behalf.
552
553 This page contains one submit button for each item of OAuthPermission,
554-except for 'Grant Permissions', which must be specifically requested.
555-
556- >>> browser.getControl('No Access')
557- <SubmitControl...
558- >>> browser.getControl('Read Non-Private Data')
559- <SubmitControl...
560- >>> browser.getControl('Change Non-Private Data')
561- <SubmitControl...
562- >>> browser.getControl('Read Anything')
563- <SubmitControl...
564- >>> browser.getControl('Change Anything')
565- <SubmitControl...
566-
567- >>> browser.getControl('Grant Permissions')
568- Traceback (most recent call last):
569- ...
570- LookupError: label 'Grant Permissions'
571-
572+except for 'Desktop Integration', which must be specifically requested.
573+
574+ >>> def print_access_levels(main_content):
575+ ... actions = main_content.findAll('input', attrs={'type': 'submit'})
576+ ... for action in actions:
577+ ... print action['value']
578+
579+ >>> print_access_levels(main_content)
580+ No Access
581+ Read Non-Private Data
582+ Change Non-Private Data
583+ Read Anything
584+ Change Anything
585+
586+ >>> from canonical.launchpad.webapp.interfaces import OAuthPermission
587 >>> actions = main_content.findAll('input', attrs={'type': 'submit'})
588- >>> from canonical.launchpad.webapp.interfaces import OAuthPermission
589 >>> len(actions) == len(OAuthPermission.items) - 1
590 True
591
592@@ -74,57 +71,53 @@
593 that isn't enough for the application. The user always has the option
594 to deny permission altogether.
595
596- >>> def print_access_levels(allow_permission):
597+ >>> def authorize_token_main_content(allow_permission):
598 ... browser.open(
599 ... "http://launchpad.dev/+authorize-token?%s&%s"
600 ... % (urlencode(params), allow_permission))
601- ... main_content = find_tag_by_id(browser.contents, 'maincontent')
602- ... actions = main_content.findAll('input', attrs={'type': 'submit'})
603- ... for action in actions:
604- ... print action['value']
605-
606- >>> print_access_levels(
607+ ... return find_tag_by_id(browser.contents, 'maincontent')
608+
609+ >>> def print_access_levels_for(allow_permission):
610+ ... main_content = authorize_token_main_content(allow_permission)
611+ ... print_access_levels(main_content)
612+
613+ >>> print_access_levels_for(
614 ... 'allow_permission=WRITE_PUBLIC&allow_permission=WRITE_PRIVATE')
615 No Access
616 Change Non-Private Data
617 Change Anything
618
619-The only time the 'Grant Permissions' permission shows up in this list
620-is if the client specifically requests it, and no other
621-permission. (Also requesting UNAUTHORIZED is okay--it will show up
622-anyway.)
623-
624- >>> print_access_levels('allow_permission=GRANT_PERMISSIONS')
625- No Access
626- Grant Permissions
627-
628- >>> print_access_levels(
629- ... 'allow_permission=GRANT_PERMISSIONS&allow_permission=UNAUTHORIZED')
630- No Access
631- Grant Permissions
632-
633- >>> print_access_levels(
634- ... 'allow_permission=WRITE_PUBLIC&allow_permission=GRANT_PERMISSIONS')
635- No Access
636- Change Non-Private Data
637-
638 If an application doesn't specify any valid access levels, or only
639 specifies the UNAUTHORIZED access level, Launchpad will show all the
640-access levels, except for GRANT_PERMISSIONS.
641-
642- >>> print_access_levels('')
643- No Access
644- Read Non-Private Data
645- Change Non-Private Data
646- Read Anything
647- Change Anything
648-
649- >>> print_access_levels('allow_permission=UNAUTHORIZED')
650- No Access
651- Read Non-Private Data
652- Change Non-Private Data
653- Read Anything
654- Change Anything
655+access levels, except for DESKTOP_INTEGRATION.
656+
657+ >>> print_access_levels_for('')
658+ No Access
659+ Read Non-Private Data
660+ Change Non-Private Data
661+ Read Anything
662+ Change Anything
663+
664+ >>> print_access_levels_for('allow_permission=UNAUTHORIZED')
665+ No Access
666+ Read Non-Private Data
667+ Change Non-Private Data
668+ Read Anything
669+ Change Anything
670+
671+An application may not request the DESKTOP_INTEGRATION access level
672+unless its consumer key matches a certain pattern. (Successful desktop
673+integration has its own section, below.)
674+
675+ >>> allow_permission = "allow_permission=DESKTOP_INTEGRATION"
676+ >>> browser.open(
677+ ... "http://launchpad.dev/+authorize-token?%s&%s"
678+ ... % (urlencode(params), allow_permission))
679+ Traceback (most recent call last):
680+ ...
681+ Unauthorized: Consumer "foobar123451432" asked for desktop
682+ integration, but didn't say what kind of desktop it is, or name
683+ the computer being integrated.
684
685 An application may also specify a context, so that the access granted
686 by the user is restricted to things related to that context.
687@@ -136,6 +129,7 @@
688 ... % urlencode(params_with_context))
689 >>> main_content = find_tag_by_id(browser.contents, 'maincontent')
690 >>> print extract_text(main_content)
691+ Integrating foobar123451432 into your Launchpad account
692 The application...wants to access things related to Mozilla Firefox...
693
694 A client other than a web browser may request a JSON representation of
695@@ -263,3 +257,95 @@
696 This request for accessing Launchpad on your behalf has been
697 reviewed ... ago.
698 See all applications authorized to access Launchpad on your behalf.
699+
700+Desktop integration
701+===================
702+
703+The test case given above shows how to integrate a single application
704+or website into Launchpad. But it's also possible to integrate an
705+entire desktop environment into Launchpad.
706+
707+The desktop integration option is only available for OAuth consumers
708+that say what kind of desktop they are (eg. Ubuntu) and give a name
709+that a user can identify with their computer (eg. the hostname). Here,
710+we'll create such a token.
711+
712+ >>> login('salgado@ubuntu.com')
713+ >>> desktop_key = "System-wide: Ubuntu desktop (mycomputer)"
714+ >>> consumer = getUtility(IOAuthConsumerSet).new(desktop_key)
715+ >>> token = consumer.newRequestToken()
716+ >>> logout()
717+
718+When a desktop tries to integrate with Launchpad, the user gets a
719+special warning about giving access to every program running on their
720+desktop.
721+
722+ >>> params = dict(oauth_token=token.key)
723+ >>> print extract_text(
724+ ... authorize_token_main_content(
725+ ... 'allow_permission=DESKTOP_INTEGRATION'))
726+ Integrating mycomputer into your Launchpad account
727+ The Ubuntu desktop called mycomputer wants access to your
728+ Launchpad account. If you allow the integration, all applications
729+ running on mycomputer will have read-write access to your
730+ Launchpad account, including to your private data.
731+ If you're using a public computer, if mycomputer is not the
732+ computer you're using right now, or if something just doesn't feel
733+ right about this situation, you should choose "No, thanks, I don't
734+ trust 'mycomputer'", or close this window now. You can always try
735+ again later.
736+ Even if you decide to allow the integration, you can change your
737+ mind later.
738+ See all applications authorized to access Launchpad on your behalf.
739+
740+
741+The only time the 'Desktop Integration' permission shows up in the
742+list of permissions is if the client specifically requests it, and no
743+other permission. (Also requesting UNAUTHORIZED is okay--it will show
744+up anyway.)
745+
746+ >>> print_access_levels_for('allow_permission=DESKTOP_INTEGRATION')
747+ Give all programs running on "mycomputer" access to my Launchpad account.
748+ No, thanks, I don't trust "mycomputer".
749+
750+ >>> print_access_levels_for(
751+ ... 'allow_permission=DESKTOP_INTEGRATION&allow_permission=UNAUTHORIZED')
752+ Give all programs running on "mycomputer" access to my Launchpad account.
753+ No, thanks, I don't trust "mycomputer".
754+
755+A desktop may not request a level of access other than
756+DESKTOP_INTEGRATION, since the whole point is to have a permission
757+level that specifically applies across the entire desktop.
758+
759+ >>> print_access_levels_for('allow_permission=WRITE_PRIVATE')
760+ Traceback (most recent call last):
761+ ...
762+ Unauthorized: Desktop integration token requested a permission
763+ ("Change Anything") not supported for desktop-wide use.
764+
765+ >>> print_access_levels_for(
766+ ... 'allow_permission=WRITE_PUBLIC&allow_permission=DESKTOP_INTEGRATION')
767+ Traceback (most recent call last):
768+ ...
769+ Unauthorized: Desktop integration token requested a permission
770+ ("Change Non-Private Data") not supported for desktop-wide use.
771+
772+You can't specify a callback URL when authorizing a desktop-wide
773+token, since callback URLs should only be used when integrating
774+websites into Launchpad.
775+
776+ >>> params['oauth_callback'] = 'http://launchpad.dev/bzr'
777+ >>> print_access_levels_for('allow_permission=DESKTOP_INTEGRATION')
778+ Traceback (most recent call last):
779+ ...
780+ Unauthorized: A desktop integration may not specify an OAuth
781+ callback URL.
782+
783+This is true even if the desktop token isn't asking for the
784+DESKTOP_INTEGRATION permission.
785+
786+ >>> print_access_levels_for('allow_permission=WRITE_PRIVATE')
787+ Traceback (most recent call last):
788+ ...
789+ Unauthorized: A desktop integration may not specify an OAuth
790+ callback URL.
791
792=== modified file 'lib/canonical/launchpad/templates/oauth-authorize.pt'
793--- lib/canonical/launchpad/templates/oauth-authorize.pt 2009-07-17 17:59:07 +0000
794+++ lib/canonical/launchpad/templates/oauth-authorize.pt 2010-10-19 14:17:46 +0000
795@@ -21,28 +21,69 @@
796 <tal:token-not-reviewed condition="not:token/is_reviewed">
797 <div metal:use-macro="context/@@launchpad_form/form">
798 <div metal:fill-slot="extra_top">
799- <p>The application identified as
800- <strong tal:content="token/consumer/key">consumer</strong>
801- wants to access
802- <tal:has-context condition="view/token_context">
803- things related to
804- <strong tal:content="view/token_context/title">Context</strong>
805- in
806- </tal:has-context>
807- Launchpad on your behalf. What level of access
808- do you want to grant?</p>
809-
810- <table>
811- <tr tal:repeat="action view/visible_actions">
812- <td style="text-align: right">
813- <tal:action replace="structure action/render" />
814- </td>
815- <td>
816- <span class="lesser"
817- tal:content="action/permission/description" />
818- </td>
819- </tr>
820- </table>
821+
822+ <tal:desktop-integration-token condition="token/consumer/is_integrated_desktop">
823+ <h1>Integrating
824+ <tal:hostname replace="structure
825+ token/consumer/integrated_desktop_name" />
826+ into your Launchpad account</h1>
827+ <p>The
828+ <tal:desktop replace="structure
829+ token/consumer/integrated_desktop_type" />
830+ called
831+ <strong tal:content="token/consumer/integrated_desktop_name">hostname</strong>
832+ wants access to your Launchpad account. If you allow the
833+ integration, all applications running
834+ on <strong tal:content="token/consumer/integrated_desktop_name">hostname</strong>
835+ will have read-write access to your Launchpad account,
836+ including to your private data.</p>
837+
838+ <p>If you're using a public computer, if
839+ <strong tal:content="token/consumer/integrated_desktop_name">hostname</strong>
840+ is not the computer you're using right now, or if
841+ something just doesn't feel right about this situation,
842+ you should choose "No, thanks, I don't trust
843+ '<tal:hostname replace="structure
844+ token/consumer/integrated_desktop_name" />'",
845+ or close this window now. You can always try
846+ again later.</p>
847+
848+ <p>Even if you decide to allow the integration, you can
849+ change your mind later.</p>
850+ </tal:desktop-integration-token>
851+
852+ <tal:web-integration-token condition="not:token/consumer/is_integrated_desktop">
853+ <h1>Integrating
854+ <tal:hostname replace="structure token/consumer/key" />
855+ into your Launchpad account</h1>
856+
857+ <p>The application identified as
858+ <strong tal:content="token/consumer/key">consumer</strong>
859+ wants to access
860+ <tal:has-context condition="view/token_context">
861+ things related to
862+ <strong tal:content="view/token_context/title">Context</strong>
863+ in
864+ </tal:has-context>
865+ Launchpad on your behalf. What level of access
866+ do you want to grant?</p>
867+ </tal:web-integration-token>
868+
869+ <table>
870+ <tr tal:repeat="action view/visible_actions">
871+ <td style="text-align: right">
872+ <tal:action replace="structure action/render" />
873+ </td>
874+
875+ <tal:web-integration-token
876+ condition="not:token/consumer/is_integrated_desktop">
877+ <td>
878+ <span class="lesser"
879+ tal:content="action/permission/description" />
880+ </td>
881+ </tal:web-integration-token>
882+ </tr>
883+ </table>
884 </div>
885
886 <div metal:fill-slot="extra_bottom">
887
888=== added file 'lib/canonical/launchpad/tests/test_oauth_tokens.py'
889--- lib/canonical/launchpad/tests/test_oauth_tokens.py 1970-01-01 00:00:00 +0000
890+++ lib/canonical/launchpad/tests/test_oauth_tokens.py 2010-10-19 14:17:46 +0000
891@@ -0,0 +1,47 @@
892+# Copyright 2010 Canonical Ltd. This software is licensed under the
893+# GNU Affero General Public License version 3 (see the file LICENSE).
894+
895+from datetime import (
896+ datetime,
897+ timedelta
898+ )
899+import pytz
900+
901+from canonical.launchpad.webapp.interfaces import OAuthPermission
902+from canonical.testing.layers import DatabaseFunctionalLayer
903+
904+from lp.testing import (
905+ TestCaseWithFactory,
906+ )
907+
908+
909+class TestRequestTokens(TestCaseWithFactory):
910+
911+ layer = DatabaseFunctionalLayer
912+
913+ def setUp(self):
914+ """Set up a dummy person and OAuth consumer."""
915+ super(TestRequestTokens, self).setUp()
916+
917+ self.person = self.factory.makePerson()
918+ self.consumer = self.factory.makeOAuthConsumer()
919+
920+ now = datetime.now(pytz.timezone('UTC'))
921+ self.a_long_time_ago = now - timedelta(hours=1000)
922+
923+ def testExpiredRequestTokenCantBeReviewed(self):
924+ """An expired request token can't be reviewed."""
925+ token = self.factory.makeOAuthRequestToken(
926+ date_created=self.a_long_time_ago)
927+ self.assertRaises(
928+ AssertionError, token.review, self.person,
929+ OAuthPermission.WRITE_PUBLIC)
930+
931+ def testExpiredRequestTokenCantBeExchanged(self):
932+ """An expired request token can't be exchanged for an access token.
933+
934+ This can only happen if the token was reviewed before it expired.
935+ """
936+ token = self.factory.makeOAuthRequestToken(
937+ date_created=self.a_long_time_ago, reviewed_by=self.person)
938+ self.assertRaises(AssertionError, token.createAccessToken)
939
940=== modified file 'lib/canonical/launchpad/webapp/authorization.py'
941--- lib/canonical/launchpad/webapp/authorization.py 2010-08-20 20:31:18 +0000
942+++ lib/canonical/launchpad/webapp/authorization.py 2010-10-19 14:17:46 +0000
943@@ -61,7 +61,8 @@
944 lp_permission = getUtility(ILaunchpadPermission, permission)
945 if lp_permission.access_level == "write":
946 required_access_level = [
947- AccessLevel.WRITE_PUBLIC, AccessLevel.WRITE_PRIVATE]
948+ AccessLevel.WRITE_PUBLIC, AccessLevel.WRITE_PRIVATE,
949+ AccessLevel.DESKTOP_INTEGRATION]
950 if access_level not in required_access_level:
951 return False
952 elif lp_permission.access_level == "read":
953@@ -80,7 +81,8 @@
954 access to private objects, return False. Return True otherwise.
955 """
956 private_access_levels = [
957- AccessLevel.READ_PRIVATE, AccessLevel.WRITE_PRIVATE]
958+ AccessLevel.READ_PRIVATE, AccessLevel.WRITE_PRIVATE,
959+ AccessLevel.DESKTOP_INTEGRATION]
960 if access_level in private_access_levels:
961 # The user has access to private objects. Return early,
962 # before checking whether the object is private, since
963
964=== modified file 'lib/canonical/launchpad/webapp/interfaces.py'
965--- lib/canonical/launchpad/webapp/interfaces.py 2010-09-24 09:42:01 +0000
966+++ lib/canonical/launchpad/webapp/interfaces.py 2010-10-19 14:17:46 +0000
967@@ -531,14 +531,13 @@
968 for reading and changing anything, including private data.
969 """)
970
971- GRANT_PERMISSIONS = DBItem(60, """
972- Grant Permissions
973+ DESKTOP_INTEGRATION = DBItem(60, """
974+ Desktop Integration
975
976- The application will be able to grant access to your Launchpad
977- account to any other application. This is a very powerful
978- level of access. You should not grant this level of access to
979- any application except the official Launchpad credential
980- manager.
981+ Every application running on your desktop will have read-write
982+ access to your Launchpad account, including to your private
983+ data. You should not allow this unless you trust the computer
984+ you're using right now.
985 """)
986
987 class AccessLevel(DBEnumeratedType):
988
989=== modified file 'lib/canonical/launchpad/webapp/servers.py'
990--- lib/canonical/launchpad/webapp/servers.py 2010-09-28 07:00:56 +0000
991+++ lib/canonical/launchpad/webapp/servers.py 2010-10-19 14:17:46 +0000
992@@ -8,7 +8,6 @@
993 __metaclass__ = type
994
995 import cgi
996-from datetime import datetime
997 import threading
998 import xmlrpclib
999
1000@@ -1277,10 +1276,9 @@
1001 token.checkNonceAndTimestamp(nonce, timestamp)
1002 except (NonceAlreadyUsed, TimestampOrderingError, ClockSkew), e:
1003 raise Unauthorized('Invalid nonce/timestamp: %s' % e)
1004- now = datetime.now(pytz.timezone('UTC'))
1005 if token.permission == OAuthPermission.UNAUTHORIZED:
1006 raise Unauthorized('Unauthorized token (%s).' % token.key)
1007- elif token.date_expires is not None and token.date_expires <= now:
1008+ elif token.is_expired:
1009 raise Unauthorized('Expired token (%s).' % token.key)
1010 elif not check_oauth_signature(request, consumer, token):
1011 raise Unauthorized('Invalid signature.')
1012
1013=== modified file 'lib/lp/archiveuploader/nascentuploadfile.py'
1014--- lib/lp/archiveuploader/nascentuploadfile.py 2010-10-02 11:41:43 +0000
1015+++ lib/lp/archiveuploader/nascentuploadfile.py 2010-10-19 14:17:46 +0000
1016@@ -50,8 +50,8 @@
1017 from lp.soyuz.enums import (
1018 BinaryPackageFormat,
1019 PackagePublishingPriority,
1020+ PackagePublishingStatus,
1021 PackageUploadCustomFormat,
1022- PackageUploadStatus,
1023 )
1024 from lp.soyuz.interfaces.binarypackagename import IBinaryPackageNameSet
1025 from lp.soyuz.interfaces.component import IComponentSet
1026@@ -781,11 +781,9 @@
1027 # Database relationship methods
1028 #
1029 def findSourcePackageRelease(self):
1030- """Return the respective ISourcePackagRelease for this binary upload.
1031+ """Return the respective ISourcePackageRelease for this binary upload.
1032
1033- It inspect publication in the targeted DistroSeries and also the
1034- ACCEPTED queue for sources matching stored
1035- (source_name, source_version).
1036+ It inspect publication in the targeted DistroSeries.
1037
1038 It raises UploadError if the source was not found.
1039
1040@@ -793,43 +791,19 @@
1041 mixed_uploads (source + binary) we do not have the source stored
1042 in DB yet (see verifySourcepackagerelease).
1043 """
1044+ assert self.source_name is not None
1045+ assert self.source_version is not None
1046 distroseries = self.policy.distroseries
1047 spphs = distroseries.getPublishedSources(
1048 self.source_name, version=self.source_version,
1049 include_pending=True, archive=self.policy.archive)
1050
1051- sourcepackagerelease = None
1052- if spphs:
1053- # We know there's only going to be one release because
1054- # version is unique.
1055- assert spphs.count() == 1, "Duplicated ancestry"
1056- sourcepackagerelease = spphs[0].sourcepackagerelease
1057- else:
1058- # XXX cprov 2006-08-09 bug=55774: Building from ACCEPTED is
1059- # special condition, not really used in production. We should
1060- # remove the support for this use case.
1061- self.logger.debug(
1062- "No source published, checking the ACCEPTED queue")
1063-
1064- queue_candidates = distroseries.getQueueItems(
1065- status=PackageUploadStatus.ACCEPTED,
1066- name=self.source_name, version=self.source_version,
1067- archive=self.policy.archive, exact_match=True)
1068-
1069- for queue_item in queue_candidates:
1070- if queue_item.sources.count():
1071- sourcepackagerelease = queue_item.sourcepackagerelease
1072-
1073- if sourcepackagerelease is None:
1074- # At this point, we can't really do much more to try
1075- # building this package. If we look in the NEW queue it is
1076- # possible that multiple versions of the package exist there
1077- # and we know how bad that can be. Time to give up!
1078+ if spphs.count() == 0:
1079 raise UploadError(
1080 "Unable to find source package %s/%s in %s" % (
1081 self.source_name, self.source_version, distroseries.name))
1082
1083- return sourcepackagerelease
1084+ return spphs[0].sourcepackagerelease
1085
1086 def verifySourcePackageRelease(self, sourcepackagerelease):
1087 """Check if the given ISourcePackageRelease matches the context."""
1088
1089=== modified file 'lib/lp/archiveuploader/tests/nascentupload-epoch-handling.txt'
1090--- lib/lp/archiveuploader/tests/nascentupload-epoch-handling.txt 2009-04-17 10:32:16 +0000
1091+++ lib/lp/archiveuploader/tests/nascentupload-epoch-handling.txt 2010-10-19 14:17:46 +0000
1092@@ -208,6 +208,14 @@
1093 >>> bar_spr.version
1094 u'1:1.0-9'
1095
1096+ >>> from lp.registry.interfaces.pocket import PackagePublishingPocket
1097+ >>> from lp.soyuz.interfaces.publishing import IPublishingSet
1098+ >>> getUtility(IPublishingSet).newSourcePublication(
1099+ ... bar_src_upload.policy.distro.main_archive, bar_spr,
1100+ ... bar_src_upload.policy.distroseries, bar_spr.component,
1101+ ... bar_spr.section, PackagePublishingPocket.RELEASE)
1102+ <SourcePackagePublishingHistory at ...>
1103+
1104 Let's accept the source and claim 'build from accepted' to process the
1105 respective binary:
1106
1107@@ -217,9 +225,6 @@
1108 >>> bar_src_queue.status.name
1109 'ACCEPTED'
1110
1111- >>> from canonical.launchpad.ftests import syncUpdate
1112- >>> syncUpdate(bar_src_queue)
1113-
1114 For a binary upload we expect the same, a BinaryPackageRelease
1115 'version' that includes 'epoch':
1116
1117
1118=== modified file 'lib/lp/archiveuploader/tests/nascentupload.txt'
1119--- lib/lp/archiveuploader/tests/nascentupload.txt 2010-10-14 18:42:19 +0000
1120+++ lib/lp/archiveuploader/tests/nascentupload.txt 2010-10-19 14:17:46 +0000
1121@@ -484,86 +484,6 @@
1122 1
1123
1124
1125-=== Building From ACCEPTED queue ===
1126-
1127-XXX cprov 20060728: Building from ACCEPTED is special condition, not
1128-really used in production. We should remove the support for this use
1129-case, see further info in bug #55774.
1130-
1131-Next we send in the binaries, since the source should be in ACCEPTED the
1132-binary should go straight there too. Note that we are not specifying
1133-any build, so it should be created appropriately, it is what happens
1134-with staged security uploads:
1135-
1136-XXX cprov 20070404: we are using a modified sync policy because we
1137-need unsigned changes and binaries uploads (same as security, but also
1138-accepts non-security uploads)
1139-
1140- >>> from lp.archiveuploader.uploadpolicy import ArchiveUploadType
1141- >>> modified_sync_policy = getPolicy(
1142- ... name='sync', distro='ubuntu', distroseries='hoary')
1143- >>> modified_sync_policy.accepted_type = ArchiveUploadType.BINARY_ONLY
1144-
1145- >>> ed_bin = NascentUpload.from_changesfile_path(
1146- ... datadir('split-upload-test/ed_0.2-20_i386.changes'),
1147- ... modified_sync_policy, mock_logger_quiet)
1148-
1149- >>> ed_bin.process()
1150- >>> ed_bin.is_rejected
1151- False
1152-
1153- >>> success = ed_bin.do_accept()
1154-
1155- >>> ed_bin.queue_root.status.name
1156- 'NEW'
1157-
1158-A build was created to represent the relationship between ed_src
1159-(waiting in ACCEPTED queue) and the just uploaded ed_bin:
1160-
1161- >>> ed_build = ed_bin.queue_root.builds[0].build
1162-
1163- >>> ed_spr.id == ed_build.source_package_release.id
1164- True
1165-
1166- >>> ed_build.title
1167- u'i386 build of ed 0.2-20 in ubuntu hoary RELEASE'
1168-
1169- >>> ed_build.status.name
1170- 'FULLYBUILT'
1171-
1172-Binary control file attributes are stored as text in the
1173-BinaryPackageRelease record.
1174-
1175- >>> ed_bpr = ed_build.binarypackages[0]
1176-
1177- >>> ed_bpr.depends
1178- u'libc6 (>= 2.6-1)'
1179-
1180- >>> ed_bpr.suggests
1181- u'emacs'
1182-
1183- >>> ed_bpr.conflicts
1184- u'vi'
1185-
1186- >>> ed_bpr.replaces
1187- u'vim'
1188-
1189- >>> ed_bpr.provides
1190- u'ed'
1191-
1192- >>> ed_bpr.essential
1193- False
1194-
1195- >>> ed_bpr.pre_depends
1196- u'dpkg'
1197-
1198- >>> ed_bpr.enhances
1199- u'bash'
1200-
1201- >>> ed_bpr.breaks
1202- u'emacs'
1203-
1204-
1205 === Staged Source and Binary upload with multiple binaries ===
1206
1207 As we could see both, sources and binaries, get into Launchpad via
1208@@ -613,12 +533,18 @@
1209 >>> multibar_src_queue.status.name
1210 'ACCEPTED'
1211
1212-We can just assume the source was published by step 3 and 4 for
1213-simplicity and claim 'build from ACCEPTED' feature.
1214+Then the source gets accepted and published, step 3 and 4:
1215+
1216+ >>> from lp.registry.interfaces.pocket import PackagePublishingPocket
1217+ >>> from lp.soyuz.interfaces.publishing import IPublishingSet
1218+ >>> getUtility(IPublishingSet).newSourcePublication(
1219+ ... multibar_src_queue.archive, multibar_spr,
1220+ ... sync_policy.distroseries, multibar_spr.component,
1221+ ... multibar_spr.section, PackagePublishingPocket.RELEASE)
1222+ <SourcePackagePublishingHistory at ...>
1223
1224 Build creation is done based on the SourcePackageRelease object, step 5:
1225
1226- >>> from lp.registry.interfaces.pocket import PackagePublishingPocket
1227 >>> multibar_build = multibar_spr.createBuild(
1228 ... hoary['i386'], PackagePublishingPocket.RELEASE,
1229 ... multibar_src_queue.archive)
1230@@ -636,8 +562,8 @@
1231 and the collection of DEB files produced.
1232
1233 At this point slave-scanner moves the upload to the appropriate path
1234-(/srv/launchpad.net/builddmaster) and invokes process-upload.py with
1235-the 'buildd' upload policy and the build record id.
1236+(/srv/launchpad.net/builddmaster). A cron job invokes process-upload.py
1237+with the 'buildd' upload policy and processes all files in that directory.
1238
1239 >>> buildd_policy = getPolicy(
1240 ... name='buildd', distro='ubuntu', distroseries='hoary')
1241
1242=== modified file 'lib/lp/archiveuploader/tests/test_buildduploads.py'
1243--- lib/lp/archiveuploader/tests/test_buildduploads.py 2010-10-06 11:46:51 +0000
1244+++ lib/lp/archiveuploader/tests/test_buildduploads.py 2010-10-19 14:17:46 +0000
1245@@ -20,6 +20,7 @@
1246 )
1247 from lp.registry.interfaces.distribution import IDistributionSet
1248 from lp.registry.interfaces.pocket import PackagePublishingPocket
1249+from lp.soyuz.interfaces.publishing import IPublishingSet
1250 from lp.soyuz.model.binarypackagebuild import BinaryPackageBuild
1251
1252
1253@@ -195,6 +196,13 @@
1254 real_policy = self.policy
1255 self.policy = 'insecure'
1256 super(TestBuilddUploads, self).setUp()
1257+ # Publish the source package release so it can be found by
1258+ # NascentUploadFile.findSourcePackageRelease().
1259+ spr = self.source_queue.sources[0].sourcepackagerelease
1260+ getUtility(IPublishingSet).newSourcePublication(
1261+ self.distroseries.main_archive, spr,
1262+ self.distroseries, spr.component,
1263+ spr.section, PackagePublishingPocket.RELEASE)
1264 self.policy = real_policy
1265
1266 def _publishBuildQueueItem(self, queue_item):
1267
1268=== modified file 'lib/lp/archiveuploader/tests/test_nascentuploadfile.py'
1269--- lib/lp/archiveuploader/tests/test_nascentuploadfile.py 2010-10-06 11:46:51 +0000
1270+++ lib/lp/archiveuploader/tests/test_nascentuploadfile.py 2010-10-19 14:17:46 +0000
1271@@ -25,7 +25,10 @@
1272 from lp.registry.interfaces.pocket import PackagePublishingPocket
1273 from lp.archiveuploader.tests import AbsolutelyAnythingGoesUploadPolicy
1274 from lp.buildmaster.enums import BuildStatus
1275-from lp.soyuz.enums import PackageUploadCustomFormat
1276+from lp.soyuz.enums import (
1277+ PackageUploadCustomFormat,
1278+ PackagePublishingStatus,
1279+ )
1280 from lp.testing import TestCaseWithFactory
1281
1282
1283@@ -387,3 +390,68 @@
1284 "foo_0.42_i386.deb", "main/python", "unknown", "mypkg", "0.42",
1285 None)
1286 self.assertRaises(UploadError, uploadfile.checkBuild, build)
1287+
1288+ def test_findSourcePackageRelease(self):
1289+ # findSourcePackageRelease finds the matching SourcePackageRelease.
1290+ das = self.factory.makeDistroArchSeries(
1291+ distroseries=self.policy.distroseries, architecturetag="i386")
1292+ build = self.factory.makeBinaryPackageBuild(
1293+ distroarchseries=das,
1294+ archive=self.policy.archive)
1295+ uploadfile = self.createDebBinaryUploadFile(
1296+ "foo_0.42_i386.deb", "main/python", "unknown", "mypkg", "0.42",
1297+ None)
1298+ spph = self.factory.makeSourcePackagePublishingHistory(
1299+ sourcepackagename=self.factory.makeSourcePackageName("foo"),
1300+ distroseries=self.policy.distroseries,
1301+ version="0.42", archive=self.policy.archive)
1302+ control = self.getBaseControl()
1303+ control["Source"] = "foo"
1304+ uploadfile.parseControl(control)
1305+ self.assertEquals(
1306+ spph.sourcepackagerelease, uploadfile.findSourcePackageRelease())
1307+
1308+ def test_findSourcePackageRelease_no_spph(self):
1309+ # findSourcePackageRelease raises UploadError if there is no
1310+ # SourcePackageRelease.
1311+ das = self.factory.makeDistroArchSeries(
1312+ distroseries=self.policy.distroseries, architecturetag="i386")
1313+ build = self.factory.makeBinaryPackageBuild(
1314+ distroarchseries=das,
1315+ archive=self.policy.archive)
1316+ uploadfile = self.createDebBinaryUploadFile(
1317+ "foo_0.42_i386.deb", "main/python", "unknown", "mypkg", "0.42",
1318+ None)
1319+ control = self.getBaseControl()
1320+ control["Source"] = "foo"
1321+ uploadfile.parseControl(control)
1322+ self.assertRaises(UploadError, uploadfile.findSourcePackageRelease)
1323+
1324+ def test_findSourcePackageRelease_multiple_sprs(self):
1325+ # findSourcePackageRelease finds the last uploaded
1326+ # SourcePackageRelease and can deal with multiple pending source
1327+ # package releases.
1328+ das = self.factory.makeDistroArchSeries(
1329+ distroseries=self.policy.distroseries, architecturetag="i386")
1330+ build = self.factory.makeBinaryPackageBuild(
1331+ distroarchseries=das,
1332+ archive=self.policy.archive)
1333+ uploadfile = self.createDebBinaryUploadFile(
1334+ "foo_0.42_i386.deb", "main/python", "unknown", "mypkg", "0.42",
1335+ None)
1336+ spn = self.factory.makeSourcePackageName("foo")
1337+ spph1 = self.factory.makeSourcePackagePublishingHistory(
1338+ sourcepackagename=spn,
1339+ distroseries=self.policy.distroseries,
1340+ version="0.42", archive=self.policy.archive,
1341+ status=PackagePublishingStatus.PUBLISHED)
1342+ spph2 = self.factory.makeSourcePackagePublishingHistory(
1343+ sourcepackagename=spn,
1344+ distroseries=self.policy.distroseries,
1345+ version="0.42", archive=self.policy.archive,
1346+ status=PackagePublishingStatus.PENDING)
1347+ control = self.getBaseControl()
1348+ control["Source"] = "foo"
1349+ uploadfile.parseControl(control)
1350+ self.assertEquals(
1351+ spph2.sourcepackagerelease, uploadfile.findSourcePackageRelease())
1352
1353=== modified file 'lib/lp/testing/factory.py'
1354--- lib/lp/testing/factory.py 2010-10-18 21:18:03 +0000
1355+++ lib/lp/testing/factory.py 2010-10-19 14:17:46 +0000
1356@@ -79,6 +79,7 @@
1357 EmailAddressStatus,
1358 IEmailAddressSet,
1359 )
1360+from canonical.launchpad.interfaces.oauth import IOAuthConsumerSet
1361 from canonical.launchpad.interfaces.gpghandler import IGPGHandler
1362 from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
1363 from canonical.launchpad.interfaces.librarian import ILibraryFileAliasSet
1364@@ -91,6 +92,7 @@
1365 DEFAULT_FLAVOR,
1366 IStoreSelector,
1367 MAIN_STORE,
1368+ OAuthPermission,
1369 )
1370 from lp.app.enums import ServiceUsage
1371 from lp.archiveuploader.dscfile import DSCFile
1372@@ -3165,6 +3167,33 @@
1373 to_source=to_source, date_fulfilled=date_fulfilled,
1374 status=status, diff_content=lfa))
1375
1376+ # Factory methods for OAuth tokens.
1377+ def makeOAuthConsumer(self, key=None, secret=None):
1378+ if key is None:
1379+ key = self.getUniqueString("oauthconsumerkey")
1380+ if secret is None:
1381+ secret = ''
1382+ return getUtility(IOAuthConsumerSet).new(key, secret)
1383+
1384+ def makeOAuthRequestToken(
1385+ self, consumer=None, date_created=None, reviewed_by=None,
1386+ access_level=OAuthPermission.READ_PUBLIC):
1387+ """Create a (possibly reviewed) OAuth request token."""
1388+ if consumer is None:
1389+ consumer = self.makeOAuthConsumer()
1390+ token = consumer.newRequestToken()
1391+
1392+ if reviewed_by is not None:
1393+ # Review the token before modifying the date_created,
1394+ # since the date_created can be used to simulate an
1395+ # expired token.
1396+ token.review(reviewed_by, access_level)
1397+
1398+ if date_created is not None:
1399+ unwrapped_token = removeSecurityProxy(token)
1400+ unwrapped_token.date_created = date_created
1401+ return token
1402+
1403
1404 # Some factory methods return simple Python types. We don't add
1405 # security wrappers for them, as well as for objects created by

Subscribers

People subscribed via source and target branches

to status/vote changes: