Skip to content

Commit

Permalink
Merge pull request #80 from level12/29-class-decoration
Browse files Browse the repository at this point in the history
Support non-Keg Flask class views
  • Loading branch information
guruofgentoo committed Oct 3, 2019
2 parents 19d184e + e433c74 commit 3d8a6cb
Show file tree
Hide file tree
Showing 4 changed files with 96 additions and 33 deletions.
25 changes: 14 additions & 11 deletions keg_auth/libs/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,30 +55,33 @@ def decorate_class(self, cls):
# when decorating a view class, all of the class's route methods will submit to the given
# auth. The view may already have check_auth defined, though, so make sure we still call
# it.
old_check_auth = getattr(cls, 'check_auth', lambda: None)
method_name, old_method = next((
(method_name, getattr(cls, method_name, None))
for method_name in ['check_auth', 'dispatch_request']
if callable(getattr(cls, method_name, None))
), (None, None))

def new_check_auth(*args, **kwargs):
self.check_auth(instance=args[0])
if not old_method:
raise TypeError('Class must inherit from a Keg or Flask view')

# the original check_auth method on the view may take any number of args/kwargs. Use
# logic similar to keg.web's _call_with_expected_args, except that method does not
# fit this case for bound methods
def new_method(*args, **kwargs):
self.check_auth(instance=args[0])
try:
# validate_arguments is made for a function, not a class method
# so we need to "trick" it by sending self here, but then
# removing it before the bound method is called below
pass_args, pass_kwargs = validate_arguments(old_check_auth, args, kwargs.copy())
pass_args, pass_kwargs = validate_arguments(old_method, args, kwargs.copy())
except ArgumentValidationError as e:
msg = _('Argument mismatch occured: method=%s, missing=%s, '
msg = _('Argument mismatch occurred: method=%s, missing=%s, '
'extra_keys=%s, extra_pos=%s.'
' Arguments available: %s') % (old_check_auth, e.missing,
' Arguments available: %s') % (old_method, e.missing,
e.extra, e.extra_positional,
kwargs) # pragma: no cover
raise ViewArgumentError(msg) # pragma: no cover

return old_check_auth(*pass_args, **pass_kwargs)
return old_method(*pass_args, **pass_kwargs)

cls.check_auth = new_check_auth
setattr(cls, method_name, new_method)

# store auth info on the class itself
self.store_auth_info(cls)
Expand Down
15 changes: 9 additions & 6 deletions keg_auth/tests/test_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,9 @@ def test_leaf_method_requires_user(self):
node.clear_authorization(user.get_id())
assert node.is_permitted

def test_leaf_class_requires_user(self):
node = NavItem('Foo', NavURL('private.secret1-class'))
@pytest.mark.parametrize('endpoint', ['private.secret1-class', 'private.secret1-flask-class'])
def test_leaf_class_requires_user(self, endpoint):
node = NavItem('Foo', NavURL(endpoint))

with flask.current_app.test_request_context('/'):
flask_login.logout_user()
Expand Down Expand Up @@ -203,8 +204,9 @@ def test_leaf_method_requires_permissions(self):
node.clear_authorization(user.get_id())
assert node.is_permitted

def test_leaf_class_requires_permissions(self):
node = NavItem('Foo', NavURL('private.secret3'))
@pytest.mark.parametrize('endpoint', ['private.secret3', 'private.secret-flask'])
def test_leaf_class_requires_permissions(self, endpoint):
node = NavItem('Foo', NavURL(endpoint))

with flask.current_app.test_request_context('/'):
flask_login.logout_user()
Expand All @@ -223,8 +225,9 @@ def test_leaf_class_requires_permissions(self):
node.clear_authorization(user.get_id())
assert node.is_permitted

def test_leaf_method_and_class_both_require(self):
node = NavItem('Foo', NavURL('private.secret4'))
@pytest.mark.parametrize('endpoint', ['private.secret4', 'private.secret-flask4'])
def test_leaf_method_and_class_both_require(self, endpoint):
node = NavItem('Foo', NavURL(endpoint))

with flask.current_app.test_request_context('/'):
flask_login.logout_user()
Expand Down
42 changes: 26 additions & 16 deletions keg_auth/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import sqlalchemy as sa
from werkzeug.datastructures import MultiDict
from keg_auth_ta.app import mail_ext
from keg_auth.libs.decorators import requires_user
from keg_auth.testing import AuthTests, AuthTestApp, ViewTestBase

from keg_auth_ta.model import entities as ents
Expand Down Expand Up @@ -40,6 +41,12 @@ def setup_class(cls):
def setup(self):
ents.User.delete_cascaded()

def test_class_decorator_throws_exception(self):
with pytest.raises(TypeError):
@requires_user
class NotKegOrFlask:
pass

def test_home(self):
client = flask_webtest.TestApp(flask.current_app)
resp = client.get('/')
Expand Down Expand Up @@ -359,53 +366,56 @@ def test_method_level(self):
client = AuthTestApp(flask.current_app, user=disallowed)
client.post('/secret2', {}, status=403)

def test_class_level(self):
@pytest.mark.parametrize('endpoint', ['secret3', 'secret-flask'])
def test_class_level(self, endpoint):
allowed = ents.User.testing_create(permissions=[self.perm1, self.perm2])
disallowed = ents.User.testing_create(permissions=[self.perm1])

client = AuthTestApp(flask.current_app, user=allowed)
resp = client.get('/secret3', status=200)
assert resp.text == 'secret3'
resp = client.get('/{}'.format(endpoint), status=200)
assert resp.text == endpoint

client = AuthTestApp(flask.current_app, user=disallowed)
client.get('/secret3', {}, status=403)
client.get('/{}'.format(endpoint), {}, status=403)

client = flask_webtest.TestApp(flask.current_app)
client.get('/secret3', status=302)
client.get('/{}'.format(endpoint), status=302)

def test_class_level_inheritance(self):
@pytest.mark.parametrize('endpoint', ['secret3-sub', 'secret-flask-sub'])
def test_class_level_inheritance(self, endpoint):
allowed = ents.User.testing_create(permissions=[self.perm1, self.perm2])
disallowed = ents.User.testing_create(permissions=[self.perm1])

client = AuthTestApp(flask.current_app, user=allowed)
resp = client.get('/secret3-sub', status=200)
assert resp.text == 'secret3-sub'
resp = client.get('/{}'.format(endpoint), status=200)
assert resp.text == endpoint

client = AuthTestApp(flask.current_app, user=disallowed)
client.get('/secret3-sub', {}, status=403)
client.get('/{}'.format(endpoint), {}, status=403)

client = flask_webtest.TestApp(flask.current_app)
client.get('/secret3-sub', status=302)
client.get('/{}'.format(endpoint), status=302)

def test_class_and_method_level_combined(self):
@pytest.mark.parametrize('endpoint', ['secret4', 'secret-flask4'])
def test_class_and_method_level_combined(self, endpoint):
allowed = ents.User.testing_create(permissions=[self.perm1, self.perm2])
disallowed1 = ents.User.testing_create(permissions=[self.perm1])
disallowed2 = ents.User.testing_create(permissions=[self.perm2])

client = AuthTestApp(flask.current_app, user=allowed)
resp = client.get('/secret4', status=200)
assert resp.text == 'secret4'
resp = client.get('/{}'.format(endpoint), status=200)
assert resp.text == endpoint

client = AuthTestApp(flask.current_app, user=disallowed1)
client.get('/secret4', {}, status=403)
client.get('/{}'.format(endpoint), {}, status=403)

# missing the class-level permission requirement triggers the class's custom auth
# failure handler
client = AuthTestApp(flask.current_app, user=disallowed2)
client.get('/secret4', {}, status=405)
client.get('/{}'.format(endpoint), {}, status=405)

client = flask_webtest.TestApp(flask.current_app)
client.get('/secret4', status=302)
client.get('/{}'.format(endpoint), status=302)

def test_nested_conditions(self):
def check(perms, allowed):
Expand Down
47 changes: 47 additions & 0 deletions keg_auth_ta/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@ def on_authentication_failure(self):
flask.abort(405)


@requires_user
class Secret1FlaskClass(flask.views.MethodView):
def get(self):
return 'secret1-flask-class'

def on_authentication_failure(self):
flask.abort(405)


private_bp.add_url_rule(
'/secret1-flask-class', view_func=Secret1FlaskClass.as_view('secret1-flask-class'))


class Secret2(keg.web.BaseView):
blueprint = private_bp

Expand Down Expand Up @@ -104,6 +117,25 @@ def get(self):
return 'secret3-sub'


@requires_permissions(has_all('permission1', 'permission2'))
class SecretFlask(flask.views.MethodView):
def get(self):
return 'secret-flask'


private_bp.add_url_rule(
'/secret-flask', view_func=SecretFlask.as_view('secret-flask'))


class SecretFlaskSub(SecretFlask):
def get(self):
return 'secret-flask-sub'


private_bp.add_url_rule(
'/secret-flask-sub', view_func=SecretFlaskSub.as_view('secret-flask-sub'))


@requires_permissions('permission1')
class Secret4(keg.web.BaseView):
blueprint = private_bp
Expand All @@ -116,6 +148,21 @@ def on_authorization_failure(self):
flask.abort(405)


@requires_permissions('permission1')
class SecretFlask4(flask.views.MethodView):

@requires_permissions('permission2')
def get(self):
return 'secret-flask4'

def on_authorization_failure(self):
flask.abort(405)


private_bp.add_url_rule(
'/secret-flask4', view_func=SecretFlask4.as_view('secret-flask4'))


@private_bp.route('/secret-nested')
@requires_permissions(has_any(has_all('permission1', 'permission2'), 'permission3'))
def secret_nested():
Expand Down

0 comments on commit 3d8a6cb

Please sign in to comment.