launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #02893
[Merge] lp:~stub/launchpad/session-prune into lp:launchpad
Stuart Bishop has proposed merging lp:~stub/launchpad/session-prune into lp:launchpad.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
Related bugs:
#357516 Session cookies are being held for too long - from a users perspective the old ones are now invalid
https://bugs.launchpad.net/bugs/357516
#729019 session pruner should be in tree
https://bugs.launchpad.net/bugs/729019
For more details, see:
https://code.launchpad.net/~stub/launchpad/session-prune/+merge/52522
If a user multiple authenticated sessions, remove all but the most recent 6.
Also, pull out extra_prune_clause from the BulkPruner, as it is currently only useful for pathalogical cases that don't really matter and it broke our end of loop detection (if all items of a batch are skipped due to the extra_prune_clause, then no rows would be deleted and the task would terminate). It would be possible to support this, but it will require refactoring and perform worse.
--
https://code.launchpad.net/~stub/launchpad/session-prune/+merge/52522
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~stub/launchpad/session-prune into lp:launchpad.
=== modified file 'database/schema/launchpad_session.sql'
--- database/schema/launchpad_session.sql 2010-09-10 09:45:45 +0000
+++ database/schema/launchpad_session.sql 2011-03-08 07:56:23 +0000
@@ -29,3 +29,28 @@
GRANT SELECT, INSERT, UPDATE, DELETE ON TimeLimitedToken TO session;
-- And the garbo needs to run on it too.
GRANT SELECT, DELETE ON TimeLimitedToken TO session;
+
+
+-- This helper needs to exist in the session database so the BulkPruner
+-- can clean up unwanted sessions.
+CREATE OR REPLACE FUNCTION cursor_fetch(cur refcursor, n integer)
+RETURNS SETOF record LANGUAGE plpgsql AS
+$$
+DECLARE
+ r record;
+ count integer;
+BEGIN
+ FOR count IN 1..n LOOP
+ FETCH FORWARD FROM cur INTO r;
+ IF NOT FOUND THEN
+ RETURN;
+ END IF;
+ RETURN NEXT r;
+ END LOOP;
+END;
+$$;
+
+COMMENT ON FUNCTION cursor_fetch(refcursor, integer) IS
+'Fetch the next n items from a cursor. Work around for not being able to use FETCH inside a SELECT statement.';
+
+GRANT EXECUTE ON FUNCTION cursor_fetch(refcursor, integer) TO session;
=== modified file 'lib/lp/scripts/garbo.py'
--- lib/lp/scripts/garbo.py 2011-02-23 10:28:53 +0000
+++ lib/lp/scripts/garbo.py 2011-03-08 07:56:23 +0000
@@ -74,6 +74,7 @@
LaunchpadCronScript,
SilentLaunchpadScriptFailure,
)
+from lp.services.session.model import SessionData
from lp.translations.interfaces.potemplate import IPOTemplateSet
from lp.translations.model.potranslation import POTranslation
@@ -107,10 +108,14 @@
# from. Must be overridden.
target_table_class = None
- # The column name in target_table we use as the integer key. May be
- # overridden.
+ # The column name in target_table we use as the key. The type must
+ # match that returned by the ids_to_prune_query and the
+ # target_table_key_type. May be overridden.
target_table_key = 'id'
+ # SQL type of the target_table_key. May be overridden.
+ target_table_key_type = 'integer'
+
# An SQL query returning a list of ids to remove from target_table.
# The query must return a single column named 'id' and should not
# contain duplicates. Must be overridden.
@@ -119,10 +124,17 @@
# See `TunableLoop`. May be overridden.
maximum_chunk_size = 10000
+ def getStore(self):
+ """The master Store for the table we are pruning.
+
+ May be overridden.
+ """
+ return IMasterStore(self.target_table_class)
+
def __init__(self, log, abort_time=None):
super(BulkPruner, self).__init__(log, abort_time)
- self.store = IMasterStore(self.target_table_class)
+ self.store = self.getStore()
self.target_table_name = self.target_table_class.__storm_table__
# Open the cursor.
@@ -139,11 +151,14 @@
def __call__(self, chunk_size):
"""See `ITunableLoop`."""
result = self.store.execute("""
- DELETE FROM %s WHERE %s IN (
+ DELETE FROM %s
+ WHERE %s IN (
SELECT id FROM
- cursor_fetch('bulkprunerid', %d) AS f(id integer))
+ cursor_fetch('bulkprunerid', %d) AS f(id %s))
"""
- % (self.target_table_name, self.target_table_key, chunk_size))
+ % (
+ self.target_table_name, self.target_table_key,
+ chunk_size, self.target_table_key_type))
self._num_removed = result.rowcount
transaction.commit()
@@ -157,9 +172,7 @@
XXX bug=723596 StuartBishop: This job only needs to run once per month.
"""
-
target_table_class = POTranslation
-
ids_to_prune_query = """
SELECT POTranslation.id AS id FROM POTranslation
EXCEPT (
@@ -186,6 +199,68 @@
"""
+class SessionPruner(BulkPruner):
+ """Base class for session removal."""
+
+ target_table_class = SessionData
+ target_table_key = 'client_id'
+ target_table_key_type = 'text'
+
+
+class AntiqueSessionPruner(SessionPruner):
+ """Remove sessions not accessed for 60 days"""
+
+ ids_to_prune_query = """
+ SELECT client_id AS id FROM SessionData
+ WHERE last_accessed < CURRENT_TIMESTAMP - CAST('60 days' AS interval)
+ """
+
+
+class UnusedSessionPruner(SessionPruner):
+ """Remove sessions older than 1 day with no authentication credentials."""
+
+ ids_to_prune_query = """
+ SELECT client_id AS id FROM SessionData
+ WHERE
+ last_accessed < CURRENT_TIMESTAMP - CAST('1 day' AS interval)
+ AND client_id NOT IN (
+ SELECT client_id
+ FROM SessionPkgData
+ WHERE
+ product_id = 'launchpad.authenticateduser'
+ AND key='logintime')
+ """
+
+
+class DuplicateSessionPruner(SessionPruner):
+ """Remove all but the most recent 6 authenticated sessions for a user.
+
+ We sometimes see users with dozens or thousands of authenticated
+ sessions. To limit exposure to replay attacks, we remove all but
+ the most recent 6 of them for a given user.
+ """
+
+ ids_to_prune_query = """
+ SELECT client_id AS id
+ FROM (
+ SELECT
+ sessiondata.client_id,
+ last_accessed,
+ rank() OVER pickle AS rank
+ FROM SessionData, SessionPkgData
+ WHERE
+ SessionData.client_id = SessionPkgData.client_id
+ AND product_id = 'launchpad.authenticateduser'
+ AND key='accountid'
+ WINDOW pickle AS (PARTITION BY pickle ORDER BY last_accessed DESC)
+ ) AS whatever
+ WHERE
+ rank > 6
+ AND last_accessed < CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
+ - CAST('1 hour' AS interval)
+ """
+
+
class OAuthNoncePruner(TunableLoop):
"""An ITunableLoop to prune old OAuthNonce records.
@@ -977,6 +1052,9 @@
RevisionCachePruner,
BugHeatUpdater,
BugWatchScheduler,
+ AntiqueSessionPruner,
+ UnusedSessionPruner,
+ DuplicateSessionPruner,
]
experimental_tunable_loops = []
=== modified file 'lib/lp/scripts/tests/test_garbo.py'
--- lib/lp/scripts/tests/test_garbo.py 2011-02-28 11:50:34 +0000
+++ lib/lp/scripts/tests/test_garbo.py 2011-03-08 07:56:23 +0000
@@ -10,7 +10,6 @@
datetime,
timedelta,
)
-import operator
import time
from pytz import UTC
@@ -18,7 +17,10 @@
Min,
SQL,
)
-from storm.locals import Storm, Int
+from storm.locals import (
+ Int,
+ Storm,
+ )
from storm.store import Store
import transaction
from zope.component import getUtility
@@ -69,13 +71,20 @@
PersonCreationRationale,
)
from lp.scripts.garbo import (
+ AntiqueSessionPruner,
BulkPruner,
DailyDatabaseGarbageCollector,
+ DuplicateSessionPruner,
HourlyDatabaseGarbageCollector,
OpenIDConsumerAssociationPruner,
+ UnusedSessionPruner,
)
from lp.services.job.model.job import Job
from lp.services.log.logger import BufferLogger
+from lp.services.session.model import (
+ SessionData,
+ SessionPkgData,
+ )
from lp.testing import (
TestCase,
TestCaseWithFactory,
@@ -181,6 +190,153 @@
pruner(chunk_size)
+class TestSessionPruner(TestCase):
+ layer = ZopelessDatabaseLayer
+
+ def setUp(self):
+ super(TestCase, self).setUp()
+
+ # Session database isn't reset between tests. We need to do this
+ # manually.
+ nuke_all_sessions = IMasterStore(SessionData).find(SessionData).remove
+ nuke_all_sessions()
+ self.addCleanup(nuke_all_sessions)
+
+ recent = datetime.now(UTC)
+ yesterday = recent - timedelta(days=1)
+ ancient = recent - timedelta(days=61)
+
+ self.make_session(u'recent_auth', recent, 'auth1')
+ self.make_session(u'recent_unauth', recent, False)
+ self.make_session(u'yesterday_auth', yesterday, 'auth2')
+ self.make_session(u'yesterday_unauth', yesterday, False)
+ self.make_session(u'ancient_auth', ancient, 'auth3')
+ self.make_session(u'ancient_unauth', ancient, False)
+
+ def make_session(self, client_id, accessed, authenticated=None):
+ session_data = SessionData()
+ session_data.client_id = client_id
+ session_data.last_accessed = accessed
+ IMasterStore(SessionData).add(session_data)
+
+ if authenticated:
+ # Add login time information.
+ session_pkg_data = SessionPkgData()
+ session_pkg_data.client_id = client_id
+ session_pkg_data.product_id = u'launchpad.authenticateduser'
+ session_pkg_data.key = u'logintime'
+ session_pkg_data.pickle = 'value is ignored'
+ IMasterStore(SessionPkgData).add(session_pkg_data)
+
+ # Add authenticated as information.
+ session_pkg_data = SessionPkgData()
+ session_pkg_data.client_id = client_id
+ session_pkg_data.product_id = u'launchpad.authenticateduser'
+ session_pkg_data.key = u'accountid'
+ # Normally Account.id, but the session pruning works
+ # at the SQL level and doesn't unpickle anything.
+ session_pkg_data.pickle = authenticated
+ IMasterStore(SessionPkgData).add(session_pkg_data)
+
+ def sessionExists(self, client_id):
+ store = IMasterStore(SessionData)
+ return not store.find(
+ SessionData, SessionData.client_id == client_id).is_empty()
+
+ def test_antique_session_pruner(self):
+ chunk_size = 2
+ log = BufferLogger()
+ pruner = AntiqueSessionPruner(log)
+ try:
+ while not pruner.isDone():
+ pruner(chunk_size)
+ finally:
+ pruner.cleanUp()
+
+ expected_sessions = set([
+ u'recent_auth',
+ u'recent_unauth',
+ u'yesterday_auth',
+ u'yesterday_unauth',
+ # u'ancient_auth',
+ # u'ancient_unauth',
+ ])
+
+ found_sessions = set(
+ IMasterStore(SessionData).find(SessionData.client_id))
+
+ self.assertEqual(expected_sessions, found_sessions)
+
+ def test_unused_session_pruner(self):
+ chunk_size = 2
+ log = BufferLogger()
+ pruner = UnusedSessionPruner(log)
+ try:
+ while not pruner.isDone():
+ pruner(chunk_size)
+ finally:
+ pruner.cleanUp()
+
+ expected_sessions = set([
+ u'recent_auth',
+ u'recent_unauth',
+ u'yesterday_auth',
+ # u'yesterday_unauth',
+ u'ancient_auth',
+ # u'ancient_unauth',
+ ])
+
+ found_sessions = set(
+ IMasterStore(SessionData).find(SessionData.client_id))
+
+ self.assertEqual(expected_sessions, found_sessions)
+
+ def test_duplicate_session_pruner(self):
+ # None of the sessions created in setUp() are duplicates, so
+ # they will all survive the pruning.
+ expected_sessions = set([
+ u'recent_auth',
+ u'recent_unauth',
+ u'yesterday_auth',
+ u'yesterday_unauth',
+ u'ancient_auth',
+ u'ancient_unauth',
+ ])
+
+ now = datetime.now(UTC)
+
+ # Make some duplicate logins from a few days ago.
+ # Only the most recent 6 will be kept. Oldest is 'old dupe 9',
+ # most recent 'old dupe 1'.
+ for count in range(1, 10):
+ self.make_session(
+ u'old dupe %d' % count,
+ now - timedelta(days=2, seconds=count),
+ 'old dupe')
+ for count in range(1, 7):
+ expected_sessions.add(u'old dupe %d' % count)
+
+ # Make some other duplicate logins less than an hour old.
+ # All of these will be kept.
+ for count in range(1, 10):
+ self.make_session(u'new dupe %d' % count, now, 'new dupe')
+ expected_sessions.add(u'new dupe %d' % count)
+
+ chunk_size = 2
+ log = BufferLogger()
+ pruner = DuplicateSessionPruner(log)
+ try:
+ while not pruner.isDone():
+ pruner(chunk_size)
+ finally:
+ pruner.cleanUp()
+
+ found_sessions = set(
+ IMasterStore(SessionData).find(SessionData.client_id))
+
+ self.assertEqual(expected_sessions, found_sessions)
+
+
class TestGarbo(TestCaseWithFactory):
layer = LaunchpadZopelessLayer
@@ -209,7 +365,7 @@
return collector
def test_OAuthNoncePruner(self):
- now = datetime.utcnow().replace(tzinfo=UTC)
+ now = datetime.now(UTC)
timestamps = [
now - timedelta(days=2), # Garbage
now - timedelta(days=1) - timedelta(seconds=60), # Garbage
@@ -287,7 +443,7 @@
self.failUnless(earliest >= now - 24*60*60, 'Still have old nonces')
def test_CodeImportResultPruner(self):
- now = datetime.utcnow().replace(tzinfo=UTC)
+ now = datetime.now(UTC)
store = IMasterStore(CodeImportResult)
results_to_keep_count = (
@@ -344,7 +500,7 @@
>= now - timedelta(days=30))
def test_CodeImportEventPruner(self):
- now = datetime.utcnow().replace(tzinfo=UTC)
+ now = datetime.now(UTC)
store = IMasterStore(CodeImportResult)
LaunchpadZopelessLayer.switchDbUser('testadmin')
=== modified file 'lib/lp/services/configure.zcml'
--- lib/lp/services/configure.zcml 2011-03-07 16:32:12 +0000
+++ lib/lp/services/configure.zcml 2011-03-08 07:56:23 +0000
@@ -16,5 +16,6 @@
<include package=".profile" />
<include package=".salesforce" />
<include package=".scripts" />
+ <include package=".session" />
<include package=".worlddata" />
</configure>
=== added directory 'lib/lp/services/session'
=== added file 'lib/lp/services/session/__init__.py'
=== added file 'lib/lp/services/session/adapters.py'
--- lib/lp/services/session/adapters.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/session/adapters.py 2011-03-08 07:56:23 +0000
@@ -0,0 +1,40 @@
+# Copyright 2011 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Session adapters."""
+
+__metaclass__ = type
+__all__ = []
+
+
+from zope.component import adapter
+from zope.interface import implementer
+
+from canonical.database.sqlbase import session_store
+from canonical.launchpad.interfaces.lpstorm import (
+ IMasterStore,
+ ISlaveStore,
+ IStore,
+ )
+from lp.services.session.interfaces import IUseSessionStore
+
+
+@adapter(IUseSessionStore)
+@implementer(IMasterStore)
+def session_master_store(cls):
+ """Adapt a Session database object to an `IMasterStore`."""
+ return session_store()
+
+
+@adapter(IUseSessionStore)
+@implementer(ISlaveStore)
+def session_slave_store(cls):
+ """Adapt a Session database object to an `ISlaveStore`."""
+ return session_store()
+
+
+@adapter(IUseSessionStore)
+@implementer(IStore)
+def session_default_store(cls):
+ """Adapt an Session database object to an `IStore`."""
+ return session_store()
=== added file 'lib/lp/services/session/configure.zcml'
--- lib/lp/services/session/configure.zcml 1970-01-01 00:00:00 +0000
+++ lib/lp/services/session/configure.zcml 2011-03-08 07:56:23 +0000
@@ -0,0 +1,12 @@
+<!-- Copyright 2011 Canonical Ltd. This software is licensed under the
+ GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+<configure
+ xmlns="http://namespaces.zope.org/zope"
+ xmlns:browser="http://namespaces.zope.org/browser"
+ xmlns:i18n="http://namespaces.zope.org/i18n"
+ i18n_domain="launchpad">
+ <adapter factory=".adapters.session_master_store" />
+ <adapter factory=".adapters.session_slave_store" />
+ <adapter factory=".adapters.session_default_store" />
+</configure>
=== added file 'lib/lp/services/session/interfaces.py'
--- lib/lp/services/session/interfaces.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/session/interfaces.py 2011-03-08 07:56:23 +0000
@@ -0,0 +1,15 @@
+# Copyright 2011 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Session interfaces."""
+
+__metaclass__ = type
+__all__ = ['IUseSessionStore']
+
+
+from zope.interface import Interface
+
+
+class IUseSessionStore(Interface):
+ """Marker interface for Session Storm database classes and instances."""
+ pass
=== added file 'lib/lp/services/session/model.py'
--- lib/lp/services/session/model.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/session/model.py 2011-03-08 07:56:23 +0000
@@ -0,0 +1,47 @@
+# Copyright 2011 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Session Storm database classes"""
+
+__metaclass__ = type
+__all__ = ['SessionData', 'SessionPkgData']
+
+from storm.locals import (
+ Pickle,
+ Storm,
+ Unicode,
+ )
+from zope.interface import (
+ classProvides,
+ implements,
+ )
+
+from canonical.database.datetimecol import UtcDateTimeCol
+from lp.services.session.interfaces import IUseSessionStore
+
+
+class SessionData(Storm):
+ """A user's Session."""
+
+ classProvides(IUseSessionStore)
+ implements(IUseSessionStore)
+
+ __storm_table__ = 'SessionData'
+ client_id = Unicode(primary=True)
+ created = UtcDateTimeCol()
+ last_accessed = UtcDateTimeCol()
+
+
+class SessionPkgData(Storm):
+ """Data storage for a Session."""
+
+ classProvides(IUseSessionStore)
+ implements(IUseSessionStore)
+
+ __storm_table__ = 'SessionPkgData'
+ __storm_primary__ = 'client_id', 'product_id', 'key'
+
+ client_id = Unicode()
+ product_id = Unicode()
+ key = Unicode()
+ pickle = Pickle()
=== added directory 'lib/lp/services/session/tests'
=== added file 'lib/lp/services/session/tests/__init__.py'
=== added file 'lib/lp/services/session/tests/test_session.py'
--- lib/lp/services/session/tests/test_session.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/session/tests/test_session.py 2011-03-08 07:56:23 +0000
@@ -0,0 +1,32 @@
+# Copyright 2011 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Session tests."""
+
+__metaclass__ = type
+
+from canonical.launchpad.interfaces.lpstorm import (
+ IMasterStore,
+ ISlaveStore,
+ IStore,
+ )
+from canonical.testing.layers import DatabaseFunctionalLayer
+from lp.services.session.model import (
+ SessionData,
+ SessionPkgData,
+ )
+from lp.testing import TestCase
+
+
+class TestSessionModelAdapters(TestCase):
+ layer = DatabaseFunctionalLayer
+
+ def test_adapters(self):
+ for adapter in [IMasterStore, ISlaveStore, IStore]:
+ for cls in [SessionData, SessionPkgData]:
+ for obj in [cls, cls()]:
+ store = adapter(obj)
+ self.assert_(
+ 'session' in store.get_database()._dsn,
+ 'Unknown store returned adapting %r to %r'
+ % (obj, adapter))
=== modified file 'lib/lp/testing/tests/test_standard_test_template.py'
--- lib/lp/testing/tests/test_standard_test_template.py 2011-02-02 13:19:02 +0000
+++ lib/lp/testing/tests/test_standard_test_template.py 2011-03-08 07:56:23 +0000
@@ -6,15 +6,16 @@
__metaclass__ = type
from canonical.testing.layers import DatabaseFunctionalLayer
-from lp.testing import TestCase # or TestCaseWithFactory
+# or TestCaseWithFactory
+from lp.testing import TestCase
class TestSomething(TestCase):
# XXX: Sample test class. Replace with your own test class(es).
- # XXX: Optional layer--see lib/canonical/testing/layers.py
- # Get the simplest layer that your test will work on, or if you
- # don't even use the database, don't set it at all.
+ # XXX: layer--see lib/canonical/testing/layers.py
+ # Get the simplest layer that your test will work on. For unit tests
+ # requiring no resources, this is BaseLayer.
layer = DatabaseFunctionalLayer
# XXX: Sample test. Replace with your own test methods.