summaryrefslogtreecommitdiff
path: root/pecan/secure.py
blob: cca81f0fba18141f599adde105d5f33871958709 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
from functools import wraps
from inspect import getmembers, isfunction
from webob import exc

import six

if six.PY3:
    from .compat import is_bound_method as ismethod
else:
    from inspect import ismethod

from .decorators import expose
from .util import _cfg, iscontroller

__all__ = ['unlocked', 'secure', 'SecureController']


class _SecureState(object):
    def __init__(self, desc, boolean_value):
        self.description = desc
        self.boolean_value = boolean_value

    def __repr__(self):
        return '<SecureState %s>' % self.description

    def __nonzero__(self):
        return self.boolean_value

    def __bool__(self):
        return self.__nonzero__()

Any = _SecureState('Any', False)
Protected = _SecureState('Protected', True)


# security method decorators
def _unlocked_method(func):
    _cfg(func)['secured'] = Any
    return func


def _secure_method(check_permissions_func):
    def wrap(func):
        cfg = _cfg(func)
        cfg['secured'] = Protected
        cfg['check_permissions'] = check_permissions_func
        return func
    return wrap


# classes to assist with wrapping attributes
class _UnlockedAttribute(object):
    def __init__(self, obj):
        self.obj = obj

    @_unlocked_method
    @expose()
    def _lookup(self, *remainder):
        return self.obj, remainder


class _SecuredAttribute(object):
    def __init__(self, obj, check_permissions):
        self.obj = obj
        self.check_permissions = check_permissions
        self._parent = None

    def _check_permissions(self):
        if isinstance(self.check_permissions, six.string_types):
            return getattr(self.parent, self.check_permissions)()
        else:
            return self.check_permissions()

    def __get_parent(self):
        return self._parent

    def __set_parent(self, parent):
        if ismethod(parent):
            self._parent = six.get_method_self(parent)
        else:
            self._parent = parent
    parent = property(__get_parent, __set_parent)

    @_secure_method('_check_permissions')
    @expose()
    def _lookup(self, *remainder):
        return self.obj, remainder


# helper for secure decorator
def _allowed_check_permissions_types(x):
    return (
        ismethod(x) or
        isfunction(x) or
        isinstance(x, six.string_types)
    )


# methods that can either decorate functions or wrap classes
# these should be the main methods used for securing or unlocking
def unlocked(func_or_obj):
    """
    This method unlocks method or class attribute on a SecureController.  Can
    be used to decorate or wrap an attribute
    """
    if ismethod(func_or_obj) or isfunction(func_or_obj):
        return _unlocked_method(func_or_obj)
    else:
        return _UnlockedAttribute(func_or_obj)


def secure(func_or_obj, check_permissions_for_obj=None):
    """
    This method secures a method or class depending on invocation.

    To decorate a method use one argument:
        @secure(<check_permissions_method>)

    To secure a class, invoke with two arguments:
        secure(<obj instance>, <check_permissions_method>)
    """
    if _allowed_check_permissions_types(func_or_obj):
        return _secure_method(func_or_obj)
    else:
        if not _allowed_check_permissions_types(check_permissions_for_obj):
            msg = "When securing an object, secure() requires the " + \
                  "second argument to be method"
            raise TypeError(msg)
        return _SecuredAttribute(func_or_obj, check_permissions_for_obj)


class SecureControllerMeta(type):
    """
    Used to apply security to a controller.
    Implementations of SecureController should extend the
    `check_permissions` method to return a True or False
    value (depending on whether or not the user has permissions
    to the controller).
    """
    def __init__(cls, name, bases, dict_):
        cls._pecan = dict(
            secured=Protected,
            check_permissions=cls.check_permissions,
            unlocked=[]
        )

        for name, value in getmembers(cls)[:]:
            if (isfunction if six.PY3 else ismethod)(value):
                if iscontroller(value) and value._pecan.get(
                    'secured'
                ) is None:
                    # Wrap the function so that the security context is
                    # local to this class definition.  This works around
                    # the fact that unbound method attributes are shared
                    # across classes with the same bases.
                    wrapped = _make_wrapper(value)
                    wrapped._pecan['secured'] = Protected
                    wrapped._pecan['check_permissions'] = \
                        cls.check_permissions
                    setattr(cls, name, wrapped)
            elif hasattr(value, '__class__'):
                if name.startswith('__') and name.endswith('__'):
                    continue
                if isinstance(value, _UnlockedAttribute):
                    # mark it as unlocked and remove wrapper
                    cls._pecan['unlocked'].append(value.obj)
                    setattr(cls, name, value.obj)
                elif isinstance(value, _SecuredAttribute):
                    # The user has specified a different check_permissions
                    # than the class level version.  As far as the class
                    # is concerned, this method is unlocked because
                    # it is using a check_permissions function embedded in
                    # the _SecuredAttribute wrapper
                    cls._pecan['unlocked'].append(value)


class SecureControllerBase(object):

    @classmethod
    def check_permissions(cls):
        """
        Returns `True` or `False` to grant access.  Implemented in subclasses
        of :class:`SecureController`.
        """
        return False


SecureController = SecureControllerMeta(
    'SecureController',
    (SecureControllerBase,),
    {'__doc__': SecureControllerMeta.__doc__}
)


def _make_wrapper(f):
    """return a wrapped function with a copy of the _pecan context"""
    @wraps(f)
    def wrapper(*args, **kwargs):
        return f(*args, **kwargs)
    wrapper._pecan = f._pecan.copy()
    return wrapper


# methods to evaluate security during routing
def handle_security(controller, im_self=None):
    """ Checks the security of a controller.  """
    if controller._pecan.get('secured', False):
        check_permissions = controller._pecan['check_permissions']

        if isinstance(check_permissions, six.string_types):
            check_permissions = getattr(
                im_self or six.get_method_self(controller),
                check_permissions
            )

        if not check_permissions():
            raise exc.HTTPUnauthorized


def cross_boundary(prev_obj, obj):
    """ Check permissions as we move between object instances. """
    if prev_obj is None:
        return

    if isinstance(obj, _SecuredAttribute):
        # a secure attribute can live in unsecure class so we have to set
        # while we walk the route
        obj.parent = prev_obj

    if hasattr(prev_obj, '_pecan'):
        if obj not in prev_obj._pecan.get('unlocked', []):
            handle_security(prev_obj)