From 55341548290ab4b9f6e5f9a150371368d641ad43 Mon Sep 17 00:00:00 2001 From: Jan Kaluza Date: Sep 26 2017 08:12:18 +0000 Subject: Merge branch 'master' of ssh://pagure.io/odcs --- diff --git a/server/conf/config.py b/server/conf/config.py index 377304e..f5255b6 100644 --- a/server/conf/config.py +++ b/server/conf/config.py @@ -54,6 +54,11 @@ class BaseConfiguration(object): 'users': [], } + # OIDC base namespace + # See also section pagure.io/odcs in + # https://fedoraproject.org/wiki/Infrastructure/Authentication + OIDC_BASE_NAMESPACE = 'https://pagure.io/odcs/' + # Select which authentication backend to work with. There are 3 choices # noauth: no authentication is enabled. Useful for development particularly. # kerberos: Kerberos authentication is enabled. @@ -75,12 +80,15 @@ class BaseConfiguration(object): # run a new compose. # See also: https://fedoraproject.org/wiki/Infrastructure/Authentication # Add additional required scope in following list + # + # ODCS has additional scopes, which will be checked later when specific + # API is called. + # https://pagure.io/odcs/new-compose + # https://pagure.io/odcs/renew-compose + # https://pagure.io/odcs/delete-compose AUTH_OPENIDC_REQUIRED_SCOPES = [ 'openid', 'https://id.fedoraproject.org/scope/groups', - 'https://pagure.io/odcs/new-compose', - 'https://pagure.io/odcs/renew-compose', - 'https://pagure.io/odcs/delete-compose', ] # Select backend where message will be sent to. Currently, umb is supported diff --git a/server/odcs/server/__init__.py b/server/odcs/server/__init__.py index 6b10f0b..474f8aa 100644 --- a/server/odcs/server/__init__.py +++ b/server/odcs/server/__init__.py @@ -36,9 +36,10 @@ from odcs.server.errors import NotFound, Unauthorized, Forbidden app = Flask(__name__) app.wsgi_app = ReverseProxy(app.wsgi_app) +conf = init_config(app) + db = SQLAlchemy(app) -conf = init_config(app) init_logging(conf) log = getLogger(__name__) diff --git a/server/odcs/server/auth.py b/server/odcs/server/auth.py index e66c7b1..e99f3e1 100644 --- a/server/odcs/server/auth.py +++ b/server/odcs/server/auth.py @@ -122,6 +122,7 @@ def load_openidc_user(request): g.groups = user_info.get('groups', []) g.user = user + g.oidc_scopes = scope.split(' ') return user @@ -138,6 +139,26 @@ def validate_scopes(scope): raise Unauthorized('Required OIDC scope {0} not present.'.format(scope)) +def require_oidc_scope(scope): + """Check if required scopes is in OIDC scopes within request""" + full_scope = '{0}{1}'.format(conf.oidc_base_namespace, scope) + if full_scope not in g.oidc_scopes: + log.error('Request does not have required scope %s', scope) + raise Forbidden('Request does not have required OIDC scope.') + + +def require_scopes(*scopes): + """Check if required scopes is in OIDC scopes within request""" + def wrapper(f): + @wraps(f) + def decorator(*args, **kwargs): + for scope in scopes: + require_oidc_scope(scope) + return f(*args, **kwargs) + return decorator + return wrapper + + def get_user_info(token): """Query FAS groups from Fedora""" headers = { diff --git a/server/odcs/server/config.py b/server/odcs/server/config.py index fdf6b13..db18089 100644 --- a/server/odcs/server/config.py +++ b/server/odcs/server/config.py @@ -214,6 +214,10 @@ class Config(object): 'type': str, 'default': '', 'desc': 'Messaging topic to which messages are sent.'}, + 'oidc_base_namespace': { + 'type': str, + 'default': 'https://pagure.io/odcs/ ', + 'desc': 'Base namespace of OIDC scopes.'}, } def __init__(self, conf_section_obj): diff --git a/server/odcs/server/models.py b/server/odcs/server/models.py index 59a936d..1cbc588 100644 --- a/server/odcs/server/models.py +++ b/server/odcs/server/models.py @@ -40,7 +40,7 @@ from odcs.server.types import ( COMPOSE_STATES, INVERSE_COMPOSE_STATES, COMPOSE_FLAGS) from sqlalchemy import event, or_ -from flask.ext.sqlalchemy import SignallingSession +from flask_sqlalchemy import SignallingSession event.listen(SignallingSession, 'before_commit', cache_composes_if_state_changed) diff --git a/server/odcs/server/views.py b/server/odcs/server/views.py index 5124b0e..c3b2eb2 100644 --- a/server/odcs/server/views.py +++ b/server/odcs/server/views.py @@ -33,6 +33,7 @@ from odcs.server.types import ( COMPOSE_RESULTS, COMPOSE_FLAGS, COMPOSE_STATES, PUNGI_SOURCE_TYPE_NAMES) from odcs.server.api_utils import pagination_metadata, filter_composes from odcs.server.auth import requires_role, login_required +from odcs.server.auth import require_oidc_scope api_v1 = { @@ -97,6 +98,12 @@ class ODCSAPI(MethodView): if not data: raise ValueError('No JSON POST data submitted') + if conf.auth_backend != "noauth": + if 'id' in data: + require_oidc_scope('renew-compose') + else: + require_oidc_scope('new-compose') + seconds_to_live = conf.seconds_to_live if "seconds-to-live" in data: try: @@ -204,6 +211,8 @@ class ODCSAPI(MethodView): @login_required @requires_role('admins') def delete(self, id): + require_oidc_scope('delete-compose') + compose = Compose.query.filter_by(id=id).first() if compose: # can remove compose that is in state of 'done' or 'failed' diff --git a/server/tests/test_auth.py b/server/tests/test_auth.py index 462005b..85ee25a 100644 --- a/server/tests/test_auth.py +++ b/server/tests/test_auth.py @@ -29,12 +29,14 @@ from mock import patch, Mock import odcs.server.auth +from odcs.server.auth import init_auth from odcs.server.auth import load_krb_user_from_request from odcs.server.auth import load_openidc_user from odcs.server.auth import query_ldap_groups -from odcs.server.auth import init_auth +from odcs.server.auth import require_scopes from odcs.server.errors import Unauthorized -from odcs.server import app, db +from odcs.server.errors import Forbidden +from odcs.server import app, conf, db from odcs.server.models import User from utils import ModelsBaseTest @@ -255,3 +257,30 @@ class TestInitAuth(unittest.TestCase): with patch.object(odcs.server.auth.conf, 'auth_ldap_group_base', ''): self.assertRaises(ValueError, init_auth, self.login_manager, 'kerberos') + + +class TestDecoratorRequireScopes(unittest.TestCase): + """Test decorator require_scopes""" + + @patch.object(conf, 'oidc_base_namespace', new='http://example.com/') + def test_function_is_called(self): + with app.test_request_context(): + flask.g.oidc_scopes = ['http://example.com/renew-compose'] + + mock_func = Mock() + mock_func.__name__ = 'real_function' + decorated_func = require_scopes('renew-compose')(mock_func) + decorated_func(1, 2, 3) + + mock_func.assert_called_once_with(1, 2, 3) + + @patch.object(conf, 'oidc_base_namespace', new='http://example.com/') + def test_function_is_not_called_if_scope_is_not_present(self): + with app.test_request_context(): + flask.g.oidc_scopes = ['http://example.com/new-compose', + 'http://example.com/renew-compose'] + + mock_func = Mock() + mock_func.__name__ = 'real_function' + decorated_func = require_scopes('delete-compose')(mock_func) + self.assertRaises(Forbidden, decorated_func, 1, 2, 3) diff --git a/server/tests/test_views.py b/server/tests/test_views.py index 5eebe93..e088979 100644 --- a/server/tests/test_views.py +++ b/server/tests/test_views.py @@ -32,7 +32,7 @@ from mock import patch, PropertyMock import odcs.server.auth -from odcs.server import db, app, login_manager +from odcs.server import conf, db, app, login_manager from odcs.server.models import Compose, User from odcs.server.types import COMPOSE_STATES, COMPOSE_RESULTS from odcs.server.pungi import PungiSourceType @@ -108,6 +108,16 @@ class ViewBaseTest(ModelsBaseTest): class TestViews(ViewBaseTest): maxDiff = None + def setUp(self): + super(TestViews, self).setUp() + self.oidc_base_namespace = patch.object(conf, 'oidc_base_namespace', + new='http://example.com/') + self.oidc_base_namespace.start() + + def tearDown(self): + self.oidc_base_namespace.stop() + super(TestViews, self).tearDown() + def setup_test_data(self): self.initial_datetime = datetime(year=2016, month=1, day=1, hour=0, minute=0, second=0) @@ -124,6 +134,10 @@ class TestViews(ViewBaseTest): def test_submit_invalid_json(self): with self.test_request_context(user='dev'): + flask.g.oidc_scopes = [ + '{0}{1}'.format(conf.oidc_base_namespace, 'new-compose') + ] + rv = self.client.post('/odcs/1/composes/', data="{") data = json.loads(rv.data.decode('utf8')) @@ -134,6 +148,10 @@ class TestViews(ViewBaseTest): def test_submit_build(self): with self.test_request_context(user='dev'): + flask.g.oidc_scopes = [ + '{0}{1}'.format(conf.oidc_base_namespace, 'new-compose') + ] + rv = self.client.post('/odcs/1/composes/', data=json.dumps( {'source': {'type': 'module', 'source': 'testmodule-master'}})) data = json.loads(rv.data.decode('utf8')) @@ -155,6 +173,10 @@ class TestViews(ViewBaseTest): def test_submit_build_nodeps(self): with self.test_request_context(user='dev'): + flask.g.oidc_scopes = [ + '{0}{1}'.format(conf.oidc_base_namespace, 'new-compose') + ] + rv = self.client.post('/odcs/1/composes/', data=json.dumps( {'source': {'type': 'tag', 'source': 'f26', 'packages': ['ed']}, 'flags': ['no_deps']})) @@ -172,6 +194,10 @@ class TestViews(ViewBaseTest): db.session.commit() with self.test_request_context(user='dev'): + flask.g.oidc_scopes = [ + '{0}{1}'.format(conf.oidc_base_namespace, 'renew-compose') + ] + rv = self.client.post('/odcs/1/composes/', data=json.dumps({'id': 1})) data = json.loads(rv.data.decode('utf8')) @@ -189,6 +215,10 @@ class TestViews(ViewBaseTest): db.session.commit() with self.test_request_context(user='dev'): + flask.g.oidc_scopes = [ + '{0}{1}'.format(conf.oidc_base_namespace, 'renew-compose') + ] + rv = self.client.post('/odcs/1/composes/', data=json.dumps({'id': 1})) data = json.loads(rv.data.decode('utf8')) @@ -202,6 +232,10 @@ class TestViews(ViewBaseTest): def test_submit_build_resurrection_no_removed(self): with self.test_request_context(user='dev'): + flask.g.oidc_scopes = [ + '{0}{1}'.format(conf.oidc_base_namespace, 'renew-compose') + ] + rv = self.client.post('/odcs/1/composes/', data=json.dumps({'id': 1})) data = json.loads(rv.data.decode('utf8')) @@ -209,6 +243,10 @@ class TestViews(ViewBaseTest): def test_submit_build_resurrection_not_found(self): with self.test_request_context(user='dev'): + flask.g.oidc_scopes = [ + '{0}{1}'.format(conf.oidc_base_namespace, 'renew-compose') + ] + rv = self.client.post('/odcs/1/composes/', data=json.dumps({'id': 100})) data = json.loads(rv.data.decode('utf8')) @@ -216,6 +254,10 @@ class TestViews(ViewBaseTest): def test_submit_build_not_allowed_source_type(self): with self.test_request_context(user='dev'): + flask.g.oidc_scopes = [ + '{0}{1}'.format(conf.oidc_base_namespace, 'new-compose') + ] + rv = self.client.post('/odcs/1/composes/', data=json.dumps( {'source': {'type': 'repo', 'source': '/path'}})) data = json.loads(rv.data.decode('utf8')) @@ -226,6 +268,10 @@ class TestViews(ViewBaseTest): def test_submit_build_unknown_source_type(self): with self.test_request_context(user='dev'): + flask.g.oidc_scopes = [ + '{0}{1}'.format(conf.oidc_base_namespace, 'new-compose') + ] + rv = self.client.post('/odcs/1/composes/', data=json.dumps( {'source': {'type': 'unknown', 'source': '/path'}})) data = json.loads(rv.data.decode('utf8')) @@ -286,6 +332,10 @@ class TestViews(ViewBaseTest): self.assertEqual(len(Compose.composes_to_expire()), 0) with self.test_request_context(user='root'): + flask.g.oidc_scopes = [ + '{0}{1}'.format(conf.oidc_base_namespace, 'delete-compose') + ] + resp = self.client.delete("/odcs/1/composes/%s" % c3.id) data = json.loads(resp.data.decode('utf8')) @@ -314,6 +364,10 @@ class TestViews(ViewBaseTest): compose_id = new_c.id with self.test_request_context(user='root'): + flask.g.oidc_scopes = [ + '{0}{1}'.format(conf.oidc_base_namespace, 'delete-compose') + ] + resp = self.client.delete("/odcs/1/composes/%s" % compose_id) data = json.loads(resp.data.decode('utf8')) @@ -325,6 +379,10 @@ class TestViews(ViewBaseTest): def test_delete_non_exist_compose(self): with self.test_request_context(user='root'): + flask.g.oidc_scopes = [ + '{0}{1}'.format(conf.oidc_base_namespace, 'delete-compose') + ] + resp = self.client.delete("/odcs/1/composes/999999") data = json.loads(resp.data.decode('utf8')) @@ -335,6 +393,10 @@ class TestViews(ViewBaseTest): def test_delete_compose_with_non_admin_user(self): with self.test_request_context(user='dev'): + flask.g.oidc_scopes = [ + '{0}{1}'.format(conf.oidc_base_namespace, 'delete-compose') + ] + resp = self.client.delete("/odcs/1/composes/%s" % self.c1.id) data = json.loads(resp.data.decode('utf8')) @@ -345,6 +407,10 @@ class TestViews(ViewBaseTest): def test_can_not_create_compose_with_non_composer_user(self): with self.test_request_context(user='qa'): + flask.g.oidc_scopes = [ + '{0}{1}'.format(conf.oidc_base_namespace, 'new-compose') + ] + resp = self.client.post('/odcs/1/composes/', data=json.dumps( {'source': {'type': 'module', 'source': 'testmodule-master'}})) data = json.loads(resp.data.decode('utf8')) @@ -356,6 +422,10 @@ class TestViews(ViewBaseTest): def test_can_create_compose_with_user_in_configured_groups(self): with self.test_request_context(user='another_user', groups=['composer']): + flask.g.oidc_scopes = [ + '{0}{1}'.format(conf.oidc_base_namespace, 'new-compose') + ] + resp = self.client.post('/odcs/1/composes/', data=json.dumps( {'source': {'type': 'module', 'source': 'testmodule-rawhide'}})) db.session.expire_all() @@ -374,6 +444,10 @@ class TestViews(ViewBaseTest): db.session.commit() with self.test_request_context(user='another_admin', groups=['admin']): + flask.g.oidc_scopes = [ + '{0}{1}'.format(conf.oidc_base_namespace, 'delete-compose') + ] + resp = self.client.delete("/odcs/1/composes/%s" % c3.id) data = json.loads(resp.data.decode('utf8')) @@ -392,6 +466,10 @@ class TestViews(ViewBaseTest): mock_max_seconds_to_live.return_value = 60 * 60 * 24 * 3 with self.test_request_context(user='dev'): + flask.g.oidc_scopes = [ + '{0}{1}'.format(conf.oidc_base_namespace, 'new-compose') + ] + rv = self.client.post('/odcs/1/composes/', data=json.dumps( {'source': {'type': 'module', 'source': 'testmodule-master'}, 'seconds-to-live': 60 * 60 * 12})) data = json.loads(rv.data.decode('utf8')) @@ -410,6 +488,10 @@ class TestViews(ViewBaseTest): mock_max_seconds_to_live.return_value = 60 * 60 * 24 * 3 with self.test_request_context(user='dev'): + flask.g.oidc_scopes = [ + '{0}{1}'.format(conf.oidc_base_namespace, 'new-compose') + ] + rv = self.client.post('/odcs/1/composes/', data=json.dumps( {'source': {'type': 'module', 'source': 'testmodule-master'}, 'seconds-to-live': 60 * 60 * 24 * 7})) data = json.loads(rv.data.decode('utf8')) @@ -427,6 +509,10 @@ class TestViews(ViewBaseTest): mock_max_seconds_to_live.return_value = 60 * 60 * 24 * 3 with self.test_request_context(user='dev'): + flask.g.oidc_scopes = [ + '{0}{1}'.format(conf.oidc_base_namespace, 'new-compose') + ] + rv = self.client.post('/odcs/1/composes/', data=json.dumps( {'source': {'type': 'module', 'source': 'testmodule-master'}})) data = json.loads(rv.data.decode('utf8')) @@ -464,6 +550,16 @@ class TestViews(ViewBaseTest): class TestExtendExpiration(ViewBaseTest): """Test view post to extend expiration""" + def setUp(self): + super(TestExtendExpiration, self).setUp() + self.oidc_base_namespace = patch.object(conf, 'oidc_base_namespace', + new='http://example.com/') + self.oidc_base_namespace.start() + + def tearDown(self): + self.oidc_base_namespace.stop() + super(TestExtendExpiration, self).tearDown() + def setup_test_data(self): self.initial_datetime = datetime(year=2016, month=1, day=1, hour=0, minute=0, second=0) @@ -492,12 +588,16 @@ class TestExtendExpiration(ViewBaseTest): self.c1_id = self.c1.id self.c3_id = self.c3.id + @patch.object(conf, 'oidc_base_namespace', new='http://example.com/') def test_fail_if_extend_non_existing_compose(self): post_data = json.dumps({ 'id': 999, 'seconds-to-live': 600 }) with self.test_request_context(): + flask.g.oidc_scopes = ['http://example.com/new-compose', + 'http://example.com/renew-compose'] + rv = self.client.post('/odcs/1/composes/', data=post_data) data = json.loads(rv.data.decode('utf8')) @@ -512,6 +612,10 @@ class TestExtendExpiration(ViewBaseTest): 'seconds-to-live': 600 }) with self.test_request_context(): + flask.g.oidc_scopes = [ + '{0}{1}'.format(conf.oidc_base_namespace, 'renew-compose') + ] + rv = self.client.post('/odcs/1/composes/', data=post_data) data = json.loads(rv.data.decode('utf8')) @@ -534,6 +638,9 @@ class TestExtendExpiration(ViewBaseTest): }) with self.test_request_context(): + flask.g.oidc_scopes = [ + '{0}{1}'.format(conf.oidc_base_namespace, 'renew-compose') + ] with freeze_time(fake_utcnow): rv = self.client.post('/odcs/1/composes/', data=post_data) data = json.loads(rv.data.decode('utf8'))