Sun, 11 Nov 2007

Making Python Do the Hard Stuff

At work we use a Python decorator to restrict access to certain web controller methods based on the current user's permissions. This was done using by simplying ANDing the permissions together. For example:

@permissions('read', 'write')
def some_method(...):
	...
The above means that a user would need both the 'read' and 'write' permission in order to be able to call the method. The permissions decorator only adds an attribute to the function being decorated. The actual permission enforcement is done in the base controller class as the request comes in.

This approach worked well for a while but then requirements started appearing for more complex relationships between permissions. What if a method required that a user has "read" and "write" or just "admin"? What about negation?

One approach could be to develop a set of classes that can be used together to build a representation of the required conditional expression. This would look something like:

@permissions(PermOr(PermAnd('read', 'write'), 'admin'))
def some_method(...):
	...
The condition objects could perform recursive evaluation of the permissions. It would work but it's not exactly easy to read and requires that we create a class for each possible operator that we want to support.

Wouldn't it be nice to just write the expression out as a simple consise string? Then we'd end up with something like:

@permissions('(read and write) or admin')
def some_method(...):
	...
Yes I hear you say, that would get nice, but wouldn't that require implementing a parser and an engine to evaluate these strings? Not when you can get Python to do the work for you! Python already has a parser and a clean expression syntax so let's use it.

The trick is to use Python's eval() built-in function and provide our own the symbols when the expression is evaluated. By providing a specially prepared dictionary to the locals argument of eval() we can have Python evaluate the permissions expression for us.

The code is short and quite simple. It looks like this:

class PermsToLocals(object):
    """A simple read-only dictionary-like object that returns True if a given
    key is in the supplied list.

    Eg:
        d = PermsToLocals(['read', 'write'])
        d['read'] == True
        d['write'] == True
        d['admin'] == False
    """

    def __init__(self, perms):
        self._perms = perms

    def __getitem__(self, key):
        return key in self._perms

def has_permission(perm_expression, perms):
    if perm_expression:
        return eval(perm_expression, {}, PermsToLocals(perms))
    elif perm_expression == '':
        # Empty expression means "no permissions required'
        return True
    else:
        raise ValueError("can't handle expression %r" % perm_expression)

A quick usage demo...

>>> has_permission('read', ['read', 'write'])
True
>>> has_permission('write', ['read', 'write'])
True
>>> has_permission('read or write', ['read', 'write'])
True
>>> has_permission('read and write', ['read', 'write'])
True
>>> has_permission('read and admin', ['read', 'write'])
False
>>> has_permission('read and admin', ['read', 'write', 'admin'])
True
>>> has_permission('(read and write) or admin', ['read'])
False
>>> has_permission('(read and write) or admin', ['read', 'write'])
True
>>> has_permission('(read and write) or admin', ['admin'])
True
>>>

Because we are using Python's parser and evaluation engine, deeply nested expressions and any boolean operator can be used out of the box. Nice.

One caveat is that you have to to be sure that the permission names use only characters that are valid for Python variable names. If you have a need for unusual characters or need to use full stops in the permission names then translate these on the way in in such a way that won't cause name collisions. Replacing unusual chars in permission names and expressions with "_" before evaluation should work in most cases.

Also, don't even think of using this technique when the expression strings might come from an untrusted source. This is asking for serious trouble because you are putting the Python interpreter in someone else's hands. This technique is only useful when only the programmers who are generating the expressions.

posted at: 13:21 | permalink | comments