Merge lp:~lifeless/launchpad/private-librarian into lp:launchpad/db-devel

Proposed by Robert Collins
Status: Merged
Approved by: Stuart Bishop
Approved revision: no longer in the source branch.
Merged at revision: 9757
Proposed branch: lp:~lifeless/launchpad/private-librarian
Merge into: lp:launchpad/db-devel
Diff against target: 1731 lines (+804/-148)
29 files modified
database/schema/launchpad_session.sql (+16/-0)
lib/canonical/database/sqlbase.py (+10/-2)
lib/canonical/launchpad/browser/librarian.py (+88/-12)
lib/canonical/launchpad/database/ftests/test_timelimitedtoken.py (+29/-0)
lib/canonical/launchpad/database/librarian.py (+69/-1)
lib/canonical/launchpad/doc/librarian.txt (+175/-24)
lib/canonical/launchpad/ftests/test_libraryfilealias.py (+1/-6)
lib/canonical/launchpad/interfaces/librarian.py (+5/-1)
lib/canonical/launchpad/interfaces/lpstorm.py (+0/-1)
lib/canonical/launchpad/scripts/garbo.py (+47/-5)
lib/canonical/launchpad/scripts/tests/test_garbo.py (+23/-2)
lib/canonical/launchpad/utilities/looptuner.py (+4/-0)
lib/canonical/launchpad/webapp/session.py (+2/-1)
lib/canonical/launchpad/zcml/librarian.zcml (+5/-0)
lib/canonical/librarian/client.py (+37/-9)
lib/canonical/librarian/db.py (+33/-5)
lib/canonical/librarian/ftests/test_db.py (+14/-12)
lib/canonical/librarian/ftests/test_storage.py (+3/-2)
lib/canonical/librarian/ftests/test_web.py (+157/-25)
lib/canonical/librarian/interfaces.py (+13/-2)
lib/canonical/librarian/storage.py (+2/-2)
lib/canonical/librarian/web.py (+37/-9)
lib/canonical/testing/layers.py (+6/-4)
lib/lp/bugs/browser/bugattachment.py (+1/-16)
lib/lp/bugs/browser/configure.zcml (+0/-4)
lib/lp/bugs/browser/tests/test_bugattachment_file_access.py (+17/-2)
lib/lp/scripts/utilities/importfascist.py (+1/-0)
lib/lp/services/features/webapp.py (+8/-0)
lib/lp/testing/fakelibrarian.py (+1/-1)
To merge this branch: bzr merge lp:~lifeless/launchpad/private-librarian
Reviewer Review Type Date Requested Status
Stuart Bishop (community) Approve
Graham Binns (community) release-critical Approve
Launchpad code reviewers Pending
Review via email: mp+31020@code.launchpad.net

Commit message

Introduce a public restricted librarian uses a time limited token approach.

Description of the change

The basic idea is to have an https librarian that uses an access token for a time limited period, rather than proxying on the appservers which is terrible in several ways that aren't all that relevant except to say its hard to improve and incompatible with our peformance goals.

So in this model, we hand out a token when someone (including wget) accesses a private attachment on launchpad, and issue a temporary redirect (over ssl) to https://iLFAID.launchpadlibrarian.net/...file?token=xxxxx

The token goes in the session DB, the garbo cleans that up, and we all are happy happy happy.

Oh, and the librarian rejects requests without a token for private files.

We can't use OAuth because then the OAuth token would be attackable by content in the private librarian.

RT 41202 contains the request for wildcard DNS keys.

The remaining work to make this fully reviewable is to:
 - profit

Folk wanting to test or collaborate on this branch should:
 - dropdb session_dev
 - dropdb session_ftest
 - pull the branch
 - run 'make schema' which will create the timelimitedtoken table in your session dbs.

To post a comment you must log in.
Revision history for this message
Stuart Bishop (stub) wrote :

The idea seems good.

I don't see why the table should exist in the session database. It should exist in the main database. Putting it in the session database just doubles the number of database connections the Librarian and garbage collector need to maintain, and makes things a maintenance pain as that database has to have schema changes and database permissions applied manually to the primary and fail over databases. This table will get comparatively little traffic, so scalability issues are a non-issue.

I believe the blessed import location for the Storm class is from storm.locals.

Should we be storing the expiry timestamp? I'm wondering if the length of time a token is valid for should be a function of the file size, or if it will be different for different parts of Launchpad.

The index on (url, token) is redundant, as it is created implicitly by the primary key constraint.

Do we actually need to store url and token separately, or just a single url column that includes the query string?

Do we want to store the url, or just the path segment? Storing just the path segment would avoid ambiguities between host and host:port, and might future proof things a bit if our SSL front ends get distributed around the world on different domain names.

Are their any magic headers the Librarian can emit so if a launchpadlibrarian.net URL is bookmarked, the original launchpad.net URL is bookmarked instead? Not terribly important, but if we can do this we will need to store the source URL too as the Librarian has no way of reconstructing it.

review: Needs Information
Revision history for this message
Stuart Bishop (stub) wrote :

So one thing we will need to do is raise an exception if we try to generate a secure url as an anonymous user. This is a safety net, as I don't think we are doing this at the moment. It will stop accidentally ending up with stale URLs in the Squid caches.

I think I understand why the session store was (ab)used here - it runs in autocommit mode and we rollback the database connections on GET requests to ensure they are idempotent. I still don't like it but can't really offer a better solution with our existing infrastructure except perhaps using memcached. Certainly session.sql should not be modified though, with all the new stuff going into launchpad_session.sql (session.sql is what should have been sent upstream a few years ago).

Revision history for this message
Robert Collins (lifeless) wrote :

I used the session DB because this stuff is transient : memcached might be an answer too but the token *system* has to be available or private file downloads will fail, and memcached is currently (AIUI) a 'when its there use it' system - which is fine. Replicating the tokens isn't terribly useful given we're just going to delete them again, but all of that is a bit beside the point : yes, we do need to commit changes in get requests :).

Will move the definitions to launchpad_session.sql.

We could store the expiry time, but I figured we'd make the lifetime a config option and thus work from the creation point rather than expiry.

I figured we might want reporting on stuff in the table - thats why I used url, token, rather than a single column. Easy to change if you have a strong opinion any which way - I don't.

I can't answer the url/path segment question yet : once its fully hooked up it should be obvious what is easiest. The current schema is what was easiest based on the attributes of the objects I am using (LibraryFileAlias etc).

We could make the librarian offer a redirect *back* to launchpad for expired tokens, rather than a go-away message - adding the source canonical_url to the table would permit that trivially.

Re: exceptions for anonymous users calling TimeLimitedToken.allocate - I can add that in, is there some existing similar safety need I can reference?

-Rob

Revision history for this message
Stuart Bishop (stub) wrote :

On Wed, Jul 28, 2010 at 5:08 AM, Robert Collins
<email address hidden> wrote:

> I used the session DB because this stuff is transient : memcached might be an answer too but the token *system* has to be available or private file downloads will fail, and memcached is currently (AIUI) a 'when its there use it' system - which is fine. Replicating the tokens isn't terribly useful given we're just going to delete them again, but all of that is a bit beside the point : yes, we do need to commit changes in get requests :).

Right. We should consider promoting memcached to an always-on service
as we have tripped over a few cases where this would be desirable.
That is a separate task though. Stuffing this in the session database
seems the best fit for now.

> Will move the definitions to launchpad_session.sql.

Ta.

> We could store the expiry time, but I figured we'd make the lifetime a config option and thus work from the creation point rather than expiry.
>
> I figured we might want reporting on stuff in the table - thats why I used url, token, rather than a single column. Easy to change if you have a strong opinion any which way - I don't.
>
> I can't answer the url/path segment question yet : once its fully hooked up it should be obvious what is easiest. The current schema is what was easiest based on the attributes of the objects I am using (LibraryFileAlias etc).

I suspect the host and port is just noise and we only want the path.
We have a complex environment, and relying on the URLs seems fragile
so I personally would feel more secure if we just stored the path.

> We could make the librarian offer a redirect *back* to launchpad for expired tokens, rather than a go-away message - adding the source canonical_url to the table would permit that trivially.

That sounds like a good idea.

> Re: exceptions for anonymous users calling TimeLimitedToken.allocate - I can add that in, is there some existing similar safety need I can reference?

If we offer the redirect back on expired tokens, there is no need for
this. My concern was expired tokens getting stuck in caches and Squid
caching Anonymous connections was the first I thought off.

After going over bug lists with Gary last night, this will solve a lot
of outstanding Librarian issues.

--
Stuart Bishop <email address hidden>
http://www.stuartbishop.net/

Revision history for this message
Stuart Bishop (stub) wrote :

So files served from the same domain can steal content from files served from the same domain. We don't care for public information, but this isn't cool for private.

One way around this is to serve every file from a unique domain. Changing the URL structure from https://launchpadlibrarian.net/123/file.txt to https://i123.launchpadlibrarian.net/file.txt will do this, and an Apache rewrite rule can be used to redirect old URLs to the new URL.

Revision history for this message
Graham Binns (gmb) :
review: Approve (release-critical)
Revision history for this message
Stuart Bishop (stub) wrote :

per IRC discussion

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'database/schema/launchpad_session.sql'
--- database/schema/launchpad_session.sql 2009-06-24 21:17:33 +0000
+++ database/schema/launchpad_session.sql 2010-09-07 04:07:54 +0000
@@ -14,3 +14,19 @@
14GRANT EXECUTE ON FUNCTION14GRANT EXECUTE ON FUNCTION
15 set_session_pkg_data(text, text, text, bytea) TO session;15 set_session_pkg_data(text, text, text, bytea) TO session;
1616
17CREATE TABLE TimeLimitedToken (
18 path text NOT NULL,
19 token text NOT NULL,
20 created timestamp without time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
21 constraint timelimitedtoken_pky primary key (path, token)
22 ) WITHOUT OIDS;
23COMMENT ON TABLE TimeLimitedToken IS 'stores tokens for granting access to a single path in the librarian for a short while. The garbo takes care of cleanups, and we should only have a few thousand at a time. Tokens are handed out just-in-time on the appserver, when a client attempts to dereference a private thing which we do not want to deliver in-line. OAuth tokens cannot be used for the launchpadlibrarian content because they would then be attackable. See lib.canonical.database.librarian for the python class.';
24-- Give the garbo an efficient selection to cleanup
25CREATE INDEX timelimitedtoken_created ON TimeLimitedToken(created);
26-- Give the librarian an efficient lookup
27CREATE INDEX timelimitedtoken_path_token ON TimeLimitedToken(path, token);
28
29-- Let the session user access file access tokens.
30GRANT SELECT, INSERT, UPDATE, DELETE ON TimeLimitedToken TO session;
31-- And the garbo needs to run on it too.
32GRANT SELECT, DELETE ON TimeLimitedToken TO session;
1733
=== modified file 'lib/canonical/database/sqlbase.py'
--- lib/canonical/database/sqlbase.py 2010-08-24 10:45:57 +0000
+++ lib/canonical/database/sqlbase.py 2010-09-07 04:07:54 +0000
@@ -27,11 +27,12 @@
27 'RandomiseOrderDescriptor',27 'RandomiseOrderDescriptor',
28 'reset_store',28 'reset_store',
29 'rollback',29 'rollback',
30 'session_store',
30 'SQLBase',31 'SQLBase',
31 'sqlvalues',32 'sqlvalues',
32 'StupidCache',33 'StupidCache',
33 'ZopelessTransactionManager',34 'ZopelessTransactionManager',
34]35 ]
3536
3637
37from datetime import datetime38from datetime import datetime
@@ -578,7 +579,7 @@
578579
579 """580 """
580 if not isinstance(x, basestring):581 if not isinstance(x, basestring):
581 raise TypeError, 'Not a string (%s)' % type(x)582 raise TypeError('Not a string (%s)' % type(x))
582 return quote(x).replace('%', r'\\%').replace('_', r'\\_')583 return quote(x).replace('%', r'\\%').replace('_', r'\\_')
583584
584585
@@ -693,6 +694,7 @@
693 clause = clause.replace('?', '%s') % sqlvalues(*parameters)694 clause = clause.replace('?', '%s') % sqlvalues(*parameters)
694 return clause695 return clause
695696
697
696def flush_database_updates():698def flush_database_updates():
697 """Flushes all pending database updates.699 """Flushes all pending database updates.
698700
@@ -833,6 +835,7 @@
833 DEPRECATED - use of this class is deprecated in favour of using835 DEPRECATED - use of this class is deprecated in favour of using
834 Store.execute().836 Store.execute().
835 """837 """
838
836 def __init__(self):839 def __init__(self):
837 self._connection = _get_sqlobject_store()._connection840 self._connection = _get_sqlobject_store()._connection
838 self._result = None841 self._result = None
@@ -861,3 +864,8 @@
861 if self._result is not None:864 if self._result is not None:
862 self._result.close()865 self._result.close()
863 self._result = None866 self._result = None
867
868
869def session_store():
870 """Return a store connected to the session DB."""
871 return getUtility(IZStorm).get('session', 'launchpad-session:')
864872
=== modified file 'lib/canonical/launchpad/browser/librarian.py'
--- lib/canonical/launchpad/browser/librarian.py 2010-09-03 07:53:11 +0000
+++ lib/canonical/launchpad/browser/librarian.py 2010-09-07 04:07:54 +0000
@@ -11,7 +11,9 @@
11 'LibraryFileAliasMD5View',11 'LibraryFileAliasMD5View',
12 'LibraryFileAliasView',12 'LibraryFileAliasView',
13 'ProxiedLibraryFileAlias',13 'ProxiedLibraryFileAlias',
14 'SafeStreamOrRedirectLibraryFileAliasView',
14 'StreamOrRedirectLibraryFileAliasView',15 'StreamOrRedirectLibraryFileAliasView',
16 'RedirectPerhapsWithTokenLibraryFileAliasView',
15 ]17 ]
1618
17import os19import os
@@ -44,6 +46,7 @@
44 filechunks,46 filechunks,
45 guess_librarian_encoding,47 guess_librarian_encoding,
46 )48 )
49from lp.services.features import getFeatureFlag
4750
4851
49class LibraryFileAliasView(LaunchpadView):52class LibraryFileAliasView(LaunchpadView):
@@ -77,11 +80,29 @@
77 return '%s %s' % (self.context.content.md5, self.context.filename)80 return '%s %s' % (self.context.content.md5, self.context.filename)
7881
7982
80class StreamOrRedirectLibraryFileAliasView(LaunchpadView):83class MixedFileAliasView(LaunchpadView):
81 """Stream or redirects to `ILibraryFileAlias`.84 """Stream or redirects to `ILibraryFileAlias`.
8285
83 It streams the contents of restricted library files or redirects86 If the file is public, it will redirect to the files http url.
84 to public ones.87
88 Otherwise if the feature flag publicrestrictedlibrarian is set to 'on' this
89 will allocate a token and redirect to the aliases private url.
90
91 Otherwise it will proxy the file in the appserver.
92
93 Once we no longer have any proxy code at all it should be possible to
94 consolidate this with LibraryFileAliasView.
95
96 Note that streaming restricted files is a security concern - they show up
97 in the launchpad.net domain rather than launchpadlibrarian.net and thus
98 we have to take special care about their origin.
99 SafeStreamOrRedirectLibraryFileAliasView is used when we do not trust the
100 content, otherwise StreamOrRedirectLibraryFileAliasView. We are working
101 to remove both of these views entirely, but some transition will be
102 needed.
103
104 The context provides a file-like interface - it can be opened and closed
105 and read from.
85 """106 """
86 implements(IBrowserPublisher)107 implements(IBrowserPublisher)
87108
@@ -91,6 +112,8 @@
91 # the shell environment changes. Download the library file112 # the shell environment changes. Download the library file
92 # content into a local temporary file. Finally, restore original113 # content into a local temporary file. Finally, restore original
93 # proxy-settings and refresh the urllib2 opener.114 # proxy-settings and refresh the urllib2 opener.
115 # XXX: This is not threadsafe, so two calls at once will collide and
116 # can then corrupt the variable. bug=395960
94 original_proxy = os.getenv('http_proxy')117 original_proxy = os.getenv('http_proxy')
95 try:118 try:
96 if original_proxy is not None:119 if original_proxy is not None:
@@ -137,25 +160,65 @@
137 return tmp_file160 return tmp_file
138161
139 def browserDefault(self, request):162 def browserDefault(self, request):
140 """Decides whether to redirect or stream the file content.163 """Decides how to deliver the file.
164
165 The options are:
166 - redirect to the contexts http url
167 - redirect to a time limited secure url
168 - stream the file content.
141169
142 Only restricted file contents are streamed, finishing the traversal170 Only restricted file contents are streamed, finishing the traversal
143 chain with this view. If the context file is public return the171 chain with this view. If the context file is public return the
144 appropriate `RedirectionView` for its HTTP url.172 appropriate `RedirectionView` for its HTTP url.
145 """173 """
174 # Perhaps we should give a 404 at this point rather than asserting?
175 # If someone has a page open with an attachment link, then someone
176 # else deletes the attachment, this is a normal situation, not an
177 # error. -- RBC 20100726.
146 assert not self.context.deleted, (178 assert not self.context.deleted, (
147 "StreamOrRedirectLibraryFileAliasView can not operate on "179 "MixedFileAliasView can not operate on deleted librarian files, "
148 "deleted librarian files, since their URL is undefined.")180 "since their URL is undefined.")
149181 if not self.context.restricted:
150 if self.context.restricted:182 # Public file, just point the client at the right place.
183 return RedirectionView(self.context.http_url, self.request), ()
184 if getFeatureFlag(u'publicrestrictedlibrarian') != 'on':
185 # Restricted file and we have not enabled the public
186 # restricted librarian yet :- deliver inline.
187 self._when_streaming()
151 return self, ()188 return self, ()
152189 # Avoids a circular import seen in
153 return RedirectionView(self.context.http_url, self.request), ()190 # scripts/ftests/librarianformatter.txt
191 from canonical.launchpad.database.librarian import TimeLimitedToken
192 # Allocate a token for the file to be accessed for a limited time and
193 # redirect the client to the file.
194 token = TimeLimitedToken.allocate(self.context.private_url)
195 final_url = self.context.private_url + '?token=%s' % token
196 return RedirectionView(final_url, self.request), ()
154197
155 def publishTraverse(self, request, name):198 def publishTraverse(self, request, name):
156 """See `IBrowserPublisher`."""199 """See `IBrowserPublisher` - can't traverse below a file."""
157 raise NotFound(name, self.context)200 raise NotFound(name, self.context)
158201
202 def _when_streaming(self):
203 """Hook for SafeStreamOrRedirectLibraryFileAliasView."""
204
205
206
207class SafeStreamOrRedirectLibraryFileAliasView(MixedFileAliasView):
208 """A view for Librarian files that sets the content disposition header."""
209
210 def _when_streaming(self):
211 super(SafeStreamOrRedirectLibraryFileAliasView, self)._when_streaming()
212 self.request.response.setHeader(
213 'Content-Disposition', 'attachment')
214
215
216# The eventual name of MixedFileAliasView once the proxy code
217# is ripped out.
218RedirectPerhapsWithTokenLibraryFileAliasView = MixedFileAliasView
219# The name for the old behaviour being removed:
220StreamOrRedirectLibraryFileAliasView = MixedFileAliasView
221
159222
160class DeletedProxiedLibraryFileAlias(NotFound):223class DeletedProxiedLibraryFileAlias(NotFound):
161 """Raised when a deleted `ProxiedLibraryFileAlias` is accessed."""224 """Raised when a deleted `ProxiedLibraryFileAlias` is accessed."""
@@ -192,7 +255,20 @@
192255
193256
194class ProxiedLibraryFileAlias:257class ProxiedLibraryFileAlias:
195 """A `LibraryFileAlias` proxied via webapp.258 """A `LibraryFileAlias` decorator for use in URL generation.
259
260 The URL's output by this decorator will always point at the webapp. This is
261 useful when:
262 - we are proxying files via the webapp (as we do at the moment)
263 - when the webapp has to be contacted to get access to a file (the case
264 for restricted files in the future)
265 - files might change from public to private and thus not work even if the
266 user has access to the once its private, unless they go via the webapp.
267
268 This should be used anywhere we are outputting URL's to LibraryFileAliases
269 other than directly in rendered pages. For rendered pages, using a
270 LibraryFileAlias directly is OK as at that point the status of the file
271 is know.
196272
197 Overrides `ILibraryFileAlias.http_url` to always point to the webapp URL,273 Overrides `ILibraryFileAlias.http_url` to always point to the webapp URL,
198 even when called from the webservice domain.274 even when called from the webservice domain.
199275
=== added file 'lib/canonical/launchpad/database/ftests/test_timelimitedtoken.py'
--- lib/canonical/launchpad/database/ftests/test_timelimitedtoken.py 1970-01-01 00:00:00 +0000
+++ lib/canonical/launchpad/database/ftests/test_timelimitedtoken.py 2010-09-07 04:07:54 +0000
@@ -0,0 +1,29 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Tests for TimeLimitedToken."""
5
6__metaclass__ = type
7
8import testtools
9import transaction
10
11from canonical.database.sqlbase import session_store
12from canonical.launchpad.database.librarian import TimeLimitedToken
13from canonical.testing import LaunchpadFunctionalLayer
14
15
16class TestLibraryFileAlias(testtools.TestCase):
17
18 layer = LaunchpadFunctionalLayer
19
20 def test_allocate(self):
21 store = session_store()
22 store.find(TimeLimitedToken).remove()
23 token1 = TimeLimitedToken.allocate('foo://')
24 token2 = TimeLimitedToken.allocate('foo://')
25 # We must get unique tokens
26 self.assertNotEqual(token1, token2)
27 # They must be bytestrings (as a surrogate for valid url fragment')
28 self.assertIsInstance(token1, str)
29 self.assertIsInstance(token2, str)
030
=== modified file 'lib/canonical/launchpad/database/librarian.py'
--- lib/canonical/launchpad/database/librarian.py 2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/database/librarian.py 2010-09-07 04:07:54 +0000
@@ -10,12 +10,16 @@
10 'LibraryFileAliasSet',10 'LibraryFileAliasSet',
11 'LibraryFileContent',11 'LibraryFileContent',
12 'LibraryFileDownloadCount',12 'LibraryFileDownloadCount',
13 'TimeLimitedToken',
13 ]14 ]
1415
15from datetime import (16from datetime import (
16 datetime,17 datetime,
17 timedelta,18 timedelta,
18 )19 )
20from hashlib import md5
21import random
22from urlparse import urlparse
1923
20from lazr.delegates import delegates24from lazr.delegates import delegates
21import pytz25import pytz
@@ -26,6 +30,7 @@
26 SQLRelatedJoin,30 SQLRelatedJoin,
27 StringCol,31 StringCol,
28 )32 )
33import storm.base
29from storm.locals import (34from storm.locals import (
30 Date,35 Date,
31 Desc,36 Desc,
@@ -48,7 +53,10 @@
48 UTC_NOW,53 UTC_NOW,
49 )54 )
50from canonical.database.datetimecol import UtcDateTimeCol55from canonical.database.datetimecol import UtcDateTimeCol
51from canonical.database.sqlbase import SQLBase56from canonical.database.sqlbase import (
57 session_store,
58 SQLBase,
59 )
52from canonical.launchpad.interfaces import (60from canonical.launchpad.interfaces import (
53 ILibraryFileAlias,61 ILibraryFileAlias,
54 ILibraryFileAliasSet,62 ILibraryFileAliasSet,
@@ -127,8 +135,15 @@
127 return url135 return url
128 return url.replace('http', 'https', 1)136 return url.replace('http', 'https', 1)
129137
138 @property
139 def private_url(self):
140 """See ILibraryFileAlias.https_url"""
141 return self.client.getURLForAlias(self.id, secure=True)
142
130 def getURL(self):143 def getURL(self):
131 """See ILibraryFileAlias.getURL"""144 """See ILibraryFileAlias.getURL"""
145 if self.restricted:
146 return self.private_url
132 if config.librarian.use_https:147 if config.librarian.use_https:
133 return self.https_url148 return self.https_url
134 else:149 else:
@@ -285,3 +300,56 @@
285 count = Int(allow_none=False)300 count = Int(allow_none=False)
286 country_id = Int(name='country', allow_none=True)301 country_id = Int(name='country', allow_none=True)
287 country = Reference(country_id, 'Country.id')302 country = Reference(country_id, 'Country.id')
303
304
305class TimeLimitedToken(storm.base.Storm):
306 """A time limited access token for accessing a private file."""
307
308 __storm_table__ = 'TimeLimitedToken'
309
310 created = UtcDateTimeCol(notNull=True, default=UTC_NOW)
311 path = StringCol(notNull=True)
312 token = StringCol(notNull=True)
313 __storm_primary__ = ("path", "token")
314
315 def __init__(self, path, token, created=None):
316 """Create a TimeLimitedToken."""
317 if created is not None:
318 self.created = created
319 self.path = path
320 self.token = token
321
322 @staticmethod
323 def allocate(url):
324 """Allocate a token for url path in the librarian.
325
326 :param url: A url bytestring. e.g.
327 https://i123.restricted.launchpad-librarian.net/123/foo.txt
328 Note that the token is generated for 123/foo.txt
329 :return: A url fragment token ready to be attached to the url.
330 e.g. 'a%20token'
331 """
332 # We use random.random to get a string which varies reasonably, and we
333 # hash it to distribute it widely and get a easily copy and pastable
334 # single string (nice for debugging). The randomness is not a key
335 # factor here: as long as tokens are not guessable, they are hidden by
336 # https, not exposed directly in the API (tokens will be allocated by
337 # the appropriate objects), not by direct access to the
338 # TimeLimitedToken class.
339 baseline = str(random.random())
340 hashed = md5(baseline).hexdigest()
341 token = hashed
342 store = session_store()
343 path = TimeLimitedToken.url_to_token_path(url)
344 store.add(TimeLimitedToken(path, token))
345 # The session isn't part of the main transaction model, and in fact it
346 # has autocommit on. The commit here is belts and bracers: after
347 # allocation the external librarian must be able to serve the file
348 # immediately.
349 store.commit()
350 return token
351
352 @staticmethod
353 def url_to_token_path(url):
354 """Return the token path used for authorising access to url."""
355 return urlparse(url)[2]
288356
=== modified file 'lib/canonical/launchpad/doc/librarian.txt'
--- lib/canonical/launchpad/doc/librarian.txt 2010-09-04 05:01:17 +0000
+++ lib/canonical/launchpad/doc/librarian.txt 2010-09-07 04:07:54 +0000
@@ -1,5 +1,31 @@
1= Librarian Access =1= Librarian Access =
22
3The librarian is a file storage service for launchpad. Conceptually similar to
4other file storage API's like S3, it is used to store binary or large content -
5bug attachments, package builds, images and so on.
6
7Content in the librarian can be exposed at different urls. To expose some
8content use a LibraryFileAlias. Private content is supported as well - for that
9tokens are added to permit access for a limited time by a client - each time a
10client attempts to dereference a private LibraryFileAlias a token is emitted.
11
12= Deployment notes =
13
14(These may seem a bit out of place - they are, but they need to be written down
15somewhere, and the deployment choices inform the implementation choices).
16
17The basics are simple: The librarian talks to clients. However restricted file
18access makes things a little more complex. As the librarian itself doesn't do
19SSL processing, and we want restricted files to be kept confidential the
20librarian will need a hint from the SSL front end that SSL was in fact used.
21The semi standard header Front-End-Https can be used for this if we filter it
22in incoming requests from clients.
23
24== setUp ==
25
26 >>> from canonical.database.sqlbase import session_store
27 >>> from canonical.launchpad.database.librarian import TimeLimitedToken
28
3== High Level ==29== High Level ==
430
5 >>> from canonical.launchpad.interfaces import ILibraryFileAliasSet31 >>> from canonical.launchpad.interfaces import ILibraryFileAliasSet
@@ -58,7 +84,9 @@
58 ... ) is not None84 ... ) is not None
59 True85 True
6086
61Librarian also serves the same file through https.87Librarian also serves the same file through https, we use this for branding
88and similar inline-presented objects which would trigger security warnings on
89https pages otherwise.
6290
63 >>> re.search(91 >>> re.search(
64 ... r'^https://localhost:58000/\d+/text.txt$', alias.https_url92 ... r'^https://localhost:58000/\d+/text.txt$', alias.https_url
@@ -83,7 +111,7 @@
83 ... """)111 ... """)
84 >>> config.push('test', test_data)112 >>> config.push('test', test_data)
85 >>> re.search(113 >>> re.search(
86 ... r'^https://localhost:58000/\d+/text.txt$', alias.getURL()114 ... r'^https://localhost:58000/\d+/text.txt$', alias.https_url
87 ... ) is not None115 ... ) is not None
88 True116 True
89117
@@ -183,6 +211,15 @@
183 >>> re.search(r'^http://localhost:58000/\d+/text.txt$', url) is not None211 >>> re.search(r'^http://localhost:58000/\d+/text.txt$', url) is not None
184 True212 True
185213
214When secure=True, the returned url has the id as part of the domain name and
215the protocol is https:
216
217 >>> expected = 'https://i%d.restricted.localhost:58000/%d/text.txt' % (
218 ... aid, aid)
219 >>> expected == client.getURLForAlias(aid, secure=True)
220 True
221
222
186Librarian reads are logged in the request timeline.223Librarian reads are logged in the request timeline.
187224
188 >>> from canonical.lazr.utils import get_current_browser_request225 >>> from canonical.lazr.utils import get_current_browser_request
@@ -465,10 +502,9 @@
465 True502 True
466503
467504
468== StreamOrRedirectLibraryFileAliasView ==505== File views setup ==
469506
470This special view allows call sites to stream restricted `LibraryFileAlias`507We need some files to test different ways of accessing them.
471contents directly from launchpad instances.
472508
473 >>> filename = 'public.txt'509 >>> filename = 'public.txt'
474 >>> content = 'PUBLIC'510 >>> content = 'PUBLIC'
@@ -482,11 +518,141 @@
482 ... filename, len(content), StringIO(content), 'text/plain',518 ... filename, len(content), StringIO(content), 'text/plain',
483 ... NEVER_EXPIRES, restricted=True)519 ... NEVER_EXPIRES, restricted=True)
484520
521 # Create a new LibraryFileAlias not referencing any LibraryFileContent
522 # record. Such records are considered as being deleted.
523 >>> from canonical.launchpad.database import LibraryFileAlias
524 >>> from canonical.launchpad.webapp.interfaces import (
525 ... IStoreSelector, MAIN_STORE, MASTER_FLAVOR)
526
527 >>> store = getUtility(IStoreSelector).get(MAIN_STORE, MASTER_FLAVOR)
528 >>> deleted_file = LibraryFileAlias(
529 ... content=None, filename='deleted.txt',
530 ... mimetype='text/plain')
531 >>> ignore = store.add(deleted_file)
532
485Commit the just-created files.533Commit the just-created files.
486534
487 >>> from canonical.database.sqlbase import commit535 >>> from canonical.database.sqlbase import commit
488 >>> commit()536 >>> commit()
489537
538 >>> deleted_file = getUtility(ILibraryFileAliasSet)[deleted_file.id]
539 >>> print deleted_file.deleted
540 True
541
542Clear out existing tokens.
543
544 >>> _ = session_store().find(TimeLimitedToken).remove()
545
546== RedirectPerhapsWithTokenLibraryFileAliasView ==
547
548This is the eventual name of StreamOrRedirectLibraryFileAliasView once we have
549migrated over to the token based approach. For now the code for the two things
550is interleaved: zope's registration system is too static to permit doing it
551cleanly with separate classes.
552
553To enable the behaviour we want to test while its controlled by a feature flag,
554we must enable a feature flag for it:
555
556 >>> from lp.services.features.model import FeatureFlag, getFeatureStore
557 >>> ignore = getFeatureStore().add(FeatureFlag(
558 ... scope=u'default', flag=u'publicrestrictedlibrarian', value=u'on',
559 ... priority=1))
560 >>> transaction.commit()
561
562This special view allows granting access to restricted `LibraryFileAlias`
563objects on the launchpadlibrarian. All requests get redirected, restricted
564files have an access token allocated.
565
566This view implements `IBrowserPublisher` which allows us to use it in
567traversals.
568
569 >>> from canonical.launchpad.browser.librarian import (
570 ... RedirectPerhapsWithTokenLibraryFileAliasView)
571
572 >>> empty_request = LaunchpadTestRequest()
573
574XXX bug=631884 we have to establish the flags object manually for testing.
575
576 >>> from lp.services.features.webapp import ScopesFromRequest
577 >>> from lp.services.features.flags import FeatureController
578 >>> from lp.services.features import per_thread
579 >>> per_thread.features = FeatureController(
580 ... ScopesFromRequest(empty_request).lookup)
581
582 >>> restricted_view = RedirectPerhapsWithTokenLibraryFileAliasView(
583 ... restricted_file, empty_request)
584
585 >>> from zope.publisher.interfaces.browser import IBrowserPublisher
586 >>> verifyObject(IBrowserPublisher, restricted_view)
587 True
588
589When the context file is a restricted `LibraryFileAlias`, traversal causes an
590access token to be allocated and a redirection to https on a unique domain to
591be issued.
592
593 >>> view, extra = restricted_view.browserDefault(empty_request)
594 >>> print view
595 <...RedirectionView...>
596 >>> view.target
597 'https://...restricted.txt?token=...'
598 >>> private_path = TimeLimitedToken.url_to_token_path(
599 ... restricted_file.private_url)
600 >>> view.target.endswith(session_store().find(TimeLimitedToken,
601 ... path=private_path).any().token)
602 True
603
604The domain for the target starts with the id of the LibraryFileAlias.
605
606 >>> view.target.startswith('https://i%d.restricted.' % restricted_file.id)
607 True
608
609Otherwise, no token is allocated and a redirection is issued to the http url.
610
611 >>> public_view = RedirectPerhapsWithTokenLibraryFileAliasView(
612 ... public_file, empty_request)
613 >>> verifyObject(IBrowserPublisher, public_view)
614 True
615 >>> view, extra = public_view.browserDefault(empty_request)
616 >>> print view
617 <...RedirectionView ...>
618 >>> view.target == public_file.http_url
619 True
620 >>> view.target
621 'http://.../public.txt'
622
623Deleted files cannot be handled by the view and it raises an appropriate
624AssertionError when it gets traversed.
625
626 >>> deleted_view = RedirectPerhapsWithTokenLibraryFileAliasView(
627 ... deleted_file, empty_request)
628
629 >>> view, extra = deleted_view.browserDefault(empty_request)
630 Traceback (most recent call last):
631 ...
632 AssertionError: MixedFileAliasView can not operate on deleted librarian
633 files, since their URL is undefined.
634
635
636As RedirectPerhapsWithTokenLibraryFileAliasView is
637StreamOrRedirectLibraryFileAliasView influence by a feature flag, until the
638proxy code is removed, we must remove the feature to test the old behaviour.
639
640 >>> ignore = getFeatureStore().find(FeatureFlag,
641 ... FeatureFlag.flag==u'publicrestrictedlibrarian').remove()
642 >>> transaction.commit()
643
644Because the flags code aggressively caches, we have to do a small dance to
645convince it a new request has started too.
646
647 >>> empty_request = LaunchpadTestRequest()
648 >>> per_thread.features = FeatureController(
649 ... ScopesFromRequest(empty_request).lookup)
650
651== StreamOrRedirectLibraryFileAliasView ==
652
653This special view allows call sites to stream restricted `LibraryFileAlias`
654contents directly from launchpad instances.
655
490This view implements `IBrowserPublisher` which allows us to use it in656This view implements `IBrowserPublisher` which allows us to use it in
491traversals.657traversals.
492658
@@ -505,6 +671,8 @@
505traversal chain is finished with the view object itself.671traversal chain is finished with the view object itself.
506672
507 >>> view, extra = restricted_view.browserDefault(empty_request)673 >>> view, extra = restricted_view.browserDefault(empty_request)
674 >>> if view != restricted_view:
675 ... print view
508 >>> view == restricted_view676 >>> view == restricted_view
509 True677 True
510678
@@ -586,31 +754,14 @@
586Files in this condition cannot be handled by the view and it raises an754Files in this condition cannot be handled by the view and it raises an
587appropriate AssertionError when it gets traversed.755appropriate AssertionError when it gets traversed.
588756
589 # Create a new LibraryFileAlias not referencing any LibraryFileContent
590 # record. Such records are considered as being deleted.
591 >>> from canonical.launchpad.database import LibraryFileAlias
592 >>> from canonical.launchpad.webapp.interfaces import (
593 ... IStoreSelector, MAIN_STORE, MASTER_FLAVOR)
594
595 >>> store = getUtility(IStoreSelector).get(MAIN_STORE, MASTER_FLAVOR)
596 >>> deleted_file = LibraryFileAlias(
597 ... content=None, filename='deleted.txt',
598 ... mimetype='text/plain')
599 >>> ignore = store.add(deleted_file)
600 >>> store.commit()
601
602 >>> deleted_file = getUtility(ILibraryFileAliasSet)[deleted_file.id]
603 >>> print deleted_file.deleted
604 True
605
606 >>> deleted_view = StreamOrRedirectLibraryFileAliasView(757 >>> deleted_view = StreamOrRedirectLibraryFileAliasView(
607 ... deleted_file, empty_request)758 ... deleted_file, empty_request)
608759
609 >>> view, extra = deleted_view.browserDefault(empty_request)760 >>> view, extra = deleted_view.browserDefault(empty_request)
610 Traceback (most recent call last):761 Traceback (most recent call last):
611 ...762 ...
612 AssertionError: StreamOrRedirectLibraryFileAliasView can not763 AssertionError: MixedFileAliasView can not operate on deleted librarian
613 operate on deleted librarian files, since their URL is undefined.764 files, since their URL is undefined.
614765
615766
616== LibraryFileAliasMD5View ==767== LibraryFileAliasMD5View ==
617768
=== modified file 'lib/canonical/launchpad/ftests/test_libraryfilealias.py'
--- lib/canonical/launchpad/ftests/test_libraryfilealias.py 2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/ftests/test_libraryfilealias.py 2010-09-07 04:07:54 +0000
@@ -6,8 +6,8 @@
6__metaclass__ = type6__metaclass__ = type
77
8from cStringIO import StringIO8from cStringIO import StringIO
9
9import unittest10import unittest
10
11import transaction11import transaction
12from zope.component import getUtility12from zope.component import getUtility
1313
@@ -45,8 +45,3 @@
45 # the remaining content. If it's reset, the file will be auto-opened45 # the remaining content. If it's reset, the file will be auto-opened
46 # and its whole content will be returned.46 # and its whole content will be returned.
47 self.assertEquals(self.text_content, self.file_alias.read())47 self.assertEquals(self.text_content, self.file_alias.read())
48
49
50def test_suite():
51 return unittest.TestLoader().loadTestsFromName(__name__)
52
5348
=== modified file 'lib/canonical/launchpad/interfaces/librarian.py'
--- lib/canonical/launchpad/interfaces/librarian.py 2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/interfaces/librarian.py 2010-09-07 04:07:54 +0000
@@ -80,13 +80,17 @@
80 # byte strings.80 # byte strings.
81 http_url = Attribute(_("The http URL to this file"))81 http_url = Attribute(_("The http URL to this file"))
82 https_url = Attribute(_("The https URL to this file"))82 https_url = Attribute(_("The https URL to this file"))
83 private_url = Attribute(_("The secure URL to this file (private files)"))
8384
84 def getURL():85 def getURL():
85 """Return this file's http or https URL.86 """Return this file's http or https URL.
8687
88 If the file is a restricted file, the private_url will be returned,
89 which is on https and uses unique domains per file alias.
90
87 The generated URL will be https if the use_https config variable is91 The generated URL will be https if the use_https config variable is
88 set, in order to prevent warnings about insecure objects from92 set, in order to prevent warnings about insecure objects from
89 happening in some browsers.93 happening in some browsers (this is used for, e.g., branding).
9094
91 If config.launchpad.virtual_host.use_https is set, then return the95 If config.launchpad.virtual_host.use_https is set, then return the
92 https URL. Otherwise return the http URL.96 https URL. Otherwise return the http URL.
9397
=== modified file 'lib/canonical/launchpad/interfaces/lpstorm.py'
--- lib/canonical/launchpad/interfaces/lpstorm.py 2009-06-25 05:30:52 +0000
+++ lib/canonical/launchpad/interfaces/lpstorm.py 2010-09-07 04:07:54 +0000
@@ -32,4 +32,3 @@
3232
33class IMasterObject(IDBObject):33class IMasterObject(IDBObject):
34 """A Storm database object associated with its master Store."""34 """A Storm database object associated with its master Store."""
35
3635
=== modified file 'lib/canonical/launchpad/scripts/garbo.py'
--- lib/canonical/launchpad/scripts/garbo.py 2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/scripts/garbo.py 2010-09-07 04:07:54 +0000
@@ -29,10 +29,14 @@
29from canonical.database.constants import THIRTY_DAYS_AGO29from canonical.database.constants import THIRTY_DAYS_AGO
30from canonical.database.sqlbase import (30from canonical.database.sqlbase import (
31 cursor,31 cursor,
32 session_store,
32 sqlvalues,33 sqlvalues,
33 )34 )
34from canonical.launchpad.database.emailaddress import EmailAddress35from canonical.launchpad.database.emailaddress import EmailAddress
35from canonical.launchpad.database.librarian import LibraryFileAlias36from canonical.launchpad.database.librarian import (
37 LibraryFileAlias,
38 TimeLimitedToken,
39 )
36from canonical.launchpad.database.oauth import OAuthNonce40from canonical.launchpad.database.oauth import OAuthNonce
37from canonical.launchpad.database.openidconsumer import OpenIDConsumerNonce41from canonical.launchpad.database.openidconsumer import OpenIDConsumerNonce
38from canonical.launchpad.interfaces import IMasterStore42from canonical.launchpad.interfaces import IMasterStore
@@ -685,6 +689,43 @@
685 transaction.commit()689 transaction.commit()
686690
687691
692class OldTimeLimitedTokenDeleter(TunableLoop):
693 """Delete expired url access tokens from the session DB."""
694
695 maximum_chunk_size = 24*60*60 # 24 hours in seconds.
696
697 def __init__(self, log, abort_time=None):
698 super(OldTimeLimitedTokenDeleter, self).__init__(log, abort_time)
699 self.store = session_store()
700 self._update_oldest()
701
702 def _update_oldest(self):
703 self.oldest_age = self.store.execute("""
704 SELECT COALESCE(EXTRACT(EPOCH FROM
705 CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
706 - MIN(created)), 0)
707 FROM TimeLimitedToken
708 """).get_one()[0]
709
710 def isDone(self):
711 return self.oldest_age <= ONE_DAY_IN_SECONDS
712
713 def __call__(self, chunk_size):
714 self.oldest_age = max(
715 ONE_DAY_IN_SECONDS, self.oldest_age - chunk_size)
716
717 self.log.debug(
718 "Removed TimeLimitedToken rows older than %d seconds"
719 % self.oldest_age)
720 self.store.find(
721 TimeLimitedToken,
722 TimeLimitedToken.created < SQL(
723 "CURRENT_TIMESTAMP AT TIME ZONE 'UTC' - interval '%d seconds'"
724 % ONE_DAY_IN_SECONDS)).remove()
725 transaction.commit()
726 self._update_oldest()
727
728
688class SuggestiveTemplatesCacheUpdater(TunableLoop):729class SuggestiveTemplatesCacheUpdater(TunableLoop):
689 """Refresh the SuggestivePOTemplate cache.730 """Refresh the SuggestivePOTemplate cache.
690731
@@ -806,13 +847,14 @@
806class DailyDatabaseGarbageCollector(BaseDatabaseGarbageCollector):847class DailyDatabaseGarbageCollector(BaseDatabaseGarbageCollector):
807 script_name = 'garbo-daily'848 script_name = 'garbo-daily'
808 tunable_loops = [849 tunable_loops = [
850 BranchJobPruner,
851 BugNotificationPruner,
852 BugWatchActivityPruner,
809 CodeImportResultPruner,853 CodeImportResultPruner,
810 RevisionAuthorEmailLinker,
811 HWSubmissionEmailLinker,854 HWSubmissionEmailLinker,
812 BugNotificationPruner,
813 BranchJobPruner,
814 BugWatchActivityPruner,
815 ObsoleteBugAttachmentDeleter,855 ObsoleteBugAttachmentDeleter,
856 OldTimeLimitedTokenDeleter,
857 RevisionAuthorEmailLinker,
816 SuggestiveTemplatesCacheUpdater,858 SuggestiveTemplatesCacheUpdater,
817 ]859 ]
818 experimental_tunable_loops = [860 experimental_tunable_loops = [
819861
=== modified file 'lib/canonical/launchpad/scripts/tests/test_garbo.py'
--- lib/canonical/launchpad/scripts/tests/test_garbo.py 2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/scripts/tests/test_garbo.py 2010-09-07 04:07:54 +0000
@@ -23,11 +23,12 @@
23from zope.security.proxy import removeSecurityProxy23from zope.security.proxy import removeSecurityProxy
2424
25from canonical.config import config25from canonical.config import config
26from canonical.database import sqlbase
26from canonical.database.constants import (27from canonical.database.constants import (
27 THIRTY_DAYS_AGO,28 THIRTY_DAYS_AGO,
28 UTC_NOW,29 UTC_NOW,
29 )30 )
30from canonical.database.sqlbase import quote31from canonical.launchpad.database.librarian import TimeLimitedToken
31from canonical.launchpad.database.message import Message32from canonical.launchpad.database.message import Message
32from canonical.launchpad.database.oauth import OAuthNonce33from canonical.launchpad.database.oauth import OAuthNonce
33from canonical.launchpad.database.openidconsumer import OpenIDConsumerNonce34from canonical.launchpad.database.openidconsumer import OpenIDConsumerNonce
@@ -561,6 +562,26 @@
561 LaunchpadZopelessLayer.switchDbUser('testadmin')562 LaunchpadZopelessLayer.switchDbUser('testadmin')
562 self.assertEqual(bug.attachments.count(), 0)563 self.assertEqual(bug.attachments.count(), 0)
563564
565 def test_TimeLimitedTokenPruner(self):
566 # Ensure there are no tokens
567 store = sqlbase.session_store()
568 map(store.remove, store.find(TimeLimitedToken))
569 store.flush()
570 self.assertEqual(0, len(list(store.find(TimeLimitedToken,
571 path="sample path"))))
572 # One to clean and one to keep
573 store.add(TimeLimitedToken(path="sample path", token="foo",
574 created=datetime(2008, 01, 01, tzinfo=UTC)))
575 store.add(TimeLimitedToken(path="sample path", token="bar")),
576 store.commit()
577 self.assertEqual(2, len(list(store.find(TimeLimitedToken,
578 path="sample path"))))
579 self.runDaily()
580 self.assertEqual(0, len(list(store.find(TimeLimitedToken,
581 path="sample path", token="foo"))))
582 self.assertEqual(1, len(list(store.find(TimeLimitedToken,
583 path="sample path", token="bar"))))
584
564 def test_CacheSuggestivePOTemplates(self):585 def test_CacheSuggestivePOTemplates(self):
565 LaunchpadZopelessLayer.switchDbUser('testadmin')586 LaunchpadZopelessLayer.switchDbUser('testadmin')
566 template = self.factory.makePOTemplate()587 template = self.factory.makePOTemplate()
@@ -571,6 +592,6 @@
571 SELECT count(*)592 SELECT count(*)
572 FROM SuggestivePOTemplate593 FROM SuggestivePOTemplate
573 WHERE potemplate = %s594 WHERE potemplate = %s
574 """ % quote(template.id)).get_one()595 """ % sqlbase.quote(template.id)).get_one()
575596
576 self.assertEqual(1, count)597 self.assertEqual(1, count)
577598
=== modified file 'lib/canonical/launchpad/utilities/looptuner.py'
--- lib/canonical/launchpad/utilities/looptuner.py 2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/utilities/looptuner.py 2010-09-07 04:07:54 +0000
@@ -314,6 +314,10 @@
314 self.log = log314 self.log = log
315 self.abort_time = abort_time315 self.abort_time = abort_time
316316
317 def isDone(self):
318 """Return True when the TunableLoop is complete."""
319 raise NotImplementedError(self.isDone)
320
317 def run(self):321 def run(self):
318 assert self.maximum_chunk_size is not None, (322 assert self.maximum_chunk_size is not None, (
319 "Did not override maximum_chunk_size.")323 "Did not override maximum_chunk_size.")
320324
=== modified file 'lib/canonical/launchpad/webapp/session.py'
--- lib/canonical/launchpad/webapp/session.py 2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/webapp/session.py 2010-09-07 04:07:54 +0000
@@ -13,6 +13,7 @@
13from zope.session.http import CookieClientIdManager13from zope.session.http import CookieClientIdManager
1414
15from canonical.config import config15from canonical.config import config
16from canonical.database.sqlbase import session_store
1617
1718
18SECONDS = 119SECONDS = 1
@@ -77,7 +78,7 @@
77 # Secret is looked up here rather than in __init__, because78 # Secret is looked up here rather than in __init__, because
78 # we can't be sure the database connections are setup at that point.79 # we can't be sure the database connections are setup at that point.
79 if self._secret is None:80 if self._secret is None:
80 store = getUtility(IZStorm).get('session', 'launchpad-session:')81 store = session_store()
81 result = store.execute("SELECT secret FROM secret")82 result = store.execute("SELECT secret FROM secret")
82 self._secret = result.get_one()[0]83 self._secret = result.get_one()[0]
83 return self._secret84 return self._secret
8485
=== modified file 'lib/canonical/launchpad/zcml/librarian.zcml'
--- lib/canonical/launchpad/zcml/librarian.zcml 2010-07-06 16:12:46 +0000
+++ lib/canonical/launchpad/zcml/librarian.zcml 2010-09-07 04:07:54 +0000
@@ -55,6 +55,11 @@
55 <allow interface="zope.publisher.interfaces.browser.IBrowserPublisher" />55 <allow interface="zope.publisher.interfaces.browser.IBrowserPublisher" />
56 </class>56 </class>
5757
58 <class class="canonical.launchpad.browser.librarian.SafeStreamOrRedirectLibraryFileAliasView">
59 <allow attributes="__call__" />
60 <allow interface="zope.publisher.interfaces.browser.IBrowserPublisher" />
61 </class>
62
58 <adapter63 <adapter
59 factory="canonical.launchpad.database.librarian.LibraryFileAliasWithParent"64 factory="canonical.launchpad.database.librarian.LibraryFileAliasWithParent"
60 provides="canonical.launchpad.interfaces.librarian.ILibraryFileAliasWithParent"65 provides="canonical.launchpad.interfaces.librarian.ILibraryFileAliasWithParent"
6166
=== modified file 'lib/canonical/librarian/client.py'
--- lib/canonical/librarian/client.py 2010-09-04 05:01:17 +0000
+++ lib/canonical/librarian/client.py 2010-09-07 04:07:54 +0000
@@ -23,7 +23,11 @@
2323
24from select import select24from select import select
25from socket import SOCK_STREAM, AF_INET25from socket import SOCK_STREAM, AF_INET
26from urlparse import urljoin26from urlparse import (
27 urlparse,
28 urljoin,
29 urlunparse,
30 )
2731
28from storm.store import Store32from storm.store import Store
29from zope.interface import implements33from zope.interface import implements
@@ -291,16 +295,17 @@
291 # raise DownloadFailed, 'Incomplete response'295 # raise DownloadFailed, 'Incomplete response'
292 # return paths296 # return paths
293297
294 def _getPathForAlias(self, aliasID):298 def _getPathForAlias(self, aliasID, secure=False):
295 """Returns the path inside the librarian to talk about the given299 """Returns the path inside the librarian to talk about the given
296 alias.300 alias.
297301
298 :param aliasID: A unique ID for the alias302 :param aliasID: A unique ID for the alias
299303 :param secure: Controls the behaviour when looking up restricted
304 files. If False restricted files are only permitted when
305 self.restricted is True. See getURLForAlias.
300 :returns: String path, url-escaped. Unicode is UTF-8 encoded before306 :returns: String path, url-escaped. Unicode is UTF-8 encoded before
301 url-escaping, as described in section 2.2.5 of RFC 2718.307 url-escaping, as described in section 2.2.5 of RFC 2718.
302 None if the file has been deleted.308 None if the file has been deleted.
303
304 :raises: DownloadFailed if the alias is invalid309 :raises: DownloadFailed if the alias is invalid
305 """310 """
306 from canonical.launchpad.database import LibraryFileAlias311 from canonical.launchpad.database import LibraryFileAlias
@@ -311,7 +316,7 @@
311 lfa = LibraryFileAlias.get(aliasID)316 lfa = LibraryFileAlias.get(aliasID)
312 except SQLObjectNotFound:317 except SQLObjectNotFound:
313 raise DownloadFailed('Alias %d not found' % aliasID)318 raise DownloadFailed('Alias %d not found' % aliasID)
314 if self.restricted != lfa.restricted:319 if not secure and self.restricted != lfa.restricted:
315 raise DownloadFailed(320 raise DownloadFailed(
316 'Alias %d cannot be downloaded from this client.' % aliasID)321 'Alias %d cannot be downloaded from this client.' % aliasID)
317 if lfa.deleted:322 if lfa.deleted:
@@ -319,18 +324,41 @@
319 return get_libraryfilealias_download_path(324 return get_libraryfilealias_download_path(
320 aliasID, lfa.filename.encode('utf-8'))325 aliasID, lfa.filename.encode('utf-8'))
321326
322 def getURLForAlias(self, aliasID):327 def getURLForAlias(self, aliasID, secure=False):
323 """Returns the url for talking to the librarian about the given328 """Returns the url for talking to the librarian about the given
324 alias.329 alias.
325330
326 :param aliasID: A unique ID for the alias331 :param aliasID: A unique ID for the alias
327332 :param secure: If true generate https urls on unique domains for
333 security.
328 :returns: String URL, or None if the file has expired and been deleted.334 :returns: String URL, or None if the file has expired and been deleted.
329 """335 """
330 path = self._getPathForAlias(aliasID)336 # Note that the path is the same for both secure and insecure URLs -
337 # this is deliberate: the server doesn't need to know about the original
338 # Host the client provides, testing is easier as we don't need wildcard
339 # https environments on dev machines.
340 path = self._getPathForAlias(aliasID, secure=secure)
331 if path is None:341 if path is None:
332 return None342 return None
333 base = self.download_url343 if not secure:
344 base = self.download_url
345 else:
346 # Secure url generation is the same for both restricted and
347 # unrestricted files aliases : it is used to give web clients (not
348 # appservers) a url to use to access a file which is either
349 # restricted (and so they will also need a TimeLimitedToken) or
350 # is suspected hostile (and so it should be isolated on its own
351 # domain). Note that only the former is currently used in LP.
352 # The algorithm is:
353 # parse the url
354 download_url = config.librarian.download_url
355 parsed = list(urlparse(download_url))
356 # Force the scheme to https
357 parsed[0] = 'https'
358 # Insert the alias id (which is a unique key, thus unique) in the
359 # netloc
360 parsed[1] = ('i%d.restricted.' % aliasID) + parsed[1]
361 base = urlunparse(parsed)
334 return urljoin(base, path)362 return urljoin(base, path)
335363
336 def _getURLForDownload(self, aliasID):364 def _getURLForDownload(self, aliasID):
337365
=== modified file 'lib/canonical/librarian/db.py'
--- lib/canonical/librarian/db.py 2009-11-24 15:36:44 +0000
+++ lib/canonical/librarian/db.py 2010-09-07 04:07:54 +0000
@@ -13,12 +13,19 @@
13from psycopg2.extensions import TransactionRollbackError13from psycopg2.extensions import TransactionRollbackError
14from sqlobject.sqlbuilder import AND14from sqlobject.sqlbuilder import AND
15from storm.exceptions import DisconnectionError, IntegrityError15from storm.exceptions import DisconnectionError, IntegrityError
16from storm.expr import SQL
16import transaction17import transaction
17from twisted.python.util import mergeFunctionMetadata18from twisted.python.util import mergeFunctionMetadata
1819
19from canonical.database.sqlbase import reset_store20from canonical.database.sqlbase import (
21 reset_store,
22 session_store,
23 )
20from canonical.launchpad.database.librarian import (24from canonical.launchpad.database.librarian import (
21 LibraryFileContent, LibraryFileAlias)25 LibraryFileAlias,
26 LibraryFileContent,
27 TimeLimitedToken,
28 )
2229
2330
24RETRY_ATTEMPTS = 331RETRY_ATTEMPTS = 3
@@ -98,19 +105,40 @@
98 def lookupBySHA1(self, digest):105 def lookupBySHA1(self, digest):
99 return [fc.id for fc in LibraryFileContent.selectBy(sha1=digest)]106 return [fc.id for fc in LibraryFileContent.selectBy(sha1=digest)]
100107
101 def getAlias(self, aliasid):108 def getAlias(self, aliasid, token, path):
102 """Returns a LibraryFileAlias, or raises LookupError.109 """Returns a LibraryFileAlias, or raises LookupError.
103110
104 A LookupError is raised if no record with the given ID exists111 A LookupError is raised if no record with the given ID exists
105 or if not related LibraryFileContent exists.112 or if not related LibraryFileContent exists.
113
114 :param token: The token for the file. If None no token is present.
115 When a token is supplied, it is looked up with path.
116 :param path: The path the request is for, unused unless a token
117 is supplied; when supplied it must match the token. The
118 value of path is expected to be that from a twisted request.args
119 e.g. /foo/bar.
106 """120 """
121 restricted = self.restricted
122 if token and path:
123 # with a token and a path we may be able to serve restricted files
124 # on the public port.
125 store = session_store()
126 token_found = store.find(TimeLimitedToken,
127 SQL("age(created) < interval '1 day'"),
128 TimeLimitedToken.token==token,
129 TimeLimitedToken.path==path).is_empty()
130 store.reset()
131 if token_found:
132 raise LookupError("Token stale/pruned/path mismatch")
133 else:
134 restricted = True
107 alias = LibraryFileAlias.selectOne(AND(135 alias = LibraryFileAlias.selectOne(AND(
108 LibraryFileAlias.q.id==aliasid,136 LibraryFileAlias.q.id==aliasid,
109 LibraryFileAlias.q.contentID==LibraryFileContent.q.id,137 LibraryFileAlias.q.contentID==LibraryFileContent.q.id,
110 LibraryFileAlias.q.restricted==self.restricted,138 LibraryFileAlias.q.restricted==restricted,
111 ))139 ))
112 if alias is None:140 if alias is None:
113 raise LookupError141 raise LookupError("No file alias with LibraryFileContent")
114 return alias142 return alias
115143
116 def getAliases(self, fileid):144 def getAliases(self, fileid):
117145
=== modified file 'lib/canonical/librarian/ftests/test_db.py'
--- lib/canonical/librarian/ftests/test_db.py 2009-11-24 16:54:45 +0000
+++ lib/canonical/librarian/ftests/test_db.py 2010-09-07 04:07:54 +0000
@@ -39,7 +39,7 @@
39 sorted(library.lookupBySHA1('deadbeef')))39 sorted(library.lookupBySHA1('deadbeef')))
4040
41 aliasID = library.addAlias(fileID, 'file1', 'text/unknown')41 aliasID = library.addAlias(fileID, 'file1', 'text/unknown')
42 alias = library.getAlias(aliasID)42 alias = library.getAlias(aliasID, None, '/')
43 self.assertEqual('file1', alias.filename)43 self.assertEqual('file1', alias.filename)
44 self.assertEqual('text/unknown', alias.mimetype)44 self.assertEqual('text/unknown', alias.mimetype)
4545
@@ -65,42 +65,44 @@
65 # Library.getAlias() returns the LibrarayFileAlias for a given65 # Library.getAlias() returns the LibrarayFileAlias for a given
66 # LibrarayFileAlias ID.66 # LibrarayFileAlias ID.
67 library = db.Library(restricted=False)67 library = db.Library(restricted=False)
68 alias = library.getAlias(1)68 alias = library.getAlias(1, None, '/')
69 self.assertEqual(1, alias.id)69 self.assertEqual(1, alias.id)
7070
71 def test_getAlias_no_such_record(self):71 def test_getAlias_no_such_record(self):
72 # Library.getAlias() raises a LookupError, if no record with72 # Library.getAlias() raises a LookupError, if no record with
73 # the given ID exists.73 # the given ID exists.
74 library = db.Library(restricted=False)74 library = db.Library(restricted=False)
75 self.assertRaises(LookupError, library.getAlias, -1)75 self.assertRaises(LookupError, library.getAlias, -1, None, '/')
7676
77 def test_getAlias_content_is_null(self):77 def test_getAlias_content_is_null(self):
78 # Library.getAlias() raises a LookupError, if no content78 # Library.getAlias() raises a LookupError, if no content
79 # record for the given alias exists.79 # record for the given alias exists.
80 library = db.Library(restricted=False)80 library = db.Library(restricted=False)
81 alias = library.getAlias(1)81 alias = library.getAlias(1, None, '/')
82 alias.content = None82 alias.content = None
83 self.assertRaises(LookupError, library.getAlias, 1)83 self.assertRaises(LookupError, library.getAlias, 1, None, '/')
8484
85 def test_getAlias_content_is_none(self):85 def test_getAlias_content_is_none(self):
86 # Library.getAlias() raises a LookupError, if the matching86 # Library.getAlias() raises a LookupError, if the matching
87 # record does not reference any LibraryFileContent record.87 # record does not reference any LibraryFileContent record.
88 library = db.Library(restricted=False)88 library = db.Library(restricted=False)
89 alias = library.getAlias(1)89 alias = library.getAlias(1, None, '/')
90 alias.content = None90 alias.content = None
91 self.assertRaises(LookupError, library.getAlias, 1)91 self.assertRaises(LookupError, library.getAlias, 1, None, '/')
9292
93 def test_getAlias_content_wrong_library(self):93 def test_getAlias_content_wrong_library(self):
94 # Library.getAlias() raises a LookupError, if a restricted94 # Library.getAlias() raises a LookupError, if a restricted
95 # library looks up a unrestricted LibraryFileAlias and95 # library looks up a unrestricted LibraryFileAlias and
96 # vice versa.96 # vice versa.
97 restricted_library = db.Library(restricted=True)97 restricted_library = db.Library(restricted=True)
98 self.assertRaises(LookupError, restricted_library.getAlias, 1)98 self.assertRaises(
99 LookupError, restricted_library.getAlias, 1, None, '/')
99100
100 unrestricted_library = db.Library(restricted=False)101 unrestricted_library = db.Library(restricted=False)
101 alias = unrestricted_library.getAlias(1)102 alias = unrestricted_library.getAlias(1, None, '/')
102 alias.restricted = True103 alias.restricted = True
103 self.assertRaises(LookupError, unrestricted_library.getAlias, 1)104 self.assertRaises(
105 LookupError, unrestricted_library.getAlias, 1, None, '/')
104106
105 def test_getAliases(self):107 def test_getAliases(self):
106 # Library.getAliases() returns a sequence108 # Library.getAliases() returns a sequence
@@ -119,7 +121,7 @@
119 # Library.getAliases() does not return records which do not121 # Library.getAliases() does not return records which do not
120 # reference any LibraryFileContent record.122 # reference any LibraryFileContent record.
121 library = db.Library(restricted=False)123 library = db.Library(restricted=False)
122 alias = library.getAlias(1)124 alias = library.getAlias(1, None, '/')
123 alias.content = None125 alias.content = None
124 aliases = library.getAliases(1)126 aliases = library.getAliases(1)
125 expected_aliases = [127 expected_aliases = [
@@ -132,7 +134,7 @@
132 # LibrarayFileAlias records when called from a unrestricted134 # LibrarayFileAlias records when called from a unrestricted
133 # library and vice versa.135 # library and vice versa.
134 unrestricted_library = db.Library(restricted=False)136 unrestricted_library = db.Library(restricted=False)
135 alias = unrestricted_library.getAlias(1)137 alias = unrestricted_library.getAlias(1, None, '/')
136 alias.restricted = True138 alias.restricted = True
137139
138 aliases = unrestricted_library.getAliases(1)140 aliases = unrestricted_library.getAliases(1)
139141
=== modified file 'lib/canonical/librarian/ftests/test_storage.py'
--- lib/canonical/librarian/ftests/test_storage.py 2010-02-09 00:17:40 +0000
+++ lib/canonical/librarian/ftests/test_storage.py 2010-09-07 04:07:54 +0000
@@ -71,7 +71,7 @@
71 fileid, aliasid = newfile.store()71 fileid, aliasid = newfile.store()
7272
73 # Check that its alias has the right mimetype73 # Check that its alias has the right mimetype
74 fa = self.storage.getFileAlias(aliasid)74 fa = self.storage.getFileAlias(aliasid, None, '/')
75 self.assertEqual('text/unknown', fa.mimetype)75 self.assertEqual('text/unknown', fa.mimetype)
7676
77 # Re-add the same file, with the same name and mimetype...77 # Re-add the same file, with the same name and mimetype...
@@ -81,7 +81,8 @@
81 fileid2, aliasid2 = newfile2.store()81 fileid2, aliasid2 = newfile2.store()
8282
83 # Verify that we didn't get back the same alias ID83 # Verify that we didn't get back the same alias ID
84 self.assertNotEqual(fa.id, self.storage.getFileAlias(aliasid2).id)84 self.assertNotEqual(fa.id,
85 self.storage.getFileAlias(aliasid2, None, '/').id)
8586
86 def test_clientProvidedDuplicateIDs(self):87 def test_clientProvidedDuplicateIDs(self):
87 # This test checks the new behaviour specified by LibrarianTransactions88 # This test checks the new behaviour specified by LibrarianTransactions
8889
=== modified file 'lib/canonical/librarian/ftests/test_web.py'
--- lib/canonical/librarian/ftests/test_web.py 2009-11-24 15:36:44 +0000
+++ lib/canonical/librarian/ftests/test_web.py 2010-09-07 04:07:54 +0000
@@ -3,17 +3,28 @@
33
4from cStringIO import StringIO4from cStringIO import StringIO
5from datetime import datetime5from datetime import datetime
6import httplib
6import unittest7import unittest
7from urllib2 import urlopen, HTTPError8from urllib2 import urlopen, HTTPError
9from urlparse import urlparse
810
9import pytz11import pytz
1012from storm.expr import SQL
11import transaction13import transaction
12from zope.component import getUtility14from zope.component import getUtility
1315
14from canonical.config import config16from canonical.config import config
15from canonical.database.sqlbase import flush_database_updates, cursor17from canonical.database.sqlbase import (
16from canonical.librarian.client import LibrarianClient18 cursor,
19 flush_database_updates,
20 session_store,
21 )
22from canonical.launchpad.database.librarian import TimeLimitedToken
23from canonical.librarian.client import (
24 get_libraryfilealias_download_path,
25 LibrarianClient,
26 RestrictedLibrarianClient,
27 )
17from canonical.librarian.interfaces import DownloadFailed28from canonical.librarian.interfaces import DownloadFailed
18from canonical.launchpad.database import LibraryFileAlias29from canonical.launchpad.database import LibraryFileAlias
19from canonical.launchpad.interfaces import IMasterStore30from canonical.launchpad.interfaces import IMasterStore
@@ -57,12 +68,7 @@
57 # librarian allow access to files that don't exist in the DB68 # librarian allow access to files that don't exist in the DB
58 # and spitting them out with an 'unknown' mime-type69 # and spitting them out with an 'unknown' mime-type
59 # -- StuartBishop)70 # -- StuartBishop)
60 try:71 self.require404(url)
61 urlopen(url)
62 self.fail('Should have raised a 404')
63 except HTTPError, x:
64 self.failUnlessEqual(x.code, 404)
65
66 self.commit()72 self.commit()
6773
68 # Make sure we can download it using the API74 # Make sure we can download it using the API
@@ -148,11 +154,7 @@
148 config.librarian.download_port,154 config.librarian.download_port,
149 aid, filename155 aid, filename
150 )156 )
151 try:157 self.require404(self._makeURL(aid, 'different.txt'))
152 urlopen(self._makeURL(aid, 'different.txt'))
153 self.fail('404 not raised')
154 except HTTPError, x:
155 self.failUnlessEqual(x.code, 404)
156158
157 def _makeURL(self, aliasID, filename):159 def _makeURL(self, aliasID, filename):
158 host = config.librarian.download_host160 host = config.librarian.download_host
@@ -172,18 +174,9 @@
172 self.failUnlessEqual(url, self._makeURL(aid, filename))174 self.failUnlessEqual(url, self._makeURL(aid, filename))
173175
174 # Change the aliasid and assert we get a 404176 # Change the aliasid and assert we get a 404
175 try:177 self.require404(self._makeURL(aid+1, filename))
176 urlopen(self._makeURL(aid+1, filename))
177 self.fail('404 not raised')
178 except HTTPError, x:
179 self.failUnlessEqual(x.code, 404)
180
181 # Change the filename and assert we get a 404178 # Change the filename and assert we get a 404
182 try:179 self.require404(self._makeURL(aid, 'different.txt'))
183 urlopen(self._makeURL(aid, 'different.txt'))
184 self.fail('404 not raised')
185 except HTTPError, x:
186 self.failUnlessEqual(x.code, 404)
187180
188 def test_duplicateuploads(self):181 def test_duplicateuploads(self):
189 client = LibrarianClient()182 client = LibrarianClient()
@@ -237,6 +230,145 @@
237 self.failUnlessEqual(230 self.failUnlessEqual(
238 last_modified_header, 'Tue, 30 Jan 2001 13:45:59 GMT')231 last_modified_header, 'Tue, 30 Jan 2001 13:45:59 GMT')
239232
233 def get_restricted_file_and_public_url(self):
234 # Use a regular LibrarianClient to ensure we speak to the nonrestricted
235 # port on the librarian which is where secured restricted files are
236 # served from.
237 client = LibrarianClient()
238 fileAlias = client.addFile('sample', 12, StringIO('a'*12),
239 contentType='text/plain')
240 # Note: We're deliberately using the wrong url here: we should be
241 # passing secure=True to getURLForAlias, but to use the returned URL we
242 # would need a wildcard DNS facility patched into urlopen; instead
243 # we use the *deliberate* choice of having the path of secure and insecure
244 # urls be the same, so that we can test it: the server code doesn't need
245 # to know about the fancy wildcard domains.
246 url = client.getURLForAlias(fileAlias)
247 # Now that we have a url which talks to the public librarian, make the
248 # file restricted.
249 IMasterStore(LibraryFileAlias).find(LibraryFileAlias,
250 LibraryFileAlias.id==fileAlias).set(
251 LibraryFileAlias.restricted==True)
252 self.commit()
253 return fileAlias, url
254
255 def test_restricted_subdomain_must_match_file_alias(self):
256 # IFF there is a .restricted. in the host, then the library file alias
257 # in the subdomain must match that in the path.
258 client = LibrarianClient()
259 fileAlias = client.addFile('sample', 12, StringIO('a'*12),
260 contentType='text/plain')
261 fileAlias2 = client.addFile('sample', 12, StringIO('b'*12),
262 contentType='text/plain')
263 self.commit()
264 url = client.getURLForAlias(fileAlias)
265 download_host = urlparse(config.librarian.download_url)[1]
266 if ':' in download_host:
267 download_host = download_host[:download_host.find(':')]
268 template_host = 'i%%d.restricted.%s' % download_host
269 path = get_libraryfilealias_download_path(fileAlias, 'sample')
270 # The basic URL must work.
271 urlopen(url)
272 # Use the network level protocol because DNS resolution won't work
273 # here (no wildcard support)
274 connection = httplib.HTTPConnection(
275 config.librarian.download_host,
276 config.librarian.download_port)
277 # A valid subdomain based URL must work.
278 good_host = template_host % fileAlias
279 connection.request("GET", path, headers={'Host': good_host})
280 response = connection.getresponse()
281 response.read()
282 self.assertEqual(200, response.status)
283 # A subdomain based URL trying to put fileAlias into the restricted
284 # domain of fileAlias2 must not work.
285 hostile_host = template_host % fileAlias2
286 connection.request("GET", path, headers={'Host': hostile_host})
287 response = connection.getresponse()
288 response.read()
289 self.assertEqual(404, response.status)
290 # A subdomain which matches the LFA but is nested under one that
291 # doesn't is also treated as hostile.
292 nested_host = 'i%d.restricted.i%d.restricted.%s' % (
293 fileAlias, fileAlias2, download_host)
294 connection.request("GET", path, headers={'Host': nested_host})
295 response = connection.getresponse()
296 response.read()
297 self.assertEqual(404, response.status)
298
299 def test_restricted_no_token(self):
300 fileAlias, url = self.get_restricted_file_and_public_url()
301 # The file should not be able to be opened - we haven't allocated a
302 # token. When the token is wrong or stale a 404 is given (to avoid
303 # disclosure about what content we hold. Alternatively a 401 could be
304 # given (as long as we give a 401 when the file is missing as well -
305 # but that requires some more complex changes in the deployment
306 # infrastructure to permit more backend knowledge of the frontend
307 # request.
308 self.require404(url)
309
310 def test_restricted_made_up_token(self):
311 fileAlias, url = self.get_restricted_file_and_public_url()
312 # The file should not be able to be opened - the token supplied
313 # is not one we issued.
314 self.require404(url + '?token=haxx0r')
315
316 def test_restricted_with_token(self):
317 fileAlias, url = self.get_restricted_file_and_public_url()
318 # We have the base url for a restricted file; grant access to it
319 # for a short time.
320 token = TimeLimitedToken.allocate(url)
321 url = url + "?token=%s" % token
322 # Now we should be able to access the file.
323 fileObj = urlopen(url)
324 try:
325 self.assertEqual("a"*12, fileObj.read())
326 finally:
327 fileObj.close()
328
329 def test_restricted_with_expired_token(self):
330 fileAlias, url = self.get_restricted_file_and_public_url()
331 # We have the base url for a restricted file; grant access to it
332 # for a short time.
333 token = TimeLimitedToken.allocate(url)
334 # But time has passed
335 store = session_store()
336 tokens = store.find(TimeLimitedToken, TimeLimitedToken.token==token)
337 tokens.set(
338 TimeLimitedToken.created==SQL("created - interval '1 week'"))
339 url = url + "?token=%s" % token
340 # Now, as per test_restricted_no_token we should get a 404.
341 self.require404(url)
342
343 def test_restricted_file_headers(self):
344 fileAlias, url = self.get_restricted_file_and_public_url()
345 token = TimeLimitedToken.allocate(url)
346 url = url + "?token=%s" % token
347 # Change the date_created to a known value for testing.
348 file_alias = IMasterStore(LibraryFileAlias).get(
349 LibraryFileAlias, fileAlias)
350 file_alias.date_created = datetime(
351 2001, 01, 30, 13, 45, 59, tzinfo=pytz.utc)
352 # Commit the update.
353 self.commit()
354 # Fetch the file via HTTP, recording the interesting headers
355 result = urlopen(url)
356 last_modified_header = result.info()['Last-Modified']
357 cache_control_header = result.info()['Cache-Control']
358 # No caching for restricted files.
359 self.failUnlessEqual(cache_control_header, 'max-age=0, private')
360 # And we should have a correct Last-Modified header too.
361 self.failUnlessEqual(
362 last_modified_header, 'Tue, 30 Jan 2001 13:45:59 GMT')
363 # Perhaps we should also set Expires to the Last-Modified.
364
365 def require404(self, url):
366 try:
367 urlopen(url)
368 self.fail('404 not raised')
369 except HTTPError, e:
370 self.failUnlessEqual(e.code, 404)
371
240372
241class LibrarianZopelessWebTestCase(LibrarianWebTestCase):373class LibrarianZopelessWebTestCase(LibrarianWebTestCase):
242 layer = LaunchpadZopelessLayer374 layer = LaunchpadZopelessLayer
243375
=== modified file 'lib/canonical/librarian/interfaces.py'
--- lib/canonical/librarian/interfaces.py 2010-08-17 21:05:47 +0000
+++ lib/canonical/librarian/interfaces.py 2010-09-07 04:07:54 +0000
@@ -89,8 +89,19 @@
89class IFileDownloadClient(Interface):89class IFileDownloadClient(Interface):
90 """Download API for the Librarian client."""90 """Download API for the Librarian client."""
9191
92 def getURLForAlias(aliasID):92 def getURLForAlias(aliasID, secure=False):
93 """Returns the URL to the given file"""93 """Returns the URL to the given file.
94
95 :param aliasID: The LibraryFileAlias for the file to get. A DB lookup
96 will be done for this - if many are to be calculated, eagar loading
97 is recommended.
98 :param secure: If False, generate an http url on the main librarian
99 domain.
100 If True, generate an https url on a subdomain
101 i$aliasID.restricted.$main_librarian_domain.
102 Note that when a secure URL is generated, a TimeLimitedToken must
103 separately be obtained and combined with the URL to use it.
104 """
94105
95 def getFileByAlias(aliasID, timeout=LIBRARIAN_SERVER_DEFAULT_TIMEOUT):106 def getFileByAlias(aliasID, timeout=LIBRARIAN_SERVER_DEFAULT_TIMEOUT):
96 """Returns a file-like object to read the file contents from.107 """Returns a file-like object to read the file contents from.
97108
=== modified file 'lib/canonical/librarian/storage.py'
--- lib/canonical/librarian/storage.py 2010-08-16 18:50:40 +0000
+++ lib/canonical/librarian/storage.py 2010-09-07 04:07:54 +0000
@@ -72,8 +72,8 @@
72 def startAddFile(self, filename, size):72 def startAddFile(self, filename, size):
73 return LibraryFileUpload(self, filename, size)73 return LibraryFileUpload(self, filename, size)
7474
75 def getFileAlias(self, aliasid):75 def getFileAlias(self, aliasid, token, path):
76 return self.library.getAlias(aliasid)76 return self.library.getAlias(aliasid, token, path)
7777
7878
79class LibraryFileUpload(object):79class LibraryFileUpload(object):
8080
=== modified file 'lib/canonical/librarian/web.py'
--- lib/canonical/librarian/web.py 2010-08-17 16:33:33 +0000
+++ lib/canonical/librarian/web.py 2010-09-07 04:07:54 +0000
@@ -5,10 +5,12 @@
55
6from datetime import datetime6from datetime import datetime
7import time7import time
8from urlparse import urlparse
89
9from twisted.web import resource, static, util, server, proxy10from twisted.web import resource, static, util, server, proxy
10from twisted.internet.threads import deferToThread11from twisted.internet.threads import deferToThread
1112
13from canonical.config import config
12from canonical.librarian.client import url_path_quote14from canonical.librarian.client import url_path_quote
13from canonical.librarian.db import read_transaction, write_transaction15from canonical.librarian.db import read_transaction, write_transaction
14from canonical.librarian.utils import guess_librarian_encoding16from canonical.librarian.utils import guess_librarian_encoding
@@ -62,11 +64,11 @@
62 self.upstreamPort = upstreamPort64 self.upstreamPort = upstreamPort
6365
64 def getChild(self, filename, request):66 def getChild(self, filename, request):
65
66 # If we still have another component of the path, then we have67 # If we still have another component of the path, then we have
67 # an old URL that encodes the content ID. We want to keep supporting68 # an old URL that encodes the content ID. We want to keep supporting
68 # these, so we just ignore the content id that is currently in69 # these, so we just ignore the content id that is currently in
69 # self.aliasID and extract the real one from the URL.70 # self.aliasID and extract the real one from the URL. Note that
71 # tokens do not work with the old URL style: they are URL specific.
70 if len(request.postpath) == 1:72 if len(request.postpath) == 1:
71 try:73 try:
72 self.aliasID = int(filename)74 self.aliasID = int(filename)
@@ -74,7 +76,27 @@
74 return fourOhFour76 return fourOhFour
75 filename = request.postpath[0]77 filename = request.postpath[0]
7678
77 deferred = deferToThread(self._getFileAlias, self.aliasID)79 # IFF the request has a .restricted. subdomain, ensure there is a
80 # alias id in the right most subdomain, and that it matches
81 # self.aliasIDd, And that the host precisely matches what we generate
82 # (specifically to stop people putting a good prefix to the left of an
83 # attacking one).
84 hostname = request.getRequestHostname()
85 if '.restricted.' in hostname:
86 # Configs can change without warning: evaluate every time.
87 download_url = config.librarian.download_url
88 parsed = list(urlparse(download_url))
89 netloc = parsed[1]
90 # Strip port if present
91 if netloc.find(':') > -1:
92 netloc = netloc[:netloc.find(':')]
93 expected_hostname = 'i%d.restricted.%s' % (self.aliasID, netloc)
94 if expected_hostname != hostname:
95 return fourOhFour
96
97 token = request.args.get('token', [None])[0]
98 path = request.path
99 deferred = deferToThread(self._getFileAlias, self.aliasID, token, path)
78 deferred.addCallback(100 deferred.addCallback(
79 self._cb_getFileAlias, filename, request101 self._cb_getFileAlias, filename, request
80 )102 )
@@ -82,12 +104,12 @@
82 return util.DeferredResource(deferred)104 return util.DeferredResource(deferred)
83105
84 @write_transaction106 @write_transaction
85 def _getFileAlias(self, aliasID):107 def _getFileAlias(self, aliasID, token, path):
86 try:108 try:
87 alias = self.storage.getFileAlias(aliasID)109 alias = self.storage.getFileAlias(aliasID, token, path)
88 alias.updateLastAccessed()110 alias.updateLastAccessed()
89 return (alias.contentID, alias.filename,111 return (alias.contentID, alias.filename,
90 alias.mimetype, alias.date_created)112 alias.mimetype, alias.date_created, alias.restricted)
91 except LookupError:113 except LookupError:
92 raise NotFound114 raise NotFound
93115
@@ -96,7 +118,7 @@
96 return fourOhFour118 return fourOhFour
97119
98 def _cb_getFileAlias(120 def _cb_getFileAlias(
99 self, (dbcontentID, dbfilename, mimetype, date_created),121 self, (dbcontentID, dbfilename, mimetype, date_created, restricted),
100 filename, request122 filename, request
101 ):123 ):
102 # Return a 404 if the filename in the URL is incorrect. This offers124 # Return a 404 if the filename in the URL is incorrect. This offers
@@ -105,8 +127,14 @@
105 if dbfilename.encode('utf-8') != filename:127 if dbfilename.encode('utf-8') != filename:
106 return fourOhFour128 return fourOhFour
107129
108 # Set our caching headers. Librarian files can be cached forever.130 if not restricted:
109 request.setHeader('Cache-Control', 'max-age=31536000, public')131 # Set our caching headers. Librarian files can be cached forever.
132 request.setHeader('Cache-Control', 'max-age=31536000, public')
133 else:
134 # Restricted files require revalidation every time. For now,
135 # until the deployment details are completely reviewed, the
136 # simplest, most cautious approach is taken: no caching permited.
137 request.setHeader('Cache-Control', 'max-age=0, private')
110138
111 if self.storage.hasFile(dbcontentID) or self.upstreamHost is None:139 if self.storage.hasFile(dbcontentID) or self.upstreamHost is None:
112 # XXX: Brad Crittenden 2007-12-05 bug=174204: When encodings are140 # XXX: Brad Crittenden 2007-12-05 bug=174204: When encodings are
113141
=== modified file 'lib/canonical/testing/layers.py'
--- lib/canonical/testing/layers.py 2010-05-06 15:51:34 +0000
+++ lib/canonical/testing/layers.py 2010-09-07 04:07:54 +0000
@@ -96,7 +96,11 @@
96from canonical.config import CanonicalConfig, config, dbconfig96from canonical.config import CanonicalConfig, config, dbconfig
97from canonical.database.revision import (97from canonical.database.revision import (
98 confirm_dbrevision, confirm_dbrevision_on_startup)98 confirm_dbrevision, confirm_dbrevision_on_startup)
99from canonical.database.sqlbase import cursor, ZopelessTransactionManager99from canonical.database.sqlbase import (
100 cursor,
101 session_store,
102 ZopelessTransactionManager,
103 )
100from canonical.launchpad.interfaces import IMailBox, IOpenLaunchBag104from canonical.launchpad.interfaces import IMailBox, IOpenLaunchBag
101from lp.testing import ANONYMOUS, login, logout, is_logged_in105from lp.testing import ANONYMOUS, login, logout, is_logged_in
102import lp.services.mail.stub106import lp.services.mail.stub
@@ -193,9 +197,7 @@
193 # as soon as SQLBase._connection is accessed197 # as soon as SQLBase._connection is accessed
194 r = main_store.execute('SELECT count(*) FROM LaunchpadDatabaseRevision')198 r = main_store.execute('SELECT count(*) FROM LaunchpadDatabaseRevision')
195 assert r.get_one()[0] > 0, 'Storm is not talking to the database'199 assert r.get_one()[0] > 0, 'Storm is not talking to the database'
196200 assert session_store() is not None, 'Failed to reconnect'
197 session_store = getUtility(IZStorm).get('session', 'launchpad-session:')
198 assert session_store is not None, 'Failed to reconnect'
199201
200202
201def wait_children(seconds=120):203def wait_children(seconds=120):
202204
=== modified file 'lib/lp/bugs/browser/bugattachment.py'
--- lib/lp/bugs/browser/bugattachment.py 2010-08-20 20:31:18 +0000
+++ lib/lp/bugs/browser/bugattachment.py 2010-09-07 04:07:54 +0000
@@ -10,7 +10,6 @@
10 'BugAttachmentSetNavigation',10 'BugAttachmentSetNavigation',
11 'BugAttachmentEditView',11 'BugAttachmentEditView',
12 'BugAttachmentURL',12 'BugAttachmentURL',
13 'SafeStreamOrRedirectLibraryFileAliasView',
14 ]13 ]
1514
16from cStringIO import StringIO15from cStringIO import StringIO
@@ -23,6 +22,7 @@
23 FileNavigationMixin,22 FileNavigationMixin,
24 ProxiedLibraryFileAlias,23 ProxiedLibraryFileAlias,
25 StreamOrRedirectLibraryFileAliasView,24 StreamOrRedirectLibraryFileAliasView,
25 SafeStreamOrRedirectLibraryFileAliasView,
26 )26 )
27from canonical.launchpad.interfaces.librarian import ILibraryFileAliasSet27from canonical.launchpad.interfaces.librarian import ILibraryFileAliasSet
28from canonical.launchpad.webapp import (28from canonical.launchpad.webapp import (
@@ -242,21 +242,6 @@
242 return self.context.type == BugAttachmentType.PATCH242 return self.context.type == BugAttachmentType.PATCH
243243
244244
245class SafeStreamOrRedirectLibraryFileAliasView(
246 StreamOrRedirectLibraryFileAliasView):
247 """A view for Librarian files that sets the content disposion header."""
248
249 def __call__(self):
250 """Stream the content of the context `ILibraryFileAlias`.
251
252 Set the content disposition header to the safe value "attachment".
253 """
254 self.request.response.setHeader(
255 'Content-Disposition', 'attachment')
256 return super(
257 SafeStreamOrRedirectLibraryFileAliasView, self).__call__()
258
259
260class BugAttachmentFileNavigation(Navigation, FileNavigationMixin):245class BugAttachmentFileNavigation(Navigation, FileNavigationMixin):
261 """Traversal to +files/${filename}."""246 """Traversal to +files/${filename}."""
262247
263248
=== modified file 'lib/lp/bugs/browser/configure.zcml'
--- lib/lp/bugs/browser/configure.zcml 2010-08-03 13:37:55 +0000
+++ lib/lp/bugs/browser/configure.zcml 2010-09-07 04:07:54 +0000
@@ -1159,9 +1159,5 @@
1159 classes="1159 classes="
1160 BugWatchSetNavigation"/>1160 BugWatchSetNavigation"/>
1161 </facet>1161 </facet>
1162 <class class="lp.bugs.browser.bugattachment.SafeStreamOrRedirectLibraryFileAliasView">
1163 <allow attributes="__call__" />
1164 <allow interface="zope.publisher.interfaces.browser.IBrowserPublisher" />
1165 </class>
11661162
1167</configure>1163</configure>
11681164
=== modified file 'lib/lp/bugs/browser/tests/test_bugattachment_file_access.py'
--- lib/lp/bugs/browser/tests/test_bugattachment_file_access.py 2010-08-20 20:31:18 +0000
+++ lib/lp/bugs/browser/tests/test_bugattachment_file_access.py 2010-09-07 04:07:54 +0000
@@ -15,6 +15,7 @@
1515
16from canonical.launchpad.browser.librarian import (16from canonical.launchpad.browser.librarian import (
17 StreamOrRedirectLibraryFileAliasView,17 StreamOrRedirectLibraryFileAliasView,
18 SafeStreamOrRedirectLibraryFileAliasView,
18 )19 )
19from canonical.launchpad.interfaces import ILaunchBag20from canonical.launchpad.interfaces import ILaunchBag
20from canonical.launchpad.interfaces.librarian import (21from canonical.launchpad.interfaces.librarian import (
@@ -25,12 +26,13 @@
25from canonical.testing import LaunchpadFunctionalLayer26from canonical.testing import LaunchpadFunctionalLayer
26from lp.bugs.browser.bugattachment import (27from lp.bugs.browser.bugattachment import (
27 BugAttachmentFileNavigation,28 BugAttachmentFileNavigation,
28 SafeStreamOrRedirectLibraryFileAliasView,
29 )29 )
30from lp.testing import (30from lp.testing import (
31 login_person,31 login_person,
32 TestCaseWithFactory,32 TestCaseWithFactory,
33 )33 )
34import lp.services.features
35from lp.services.features.flags import NullFeatureController
3436
3537
36class TestAccessToBugAttachmentFiles(TestCaseWithFactory):38class TestAccessToBugAttachmentFiles(TestCaseWithFactory):
@@ -80,7 +82,10 @@
80 self.assertIsNot(None, mo)82 self.assertIsNot(None, mo)
8183
82 def test_access_to_restricted_file(self):84 def test_access_to_restricted_file(self):
83 # Requests of restricted files are handled by ProxiedLibraryFileAlias.85 # Requests of restricted files are handled by ProxiedLibraryFileAlias
86 # until we enable the publicrestrictedlibrarian (at which point
87 # this test should check the view like
88 # test_access_to_unrestricted_file.
84 lfa_with_parent = getMultiAdapter(89 lfa_with_parent = getMultiAdapter(
85 (self.bugattachment.libraryfile, self.bugattachment),90 (self.bugattachment.libraryfile, self.bugattachment),
86 ILibraryFileAliasWithParent)91 ILibraryFileAliasWithParent)
@@ -91,6 +96,11 @@
91 request.setTraversalStack(['foo.txt'])96 request.setTraversalStack(['foo.txt'])
92 navigation = BugAttachmentFileNavigation(self.bugattachment, request)97 navigation = BugAttachmentFileNavigation(self.bugattachment, request)
93 view = navigation.publishTraverse(request, '+files')98 view = navigation.publishTraverse(request, '+files')
99 # XXX Ensure the feature will be off - everything is off with
100 # NullFeatureController. bug=631884
101 lp.services.features.per_thread.features = NullFeatureController()
102 self.addCleanup(
103 setattr, lp.services.features.per_thread, 'features', None)
94 next_view, traversal_path = view.browserDefault(request)104 next_view, traversal_path = view.browserDefault(request)
95 self.assertEqual(view, next_view)105 self.assertEqual(view, next_view)
96 file_ = next_view()106 file_ = next_view()
@@ -130,6 +140,11 @@
130 request.setTraversalStack(['foo.txt'])140 request.setTraversalStack(['foo.txt'])
131 navigation = BugAttachmentFileNavigation(self.bugattachment, request)141 navigation = BugAttachmentFileNavigation(self.bugattachment, request)
132 view = navigation.publishTraverse(request, '+files')142 view = navigation.publishTraverse(request, '+files')
143 # XXX Ensure the feature will be off - everything is off with
144 # NullFeatureController. bug=631884
145 lp.services.features.per_thread.features = NullFeatureController()
146 self.addCleanup(
147 setattr, lp.services.features.per_thread, 'features', None)
133 next_view, traversal_path = view.browserDefault(request)148 next_view, traversal_path = view.browserDefault(request)
134 self.assertIsInstance(149 self.assertIsInstance(
135 next_view, SafeStreamOrRedirectLibraryFileAliasView)150 next_view, SafeStreamOrRedirectLibraryFileAliasView)
136151
=== modified file 'lib/lp/scripts/utilities/importfascist.py'
--- lib/lp/scripts/utilities/importfascist.py 2010-08-20 20:31:18 +0000
+++ lib/lp/scripts/utilities/importfascist.py 2010-09-07 04:07:54 +0000
@@ -31,6 +31,7 @@
31 lp.codehosting.inmemory31 lp.codehosting.inmemory
32 canonical.launchpad.browser.branchlisting32 canonical.launchpad.browser.branchlisting
33 lp.code.browser.branchlisting33 lp.code.browser.branchlisting
34 canonical.launchpad.browser.librarian
34 canonical.launchpad.feed.branch35 canonical.launchpad.feed.branch
35 lp.code.feed.branch36 lp.code.feed.branch
36 canonical.launchpad.interfaces.person37 canonical.launchpad.interfaces.person
3738
=== modified file 'lib/lp/services/features/webapp.py'
--- lib/lp/services/features/webapp.py 2010-08-20 20:31:18 +0000
+++ lib/lp/services/features/webapp.py 2010-09-07 04:07:54 +0000
@@ -19,6 +19,14 @@
19 self._request = request19 self._request = request
2020
21 def lookup(self, scope_name):21 def lookup(self, scope_name):
22 """Determine if scope_name applies to this request.
23
24 Currently supports the following scopes:
25 - default
26 - is_edge/is_lpnet etc (thunks through to the config)
27 """
28 if scope_name == 'default':
29 return True
22 parts = scope_name.split('.')30 parts = scope_name.split('.')
23 if len(parts) == 2:31 if len(parts) == 2:
24 if parts[0] == 'server':32 if parts[0] == 'server':
2533
=== modified file 'lib/lp/testing/fakelibrarian.py'
--- lib/lp/testing/fakelibrarian.py 2010-09-03 13:08:42 +0000
+++ lib/lp/testing/fakelibrarian.py 2010-09-07 04:07:54 +0000
@@ -150,7 +150,7 @@
150 """See `IFileUploadClient`."""150 """See `IFileUploadClient`."""
151 return NotImplementedError()151 return NotImplementedError()
152152
153 def getURLForAlias(self, aliasID):153 def getURLForAlias(self, aliasID, secure=False):
154 """See `IFileDownloadClient`."""154 """See `IFileDownloadClient`."""
155 alias = self.aliases.get(aliasID)155 alias = self.aliases.get(aliasID)
156 path = get_libraryfilealias_download_path(aliasID, alias.filename)156 path = get_libraryfilealias_download_path(aliasID, alias.filename)

Subscribers

People subscribed via source and target branches

to status/vote changes: