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:
parent
1f28d8bc0b
commit
217cadd608
@ -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
|
||||
|
8
refstack-ui/app/components/alerts/alertModal.html
Normal file
8
refstack-ui/app/components/alerts/alertModal.html
Normal 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>
|
42
refstack-ui/app/components/alerts/alertModalFactory.js
Normal file
42
refstack-ui/app/components/alerts/alertModalFactory.js
Normal 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);
|
||||
}
|
||||
]
|
||||
);
|
21
refstack-ui/app/components/profile/importPubKeyModal.html
Normal file
21
refstack-ui/app/components/profile/importPubKeyModal.html
Normal 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>
|
@ -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>
|
||||
|
||||
|
@ -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');
|
||||
};
|
||||
}
|
||||
]
|
||||
);
|
||||
|
10
refstack-ui/app/components/profile/showPubKeyModal.html
Normal file
10
refstack-ui/app/components/profile/showPubKeyModal.html
Normal 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>
|
@ -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>
|
||||
|
@ -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',
|
||||
|
@ -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')
|
||||
|
@ -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
|
||||
|
87
refstack/api/controllers/capabilities.py
Normal file
87
refstack/api/controllers/capabilities.py
Normal 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)
|
118
refstack/api/controllers/results.py
Normal file
118
refstack/api/controllers/results.py
Normal 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
|
@ -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,
|
||||
|
@ -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()
|
||||
|
76
refstack/api/controllers/validation.py
Normal file
76
refstack/api/controllers/validation.py
Normal 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
|
@ -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
|
||||
|
@ -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')
|
||||
|
@ -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)
|
||||
|
@ -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"}
|
||||
|
@ -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')
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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),
|
||||
]
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
Loading…
Reference in New Issue
Block a user