Skip to content

Instantly share code, notes, and snippets.

@justanr
Last active August 29, 2015 14:27
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save justanr/e5a51aa143dab928b382 to your computer and use it in GitHub Desktop.
Save justanr/e5a51aa143dab928b382 to your computer and use it in GitHub Desktop.
Proposal: FlaskBB Permissions
import operator
from inspect import isclass, isfunction
def _is_permission_factory(perm):
return isclass(perm) or isfunction(perm)
class Permission(object):
"Base permission class"
def allow(self, user, request, *args, **kwargs):
raise NotImplementedError("Must impement Permission.allow")
class ConditionalPermission(Permission):
# see rest_condition from the django community
# for direct inspiration
@classmethod
def Or(cls, *perms):
return cls(op=operator.or_, lazy_until=True, *perms)
@classmethod
def And(cls, *perms):
return cls(op=operator.and_, lazy_until=False, *perms)
@classmethod
def Not(cls, *perms):
return cls(negated=True, *perms)
def __init__(self, *perms, **kwargs):
self.perms = perms
self.op = kwargs.get('op', operator.and_)
self.negated = kwargs.get('negated')
self.lazy_until = kwargs.get('lazy_until')
def allow(self, user, request, *args, **kwargs):
reduced_result = None
for perm in self.perms:
if _is_permission_factory(perm):
perm = perm()
result = perm.allow(user, request, *args, **kwargs)
if reduced_result is None:
reduced_result = result
else:
reduced_result = self.op(reduced_result, result)
if self.lazy_until is not None and self.lazy_until == reduced_result:
break
if reduced_result is not None:
return not reduced_result if self.negated else reduced_result
return False
def __or__(self, perm):
return self.Or(self, perm)
def __and__(self, perm):
return self.And(self, perm)
def __invert__(self):
return self.Not(self)
def __call__(self, *args, **kwargs):
return self
(C, And, Or, Not) = (ConditionalPermission, ConditionalPermission.And,
ConditionalPermission.Or, ConditionalPermission.Not)
from functools import wraps
from flaskbb.exceptions import AuthorizationRequired
def requires(*perms):
# initialize permissions
perms = [perm() for perm in perms]
def deco(f):
@wraps(f)
def wrapper(*args, **kwargs):
# find a way to dependency inject current_user
# and request for testing purposes
if all(p.allow(current_user, request, *args, **kwargs) for p in perms):
return f(*args, **kwargs)
else:
raise AuthorizationRequired
return wrapper
return deco
@fourm.route('/topic/<int:topic_id>/lock', methods=['POST'])
@fourm.route('/topic/<int:topic_id>-<slug>/lock', methods=['POST'])
@requires(Or(IsModerator, IsAtleastSuperMod))
def lock_topic(topic_id=None, topic_slug=None):
topic = Topic.query.get(topic_id)
topic.lock = True
topic.save()
return redirect(topic.url)
class LockTopic(PermissionedView):
methods = ['POST']
permissions = [Or(IsModerator, IsAtleastSuperMod)]
def dispatch_request(self, topic_id=None, topic_slug=None):
topic = Topic.query.get(topic_id)
topic.lock = True
topic.save()
return redirect(topic.url)
class LockTopicMV(PermissionedMethodView):
permissions = [Or(IsModerator, IsAtleastSuperMod)]
def get(self, topic_id=None, topic_slug=None):
topic = Topic.query.get(topic_id)
form = TopicLockForm(obj=topic)
# maybe we want to provide a form to provide a reason why a topic was locked
return render_template('lock_topic.html', form=form, topic=topic)
def post(self, topic_id=None, topic_slug=None):
form = TopicLockForm()
topic = Topic.query.get(topic_id)
if form.validate_on_submit():
topic.lock = True
topic.lock_reason = form.reason.data
topic.save()
return redirect(topic.url)
forum.add_url_rule('/topic/<int:topic_id>/lock', endpoint='.lock_topic', view_func=LockTopic.as_view())
forum.add_url_rule('/topic/<int:topic_id>-<topic_slug>/lock', endpoint='.lock_topic', view_func=LockTopic.as_view())
class IsModerator(Permission):
def allow(self, user, request, *args, **kwargs):
return user in self.determine_forum(request, *args, **kwargs)
def determine_forum(self, request, *args, **kwargs):
# no validation on existing forums or topics
if 'forum_id' in request.view_args:
return Forum.query.get(request.view_args['forum_id'])
elif 'topic_id' in request.view_args:
return Topic.query.get(request.view_args['topic_id']).forum
elif 'post_id' in request.view_args:
return Post.query.get(request.view_args['post_id']).topic.forum
else:
raise Exception("Cannot determine forum")
class IsSuperMod(Permission):
def allow(self, user, request, *args, **kwargs):
return user.permissions.get('supermod', False)
class IsAdmin(Permission):
def allow(self, user, request, *args, **kwargs):
return user.permissions.get('admin', False)
class IsSameUser(Permission):
def allow(self, user, request, *args, **kwargs):
return user is self.determine_user(request, *args, **kwargs)
def determine_user(request, *args, **kwargs):
if 'post_id' in request.view_args:
return Post.query.get(self.request.view_args['post_id']).user
if 'username' in request.view_args:
return User.query.filter_by(username=self.request.view_args['username']).first()
IsAtleastSuperMod = Or(IsSuperMod, IsAdmin)
CanEditPost = Or(IsSameUser, IsModerator, IsAtleastSuperMod)
from flask.views import View, MethodView
# or where ever we stick it
from flaskbb.permissions import requires
class PermissionedView(View):
permissions = ()
@classmethod
def as_view(cls, name, *cls_args, **cls_kwargs):
view = super(PermissionedView, cls).as_view(name, *cls_args, **cls_kwargs)
return requires(*cls.permissions)(view)
class PermissionedMethodView(PermissionedView, MethodView):
pass
@justanr
Copy link
Author

justanr commented Aug 16, 2015

Something to consider, we're hitting the database multiple times in cases like:

@forum.route('/topic/<int:post_id>/edit')
@requires(CanEditPost)
def edit_post(post_id):
    ...

SQLAlchemy does use an identity map, so it's smart enough to check its local cache for the primary key first (motivation to switch lookups that use filter_by(id=name_id) to get(name_id), but in some cases this wouldn't be possible).

IsModerator also presents issues since this basically excludes moderators from the management panel. Maybe splitting this into two permissions is a better idea:

  • IsModerator which just does something like return user.permission.get('mod', False)
  • CanModerate which does IsModerator().allow(user, request) and user in self.determine_forum(request)

Another concept I'd like to introduce is a more dynamic permission system, though I'd save this proposal for 2.0, as it'd be a major change.

This would involve creating two more tables in the database:

  • A Permission table which stores an id and a permission name
  • And a Role_to_Permission table which is just a many-to-many

This would allow fine grained permission granting. We could extend Group and User to inherit from a common entity (Role seems like an obvious name) and do some magic to merge all the permissions an individual user has with all the permissions granted from the user's groups.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment