Add endpoint and UI for public keys uploading

Users can upload, delete and retrieve list of their public keys.

User can upload test result with any public keys without any connection
with uploading public keys in Refstack. It means that you can upload
signed test results and upload public key later. Also, it means that
deleting public key doesn't mean deleting results signed with this key.

Change-Id: Idc418c4c90221740eef04fcf498d7071a4446b9c
This commit is contained in:
sslypushenko 2015-07-09 01:20:31 +03:00 committed by Sergey Slipushenko
parent 1f28d8bc0b
commit 217cadd608
26 changed files with 850 additions and 318 deletions

View File

@ -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

View File

@ -0,0 +1,8 @@
<div class="modal-body" style="padding:0px">
<div class="alert alert-{{::data.mode}}" style="margin-bottom:0px">
<button type="button" class="close" data-ng-click="close()" >
<span class="glyphicon glyphicon-remove-circle"></span>
</button>
<strong>{{::data.title}}</strong> {{::data.text}}
</div>
</div>

View File

@ -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);
}
]
);

View File

@ -0,0 +1,21 @@
<div class="modal-header">
<h4>Import public key</h4>
</div>
<div class="modal-body container-fluid">
<div class="row">
<div class="col-md-2">Public key</div>
<div class="col-md-9 pull-right">
<textarea type="text" rows="11" cols="42" ng-model="raw_key" required></textarea>
</div>
</div>
<div class="row">
<div class="col-md-2">Signature</div>
<div class="col-md-9 pull-right">
<textarea type="text" rows="11" cols="42" ng-model="self_signature" required></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-warning" ng-click="cancel()">Cancel</button>
<button type="button" class="btn btn-default btn-sm" ng-click="importPubKey()">Import public key</button>
</div>
</div>

View File

@ -1,4 +1,36 @@
<h1>Hello, {{user.fullname}}!</h1>
{{bar}}
<p>openid: {{user.openid}}</p>
<p>email: {{user.email}}</p>
<h3>User profile</h3>
<div ng-show="user">
<table ng-show="user" class="table table-striped table-hover">
<tbody>
<tr> <td>User name</td> <td>{{user.fullname}}</td> </tr>
<tr> <td>User OpenId</td> <td>{{user.openid}}</td> </tr>
<tr> <td>Email</td> <td>{{user.email}}</td> </tr>
</tbody>
</table>
</div>
<div class="container-fluid">
<div class="row">
<div class="col-md-4">
<h4>User public keys</h4>
</div>
<div class="col-md-2 pull-right">
<button type="button" class="btn btn-default btn-sm" ng-click="openImportPubKeyModal()">
<span class="glyphicon glyphicon-plus"></span> Import public key
</button>
</div>
</div>
</div>
<div ng-show="pubkeys">
<table ng-show="pubkeys" class="table table-striped table-hover">
<tbody>
<tr ng-repeat="pubKey in pubkeys" ng-click="openShowPubKeyModal(pubKey)">
<td>{{pubKey.format}}</td>
<td>{{pubKey.shortKey}}</td>
<td>{{pubKey.comment}}</td>
</tr>
</tbody>
</table>
</div>

View File

@ -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');
};
}
]
);

View File

@ -0,0 +1,10 @@
<div class="modal-header">
<h4>Public key</h4>
</div>
<div class="modal-body container-fluid">
<textarea type="text" rows="10" cols="67" readonly="readonly">{{::rawKey}}</textarea>
<div class="modal-footer">
<button class="btn btn-warning" ng-click="cancel()">Cancel</button>
<button type="button" class="btn btn-danger btn-sm" ng-click="deletePubKey()">Delete</button>
</div>
</div>

View File

@ -29,6 +29,7 @@
<script src="assets/lib/angular/angular.js"></script>
<script src="assets/lib/angular-ui-router/release/angular-ui-router.js"></script>
<script src="assets/lib/angular-resource/angular-resource.min.js"></script>
<script src="assets/lib/angular-bootstrap/ui-bootstrap.min.js"></script>
<script src="assets/lib/angular-bootstrap/ui-bootstrap-tpls.min.js"></script>
<script src="assets/lib/angular-busy/dist/angular-busy.min.js"></script>
@ -42,6 +43,7 @@
<script src="components/results-report/resultsReportController.js"></script>
<script src="components/profile/profileController.js"></script>
<script src="components/auth/authController.js"></script>
<script src="components/alerts/alertModalFactory.js"></script>
<!-- Filters -->
<script src="shared/filters.js"></script>

View File

@ -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',

View File

@ -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')

View File

@ -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

View File

@ -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)

View File

@ -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=<page number>&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

View File

@ -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 <pubkeys endpoint>/<id>
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,

View File

@ -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/<item uid>
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=<page number>&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()

View File

@ -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/<item uid>
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

View File

@ -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

View File

@ -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')

View File

@ -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)

View File

@ -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"}

View File

@ -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')

View File

@ -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

View File

@ -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)

View File

@ -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),
]

View File

@ -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)

View File

@ -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()