diff --git a/setup.py b/setup.py index f32c2cc..304b9d6 100644 --- a/setup.py +++ b/setup.py @@ -18,4 +18,9 @@ setup( ], packages=find_packages('src', exclude=['distribute_setup']), package_dir={'': 'src'}, + entry_points={ + 'milla.request_validator': [ + 'default = milla.auth:RequestValidator' + ] + } ) diff --git a/src/milla/__init__.py b/src/milla/__init__.py index 6e4b3ea..d2b1674 100644 --- a/src/milla/__init__.py +++ b/src/milla/__init__.py @@ -5,3 +5,4 @@ from app import * from webob.exc import * from webob.request import * from webob.response import * +from auth.decorators import * diff --git a/src/milla/auth/__init__.py b/src/milla/auth/__init__.py new file mode 100644 index 0000000..556ae9d --- /dev/null +++ b/src/milla/auth/__init__.py @@ -0,0 +1,99 @@ +'''Request authorization + +:Created: Apr 5, 2011 +:Author: dustin +:Updated: $Date$ +:Updater: $Author$ +''' + +class NotAuthorized(Exception): + '''Base class for unauthorized exceptions + + This class is both an exception and a controller callable. If the + request validator raises an instance of this class, it will be + called and the resulting value will become the HTTP response. The + default implementation simply returns HTTP status 403 and a simple + body containing the exception message. + ''' + + def __call__(self, request, *args, **kwargs): + '''Return a response indicating the request is not authorized + + :param request: WebOb Request instance for the current request + + All other arguments and keywords are ignored. + ''' + + response = request.ResponseClass(unicode(self)) + response.status_int = 403 + return response + +class RequestValidator(object): + '''Base class for request validators + + A request validator is a class that exposes a ``validate`` method, + which accepts an instance of :py:class:`webob.Request` and an + optional ``requirement``. The ``validate`` method should return + ``None`` on successful validation, or raise an instance of + :py:exc:`NotAuthorized` on failure. The base implementation will + raise an instance of the exception specified by + :py:attr:`exc_class`, which defaults to :py:class`NotAuthorized`. + + To customize the response to unauthorized requests, it is + sufficient to subclass :py:class:`NotAuthorized`, override its + :py:meth:`~NotAuthorized.__call__` method, and specify the class + in :py:attr:`exc_class`. + ''' + + #: Exception class to raise if the request is unauthorized + exc_class = NotAuthorized + + def validate(self, request, requirement=None): + '''Validates a request + + :param request: The request to validate. Should be an instance + of :py:class:`webob.Request`. + :param requirement: (Optional) A requirement to check. Should be + an instance of :py:class:`~milla.auth.permissions.Permission` + or :py:class:`~milla.auth.permissions.PermissionRequirement`, + or some other class with a ``check`` method that accepts a + sequence of permissions. + + The base implementation will perform authorization in the + following way: + + 1. Does the ``request`` have a ``user`` attribute? If not, + raise :py:exc:`NotAuthorized`. + 2. Is the truth value of ``request.user`` true? If not, raise + :py:exc:`NotAuthorized`. + 3. Does the ``request.user`` object have a ``permissions`` + attribute? If not, raise :py:exc:`NotAuthorized`. + 4. Do the user's permissions meet the requirements? If not, + raise :py:exc:`NotAuthorized`. + + If none of the above steps raised an exception, the method will + return ``None``, indicating that the validation was successful. + + .. note:: WebOb Request instances do not have a ``user`` + attribute by default. You will need to supply this yourself, + i.e. in a WSGI middleware or in the ``__before__`` method of + your controller class. + ''' + + try: + user = request.user + except AttributeError: + # No user associated with the request at all + raise self.exc_class('Request has no user') + + if not user: + raise self.exc_class('Anonymous not allowed') + + if requirement: + try: + user_perms = user.permissions + except AttributeError: + raise self.exc_class('User has no permissions') + + if not requirement.check(user_perms): + raise self.exc_class('User does not have required permissions') diff --git a/src/milla/auth/decorators.py b/src/milla/auth/decorators.py new file mode 100644 index 0000000..2163f23 --- /dev/null +++ b/src/milla/auth/decorators.py @@ -0,0 +1,126 @@ +'''Convenient decorators for enforcing authorization on controllers + +:Created: Mar 3, 2011 +:Author: dustin +:Updated: $Date$ +:Updater: $Author$ +''' + +from functools import wraps +from milla.auth import RequestValidator, NotAuthorized, permissions +import milla +import pkg_resources + +__all__ = ['auth_required', 'require_perms'] + +VALIDATOR_EP_GROUP = 'milla.request_validator' + +def _find_request(*args, **kwargs): + try: + return kwargs['request'] + except KeyError: + for arg in args: + if isinstance(arg, milla.Request): + return arg + +def _validate_request(func, requirement, *args, **kwargs): + request = _find_request(*args, **kwargs) + ep_name = request.config.get('request_validator', 'default') + + # Override the RequestVariable name with a class from the specified + # entry point, if one is available. Otherwise, the default is used. + for ep in pkg_resources.iter_entry_points(VALIDATOR_EP_GROUP, ep_name): + try: + RequestValidator = ep.load() + except: + continue + + try: + validator = RequestValidator() + validator.validate(request, requirement) + except NotAuthorized as e: + return e(request) + return func(*args, **kwargs) + +def auth_required(func): + '''Simple decorator to enforce authentication for a controller + + Example usage:: + + class SomeController(object): + + def __before__(request): + request.user = find_a_user_somehow(request) + + @milla.auth_required + def __call__(request): + return 'Hello, world!' + + In this example, the ``SomeController`` controller class implements + an ``__before__`` method that adds the ``user`` attribute to the + ``request`` instance. This could be done by extracting user + information from the HTTP session, for example. The ``__call__`` + method is decorated with ``auth_required``, which will ensure that + the user is successfully authenticated. This is handled by a + *request validator*. + + If the request is not authorized, the decorated method will never + be called. Instead, the response is generated by calling the + :py:exc:`~milla.auth.NotAuthorized` exception raised inside + the ``auth_required`` decorator. + ''' + + @wraps(func) + def wrapper(*args, **kwargs): + return _validate_request(func, None, *args, **kwargs) + return wrapper + +class require_perms(object): + '''Decorator that requires the user have certain permissions + + Example usage:: + + class SomeController(object): + + def __before__(request): + request.user = find_a_user_somehow(request) + + @milla.require_perms('some_permission', 'and_this_permission') + def __call__(request): + return 'Hello, world!' + + In this example, the ``SomeController`` controller class implements + an ``__before__`` method that adds the ``user`` attribute to the + ``request`` instance. This could be done by extracting user + information from the HTTP session, for example. The ``__call__`` + method is decorated with ``require_perms``, which will ensure that + the user is successfully authenticated and the the user has the + specified permissions. This is handled by a *request validator*. + + There are two ways to specify the required permissions: + + * By passing the string name of all required permissions as + positional arguments. A complex permission requirement will be + constructed that requires *all* of the given permissions to be + held by the user in order to validate + * By explicitly passing an instance of + :py:class:`~milla.auth.permissions.Permission` or + :py:class:`~milla.auth.permissions.PermissionRequirement` + ''' + + def __init__(self, *requirements): + requirement = None + for req in requirements: + if isinstance(req, basestring): + req = permissions.Permission(req) + if not requirement: + requirement = req + else: + requirement &= req + self.requirement = requirement + + def __call__(self, func): + @wraps(func) + def wrapper(*args, **kwargs): + return _validate_request(func, self.requirement, *args, **kwargs) + return wrapper diff --git a/src/milla/auth/permissions.py b/src/milla/auth/permissions.py new file mode 100644 index 0000000..038c90d --- /dev/null +++ b/src/milla/auth/permissions.py @@ -0,0 +1,127 @@ +'''Classes for calculating user permissions + +Examples:: + + >>> req = Permission('foo') & Permission('bar') + >>> req.check(PermissionContainer(['foo', 'baz'], ['bar'])) + True + + >>> req = Permission('login') + >>> req.check(['login']) + True + + >>> req = Permission('login') | Permission('admin') + >>> req.check(['none']) + False +''' + +class PermissionContainer(object): + '''Container object for user and group permissions + + :param list user_perms: List of permissions held by the user itself + :param list group_perms: List of permissions held by the groups to + which the user belongs + + Iterating over :py:class:`PermissionContainer` objects results in + a flattened representation of all permissions. + ''' + + def __init__(self, user_perms=[], group_perms=[]): + self._user_perms = user_perms + self._group_perms = group_perms + + def __iter__(self): + for perm in self._user_perms: + yield perm + for perm in self._group_perms: + yield perm + + def __in__(self, perm): + return perm in self._user_perms or perm in self._group_perms + + +class BasePermission(object): + '''Base class for permissions and requirements + + Complex permission requirements can be created using the bitwise + ``and`` and ``or`` operators:: + + login_and_view = Permission('login') & Permission('view') + admin_or_root = Permission('admin') | Permission('root') + + complex = Permission('login') & Permission('view') | Permission('admin') + ''' + + def __and__(self, other): + assert isinstance(other, BasePermission) + return PermissionRequirementAll(self, other) + + def __or__(self, other): + assert isinstance(other, BasePermission) + return PermissionRequirementAny(self, other) + +class Permission(BasePermission): + '''Simple permission implementation + + :param str name: Name of the permission + + Permissions must implement a ``check`` method that accepts an + iterable and returns ``True`` if the permission is present or + ``False`` otherwise. + ''' + + def __init__(self, name): + self.name = name + + def __unicode__(self): + if isinstance(self.name, unicode): + return self.name + else: + return self.name.decode('utf-8') + + def __str__(self): + if isinstance(self.name, str): + return self.name + else: + return self.name.encode('utf-8') + + def __eq__(self, other): + return self is other or str(self) == str(other) + + def check(self, perms): + '''Check if the permission is held + + This method can be overridden to provide more robust + support, but this implementation is simple:: + + return self in perms + ''' + + return self in perms + +class PermissionRequirement(BasePermission): + '''Base class for complex permission requirements''' + + def __init__(self, *requirements): + self.requirements = requirements + + def __str__(self): + return unicode(self).encode('utf-8') + +class PermissionRequirementAll(PermissionRequirement): + '''Complex permission requirement needing all given permissions''' + + def check(self, perms): + for req in self.requirements: + if not req.check(perms): + return False + return True + +class PermissionRequirementAny(PermissionRequirement): + '''Complex permission requirement needing any given permissions''' + + def check(self, perms): + for req in self.requirements: + if req.check(perms): + return True + return False