Add api-root tenant config object

In order to allow for authenticated read-only access to zuul-web,
we need to be able to control the authz of the API root.  Currently,
we can only specify auth info for tenants.  But if we want to control
access to the tenant list itself, we need to be able to specify auth
rules.

To that end, add a new "api-root" tenant configuration object which,
like tenants themselves, will allow attaching authz rules to it.

We don't have any admin-level API endpoints at the root, so this change
does not add "admin-rules" to the api-root object, but if we do develop
those in the future, it could be added.

A later change will add "access-rules" to the api-root in order to
allow configuration of authenticated read-only access.

This change does add an "authentication-realm" to the api-root object
since that already exists for tenants and it will make sense to have
that in the future as well.  Currently the /info endpoint uses the
system default authentication realm, but this will override it if
set.

In general, the approach here is that the "api-root" object should
mirror the "tenant" object for all attributes that make sense.

Change-Id: I4efc6fbd64f266e7a10e101db3350837adce371f
This commit is contained in:
James E. Blair 2022-09-24 16:16:01 -07:00
parent eb330c4605
commit 8c47d9ce4e
14 changed files with 260 additions and 7 deletions

View File

@ -417,12 +417,12 @@ configuration. Some examples of tenant definitions are:
.. 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).
Defining a default realm for a tenant will not invalidate
access tokens issued from other configured realms. This is
intended so that an operator can 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).
.. attr:: semaphores
@ -650,3 +650,46 @@ and **tenant-two**:
'iat': 1234556780,
'groups': ['tenant-one', 'tenant-two'],
}
API Root
--------
Most actions in zuul-web, zuul-client, and the REST API are understood
to be within the context of a specific tenant and therefore the
authorization rules specified by that tenant apply. When zuul-web is
deployed in a multi-tenant scenario (the default), there are a few
extra actions or API methods which are outside of the context of an
individual tenant (for example, listing the tenants or observing the
state of Zuul system components). To control access to these methods,
an `api-root` object can be used.
At most one `api-root` object may appear in the tenant configuration
file. If more than one appears, it is an error. If there is no
`api-root` object, then anonymous read-only access to the tenant list
and other root-level API methods is assumed.
The ``/api/info`` endpoint is never protected by Zuul since it
supplies the authentication information needed by the web UI.
API root access is not a pre-requisite to access tenant-specific URLs.
.. attr:: api-root
The following attributes are supported:
.. attr:: authentication-realm
Each authenticator defined in Zuul's configuration is associated
to a realm. When authenticating through Zuul's Web User
Interface at the multi-tenant root, 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 the root API will not invalidate
access tokens issued from other configured realms. This is
intended so that an operator can 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).

View File

@ -0,0 +1,2 @@
- hosts: all
tasks: []

View File

@ -0,0 +1,38 @@
- pipeline:
name: check
manager: independent
trigger:
gerrit:
- event: patchset-created
success:
gerrit:
Verified: 1
failure:
gerrit:
Verified: -1
- pipeline:
name: gate
manager: dependent
success-message: Build succeeded (gate).
trigger:
gerrit:
- event: comment-added
approval:
- Approved: 1
success:
gerrit:
Verified: 2
submit: true
failure:
gerrit:
Verified: -2
start:
gerrit:
Verified: 0
precedence: high
- job:
name: base
parent: null
run: playbooks/run.yaml

View File

@ -0,0 +1 @@
test

View File

@ -0,0 +1,10 @@
- job:
name: project-testjob
- project:
check:
jobs:
- project-testjob
gate:
jobs:
- project-testjob

View File

@ -0,0 +1 @@
test

View File

@ -0,0 +1,10 @@
- job:
name: project1-testjob
- project:
check:
jobs:
- project1-testjob
gate:
jobs:
- project1-testjob

View File

@ -0,0 +1 @@
test

View File

@ -0,0 +1,10 @@
- job:
name: project2-testjob
- project:
check:
jobs:
- project2-testjob
gate:
jobs:
- project2-testjob

View File

@ -0,0 +1,38 @@
- authorization-rule:
name: tenant-admin
conditions:
- groups: "{tenant.name}"
- api-root:
authentication-realm: myOIDC2
- tenant:
name: tenant-zero
admin-rules:
- tenant-admin
source:
gerrit:
config-projects:
- common-config
untrusted-projects:
- org/project
- tenant:
name: tenant-one
authentication-realm: myOIDC1
admin-rules:
- tenant-admin
source:
gerrit:
config-projects:
- common-config
untrusted-projects:
- org/project1
- tenant:
name: tenant-two
authentication-realm: myOIDC2
admin-rules:
- tenant-admin
source:
gerrit:
config-projects:
- common-config
untrusted-projects:
- org/project2

View File

@ -1671,6 +1671,43 @@ class TestTenantAuthRealmInfo(TestWebCapabilitiesInfo):
info)
class TestRootAuth(TestWebCapabilitiesInfo):
tenant_config_file = 'config/authorization/api-root/main.yaml'
def test_info(self):
# This overrides the test in TestInfo
expected_info = self._expected_info()
info = self.get_url("api/info").json()
expected_info['info']['capabilities']['auth']['default_realm'] =\
'myOIDC2'
self.assertEqual(expected_info, info)
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

@ -1512,6 +1512,24 @@ class GlobalSemaphoreParser(object):
return semaphore
class ApiRootParser(object):
def __init__(self):
self.log = logging.getLogger("zuul.ApiRootParser")
self.schema = self.getSchema()
def getSchema(self):
api_root = {
'authentication-realm': str
}
return vs.Schema(api_root)
def fromYaml(self, conf):
self.schema(conf)
api_root = model.ApiRoot(conf.get('authentication-realm'))
api_root.freeze()
return api_root
class ParseContext(object):
"""Hold information about a particular run of the parser"""
@ -2536,6 +2554,7 @@ class ConfigLoader(object):
zuul_globals, statsd)
self.authz_rule_parser = AuthorizationRuleParser()
self.global_semaphore_parser = GlobalSemaphoreParser()
self.api_root_parser = ApiRootParser()
def expandConfigPath(self, config_path):
if config_path:
@ -2599,6 +2618,13 @@ class ConfigLoader(object):
abide.semaphores[semaphore.name] = semaphore
def loadTPCs(self, abide, unparsed_abide, tenants=None):
# Load the global api root too
if unparsed_abide.api_roots:
api_root_conf = unparsed_abide.api_roots[0]
else:
api_root_conf = {}
abide.api_root = self.api_root_parser.fromYaml(api_root_conf)
if tenants:
tenants_to_load = {t: unparsed_abide.tenants[t] for t in tenants
if t in unparsed_abide.tenants}

View File

@ -1248,6 +1248,23 @@ class Project(object):
return d
class ApiRoot(ConfigObject):
def __init__(self, default_auth_realm=None):
super().__init__()
self.default_auth_realm = default_auth_realm
def __ne__(self, other):
return not self.__eq__(other)
def __eq__(self, other):
if not isinstance(other, ApiRoot):
return False
return (self.default_auth_realm == other.default_auth_realm)
def __repr__(self):
return f'<ApiRoot realm={self.default_auth_realm}>'
class Node(ConfigObject):
"""A single node for use by a job.
@ -7142,12 +7159,16 @@ class UnparsedAbideConfig(object):
self.tenants = {}
self.authz_rules = []
self.semaphores = []
self.api_roots = []
def extend(self, conf):
if isinstance(conf, UnparsedAbideConfig):
self.tenants.update(conf.tenants)
self.authz_rules.extend(conf.authz_rules)
self.semaphores.extend(conf.semaphores)
self.api_roots.extend(conf.api_roots)
if len(self.api_roots) > 1:
raise Exception("More than one api-root object")
return
if not isinstance(conf, list):
@ -7171,6 +7192,10 @@ class UnparsedAbideConfig(object):
self.authz_rules.append(value)
elif key == 'global-semaphore':
self.semaphores.append(value)
elif key == 'api-root':
if self.api_roots:
raise Exception("More than one api-root object")
self.api_roots.append(value)
else:
raise ConfigItemUnknownError(item)
@ -7179,6 +7204,7 @@ class UnparsedAbideConfig(object):
"uuid": self.uuid,
"tenants": self.tenants,
"semaphores": self.semaphores,
"api_roots": self.api_roots,
}
if (COMPONENT_REGISTRY.model_api < 10):
d["admin_rules"] = self.authz_rules
@ -7196,6 +7222,7 @@ class UnparsedAbideConfig(object):
data.get('admin_rules',
[]))
unparsed_abide.semaphores = data.get("semaphores", [])
unparsed_abide.api_roots = data.get("api_roots", [])
return unparsed_abide
@ -8184,6 +8211,7 @@ class Abide(object):
self.untrusted_tpcs = defaultdict(lambda: defaultdict(list))
# project -> branch -> UnparsedBranchCache
self.unparsed_project_branch_cache = {}
self.api_root = None
def addConfigTPC(self, tenant_name, tpc):
self.config_tpcs[tenant_name][tpc.project.name].append(tpc)

View File

@ -802,7 +802,15 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
def info(self):
return self._handleInfo(self.zuulweb.info)
info = self.zuulweb.info.copy()
root_realm = self.zuulweb.abide.api_root.default_auth_realm
if root_realm:
if (info.capabilities is not None and
info.capabilities.toDict().get('auth') is not None):
info.capabilities.capabilities['auth']['default_realm'] =\
root_realm
return self._handleInfo(info)
@cherrypy.expose
@cherrypy.tools.save_params()