From 90184b32cc7a1df47bf1e7af1664640b154ff56a Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand <benjamin.bertrand@esss.se> Date: Fri, 18 Aug 2017 13:29:03 +0200 Subject: [PATCH] Replace roles with LDAP groups --- app/api/main.py | 13 ++++---- app/defaults.py | 3 -- app/factory.py | 4 +-- app/models.py | 56 +++++++++++++++++++++++--------- app/settings.py | 11 ++++++- app/templates/users/profile.html | 4 +-- app/utils.py | 18 ++++++++++ 7 files changed, 78 insertions(+), 31 deletions(-) diff --git a/app/api/main.py b/app/api/main.py index da1da35..1d3e9e8 100644 --- a/app/api/main.py +++ b/app/api/main.py @@ -14,7 +14,7 @@ from flask import (current_app, Blueprint, jsonify, request) from flask_jwt_extended import create_access_token, jwt_required from flask_ldap3_login import AuthenticationResponseStatus from ..extensions import ldap_manager, db -from ..models import User, Item, Manufacturer, Model, Location, Status +from ..models import Item, Manufacturer, Model, Location, Status from .. import utils bp = Blueprint('api', __name__) @@ -61,12 +61,11 @@ def login(): response = ldap_manager.authenticate(username, password) if response.status == AuthenticationResponseStatus.success: current_app.logger.debug(f'{username} successfully logged in') - user = User.query.filter_by(username=username).first() - if user is None: - # LDAP user not in database yet - create it - user = User(username, name=response.user_info['cn'], email=response.user_info['mail']) - db.session.add(user) - db.session.commit() + user = ldap_manager._save_user( + response.user_dn, + response.user_id, + response.user_info, + response.user_groups) payload = {'access_token': create_access_token(identity=user.id)} return jsonify(payload), 200 raise utils.InventoryError('Invalid credentials', status_code=401) diff --git a/app/defaults.py b/app/defaults.py index 049dd28..ef68434 100644 --- a/app/defaults.py +++ b/app/defaults.py @@ -13,9 +13,6 @@ from . import models defaults = [ - models.Role(id=0, name='admin'), - models.Role(id=1, name='user'), - models.Location(name='ICS lab'), models.Location(name='Utgård'), models.Location(name='Site'), diff --git a/app/factory.py b/app/factory.py index ea0ca8d..736229d 100644 --- a/app/factory.py +++ b/app/factory.py @@ -13,7 +13,7 @@ import sqlalchemy as sa from flask import Flask from . import settings from .extensions import db, migrate, login_manager, ldap_manager, bootstrap, admin, mail, jwt -from .models import User, Role, Action, Manufacturer, Model, Location, Status +from .models import User, Group, Action, Manufacturer, Model, Location, Status from .admin.views import AdminModelView, ItemAdmin from .main.views import bp as main from .users.views import bp as users @@ -89,7 +89,7 @@ def create_app(): jwt.init_app(app) admin.init_app(app) - admin.add_view(AdminModelView(Role, db.session)) + admin.add_view(AdminModelView(Group, db.session)) admin.add_view(AdminModelView(User, db.session)) admin.add_view(AdminModelView(Action, db.session)) admin.add_view(AdminModelView(Manufacturer, db.session)) diff --git a/app/models.py b/app/models.py index 01107be..3309c00 100644 --- a/app/models.py +++ b/app/models.py @@ -13,7 +13,9 @@ import uuid import qrcode from sqlalchemy.types import TypeDecorator, CHAR from sqlalchemy.dialects.postgresql import UUID +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 . import utils @@ -73,26 +75,41 @@ def save_user(dn, username, data, memberships): """ user = User.query.filter_by(username=username).first() if user is None: - user = User(username, name=data['cn'], email=data['mail']) - db.session.add(user) - db.session.commit() - else: - pass - # TODO: update the user in the database? - # probably not needed for the name and email fields - # maybe when we add groups from LDAP + user = User(username, + name=utils.attribute_to_string(data['cn']), + email=utils.attribute_to_string(data['mail'])) + # Always update the user groups to keep them up-to-date + user.groups = [utils.attribute_to_string(group['cn']) for group in memberships] + db.session.add(user) + db.session.commit() return user -class Role(db.Model): +# Table required for Many-to-Many relationships between users and groups +usergroups_table = db.Table( + 'usergroups', + db.Column('user_id', db.Integer, db.ForeignKey('user_account.id')), + db.Column('group_id', db.Integer, db.ForeignKey('group.id')) +) + + +class Group(db.Model): id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(50), unique=True) - users = db.relationship('User', backref='role') + name = db.Column(db.String(100), nullable=False, unique=True) + + def __init__(self, name): + self.name = name def __str__(self): return self.name +def find_or_create_group(name): + """Return the existing group or a newly created one""" + group = Group.query.filter_by(name=name).first() + return group or Group(name=name) + + class User(db.Model, UserMixin): # "user" is a reserved word in postgresql # so let's use another name @@ -102,11 +119,15 @@ class User(db.Model, UserMixin): username = db.Column(db.String(50), unique=True) name = db.Column(db.String(100)) email = db.Column(db.String(100)) - role_id = db.Column(db.Integer, db.ForeignKey('role.id')) - - def __init__(self, username, name, email, role='user'): + grp = db.relationship('Group', secondary=usergroups_table, + backref=db.backref('members', lazy='dynamic')) + # Proxy the 'name' attribute from the 'grp' relationship + # See http://docs.sqlalchemy.org/en/latest/orm/extensions/associationproxy.html + groups = association_proxy('grp', 'name', + creator=find_or_create_group) + + def __init__(self, username, name, email): self.username = username - self.role = Role.query.filter_by(name=role).first() self.name = name self.email = email @@ -119,7 +140,7 @@ class User(db.Model, UserMixin): @property def is_admin(self): - return self.role.name == 'admin' + return current_app.config['INVENTORY_ADMIN_GROUP'] in self.groups def __str__(self): return self.name @@ -129,6 +150,9 @@ class QRCodeMixin: id = db.Column(db.Integer, primary_key=True) name = db.Column(CIText, nullable=False, unique=True) + def __init__(self, name=None): + self.name = name + def image(self): """Return a QRCode image to identify a record diff --git a/app/settings.py b/app/settings.py index 90c92a7..5463230 100644 --- a/app/settings.py +++ b/app/settings.py @@ -21,11 +21,20 @@ ADMIN_EMAILS = ['admin@example.com'] EMAIL_SENDER = 'inventory@esss.se' LDAP_HOST = 'esss.lu.se' -LDAP_BASE_DN = 'OU=ESS Users,DC=esss,DC=lu,DC=se' +LDAP_BASE_DN = 'DC=esss,DC=lu,DC=se' +LDAP_USER_DN = 'OU=ESS Users' +LDAP_GROUP_DN = '' LDAP_BIND_USER_DN = 'ldapuser' LDAP_BIND_USER_PASSWORD = 'secret' LDAP_USER_RDN_ATTR = 'cn' LDAP_USER_LOGIN_ATTR = 'sAMAccountName' LDAP_ALWAYS_SEARCH_BIND = True LDAP_USER_OBJECT_FILTER = '(samAccountType=805306368)' +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_GROUP_ATTRIBUTES = ['cn'] + +INVENTORY_ADMIN_GROUP = 'ICS Control System Infrastructure group' diff --git a/app/templates/users/profile.html b/app/templates/users/profile.html index e2e082b..dd813eb 100644 --- a/app/templates/users/profile.html +++ b/app/templates/users/profile.html @@ -12,8 +12,8 @@ <dd>{{user.name}}</dd> <dt>Email</dt> <dd>{{user.email}}</dd> - <dt>Role</dt> - <dd>{{ user.role }}</dd> + <dt>Groups</dt> + <dd>{{ user.groups | join(', ') }}</dd> {% if token %} <dt>Token</dt> <dd>{{ token }}</dd> diff --git a/app/utils.py b/app/utils.py index f67893d..03f8a88 100644 --- a/app/utils.py +++ b/app/utils.py @@ -83,3 +83,21 @@ def convert_to_model(item, model): raise InventoryError(f'{item} is not a valid {model.__name__.lower()}') return instance return item + + +def attribute_to_string(value): + """Return the attribute as a string + + If the attribute is defined in the schema as multi valued + then the attribute value is returned as a list + See http://ldap3.readthedocs.io/tutorial_searches.html#entries-retrieval + + This function returns the first item of the list if it's a list + + :param value: string or list + :returns: string + """ + if isinstance(value, list): + return value[0] + else: + return value -- GitLab