diff options
| author | Martin Pitt <martin.pitt@ubuntu.com> | 2016-01-25 20:09:37 (GMT) |
|---|---|---|
| committer | Martin Pitt <martin.pitt@ubuntu.com> | 2016-01-25 20:09:37 (GMT) |
| commit | 4fe5b6e9002c5dd64c280ff495e690d493ca1dbd (patch) | |
| tree | 77a876c6257bf63452470d59d0d0ca66eba8ec19 | |
| parent | 688784d333683b73ed3b5641832566194d53d83a (diff) | |
Check per-package upload privilege instead of team membership
Ask Launchpad's checkUpload() function if the requestor can upload the tested
package or the trigger. In both cases she could trigger a new request via an
upload, so we should allow PPUs to retry tests too.
This also drops the whole Teams extension/query/module, as the login nickname
is now fully sufficient for the validation.
| -rw-r--r-- | retry/app.py | 3 | ||||
| -rw-r--r-- | retry/login.py | 6 | ||||
| -rw-r--r-- | retry/submit.py | 105 | ||||
| -rw-r--r-- | retry/teams.py | 35 | ||||
| -rw-r--r-- | retry/tests/test_login.py | 10 | ||||
| -rw-r--r-- | retry/tests/test_submit.py | 55 | ||||
| -rw-r--r-- | retry/tests/test_teams.py | 34 | ||||
| -rw-r--r-- | retry/tests/test_v1.py | 16 | ||||
| -rw-r--r-- | retry/v1.py | 16 |
9 files changed, 123 insertions, 157 deletions
diff --git a/retry/app.py b/retry/app.py index da0daea..1c388ef 100644 --- a/retry/app.py +++ b/retry/app.py @@ -7,7 +7,6 @@ Initiate the Flask app. from flask import Flask from flask.ext.openid import OpenID -from retry.teams import Teams from retry.sessions import PickleSessionManager @@ -17,4 +16,4 @@ app = Flask(__name__) app.session_interface = PickleSessionManager(PATH + 'session') -oid = OpenID(app, PATH + 'openid', safe_roots=[], extension_responses=[Teams]) +oid = OpenID(app, PATH + 'openid', safe_roots=[]) diff --git a/retry/login.py b/retry/login.py index 7d955f8..004a99d 100644 --- a/retry/login.py +++ b/retry/login.py @@ -6,21 +6,19 @@ Define the necessary API endpoints to make OpenID function correctly. from flask import request, session, redirect from retry.app import app, oid -from retry.teams import Teams @app.route('/login', methods=['GET', 'POST']) @oid.loginhandler def login(): """Initiate OpenID login.""" - if Teams.verify(): + if 'nickname' in session: return redirect(oid.get_next_url()) if 'next' in request.form: return oid.try_login( 'https://login.ubuntu.com/', ask_for=['nickname'], - ask_for_optional=['fullname', 'email'], - extensions=[Teams]) + ask_for_optional=['fullname', 'email']) return redirect('/') diff --git a/retry/submit.py b/retry/submit.py index 0ba4f33..2506d52 100644 --- a/retry/submit.py +++ b/retry/submit.py @@ -10,9 +10,13 @@ import logging import subprocess import urllib.request import urllib.parse +from urllib.error import HTTPError import amqplib.client_0_8 as amqp +# Launchpad REST API base +LP = 'https://api.launchpad.net/1.0/' + class Submit: def __init__(self): @@ -40,7 +44,7 @@ class Submit: assert self.amqp_creds.scheme == 'amqp' logging.debug('AMQP credentials: %s' % repr(self.amqp_creds)) - def validate_request(self, release, arch, package, trigger): + def validate_request(self, release, arch, package, trigger, requester): """Validate package and trigger and send an autopkgtest request 'package' is a single source package name. 'trigger' has the format @@ -64,10 +68,18 @@ class Submit: if not re.match('^[a-z0-9][a-z0-9.+-]+$', trigsrc) or \ not re.match('^[a-z0-9.+:~-]+$', trigver): raise ValueError('Malformed trigger') - if not self.is_valid_package_version(release, trigsrc, trigver): + component = self.is_valid_package_version(release, trigsrc, trigver) + if not component: raise ValueError('%s is not published in %s' % (trigger, release)) + # verify that requester can upload package or trigsrc + if not self.can_upload(requester, release, component, package) and \ + not self.can_upload(requester, release, component, trigsrc): + raise ValueError('You are not allowed to upload %s or %s to ' + 'Ubuntu, thus you are not allowed to use this ' + 'service.' % (package, trigsrc)) + def send_request(self, release, arch, package, trigger, requester): """Send autopkgtest request to AMQP""" @@ -103,34 +115,67 @@ class Submit: Use this for validating trigger packages. This queries the Launchpad REST API. + + Return the component name if package/version exists, otherwise None. """ # https://launchpad.net/+apidoc/1.0.html#archive-getPublishedSources - query = {'ws.op': 'getPublishedSources', - 'source_name': package, - 'version': version, - 'distro_series': 'https://api.launchpad.net/1.0/ubuntu/' + - release, - 'status': 'Published'} - with urllib.request.urlopen( - 'https://api.launchpad.net/1.0/ubuntu/+archive/primary?' + - urllib.parse.urlencode(query), - timeout=10) as req: - if req.getcode() >= 300: - logging.error( - 'is_valid_package_version(%s, %s, %s): ' - 'URL %s failed with code %u', - release, package, version, req.geturl(), req.getcode()) - return False - response = req.read() - try: - response = json.loads(response.decode('UTF-8')) - except (UnicodeDecodeError, ValueError) as e: - logging.error( - 'is_valid_package_version(%s, %s, %s): ' - 'URL %s gave invalid response %s: %s', - release, package, version, req.geturl(), response, str(e)) - return False + (code, response) = self.lp_request('ubuntu/+archive/primary', + {'ws.op': 'getPublishedSources', + 'source_name': package, + 'version': version, + 'distro_series': LP + 'ubuntu/' + release, + 'status': 'Published'}) + if code < 200 or code >= 300: + return None logging.debug( - 'is_valid_package_version(%s, %s, %s): response %s', - release, package, version, repr(response)) - return response.get('total_size', 0) > 0 + 'is_valid_package_version(%s, %s, %s): code %u, response %s', + release, package, version, code, repr(response)) + if response.get('total_size', 0) > 0: + return response['entries'][0]['component_name'] + else: + return None + + def can_upload(self, person, release, component, package): + """Check if person can upload package into Ubuntu release""" + + # https://launchpad.net/+apidoc/1.0.html#archive-checkUpload + (code, response) = self.lp_request('ubuntu/+archive/primary', + {'ws.op': 'checkUpload', + 'distroseries': LP + 'ubuntu/' + release, + 'person': LP + '~' + person, + 'component': component, + 'pocket': 'Proposed', + 'sourcepackagename': package, + }) + logging.warning('can_upload(%s, %s, %s, %s): (%u, %s)', + person, release, component, package, code, repr(response)) + return code >= 200 and code < 300 + + @classmethod + def lp_request(klass, obj, query): + """Do a Launchpad REST request + + Request https://api.launchpad.net/1.0/<obj>?<query>. + + Return (code, json), where json is defined for successful codes + (200 <= code < 300) and None otherwise. + """ + url = LP + obj + '?' + urllib.parse.urlencode(query) + try: + with urllib.request.urlopen(url, timeout=10) as req: + code = req.getcode() + if code >= 300: + logging.error('URL %s failed with code %u', req.geturl(), code) + return (code, None) + response = req.read() + except HTTPError as e: + logging.error('%s failed with %u: %s\n%s', url, e.code, e.reason, e.headers) + return (e.code, None) + + try: + response = json.loads(response.decode('UTF-8')) + except (UnicodeDecodeError, ValueError) as e: + logging.error('URL %s gave invalid response %s: %s', + req.geturl(), response, str(e)) + return (500, None) + return (code, response) diff --git a/retry/teams.py b/retry/teams.py deleted file mode 100644 index 13b2dc2..0000000 --- a/retry/teams.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Retry Teams. - -This file implements the Launchpad team SSO extension documented here: - -https://dev.launchpad.net/OpenIDTeams -""" - -from flask import session -from openid.extension import Extension - -COMMA = ',' - - -class Teams(Extension): - """Launchpad team membership OpenID Extension.""" - teams = {'ubuntu-dev'} - ns_uri = 'http://ns.launchpad.net/2007/openid-teams' - __name__ = 'lp' - ns_alias = 'lp' - - def getExtensionArgs(self): - """Tell Launchpad what teams we want.""" - return {'query_membership': COMMA.join(sorted(self.teams))} - - def fromSuccessResponse(self, resp): - """Store the teams we have in the secure session.""" - session['teams'] = resp.message.args[(self.ns_uri, 'is_member')] - - @staticmethod - def verify(): - """Confirm that the user has at least one of the given teams.""" - return bool(set(session.get('teams', '').split(COMMA)) & Teams.teams) - - -Teams = Teams() diff --git a/retry/tests/test_login.py b/retry/tests/test_login.py index eaeded3..cd74bcf 100644 --- a/retry/tests/test_login.py +++ b/retry/tests/test_login.py @@ -4,7 +4,6 @@ Test all things related to OpenID logins. """ from retry.login import identify -from retry.teams import Teams from retry.testing.base import RetryTestBase from unittest.mock import patch @@ -30,15 +29,6 @@ class LoginTestCase(RetryTestBase): self.assertIn(b'<a href="/">/</a>.', ret.data) self.assertEqual(ret.status_code, 302) - def test_login_by_team_member(self): - """Login with a matching team in the session.""" - with self.app.session_transaction() as session: - # Just pick one team to be a member of. - session['teams'] = Teams.teams.copy().pop() - ret = self.app.get('/login', follow_redirects=False) - self.assertIn(b'You should be redirected automatically', ret.data) - self.assertEqual(ret.status_code, 302) - def test_logged_already(self): """Ensure correct redirect when already logged in.""" with self.app.session_transaction() as session: diff --git a/retry/tests/test_submit.py b/retry/tests/test_submit.py index f731b65..72c5fd3 100644 --- a/retry/tests/test_submit.py +++ b/retry/tests/test_submit.py @@ -4,6 +4,7 @@ Test all things related verifying input arguments and sending AMQP requests. """ from unittest.mock import patch, MagicMock +from urllib.error import HTTPError import retry.submit from retry.testing.base import RetryTestBase @@ -48,21 +49,21 @@ class SubmitTestCase(RetryTestBase): """Unknown release""" with self.assertRaises(ValueError) as cme: - self.submit.validate_request('fooly', 'C51', 'ab', 'ab/1') + self.submit.validate_request('fooly', 'C51', 'ab', 'ab/1', 'joe') self.assertEqual(str(cme.exception), 'Unknown release fooly') def test_validate_bad_arch(self): """Unknown architecture""" with self.assertRaises(ValueError) as cme: - self.submit.validate_request('testy', 'wut', 'ab', 'ab/1') + self.submit.validate_request('testy', 'wut', 'ab', 'ab/1', 'joe') self.assertEqual(str(cme.exception), 'Unknown architecture wut') def test_validate_bad_package(self): """Unknown package""" with self.assertRaises(ValueError) as cme: - self.submit.validate_request('testy', 'C51', 'foo', 'ab/1') + self.submit.validate_request('testy', 'C51', 'foo', 'ab/1', 'joe') self.assertIn('Package foo', str(cme.exception)) @patch('retry.submit.os.path.isdir') @@ -74,20 +75,20 @@ class SubmitTestCase(RetryTestBase): # invalid trigger format with self.assertRaises(ValueError) as cme: - self.submit.validate_request('testy', 'C51', 'foo', 'ab') + self.submit.validate_request('testy', 'C51', 'foo', 'ab', 'joe') self.assertIn('Malformed trigger', str(cme.exception)) mock_isdir.assert_called_once_with('/data/autopkgtest/testy/C51/f/foo') mock_isdir.reset_mock() # invalid trigger source package name chars with self.assertRaises(ValueError) as cme: - self.submit.validate_request('testy', 'C51', 'libfoo', 'a!b/1') + self.submit.validate_request('testy', 'C51', 'libfoo', 'a!b/1', 'joe') mock_isdir.assert_called_once_with('/data/autopkgtest/testy/C51/libf/libfoo') self.assertIn('Malformed trigger', str(cme.exception)) # invalid trigger version chars with self.assertRaises(ValueError) as cme: - self.submit.validate_request('testy', 'C51', 'foo', 'ab/1!1') + self.submit.validate_request('testy', 'C51', 'foo', 'ab/1!1', 'joe') self.assertIn('Malformed trigger', str(cme.exception)) @patch('retry.submit.urllib.request.urlopen') @@ -109,42 +110,66 @@ class SubmitTestCase(RetryTestBase): mock_urlopen.return_value = cm with self.assertRaises(ValueError) as cme: - self.submit.validate_request('testy', 'C51', 'foo', 'ab/1.2') + self.submit.validate_request('testy', 'C51', 'foo', 'ab/1.2', 'joe') self.assertEqual(str(cme.exception), 'ab/1.2 is not published in testy') self.assertEqual(mock_urlopen.call_count, 1) # broken JSON response cm.read.return_value = b'not { json}' with self.assertRaises(ValueError) as cme: - self.submit.validate_request('testy', 'C51', 'foo', 'ab/1.2') + self.submit.validate_request('testy', 'C51', 'foo', 'ab/1.2', 'joe') # same, but entirely failing query -- let's be on the safe side cm.getcode.return_value = 404 cm.read.return_value = b'<html>not found</html>' with self.assertRaises(ValueError) as cme: - self.submit.validate_request('testy', 'C51', 'foo', 'ab/1.2') + self.submit.validate_request('testy', 'C51', 'foo', 'ab/1.2', 'joe') self.assertEqual(str(cme.exception), 'ab/1.2 is not published in testy') @patch('retry.submit.urllib.request.urlopen') @patch('retry.submit.os.path.isdir') + def test_validate_no_upload_perm(self, mock_isdir, mock_urlopen): + """Requester is not allowed to upload package""" + + # mock 'existing logs for package?' check + mock_isdir.return_value = True + + # mock Launchpad response: successful form, matching + # source/version, upload not allowed + cm = MagicMock() + cm.__enter__.return_value = cm + cm.getcode.return_value = 200 + cm.read.side_effect = [b'{"total_size": 1, "entries": [{"component_name": "main"}]}', + HTTPError('https://lp/checkUpload', 403, 'Forbidden', {}, None), + HTTPError('https://lp/checkUpload', 403, 'Forbidden', {}, None)] + cm.return_value = cm + mock_urlopen.return_value = cm + + with self.assertRaises(ValueError) as cme: + self.submit.validate_request('testy', 'C51', 'foo', 'ab/1.2', 'joe') + self.assertIn('not allowed to upload foo or ab', str(cme.exception)) + self.assertEqual(mock_urlopen.call_count, 3) + + @patch('retry.submit.urllib.request.urlopen') + @patch('retry.submit.os.path.isdir') def test_validate_ok(self, mock_isdir, mock_urlopen): """Valid request is accepted""" # mock 'existing logs for package?' check mock_isdir.return_value = True - # mock Launchpad response: successful form, and matching - # source/version + # mock Launchpad response: successful form, matching + # source/version, upload allowed cm = MagicMock() cm.__enter__.return_value = cm cm.getcode.return_value = 200 - cm.geturl.return_value = 'http://mock.launchpad.net' - cm.read.return_value = b'{"total_size": 1}' + cm.read.side_effect = [b'{"total_size": 1, "entries": [{"component_name": "main"}]}', + b'true'] cm.return_value = cm mock_urlopen.return_value = cm - self.submit.validate_request('testy', 'C51', 'foo', 'ab/1.2') - self.assertEqual(mock_urlopen.call_count, 1) + self.submit.validate_request('testy', 'C51', 'foo', 'ab/1.2', 'joe') + self.assertEqual(mock_urlopen.call_count, 2) @patch('retry.submit.amqp.Connection') @patch('retry.submit.amqp.Message') diff --git a/retry/tests/test_teams.py b/retry/tests/test_teams.py deleted file mode 100644 index 4a678a7..0000000 --- a/retry/tests/test_teams.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Retrier Teams Test - -Test everything related to Launchpad SSO teams. -""" - -from retry.teams import Teams -from retry.testing.base import RetryTestBase -from unittest.mock import patch - - -COMMA = ',' -SESSION = {} - - -class TeamsTestCase(RetryTestBase): - """Test SSO Teams""" - - def test_get_extension_args(self): - """Ensure that we request the correct teams from Launchpad.""" - args = Teams.getExtensionArgs() - self.assertEqual(args['query_membership'], - COMMA.join(sorted(Teams.teams))) - - @patch('retry.teams.session', SESSION) - def test_from_success_response(self): - """Ensure that we correctly handle the response from Launchpad.""" - class Resp: - """Fake Launchpad SSO response.""" - class message: - """Fake Launchpad SSO message.""" - args = {(Teams.ns_uri, 'is_member'): 'hello'} - # pylint: disable=no-value-for-parameter - Teams.fromSuccessResponse(Resp) - self.assertDictEqual(SESSION, dict(teams='hello')) diff --git a/retry/tests/test_v1.py b/retry/tests/test_v1.py index ff5a9b7..d673ed3 100644 --- a/retry/tests/test_v1.py +++ b/retry/tests/test_v1.py @@ -2,7 +2,6 @@ from unittest.mock import patch -from retry.teams import Teams from retry.testing.base import RetryTestBase @@ -11,13 +10,8 @@ class TestRoot(RetryTestBase): def prep_session(self): """Set some commonly needed session data.""" - # Just pick one team to be a member of, but use the first one - # alphabetically for predictable test results. - team = sorted(Teams.teams)[0] with self.app.session_transaction() as session: - session['teams'] = team session['nickname'] = 'person' - return team def test_login(self): """Hitting / when not logged in prompts for a login.""" @@ -29,15 +23,7 @@ class TestRoot(RetryTestBase): with self.app.session_transaction() as session: session['nickname'] = 'person' ret = self.app.get('/') - self.assertIn(b'Logout person (<i>no teams</i>)', ret.data) - - def test_teams(self): - """Hitting / with a matching team in the session prompts for logout.""" - team = self.prep_session() - ret = self.app.get('/') - # There is no bytes.format until Python 3.6. - self.assertIn('Logout person ({})'.format(team).encode('utf-8'), - ret.data) + self.assertIn(b'Logout person', ret.data) def test_missing_request(self): """Missing GET params should return 400.""" diff --git a/retry/v1.py b/retry/v1.py index 73ccb6d..04211bb 100644 --- a/retry/v1.py +++ b/retry/v1.py @@ -1,7 +1,6 @@ from collections import ChainMap from flask import request, session from retry.app import app -from retry.teams import Teams from retry.submit import Submit @@ -30,14 +29,9 @@ LOGIN = """ """ LOGOUT = """ -<p><a href="/logout">Logout {nickname} ({teams})</a></p> +<p><a href="/logout">Logout {nickname}</a></p> """ -NOTEAM = """ -<p>Necessary team membership not found. You must be a member of {} to use this -service. If you are, try logging out and logging back in.</p> -""".format(' or '.join(Teams.teams)) - MISSING = """ <p>You submitted an incomplete request. Please ensure you supply <code>{need}</code> as a GET parameter.</p> @@ -62,7 +56,7 @@ SUCCESS = """ def index_root(): """Deliver the front page.""" session['next'] = request.url - if Teams.verify(): + if session.get('nickname'): params = {key: val for key, val in request.args.items()} for need in NEEDED: if not params.get(need): @@ -73,7 +67,8 @@ def index_root(): s = Submit() try: s.validate_request(params['release'], params['arch'], - params['package'], params['trigger']) + params['package'], params['trigger'], + session.get('nickname')) except ValueError as e: return HTML.format(LOGOUT + INVALID).format( **ChainMap({'message': str(e)}, session)), 400 @@ -85,8 +80,5 @@ def index_root(): return HTML.format(LOGOUT + SUCCESS).format( **ChainMap(session, params)) - elif session.get('nickname'): - session['teams'] = '<i>no teams</i>' - return HTML.format((LOGOUT + NOTEAM).format(**session)) else: return HTML.format(LOGIN).format(**session) |
