From ac074848fe57185bcfd21dd9eeae21f988ea837c Mon Sep 17 00:00:00 2001 From: Daniel Nouri Date: Tue, 15 Feb 2011 14:04:56 +0100 Subject: [PATCH] Add a user database interface and implementation. --- README.rst | 10 ++++++ kotti/__init__.py | 8 +++-- kotti/resources.py | 6 ++-- kotti/security.py | 87 +++++++++++++++++++++++++++++++++++++++++++++- kotti/tests.py | 60 ++++++++++++++++++++++++++++++++ 5 files changed, 165 insertions(+), 6 deletions(-) diff --git a/README.rst b/README.rst index 3a1a2a6a1..4c4e83b5f 100644 --- a/README.rst +++ b/README.rst @@ -95,6 +95,16 @@ The ``kotti.session_factory`` configuration variable allows the overriding of the default session factory, which is `pyramid.session.UnencryptedCookieSessionFactoryConfig`_. +*kotti.users* +------------- + +Kotti comes with a default user database implementation in +``kotti.security.users``. You can use the ``kotti.users`` +configuration variable to override the implementation used. The +default looks like this:: + + kotti.users = kotti.security.users + *kotti.templates.master_view* and *kotti.templates.master_edit* --------------------------------------------------------------- diff --git a/kotti/__init__.py b/kotti/__init__.py index f8108339d..1210a6d08 100644 --- a/kotti/__init__.py +++ b/kotti/__init__.py @@ -1,5 +1,3 @@ -from kotti.security import list_groups_callback - from sqlalchemy import engine_from_config from pyramid.authentication import AuthTktAuthenticationPolicy from pyramid.authorization import ACLAuthorizationPolicy @@ -29,7 +27,11 @@ def __getitem__(self, key): else: return value + def __getattr__(self, key): + return self[key] + def authtkt_factory(**kwargs): + from kotti.security import list_groups_callback kwargs.setdefault('callback', list_groups_callback) return AuthTktAuthenticationPolicy(**kwargs) @@ -56,6 +58,7 @@ def none_factory(**kwargs): 'kotti.authentication_policy_factory': 'kotti.authtkt_factory', 'kotti.authorization_policy_factory': 'kotti.acl_factory', 'kotti.session_factory': 'kotti.cookie_session_factory', + 'kotti.users': 'kotti.security.users', }, dotted_names=set([ 'kotti.configurators', @@ -64,6 +67,7 @@ def none_factory(**kwargs): 'kotti.authentication_policy_factory', 'kotti.authorization_policy_factory', 'kotti.session_factory', + 'kotti.users', ]), ) diff --git a/kotti/resources.py b/kotti/resources.py index 7695c2e2c..899d760d9 100644 --- a/kotti/resources.py +++ b/kotti/resources.py @@ -23,12 +23,12 @@ from pyramid.traversal import resource_path from pyramid.security import view_execution_permitted -from kotti.util import JsonType -from kotti.security import ACL - metadata = MetaData() DBSession = scoped_session(sessionmaker(extension=ZopeTransactionExtension())) +from kotti.util import JsonType +from kotti.security import ACL + class Container(object, DictMixin): """Containers form the API of a Node that's used for subitem access and in traversal. diff --git a/kotti/security.py b/kotti/security.py index 4336ecd7b..e72459b5e 100644 --- a/kotti/security.py +++ b/kotti/security.py @@ -1,7 +1,21 @@ +from datetime import datetime +from UserDict import DictMixin + +from sqlalchemy import Table +from sqlalchemy import Column +from sqlalchemy import Unicode +from sqlalchemy import DateTime +from sqlalchemy.orm import mapper +from sqlalchemy.orm.exc import NoResultFound from pyramid.location import lineage from pyramid.security import Allow from pyramid.security import ALL_PERMISSIONS +from kotti import configuration +from kotti.resources import DBSession +from kotti.resources import metadata +from kotti.util import JsonType + ALL_PERMISSIONS_SERIALIZED = '__ALL_PERMISSIONS__' class ACL(object): @@ -56,9 +70,13 @@ def list_groups_raw(userid, context): return set() def list_groups(userid, context, _seen=None): + groups = set() if _seen is None: + user = get_users().get(userid) + if user is not None: + groups.update(user.groups) _seen = set() - groups = set() + for item in lineage(context): groups.update(list_groups_raw(userid, item)) @@ -78,3 +96,70 @@ def set_groups(userid, context, groups_to_set): def list_groups_callback(userid, request): return list_groups(userid, request.context) + +def get_users(): + return configuration['kotti.users'][0] + +class Users(DictMixin): + """Kotti's default user database. + + Promises dict-like access to user profiles, and a 'query' method + for finding users. + + This is a default implementation that may be replaced by using the + 'kotti.users' configuration variable. + """ + def __getitem__(self, key): + key = unicode(key) + session = DBSession() + try: + return session.query(User).filter(User.id==key).one() + except NoResultFound: + raise KeyError(key) + + def __setitem__(self, key, user): + key = unicode(key) + session = DBSession() + if isinstance(user, dict): + profile = User(**user) + session.add(profile) + + def __delitem__(self, key): + key = unicode(key) + session = DBSession() + try: + user = session.query(User).filter(User.id==key).one() + session.delete(user) + except NoResultFound: + raise KeyError(key) + + def keys(self): + session = DBSession() + for (userid,) in session.query(User.id): + yield userid + + def query(self, **kwargs): + session = DBSession() + query = session.query(User) + for key, value in kwargs.items(): + attr = getattr(User, key) + query = query.filter(attr.like(value)) + return query + +class User(object): + def __init__(self, id, title=None, groups=()): + self.id = id + self.title = title + self.groups = groups + self.creation_date = datetime.now() + +users = Users() + +users_table = Table('users', metadata, + Column('id', Unicode(100), primary_key=True), + Column('title', Unicode(100)), + Column('groups', JsonType(), nullable=False), + Column('creation_date', DateTime(), nullable=False), +) + +mapper(User, users_table) diff --git a/kotti/tests.py b/kotti/tests.py index c81d6daaa..a0245baf0 100644 --- a/kotti/tests.py +++ b/kotti/tests.py @@ -17,6 +17,7 @@ from kotti.security import list_groups_raw from kotti.security import set_groups from kotti.security import list_groups_callback +from kotti.security import get_users from kotti import main BASE_URL = 'http://localhost:6543' @@ -260,6 +261,65 @@ def test_works_with_auth(self): 'bob', 'group:bobsgroup'] ) +class TestUser(UnitTestBase): + def _make_bob(self): + users = get_users() + users['bob'] = dict( + id=u'bob', title=u'Bob Dabolina', groups=[u'group:bobsgroup']) + + def _assert_is_bob(self, bob): + self.assertEqual(bob.id, u'bob') + self.assertEqual(bob.title, u'Bob Dabolina') + self.assertEqual(bob.groups, [u'group:bobsgroup']) + + def test_users_empty(self): + users = get_users() + self.assertRaises(KeyError, users.__getitem__, u'bob') + self.assertRaises(KeyError, users.__delitem__, u'bob') + self.assertEqual(len(list(users.keys())), 0) + self.assertEqual(len(list(users.query())), 0) + + def test_users_add_and_remove(self): + self._make_bob() + users = get_users() + self._assert_is_bob(users[u'bob']) + self.assertEqual(list(users.keys()), [u'bob']) + + del users['bob'] + self.assertRaises(KeyError, users.__getitem__, u'bob') + self.assertRaises(KeyError, users.__delitem__, u'bob') + + def test_users_query(self): + users = get_users() + self.assertEqual(list(users.query(title=u"%Bob%")), []) + self._make_bob() + [bob] = list(users.query(id=u"bob")) + self._assert_is_bob(bob) + [bob] = list(users.query(title=u"%Bob%")) + self._assert_is_bob(bob) + + def test_groups_from_users(self): + self._make_bob() + + session = DBSession() + root = session.query(Node).get(1) + child = root[u'child'] = Node() + session.flush() + + self.assertEqual(list_groups('bob', root), ['group:bobsgroup']) + + set_groups('group:bobsgroup', root, ['group:editors']) + set_groups('group:editors', child, ['group:foogroup']) + + self.assertEqual( + set(list_groups('bob', root)), + set(['group:bobsgroup', 'group:editors']) + ) + self.assertEqual( + set(list_groups('bob', child)), + set(['group:bobsgroup', 'group:editors', 'group:foogroup']) + ) + class TestEvents(UnitTestBase): def setUp(self): super(TestEvents, self).setUp()