Disable anonymous result upload

This patch disables anonymous test result uploads. It also
fixes a python35 incompatability uncovered by more extensive
testing of signing uploads (bytes can no longer be decoded).
Also updated coverage tests to not include tests in coverage.

Co-Authored-By: megan guiney <meganmguiney@gmail.com>

Change-Id: I3f19f1399eb2948eaf8340b5c4be18b698c7e139
This commit is contained in:
Chris Hoge 2017-09-26 15:12:08 -07:00 committed by Megan
parent 2afff3094a
commit 4595c1f0b6
6 changed files with 163 additions and 26 deletions

View File

@ -154,6 +154,11 @@
# contents of that file. (string value) # contents of that file. (string value)
#github_raw_base_url = https://raw.githubusercontent.com/openstack/interop/master/ #github_raw_base_url = https://raw.githubusercontent.com/openstack/interop/master/
# Enable or disable anonymous uploads. If set to False, all clients
# will need to authenticate and sign with a public/private keypair
# previously uploaded to their user account.
#enable_anonymous_upload = true
# Number of results for one page (integer value) # Number of results for one page (integer value)
#results_per_page = 20 #results_per_page = 20

View File

@ -94,7 +94,14 @@ API_OPTS = [
help='This is the base URL that is used for retrieving ' help='This is the base URL that is used for retrieving '
'specific capability files. Capability file names will ' 'specific capability files. Capability file names will '
'be appended to this URL to get the contents of that file.' 'be appended to this URL to get the contents of that file.'
) ),
cfg.BoolOpt('enable_anonymous_upload',
default=True,
help='Enable or disable anonymous uploads. If set to False, '
'all clients will need to authenticate and sign with a '
'public/private keypair previously uploaded to their '
'user account.'
)
] ]
CONF = cfg.CONF CONF = cfg.CONF

View File

@ -103,6 +103,40 @@ class ResultsController(validation.BaseRestControllerWithValidation):
meta = MetadataController() meta = MetadataController()
def _check_authentication(self):
x_public_key = pecan.request.headers.get('X-Public-Key')
if x_public_key:
public_key = x_public_key.strip().split()[1]
stored_public_key = db.get_pubkey(public_key)
if not stored_public_key:
pecan.abort(401, 'User with specified key not found. '
'Please log into the RefStack server to '
'upload your key.')
else:
stored_public_key = None
if not CONF.api.enable_anonymous_upload and not stored_public_key:
pecan.abort(401, 'Anonymous result uploads are disabled. '
'Please create a user account and an api '
'key at https://refstack.openstack.org/#/')
return stored_public_key
def _auto_version_associate(self, test, test_, pubkey):
if test.get('cpid'):
version = db.get_product_version_by_cpid(
test['cpid'], allowed_keys=['id', 'product_id'])
# Only auto-associate if there is a single product version
# with the given cpid.
if len(version) == 1:
is_foundation = api_utils.check_user_is_foundation_admin(
pubkey.openid)
is_product_admin = api_utils.check_user_is_product_admin(
version[0]['product_id'], pubkey.openid)
if is_foundation or is_product_admin:
test_['product_version_id'] = version[0]['id']
return test_
@pecan.expose('json') @pecan.expose('json')
@api_utils.check_permissions(level=const.ROLE_USER) @api_utils.check_permissions(level=const.ROLE_USER)
def get_one(self, test_id): def get_one(self, test_id):
@ -125,7 +159,8 @@ class ResultsController(validation.BaseRestControllerWithValidation):
if user_role not in (const.ROLE_FOUNDATION, const.ROLE_OWNER): if user_role not in (const.ROLE_FOUNDATION, const.ROLE_OWNER):
# Don't expose product information if product is not public. # Don't expose product information if product is not public.
if (test_info.get('product_version') and if (test_info.get('product_version') and
not test_info['product_version']['product_info']['public']): not test_info['product_version']
['product_info']['public']):
test_info['product_version'] = None test_info['product_version'] = None
@ -137,30 +172,16 @@ class ResultsController(validation.BaseRestControllerWithValidation):
def store_item(self, test): def store_item(self, test):
"""Handler for storing item. Should return new item id.""" """Handler for storing item. Should return new item id."""
# If we need a key, or the key isn't available, this will throw
# an exception with a 401
pubkey = self._check_authentication()
test_ = test.copy() test_ = test.copy()
if pecan.request.headers.get('X-Public-Key'): if pubkey:
key = pecan.request.headers.get('X-Public-Key').strip().split()[1]
if 'meta' not in test_: if 'meta' not in test_:
test_['meta'] = {} test_['meta'] = {}
pubkey = db.get_pubkey(key)
if not pubkey:
pecan.abort(400, 'User with specified key not found. '
'Please log into the RefStack server to '
'upload your key.')
test_['meta'][const.USER] = pubkey.openid test_['meta'][const.USER] = pubkey.openid
if test.get('cpid'): test_ = self._auto_version_associate(test, test_, pubkey)
version = db.get_product_version_by_cpid(
test['cpid'], allowed_keys=['id', 'product_id'])
# Only auto-associate if there is a single product version
# with the given cpid.
if len(version) == 1:
is_foundation = api_utils.check_user_is_foundation_admin(
pubkey.openid)
is_product_admin = api_utils.check_user_is_product_admin(
version[0]['product_id'], pubkey.openid)
if is_foundation or is_product_admin:
test_['product_version_id'] = version[0]['id']
test_id = db.store_results(test_) test_id = db.store_results(test_)
return {'test_id': test_id, return {'test_id': test_id,
'url': parse.urljoin(CONF.ui_url, 'url': parse.urljoin(CONF.ui_url,
@ -224,7 +245,8 @@ class ResultsController(validation.BaseRestControllerWithValidation):
# Don't expose product info if the product is not public. # Don't expose product info if the product is not public.
if (result.get('product_version') and not if (result.get('product_version') and not
result['product_version']['product_info']['public']): result['product_version']['product_info']
['public']):
result['product_version'] = None result['product_version'] = None
# Only show all metadata if the user is the owner or a # Only show all metadata if the user is the owner or a

View File

@ -323,7 +323,7 @@ def get_pubkey(key):
The md5 hash of the key is used for the query for quicker lookups. The md5 hash of the key is used for the query for quicker lookups.
""" """
session = get_session() session = get_session()
md5_hash = hashlib.md5(base64.b64decode(key.encode('ascii'))).hexdigest() md5_hash = hashlib.md5(base64.b64decode(key)).hexdigest()
pubkeys = session.query(models.PubKey).filter_by(md5_hash=md5_hash).all() pubkeys = session.query(models.PubKey).filter_by(md5_hash=md5_hash).all()
if len(pubkeys) == 1: if len(pubkeys) == 1:
return pubkeys[0] return pubkeys[0]
@ -342,7 +342,7 @@ def store_pubkey(pubkey_info):
pubkey.pubkey = pubkey_info['pubkey'] pubkey.pubkey = pubkey_info['pubkey']
pubkey.md5_hash = hashlib.md5( pubkey.md5_hash = hashlib.md5(
base64.b64decode( base64.b64decode(
pubkey_info['pubkey'].encode('ascii') pubkey_info['pubkey']
) )
).hexdigest() ).hexdigest()
pubkey.comment = pubkey_info['comment'] pubkey.comment = pubkey_info['comment']

View File

@ -25,6 +25,14 @@ from refstack.api import validators
from refstack import db from refstack import db
from refstack.tests import api from refstack.tests import api
import binascii
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives import serialization
FAKE_TESTS_RESULT = { FAKE_TESTS_RESULT = {
'cpid': 'foo', 'cpid': 'foo',
'duration_seconds': 10, 'duration_seconds': 10,
@ -407,3 +415,98 @@ class TestResultsEndpoint(api.FunctionalTest):
db.update_test({'id': test_id, 'verification_status': 0}) db.update_test({'id': test_id, 'verification_status': 0})
resp = self.delete(url, expect_errors=True) resp = self.delete(url, expect_errors=True)
self.assertEqual(204, resp.status_code) self.assertEqual(204, resp.status_code)
class TestResultsEndpointNoAnonymous(api.FunctionalTest):
URL = '/v1/results/'
def _generate_keypair_(self):
return rsa.generate_private_key(
public_exponent=65537,
key_size=1024,
backend=default_backend()
)
def _sign_body_(self, keypair, body):
signer = keypair.signer(padding.PKCS1v15(), hashes.SHA256())
signer.update(body)
return signer.finalize()
def _get_public_key_(self, keypair):
pubkey = keypair.public_key().public_bytes(
serialization.Encoding.OpenSSH,
serialization.PublicFormat.OpenSSH
)
return pubkey
def setUp(self):
super(TestResultsEndpointNoAnonymous, self).setUp()
self.config_fixture = config_fixture.Config()
self.CONF = self.useFixture(self.config_fixture).conf
self.CONF.api.enable_anonymous_upload = False
self.user_info = {
'openid': 'test-open-id',
'email': 'foo@bar.com',
'fullname': 'Foo Bar'
}
db.user_save(self.user_info)
good_key = self._generate_keypair_()
self.body = json.dumps(FAKE_TESTS_RESULT).encode()
signature = self._sign_body_(good_key, self.body)
pubkey = self._get_public_key_(good_key)
x_signature = binascii.b2a_hex(signature)
self.good_headers = {
'X-Signature': x_signature,
'X-Public-Key': pubkey
}
self.pubkey_info = {
'openid': 'test-open-id',
'format': 'ssh-rsa',
'pubkey': pubkey.split()[1],
'comment': 'comment'
}
db.store_pubkey(self.pubkey_info)
bad_key = self._generate_keypair_()
bad_signature = self._sign_body_(bad_key, self.body)
bad_pubkey = self._get_public_key_(bad_key)
x_bad_signature = binascii.b2a_hex(bad_signature)
self.bad_headers = {
'X-Signature': x_bad_signature,
'X-Public-Key': bad_pubkey
}
def test_post_with_no_token(self):
"""Test results endpoint with post request."""
results = json.dumps(FAKE_TESTS_RESULT)
actual_response = self.post_json(self.URL, expect_errors=True,
params=results)
self.assertEqual(actual_response.status_code, 401)
def test_post_with_valid_token(self):
"""Test results endpoint with post request."""
results = json.dumps(FAKE_TESTS_RESULT)
actual_response = self.post_json(self.URL,
headers=self.good_headers,
params=results)
self.assertIn('test_id', actual_response)
try:
uuid.UUID(actual_response.get('test_id'), version=4)
except ValueError:
self.fail("actual_response doesn't contain test_id")
def test_post_with_invalid_token(self):
results = json.dumps(FAKE_TESTS_RESULT)
actual_response = self.post_json(self.URL,
headers=self.bad_headers,
expect_errors=True,
params=results)
self.assertEqual(actual_response.status_code, 401)

View File

@ -57,7 +57,7 @@ commands = {posargs}
[testenv:gen-cover] [testenv:gen-cover]
commands = python setup.py testr --coverage \ commands = python setup.py testr --coverage \
--omit='{toxinidir}/refstack/tests*,{toxinidir}/refstack/api/config.py,{toxinidir}/refstack/db/migrations/alembic/*,{toxinidir}/refstack/opts.py' \ --omit='{toxinidir}/refstack/tests/unit/*,{toxinidir}/refstack/tests/api/*,{toxinidir}/refstack/api/config.py,{toxinidir}/refstack/db/migrations/alembic/*,{toxinidir}/refstack/opts.py' \
--testr-args='{posargs}' --testr-args='{posargs}'
[testenv:cover] [testenv:cover]