diff --git a/requirements.txt b/requirements.txt index 16cc574..c2e7a50 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,13 @@ attrs==22.2.0 bcrypt==4.0.1 +bitmask @ git+https://github.com/dogeystamp/bitmask@8524113fcdc22a570bda77d440374f5f269fdb79 click==8.1.3 coverage==7.2.1 exceptiongroup==1.1.0 Flask==2.2.3 Flask-Bcrypt==1.0.1 Flask-Cors==3.0.10 +flask-marshmallow==0.14.0 Flask-Script==2.0.6 Flask-SQLAlchemy==3.0.3 Flask-Testing==0.8.1 diff --git a/sachet/server/commands.py b/sachet/server/commands.py index 8462372..e8f7fb2 100644 --- a/sachet/server/commands.py +++ b/sachet/server/commands.py @@ -1,8 +1,9 @@ import click from sachet.server import app, db -from sachet.server.models import User +from sachet.server.models import User, Permissions from sachet.server.users import manage from flask.cli import AppGroup +from bitmask import Bitmask db_cli = AppGroup("db") @@ -32,7 +33,10 @@ user_cli = AppGroup("user") help="Sets the user's password (for security, avoid setting this from the command line).") def create_user(admin, username, password): """Create a user directly in the database.""" - manage.create_user(admin, username, password) + perms = Bitmask() + if admin: + perms.add(Permissions.ADMIN) + manage.create_user(perms, username, password) @user_cli.command("delete") @click.argument("username") diff --git a/sachet/server/models.py b/sachet/server/models.py index 5ede7a9..ca5927b 100644 --- a/sachet/server/models.py +++ b/sachet/server/models.py @@ -1,8 +1,21 @@ from sachet.server import app, db, ma, bcrypt +from marshmallow import fields, ValidationError from flask import request, jsonify from functools import wraps import datetime import jwt +from bitmask import Bitmask +from enum import IntFlag + + +class Permissions(IntFlag): + CREATE = 1 + MODIFY = 1<<1 + DELETE = 1<<2 + LOCK = 1<<3 + LIST = 1<<4 + ADMIN = 1<<5 + class User(db.Model): __tablename__ = "users" @@ -10,15 +23,44 @@ class User(db.Model): username = db.Column(db.String(255), unique=True, nullable=False, primary_key=True) password = db.Column(db.String(255), nullable=False) register_date = db.Column(db.DateTime, nullable=False) - admin = db.Column(db.Boolean, nullable=False, default=False) - def __init__(self, username, password, admin=False): + permissions_number = db.Column(db.BigInteger, nullable=False, default=0) + + @property + def permissions(self): + """ + Bitmask listing all permissions. + + See the Permissions class for all possible permissions. + + Also, see https://github.com/dogeystamp/bitmask for information on how + to use this field. + """ + + mask = Bitmask() + mask.AllFlags = Permissions + mask.value = self.permissions_number + return mask + + @permissions.setter + def permissions(self, value): + mask = Bitmask() + mask.AllFlags = Permissions + mask += value + self.permissions_number = mask.value + db.session.commit() + + + def __init__(self, username, password, permissions): + permissions.AllFlags = Permissions + self.permissions = permissions + self.password = bcrypt.generate_password_hash( password, app.config.get("BCRYPT_LOG_ROUNDS") ).decode() self.username = username self.register_date = datetime.datetime.now() - self.admin = admin + def encode_token(self, jti=None): """Generates an authentication token""" @@ -35,13 +77,37 @@ class User(db.Model): ) +class PermissionField(fields.Field): + """Field that serializes a Permissions bitmask to an array of strings.""" + + def _serialize(self, value, attr, obj, **kwargs): + mask = Bitmask() + mask.AllFlags = Permissions + mask += value + return [flag.name for flag in mask] + + def _deserialize(self, value, attr, data, **kwargs): + mask = Bitmask() + mask.AllFlags = Permissions + + flags = value + + try: + for flag in flags: + mask.add(Permissions[flag]) + except KeyError as e: + raise ValidationError("Invalid permission.") from e + + return mask + + class UserSchema(ma.SQLAlchemySchema): class Meta: model = User username = ma.auto_field() register_date = ma.auto_field() - admin = ma.auto_field() + permissions = PermissionField(data_key="permissions") class BlacklistToken(db.Model): diff --git a/sachet/server/users/manage.py b/sachet/server/users/manage.py index 622d845..8985381 100644 --- a/sachet/server/users/manage.py +++ b/sachet/server/users/manage.py @@ -1,7 +1,7 @@ from sachet.server import app, db from sachet.server.models import User -def create_user(admin, username, password): +def create_user(permissions, username, password): # to reduce confusion with API endpoints forbidden = {"login", "logout", "extend"} @@ -13,7 +13,7 @@ def create_user(admin, username, password): user = User( username=username, password=password, - admin=admin + permissions=permissions ) db.session.add(user) db.session.commit() diff --git a/sachet/server/users/views.py b/sachet/server/users/views.py index 808ea17..5113b56 100644 --- a/sachet/server/users/views.py +++ b/sachet/server/users/views.py @@ -1,7 +1,7 @@ import jwt from flask import Blueprint, request, jsonify from flask.views import MethodView -from sachet.server.models import auth_required, read_token, User, UserSchema, BlacklistToken +from sachet.server.models import auth_required, read_token, Permissions, User, UserSchema, BlacklistToken from sachet.server import bcrypt, db user_schema = UserSchema() @@ -66,7 +66,7 @@ class LogoutAPI(MethodView): except jwt.InvalidTokenError: return jsonify({"status": "fail", "message": "Invalid auth token."}), 400 - if user == token_user or user.admin == True: + if user == token_user or Permissions.ADMIN in user.permissions: entry = BlacklistToken(token=token) db.session.add(entry) db.session.commit() @@ -109,7 +109,7 @@ class UserAPI(MethodView): @auth_required def get(user, self, username): info_user = User.query.filter_by(username=username).first() - if (not info_user) or (info_user != user and not user.admin): + if (not info_user) or (info_user != user and Permissions.ADMIN not in user.permissions): resp = { "status": "fail", "message": "You are not authorized to view this page." diff --git a/tests/conftest.py b/tests/conftest.py index a218ece..a19454f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,10 @@ import yaml from sachet.server.users import manage from click.testing import CliRunner from sachet.server import app, db +from sachet.server.models import Permissions, UserSchema +from bitmask import Bitmask + +user_schema = UserSchema() @pytest.fixture def client(): @@ -32,21 +36,21 @@ def users(client): Returns a dictionary with all the info for each user. """ - userinfo = { - "jeff": { - "password": "1234", - "admin": False - }, - "administrator": { - "password": "4321", - "admin": True - } - } + userinfo = dict( + jeff = dict( + password = "1234", + permissions = Bitmask() + ), + administrator = dict( + password = "4321", + permissions = Bitmask(Permissions.ADMIN) + ), + ) for user, info in userinfo.items(): info["username"] = user manage.create_user( - info["admin"], + info["permissions"], info["username"], info["password"] ) @@ -56,14 +60,16 @@ def users(client): @pytest.fixture def validate_info(users): - """Given a dictionary, validate the information against a given user's info.""" + """Given a response, deserialize and validate the information against a given user's info.""" verify_fields = [ "username", - "admin", + "permissions", ] def _validate(user, info): + info = user_schema.load(info) + for k in verify_fields: assert users[user][k] == info[k]