Designing a user-orientated permission system

doug
Sun, 14 Mar 2010 03:13:46 +0000

System permissions are important. Defining what people can and can't do with your application is a significant part of security.

There are two perspectives I tend to care about with permissioning. The first is user-orientated and the second is data-orientated. In this article I will talk about designing a user-orientated permission system.

For the purposes of this post a permission will be considered a boolean value that represents whether a person can or can't perform an operation. In other systems you might go as far as to consider the extent to which they have permission which ends up working like a priority based permissiong system. This is only really useful in my opinion if you've an operation two people can perform at once and you wish to provide a fine grained hints to the system as to who should have the operation performed first. It's something to consider but usually unnecessary and out of the scope of this article.

Users and user-level permissioning

In a user-orientated permission system what we care about is what users can do with our system as a whole. Example permissions under this context include CanCreateUsers, CanViewUsers, CanUpdateUsers, CanDeleteUsers, CanInitiateReconciliation, etc.

Concepts that exist in this permission system include:

User

PermissionSet

The relationship between these concepts can be described as a one-to-one as every user should have a set of permissions (even if all those permissions are set to false!). At this point we can make a decision that because our permissioning system is simply we can combine these concepts into one system entity. This entity is described in the following ER diagram.

User groups and group-level permissioning

This will work well for simply systems with a few users. What happens when our systems become more complex and busy. Let's say that our system has so many users that we want to permission groups of them at a time. This might be by their role in the system or simply that an identified collection of users will always need the same permissions. We can create the concept of a UserGroup which defines global attributes available to all Users such as permissions.

Now we have three concepts of note.

User

PermissionSet

UserGroup

Since none of these can be combined we end up with a structure that is described in the following ER diagram.

The relationship between UserGroup and User is a one-to-many. One UserGroup can have many Users. In this case we can note that the relationship between UserGroup and PermissionSet is a one-to-one relationship. We could combine these into a single UserGroup entity as we did in the previous structure. However if we keep them separate we can allow fine grain control over the user permissions.

User groups and user-level permissioning

Shown below we have created a subset of permissions specific to the user. This allows the UserGroup entity to take on a more simple role-based function within our application. Used to set blanket default permissions over a large collection of users that can then be fine tuned for a single user without having to create a new very-simplier but specialised UserGroup just for that single user.

You can see here how a user has a one-to-one relationship with a permission set and that a user group has a one-to-one relationship with a permission set. The relationship between user group and user remains one-to-many. In cases where we don't care about specific user permissions we decide to allow User.userPermissionSetId to be null as indicated by the empty white circle on the relationship between user and permission set. In the application there must be some logic to determine which permission set to use. If userPermissionSetId is null then follow through the user group to find the permission set.

Imagine this scenario. A user is set into a user group with no specific user-level permissions. The administrator then gives the user specific user-level permissions. At a later date the administrator alters the permissions of the user group. Since the user has specific user-level permissions these changes don't affect the user. This could probably be a problem in that it defeats the point of user groups. To keep the relevance of user groups after a user has been given user-level permissions we allow that our permissions in the permission set may be null. What this means to our application logic is that if a user-level permission is null then look for that permission at group-level regardless of whether user-level permissions were assigned as a whole. If we get up to group-level and find a null we just assume false for safetys sake.

Example program logic that resolves these permissions:

class PermissionSet:
    canCreateUsers            = None
    canViewUsers              = None
    canUpdateUsers            = None
    canDeleteUsers            = None
    canInitiateReconciliation = None

class UserGroup:
    permissions = None
    def __init__(self, permissions):
        # This object can only be constructed if it gives permissions since the relationship between user group and permission set is mandatory
        self.permissions = permissions

class User:
    group = None
    permissions = None
    def __init__(self, group, permissions = None):
        # This object can only be constructed if it gives a group since that relationship between user and user group is mandatory
        # User-level permissions are option and default to not-set
        self.group       = group
        self.permissions = permissions

    def has_permission(self, permission_name):
        """Returns true if this user has the named permission. False or None otherwise."""
        value = None
        if self.permissions is not None:
            # We've defined user-level permissions
            value = getattr(self.permissions, permission_name)

        if self.permissions is None or value is None:
            # Either we didn't define user-level permissions or we have no user-level permissions
            value = getattr(self.group.permissions, permission_name)

        return value

If you want to see the above functional structure in action you can use the following code for demonstration.

p1                           = PermissionSet()
p1.canCreateUsers            = True
p1.canUpdateUsers            = True
p1.canViewUsers              = True
p1.canDeleteUsers            = False
p1.canInitiateReconciliation = False

p2                           = PermissionSet()
p2.canCreateUsers            = False
p2.canInitiateReconciliation = True

group                        = UserGroup(p1)
user1                        = User(group)     # Using the default group-level permissions
user2                        = User(group, p2) # Using mostly group-level permissions but with user-level refinements

print "User1 canCreateUsers =", user1.has_permission("canCreateUsers") # True
print "User2 canCreateUsers =", user2.has_permission("canCreateUsers") # False

group.permissions.canCreateUsers = False # Change group-level permissions
print "User1 canCreateUsers =", user1.has_permission("canCreateUsers") # False
print "User2 canCreateUsers =", user2.has_permission("canCreateUsers") # False

Now we have a pretty flexible user-orientated permission system. It can be made even more reflexible if you allow users to have multiple groups or roles but when we get to this level of flexibility we introduce the concept of conflict resolution.

Imagine if you will a user with two roles, user manager and reconciliation administrator. The former role has the first four permissions set to true and canInitiateReconciliation remains null. The latter role has canInitiateReconciliation permission set to true but the rest to null. This is fine as is but what were to happen if somebody decided that the user manager role can definitely not initiate reconciliation and sets that permission to false? We end up with two permission sets for our user with a contradicting setting for canInitiateReconciliation. One possibly resolution is to simply say that if any role permission is true, then the user has permission to perform that operation. This decision lies in the application designer and out of the scope of this artile.

Generalising to hierarchical user entities

It would be nicer if we could create a hierarchy of permission levels rather than the two level system we had before where only user-level and group-level existed. To do this we generalise the concept of what a user and group is to what I'm going to call a user entity. Simply put the entity model can have a single parent and permissions set at any level of the entities parentage. You could then organise your permission scheme into a tree where the terminal leafs represent the users themselves. An example instance view could be a small business where the following diagram describes the users and groupings that exist.

Here we see that Randall and Doug are both part of the London development group, Andrew is part of the London sales group, Joey is part of the New York marketting group and Nigel is part of the New York branch of Example Enterprises. Each of these users inherit the permissions of their parent groups all the way to the very root group.

This allows us greater flexibility about how we organise and inherit permissions. Allowing us to specialise permissions near the bottom and generalising permissions near the top. For instance in the organisation hierarchy the overall policy of the company might deny everything except read permissions. The London development division might grant full permissions to faciliate their business role. The London Sales division might need to update records only so with inheritance they can read and update. Perhaps the marketting department are responsible for initiating a reconciliation but have no other interest in the system. They can enable reconciliation and disable read access.

In order to archieve this we can modify our system to the following:

We generalise both users and groups of users to simply be some sort of user entity that has permissions associated with it. We define users to be entities where no other entity inherits from them. We define groups to be entities that are referred to by at least one other entity. In reality in order to support empty groups we'd need some sort of flag to define the entity as a user or group.

The end result of this generalisation allows us to create this implementation of a hierarchical user-orientated permissioning system that supports permission inheritance.

class PermissionSet:
    """A set of permissions. Must be inherited and availablePermissions defined."""

    # This is a list of the names of the available permissions
    availablePermissions = []

    def __init__(self):
        """Constructor."""
        self.permissions = dict()
        for perm in self.availablePermissions:
            # Initialise all permissions to undefined
            setattr(self, perm, None)


    # __setattr__ is just needed for ease of use in Python to allow the usage of PermissionSet().somePermissionName = True
    def __setattr__(self, name, value):
        """Set attribute."""
        if name in self.availablePermissions:
            # Set a permission value
            self.__dict__["permissions"][name] = value
        else:
            # This is needed for internal operation of this class
            self.__dict__[name] = value



    # __getattr__ is just needed for ease of use in Python to allow the usage of print PermissionSet().somePermissionName
    def __getattr__(self, name):
        """Get attribute."""
        if name in self.availablePermissions:
            # Get a permission value
            return self.__dict__["permissions"][name]
        else:
            # This is needed for internal operation of this class
            return self.__dict__[name]


    # __str__ is just needed to display all the permissions this set represents neatly as a string.
    def __str__(self):
        """Return all available permissions and their current values."""
        return "\n".join(["  %s = %s;" % (k, v) for (k, v) in self.permissions.items()])



class UserEntity:
    """An abstract hierarchical user entity."""

    def __init__(self, name, permissionSetClass, permissions = None, parent = None):
        """Constructor."""
        self.name               = name               # The name of this entity.
        self.permissions        = permissions        # A permission set instance. This should be of the same type as permissionSetClass or None.
        self.parent             = parent             # The parent object for this entity.
        self.permissionSetClass = permissionSetClass # The permission set that this identity references.



    def get_aggregate_permissions(self, aggregate = None, undefinedList = None, visited = None):
        """Returns an aggregate set of permissions. These represent the actual permissions this entity has in total."""

        if visited is None:
            # This is used to guard against cyclic relationships and infinite recursion
            visited = list()

        if aggregate is None:
            # Construct our aggregate permission set
            aggregate = self.permissionSetClass()
            # Make a copy of the available permissions
            undefinedList = list(aggregate.availablePermissions)

        if self.permissions is not None:
            # If we have some level specific permissions copy them into the aggregate.
            newUndefined = list(undefinedList)
            for k in undefinedList:
                v = getattr(self.permissions, k)
                if v is not None and getattr(aggregate, k) is None:
                    # k is set at this level and hasn't been set at a lower level
                    setattr(aggregate, k, v)
                    newUndefined.remove(k) # k is now defined
            undefinedList = newUndefined # Update what permissions remain undefined

        if self.parent is not None and self.parent not in visited and undefinedList:
            # This isn't a top level entity and our parent hasn't previously been visited.
            return self.parent.get_aggregate_permissions(aggregate, undefinedList, visited)
        else:
            # There is absolutely no more definitions for this aggregation at any level in the hierarchy
            return aggregate


    def has_permission(self, permission_name, visited = None):
        """Returns True if this entity has permission, False if this entity does not have permission and None if permission is undefined for this entity."""

        value = None
        if visited is None:
            # This is used to guard against cyclic relationships and infinite recursion
            visited = list()

        if self.permissions is not None:
            # We have our own set of level specific permissions.
            # Check if we've got a value for the named permission.
            value = getattr(self.permissions, permission_name)
            if value is not None:
                # We've a level specific value for this permission, return it.
                return value

        if self.parent is not None and self.parent not in visited:
            # This isn't a top level entity and our parent hasn't previously been checked for the permission.
            # Let's defer the decision of this permission to our parent.
            return self.parent.has_permission(permission_name, visited)
        else:
            # There is absolutely no definition for this permission at any level in the hierarchy
            return None


    # __str__ is just needed to display all the permissions this entity defines neatly as a string.
    def __str__(self):
        """Return a printable string for this entity."""
        return "%s '%s' {\n%s\n}" % (self.__class__.__name__, self.name, self.get_aggregate_permissions())



class UserGroup(UserEntity):
    """This class provides a concrete type for a user entity that represents a group."""

    def __init__(self, name, permissionSetClass, permissions, parent = None):
        """Constructor."""
        UserEntity.__init__(self, name, permissionSetClass, permissions, parent)



class User(UserEntity):
    """This class provides a concrete type for a user entity that represents a user.
    The only requirement for this qualification is that no other entities reference this object as a parent."""

    def __init__(self, name, permissionSetClass, group, permissions = None):
        """Constructor."""
        UserEntity.__init__(self, name, permissionSetClass, permissions, group)

Example usage of this code is given below.

class ExamplePermissionSet(PermissionSet):
    """This is a specific set of permissions."""

    availablePermissions = ["canCreateUsers",
                            "canViewUsers",
                            "canUpdateUsers",
                            "canDeleteUsers",
                            "canInitiateReconciliation",
                            "neverDefined"]



class ExampleUserGroup(UserGroup):
    """A specific user group class that uses ExamplePermissionSet."""

    def __init__(self, name, permissions, parent = None):
        UserGroup.__init__(self, name, ExamplePermissionSet, permissions, parent)



class ExampleUser(User):
    """A specific user group that uses ExamplePermissionSet."""

    def __init__(self, name, group, permissions = None):
        User.__init__(self, name, ExamplePermissionSet, group, permissions)



p1                           = ExamplePermissionSet()
p1.canInitiateReconciliation = False
p1.canDeleteUsers            = False
p1.canCreateUsers            = True
p1.canUpdateUsers            = True
p1.canViewUsers              = True

p2                           = ExamplePermissionSet()
p2.canInitiateReconciliation = True

p3                           = ExamplePermissionSet()
p3.canViewUsers              = False
p3.canCreateUsers            = False

supergroup                   = ExampleUserGroup("SuperGroup", p1)
group                        = ExampleUserGroup("Group", p2, parent = supergroup)
user1                        = ExampleUser("User 1", group)     # Using the default group-level permissions
user2                        = ExampleUser("User 2", group, p3) # Using mostly group-level permissions but with user-level refinements

all = [supergroup, group, user1, user2]

for e in all:
    print e
print "user1 canDeleteUsers =", user1.has_permission("canDeleteUsers")
print "-" * 30


group.permissions.canDeleteUsers = True # Change group-level permissions this should filter down to user1

for e in all:
    print e
print "user1 canDeleteUsers =", user1.has_permission("canDeleteUsers")
print "-" * 30

That's all folks! If I get motiviated I'll discuss data-orientated permissioning and the use of access control lists.