diff --git a/refstack-ui/app/app.js b/refstack-ui/app/app.js index ea6fd192..eaa608c4 100644 --- a/refstack-ui/app/app.js +++ b/refstack-ui/app/app.js @@ -1,6 +1,6 @@ /** Main app module where application dependencies are listed. */ var refstackApp = angular.module('refstackApp', [ - 'ui.router', 'ui.bootstrap', 'cgBusy']); + 'ui.router', 'ui.bootstrap', 'cgBusy', 'ngResource']); /** * Handle application routing. Specific templates and controllers will be diff --git a/refstack-ui/app/components/alerts/alertModal.html b/refstack-ui/app/components/alerts/alertModal.html new file mode 100644 index 00000000..7aee7921 --- /dev/null +++ b/refstack-ui/app/components/alerts/alertModal.html @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/refstack-ui/app/components/alerts/alertModalFactory.js b/refstack-ui/app/components/alerts/alertModalFactory.js new file mode 100644 index 00000000..8cb76e47 --- /dev/null +++ b/refstack-ui/app/components/alerts/alertModalFactory.js @@ -0,0 +1,42 @@ +var refstackApp = angular.module('refstackApp'); + +refstackApp.factory('raiseAlert', + ['$modal', function($modal) { + 'use strict'; + return function(mode, title, text) { + $modal.open({ + templateUrl: '/components/alerts/alertModal.html', + controller: 'raiseAlertModalController', + backdrop: true, + keyboard: true, + backdropClick: true, + size: 'md', + resolve: { + data: function () { + return { + mode: mode, + title: title, + text: text + }; + } + } + }); + }; + }] +); + + +refstackApp.controller('raiseAlertModalController', + ['$scope', '$modalInstance', '$interval', 'data', + function ($scope, $modalInstance, $interval, data) { + 'use strict'; + $scope.data = data; + $scope.close = function() { + $modalInstance.close(); + }; + $interval(function(){ + $scope.close(); + }, 3000, 1); + } + ] +); diff --git a/refstack-ui/app/components/profile/importPubKeyModal.html b/refstack-ui/app/components/profile/importPubKeyModal.html new file mode 100644 index 00000000..d9b1107a --- /dev/null +++ b/refstack-ui/app/components/profile/importPubKeyModal.html @@ -0,0 +1,21 @@ + + \ No newline at end of file diff --git a/refstack-ui/app/components/profile/profile.html b/refstack-ui/app/components/profile/profile.html index 194a696c..f7c46c21 100644 --- a/refstack-ui/app/components/profile/profile.html +++ b/refstack-ui/app/components/profile/profile.html @@ -1,4 +1,36 @@ -

Hello, {{user.fullname}}!

-{{bar}} -

openid: {{user.openid}}

-

email: {{user.email}}

+

User profile

+ +
+ + + + + + +
User name {{user.fullname}}
User OpenId {{user.openid}}
Email {{user.email}}
+
+
+
+
+

User public keys

+
+
+ +
+
+
+ +
+ + + + + + + + +
{{pubKey.format}}{{pubKey.shortKey}}{{pubKey.comment}}
+
+ diff --git a/refstack-ui/app/components/profile/profileController.js b/refstack-ui/app/components/profile/profileController.js index e5856bcc..11e837cc 100644 --- a/refstack-ui/app/components/profile/profileController.js +++ b/refstack-ui/app/components/profile/profileController.js @@ -1,4 +1,4 @@ - /** +/** * Refstack User Profile Controller * This controller handles user's profile page, where a user can view * account-specific information. @@ -6,16 +6,134 @@ var refstackApp = angular.module('refstackApp'); -refstackApp.controller('profileController', - ['$scope', '$http', 'refstackApiUrl', '$state', - function($scope, $http, refstackApiUrl, $state) { +refstackApp.factory('PubKeys', + ['$resource', 'refstackApiUrl', function($resource, refstackApiUrl) { 'use strict'; - var profile_url = refstackApiUrl + '/profile'; - $http.get(profile_url, {withCredentials: true}). - success(function(data) { - $scope.user = data; - }). - error(function() { - $state.go('home'); - }); + return $resource(refstackApiUrl + '/profile/pubkeys/:id', null, null); }]); + +refstackApp.controller('profileController', + [ + '$scope', '$http', 'refstackApiUrl', '$state', 'PubKeys', + '$modal', 'raiseAlert', + function($scope, $http, refstackApiUrl, $state, + PubKeys, $modal, raiseAlert) { + 'use strict'; + $scope.updateProfile = function () { + var profile_url = refstackApiUrl + '/profile'; + $http.get(profile_url, {withCredentials: true}). + success(function(data) { + $scope.user = data; + }). + error(function() { + $state.go('home'); + }); + }; + + $scope.updatePubKeys = function (){ + var keys = PubKeys.query(function(){ + $scope.pubkeys = []; + angular.forEach(keys, function (key) { + $scope.pubkeys.push({ + 'resource': key, + 'format': key.format, + 'shortKey': [ + key.key.slice(0, 10), + '.', + key.key.slice(-10, -1) + ].join('.'), + 'key': key.key, + 'comment': key.comment + }); + }); + }); + }; + $scope.openImportPubKeyModal = function () { + $modal.open({ + templateUrl: '/components/profile/importPubKeyModal.html', + backdrop: true, + windowClass: 'modal', + controller: 'importPubKeyModalController' + }).result.finally(function() { + $scope.updatePubKeys(); + }); + }; + + $scope.openShowPubKeyModal = function (pubKey) { + $modal.open({ + templateUrl: '/components/profile/showPubKeyModal.html', + backdrop: true, + windowClass: 'modal', + controller: 'showPubKeyModalController', + resolve: { + pubKey: function(){ + return pubKey; + } + } + }).result.finally(function() { + $scope.updatePubKeys(); + }); + }; + $scope.showRes = function(pubKey){ + raiseAlert('success', '', pubKey.key); + }; + $scope.updateProfile(); + $scope.updatePubKeys(); + } + ]); + +refstackApp.controller('importPubKeyModalController', + ['$scope', '$modalInstance', 'PubKeys', 'raiseAlert', + function ($scope, $modalInstance, PubKeys, raiseAlert) { + 'use strict'; + $scope.importPubKey = function () { + var newPubKey = new PubKeys( + {raw_key: $scope.raw_key, + self_signature: $scope.self_signature} + ); + newPubKey.$save(function(newPubKey_){ + raiseAlert('success', + '', 'Public key saved successfully'); + $modalInstance.close(newPubKey_); + }, + function(httpResp){ + raiseAlert('danger', + httpResp.statusText, httpResp.data.title); + $scope.cancel(); + } + ); + }; + $scope.cancel = function () { + $modalInstance.dismiss('cancel'); + }; + } + ]); + +refstackApp.controller('showPubKeyModalController', + ['$scope', '$modalInstance', 'raiseAlert', 'pubKey', + function ($scope, $modalInstance, raiseAlert, pubKey) { + 'use strict'; + $scope.pubKey = pubKey.resource; + $scope.rawKey = [pubKey.format, + pubKey.key, pubKey.comment].join('\n'); + $scope.deletePubKey = function () { + $scope.pubKey.$remove( + {id: $scope.pubKey.id}, + function(){ + raiseAlert('success', + '', 'Public key deleted successfully'); + $modalInstance.close($scope.pubKey.id); + }, + function(httpResp){ + raiseAlert('danger', + httpResp.statusText, httpResp.data.title); + $scope.cancel(); + } + ); + }; + $scope.cancel = function () { + $modalInstance.dismiss('cancel'); + }; + } + ] +); diff --git a/refstack-ui/app/components/profile/showPubKeyModal.html b/refstack-ui/app/components/profile/showPubKeyModal.html new file mode 100644 index 00000000..27f4862a --- /dev/null +++ b/refstack-ui/app/components/profile/showPubKeyModal.html @@ -0,0 +1,10 @@ + + \ No newline at end of file diff --git a/refstack-ui/app/index.html b/refstack-ui/app/index.html index 0176c11d..f0ef0a9d 100644 --- a/refstack-ui/app/index.html +++ b/refstack-ui/app/index.html @@ -29,6 +29,7 @@ + @@ -42,6 +43,7 @@ + diff --git a/refstack-ui/tests/karma.conf.js b/refstack-ui/tests/karma.conf.js index f6dd499a..38ea1d4b 100644 --- a/refstack-ui/tests/karma.conf.js +++ b/refstack-ui/tests/karma.conf.js @@ -13,6 +13,7 @@ module.exports = function (config) { 'app/assets/lib/angular-mocks/angular-mocks.js', 'app/assets/lib/angular-bootstrap/ui-bootstrap-tpls.min.js', 'app/assets/lib/angular-busy/dist/angular-busy.min.js', + 'app/assets/lib/angular-resource/angular-resource.min.js', // JS files. 'app/app.js', diff --git a/refstack/api/controllers/__init__.py b/refstack/api/controllers/__init__.py index 9c943985..7fef83c1 100644 --- a/refstack/api/controllers/__init__.py +++ b/refstack/api/controllers/__init__.py @@ -12,4 +12,25 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. + """API controllers package.""" + +from oslo_config import cfg + +from refstack.api import constants as const + +CTRLS_OPTS = [ + cfg.IntOpt('results_per_page', + default=20, + help='Number of results for one page'), + cfg.StrOpt('input_date_format', + default='%Y-%m-%d %H:%M:%S', + help='The format for %(start)s and %(end)s parameters' % { + 'start': const.START_DATE, + 'end': const.END_DATE + }) +] + +CONF = cfg.CONF + +CONF.register_opts(CTRLS_OPTS, group='api') diff --git a/refstack/api/controllers/auth.py b/refstack/api/controllers/auth.py index b8ceffdd..2949017d 100644 --- a/refstack/api/controllers/auth.py +++ b/refstack/api/controllers/auth.py @@ -12,7 +12,9 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. + """Authentication controller.""" + from oslo_config import cfg from oslo_log import log import pecan @@ -154,7 +156,7 @@ class AuthController(rest.RestController): 'email': pecan.request.GET.get(const.OPENID_NS_SREG_EMAIL), 'fullname': pecan.request.GET.get(const.OPENID_NS_SREG_FULLNAME) } - user = db.user_update_or_create(user_info) + user = db.user_save(user_info) api_utils.delete_params_from_user_session([const.CSRF_TOKEN]) session[const.USER_OPENID] = user.openid diff --git a/refstack/api/controllers/capabilities.py b/refstack/api/controllers/capabilities.py new file mode 100644 index 00000000..5c9f0ed3 --- /dev/null +++ b/refstack/api/controllers/capabilities.py @@ -0,0 +1,87 @@ +# Copyright (c) 2015 Mirantis, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Defcore capabilities controller.""" + +from oslo_config import cfg +from oslo_log import log +import pecan +from pecan import rest +import re +import requests +import requests_cache + +CONF = cfg.CONF +LOG = log.getLogger(__name__) + +# Cached requests will expire after 10 minutes. +requests_cache.install_cache(cache_name='github_cache', + backend='memory', + expire_after=600) + + +class CapabilitiesController(rest.RestController): + + """/v1/capabilities handler. + + This acts as a proxy for retrieving capability files + from the openstack/defcore Github repository. + """ + + @pecan.expose('json') + def get(self): + """Get a list of all available capabilities.""" + try: + response = requests.get(CONF.api.github_api_capabilities_url) + LOG.debug("Response Status: %s / Used Requests Cache: %s" % + (response.status_code, + getattr(response, 'from_cache', False))) + if response.status_code == 200: + regex = re.compile('^[0-9]{4}\.[0-9]{2}\.json$') + capability_files = [] + for rfile in response.json(): + if rfile["type"] == "file" and regex.search(rfile["name"]): + capability_files.append(rfile["name"]) + return capability_files + else: + LOG.warning('Github returned non-success HTTP ' + 'code: %s' % response.status_code) + pecan.abort(response.status_code) + + except requests.exceptions.RequestException as e: + LOG.warning('An error occurred trying to get GitHub ' + 'repository contents: %s' % e) + pecan.abort(500) + + @pecan.expose('json') + def get_one(self, file_name): + """Handler for getting contents of specific capability file.""" + github_url = ''.join((CONF.api.github_raw_base_url.rstrip('/'), + '/', file_name, ".json")) + try: + response = requests.get(github_url) + LOG.debug("Response Status: %s / Used Requests Cache: %s" % + (response.status_code, + getattr(response, 'from_cache', False))) + if response.status_code == 200: + return response.json() + else: + LOG.warning('Github returned non-success HTTP ' + 'code: %s' % response.status_code) + pecan.abort(response.status_code) + except requests.exceptions.RequestException as e: + LOG.warning('An error occurred trying to get GitHub ' + 'capability file contents: %s' % e) + pecan.abort(500) diff --git a/refstack/api/controllers/results.py b/refstack/api/controllers/results.py new file mode 100644 index 00000000..e14ea55b --- /dev/null +++ b/refstack/api/controllers/results.py @@ -0,0 +1,118 @@ +# Copyright (c) 2015 Mirantis, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Test results controller.""" + +from oslo_config import cfg +from oslo_log import log +import pecan +from six.moves.urllib import parse + +from refstack import db +from refstack.api import constants as const +from refstack.api import utils as api_utils +from refstack.api.controllers import validation +from refstack.common import validators + +LOG = log.getLogger(__name__) + +CONF = cfg.CONF + + +class ResultsController(validation.BaseRestControllerWithValidation): + + """/v1/results handler.""" + + __validator__ = validators.TestResultValidator + + def get_item(self, item_id): + """Handler for getting item.""" + test_info = db.get_test(item_id) + if not test_info: + pecan.abort(404) + test_list = db.get_test_results(item_id) + test_name_list = [test_dict[0] for test_dict in test_list] + return {"cpid": test_info.cpid, + "created_at": test_info.created_at, + "duration_seconds": test_info.duration_seconds, + "results": test_name_list} + + def store_item(self, item_in_json): + """Handler for storing item. Should return new item id.""" + item = item_in_json.copy() + if pecan.request.headers.get('X-Public-Key'): + if 'metadata' not in item: + item['metadata'] = {} + item['metadata']['public_key'] = \ + pecan.request.headers.get('X-Public-Key') + test_id = db.store_results(item) + LOG.debug(item) + return {'test_id': test_id, + 'url': parse.urljoin(CONF.ui_url, + CONF.api.test_results_url) % test_id} + + @pecan.expose('json') + def get(self): + """Get information of all uploaded test results. + + Get information of all uploaded test results in descending + chronological order. Make it possible to specify some + input parameters for filtering. + For example: + /v1/results?page=&cpid=1234. + By default, page is set to page number 1, + if the page parameter is not specified. + """ + expected_input_params = [ + const.START_DATE, + const.END_DATE, + const.CPID, + ] + + try: + filters = api_utils.parse_input_params(expected_input_params) + records_count = db.get_test_records_count(filters) + page_number, total_pages_number = \ + api_utils.get_page_number(records_count) + except api_utils.ParseInputsError as ex: + pecan.abort(400, 'Reason: %s' % ex) + except Exception as ex: + LOG.debug('An error occurred: %s' % ex) + pecan.abort(500) + + try: + per_page = CONF.api.results_per_page + records = db.get_test_records(page_number, per_page, filters) + + results = [] + for r in records: + results.append({ + 'test_id': r.id, + 'created_at': r.created_at, + 'cpid': r.cpid, + 'url': CONF.api.test_results_url % r.id + }) + + page = {'results': results, + 'pagination': { + 'current_page': page_number, + 'total_pages': total_pages_number + }} + except Exception as ex: + LOG.debug('An error occurred during ' + 'operation with database: %s' % ex) + pecan.abort(400) + + return page diff --git a/refstack/api/controllers/user.py b/refstack/api/controllers/user.py index 058d60f0..8e275f6c 100644 --- a/refstack/api/controllers/user.py +++ b/refstack/api/controllers/user.py @@ -12,26 +12,80 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. + """User profile controller.""" + import pecan from pecan import rest from pecan.secure import secure -from refstack.api import constants as const from refstack.api import utils as api_utils +from refstack.api.controllers import validation +from refstack.common import validators from refstack import db +class PublicKeysController(validation.BaseRestControllerWithValidation): + + """/v1/profile/pubkeys handler.""" + + __validator__ = validators.PubkeyValidator + + # We don't need expose GET url / + def get_item(self, item_id): + """Handler for getting item.""" + pecan.abort(404) + + @secure(api_utils.is_authenticated) + @pecan.expose('json') + def post(self, ): + """Handler for uploading public pubkeys.""" + return super(PublicKeysController, self).post() + + def store_item(self, body): + """Handler for storing item.""" + pubkey = {'openid': api_utils.get_user_id()} + parts = body['raw_key'].strip().split() + if len(parts) == 2: + parts.append('') + pubkey['format'], pubkey['key'], pubkey['comment'] = parts + pubkey_id = db.store_pubkey(pubkey) + return pubkey_id + + @secure(api_utils.is_authenticated) + @pecan.expose('json') + def get(self): + """Retrieve all user's public pubkeys.""" + user_openid = api_utils.get_user_id() + return db.get_user_pubkeys(user_openid) + + @secure(api_utils.is_authenticated) + @pecan.expose('json') + def delete(self, pubkey_id): + """Delete public key.""" + pubkeys = db.get_user_pubkeys(api_utils.get_user_id()) + for key in pubkeys: + if key['id'] == pubkey_id: + db.delete_pubkey(pubkey_id) + return + else: + pecan.abort(404) + + class ProfileController(rest.RestController): - """Controller provides user information in OpenID 2.0 IdP.""" + """Controller provides user information in OpenID 2.0 IdP. + + /v1/profile handler + """ + + pubkeys = PublicKeysController() @secure(api_utils.is_authenticated) @pecan.expose('json') def get(self): """Handle get request on user info.""" - session = api_utils.get_user_session() - user = db.user_get(session.get(const.USER_OPENID)) + user = api_utils.get_user() return { "openid": user.openid, "email": user.email, diff --git a/refstack/api/controllers/v1.py b/refstack/api/controllers/v1.py index a5c00c40..3d9bd59f 100644 --- a/refstack/api/controllers/v1.py +++ b/refstack/api/controllers/v1.py @@ -15,247 +15,17 @@ """Version 1 of the API.""" -import json - -from oslo_config import cfg -from oslo_log import log -import pecan -from pecan import rest -import re -import requests -import requests_cache -from six.moves.urllib import parse - -from refstack import db -from refstack.api import constants as const -from refstack.api import utils as api_utils from refstack.api.controllers import auth +from refstack.api.controllers import capabilities +from refstack.api.controllers import results from refstack.api.controllers import user -from refstack.common import validators - -LOG = log.getLogger(__name__) - -CTRLS_OPTS = [ - cfg.IntOpt('results_per_page', - default=20, - help='Number of results for one page'), - cfg.StrOpt('input_date_format', - default='%Y-%m-%d %H:%M:%S', - help='The format for %(start)s and %(end)s parameters' % { - 'start': const.START_DATE, - 'end': const.END_DATE - }) -] - -CONF = cfg.CONF - -CONF.register_opts(CTRLS_OPTS, group='api') -# Cached requests will expire after 10 minutes. -requests_cache.install_cache(cache_name='github_cache', - backend='memory', - expire_after=600) - - -class BaseRestControllerWithValidation(rest.RestController): - - """Rest controller with validation. - - Controller provides validation for POSTed data - exposed endpoints: - POST base_url/ - GET base_url/ - GET base_url/schema - """ - - __validator__ = None - - def __init__(self): # pragma: no cover - """Init.""" - if self.__validator__: - self.validator = self.__validator__() - else: - raise ValueError("__validator__ is not defined") - - def get_item(self, item_id): # pragma: no cover - """Handler for getting item.""" - raise NotImplementedError - - def store_item(self, item_in_json): # pragma: no cover - """Handler for storing item. Should return new item id.""" - raise NotImplementedError - - @pecan.expose('json') - def get_one(self, arg): - """Return test results in JSON format. - - :param arg: item ID in uuid4 format or action - """ - if self.validator.assert_id(arg): - return self.get_item(item_id=arg) - - elif arg == 'schema': - return self.validator.schema - - else: - pecan.abort(404) - - @pecan.expose('json') - def post(self, ): - """POST handler.""" - self.validator.validate(pecan.request) - item = json.loads(pecan.request.body) - item_id = self.store_item(item) - pecan.response.status = 201 - return item_id - - -class ResultsController(BaseRestControllerWithValidation): - - """/v1/results handler.""" - - __validator__ = validators.TestResultValidator - - def get_item(self, item_id): - """Handler for getting item.""" - test_info = db.get_test(item_id) - if not test_info: - pecan.abort(404) - test_list = db.get_test_results(item_id) - test_name_list = [test_dict[0] for test_dict in test_list] - return {"cpid": test_info.cpid, - "created_at": test_info.created_at, - "duration_seconds": test_info.duration_seconds, - "results": test_name_list} - - def store_item(self, item_in_json): - """Handler for storing item. Should return new item id.""" - item = item_in_json.copy() - if pecan.request.headers.get('X-Public-Key'): - if 'metadata' not in item: - item['metadata'] = {} - item['metadata']['public_key'] = \ - pecan.request.headers.get('X-Public-Key') - test_id = db.store_results(item) - LOG.debug(item) - return {'test_id': test_id, - 'url': parse.urljoin(CONF.ui_url, - CONF.api.test_results_url) % test_id} - - @pecan.expose('json') - def get(self): - """Get information of all uploaded test results. - - Get information of all uploaded test results in descending - chronological order. Make it possible to specify some - input parameters for filtering. - For example: - /v1/results?page=&cpid=1234. - By default, page is set to page number 1, - if the page parameter is not specified. - """ - expected_input_params = [ - const.START_DATE, - const.END_DATE, - const.CPID, - ] - - try: - filters = api_utils.parse_input_params(expected_input_params) - records_count = db.get_test_records_count(filters) - page_number, total_pages_number = \ - api_utils.get_page_number(records_count) - except api_utils.ParseInputsError as ex: - pecan.abort(400, 'Reason: %s' % ex) - except Exception as ex: - LOG.debug('An error occurred: %s' % ex) - pecan.abort(500) - - try: - per_page = CONF.api.results_per_page - records = db.get_test_records(page_number, per_page, filters) - - results = [] - for r in records: - results.append({ - 'test_id': r.id, - 'created_at': r.created_at, - 'cpid': r.cpid, - 'url': CONF.api.test_results_url % r.id - }) - - page = {'results': results, - 'pagination': { - 'current_page': page_number, - 'total_pages': total_pages_number - }} - except Exception as ex: - LOG.debug('An error occurred during ' - 'operation with database: %s' % ex) - pecan.abort(400) - - return page - - -class CapabilitiesController(rest.RestController): - - """/v1/capabilities handler. - - This acts as a proxy for retrieving capability files - from the openstack/defcore Github repository. - """ - - @pecan.expose('json') - def get(self): - """Get a list of all available capabilities.""" - try: - response = requests.get(CONF.api.github_api_capabilities_url) - LOG.debug("Response Status: %s / Used Requests Cache: %s" % - (response.status_code, - getattr(response, 'from_cache', False))) - if response.status_code == 200: - regex = re.compile('^[0-9]{4}\.[0-9]{2}\.json$') - capability_files = [] - for rfile in response.json(): - if rfile["type"] == "file" and regex.search(rfile["name"]): - capability_files.append(rfile["name"]) - return capability_files - else: - LOG.warning('Github returned non-success HTTP ' - 'code: %s' % response.status_code) - pecan.abort(response.status_code) - - except requests.exceptions.RequestException as e: - LOG.warning('An error occurred trying to get GitHub ' - 'repository contents: %s' % e) - pecan.abort(500) - - @pecan.expose('json') - def get_one(self, file_name): - """Handler for getting contents of specific capability file.""" - github_url = ''.join((CONF.api.github_raw_base_url.rstrip('/'), - '/', file_name, ".json")) - try: - response = requests.get(github_url) - LOG.debug("Response Status: %s / Used Requests Cache: %s" % - (response.status_code, - getattr(response, 'from_cache', False))) - if response.status_code == 200: - return response.json() - else: - LOG.warning('Github returned non-success HTTP ' - 'code: %s' % response.status_code) - pecan.abort(response.status_code) - except requests.exceptions.RequestException as e: - LOG.warning('An error occurred trying to get GitHub ' - 'capability file contents: %s' % e) - pecan.abort(500) class V1Controller(object): """Version 1 API controller root.""" - results = ResultsController() - capabilities = CapabilitiesController() + results = results.ResultsController() + capabilities = capabilities.CapabilitiesController() auth = auth.AuthController() profile = user.ProfileController() diff --git a/refstack/api/controllers/validation.py b/refstack/api/controllers/validation.py new file mode 100644 index 00000000..b4e3a7a5 --- /dev/null +++ b/refstack/api/controllers/validation.py @@ -0,0 +1,76 @@ +# Copyright (c) 2015 Mirantis, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Base for controllers with validation.""" + +import json + +import pecan +from pecan import rest + + +class BaseRestControllerWithValidation(rest.RestController): + + """Rest controller with validation. + + Controller provides validation for POSTed data + exposed endpoints: + POST base_url/ + GET base_url/ + GET base_url/schema + """ + + __validator__ = None + + _custom_actions = { + "schema": ["GET"], + } + + def __init__(self): # pragma: no cover + """Init.""" + if self.__validator__: + self.validator = self.__validator__() + else: + raise ValueError("__validator__ is not defined") + + def get_item(self, item_id): # pragma: no cover + """Handler for getting item.""" + raise NotImplementedError + + def store_item(self, item_in_json): # pragma: no cover + """Handler for storing item. Should return new item id.""" + raise NotImplementedError + + @pecan.expose('json') + def get_one(self, item_id): + """Return test results in JSON format. + + :param item_id: item ID in uuid4 format or action + """ + return self.get_item(item_id=item_id) + + @pecan.expose('json') + def schema(self): + """Return validation schema.""" + return self.validator.schema + + @pecan.expose('json') + def post(self, ): + """POST handler.""" + self.validator.validate(pecan.request) + item = json.loads(pecan.request.body) + item_id = self.store_item(item) + pecan.response.status = 201 + return item_id diff --git a/refstack/api/utils.py b/refstack/api/utils.py index 7307dbd4..40fc3b68 100644 --- a/refstack/api/utils.py +++ b/refstack/api/utils.py @@ -166,12 +166,21 @@ def get_user_session(): return pecan.request.environ['beaker.session'] +def get_user_id(): + """Return authenticated user id.""" + return get_user_session().get(const.USER_OPENID) + + +def get_user(): + """Return db record for authenticated user.""" + return db.user_get(get_user_id()) + + def is_authenticated(): """Return True if user is authenticated.""" - session = get_user_session() - if session.get(const.USER_OPENID): + if get_user_id(): try: - if db.user_get(session.get(const.USER_OPENID)): + if get_user(): return True except db.UserNotFound: pass diff --git a/refstack/common/validators.py b/refstack/common/validators.py index bdb2ce87..08e2238a 100644 --- a/refstack/common/validators.py +++ b/refstack/common/validators.py @@ -67,13 +67,19 @@ def checker_uuid(inst): return is_uuid(inst) -class Validator(object): +class BaseValidator(object): """Base class for validators.""" + schema = {} + def __init__(self): """Init.""" - self.schema = {} # pragma: no cover + jsonschema.Draft4Validator.check_schema(self.schema) + self.validator = jsonschema.Draft4Validator( + self.schema, + format_checker=ext_format_checker + ) def validate(self, request): """Validate request.""" @@ -88,42 +94,35 @@ class Validator(object): raise ValidationError('Request doesn''t correspond to schema', e) -class TestResultValidator(Validator): +class TestResultValidator(BaseValidator): """Validator for incoming test results.""" - def __init__(self): - """Init.""" - self.schema = { - 'type': 'object', - 'properties': { - 'cpid': { - 'type': 'string' - }, - 'duration_seconds': {'type': 'integer'}, - 'results': { - "type": "array", - "items": [{ - 'type': 'object', - 'properties': { - 'name': {'type': 'string'}, - 'uuid': { - 'type': 'string', - 'format': 'uuid_hex' - } - } - }] - - } + schema = { + 'type': 'object', + 'properties': { + 'cpid': { + 'type': 'string' }, - 'required': ['cpid', 'duration_seconds', 'results'], - 'additionalProperties': False - } - jsonschema.Draft4Validator.check_schema(self.schema) - self.validator = jsonschema.Draft4Validator( - self.schema, - format_checker=ext_format_checker - ) + 'duration_seconds': {'type': 'integer'}, + 'results': { + 'type': 'array', + 'items': [{ + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + 'uuid': { + 'type': 'string', + 'format': 'uuid_hex' + } + } + }] + + } + }, + 'required': ['cpid', 'duration_seconds', 'results'], + 'additionalProperties': False + } def validate(self, request): """Validate uploaded test results.""" @@ -149,3 +148,38 @@ class TestResultValidator(Validator): def assert_id(_id): """Check that _id is a valid uuid_hex string.""" return is_uuid(_id) + + +class PubkeyValidator(BaseValidator): + + """Validator for uploaded public pubkeys.""" + + schema = { + 'raw_key': 'string', + 'self_signature': 'string', + } + + def validate(self, request): + """Validate uploaded test results.""" + super(PubkeyValidator, self).validate(request) + body = json.loads(request.body) + key_format = body['raw_key'].strip().split()[0] + + if key_format not in ('ssh-dss', 'ssh-rsa', + 'pgp-sign-rsa', 'pgp-sign-dss'): + raise ValidationError('Public key has unsupported format') + + try: + sign = binascii.a2b_hex(body['self_signature']) + except (binascii.Error, TypeError) as e: + raise ValidationError('Malformed signature', e) + + try: + key = RSA.importKey(body['raw_key']) + except ValueError as e: + raise ValidationError('Malformed public key', e) + signer = PKCS1_v1_5.new(key) + data_hash = SHA256.new() + data_hash.update('signature'.encode('utf-8')) + if not signer.verify(data_hash, sign): + raise ValidationError('Signature verification failed') diff --git a/refstack/db/api.py b/refstack/db/api.py index 16670842..3c2c43bf 100644 --- a/refstack/db/api.py +++ b/refstack/db/api.py @@ -89,9 +89,24 @@ def user_get(user_openid): return IMPL.user_get(user_openid) -def user_update_or_create(user_info): +def user_save(user_info): """Create user DB record if it exists, otherwise record will be updated. :param user_info: User record """ - return IMPL.user_update_or_create(user_info) + return IMPL.user_save(user_info) + + +def store_pubkey(pubkey_info): + """Store public key in to DB.""" + return IMPL.store_pubkey(pubkey_info) + + +def delete_pubkey(pubkey_id): + """Delete public key from DB.""" + return IMPL.delete_pubkey(pubkey_id) + + +def get_user_pubkeys(user_openid): + """Get public pubkeys for specified user.""" + return IMPL.get_user_pubkeys(user_openid) diff --git a/refstack/db/migrations/alembic/script.py.mako b/refstack/db/migrations/alembic/script.py.mako index 95702017..c37c68fa 100755 --- a/refstack/db/migrations/alembic/script.py.mako +++ b/refstack/db/migrations/alembic/script.py.mako @@ -9,14 +9,17 @@ Create Date: ${create_date} # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} +MYSQL_CHARSET = 'utf8' from alembic import op import sqlalchemy as sa ${imports if imports else ""} def upgrade(): + """Upgrade DB.""" ${upgrades if upgrades else "pass"} def downgrade(): + """Downgrade DB.""" ${downgrades if downgrades else "pass"} diff --git a/refstack/db/migrations/alembic/versions/534e20be9964_create_pubkey_table.py b/refstack/db/migrations/alembic/versions/534e20be9964_create_pubkey_table.py new file mode 100644 index 00000000..5f8185e9 --- /dev/null +++ b/refstack/db/migrations/alembic/versions/534e20be9964_create_pubkey_table.py @@ -0,0 +1,41 @@ +"""Create user metadata table. + +Revision ID: 534e20be9964 +Revises: 2f178b0bf762 +Create Date: 2015-07-03 13:26:29.138416 + +""" + +# revision identifiers, used by Alembic. +revision = '534e20be9964' +down_revision = '2f178b0bf762' +MYSQL_CHARSET = 'utf8' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + """Upgrade DB.""" + op.create_table( + 'pubkeys', + sa.Column('updated_at', sa.DateTime()), + sa.Column('deleted_at', sa.DateTime()), + sa.Column('deleted', sa.Integer, default=0), + sa.Column('id', sa.String(length=36), primary_key=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('openid', sa.String(length=128), + nullable=False, index=True), + sa.Column('format', sa.String(length=24), nullable=False), + sa.Column('pubkey', sa.Text(), nullable=False), + sa.Column('md5_hash', sa.String(length=32), + nullable=False, index=True), + sa.Column('comment', sa.String(length=128)), + sa.ForeignKeyConstraint(['openid'], ['user.openid'], ), + mysql_charset=MYSQL_CHARSET + ) + + +def downgrade(): + """Downgrade DB.""" + op.drop_table('pubkeys') diff --git a/refstack/db/sqlalchemy/api.py b/refstack/db/sqlalchemy/api.py index 033c3aab..ad57842c 100644 --- a/refstack/db/sqlalchemy/api.py +++ b/refstack/db/sqlalchemy/api.py @@ -12,13 +12,18 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. + """Implementation of SQLAlchemy backend.""" + +import base64 +import hashlib import sys import uuid from oslo_config import cfg from oslo_db import options as db_options from oslo_db.sqlalchemy import session as db_session +from oslo_db.exception import DBDuplicateEntry import six from refstack.api import constants as api_const @@ -154,7 +159,7 @@ def user_get(user_openid): return user -def user_update_or_create(user_info): +def user_save(user_info): """Create user DB record if it exists, otherwise record will be updated.""" try: user = user_get(user_info['openid']) @@ -166,3 +171,52 @@ def user_update_or_create(user_info): user.update(user_info) user.save(session=session) return user + + +def store_pubkey(pubkey_info): + """Store public key in to DB.""" + pubkey = models.PubKey() + pubkey.openid = pubkey_info['openid'] + pubkey.format = pubkey_info['format'] + pubkey.pubkey = pubkey_info['key'] + pubkey.md5_hash = hashlib.md5( + base64.b64decode( + pubkey_info['key'].encode('ascii') + ) + ).hexdigest() + pubkey.comment = pubkey_info['comment'] + session = get_session() + with session.begin(): + pubkeys_collision = (session. + query(models.PubKey). + filter_by(md5_hash=pubkey.md5_hash). + filter_by(pubkey=pubkey.pubkey).all()) + if not pubkeys_collision: + pubkey.save(session) + else: + raise DBDuplicateEntry(columns=['pubkeys.pubkey'], + value=pubkey.pubkey) + return pubkey.id + + +def delete_pubkey(id): + """Delete public key from DB.""" + session = get_session() + with session.begin(): + key = session.query(models.PubKey).filter_by(id=id).first() + session.delete(key) + + +def get_user_pubkeys(user_openid): + """Get public pubkeys for specified user.""" + session = get_session() + pubkeys = session.query(models.PubKey).filter_by(openid=user_openid).all() + result = [] + for pubkey in pubkeys: + result.append({ + 'id': pubkey.id, + 'format': pubkey.format, + 'key': pubkey.pubkey, + 'comment': pubkey.comment + }) + return result diff --git a/refstack/db/sqlalchemy/models.py b/refstack/db/sqlalchemy/models.py index b271312e..099dedba 100644 --- a/refstack/db/sqlalchemy/models.py +++ b/refstack/db/sqlalchemy/models.py @@ -13,10 +13,14 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. + """SQLAlchemy models for Refstack data.""" +import uuid + from oslo_config import cfg from oslo_db.sqlalchemy import models +import six import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy.ext.declarative import declarative_base @@ -92,3 +96,20 @@ class User(BASE, RefStackBase): index=True) email = sa.Column(sa.String(128)) fullname = sa.Column(sa.String(128)) + pubkeys = orm.relationship('PubKey', backref='user') + + +class PubKey(BASE, RefStackBase): + + """User public pubkeys.""" + + __tablename__ = 'pubkeys' + + id = sa.Column(sa.String(36), primary_key=True, + default=lambda: six.text_type(uuid.uuid4())) + openid = sa.Column(sa.String(128), sa.ForeignKey('user.openid'), + nullable=False, unique=True, index=True) + format = sa.Column(sa.String(24), nullable=False) + pubkey = sa.Column(sa.Text(), nullable=False) + comment = sa.Column(sa.String(128)) + md5_hash = sa.Column(sa.String(32), nullable=False, index=True) diff --git a/refstack/opts.py b/refstack/opts.py index de27ee07..021fcd2a 100644 --- a/refstack/opts.py +++ b/refstack/opts.py @@ -49,6 +49,6 @@ def list_opts(): ('DEFAULT', itertools.chain(refstack.api.app.UI_OPTS, refstack.db.api.db_opts)), ('api', itertools.chain(refstack.api.app.API_OPTS, - refstack.api.controllers.v1.CTRLS_OPTS)), + refstack.api.controllers.CTRLS_OPTS)), ('osid', refstack.api.controllers.auth.OPENID_OPTS), ] diff --git a/refstack/tests/unit/test_api.py b/refstack/tests/unit/test_api.py index c2b2de7e..faa10304 100644 --- a/refstack/tests/unit/test_api.py +++ b/refstack/tests/unit/test_api.py @@ -29,7 +29,9 @@ import webob.exc from refstack.api import constants as const from refstack.api import utils as api_utils from refstack.api.controllers import auth -from refstack.api.controllers import v1 +from refstack.api.controllers import capabilities +from refstack.api.controllers import results +from refstack.api.controllers import validation from refstack.api.controllers import user @@ -61,9 +63,9 @@ class ResultsControllerTestCase(base.BaseTestCase): def setUp(self): super(ResultsControllerTestCase, self).setUp() self.validator = mock.Mock() - v1.ResultsController.__validator__ = \ + results.ResultsController.__validator__ = \ mock.Mock(exposed=False, return_value=self.validator) - self.controller = v1.ResultsController() + self.controller = results.ResultsController() self.config_fixture = config_fixture.Config() self.CONF = self.useFixture(self.config_fixture).conf self.test_results_url = '/#/results/%s' @@ -76,7 +78,6 @@ class ResultsControllerTestCase(base.BaseTestCase): @mock.patch('refstack.db.get_test') @mock.patch('refstack.db.get_test_results') def test_get(self, mock_get_test_res, mock_get_test): - self.validator.assert_id = mock.Mock(return_value=True) test_info = mock.Mock() test_info.cpid = 'foo' @@ -97,7 +98,6 @@ class ResultsControllerTestCase(base.BaseTestCase): self.assertEqual(actual_result, expected_result) mock_get_test_res.assert_called_once_with('fake_arg') mock_get_test.assert_called_once_with('fake_arg') - self.validator.assert_id.assert_called_once_with('fake_arg') @mock.patch('refstack.db.store_results') @mock.patch('pecan.response') @@ -262,7 +262,7 @@ class CapabilitiesControllerTestCase(base.BaseTestCase): def setUp(self): super(CapabilitiesControllerTestCase, self).setUp() - self.controller = v1.CapabilitiesController() + self.controller = capabilities.CapabilitiesController() def test_get_capabilities(self): """Test when getting a list of all capability files.""" @@ -338,9 +338,9 @@ class BaseRestControllerWithValidationTestCase(base.BaseTestCase): def setUp(self): super(BaseRestControllerWithValidationTestCase, self).setUp() self.validator = mock.Mock() - v1.BaseRestControllerWithValidation.__validator__ = \ + validation.BaseRestControllerWithValidation.__validator__ = \ mock.Mock(exposed=False, return_value=self.validator) - self.controller = v1.BaseRestControllerWithValidation() + self.controller = validation.BaseRestControllerWithValidation() @mock.patch('pecan.response') @mock.patch('pecan.request') @@ -361,21 +361,14 @@ class BaseRestControllerWithValidationTestCase(base.BaseTestCase): result = self.controller.get_one('fake_arg') self.assertEqual(result, 'fake_item') - self.validator.assert_id.assert_called_once_with('fake_arg') self.controller.get_item.assert_called_once_with(item_id='fake_arg') def test_get_one_return_schema(self): self.validator.assert_id = mock.Mock(return_value=False) self.validator.schema = 'fake_schema' - result = self.controller.get_one('schema') + result = self.controller.schema() self.assertEqual(result, 'fake_schema') - @mock.patch('pecan.abort') - def test_get_one_abort(self, mock_abort): - self.validator.assert_id = mock.Mock(return_value=False) - self.controller.get_one('fake_arg') - mock_abort.assert_called_once_with(404) - class ProfileControllerTestCase(base.BaseTestCase): @@ -489,7 +482,7 @@ class AuthControllerTestCase(base.BaseTestCase): mock_request.environ['beaker.session']) @mock.patch('refstack.api.utils.verify_openid_request', return_value=True) - @mock.patch('refstack.db.user_update_or_create') + @mock.patch('refstack.db.user_save') @mock.patch('pecan.request') @mock.patch('refstack.api.utils.get_user_session') @mock.patch('pecan.redirect', side_effect=webob.exc.HTTPRedirection) diff --git a/refstack/tests/unit/test_db.py b/refstack/tests/unit/test_db.py index 905150fe..71975887 100644 --- a/refstack/tests/unit/test_db.py +++ b/refstack/tests/unit/test_db.py @@ -61,10 +61,10 @@ class DBAPITestCase(base.BaseTestCase): db.user_get(user_openid) mock_db.assert_called_once_with(user_openid) - @mock.patch.object(api, 'user_update_or_create') - def test_user_update_or_create(self, mock_db): + @mock.patch.object(api, 'user_save') + def test_user_save(self, mock_db): user_info = 'user@example.com' - db.user_update_or_create(user_info) + db.user_save(user_info) mock_db.assert_called_once_with(user_info) @@ -310,7 +310,7 @@ class DBBackendTestCase(base.BaseTestCase): user_info = {'openid': 'user@example.com'} session = mock_get_session.return_value user = mock_model.return_value - result = api.user_update_or_create(user_info) + result = api.user_save(user_info) self.assertEqual(result, user) mock_model.assert_called_once_with()