diff --git a/.github/workflows/python-ci.yaml b/.github/workflows/python-ci.yaml
deleted file mode 100644
index a6f4c699..00000000
--- a/.github/workflows/python-ci.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-name: Python CI
-
-on: [push]
-
-jobs:
diff --git a/.gitignore b/.gitignore
index 8a07be70..c4e3eff4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,9 @@
+.idea
+megaqc/static/js/admin*
+megaqc/static/js/trend*
+venv/
+*.db
+
# Uploads
uploads/*
!uploads/README.md
diff --git a/docs/changelog.md b/docs/changelog.md
new file mode 100644
index 00000000..5151e2ba
--- /dev/null
+++ b/docs/changelog.md
@@ -0,0 +1,25 @@
+# Changelog
+
+## 0.3.0
+
+### Breaking Changes
+
+- [[#138]](https://github.com/ewels/MegaQC/issues/138) Added `USER_REGISTRATION_APPROVAL` as a config variable, which defaults to true. This means that the admin must explicitly activate new users in the user management page (`/users/admin/users`) before they can login. To disable this feature, you need to create a config file (for example `megaqc.conf.yaml`) with the contents:
+ ```yaml
+ STRICT_REGISTRATION: false
+ ```
+ Then, whenever you run MegaQC, you need to `export MEGAQC_CONFIG /path/to/megaqc.conf.yaml
+- Much stricter REST API permissions. You now need an API token for almost all requests. One exception is creating a new account, which you can do without a token, but it will be deactivated by default, unless it is the first account created
+
+### New Features
+
+- [[#140]](https://github.com/ewels/MegaQC/issues/140) Added a changelog. It's here! You're reading it!
+
+### Bug Fixes
+
+- [[#139]](https://github.com/ewels/MegaQC/issues/139) Fixed the user management page (`/users/admin/users`), which lost its JavaScript
+
+### Internal Changes
+
+- Tests for the REST API permissions
+- Enforce inactive users (by default) in the model layer
diff --git a/megaqc/api/views.py b/megaqc/api/views.py
index 293c0e55..87212f94 100644
--- a/megaqc/api/views.py
+++ b/megaqc/api/views.py
@@ -186,6 +186,7 @@ def admin_add_users(user, *args, **kwargs):
except:
abort(400)
new_user = User(**data)
+ new_user.enforce_admin()
password = new_user.reset_password()
new_user.active = True
new_user.save()
diff --git a/megaqc/public/views.py b/megaqc/public/views.py
index 1fbacaf0..0856e6b4 100644
--- a/megaqc/public/views.py
+++ b/megaqc/public/views.py
@@ -10,6 +10,7 @@
Blueprint,
Request,
abort,
+ current_app,
flash,
json,
redirect,
@@ -108,18 +109,23 @@ def register():
"""
form = RegisterForm(request.form)
if form.validate_on_submit():
- user_cnt = db.session.query(User).count()
- u = User.create(
+ u = User(
username=form.username.data,
email=form.email.data,
password=form.password.data,
first_name=form.first_name.data,
last_name=form.last_name.data,
- active=True,
- is_admin=True if user_cnt == 0 else False,
)
- flash("Thanks for registering! You're now logged in.", "success")
- login_user(u)
+ u.enforce_admin()
+ u.save()
+ if u.active:
+ flash("Thanks for registering! You're now logged in.", "success")
+ login_user(u)
+ else:
+ flash(
+ "Thanks for registering! You will now need to wait for your admin to approve this account.",
+ "success",
+ )
return redirect(url_for("public.home"))
else:
flash_errors(form)
diff --git a/megaqc/rest_api/schemas.py b/megaqc/rest_api/schemas.py
index 724bf762..b21a9d2c 100644
--- a/megaqc/rest_api/schemas.py
+++ b/megaqc/rest_api/schemas.py
@@ -363,7 +363,7 @@ class Meta:
email = f.String()
salt = f.String()
password = f.String()
- created_at = f.DateTime()
+ created_at = f.DateTime(dump_only=True)
first_name = f.String()
last_name = f.String()
active = f.Boolean()
diff --git a/megaqc/rest_api/utils.py b/megaqc/rest_api/utils.py
index 6d045fe5..68bba0eb 100644
--- a/megaqc/rest_api/utils.py
+++ b/megaqc/rest_api/utils.py
@@ -3,7 +3,8 @@
from functools import wraps
from uuid import uuid4
-from flask import request
+from flapison.exceptions import JsonApiException
+from flask import abort, request
from flask.globals import current_app
from megaqc.user.models import User
@@ -26,39 +27,56 @@ def get_unique_filename():
class Permission(IntEnum):
- VIEWER = auto()
+ NONUSER = auto()
USER = auto()
ADMIN = auto()
-def check_perms(function):
+def api_perms(min_level: Permission = Permission.NONUSER):
"""
- Adds a "user" and "permission" kwarg to the view function.
+ Adds a "user" and "permission" kwarg to the view function. Also verifies a
+ minimum permissions level.
- :param function:
- :return:
+ :param min_level: If provided, this is the minimum permission level required by this endpoint
"""
- @wraps(function)
- def user_wrap_function(*args, **kwargs):
- if not request.headers.has_key("access_token"):
- perms = Permission.VIEWER
- user = None
- else:
- user = User.query.filter_by(
- api_token=request.headers.get("access_token")
- ).first()
- if not user:
- perms = Permission.VIEWER
- elif user.is_anonymous:
- perms = Permission.VIEWER
- elif user.is_admin:
- perms = Permission.ADMIN
+ def wrapper(function):
+ @wraps(function)
+ def user_wrap_function(*args, **kwargs):
+ extra = None
+ if not request.headers.has_key("access_token"):
+ perms = Permission.NONUSER
+ user = None
+ extra = "No access token provided. Please add a header with the name 'access_token'."
else:
- perms = Permission.USER
+ user = User.query.filter_by(
+ api_token=request.headers.get("access_token")
+ ).first()
+ if not user:
+ perms = Permission.NONUSER
+ extra = "The provided access token was invalid."
+ elif user.is_anonymous:
+ perms = Permission.NONUSER
+ elif user.is_admin:
+ perms = Permission.ADMIN
+ elif not user.is_active():
+ perms = Permission.NONUSER
+ extra = "User is not active."
+ else:
+ perms = Permission.USER
- kwargs["user"] = user
- kwargs["permission"] = perms
- return function(*args, **kwargs)
+ if perms < min_level:
+ title = "Insufficient permissions to access this resource"
+ raise JsonApiException(
+ title=title,
+ detail=extra,
+ status=403,
+ )
- return user_wrap_function
+ kwargs["user"] = user
+ kwargs["permission"] = perms
+ return function(*args, **kwargs)
+
+ return user_wrap_function
+
+ return wrapper
diff --git a/megaqc/rest_api/views.py b/megaqc/rest_api/views.py
index 7536225a..37760d11 100644
--- a/megaqc/rest_api/views.py
+++ b/megaqc/rest_api/views.py
@@ -7,29 +7,57 @@
from http import HTTPStatus
from flapison import ResourceDetail, ResourceList, ResourceRelationship
-from flask import Blueprint, jsonify, make_response, request
-from flask_login import current_user
+from flapison.schema import get_nested_fields, get_relationships
+from flask import Blueprint, current_app, jsonify, make_response, request
+from flask_login import current_user, login_required
from marshmallow.utils import EXCLUDE, INCLUDE
from marshmallow_jsonapi.exceptions import IncorrectTypeError
import megaqc.user.models as user_models
-from megaqc.api.views import check_user
from megaqc.extensions import db, json_api, restful
from megaqc.model import models
from megaqc.rest_api import plot, schemas, utils
from megaqc.rest_api.content import json_to_csv
+from megaqc.rest_api.utils import Permission, api_perms
from megaqc.rest_api.webarg_parser import use_args, use_kwargs
api_bp = Blueprint("rest_api", __name__, url_prefix="/rest_api/v1")
json_api.blueprint = api_bp
-class Upload(ResourceDetail):
+class PermissionsMixin:
+ """
+ Adds shared config to all views.
+
+ Logged-out users shouldn't be able to access the API at all, logged
+ in users should be able to only GET, and only admins should be able
+ to POST, PATCH or DELETE. These decorators can be overriden by child
+ classes, however
+ """
+
+ @api_perms(Permission.USER)
+ def get(self, **kwargs):
+ return super().get(**kwargs)
+
+ @api_perms(Permission.ADMIN)
+ def post(self, **kwargs):
+ return super().post(**kwargs)
+
+ @api_perms(Permission.ADMIN)
+ def patch(self, **kwargs):
+ return super().patch(**kwargs)
+
+ @api_perms(Permission.ADMIN)
+ def delete(self, **kwargs):
+ return super().delete(**kwargs)
+
+
+class Upload(PermissionsMixin, ResourceDetail):
schema = schemas.UploadSchema
data_layer = dict(session=db.session, model=models.Upload)
-class UploadList(ResourceList):
+class UploadList(PermissionsMixin, ResourceList):
view_kwargs = True
schema = schemas.UploadSchema
data_layer = dict(session=db.session, model=models.Upload)
@@ -42,10 +70,13 @@ def get_schema_kwargs(self, args, kwargs):
return {}
- @check_user
+ @api_perms(Permission.USER)
def post(self, **kwargs):
"""
Upload a new report.
+
+ This is rare in that average users *can* do this, even though
+ they aren't allowed to edit arbitrary data
"""
# This doesn't exactly follow the JSON API spec, since it doesn't exactly support file uploads:
# https://github.com/json-api/json-api/issues/246
@@ -61,61 +92,61 @@ def post(self, **kwargs):
return schemas.UploadSchema(many=False).dump(upload_row), HTTPStatus.CREATED
-class UploadRelationship(ResourceRelationship):
+class UploadRelationship(PermissionsMixin, ResourceRelationship):
schema = schemas.UploadSchema
data_layer = dict(session=db.session, model=models.Upload)
-class ReportList(ResourceList):
+class ReportList(PermissionsMixin, ResourceList):
view_kwargs = True
schema = schemas.ReportSchema
data_layer = dict(session=db.session, model=models.Report)
-class Report(ResourceDetail):
+class Report(PermissionsMixin, ResourceDetail):
schema = schemas.ReportSchema
data_layer = dict(session=db.session, model=models.Report)
-class ReportRelationship(ResourceRelationship):
+class ReportRelationship(PermissionsMixin, ResourceRelationship):
schema = schemas.ReportSchema
data_layer = dict(session=db.session, model=models.Report)
-class ReportMeta(ResourceDetail):
+class ReportMeta(PermissionsMixin, ResourceDetail):
view_kwargs = True
schema = schemas.ReportMetaSchema
data_layer = dict(session=db.session, model=models.ReportMeta)
-class ReportMetaList(ResourceList):
+class ReportMetaList(PermissionsMixin, ResourceList):
view_kwargs = True
schema = schemas.ReportMetaSchema
data_layer = dict(session=db.session, model=models.ReportMeta)
-class ReportMetaRelationship(ResourceRelationship):
+class ReportMetaRelationship(PermissionsMixin, ResourceRelationship):
schema = schemas.ReportMetaSchema
data_layer = dict(session=db.session, model=models.ReportMeta)
-class Sample(ResourceDetail):
+class Sample(PermissionsMixin, ResourceDetail):
schema = schemas.SampleSchema
data_layer = dict(session=db.session, model=models.Sample)
-class SampleList(ResourceList):
+class SampleList(PermissionsMixin, ResourceList):
view_kwargs = True
schema = schemas.SampleSchema
data_layer = dict(session=db.session, model=models.Sample)
-class SampleRelationship(ResourceRelationship):
+class SampleRelationship(PermissionsMixin, ResourceRelationship):
schema = schemas.SampleSchema
data_layer = dict(session=db.session, model=models.Sample)
-class ReportMetaTypeList(ResourceList):
+class ReportMetaTypeList(PermissionsMixin, ResourceList):
view_kwargs = True
schema = schemas.ReportMetaTypeSchema
data_layer = dict(session=db.session, model=models.ReportMeta)
@@ -133,40 +164,40 @@ def get_collection(self, qs, kwargs, filters=None):
return query.count(), query.all()
-class SampleData(ResourceDetail):
+class SampleData(PermissionsMixin, ResourceDetail):
view_kwargs = True
schema = schemas.SampleDataSchema
data_layer = dict(session=db.session, model=models.SampleData)
-class SampleDataList(ResourceList):
+class SampleDataList(PermissionsMixin, ResourceList):
view_kwargs = True
schema = schemas.SampleDataSchema
data_layer = dict(session=db.session, model=models.SampleData)
-class SampleDataRelationship(ResourceRelationship):
+class SampleDataRelationship(PermissionsMixin, ResourceRelationship):
schema = schemas.SampleDataSchema
data_layer = dict(session=db.session, model=models.SampleData)
-class DataType(ResourceDetail):
+class DataType(PermissionsMixin, ResourceDetail):
schema = schemas.SampleDataTypeSchema
data_layer = dict(session=db.session, model=models.SampleDataType)
-class DataTypeList(ResourceList):
+class DataTypeList(PermissionsMixin, ResourceList):
view_kwargs = True
schema = schemas.SampleDataTypeSchema
data_layer = dict(session=db.session, model=models.SampleDataType)
-class User(ResourceDetail):
+class User(PermissionsMixin, ResourceDetail):
schema = schemas.UserSchema
data_layer = dict(session=db.session, model=user_models.User)
-class UserRelationship(ResourceRelationship):
+class UserRelationship(PermissionsMixin, ResourceRelationship):
schema = schemas.UserSchema
data_layer = dict(session=db.session, model=user_models.User)
@@ -176,6 +207,22 @@ class UserList(ResourceList):
schema = schemas.UserSchema
data_layer = dict(session=db.session, model=user_models.User)
+ @api_perms(Permission.USER)
+ def get(self, **kwargs):
+ return super().get(**kwargs)
+
+ # We allow this endpoint to be hit by a non user, to allow the first user to be created
+ @api_perms(Permission.NONUSER)
+ def post(self, **kwargs):
+ return super().post(**kwargs)
+
+ def post_schema_kwargs(self, args, kwargs):
+ # None of these fields should be set directly by a user
+ if kwargs["permission"] < utils.Permission.ADMIN:
+ return {"dump_only": ["admin", "active", "salt", "api_token"]}
+ else:
+ return {}
+
def get_schema_kwargs(self, args, kwargs):
# Only show the filepath if they're an admin
if "user" in kwargs and kwargs["permission"] <= utils.Permission.ADMIN:
@@ -184,19 +231,27 @@ def get_schema_kwargs(self, args, kwargs):
return {}
def create_object(self, data, kwargs):
+ # This is mostly copied from flapison.data_layers.alchemy
+ relationship_fields = get_relationships(self.schema, model_field=True)
+ nested_fields = get_nested_fields(self.schema, model_field=True)
+ join_fields = relationship_fields + nested_fields
+ new_user = self.data_layer["model"](
+ **{key: value for (key, value) in data.items() if key not in join_fields}
+ )
+ self._data_layer.apply_relationships(data, new_user)
+ self._data_layer.apply_nested_fields(data, new_user)
# Creating a user requires generating a password
- new_user = super().create_object(data, kwargs)
+ new_user.enforce_admin()
new_user.set_password(data["password"])
- new_user.active = True
new_user.save()
return new_user
-class CurrentUser(ResourceDetail):
+class CurrentUser(PermissionsMixin, ResourceDetail):
schema = schemas.UserSchema
data_layer = dict(session=db.session, model=user_models.User)
- @utils.check_perms
+ @login_required
def get(self, **kwargs):
"""
Get details about the current user.
@@ -216,7 +271,7 @@ def get(self, **kwargs):
.first_or_404()
)
- if kwargs["permission"] >= utils.Permission.ADMIN:
+ if current_user.is_admin:
# If an admin is making this request, give them everything
schema_kwargs = {}
else:
@@ -226,23 +281,23 @@ def get(self, **kwargs):
return schemas.UserSchema(many=False, **schema_kwargs).dump(user)
-class FilterList(ResourceList):
+class FilterList(PermissionsMixin, ResourceList):
view_kwargs = True
schema = schemas.SampleFilterSchema
data_layer = dict(session=db.session, model=models.SampleFilter)
-class Filter(ResourceDetail):
+class Filter(PermissionsMixin, ResourceDetail):
schema = schemas.SampleFilterSchema
data_layer = dict(session=db.session, model=models.SampleFilter)
-class FilterRelationship(ResourceRelationship):
+class FilterRelationship(PermissionsMixin, ResourceRelationship):
schema = schemas.SampleFilterSchema
data_layer = dict(session=db.session, model=models.SampleFilter)
-class FilterGroupList(ResourceList):
+class FilterGroupList(PermissionsMixin, ResourceList):
view_kwargs = True
schema = schemas.FilterGroupSchema
data_layer = dict(session=db.session, model=models.SampleFilter)
@@ -257,40 +312,40 @@ def get_collection(self, qs, kwargs, filters=None):
return query.count(), query.all()
-class FavouritePlotList(ResourceList):
+class FavouritePlotList(PermissionsMixin, ResourceList):
view_kwargs = True
schema = schemas.FavouritePlotSchema
data_layer = dict(session=db.session, model=models.PlotFavourite)
-class FavouritePlot(ResourceDetail):
+class FavouritePlot(PermissionsMixin, ResourceDetail):
schema = schemas.FavouritePlotSchema
data_layer = dict(session=db.session, model=models.PlotFavourite)
-class FavouritePlotRelationship(ResourceRelationship):
+class FavouritePlotRelationship(PermissionsMixin, ResourceRelationship):
schema = schemas.FavouritePlotSchema
data_layer = dict(session=db.session, model=models.PlotFavourite)
-class DashboardList(ResourceList):
+class DashboardList(PermissionsMixin, ResourceList):
view_kwargs = True
schema = schemas.DashboardSchema
data_layer = dict(session=db.session, model=models.Dashboard)
-class DashboardRelationship(ResourceList):
+class DashboardRelationship(PermissionsMixin, ResourceList):
view_kwargs = True
schema = schemas.DashboardSchema
data_layer = dict(session=db.session, model=models.Dashboard)
-class Dashboard(ResourceDetail):
+class Dashboard(PermissionsMixin, ResourceDetail):
schema = schemas.DashboardSchema
data_layer = dict(session=db.session, model=models.Dashboard)
-class TrendSeries(ResourceList):
+class TrendSeries(PermissionsMixin, ResourceList):
@use_args(schemas.TrendInputSchema(), locations=("querystring",))
def get(self, args):
# We need to give each resource a unique ID so the client doesn't try to cache
diff --git a/megaqc/settings.py b/megaqc/settings.py
index 494007ec..e6a62b8f 100644
--- a/megaqc/settings.py
+++ b/megaqc/settings.py
@@ -40,6 +40,8 @@ class Config(object):
SQLALCHEMY_USER = "megaqc_user"
SQLALCHEMY_PASS = ""
SQLALCHEMY_DATABASE = "megaqc"
+ # If this is true, every user after the first has to be approved before it becomes active
+ USER_REGISTRATION_APPROVAL = True
def __init__(self):
if self.EXTRA_CONFIG:
diff --git a/megaqc/static/js/user_admin.js b/megaqc/static/js/user_admin.js
new file mode 100644
index 00000000..f5b8ca35
--- /dev/null
+++ b/megaqc/static/js/user_admin.js
@@ -0,0 +1,158 @@
+
+$(function(){
+ init_buttons();
+});
+
+
+function init_buttons(){
+ $(".update_btn").click(function(e){
+ $("#my_alert").remove()
+ var data = {'csrf_token':$("#csrf_token").val()};
+ e.preventDefault();
+ $(this).parentsUntil('tbody').find('input').each(function(idx, el){
+ if (el.type != 'button'){
+ if (el.type=="checkbox"){
+ data[el.name] = $(el).prop("checked");
+ } else {
+ data[el.name] = el.value;
+ }
+ }
+ });
+ $.ajax({
+ url:"/api/update_users",
+ type: 'post',
+ data:JSON.stringify(data),
+ headers : {access_token:window.token},
+ dataType: 'json',
+ contentType:"application/json; charset=UTF-8",
+ success: function(){
+ toastr.success('User updated successfully');
+ },
+ error: function(ret_data){
+ toastr.error('Error: '+ret_data.responseJSON.message);
+ }
+ });
+ });
+ $(".delete_btn").click(function(e){
+ $("#my_alert").remove()
+ var my_btn = $(this);
+ var data = {};
+ e.preventDefault();
+ $(this).parentsUntil('tbody').find('input').each(function(idx, el){
+ if (el.name == 'user_id'){
+ data[el.name] = el.value;
+ }
+ });
+ $.ajax({
+ url:"/api/delete_users",
+ type: 'post',
+ data:JSON.stringify(data),
+ headers : {access_token:window.token},
+ dataType: 'json',
+ contentType:"application/json; charset=UTF-8",
+ success: function(){
+ my_btn.parentsUntil('tbody').remove();
+ toastr.success('User deleted');
+ },
+ error: function(ret_data){
+ toastr.error('Error: '+ret_data.responseJSON.message);
+ }
+ });
+ });
+ $(".reset_btn").click(function(e){
+ var data = {};
+ e.preventDefault();
+ $(this).parentsUntil('tbody').find('input').each(function(idx, el){
+ if (el.name == 'user_id'){
+ data[el.name] = el.value;
+ }
+ });
+ $.ajax({
+ url:"/api/reset_password",
+ type: 'post',
+ data:JSON.stringify(data),
+ headers : {access_token:window.token},
+ dataType: 'json',
+ contentType:"application/json; charset=UTF-8",
+ success: function(data){
+ toastr.success(
+ 'Password updated successfully!
New password: '+data.password+'
',
+ null, {
+ "closeButton": true,
+ "timeOut": 0,
+ "extendedTimeOut": 0,
+ "tapToDismiss": false
+ }
+ );
+ },
+ error: function(ret_data){
+ toastr.error('Error: '+ret_data.responseJSON.message);
+ }
+ });
+ });
+ $(".add_btn").click(function(e){
+ $("#my_alert").remove()
+ var data = {};
+ e.preventDefault();
+ $(this).parentsUntil('tbody').find('input').each(function(idx, el){
+ if (el.type != 'button'){
+ if (el.type=="checkbox"){
+ data[el.name] = $(el).prop("checked");
+ }else{
+ data[el.name] = el.value;
+ }
+ }
+ });
+ $.ajax({
+ url:"/api/add_user",
+ type: 'post',
+ data: JSON.stringify(data),
+ headers: {access_token:window.token},
+ dataType: 'json',
+ contentType: "application/json; charset=UTF-8",
+ success: function(ret_data){
+ $('#add_table').find('input').each(function (idx, el){
+ if (el.type != 'button'){
+ if (el.type == "checkbox"){
+ $(el).prop("checked", false);
+ }else{
+ el.value = '';
+ }
+ }
+ });
+ $('#user_table tr:last').after(
+ '
'+ret_data.password+'
',
+ null, {
+ "closeButton": true,
+ "timeOut": 0,
+ "extendedTimeOut": 0,
+ "tapToDismiss": false
+ }
+ );
+ init_buttons();
+ },
+ error: function(){
+ toastr.error('Error saving new user');
+ }
+ });
+ });
+}
diff --git a/megaqc/templates/users/manage_users.html b/megaqc/templates/users/manage_users.html
index 97afd3cc..ae9ab1c6 100644
--- a/megaqc/templates/users/manage_users.html
+++ b/megaqc/templates/users/manage_users.html
@@ -67,5 +67,5 @@