Browse Source

Updating with latest Atheneum changes

merge-requests/1/merge
Drew Short 6 years ago
parent
commit
4e770c4360
  1. BIN
      server/.admin_credentials.swp
  2. 17
      server/corvus/__init__.py
  3. 2
      server/corvus/api/decorators.py
  4. 14
      server/corvus/api/model.py
  5. 30
      server/corvus/api/user_api.py
  6. 36
      server/corvus/errors.py
  7. 30
      server/corvus/middleware/authentication_middleware.py
  8. 7
      server/corvus/model/user_model.py
  9. 88
      server/corvus/service/role_service.py
  10. 2
      server/corvus/service/transformation_service.py
  11. 39
      server/corvus/service/user_service.py
  12. 11
      server/corvus/utility/json_utility.py
  13. 49
      server/tests/api/test_user_api.py
  14. 8
      server/tests/conftest.py
  15. 19
      server/tests/service/test_role_service.py

BIN
server/.admin_credentials.swp

17
server/corvus/__init__.py

@ -6,6 +6,7 @@ from flask import Flask
from flask_migrate import Migrate from flask_migrate import Migrate
from corvus.db import db from corvus.db import db
from corvus.errors import BaseError, handle_corvus_base_error
from corvus.utility import json_utility, session_utility from corvus.utility import json_utility, session_utility
dictConfig({ dictConfig({
@ -42,7 +43,8 @@ def create_app(test_config: dict = None) -> Flask:
app.config.from_mapping( app.config.from_mapping(
SECRET_KEY='dev', SECRET_KEY='dev',
SQLALCHEMY_DATABASE_URI=default_database_uri, SQLALCHEMY_DATABASE_URI=default_database_uri,
SQLALCHEMY_TRACK_MODIFICATIONS=False
SQLALCHEMY_TRACK_MODIFICATIONS=False,
TRAP_HTTP_EXCEPTIONS=True,
) )
if test_config is None: if test_config is None:
@ -53,7 +55,7 @@ def create_app(test_config: dict = None) -> Flask:
app.config.from_envvar('CORVUS_SETTINGS') app.config.from_envvar('CORVUS_SETTINGS')
else: else:
app.logger.debug('Loading test configuration') app.logger.debug('Loading test configuration')
app.config.from_object(test_config)
app.config.from_mapping(test_config)
try: try:
os.makedirs(app.instance_path) os.makedirs(app.instance_path)
@ -83,8 +85,19 @@ def register_blueprints(app: Flask) -> None:
app.register_blueprint(USER_BLUEPRINT) 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 corvus = create_app() # pylint: disable=C0103
register_blueprints(corvus) register_blueprints(corvus)
register_error_handlers(corvus)
logger = corvus.logger # pylint: disable=C0103 logger = corvus.logger # pylint: disable=C0103

2
server/corvus/api/decorators.py

@ -27,7 +27,7 @@ def return_json(func: Callable) -> Callable:
if isinstance(result, Response): if isinstance(result, Response):
return result return result
if isinstance(result, APIResponse): if isinstance(result, APIResponse):
return jsonify(result.payload), result.status
return jsonify(result), result.status
return jsonify(result) return jsonify(result)
return decorate return decorate

14
server/corvus/api/model.py

@ -1,9 +1,15 @@
"""Model definitions for the api module.""" """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.""" """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

30
server/corvus/api/user_api.py

@ -1,10 +1,12 @@
"""User API blueprint and endpoint definitions.""" """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.decorators import return_json
from corvus.api.model import APIResponse from corvus.api.model import APIResponse
from corvus.middleware import authentication_middleware 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( USER_BLUEPRINT = Blueprint(
name='user', import_name=__name__, url_prefix='/user') name='user', import_name=__name__, url_prefix='/user')
@ -15,11 +17,31 @@ USER_BLUEPRINT = Blueprint(
@authentication_middleware.require_token_auth @authentication_middleware.require_token_auth
def get_user(name: str) -> APIResponse: 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) user = user_service.find_by_name(name)
if user is not None: if user is not None:
return APIResponse(user, 200) return APIResponse(user, 200)
return abort(404) 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)

36
server/corvus/errors.py

@ -1,20 +1,50 @@
"""Error definitions for Corvus.""" """Error definitions for Corvus."""
from typing import Dict 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__( def __init__(
self, self,
message: str = 'Unknown error', message: str = 'Unknown error',
status_code: int = 500,
extra_fields: Dict[str, str] = None) -> None: extra_fields: Dict[str, str] = None) -> None:
"""Populate The Error Definition.""" """Populate The Error Definition."""
super().__init__(message) super().__init__(message)
self.message = message
self.status_code = status_code
self.extra_fields = extra_fields 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.""" """Corvus Validation Error."""
pass 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)

30
server/corvus/middleware/authentication_middleware.py

@ -1,10 +1,10 @@
"""Middleware to handle authentication.""" """Middleware to handle authentication."""
import base64 import base64
import binascii
from functools import wraps from functools import wraps
from typing import Optional, Callable, Any 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.datastructures import Authorization
from werkzeug.http import bytes_to_wsgi, wsgi_to_bytes from werkzeug.http import bytes_to_wsgi, wsgi_to_bytes
@ -13,6 +13,7 @@ from corvus.service import (
user_service, user_service,
user_token_service user_token_service
) )
from corvus.service.role_service import ROLES, Role
def authenticate_with_password(name: str, password: str) -> bool: 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( def parse_token_header(
header_value: str) -> Optional[Authorization]: header_value: str) -> Optional[Authorization]:
""" """
@ -137,3 +149,17 @@ def require_token_auth(func: Callable) -> Callable:
return authentication_failed('Bearer') return authentication_failed('Bearer')
return decorate 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

7
server/corvus/model/user_model.py

@ -1,5 +1,8 @@
"""User related SQLALchemy models.""" """User related SQLALchemy models."""
from sqlalchemy import Enum
from corvus.db import db from corvus.db import db
from corvus.service.role_service import Role
class User(db.Model): # pylint: disable=too-few-public-methods 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) name = db.Column(db.Unicode(60), unique=True, nullable=False)
role = db.Column( role = db.Column(
'role', 'role',
db.Unicode(32),
Enum(Role),
nullable=False, nullable=False,
default=ROLE_USER, )
default=Role.USER, )
password_hash = db.Column('password_hash', db.Unicode(128), nullable=False) password_hash = db.Column('password_hash', db.Unicode(128), nullable=False)
password_revision = db.Column( password_revision = db.Column(
'password_revision', db.SmallInteger, default=0, nullable=False) 'password_revision', db.SmallInteger, default=0, nullable=False)

88
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
}
})

2
server/corvus/service/transformation_service.py

@ -90,7 +90,7 @@ def serialize_model(model_obj: db.Model,
def deserialize_model( def deserialize_model(
model_type: str, model_type: str,
json_model_object: dict, 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.""" """Lookup a Model and hand it off to the deserializer."""
try: try:
transformer = _model_transformers[model_type] transformer = _model_transformers[model_type]

39
server/corvus/service/user_service.py

@ -1,10 +1,14 @@
"""Service to handle user operations.""" """Service to handle user operations."""
import logging import logging
import random
import string
from datetime import datetime from datetime import datetime
from typing import Optional, Dict, Callable, Any from typing import Optional, Dict, Callable, Any
from corvus import errors
from corvus.db import db from corvus.db import db
from corvus.model import User from corvus.model import User
from corvus.service.role_service import Role
from corvus.service.transformation_service import ( from corvus.service.transformation_service import (
BaseTransformer, BaseTransformer,
register_transformer register_transformer
@ -80,18 +84,28 @@ class UserTransformer(BaseTransformer):
def serialize_role(self) -> str: def serialize_role(self) -> str:
"""User role.""" """User role."""
return self.model.role
return self.model.role.value
@staticmethod @staticmethod
def deserialize_role(model: User, role: str) -> None:
def deserialize_role(model: User, role_value: str) -> None:
"""User role.""" """User role."""
model.role = role
model.role = Role(role_value)
register_transformer(User.__name__, UserTransformer) 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. 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] :param role: Role to assign the user [ROLE_USER, ROLE_ADMIN]
:return: :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) pw_hash, pw_revision = authentication_utility.get_password_hash(password)
new_user = User( new_user = User(
@ -155,13 +176,3 @@ def update_password(user: User, password: str) -> None:
user.password_hash = pw_hash user.password_hash = pw_hash
user.password_revision = pw_revision user.password_revision = pw_revision
db.session.commit() 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()

11
server/corvus/utility/json_utility.py

@ -5,18 +5,27 @@ from typing import Any
import rfc3339 import rfc3339
from flask.json import JSONEncoder from flask.json import JSONEncoder
from corvus.api.model import APIResponse
from corvus.db import db from corvus.db import db
from corvus.errors import BaseError
from corvus.service.transformation_service import serialize_model from corvus.service.transformation_service import serialize_model
class CustomJSONEncoder(JSONEncoder): class CustomJSONEncoder(JSONEncoder):
"""Ensure that datetime values are serialized correctly.""" """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.""" """Handle encoding date and datetime objects according to rfc3339."""
try: try:
if isinstance(o, date): if isinstance(o, date):
return rfc3339.format(o) 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): if isinstance(o, db.Model):
return serialize_model(o) return serialize_model(o)
iterable = iter(o) iterable = iter(o)

49
server/tests/api/test_user_api.py

@ -1,3 +1,4 @@
from flask import json
from flask.testing import FlaskClient from flask.testing import FlaskClient
from tests.conftest import AuthActions from tests.conftest import AuthActions
@ -11,6 +12,52 @@ def test_get_user_happy_path(auth: AuthActions, client: FlaskClient):
headers={ headers={
auth_header[0]: auth_header[1] 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 is not None
assert result.json['name'] == client.application.config['test_username'] 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.'

8
server/tests/conftest.py

@ -10,7 +10,7 @@ from flask import Flask
from flask.testing import FlaskClient, FlaskCliRunner from flask.testing import FlaskClient, FlaskCliRunner
from werkzeug.test import Client 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.db import init_db
from corvus.model import User from corvus.model import User
from corvus.service import user_service from corvus.service import user_service
@ -29,13 +29,15 @@ def add_test_user() -> Tuple[str, str]:
def app() -> Flask: def app() -> Flask:
"""Create and configure a new corvus_app instance for each test.""" """Create and configure a new corvus_app instance for each test."""
# create a temporary file to isolate the database 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 # create the corvus_app with common test config
corvus_app = create_app({ corvus_app = create_app({
'TESTING': True, 'TESTING': True,
'DATABASE': db_path,
'SQLALCHEMY_DATABASE_URI': test_database_uri,
}) })
register_blueprints(corvus_app) register_blueprints(corvus_app)
register_error_handlers(corvus_app)
# create the database and load test data # create the database and load test data
with corvus_app.app_context(): with corvus_app.app_context():

19
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
Loading…
Cancel
Save