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 '' % 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 '' % 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 %} +

Administration tools

+
+
+
+ User list +
+
+
+{% 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 ' 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()