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