From 79cccd36eee9f6eb7d759685abd079f08757cdf8 Mon Sep 17 00:00:00 2001
From: Stephan Porada <sporada@uni-bielefeld.de>
Date: Tue, 9 Jul 2019 15:41:16 +0200
Subject: [PATCH] Add Roles and Permission models so that only admins can
 access /admin

---
 app/decorators.py                    | 19 +++++++
 app/main/__init__.py                 |  9 +++-
 app/main/views.py                    |  9 ++++
 app/models.py                        | 77 +++++++++++++++++++++++++++-
 app/templates/main/admin.html.j2     | 12 +++++
 config.py                            |  1 +
 migrations/versions/01a7d98d9647_.py | 32 ++++++++++++
 opaque.py                            |  4 +-
 8 files changed, 159 insertions(+), 4 deletions(-)
 create mode 100644 app/decorators.py
 create mode 100644 app/templates/main/admin.html.j2
 create mode 100644 migrations/versions/01a7d98d9647_.py

diff --git a/app/decorators.py b/app/decorators.py
new file mode 100644
index 00000000..14ddc034
--- /dev/null
+++ b/app/decorators.py
@@ -0,0 +1,19 @@
+from functools import wraps
+from flask import abort
+from flask_login import current_user
+from .models import Permission
+
+
+def permission_required(permission):
+    def decorator(f):
+        @wraps(f)
+        def decorated_function(*args, **kwargs):
+            if not current_user.can(permission):
+                abort(403)
+            return f(*args, **kwargs)
+        return decorated_function
+    return decorator
+
+
+def admin_required(f):
+    return permission_required(Permission.ADMIN)(f)
diff --git a/app/main/__init__.py b/app/main/__init__.py
index 26860e81..484be0c1 100644
--- a/app/main/__init__.py
+++ b/app/main/__init__.py
@@ -2,4 +2,11 @@ from flask import Blueprint
 
 main = Blueprint('main', __name__)
 
-from . import views
+
+from . import views, errors
+from ..models import Permission
+
+
+@main.app_context_processor
+def inject_permissions():
+    return dict(Permission=Permission)
diff --git a/app/main/views.py b/app/main/views.py
index db3f8030..5379a811 100644
--- a/app/main/views.py
+++ b/app/main/views.py
@@ -1,7 +1,16 @@
 from flask import render_template
 from . import main
+from ..decorators import admin_required
+from flask_login import login_required
 
 
 @main.route('/')
 def index():
     return render_template('main/index.html.j2')
+
+
+@main.route('/admin')
+@login_required
+@admin_required
+def for_admins_only():
+    return "For administrators!"
diff --git a/app/models.py b/app/models.py
index d8d18d08..2a32cb3c 100644
--- a/app/models.py
+++ b/app/models.py
@@ -1,19 +1,69 @@
 from flask import current_app
-from flask_login import UserMixin
+from flask_login import UserMixin, AnonymousUserMixin
 from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
 from werkzeug.security import generate_password_hash, check_password_hash
 from . import db
 from . import login_manager
 
 
+class Permission:
+    CREATE_JOB = 1
+    DELETE_JOB = 2
+    # WRITE = 4
+    # MODERATE = 8
+    ADMIN = 16
+
+
 class Role(db.Model):
     __tablename__ = 'roles'
     id = db.Column(db.Integer, primary_key=True)
     name = db.Column(db.String(64), unique=True)
+    default = db.Column(db.Boolean, default=False, index=True)
+    permissions = db.Column(db.Integer)
+    users = db.relationship('User', backref='role', lazy='dynamic')
+
+    def __init__(self, **kwargs):
+        super(Role, self).__init__(**kwargs)
+        if self.permissions is None:
+            self.permissions = 0
 
     def __repr__(self):
         return '<Role %r>' % self.name
 
+    def add_permission(self, perm):
+        if not self.has_permission(perm):
+            self.permissions += perm
+
+    def remove_permission(self, perm):
+        if self.has_permission(perm):
+            self.permissions -= perm
+
+    def reset_permissions(self):
+        self.permissions = 0
+
+    def has_permission(self, perm):
+        return self.permissions & perm == perm
+
+    @staticmethod
+    def insert_roles():
+        roles = {
+                    'User': [Permission.CREATE_JOB],
+                    'Administrator': [Permission.ADMIN,
+                                      Permission.CREATE_JOB,
+                                      Permission.DELETE_JOB]
+        }
+        default_role = 'User'
+        for r in roles:
+            role = Role.query.filter_by(name=r).first()
+            if role is None:
+                role = Role(name=r)
+            role.reset_permissions()
+            for perm in roles[r]:
+                role.add_permission(perm)
+            role.default = (role.name == default_role)
+            db.session.add(role)
+        db.session.commit()
+
 
 class User(UserMixin, db.Model):
     __tablename__ = 'users'
@@ -27,6 +77,14 @@ class User(UserMixin, db.Model):
     def __repr__(self):
         return '<User %r>' % self.username
 
+    def __init__(self, **kwargs):
+        super(User, self).__init__(**kwargs)
+        if self.role is None:
+            if self.email == current_app.config['OPAQUE_ADMIN']:
+                self.role = Role.query.filter_by(name='Administrator').first()
+            if self.role is None:
+                self.role = Role.query.filter_by(default=True).first()
+
     def generate_confirmation_token(self, expiration=3600):
         s = Serializer(current_app.config['SECRET_KEY'], expiration)
         return s.dumps({'confirm': self.id}).decode('utf-8')
@@ -72,6 +130,23 @@ class User(UserMixin, db.Model):
     def verify_password(self, password):
         return check_password_hash(self.password_hash, password)
 
+    def can(self, perm):
+        return self.role is not None and self.role.has_permission(perm)
+
+    def is_administrator(self):
+        return self.can(Permission.ADMIN)
+
+
+class AnonymousUser(AnonymousUserMixin):
+    def can(self, permissions):
+        return False
+
+    def is_administrator(self):
+        return False
+
+
+login_manager.anonymous_user = AnonymousUser  # Flask-Login is told to use the application’s custom anonymous user by setting its class in the login_manager.anonymous_user attribute.
+
 
 @login_manager.user_loader
 def load_user(user_id):
diff --git a/app/templates/main/admin.html.j2 b/app/templates/main/admin.html.j2
new file mode 100644
index 00000000..35511c07
--- /dev/null
+++ b/app/templates/main/admin.html.j2
@@ -0,0 +1,12 @@
+{% extends "base.html.j2" %}
+
+{% block page_content %}
+<h1>Administration tools</h1>
+<div class="col s12">
+  <div class="card large">
+    <div class="card-content">
+      <span class="card-title">User list</span>
+    </div>
+  </div>
+</div>
+{% endblock %}
diff --git a/config.py b/config.py
index 5aee1326..c92907ff 100644
--- a/config.py
+++ b/config.py
@@ -11,6 +11,7 @@ class Config:
         ['true', 'on', '1']
     MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
     MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
+    OPAQUE_ADMIN = os.environ.get('OPAQUE_ADMIN')
     OPAQUE_MAIL_SUBJECT_PREFIX = '[Opaque]'
     OPAQUE_MAIL_SENDER = 'Opaque Development <dev.opaque@gmail.com>'
     SECRET_KEY = os.environ.get('SECRET_KEY') or 'hard to guess string'
diff --git a/migrations/versions/01a7d98d9647_.py b/migrations/versions/01a7d98d9647_.py
new file mode 100644
index 00000000..37b17682
--- /dev/null
+++ b/migrations/versions/01a7d98d9647_.py
@@ -0,0 +1,32 @@
+"""empty message
+
+Revision ID: 01a7d98d9647
+Revises: 69f5d9c59c34
+Create Date: 2019-07-09 10:59:08.639902
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '01a7d98d9647'
+down_revision = '69f5d9c59c34'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('roles', sa.Column('default', sa.Boolean(), nullable=True))
+    op.add_column('roles', sa.Column('permissions', sa.Integer(), nullable=True))
+    op.create_index(op.f('ix_roles_default'), 'roles', ['default'], unique=False)
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_index(op.f('ix_roles_default'), table_name='roles')
+    op.drop_column('roles', 'permissions')
+    op.drop_column('roles', 'default')
+    # ### end Alembic commands ###
diff --git a/opaque.py b/opaque.py
index 0e603289..c06c59d3 100644
--- a/opaque.py
+++ b/opaque.py
@@ -1,5 +1,5 @@
 from app import create_app, db
-from app.models import User, Role
+from app.models import User, Role, Permission
 from flask_migrate import Migrate
 import os
 
@@ -10,7 +10,7 @@ migrate = Migrate(app, db)
 
 @app.shell_context_processor
 def make_shell_context():
-    return dict(db=db, User=User, Role=Role)
+    return dict(db=db, User=User, Role=Role, Permission=Permission)
 
 
 @app.cli.command()
-- 
GitLab