From 0efae3a3d7424d03a4d1fafb33669ca3c41c00b3 Mon Sep 17 00:00:00 2001
From: Benjamin Bertrand <benjamin.bertrand@esss.se>
Date: Thu, 11 Jan 2018 22:55:30 +0100
Subject: [PATCH] Cache user retrieval

The user_id is stored in the flask session. On every page load, we query
the database to get:
- the user objet (user_account table)
- the user's groups (group table) to know what the user can access

If we try to cache the user_load function, the following error is raised
when retrieving the groups:
sqlalchemy.orm.exc.DetachedInstanceError: Parent instance <User at
0x7f51e050a940> is not bound to a Session; lazy load operation of
attribute 'grp' cannot proceed

The groups from AD are only stored to map them with CSENTRY_LDAP_GROUPS.
We don't do any query on them (get all users from one group).
As we always query them with one user, it's more efficient to store them
in the user_account table as an array than in a separate table.
This allows to easily cache the load_user function.

Note that we have to remember to add the current_user to the sqlalchemy session if
we want to modify it or access a relationship (like tokens)!

The load_user function is now cached for 30 minutes. This decreases a
lot the number of database queries.

Fix INFRA-156
---
 app/admin/views.py |  6 ------
 app/factory.py     |  3 +--
 app/models.py      | 31 ++-----------------------------
 app/user/views.py  |  8 ++++++++
 4 files changed, 11 insertions(+), 37 deletions(-)

diff --git a/app/admin/views.py b/app/admin/views.py
index 96f75ed..69aad41 100644
--- a/app/admin/views.py
+++ b/app/admin/views.py
@@ -51,12 +51,6 @@ class AdminModelView(sqla.ModelView):
         return current_user.is_authenticated and current_user.is_admin
 
 
-class GroupAdmin(AdminModelView):
-    can_create = False
-    can_edit = False
-    can_delete = False
-
-
 class UserAdmin(AdminModelView):
     can_create = False
     can_edit = False
diff --git a/app/factory.py b/app/factory.py
index 1bdfe49..79bc856 100644
--- a/app/factory.py
+++ b/app/factory.py
@@ -15,7 +15,7 @@ from whitenoise import WhiteNoise
 from . import settings, models
 from .extensions import (db, migrate, login_manager, ldap_manager, bootstrap,
                          admin, mail, jwt, toolbar, redis_store, fsession, cache)
-from .admin.views import (AdminModelView, ItemAdmin, UserAdmin, GroupAdmin, TokenAdmin,
+from .admin.views import (AdminModelView, ItemAdmin, UserAdmin, TokenAdmin,
                           NetworkAdmin)
 from .main.views import bp as main
 from .inventory.views import bp as inventory
@@ -107,7 +107,6 @@ def create_app(config=None):
     cache.init_app(app)
 
     admin.init_app(app)
-    admin.add_view(GroupAdmin(models.Group, db.session))
     admin.add_view(UserAdmin(models.User, db.session, endpoint='users'))
     admin.add_view(TokenAdmin(models.Token, db.session))
     admin.add_view(AdminModelView(models.Action, db.session))
diff --git a/app/models.py b/app/models.py
index 89ed072..fa52ac2 100644
--- a/app/models.py
+++ b/app/models.py
@@ -16,7 +16,6 @@ import sqlalchemy as sa
 from sqlalchemy.ext.declarative import declared_attr
 from sqlalchemy.dialects import postgresql
 from sqlalchemy.orm import validates
-from sqlalchemy.ext.associationproxy import association_proxy
 from sqlalchemy_continuum import make_versioned, version_class
 from citext import CIText
 from flask import current_app
@@ -67,6 +66,7 @@ def get_temporary_ics_id():
 
 
 @login_manager.user_loader
+@cache.memoize(timeout=1800)
 def load_user(user_id):
     """User loader callback for flask-login
 
@@ -95,28 +95,6 @@ def save_user(dn, username, data, memberships):
     return user
 
 
-# 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.Text, nullable=False, unique=True)
-
-    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
@@ -126,12 +104,7 @@ class User(db.Model, UserMixin):
     username = db.Column(db.Text, nullable=False, unique=True)
     display_name = db.Column(db.Text, nullable=False)
     email = db.Column(db.Text)
-    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)
+    groups = db.Column(postgresql.ARRAY(db.Text))
     tokens = db.relationship("Token", backref="user")
 
     def get_id(self):
diff --git a/app/user/views.py b/app/user/views.py
index 473f9bc..9440dfc 100644
--- a/app/user/views.py
+++ b/app/user/views.py
@@ -14,6 +14,8 @@ from flask import (Blueprint, render_template, request, redirect, url_for,
 from flask_login import login_user, logout_user, login_required, current_user
 from flask_ldap3_login.forms import LDAPLoginForm
 from .forms import TokenForm
+from ..extensions import cache, db
+from ..models import load_user
 from .. import tokens, utils
 
 bp = Blueprint('user', __name__)
@@ -31,6 +33,8 @@ def login():
 @bp.route('/logout')
 @login_required
 def logout():
+    # Don't forget to remove the user from the cache
+    cache.delete_memoized(load_user, str(current_user.id))
     logout_user()
     return redirect(url_for('user.login'))
 
@@ -38,6 +42,10 @@ def logout():
 @bp.route('/profile', methods=['GET', 'POST'])
 @login_required
 def profile():
+    if current_user not in db.session:
+        # If the current user is cached, it won't be in the sqlalchemy session
+        # Add it to access the user.tokens relationship in the template
+        db.session.add(current_user)
     # Try to get the generated token from the session
     token = session.pop('generated_token', None)
     form = TokenForm(request.form)
-- 
GitLab