summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Pitt <martin.pitt@ubuntu.com>2016-01-25 20:09:37 (GMT)
committerMartin Pitt <martin.pitt@ubuntu.com>2016-01-25 20:09:37 (GMT)
commit4fe5b6e9002c5dd64c280ff495e690d493ca1dbd (patch)
tree77a876c6257bf63452470d59d0d0ca66eba8ec19
parent688784d333683b73ed3b5641832566194d53d83a (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.py3
-rw-r--r--retry/login.py6
-rw-r--r--retry/submit.py105
-rw-r--r--retry/teams.py35
-rw-r--r--retry/tests/test_login.py10
-rw-r--r--retry/tests/test_submit.py55
-rw-r--r--retry/tests/test_teams.py34
-rw-r--r--retry/tests/test_v1.py16
-rw-r--r--retry/v1.py16
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)