diff --git a/app/api/main.py b/app/api/main.py index da1da3592eface543d0cf1118194b4afc004b619..1d3e9e86f2a9d780fa8b328dcfd9667b0ab71ef2 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 049dd283d9545d514ee033011f2df793afed9078..ef684344a6c6552bf320ecba2555989889cff59a 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 ea0ca8ddcc9f58a3420554d7710d30febe05caab..736229dc8844e5b453cfedf67c61cc5ac32ef2ca 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 01107becda6dec06b4488384aa4a87a257746384..3309c008eb30cdedc611fff4191ae08ccf89c224 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 90c92a7919d08f010fc48ae98d6a7c15a2946d7d..546323029590d5a8892339248bc5a443addc0b9b 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 e2e082bd49bce2466afbbf742a8c7d8ee4bc9557..dd813eb0430bae8d838afaf87edbd931497d2506 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 f67893de7e450167f71fb7de8100ef88001a4249..03f8a885ab721a4f2b48c5720d3ca6ffcd4c595e 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