From b001fa8fa3cdcced6d1568ec107c2fb50ba8b6b6 Mon Sep 17 00:00:00 2001 From: Matthieu Huin Date: Mon, 13 Jan 2020 18:46:35 +0100 Subject: [PATCH] OIDCAuthenticator: add capabilities, scope option The OIDC Authenticator can be configured to specify scope(s). By default, use scopes "openid profile", the smallest subset of scopes supported by all OpenID Connect Identity Providers. Add a basic capability register for the web service. This is simply meant to expose configuration details that can be public, so that other services (namely zuul web-app) can access them through the REST API. Fix capability 'job_history' by setting it to True if a SQL driver is active. Change-Id: I6ec0338cc0f7c0756c0cb26d6e5b3732c3ca655c --- tests/base.py | 2 +- tests/fixtures/zuul-admin-web-oidc.conf | 45 +++++++++++++ tests/unit/test_web.py | 86 +++++++++++++++++-------- zuul/driver/auth/jwt.py | 17 ++++- zuul/driver/sql/__init__.py | 3 + zuul/lib/auth.py | 18 +++++- zuul/lib/capabilities.py | 51 +++++++++++++++ zuul/model.py | 18 +++--- 8 files changed, 201 insertions(+), 39 deletions(-) create mode 100644 tests/fixtures/zuul-admin-web-oidc.conf create mode 100644 zuul/lib/capabilities.py diff --git a/tests/base.py b/tests/base.py index aa43ab2e7e..2412ceef98 100644 --- a/tests/base.py +++ b/tests/base.py @@ -3179,7 +3179,7 @@ class ZuulWebFixture(fixtures.Fixture): self.authenticators = zuul.lib.auth.AuthenticatorRegistry() self.authenticators.configure(config) if info is None: - self.info = zuul.model.WebInfo() + self.info = zuul.model.WebInfo.fromConfig(config) else: self.info = info self.zk_hosts = zk_hosts diff --git a/tests/fixtures/zuul-admin-web-oidc.conf b/tests/fixtures/zuul-admin-web-oidc.conf new file mode 100644 index 0000000000..33e5136b53 --- /dev/null +++ b/tests/fixtures/zuul-admin-web-oidc.conf @@ -0,0 +1,45 @@ +[gearman] +server=127.0.0.1 + +[scheduler] +tenant_config=main.yaml +relative_priority=true + +[merger] +git_dir=/tmp/zuul-test/merger-git +git_user_email=zuul@example.com +git_user_name=zuul + +[executor] +git_dir=/tmp/zuul-test/executor-git + +[connection gerrit] +driver=gerrit +server=review.example.com +user=jenkins +sshkey=fake_id_rsa_path + +[web] +static_cache_expiry=1200 + +[auth zuul_operator] +driver=HS256 +allow_authz_override=true +realm=zuul.example.com +client_id=zuul.example.com +issuer_id=zuul_operator +secret=NoDanaOnlyZuul + +[auth myOIDC1] +driver=OpenIDConnect +realm=myOIDC1 +default=true +client_id=zuul +issuer_id=http://oidc1 + +[auth myOIDC2] +driver=OpenIDConnect +realm=myOIDC2 +client_id=zuul +issuer_id=http://oidc2 +scope=openid profile email special-scope diff --git a/tests/unit/test_web.py b/tests/unit/test_web.py index 200a50e399..c0695fa7b6 100644 --- a/tests/unit/test_web.py +++ b/tests/unit/test_web.py @@ -969,7 +969,9 @@ class TestWebSecrets(BaseTestWeb): self.assertEqual([secret], run[0]['secrets']) -class TestInfo(BaseTestWeb): +class TestInfo(ZuulDBTestCase, BaseTestWeb): + + config_file = 'zuul-sql-driver.conf' def setUp(self): super(TestInfo, self).setUp() @@ -979,40 +981,70 @@ class TestInfo(BaseTestWeb): statsd_config = self.config_ini_data.get('statsd', {}) self.stats_prefix = statsd_config.get('prefix') + def _expected_info(self): + return { + "info": { + "capabilities": { + "job_history": True, + "auth": { + "realms": {}, + "default_realm": None + } + }, + "stats": { + "url": self.stats_url, + "prefix": self.stats_prefix, + "type": "graphite", + }, + "websocket_url": self.websocket_url, + } + } + def test_info(self): info = self.get_url("api/info").json() self.assertEqual( - info, { - "info": { - "capabilities": { - "job_history": False - }, - "stats": { - "url": self.stats_url, - "prefix": self.stats_prefix, - "type": "graphite", - }, - "websocket_url": self.websocket_url, - } - }) + info, self._expected_info()) def test_tenant_info(self): info = self.get_url("api/tenant/tenant-one/info").json() + expected_info = self._expected_info() + expected_info['info']['tenant'] = 'tenant-one' self.assertEqual( - info, { - "info": { - "tenant": "tenant-one", - "capabilities": { - "job_history": False - }, - "stats": { - "url": self.stats_url, - "prefix": self.stats_prefix, - "type": "graphite", - }, - "websocket_url": self.websocket_url, + info, expected_info) + + +class TestWebCapabilitiesInfo(TestInfo): + + config_file = 'zuul-admin-web-oidc.conf' + + def _expected_info(self): + info = super(TestWebCapabilitiesInfo, self)._expected_info() + info['info']['capabilities']['auth'] = { + 'realms': { + 'myOIDC1': { + 'authority': 'http://oidc1', + 'client_id': 'zuul', + 'type': 'JWT', + 'scope': 'openid profile', + 'driver': 'OpenIDConnect', + }, + 'myOIDC2': { + 'authority': 'http://oidc2', + 'client_id': 'zuul', + 'type': 'JWT', + 'scope': 'openid profile email special-scope', + 'driver': 'OpenIDConnect', + }, + 'zuul.example.com': { + 'authority': 'zuul_operator', + 'client_id': 'zuul.example.com', + 'type': 'JWT', + 'driver': 'HS256', } - }) + }, + 'default_realm': 'myOIDC1' + } + return info class TestTenantInfoConfigBroken(BaseTestWeb): diff --git a/zuul/driver/auth/jwt.py b/zuul/driver/auth/jwt.py index f8fb5af016..5df48971b6 100644 --- a/zuul/driver/auth/jwt.py +++ b/zuul/driver/auth/jwt.py @@ -54,6 +54,16 @@ class JWTAuthenticator(AuthenticatorInterface): except ValueError: raise ValueError('"max_validity_time" must be a numerical value') + def get_capabilities(self): + return { + self.realm: { + 'authority': self.issuer_id, + 'client_id': self.audience, + 'type': 'JWT', + 'driver': getattr(self, 'name', 'N/A'), + } + } + def _decode(self, rawToken): raise NotImplementedError @@ -173,7 +183,7 @@ class OpenIDConnectAuthenticator(JWTAuthenticator): described in https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig""" # noqa - # default algorithm, TOFO: should this be a config param? + # default algorithm, TODO: should this be a config param? algorithm = 'RS256' name = 'OpenIDConnect' @@ -234,6 +244,11 @@ class OpenIDConnectAuthenticator(JWTAuthenticator): msg='There was an error while fetching ' 'OpenID configuration, check logs for details') + def get_capabilities(self): + d = super(OpenIDConnectAuthenticator, self).get_capabilities() + d[self.realm]['scope'] = self.scope + return d + def _decode(self, rawToken): unverified_headers = jwt.get_unverified_header(rawToken) key_id = unverified_headers.get('kid', None) diff --git a/zuul/driver/sql/__init__.py b/zuul/driver/sql/__init__.py index 3c91b52248..955a58a99e 100644 --- a/zuul/driver/sql/__init__.py +++ b/zuul/driver/sql/__init__.py @@ -15,6 +15,7 @@ from zuul.driver import Driver, ConnectionInterface, ReporterInterface from zuul.driver.sql import sqlconnection from zuul.driver.sql import sqlreporter +from zuul.lib import capabilities as cpb class SQLDriver(Driver, ConnectionInterface, ReporterInterface): @@ -22,6 +23,8 @@ class SQLDriver(Driver, ConnectionInterface, ReporterInterface): def __init__(self): self.tenant_connections = {} + cpb.capabilities_registry.register_capabilities( + 'job_history', True) def reconfigure(self, tenant): # NOTE(corvus): This stores the connection of the first diff --git a/zuul/lib/auth.py b/zuul/lib/auth.py index 3e96fd140f..effb4bc5ea 100644 --- a/zuul/lib/auth.py +++ b/zuul/lib/auth.py @@ -19,6 +19,7 @@ import jwt from zuul import exceptions import zuul.driver.auth.jwt as auth_jwt +import zuul.lib.capabilities as cpb """AuthN/AuthZ related library, used by zuul-web.""" @@ -34,6 +35,8 @@ class AuthenticatorRegistry(object): self.default_realm = None def configure(self, config): + capabilities = {'realms': {}} + first_realm = None for section_name in config.sections(): auth_match = re.match(r'^auth ([\'\"]?)(.*)(\1)$', section_name, re.I) @@ -54,10 +57,21 @@ class AuthenticatorRegistry(object): auth_name)) # TODO catch config specific errors (missing fields) self.authenticators[auth_name] = driver(**auth_config) + caps = self.authenticators[auth_name].get_capabilities() + # TODO there should be a bijective relationship between realms and + # authenticators. This should be enforced at config parsing. + capabilities['realms'].update(caps) + if first_realm is None: + first_realm = auth_config.get('realm', None) if auth_config.get('default', 'false').lower() == 'true': self.default_realm = auth_config.get('realm', 'DEFAULT') - if self.default_realm is None: - self.default_realm = 'DEFAULT' + # do we have any auth defined ? + if len(capabilities['realms'].keys()) > 0: + if self.default_realm is None: + # pick arbitrarily the first defined realm + self.default_realm = first_realm + capabilities['default_realm'] = self.default_realm + cpb.capabilities_registry.register_capabilities('auth', capabilities) def authenticate(self, rawToken): unverified = jwt.decode(rawToken, verify=False) diff --git a/zuul/lib/capabilities.py b/zuul/lib/capabilities.py new file mode 100644 index 0000000000..96c10905b5 --- /dev/null +++ b/zuul/lib/capabilities.py @@ -0,0 +1,51 @@ +# Copyright 2020 OpenStack Foundation +# Copyright 2020 Red Hat, Inc. +# +# 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. + + +import logging + + +"""Simple Capabilities registry, to be used by Zuul Web.""" + + +class CapabilitiesRegistry(object): + + log = logging.getLogger("Zuul.CapabilitiesRegistry") + + def __init__(self): + self.capabilities = {} + self.set_default_capabilities() + + def set_default_capabilities(self): + self.capabilities['job_history'] = False + self.capabilities['auth'] = { + 'realms': {}, + 'default_realm': None, + } + + def register_capabilities(self, capability_name, capabilities): + is_set = self.capabilities.setdefault(capability_name, None) + if is_set is None: + action = 'registered' + else: + action = 'updated' + if isinstance(is_set, dict) and isinstance(capabilities, dict): + self.capabilities[capability_name].update(capabilities) + else: + self.capabilities[capability_name] = capabilities + self.log.debug('Capabilities "%s" %s' % (capability_name, action)) + + +capabilities_registry = CapabilitiesRegistry() diff --git a/zuul/model.py b/zuul/model.py index a272371fc5..04961dde1a 100644 --- a/zuul/model.py +++ b/zuul/model.py @@ -33,6 +33,7 @@ from zuul import change_matcher from zuul.lib.config import get_default from zuul.lib.artifacts import get_artifacts_from_result_data from zuul.lib.logutil import get_annotated_logger +from zuul.lib.capabilities import capabilities_registry MERGER_MERGE = 1 # "git merge" MERGER_MERGE_RESOLVE = 2 # "git merge -s resolve" @@ -4678,23 +4679,21 @@ class Capabilities(object): facilitate consumers knowing if functionality is available or not, keep track of distinct capability flags. """ - def __init__(self, job_history=False): - self.job_history = job_history + def __init__(self, **kwargs): + self._capabilities = kwargs def __repr__(self): return '' % (id(self), self._renderFlags()) def _renderFlags(self): - d = self.toDict() - return " ".join(['{k}={v}'.format(k=k, v=v) for (k, v) in d.items()]) + return " ".join(['{k}={v}'.format(k=k, v=repr(v)) + for (k, v) in self._capabilities.items()]) def copy(self): return Capabilities(**self.toDict()) def toDict(self): - d = dict() - d['job_history'] = self.job_history - return d + return self._capabilities class WebInfo(object): @@ -4703,7 +4702,10 @@ class WebInfo(object): def __init__(self, websocket_url=None, capabilities=None, stats_url=None, stats_prefix=None, stats_type=None): - self.capabilities = capabilities or Capabilities() + _caps = capabilities + if _caps is None: + _caps = Capabilities(**capabilities_registry.capabilities) + self.capabilities = _caps self.stats_prefix = stats_prefix self.stats_type = stats_type self.stats_url = stats_url