Zuul-web: Add authentication-realm attribute to tenants

With this attribute, an operator can map a tenant to an authenticator.
This is the authenticator that will be used by the Web UI on a
whitelabeled setup, or when scoping the UI to the tenant.

Change-Id: Ifd3d629ff3ed0c3c902433bdf808d457e755c528
This commit is contained in:
Matthieu Huin 2020-06-15 14:59:39 +02:00
parent 1d05b0a95f
commit 0cfd75d7ef
6 changed files with 96 additions and 3 deletions

View File

@ -340,6 +340,22 @@ configuration. Some examples of tenant definitions are:
:ref:`tenant-scoped-rest-api`.
.. attr:: authentication-realm
Each authenticator defined in Zuul's configuration is associated to a realm.
When authenticating through Zuul's Web User Interface under this tenant, the
Web UI will redirect the user to this realm's authentication service. The
authenticator must be of the type ``OpenIDConnect``.
.. note::
Defining a default realm for a tenant will not invalidate access tokens
issued from other configured realms, especially if they match the tenant's
admin rules. This is intended, so that an operator can for example issue
an overriding access token manually. If this is an issue, it is advised
to add finer filtering to admin rules, for example filtering by the ``iss``
claim (generally equal to the issuer ID).
.. _admin_rule_definition:
Access Rule

View File

@ -14,6 +14,7 @@
- org/project
- tenant:
name: tenant-one
authentication-realm: myOIDC1
admin-rules:
- tenant-admin
source:
@ -24,6 +25,7 @@
- org/project1
- tenant:
name: tenant-two
authentication-realm: myOIDC2
admin-rules:
- tenant-admin
source:

View File

@ -1270,6 +1270,35 @@ class TestWebCapabilitiesInfo(TestInfo):
return info
class TestTenantAuthRealmInfo(TestWebCapabilitiesInfo):
tenant_config_file = 'config/authorization/rules-templating/main.yaml'
def test_tenant_info(self):
expected_info = self._expected_info()
info = self.get_url("api/tenant/tenant-zero/info").json()
expected_info['info']['tenant'] = 'tenant-zero'
expected_info['info']['capabilities']['auth']['default_realm'] =\
'myOIDC1'
self.assertEqual(expected_info,
info,
info)
info = self.get_url("api/tenant/tenant-one/info").json()
expected_info['info']['tenant'] = 'tenant-one'
expected_info['info']['capabilities']['auth']['default_realm'] =\
'myOIDC1'
self.assertEqual(expected_info,
info,
info)
info = self.get_url("api/tenant/tenant-two/info").json()
expected_info['info']['tenant'] = 'tenant-two'
expected_info['info']['capabilities']['auth']['default_realm'] =\
'myOIDC2'
self.assertEqual(expected_info,
info,
info)
class TestTenantInfoConfigBroken(BaseTestWeb):
tenant_config_file = 'config/broken/main.yaml'

View File

@ -1510,6 +1510,7 @@ class TenantParser(object):
'default-parent': str,
'default-ansible-version': vs.Any(str, float),
'admin-rules': to_list(str),
'authentication-realm': str,
# TODO: Ignored, allowed for backwards compat, remove for v5.
'report-build-page': bool,
'web-root': str,
@ -1530,6 +1531,8 @@ class TenantParser(object):
conf['exclude-unprotected-branches']
if conf.get('admin-rules') is not None:
tenant.authorization_rules = conf['admin-rules']
if conf.get('authentication-realm') is not None:
tenant.default_auth_realm = conf['authentication-realm']
tenant.web_root = conf.get('web-root', self.scheduler.globals.web_root)
if tenant.web_root and not tenant.web_root.endswith('/'):
tenant.web_root += '/'

View File

@ -6058,6 +6058,7 @@ class Tenant(object):
self.default_ansible_version = None
self.authorization_rules = []
self.default_auth_realm = None
@property
def all_projects(self):
@ -6348,20 +6349,20 @@ class Capabilities(object):
or not, keep track of distinct capability flags.
"""
def __init__(self, **kwargs):
self._capabilities = kwargs
self.capabilities = kwargs
def __repr__(self):
return '<Capabilities 0x%x %s>' % (id(self), self._renderFlags())
def _renderFlags(self):
return " ".join(['{k}={v}'.format(k=k, v=repr(v))
for (k, v) in self._capabilities.items()])
for (k, v) in self.capabilities.items()])
def copy(self):
return Capabilities(**self.toDict())
def toDict(self):
return self._capabilities
return self.capabilities
class WebInfo(object):

View File

@ -42,6 +42,7 @@ from zuul.zk.components import ComponentRegistry, WebComponent
from zuul.zk.executor import ExecutorApi
from zuul.zk.nodepool import ZooKeeperNodepool
from zuul.zk.system import ZuulSystem
from zuul.zk.config_cache import SystemConfigCache
from zuul.lib.auth import AuthenticatorRegistry
from zuul.lib.config import get_default
@ -646,6 +647,15 @@ class ZuulWebAPI(object):
def tenant_info(self, tenant):
info = self.zuulweb.info.copy()
info.tenant = tenant
tenant_config = self.zuulweb.unparsed_abide.tenants.get(tenant)
if tenant_config is not None:
# TODO: should we return 404 if tenant not found?
tenant_auth_realm = tenant_config.get('authentication-realm')
if tenant_auth_realm is not None:
if (info.capabilities is not None and
info.capabilities.toDict().get('auth') is not None):
info.capabilities.capabilities['auth']['default_realm'] =\
tenant_auth_realm
return self._handleInfo(info)
def _handleInfo(self, info):
@ -694,6 +704,8 @@ class ZuulWebAPI(object):
return {'description': e.error_description,
'error': e.error,
'realm': e.realm}
resp = cherrypy.response
resp.headers['Access-Control-Allow-Origin'] = '*'
return {'zuul': {'admin': admin_tenants}, }
@cherrypy.expose
@ -716,6 +728,8 @@ class ZuulWebAPI(object):
return {'description': e.error_description,
'error': e.error,
'realm': e.realm}
resp = cherrypy.response
resp.headers['Access-Control-Allow-Origin'] = '*'
return {'zuul': {'admin': tenant in admin_tenants,
'scope': [tenant, ]}, }
@ -1327,6 +1341,14 @@ class ZuulWeb(object):
self.component_registry = ComponentRegistry(self.zk_client)
self.system_config_cache_wake_event = threading.Event()
self.system_config_cache = SystemConfigCache(
self.zk_client,
self.system_config_cache_wake_event.set)
# Fetch an initial value so we we have something to serve
# requests before the initial callback fires.
self.unparsed_abide, _ = self.system_config_cache.get()
self.connections = connections
self.authenticators = authenticators
self.stream_manager = StreamManager()
@ -1480,6 +1502,16 @@ class ZuulWeb(object):
def port(self):
return cherrypy.server.bound_addr[1]
def updateSystemConfigCache(self):
while self._system_config_running:
try:
self.system_config_cache_wake_event.wait()
if not self._system_config_running:
return
self.unparsed_abide, _ = self.system_config_cache.get()
except Exception:
self.log.exception("Exception while processing command")
def start(self):
self.log.debug("ZuulWeb starting")
self.stream_manager.start()
@ -1496,6 +1528,13 @@ class ZuulWeb(object):
self.command_thread.start()
self.component_info.state = self.component_info.RUNNING
self.system_config_thread = threading.Thread(
target=self.updateSystemConfigCache,
name='system_config')
self._system_config_running = True
self.system_config_thread.daemon = True
self.system_config_thread.start()
def stop(self):
self.log.debug("ZuulWeb stopping")
self.component_info.state = self.component_info.STOPPED
@ -1507,6 +1546,9 @@ class ZuulWeb(object):
cherrypy.server.httpserver = None
self.wsplugin.unsubscribe()
self.stream_manager.stop()
self._system_config_running = False
self.system_config_cache_wake_event.set()
self.system_config_thread.join()
self.zk_client.disconnect()
self.stop_repl()
self._command_running = False