Merge lp:~soren/surveilr/auth into lp:surveilr

Proposed by Soren Hansen
Status: Merged
Approved by: Soren Hansen
Approved revision: 21
Merged at revision: 21
Proposed branch: lp:~soren/surveilr/auth
Merge into: lp:surveilr
Diff against target: 643 lines (+387/-22)
15 files modified
surveilr/api/__init__.py (+19/-0)
surveilr/api/server/__init__.py (+21/-0)
surveilr/api/server/app.py (+42/-10)
surveilr/api/server/auth.py (+48/-0)
surveilr/api/server/factory.py (+29/-0)
surveilr/defaults.cfg (+12/-3)
surveilr/models.py (+6/-0)
surveilr/tests/api/server/__init__.py (+19/-0)
surveilr/tests/api/server/test_app.py (+30/-9)
surveilr/tests/api/server/test_auth.py (+82/-0)
surveilr/tests/api/server/test_factory.py (+43/-0)
surveilr/tests/test_utils.py (+6/-0)
surveilr/utils.py (+6/-0)
surveilr/who.ini (+23/-0)
tools/pip-requirements.txt (+1/-0)
To merge this branch: bzr merge lp:~soren/surveilr/auth
Reviewer Review Type Date Requested Status
Soren Hansen Pending
Review via email: mp+87544@code.launchpad.net

Commit message

Add authentication

Use repoze.who to provide a simple authentication system.

To post a comment you must log in.
lp:~soren/surveilr/auth updated
21. By Soren Hansen

Add authentication

Use repoze.who to provide a simple authentication system.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'surveilr/api/__init__.py'
2--- surveilr/api/__init__.py 2011-11-19 22:01:55 +0000
3+++ surveilr/api/__init__.py 2012-01-04 23:13:32 +0000
4@@ -0,0 +1,19 @@
5+"""
6+ Surveilr - Log aggregation, analysis and visualisation
7+
8+ Copyright (C) 2011 Linux2Go
9+
10+ This program is free software: you can redistribute it and/or
11+ modify it under the terms of the GNU Affero General Public License
12+ as published by the Free Software Foundation, either version 3 of
13+ the License, or (at your option) any later version.
14+
15+ This program is distributed in the hope that it will be useful,
16+ but WITHOUT ANY WARRANTY; without even the implied warranty of
17+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18+ GNU Affero General Public License for more details.
19+
20+ You should have received a copy of the GNU Affero General Public
21+ License along with this program. If not, see
22+ <http://www.gnu.org/licenses/>.
23+"""
24
25=== added directory 'surveilr/api/server'
26=== added file 'surveilr/api/server/__init__.py'
27--- surveilr/api/server/__init__.py 1970-01-01 00:00:00 +0000
28+++ surveilr/api/server/__init__.py 2012-01-04 23:13:32 +0000
29@@ -0,0 +1,21 @@
30+"""
31+ Surveilr - Log aggregation, analysis and visualisation
32+
33+ Copyright (C) 2011 Linux2Go
34+
35+ This program is free software: you can redistribute it and/or
36+ modify it under the terms of the GNU Affero General Public License
37+ as published by the Free Software Foundation, either version 3 of
38+ the License, or (at your option) any later version.
39+
40+ This program is distributed in the hope that it will be useful,
41+ but WITHOUT ANY WARRANTY; without even the implied warranty of
42+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
43+ GNU Affero General Public License for more details.
44+
45+ You should have received a copy of the GNU Affero General Public
46+ License along with this program. If not, see
47+ <http://www.gnu.org/licenses/>.
48+
49+ Log collection server implementation
50+"""
51
52=== renamed file 'surveilr/api/server.py' => 'surveilr/api/server/app.py'
53--- surveilr/api/server.py 2011-12-18 14:46:48 +0000
54+++ surveilr/api/server/app.py 2012-01-04 23:13:32 +0000
55@@ -18,11 +18,12 @@
56 License along with this program. If not, see
57 <http://www.gnu.org/licenses/>.
58
59- Log collection server implementation
60+ API server implementation
61 """
62
63 import eventlet
64 import eventlet.wsgi
65+import functools
66 import json
67 import time
68
69@@ -34,12 +35,29 @@
70 from webob import Response
71 from webob.dec import wsgify
72 from webob.exc import HTTPNotFound
73+from webob.exc import HTTPForbidden
74
75 from surveilr import config
76 from surveilr import messaging
77 from surveilr import models
78 from surveilr import utils
79
80+def is_privileged(req):
81+ if 'surveilr.user' in req.environ:
82+ return req.environ['surveilr.user'].credentials['admin']
83+ # This feels pretty scary
84+ return True
85+
86+
87+def privileged(f):
88+ @functools.wraps(f)
89+ def wrapped(self, req, *args):
90+ if is_privileged(req):
91+ return f(self, req, *args)
92+ else:
93+ return HTTPForbidden()
94+ return wrapped
95+
96
97 class NotificationController(object):
98 """Routes style controller for notifications"""
99@@ -58,15 +76,30 @@
100 class UserController(object):
101 """Routes style controller for actions related to users"""
102
103+ @privileged
104 def create(self, req):
105 """Called for POST requests to /users
106
107 Creates the user, returns a JSON object with the ID assigned
108 to the user"""
109 data = json.loads(req.body)
110- user = models.User(**data)
111+
112+ obj_data = {}
113+
114+ obj_data['credentials'] = {}
115+
116+ if 'admin' in data:
117+ obj_data['credentials']['admin'] = data['admin']
118+
119+ for key in ['messaging_driver', 'messaging_address']:
120+ if key in data:
121+ obj_data[key] = data[key]
122+
123+ user = models.User(**obj_data)
124 user.save()
125- response = {'id': user.key}
126+ response = {'id': user.key,
127+ 'key': user.api_key,
128+ 'admin': user.credentials.get('admin', False)}
129 return Response(json.dumps(response))
130
131 def show(self, req, id):
132@@ -77,7 +110,8 @@
133 user = models.User.get(id)
134 resp_dict = {'id': user.key,
135 'messaging_driver': user.messaging_driver,
136- 'messaging_address': user.messaging_address}
137+ 'messaging_address': user.messaging_address,
138+ 'admin': user.credentials.get('admin', False)}
139 return Response(json.dumps(resp_dict))
140 except riakalchemy.NoSuchObjectError:
141 return HTTPNotFound()
142@@ -183,7 +217,10 @@
143 path_prefix='/users/{user_id}')
144
145 def __init__(self, global_config):
146- pass
147+ riak_host = config.get_str('riak', 'host')
148+ riak_port = config.get_int('riak', 'port')
149+
150+ riakalchemy.connect(host=riak_host, port=riak_port)
151
152 @wsgify
153 def __call__(self, req):
154@@ -216,11 +253,6 @@
155
156
157 def main():
158- riak_host = config.get_str('riak', 'host')
159- riak_port = config.get_int('riak', 'port')
160-
161- riakalchemy.connect(host=riak_host, port=riak_port)
162-
163 server_factory({}, '', 9877)(SurveilrApplication({}))
164
165
166
167=== added file 'surveilr/api/server/auth.py'
168--- surveilr/api/server/auth.py 1970-01-01 00:00:00 +0000
169+++ surveilr/api/server/auth.py 2012-01-04 23:13:32 +0000
170@@ -0,0 +1,48 @@
171+"""
172+ Surveilr - Log aggregation, analysis and visualisation
173+
174+ Copyright (C) 2011 Linux2Go
175+
176+ This program is free software: you can redistribute it and/or
177+ modify it under the terms of the GNU Affero General Public License
178+ as published by the Free Software Foundation, either version 3 of
179+ the License, or (at your option) any later version.
180+
181+ This program is distributed in the hope that it will be useful,
182+ but WITHOUT ANY WARRANTY; without even the implied warranty of
183+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
184+ GNU Affero General Public License for more details.
185+
186+ You should have received a copy of the GNU Affero General Public
187+ License along with this program. If not, see
188+ <http://www.gnu.org/licenses/>.
189+
190+ API server auth implementation
191+"""
192+
193+from surveilr import models
194+
195+from riakalchemy import NoSuchObjectError
196+
197+class AlwaysRequireAuth(object):
198+ def __call__(self, environ, status, headers):
199+ return 'repoze.who.identity' not in environ
200+
201+
202+class SurveilrAuthPlugin(object):
203+ def authenticate(self, environ, identity):
204+ try:
205+ login = identity['login']
206+ password = identity['password']
207+ except KeyError:
208+ return None
209+
210+ try:
211+ user = models.User.get(key=login)
212+ except NoSuchObjectError:
213+ return None
214+
215+ if user.api_key == password:
216+ environ['surveilr.user'] = user
217+ return login
218+ return None
219
220=== added file 'surveilr/api/server/factory.py'
221--- surveilr/api/server/factory.py 1970-01-01 00:00:00 +0000
222+++ surveilr/api/server/factory.py 2012-01-04 23:13:32 +0000
223@@ -0,0 +1,29 @@
224+"""
225+ Surveilr - Log aggregation, analysis and visualisation
226+
227+ Copyright (C) 2011 Linux2Go
228+
229+ This program is free software: you can redistribute it and/or
230+ modify it under the terms of the GNU Affero General Public License
231+ as published by the Free Software Foundation, either version 3 of
232+ the License, or (at your option) any later version.
233+
234+ This program is distributed in the hope that it will be useful,
235+ but WITHOUT ANY WARRANTY; without even the implied warranty of
236+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
237+ GNU Affero General Public License for more details.
238+
239+ You should have received a copy of the GNU Affero General Public
240+ License along with this program. If not, see
241+ <http://www.gnu.org/licenses/>.
242+
243+ API server factory
244+"""
245+import eventlet
246+
247+def server_factory(global_conf, host, port):
248+ port = int(port)
249+ def serve(application):
250+ socket = eventlet.listen((host, port))
251+ eventlet.wsgi.server(socket, application)
252+ return serve
253
254=== modified file 'surveilr/defaults.cfg'
255--- surveilr/defaults.cfg 2011-12-20 10:59:35 +0000
256+++ surveilr/defaults.cfg 2012-01-04 23:13:32 +0000
257@@ -12,13 +12,22 @@
258 port = 8098
259
260 [server:main]
261-paste.server_factory = surveilr.api.server:server_factory
262+paste.server_factory = surveilr.api.server.factory:server_factory
263 host = 0.0.0.0
264 port = 8977
265
266 [composite:main]
267 use = egg:Paste#urlmap
268-/surveilr = core
269+/surveilr = secured
270+
271+[pipeline:secured]
272+pipeline = who core
273
274 [app:core]
275-paste.app_factory = surveilr.api.server:SurveilrApplication
276+paste.app_factory = surveilr.api.server.app:SurveilrApplication
277+
278+[filter:who]
279+use = egg:repoze.who#config
280+config_file = %(here)s/who.ini
281+log_file = stdout
282+log_level = debug
283
284=== modified file 'surveilr/models.py'
285--- surveilr/models.py 2011-12-09 16:17:30 +0000
286+++ surveilr/models.py 2012-01-04 23:13:32 +0000
287@@ -22,6 +22,7 @@
288 from riakalchemy import RiakObject
289 from riakalchemy.types import Integer, String, Dict, RelatedObjects
290
291+from surveilr import utils
292
293 class Service(RiakObject):
294 """A service that is referenced by many LogEntry's
295@@ -43,9 +44,14 @@
296 """A user of the service"""
297 bucket_name = 'users'
298
299+ api_key = String()
300+ credentials = Dict()
301 messaging_driver = String()
302 messaging_address = String()
303
304+ def pre_save(self):
305+ if not hasattr(self, 'api_key'):
306+ self.api_key = utils.generate_key()
307
308 class LogEntry(RiakObject):
309 """A log entry holding one or more metrics
310
311=== added directory 'surveilr/tests/api/server'
312=== added file 'surveilr/tests/api/server/__init__.py'
313--- surveilr/tests/api/server/__init__.py 1970-01-01 00:00:00 +0000
314+++ surveilr/tests/api/server/__init__.py 2012-01-04 23:13:32 +0000
315@@ -0,0 +1,19 @@
316+"""
317+ Surveilr - Log aggregation, analysis and visualisation
318+
319+ Copyright (C) 2011 Linux2Go
320+
321+ This program is free software: you can redistribute it and/or
322+ modify it under the terms of the GNU Affero General Public License
323+ as published by the Free Software Foundation, either version 3 of
324+ the License, or (at your option) any later version.
325+
326+ This program is distributed in the hope that it will be useful,
327+ but WITHOUT ANY WARRANTY; without even the implied warranty of
328+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
329+ GNU Affero General Public License for more details.
330+
331+ You should have received a copy of the GNU Affero General Public
332+ License along with this program. If not, see
333+ <http://www.gnu.org/licenses/>.
334+"""
335
336=== renamed file 'surveilr/tests/api/test_server.py' => 'surveilr/tests/api/server/test_app.py'
337--- surveilr/tests/api/test_server.py 2011-12-20 10:59:35 +0000
338+++ surveilr/tests/api/server/test_app.py 2012-01-04 23:13:32 +0000
339@@ -27,8 +27,8 @@
340 from surveilr import models
341 from surveilr import tests
342 from surveilr import utils
343-from surveilr.api import server
344-from surveilr.api.server import SurveilrApplication
345+from surveilr.api.server import app
346+from surveilr.api.server.app import SurveilrApplication
347
348
349 class APIServerTests(tests.TestCase):
350@@ -36,12 +36,32 @@
351 super(APIServerTests, self).setUp()
352 self.application = SurveilrApplication({})
353
354+ def test_create_user_denied(self):
355+ """Creating a user is refused for non-privileged users"""
356+ req = Request.blank('/users',
357+ method='POST',
358+ POST=json.dumps({'messaging_driver': 'fake',
359+ 'messaging_address': 'foo'}))
360+ class FakeUser(object):
361+ credentials = {'admin': False}
362+
363+ req.environ['surveilr.user'] = FakeUser()
364+ resp = self.application(req)
365+ self.assertEquals(resp.status_int, 403)
366+
367 def test_create_retrieve_user(self):
368- """Create, retrieve, delete, attempt to retrieve again"""
369+ self._test_create_retrieve_user()
370+
371+ def test_create_retrieve_admin_user(self):
372+ self._test_create_retrieve_user(admin=True)
373+
374+ def _test_create_retrieve_user(self, admin=False):
375+ """Create, retrieve, delete, attempt to retrieve again (user)"""
376 req = Request.blank('/users',
377 method='POST',
378 POST=json.dumps({'messaging_driver': 'fake',
379- 'messaging_address': 'foo'}))
380+ 'messaging_address': 'foo',
381+ 'admin': True}))
382 resp = self.application(req)
383 self.assertEquals(resp.status_int, 200)
384
385@@ -54,6 +74,7 @@
386 user = json.loads(resp.body)
387 self.assertEquals(user['messaging_driver'], 'fake')
388 self.assertEquals(user['messaging_address'], 'foo')
389+ self.assertEquals(user['admin'], True)
390
391 req = Request.blank('/users/%s' % service_id, method='DELETE')
392 resp = self.application(req)
393@@ -81,7 +102,7 @@
394 self.assertEquals(resp.status_int, 200)
395
396 def test_create_retrieve_service(self):
397- """Create, retrieve, delete, attempt to retrieve again"""
398+ """Create, retrieve, delete, attempt to retrieve again (service)"""
399 req = Request.blank('/services',
400 method='POST',
401 POST=json.dumps({'name': 'this_or_the_other'}))
402@@ -149,7 +170,7 @@
403 'timestamp': 13217362355575,
404 'metrics': {'duration': 85000,
405 'response_size': 12435}}))
406- with mock.patch('surveilr.api.server.eventlet') as eventlet:
407+ with mock.patch('surveilr.api.server.app.eventlet') as eventlet:
408 resp = self.application(req)
409
410 self.assertEquals(eventlet.spawn_n.call_args[0][0],
411@@ -167,12 +188,12 @@
412 resp = self.application(req)
413 self.assertEquals(resp.status_int, 404)
414
415- @mock.patch('surveilr.api.server.eventlet', spec=['listen', 'wsgi'])
416- @mock.patch('surveilr.api.server.riakalchemy', spec=['connect'])
417+ @mock.patch('surveilr.api.server.app.eventlet', spec=['listen', 'wsgi'])
418+ @mock.patch('surveilr.api.server.app.riakalchemy', spec=['connect'])
419 def test_main(self, riakalchemy, eventlet):
420 socket_sentinel = mock.sentinel.return_value
421 eventlet.listen.return_value = socket_sentinel
422- server.main()
423+ app.main()
424
425 riakalchemy.connect.assert_called_with(host='127.0.0.1', port=8098)
426 eventlet.listen.assert_called_with(('', 9877))
427
428=== added file 'surveilr/tests/api/server/test_auth.py'
429--- surveilr/tests/api/server/test_auth.py 1970-01-01 00:00:00 +0000
430+++ surveilr/tests/api/server/test_auth.py 2012-01-04 23:13:32 +0000
431@@ -0,0 +1,82 @@
432+"""
433+ Surveilr - Log aggregation, analysis and visualisation
434+
435+ Copyright (C) 2011 Linux2Go
436+
437+ This program is free software: you can redistribute it and/or
438+ modify it under the terms of the GNU Affero General Public License
439+ as published by the Free Software Foundation, either version 3 of
440+ the License, or (at your option) any later version.
441+
442+ This program is distributed in the hope that it will be useful,
443+ but WITHOUT ANY WARRANTY; without even the implied warranty of
444+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
445+ GNU Affero General Public License for more details.
446+
447+ You should have received a copy of the GNU Affero General Public
448+ License along with this program. If not, see
449+ <http://www.gnu.org/licenses/>.
450+
451+ Tests for API server auth
452+"""
453+
454+from surveilr import models
455+from surveilr import tests
456+from surveilr.api.server import auth
457+
458+class TestAPIServerAuth(tests.TestCase):
459+ def test_AlwaysRequireAuth_unauthenticated(self):
460+ decider = auth.AlwaysRequireAuth()
461+ self.assertEquals(decider({}, '200 OK', {}), True)
462+
463+ def test_AlwaysRequireAuth_already_authenticated(self):
464+ decider = auth.AlwaysRequireAuth()
465+ self.assertEquals(decider({'repoze.who.identity': 'someone'}, '200 OK', {}), False)
466+
467+class TestSurveilrAuthPlugin(tests.TestCase):
468+ def test_authenticate_invcomplete_identity(self):
469+ env = {}
470+ identity = {'password': 'apikey'}
471+
472+ self.assertIsNone(auth.SurveilrAuthPlugin().authenticate(env,
473+ identity))
474+ self.assertEquals(env, {})
475+
476+
477+ def test_authenticate_invalid_identity(self):
478+ env = {}
479+ identity = {'login': 'testuser',
480+ 'password': 'apikey'}
481+
482+ self.assertIsNone(auth.SurveilrAuthPlugin().authenticate(env,
483+ identity))
484+ self.assertEquals(env, {})
485+
486+
487+ def test_authenticate_valid_credentials(self):
488+ env = {}
489+
490+ user = models.User()
491+ user.save()
492+ identity = {'login': user.key,
493+ 'password': user.api_key}
494+
495+ self.assertEquals(auth.SurveilrAuthPlugin().authenticate(env,
496+ identity),
497+ user.key)
498+ self.assertEquals(env['surveilr.user'].key, user.key)
499+
500+ def test_authenticate_wrong_key(self):
501+ env = {}
502+
503+ user = models.User()
504+ user.save()
505+ self.addCleanup(user.delete)
506+
507+ identity = {'login': user.key,
508+ 'password': 'not the right key'}
509+
510+ self.assertIsNone(auth.SurveilrAuthPlugin().authenticate(env,
511+ identity),
512+ user.key)
513+ self.assertEquals(env, {})
514
515=== added file 'surveilr/tests/api/server/test_factory.py'
516--- surveilr/tests/api/server/test_factory.py 1970-01-01 00:00:00 +0000
517+++ surveilr/tests/api/server/test_factory.py 2012-01-04 23:13:32 +0000
518@@ -0,0 +1,43 @@
519+"""
520+ Surveilr - Log aggregation, analysis and visualisation
521+
522+ Copyright (C) 2011 Linux2Go
523+
524+ This program is free software: you can redistribute it and/or
525+ modify it under the terms of the GNU Affero General Public License
526+ as published by the Free Software Foundation, either version 3 of
527+ the License, or (at your option) any later version.
528+
529+ This program is distributed in the hope that it will be useful,
530+ but WITHOUT ANY WARRANTY; without even the implied warranty of
531+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
532+ GNU Affero General Public License for more details.
533+
534+ You should have received a copy of the GNU Affero General Public
535+ License along with this program. If not, see
536+ <http://www.gnu.org/licenses/>.
537+
538+ Tests for API server factory
539+"""
540+
541+import mock
542+
543+from surveilr import tests
544+from surveilr.api.server import factory
545+
546+class APIServerFactoryTests(tests.TestCase):
547+ def test_server_factory_returns_callable(self):
548+ self.assertTrue(callable(factory.server_factory({}, 'somehost', 1234)))
549+
550+ @mock.patch('surveilr.api.server.factory.eventlet')
551+ def test_server_factory_returns_server(self, eventlet):
552+ testhost = 'somehost'
553+ testport = '1234'
554+ serve = factory.server_factory({}, testhost, testport)
555+ app = mock.sentinel.app
556+
557+ serve(app)
558+
559+ eventlet.listen.assert_called_with((testhost, int(testport)))
560+ eventlet.wsgi.server.assert_called_with(eventlet.listen.return_value,
561+ app)
562
563=== modified file 'surveilr/tests/test_utils.py'
564--- surveilr/tests/test_utils.py 2011-12-20 09:49:02 +0000
565+++ surveilr/tests/test_utils.py 2012-01-04 23:13:32 +0000
566@@ -23,6 +23,7 @@
567 import json
568 import time
569 import mock
570+import string
571
572 from surveilr import models
573 from surveilr import tests
574@@ -94,3 +95,8 @@
575 self.assertEquals(utils.truncate(133, 20), 120)
576 self.assertEquals(utils.truncate(133, 100), 100)
577 self.assertEquals(utils.truncate(133, 200), 0)
578+
579+ def test_generate_key(self):
580+ key = utils.generate_key()
581+ self.assertEquals(len(key), 32)
582+ self.assertEquals(len(key.strip(string.letters + string.digits)), 0)
583
584=== modified file 'surveilr/utils.py'
585--- surveilr/utils.py 2011-12-11 22:11:47 +0000
586+++ surveilr/utils.py 2012-01-04 23:13:32 +0000
587@@ -22,6 +22,8 @@
588
589 import httplib2
590 import json
591+import random
592+import string
593
594
595 def truncate(number, rounding_factor):
596@@ -39,6 +41,10 @@
597 return (int(number) / int(rounding_factor)) * int(rounding_factor)
598
599
600+def generate_key():
601+ return ''.join([random.choice(string.letters + string.digits)
602+ for x in range(32)])
603+
604 def enhance_data_point(data_point):
605 http = httplib2.Http(timeout=10)
606
607
608=== added file 'surveilr/who.ini'
609--- surveilr/who.ini 1970-01-01 00:00:00 +0000
610+++ surveilr/who.ini 2012-01-04 23:13:32 +0000
611@@ -0,0 +1,23 @@
612+[plugin:basicauth]
613+use = repoze.who.plugins.basicauth:make_plugin
614+realm = 'Surveilr'
615+
616+[plugin:surveilrauth]
617+use = surveilr.api.server.auth:SurveilrAuthPlugin
618+
619+[general]
620+request_classifier = repoze.who.classifiers:default_request_classifier
621+challenge_decider = surveilr.api.server.auth:AlwaysRequireAuth
622+remote_user_key = REMOTE_USER
623+
624+[identifiers]
625+plugins =
626+ basicauth
627+
628+[authenticators]
629+plugins =
630+ surveilrauth
631+
632+[challengers]
633+plugins =
634+ basicauth
635
636=== modified file 'tools/pip-requirements.txt'
637--- tools/pip-requirements.txt 2011-12-20 10:59:35 +0000
638+++ tools/pip-requirements.txt 2012-01-04 23:13:32 +0000
639@@ -13,3 +13,4 @@
640 paste
641 PasteScript
642 -e hg+https://code.google.com/p/soren-bulksms/#egg=BulkSMS
643+repoze.who

Subscribers

People subscribed via source and target branches

to all changes: