diff --git a/server/.admin_credentials.swp b/server/.admin_credentials.swp deleted file mode 100644 index fe31a45..0000000 Binary files a/server/.admin_credentials.swp and /dev/null differ diff --git a/server/corvus/__init__.py b/server/corvus/__init__.py index cc02696..59825e2 100644 --- a/server/corvus/__init__.py +++ b/server/corvus/__init__.py @@ -6,6 +6,7 @@ from flask import Flask from flask_migrate import Migrate from corvus.db import db +from corvus.errors import BaseError, handle_corvus_base_error from corvus.utility import json_utility, session_utility dictConfig({ @@ -42,7 +43,8 @@ def create_app(test_config: dict = None) -> Flask: app.config.from_mapping( SECRET_KEY='dev', SQLALCHEMY_DATABASE_URI=default_database_uri, - SQLALCHEMY_TRACK_MODIFICATIONS=False + SQLALCHEMY_TRACK_MODIFICATIONS=False, + TRAP_HTTP_EXCEPTIONS=True, ) if test_config is None: @@ -53,7 +55,7 @@ def create_app(test_config: dict = None) -> Flask: app.config.from_envvar('CORVUS_SETTINGS') else: app.logger.debug('Loading test configuration') - app.config.from_object(test_config) + app.config.from_mapping(test_config) try: os.makedirs(app.instance_path) @@ -83,8 +85,19 @@ def register_blueprints(app: Flask) -> None: app.register_blueprint(USER_BLUEPRINT) +def register_error_handlers(app: Flask) -> None: + """ + Register error handlers for the application. + + :param app: + :return: + """ + app.register_error_handler(BaseError, handle_corvus_base_error) + + corvus = create_app() # pylint: disable=C0103 register_blueprints(corvus) +register_error_handlers(corvus) logger = corvus.logger # pylint: disable=C0103 diff --git a/server/corvus/api/decorators.py b/server/corvus/api/decorators.py index 7fdb1df..be19905 100644 --- a/server/corvus/api/decorators.py +++ b/server/corvus/api/decorators.py @@ -27,7 +27,7 @@ def return_json(func: Callable) -> Callable: if isinstance(result, Response): return result if isinstance(result, APIResponse): - return jsonify(result.payload), result.status + return jsonify(result), result.status return jsonify(result) return decorate diff --git a/server/corvus/api/model.py b/server/corvus/api/model.py index a114d68..fb84b49 100644 --- a/server/corvus/api/model.py +++ b/server/corvus/api/model.py @@ -1,9 +1,15 @@ """Model definitions for the api module.""" -from typing import Any, NamedTuple +from typing import Any, List, Optional -class APIResponse(NamedTuple): # pylint: disable=too-few-public-methods +class APIResponse: # pylint: disable=too-few-public-methods """Custom class to wrap api responses.""" - payload: Any - status: int + def __init__(self, + payload: Any, + status: int = 200, + options: Optional[List[str]] = None) -> None: + """Construct an APIResponse object.""" + self.payload = payload + self.status = status + self.options = options diff --git a/server/corvus/api/user_api.py b/server/corvus/api/user_api.py index 5af9d79..81a967d 100644 --- a/server/corvus/api/user_api.py +++ b/server/corvus/api/user_api.py @@ -1,10 +1,12 @@ """User API blueprint and endpoint definitions.""" -from flask import Blueprint, abort +from flask import Blueprint, abort, request from corvus.api.decorators import return_json from corvus.api.model import APIResponse from corvus.middleware import authentication_middleware -from corvus.service import user_service +from corvus.model import User +from corvus.service import user_service, transformation_service +from corvus.service.role_service import Role USER_BLUEPRINT = Blueprint( name='user', import_name=__name__, url_prefix='/user') @@ -15,11 +17,31 @@ USER_BLUEPRINT = Blueprint( @authentication_middleware.require_token_auth def get_user(name: str) -> APIResponse: """ - Get a token for continued authentication. + Get a user. - :return: A login token for continued authentication + :return: user if exists, else 404 """ user = user_service.find_by_name(name) if user is not None: return APIResponse(user, 200) return abort(404) + + +@USER_BLUEPRINT.route('/', methods=['POST']) +@return_json +@authentication_middleware.require_token_auth +@authentication_middleware.require_role(required_role=Role.ADMIN) +def register_user() -> APIResponse: + """ + Register a user with the service. + + :return: The newly registered User + """ + new_user: User = transformation_service.deserialize_model( + User.__name__, request.json) + registered_user = user_service.register( + name=new_user.name, + password=None, + role=new_user.role + ) + return APIResponse(payload=registered_user, status=200) diff --git a/server/corvus/errors.py b/server/corvus/errors.py index 4496ef2..7be7750 100644 --- a/server/corvus/errors.py +++ b/server/corvus/errors.py @@ -1,20 +1,50 @@ """Error definitions for Corvus.""" from typing import Dict +from corvus.api.decorators import return_json +from corvus.api.model import APIResponse -class BaseError(RuntimeError): - """Corvus Base Error Class.""" + +class BaseError(Exception): + """Corvus Base Error Class (5xx errors).""" def __init__( self, message: str = 'Unknown error', + status_code: int = 500, extra_fields: Dict[str, str] = None) -> None: """Populate The Error Definition.""" super().__init__(message) + self.message = message + self.status_code = status_code self.extra_fields = extra_fields + def to_dict(self) -> dict: + """Serialize an error message to return.""" + return { + 'message': self.message, + 'status_code': self.status_code + } + + +class ClientError(BaseError): + """Corvus errors where the client is wrong (4xx errors).""" + + def __init__(self, + message: str = 'Unknown client error', + status_code: int = 400, + extra_fields: Dict[str, str] = None) -> None: + """Init for client originated errors.""" + super().__init__(message, status_code, extra_fields) -class ValidationError(BaseError): + +class ValidationError(ClientError): """Corvus Validation Error.""" pass + + +@return_json +def handle_corvus_base_error(error: BaseError) -> APIResponse: + """Error handler for basic Corvus raised errors.""" + return APIResponse(payload=error, status=error.status_code) diff --git a/server/corvus/middleware/authentication_middleware.py b/server/corvus/middleware/authentication_middleware.py index 3233772..3b10f81 100644 --- a/server/corvus/middleware/authentication_middleware.py +++ b/server/corvus/middleware/authentication_middleware.py @@ -1,10 +1,10 @@ """Middleware to handle authentication.""" import base64 +import binascii from functools import wraps from typing import Optional, Callable, Any -import binascii -from flask import request, Response, g +from flask import request, Response, g, json from werkzeug.datastructures import Authorization from werkzeug.http import bytes_to_wsgi, wsgi_to_bytes @@ -13,6 +13,7 @@ from corvus.service import ( user_service, user_token_service ) +from corvus.service.role_service import ROLES, Role def authenticate_with_password(name: str, password: str) -> bool: @@ -64,6 +65,17 @@ def authentication_failed(auth_type: str) -> Response: }) +def authorization_failed(required_role: str) -> Response: + """Return a correct response for failed authorization.""" + return Response( + status=401, + response=json.dumps({ + 'message': '{} role not present'.format(required_role) + }), + content_type='application/json' + ) + + def parse_token_header( header_value: str) -> Optional[Authorization]: """ @@ -137,3 +149,17 @@ def require_token_auth(func: Callable) -> Callable: return authentication_failed('Bearer') return decorate + + +def require_role(required_role: Role) -> Callable: + """Decorate require user role.""" + def required_role_decorator(func: Callable) -> Callable: + """Decorate the function.""" + @wraps(func) + def decorate(*args: list, **kwargs: dict) -> Any: + """Require a user role.""" + if g.user.role in ROLES.find_roles_in_hierarchy(required_role): + return func(*args, **kwargs) + return authorization_failed(required_role.value) + return decorate + return required_role_decorator diff --git a/server/corvus/model/user_model.py b/server/corvus/model/user_model.py index 1b8d108..49e3f8b 100644 --- a/server/corvus/model/user_model.py +++ b/server/corvus/model/user_model.py @@ -1,5 +1,8 @@ """User related SQLALchemy models.""" +from sqlalchemy import Enum + from corvus.db import db +from corvus.service.role_service import Role class User(db.Model): # pylint: disable=too-few-public-methods @@ -14,9 +17,9 @@ class User(db.Model): # pylint: disable=too-few-public-methods name = db.Column(db.Unicode(60), unique=True, nullable=False) role = db.Column( 'role', - db.Unicode(32), + Enum(Role), nullable=False, - default=ROLE_USER, ) + default=Role.USER, ) password_hash = db.Column('password_hash', db.Unicode(128), nullable=False) password_revision = db.Column( 'password_revision', db.SmallInteger, default=0, nullable=False) diff --git a/server/corvus/service/role_service.py b/server/corvus/service/role_service.py new file mode 100644 index 0000000..2b2135e --- /dev/null +++ b/server/corvus/service/role_service.py @@ -0,0 +1,88 @@ +"""Role service for Corvus.""" +from collections import defaultdict +from enum import Enum +from typing import Optional, List, Set, Dict + + +class Role(Enum): + """User role definitions.""" + + ADMIN = 'ADMIN' + AUDITOR = 'AUDITOR' + MODERATOR = 'MODERATOR' + USER = 'USER' + ANONYMOUS = 'ANONYMOUS' + NONE = 'NONE' + + +class RoleTree(defaultdict): + """Simple tree structure to handle hierarchy.""" + + def __call__(self, data: Role) -> 'RoleTree': + """Handle direct calls to the tree.""" + return RoleTree(self, data) + + # def __hash__(self): + + def __init__( + self, + parent: Optional['RoleTree'], + data: Role, + **kwargs: dict) -> None: + """Configure a RoleTree.""" + super().__init__(**kwargs) + self.parent: Optional[RoleTree] = parent + self.data: Role = data + self.default_factory = self # type: ignore + self.roles: Dict[Role, List[RoleTree]] = {data: [self]} + + def populate( + self, children: Dict[Role, Optional[dict]]) -> List['RoleTree']: + """Populate a RoleTree from a dictionary of a Role hierarchy.""" + role_list: List[RoleTree] = [] + for child_role in children.keys(): + element = children[child_role] + new_node = self(child_role) + if isinstance(element, dict) and element: + role_list += new_node.populate(element) + self[child_role] = new_node + role_list.append(new_node) + for role_tree in role_list: + if role_tree.data not in self.roles.keys(): + self.roles[role_tree.data] = [] + self.roles[role_tree.data].append(role_tree) + return role_list + + def find_role(self, request_role: Role) -> List['RoleTree']: + """Identify all instances of a role.""" + try: + return [role_tree for role_tree in self.roles[request_role]] + except KeyError: + return [] + + def get_parent_roles(self) -> List[Role]: + """Return all the roles from self to the highest parent.""" + if self.parent is not None: + return [self.data] + self.parent.get_parent_roles() + return [self.data] + + def find_roles_in_hierarchy(self, request_role: Role) -> Set[Role]: + """Find a set of all roles that fall within the hierarchy.""" + roles: List[Role] = [] + role_trees = self.find_role(request_role) + for role_tree in role_trees: + roles.extend(role_tree.get_parent_roles()) + return set(role for role in roles) + + +ROLES = RoleTree(None, Role.ADMIN) +ROLES.populate({ + Role.MODERATOR: { + Role.USER: { + Role.ANONYMOUS: None + } + }, + Role.AUDITOR: { + Role.USER: None + } +}) diff --git a/server/corvus/service/transformation_service.py b/server/corvus/service/transformation_service.py index 845f271..b268c86 100644 --- a/server/corvus/service/transformation_service.py +++ b/server/corvus/service/transformation_service.py @@ -90,7 +90,7 @@ def serialize_model(model_obj: db.Model, def deserialize_model( model_type: str, json_model_object: dict, - options: Optional[List[str]] = None) -> Type[db.Model]: + options: Optional[List[str]] = None) -> db.Model: """Lookup a Model and hand it off to the deserializer.""" try: transformer = _model_transformers[model_type] diff --git a/server/corvus/service/user_service.py b/server/corvus/service/user_service.py index e937881..8f7fffd 100644 --- a/server/corvus/service/user_service.py +++ b/server/corvus/service/user_service.py @@ -1,10 +1,14 @@ """Service to handle user operations.""" import logging +import random +import string from datetime import datetime from typing import Optional, Dict, Callable, Any +from corvus import errors from corvus.db import db from corvus.model import User +from corvus.service.role_service import Role from corvus.service.transformation_service import ( BaseTransformer, register_transformer @@ -80,18 +84,28 @@ class UserTransformer(BaseTransformer): def serialize_role(self) -> str: """User role.""" - return self.model.role + return self.model.role.value @staticmethod - def deserialize_role(model: User, role: str) -> None: + def deserialize_role(model: User, role_value: str) -> None: """User role.""" - model.role = role + model.role = Role(role_value) register_transformer(User.__name__, UserTransformer) -def register(name: str, password: str, role: str) -> User: +def find_by_name(name: str) -> Optional[User]: + """ + Find a user by name. + + :param name: + :return: + """ + return User.query.filter_by(name=name).first() + + +def register(name: str, password: Optional[str], role: Optional[str]) -> User: """ Register a new user. @@ -100,6 +114,13 @@ def register(name: str, password: str, role: str) -> User: :param role: Role to assign the user [ROLE_USER, ROLE_ADMIN] :return: """ + password = password if password is not None else ''.join( + random.choices(string.ascii_letters + string.digits, k=32)) + role = role if role is not None else User.ROLE_USER + + if find_by_name(name=name) is not None: + raise errors.ValidationError('User name is already taken.') + pw_hash, pw_revision = authentication_utility.get_password_hash(password) new_user = User( @@ -155,13 +176,3 @@ def update_password(user: User, password: str) -> None: user.password_hash = pw_hash user.password_revision = pw_revision db.session.commit() - - -def find_by_name(name: str) -> Optional[User]: - """ - Find a user by name. - - :param name: - :return: - """ - return User.query.filter_by(name=name).first() diff --git a/server/corvus/utility/json_utility.py b/server/corvus/utility/json_utility.py index 037fa8b..f1b60f1 100644 --- a/server/corvus/utility/json_utility.py +++ b/server/corvus/utility/json_utility.py @@ -5,18 +5,27 @@ from typing import Any import rfc3339 from flask.json import JSONEncoder +from corvus.api.model import APIResponse from corvus.db import db +from corvus.errors import BaseError from corvus.service.transformation_service import serialize_model class CustomJSONEncoder(JSONEncoder): """Ensure that datetime values are serialized correctly.""" - def default(self, o: Any) -> Any: # pylint: disable=E0202 + def default(self, o: Any) -> Any: # pylint: disable=E0202,R0911 """Handle encoding date and datetime objects according to rfc3339.""" try: if isinstance(o, date): return rfc3339.format(o) + if isinstance(o, APIResponse): + payload = o.payload + if isinstance(payload, db.Model): + return serialize_model(o.payload, o.options) + if isinstance(payload, BaseError): + return payload.to_dict() + return payload if isinstance(o, db.Model): return serialize_model(o) iterable = iter(o) diff --git a/server/tests/api/test_user_api.py b/server/tests/api/test_user_api.py index 135f896..d0d1f73 100644 --- a/server/tests/api/test_user_api.py +++ b/server/tests/api/test_user_api.py @@ -1,3 +1,4 @@ +from flask import json from flask.testing import FlaskClient from tests.conftest import AuthActions @@ -11,6 +12,52 @@ def test_get_user_happy_path(auth: AuthActions, client: FlaskClient): headers={ auth_header[0]: auth_header[1] }) - assert result.status_code == 200 + assert 200 == result.status_code assert result.json is not None assert result.json['name'] == client.application.config['test_username'] + + +def test_register_user_happy_path(auth: AuthActions, client: FlaskClient): + auth.login() + auth_header = auth.get_authorization_header_token() + result = client.post( + '/user/', + data=json.dumps({ + 'name': 'test_registered_user' + }), + headers={ + auth_header[0]: auth_header[1], + 'Content-Type': 'application/json' + }) + assert 200 == result.status_code + assert result.json is not None + assert result.json['name'] == 'test_registered_user' + + +def test_register_user_twice_failure(auth: AuthActions, client: FlaskClient): + auth.login() + auth_header = auth.get_authorization_header_token() + result1 = client.post( + '/user/', + data=json.dumps({ + 'name': 'test_registered_user' + }), + headers={ + auth_header[0]: auth_header[1], + 'Content-Type': 'application/json' + }) + result2 = client.post( + '/user/', + data=json.dumps({ + 'name': 'test_registered_user' + }), + headers={ + auth_header[0]: auth_header[1], + 'Content-Type': 'application/json' + }) + assert 200 == result1.status_code + assert result1.json is not None + assert result1.json['name'] == 'test_registered_user' + assert 400 == result2.status_code + assert result2.json is not None + assert result2.json['message'] == 'User name is already taken.' diff --git a/server/tests/conftest.py b/server/tests/conftest.py index 81fe744..526b805 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -10,7 +10,7 @@ from flask import Flask from flask.testing import FlaskClient, FlaskCliRunner from werkzeug.test import Client -from corvus import create_app, register_blueprints +from corvus import create_app, register_blueprints, register_error_handlers from corvus.db import init_db from corvus.model import User from corvus.service import user_service @@ -29,13 +29,15 @@ def add_test_user() -> Tuple[str, str]: def app() -> Flask: """Create and configure a new corvus_app instance for each test.""" # create a temporary file to isolate the database for each test - db_fd, db_path = tempfile.mkstemp() + db_fd, db_path = tempfile.mkstemp(suffix='.db') + test_database_uri = 'sqlite:///{}'.format(db_path) # create the corvus_app with common test config corvus_app = create_app({ 'TESTING': True, - 'DATABASE': db_path, + 'SQLALCHEMY_DATABASE_URI': test_database_uri, }) register_blueprints(corvus_app) + register_error_handlers(corvus_app) # create the database and load test data with corvus_app.app_context(): diff --git a/server/tests/service/test_role_service.py b/server/tests/service/test_role_service.py new file mode 100644 index 0000000..875ffb2 --- /dev/null +++ b/server/tests/service/test_role_service.py @@ -0,0 +1,19 @@ +from corvus.service.role_service import ROLES, Role + + +def test_role_tree_find_roles_in_hierarchy(): + roles = ROLES.find_roles_in_hierarchy(Role.USER) + assert len(roles) == 4 + assert Role.USER in roles + assert Role.MODERATOR in roles + assert Role.AUDITOR in roles + assert Role.ADMIN in roles + + roles = ROLES.find_roles_in_hierarchy(Role.AUDITOR) + assert len(roles) == 2 + assert Role.AUDITOR in roles + assert Role.ADMIN in roles + + +def test_role_tree_find_role_key_error(): + assert len(ROLES.find_role(Role.NONE)) == 0