diff --git a/app/api/main.py b/app/api/main.py index 1d3e9e86f2a9d780fa8b328dcfd9667b0ab71ef2..07655356ad1a5a4225c97299c44010226304755d 100644 --- a/app/api/main.py +++ b/app/api/main.py @@ -16,6 +16,7 @@ from flask_ldap3_login import AuthenticationResponseStatus from ..extensions import ldap_manager, db from ..models import Item, Manufacturer, Model, Location, Status from .. import utils +from ..decorators import jwt_groups_accepted bp = Blueprint('api', __name__) @@ -82,12 +83,14 @@ def get_items(): @bp.route('/items', methods=['POST']) @jwt_required +@jwt_groups_accepted('admin', 'create') def create_item(): return create_generic_model(Item, mandatory_field='serial_number') @bp.route('/items/<item_id>', methods=['PATCH']) @jwt_required +@jwt_groups_accepted('admin', 'create') def patch_item(item_id): data = request.get_json() if data is None: @@ -112,6 +115,7 @@ def get_manufacturers(): @bp.route('/manufacturers', methods=['POST']) @jwt_required +@jwt_groups_accepted('admin', 'create') def create_manufacturer(): return create_generic_model(Manufacturer) @@ -124,6 +128,7 @@ def get_models(): @bp.route('/models', methods=['POST']) @jwt_required +@jwt_groups_accepted('admin', 'create') def create_model(): return create_generic_model(Model) @@ -136,6 +141,7 @@ def get_locations(): @bp.route('/locations', methods=['POST']) @jwt_required +@jwt_groups_accepted('admin', 'create') def create_locations(): return create_generic_model(Location) @@ -148,5 +154,6 @@ def get_status(): @bp.route('/status', methods=['POST']) @jwt_required +@jwt_groups_accepted('admin', 'create') def create_status(): return create_generic_model(Status) diff --git a/app/decorators.py b/app/decorators.py new file mode 100644 index 0000000000000000000000000000000000000000..18c31589676b966c579b2ef9eb0e944ab8393be0 --- /dev/null +++ b/app/decorators.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +""" +app.decorators +~~~~~~~~~~~~~~ + +This module defines some useful decorators. + +:copyright: (c) 2017 European Spallation Source ERIC +:license: BSD 2-Clause, see LICENSE for more details. + +""" +from functools import wraps +from flask_jwt_extended import get_current_user +from .utils import InventoryError + + +def jwt_groups_required(*groups): + """Decorator which specifies that a user must have all the specified groups. + + Example:: + @bp.route('/models', methods=['POST']) + @jwt_required + @jwt_groups_required('admin', 'create') + def create_model(): + return create() + + The current user must be in both 'admin' and 'create' groups + to access this route. + + :param groups: required groups + """ + def wrapper(fn): + @wraps(fn) + def decorated_view(*args, **kwargs): + user = get_current_user() + if user is None: + raise InventoryError('Invalid indentity', status_code=403) + if not user.is_member_of_all_groups(groups): + raise InventoryError("User doesn't have the required group(s)", status_code=403) + return fn(*args, **kwargs) + return decorated_view + return wrapper + + +def jwt_groups_accepted(*groups): + """Decorator which specifies that a user must have at least one of the specified groups. + + Example:: + @bp.route('/models', methods=['POST']) + @jwt_required + @jwt_groups_accepted('admin', 'create') + def create_model(): + return create() + + The current user must be in either 'admin' or 'create' group + to access this route. + + :param groups: accepted groups + """ + def wrapper(fn): + @wraps(fn) + def decorated_view(*args, **kwargs): + user = get_current_user() + if user is None: + raise InventoryError('Invalid indentity', status_code=403) + if not user.is_member_of_one_group(groups): + raise InventoryError("User doesn't have the required group", status_code=403) + return fn(*args, **kwargs) + return decorated_view + return wrapper diff --git a/app/models.py b/app/models.py index 3309c008eb30cdedc611fff4191ae08ccf89c224..40680d58b15f8e6d09009eb05d09ddf8214d0cae 100644 --- a/app/models.py +++ b/app/models.py @@ -17,7 +17,7 @@ from sqlalchemy.ext.associationproxy import association_proxy from citext import CIText from flask import current_app from flask_login import UserMixin -from .extensions import db, login_manager, ldap_manager +from .extensions import db, login_manager, ldap_manager, jwt from . import utils @@ -61,11 +61,21 @@ def load_user(user_id): """User loader callback for flask-login :param str user_id: unicode ID of a user - :returns: corresponding user object + :returns: corresponding user object or None """ return User.query.get(int(user_id)) +@jwt.user_loader_callback_loader +def user_loader_callback(identity): + """User loader callback for flask-jwt-extended + + :param str identity: identity from the token (user_id) + :returns: corresponding user object or None + """ + return User.query.get(int(identity)) + + @ldap_manager.save_user def save_user(dn, username, data, memberships): """User saver for flask-ldap3-login @@ -140,7 +150,17 @@ class User(db.Model, UserMixin): @property def is_admin(self): - return current_app.config['INVENTORY_ADMIN_GROUP'] in self.groups + return current_app.config['INVENTORY_LDAP_GROUPS']['admin'] in self.groups + + def is_member_of_one_group(self, groups): + """Return True if the user is at least member of one of the given groups""" + names = [current_app.config['INVENTORY_LDAP_GROUPS'].get(group) for group in groups] + return bool(set(self.groups) & set(names)) + + def is_member_of_all_groups(self, groups): + """Return True if the user is member of all the given groups""" + names = [current_app.config['INVENTORY_LDAP_GROUPS'].get(group) for group in groups] + return set(names).issubset(self.groups) def __str__(self): return self.name diff --git a/app/settings.py b/app/settings.py index 546323029590d5a8892339248bc5a443addc0b9b..b9a4349ce078a43613246c60b2d6ffdfbf49cac5 100644 --- a/app/settings.py +++ b/app/settings.py @@ -34,7 +34,10 @@ LDAP_GROUP_OBJECT_FILTER = '' LDAP_USER_SEARCH_SCOPE = 'SUBTREE' LDAP_GROUP_SEARCH_SCOPE = 'SUBTREE' LDAP_GROUP_MEMBERS_ATTR = 'member' -LDAP_GET_USER_ATTRIBUTES = ['cn', 'sAMAccountName', 'mail', 'memberOf'] +LDAP_GET_USER_ATTRIBUTES = ['cn', 'sAMAccountName', 'mail'] LDAP_GET_GROUP_ATTRIBUTES = ['cn'] -INVENTORY_ADMIN_GROUP = 'ICS Control System Infrastructure group' +INVENTORY_LDAP_GROUPS = { + 'admin': 'ICS Control System Infrastructure group', + 'create': 'ICS Employees', +}