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