diff --git a/app/__init__.py b/app/__init__.py index 4d12545092e77a76af6974b3bd3b81908eff5071..cb425bfb3ace7e6444a6f3a8f651756dfbaf0941 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,3 +1,4 @@ +"""BP flask microservices""" import logging from logging.handlers import RotatingFileHandler from flask import Flask @@ -6,6 +7,7 @@ MEGABYTE = 10**6 LOG_FORMAT = '[%(asctime)s] %(pathname)s:%(lineno)d %(levelname)s - %(message)s' LOG_PATH = '/var/log/flask.log' + def create_app(config): """Set up the application.""" app = Flask(__name__) @@ -22,16 +24,15 @@ def create_app(config): app.logger.info('Setting up application...') - from .lib import database - database.register(app) - from . import views views.register(app) - from .lib import exceptions - exceptions.register(app) + from app.lib import database, red, exceptions, auth0 + services = (database, red, exceptions, auth0) + for service in services: + service.register(app) - with app.app_context(): - database.db.create_all() + # with app.app_context(): + # database.db.create_all() return app diff --git a/app/config/development.default.py b/app/config/development.default.py index 7b4be41032e2d6152c34291609849e20d899f944..9f3058ea9fc570a81e91279fe26a97107aeff6ff 100644 --- a/app/config/development.default.py +++ b/app/config/development.default.py @@ -8,3 +8,11 @@ DEBUG = True HEADER_APP_KEY = 'x-blocpower-app-key' HEADER_APP_SECRET = 'x-blocpower-app-secret' + +# Auth0 Authentication +AUTH0_AUTH_HEADER = 'x-blocpower-auth0-token' +AUTH0_DOMAIN = os.environ['AUTH0_DOMAIN'] +AUTH0_AUDIENCE = os.environ['AUTH0_AUDIENCE'] +AUTH0_CLAIMS_NAMESPACE = os.environ['AUTH0_CLAIMS_NAMESPACE'] +AUTH0_CLIENT_ID = os.environ['AUTH0_CLIENT_ID'] +AUTH0_CLIENT_SECRET = os.environ['AUTH0_CLIENT_SECRET'] diff --git a/app/config/local.default.py b/app/config/local.default.py index 4d08b75f0f87924691a1c745392329049bfa787c..3e7aea6a2e53ca9531bb0e65a984d842cdf88181 100644 --- a/app/config/local.default.py +++ b/app/config/local.default.py @@ -6,5 +6,13 @@ SQLALCHEMY_DATABASE_URI = os.environ['SQLALCHEMY_DATABASE_URI'] DEBUG = True +# Auth0 Authentication +AUTH0_AUTH_HEADER = 'x-blocpower-auth0-token' +AUTH0_DOMAIN = '$AUTH0_DOMAIN' +AUTH0_AUDIENCE = '$AUTH0_AUDIENCE' +AUTH0_CLAIMS_NAMESPACE = '$AUTH0_CLAIMS_NAMESPACE' +AUTH0_CLIENT_ID = '$AUTH0_CLIENT_ID' +AUTH0_CLIENT_SECRET = '$AUTH0_CLIENT_SECRET' + HEADER_APP_KEY = 'x-blocpower-app-key' HEADER_APP_SECRET = 'x-blocpower-app-secret' diff --git a/app/config/production.default.py b/app/config/production.default.py index e57609219b4a9275ae1b8be4a320b5a3f341038a..36d39fce9c802c4338affb9b152bfd614d4c7988 100644 --- a/app/config/production.default.py +++ b/app/config/production.default.py @@ -7,3 +7,11 @@ DEBUG = False HEADER_APP_KEY = 'x-blocpower-app-key' HEADER_APP_SECRET = 'x-blocpower-app-secret' + +# Auth0 Authentication +AUTH0_AUTH_HEADER = 'x-blocpower-auth0-token' +AUTH0_DOMAIN = os.environ['AUTH0_DOMAIN'] +AUTH0_AUDIENCE = os.environ['AUTH0_AUDIENCE'] +AUTH0_CLAIMS_NAMESPACE = os.environ['AUTH0_CLAIMS_NAMESPACE'] +AUTH0_CLIENT_ID = os.environ['AUTH0_CLIENT_ID'] +AUTH0_CLIENT_SECRET = os.environ['AUTH0_CLIENT_SECRET'] diff --git a/app/config/staging.default.py b/app/config/staging.default.py index 7b4be41032e2d6152c34291609849e20d899f944..9f3058ea9fc570a81e91279fe26a97107aeff6ff 100644 --- a/app/config/staging.default.py +++ b/app/config/staging.default.py @@ -8,3 +8,11 @@ DEBUG = True HEADER_APP_KEY = 'x-blocpower-app-key' HEADER_APP_SECRET = 'x-blocpower-app-secret' + +# Auth0 Authentication +AUTH0_AUTH_HEADER = 'x-blocpower-auth0-token' +AUTH0_DOMAIN = os.environ['AUTH0_DOMAIN'] +AUTH0_AUDIENCE = os.environ['AUTH0_AUDIENCE'] +AUTH0_CLAIMS_NAMESPACE = os.environ['AUTH0_CLAIMS_NAMESPACE'] +AUTH0_CLIENT_ID = os.environ['AUTH0_CLIENT_ID'] +AUTH0_CLIENT_SECRET = os.environ['AUTH0_CLIENT_SECRET'] diff --git a/app/controllers/base.py b/app/controllers/base.py index 8d5ad7a4e14950a5c4592ec4ac490d6aee659d9b..cf86692dd25dcda3e91e3b7f0defdc7a89b4c12b 100644 --- a/app/controllers/base.py +++ b/app/controllers/base.py @@ -1,127 +1,12 @@ -from sqlalchemy.exc import IntegrityError -from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import NotFound, BadRequest -from flask import current_app +"""Flask RestController wrapper""" +from bpvalve.flask.controllers import RestController as BaseRestController +from app.lib import db, redis, auth0_ -from ..lib.database import db, commit - -class RestController(object): - """A RESTful class for manipulating database objects. - - Controllers should be request-agnostic. The can be initialized and used - outside of a request context. This provides a convenient place to dump - complicated filters or additional data manipulation that happens before - or after a database fetch. +class RestController(BaseRestController): """ - # The model associated with this REST resource. - Model = None - # A dictionary of available filters on the resource. - # - # For each key in this dictionary, if the key is present in the dictionary, - # the value associated with that key (a function taking one argument) is - # passed the filter_data dictionary and the return value is used as a - # database filter. For example, - # - # { - # 'id_': lambda d: Model.id_ == d['id_'] - # } - # - # There is no requirement that the filter function only manipulate its - # key, so these filters can be arbitrarily complex as needed. Multiline - # filters should be defined as separate functions and referenced here. - filters = {} - - # The primary key to use to retrieve the model in get/put/delete requests. - key = 'id' - - def query(self, filter_data): - """Construct a query for the model. - - Args: - filter_data - A dictionary representing the query string - parameters. This is unused at the moment. - """ - return db.session.query(self.Model) - - def filter(self, q, filter_data): - """Parse a dictionary representing the query parameters into database - filters. - - q - A SQLAlchemy query. - filter_data - A dictionary representing the query string parameters. - """ - for k, f in self.filters.items(): - if k in filter_data: - q = q.filter(f(filter_data)) - return q - - def get_form(self, filter_data): - """Get the WTForms form for the model.""" - pass - - def index(self, filter_data): - """Get a query for all models matching filter_data.""" - q = self.query(filter_data) - q = self.filter(q, filter_data) - return q.all() - - def get(self, id_, filter_data): - """Get a single model matching an id.""" - model = db.session.query(self.Model)\ - .filter(getattr(self.Model, self.key)==id_).first() - if not model: - raise NotFound - return model - - def create(self, data, filter_data): - """Creates a model, but does not add it to the database.""" - model = self.Model() - form = self.get_form(filter_data)(formdata=MultiDict(data)) - - if not form.validate(): - raise BadRequest(form.errors) - - form.populate_obj(model) - return model - - def post(self, data, filter_data): - """Post a new model from a dictionary.""" - model = self.create(data, filter_data) - db.session.add(model) - try: - commit() - except IntegrityError as e: - raise ( - BadRequest(str(e)) if current_app.config['DEBUG'] else - BadRequest) - - return model - - def put(self, id_, data, filter_data): - """Change an existing model using an id and dictionary data.""" - model = self.get(id_, filter_data) - form = self.get_form(filter_data)(formdata=MultiDict(data)) - - if not str(form.id.data) == str(id_): - raise BadRequest(['The id in the model and the uri do not match.']) - if not form.validate(): - raise BadRequest(form.errors) - - form.populate_obj(model) - db.session.add(model) - try: - commit() - except IntegrityError as e: - raise ( - BadRequest(str(e)) if current_app.config['DEBUG'] else - BadRequest) - - return model - - def delete(self, id_, filter_data): - """Delete a model given its id.""" - model = self.get(id_, filter_data) - db.session.delete(model) - commit() - return {} + Wrapper for BaseRestController + """ + db = db + redis = redis + auth0 = auth0_ diff --git a/app/forms/base.py b/app/forms/base.py index 8118e9321ae410765aeadb51cf5ef8f14f3065c4..a886f9cfbb23c3331c564e36a26297aca6910381 100644 --- a/app/forms/base.py +++ b/app/forms/base.py @@ -9,6 +9,11 @@ class Form(wtf.Form): id = wtf.IntegerField() +class UserForm: + user_created = wtf.StringField(validators=[wtf.validators.Length(max=64)]) + user_modified = wtf.StringField(validators=[wtf.validators.Length(max=64)]) + + class AddressForm(object): """A form for validating address information.""" street_address = wtf.StringField( diff --git a/app/lib/__init__.py b/app/lib/__init__.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..3fac2006f832124ac650884f896339cd69d51619 100644 --- a/app/lib/__init__.py +++ b/app/lib/__init__.py @@ -0,0 +1,3 @@ +from .database import db +from .red import redis +from .auth0 import auth0_ diff --git a/app/lib/auth0.py b/app/lib/auth0.py new file mode 100644 index 0000000000000000000000000000000000000000..3014a0a877acf02778d047e4944312048c488128 --- /dev/null +++ b/app/lib/auth0.py @@ -0,0 +1,19 @@ +from bpvalve.auth0 import Auth0Wrapper + + +class FlaskAuth0: + """Initilalization class for Auth0Wrapper""" + + def init_app(self, app): + """Intialize Auth0Wrapper""" + self.auth = Auth0Wrapper(app.config.get('AUTH0_DOMAIN'), + app.config.get('AUTH0_CLIENT_ID'), + app.config.get('AUTH0_CLIENT_SECRET'), + 'https://{}/api/v2/'.format(app.config.get('AUTH0_DOMAIN'))) + +auth0_ = FlaskAuth0() + + +def register(app): + """Configure the Auth0Wrapper based on the app configuration.""" + auth0_.init_app(app) diff --git a/app/lib/red.py b/app/lib/red.py new file mode 100644 index 0000000000000000000000000000000000000000..2a556155c5c276ab349bb58fc3e3512eeadc7790 --- /dev/null +++ b/app/lib/red.py @@ -0,0 +1,8 @@ +from bpvalve.flask.lib.red import RedisWrapper + +redis = RedisWrapper() + + +def register(app): + """Configure the RedisWrapper based on the app configuration.""" + redis.init_app(app) diff --git a/app/models/base.py b/app/models/base.py index e304c904fa6fad7ec21fb11bc515609e208a900f..c4470e2a51777600a1fd8b3593d8f47910fb40fd 100644 --- a/app/models/base.py +++ b/app/models/base.py @@ -59,6 +59,11 @@ class Model(BaseModel): id = db.Column(db.Integer, primary_key=True) +class User: + user_created = db.Column(db.String(64)) + user_modified = db.Column(db.String(64)) + + class Tracked(object): """A mixin to include tracking datetime fields.""" created = db.Column(columns.Arrow, default=func.now()) diff --git a/app/permissions/auth.py b/app/permissions/auth.py index fb6bfbd95db87013f9f0278b67761024044bbd1b..107a77f2bec650fd45f10479ea84f6ceb0a7b9c9 100644 --- a/app/permissions/auth.py +++ b/app/permissions/auth.py @@ -1,12 +1,10 @@ """Permissions to check authentication.""" -from flask import current_app +from flask import current_app, g from werkzeug.exceptions import Unauthorized from jose import jwt import json import requests -from ..lib.red import redis -from ..lib.service import services from .base import Permission from .application import app_need @@ -49,6 +47,7 @@ class AuthNeed(Permission): audience=API_AUDIENCE, issuer="https://"+AUTH0_DOMAIN+"/" ) + g.sub = payload['sub'] # For now we will print and return unauthorized. In the future # we will log these errors and the requester except jwt.ExpiredSignatureError: diff --git a/app/views/application.py b/app/views/application.py index 6ac7b3c613f8a3d2d64c256f4242757c73a791cd..54f1adf551bc6e0f2be41015880fab95c7d03865 100644 --- a/app/views/application.py +++ b/app/views/application.py @@ -2,14 +2,15 @@ from werkzeug.exceptions import MethodNotAllowed from flask import request -from app.views.base import RestView +from bpvalve.flask.views import UnprotectedRestView from app.controllers.application import AuthenticationController, RoleController -from app.permissions.application import AppNeed +from app.permissions.application import AppNeed, app_need -class AuthenticationView(RestView): +class AuthenticationView(UnprotectedRestView): """A view for app authentications.""" route_base = '/auth/' + decorators = [app_need,] def get_controller(self): """Return an instance of the authentication controller.""" @@ -43,8 +44,10 @@ class AuthenticationView(RestView): raise MethodNotAllowed -class RoleView(RestView): +class RoleView(UnprotectedRestView): """A view for auth roles.""" + decorators = [app_need,] + def get_controller(self): """Return an instance of the role controller.""" return RoleController() diff --git a/app/views/base.py b/app/views/base.py index ad9e60aedbb4d1878c1bb34c07325ce85e2ec65d..4700da8cccec2b0ca3e7b047f29277ac4361303d 100644 --- a/app/views/base.py +++ b/app/views/base.py @@ -1,69 +1,8 @@ -import json -from werkzeug.exceptions import NotFound -from flask import jsonify, request -from flask.ext.classy import FlaskView -from app.lib.database import db -from app.permissions.application import app_need - - -class View(FlaskView): - """A base view to provide convenience methods to subclasses.""" - def request_json(self): - """Return json parsed from the request.""" - return json.loads(request.data.decode('utf-8')) - - def json(self, obj, status=200): - """A wrapper for Flask's jsonify().""" - return jsonify(data=obj), status - - def parse(self, model): - """Parse the model into a dictionary.""" - return model.get_dictionary() - - -class UnprotectedRestView(View): - """A view wrapper for RESTful controllers that does not offer API - protection. - - Views should remain agnostic to the database (with the exception of - permissions). In general, the view is responsible for parsing the - request, checking permissions, calling the controller, and parsing that - return into a json output. - """ - def get_controller(self): - """Return a controller instance to use for database interactions.""" - pass - - def index(self): - """/ GET - Retrieve a list of resources.""" - return self.json([ - self.parse(m) for m in self.get_controller().index(request.args) - ]) - - def get(self, id_): - """/{id} GET - Retrieve a resource by id.""" - return self.json(self.parse( - self.get_controller().get(id_, request.args) - )) - - def post(self): - """/ POST - Post a new resource.""" - return self.json(self.parse( - self.get_controller().post(self.request_json(), request.args) - ), 201) - - def put(self, id_): - """/{id} PUT - Modify a resource by id.""" - return self.json(self.parse( - self.get_controller().put(id_, self.request_json(), request.args) - )) - - def delete(self, id_): - """/{id} DELETE - Delete a resource by id.""" - return self.json(self.get_controller().delete(id_, request.args), 204) +from bpvalve.flask.views import UnprotectedRestView +from app.permissions.auth import standard_login_need class RestView(UnprotectedRestView): - """A view wrapper for RESTful controllers that _does_ offer API protection. - """ - decorators = (app_need,) + """A view wrapper for RESTful controllers that _does_ offer API protection.""" + + decorators = (standard_login_need,) diff --git a/requirements-dev.txt b/requirements-dev.txt index 5660f022283738e06640ee95df95506320277c59..2748f38cc5c9e3ba6e533abbf10af2f473d9aca9 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,9 @@ -r requirements.txt Flask-Testing==0.4.2 nose==1.3.7 +pycodestyle>=2.3.1 +pydocstyle>=1.1.1 pytest==2.9.1 pylint>=1.6.5 pylint-flask>=0.5 +yapf>=0.16.1 diff --git a/requirements.txt b/requirements.txt index dc392034a00f80407a95c931df97691a9418cb3e..32077957e7c670fdb47dd3d22e5e901c5b5fc648 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,15 @@ arrow==0.7.0 bcrypt==2.0.0 cffi==1.5.2 +blessed==1.9.5 +botocore==1.3.28 +git+ssh://git@github.com/Blocp/bpvalve.git@v1.1.1 +cement==2.4.0 +colorama==0.3.3 +docker-py==1.1.0 +dockerpty==0.3.4 +docopt==0.6.2 +docutils==0.12 Flask==0.10.1 Flask-Classy==0.6.10 Flask-SQLAlchemy==2.1 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000000000000000000000000000000000000..64a0dfb03c1ac2943c7e7bc75475d5d685df4f9f --- /dev/null +++ b/setup.cfg @@ -0,0 +1,9 @@ +[pycodestyle] +max-line-length=100 + +[isort] +line_length=100 + +[yapf] +based_on_style=pep8 +spaces_before_comment=1