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:
parent
b9f885e2a7
commit
b001fa8fa3
@ -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
45
tests/fixtures/zuul-admin-web-oidc.conf
vendored
Normal 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
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
51
zuul/lib/capabilities.py
Normal 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()
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user