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
This commit is contained in:
Matthieu Huin 2020-01-13 18:46:35 +01:00
parent b9f885e2a7
commit b001fa8fa3
8 changed files with 201 additions and 39 deletions

View File

@ -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

45
tests/fixtures/zuul-admin-web-oidc.conf vendored Normal file
View File

@ -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

View File

@ -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):

View File

@ -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)

View File

@ -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

View File

@ -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)

51
zuul/lib/capabilities.py Normal file
View File

@ -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()

View File

@ -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 '<Capabilities 0x%x %s>' % (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