Add access-rules configuration and documentation

This allows configuration of read-only access rules, and corresponding
documentation.  It wraps every API method in an auth check (other than
info endpoints).

It exposes information in the info endpoints that the web UI can use
to decide whether it should send authentication information for all
requests.  A later change will update the web UI to use that.

Change-Id: I3985c3d0b9f831fd004b2bb010ab621c00486e05
This commit is contained in:
James E. Blair 2022-09-29 13:47:17 -07:00
parent c2f2891bd3
commit c22f2c98e0
11 changed files with 520 additions and 155 deletions

View File

@ -2,26 +2,32 @@
.. _authentication:
Authenticated Actions
=====================
Authenticated Access
====================
Users can perform some privileged actions at the tenant level through protected
endpoints of the REST API, if these endpoints are activated.
Access to Zuul's REST API and web interface can optionally be
restricted. By default, anonymous read access to any tenant is
permitted. Optionally, some administrative actions may also be
enabled and restricted to authorized users. Additionally, individual
tenants or the entire system may have read-level access restricted
to authorized users.
The supported actions are **autohold**, **enqueue/enqueue-ref**,
**dequeue/dequeue-ref** and **promote**. These are similar to the ones available
through Zuul's CLI.
The supported administrative actions are **autohold**,
**enqueue/enqueue-ref**, **dequeue/dequeue-ref** and
**promote**. These are similar to the ones available through
Zuul's CLI.
The protected endpoints require a bearer token, passed to Zuul Web Server as the
**Authorization** header of the request. The token and this workflow follow the
JWT standard as established in this `RFC <https://tools.ietf.org/html/rfc7519>`_.
The protected endpoints require a bearer token, passed to Zuul Web
Server as the **Authorization** header of the request. The token and
this workflow follow the JWT standard as established in this `RFC
<https://tools.ietf.org/html/rfc7519>`_.
Important Security Considerations
---------------------------------
Anybody with a valid token can perform privileged actions exposed
through the REST API. Furthermore revoking tokens, especially when manually
issued, is not trivial.
Anybody with a valid administrative token can perform privileged
actions exposed through the REST API. Furthermore revoking tokens,
especially when manually issued, is not trivial.
As a mitigation, tokens should be generated with a short time to
live, like 10 minutes or less. If the token contains authorization Information
@ -38,10 +44,12 @@ and tokens should be handed over with discernment.
Configuration
-------------
.. important:: In order to use admin commands in the zuul command line interface, at least one HS256 authenticator should be configured.
.. important:: In order to use restricted commands in the zuul command
line interface, at least one HS256 authenticator should
be configured.
To enable tenant-scoped access to privileged actions, see the Zuul Web Server
component's section.
To enable tenant-scoped access to privileged actions or restrict
read-level access, see the Zuul Web Server component's section.
To set access rules for a tenant, see :ref:`the documentation about tenant
definition <authz_rule_definition>`.

View File

@ -403,7 +403,23 @@ configuration. Some examples of tenant definitions are:
web interface.
At least one rule in the list must match for the user to be allowed to
execute privileged actions.
execute privileged actions. A matching rule will also allow the user
access to the tenant in general (i.e., the rule does not need to be
duplicated in `access-rules`).
More information on tenant-scoped actions can be found in
:ref:`authentication`.
.. attr:: access-rules
A list of authorization rules to be checked in order to grant
read access to the tenant through Zuul's REST API and web
interface.
If no rules are listed, then anonymous access to the tenant is
permitted. If any rules are present then at least one rule in
the list must match for the user to be allowed to access the
tenant.
More information on tenant-scoped actions can be found in
:ref:`authentication`.
@ -693,3 +709,17 @@ API root access is not a pre-requisite to access tenant-specific URLs.
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:: access-rules
A list of authorization rules to be checked in order to grant
read access to the top-level (i.e., non-tenant-specific) portion
of Zuul's REST API and web interface.
If no rules are listed, then anonymous access to top-level
methods is permitted. If any rules are present then at at least
one rule in the list must match for the user to be allowed
access.
More information on tenant-scoped actions can be found in
:ref:`authentication`.

View File

@ -0,0 +1,7 @@
---
features:
- |
Read-level access to tenants or the tenant list may now be
restricted to authorized users using the
:attr:`tenant.access-rules` and :attr:`api-root.access-rules`
attributes.

View File

@ -0,0 +1 @@
---

View File

@ -0,0 +1,61 @@
- pipeline:
name: check
manager: independent
trigger:
gerrit:
- event: patchset-created
- event: comment-added
comment: '^(Patch Set [0-9]+:\n\n)?(?i:recheck)$'
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
- pipeline:
name: post
manager: independent
trigger:
gerrit:
- event: ref-updated
ref: ^(?!refs/).*$
precedence: low
- job:
name: base
parent: null
run: playbooks/run.yaml
- job:
name: testjob
- project:
name: org/project
check:
jobs:
- testjob
gate:
jobs:
- testjob

View File

@ -0,0 +1 @@
test

View File

@ -0,0 +1,17 @@
- authorization-rule:
name: user-rule
conditions:
- groups: users
- api-root:
access-rules: user-rule
- tenant:
name: tenant-one
access-rules: user-rule
source:
gerrit:
config-projects:
- common-config
untrusted-projects:
- org/project

View File

@ -1581,7 +1581,8 @@ class TestInfo(BaseTestWeb):
"job_history": True,
"auth": {
"realms": {},
"default_realm": None
"default_realm": None,
"read_protected": False,
}
},
"stats": {
@ -1637,7 +1638,8 @@ class TestWebCapabilitiesInfo(TestInfo):
'driver': 'HS256',
}
},
'default_realm': 'myOIDC1'
'default_realm': 'myOIDC1',
'read_protected': False,
}
return info
@ -3538,3 +3540,84 @@ class TestWebUnprotectedBranches(BaseWithWeb):
config_errors = self.get_url(
"api/tenant/tenant-one/config-errors").json()
self.assertEqual(len(config_errors), 0)
class TestWebApiAccessRules(BaseTestWeb):
# Test read-level access restrictions
config_file = 'zuul-admin-web.conf'
tenant_config_file = 'config/access-rules/main.yaml'
routes = [
'/api/connections',
'/api/components',
'/api/tenants',
'/api/tenant/{tenant}/status',
'/api/tenant/{tenant}/status/change/{change}',
'/api/tenant/{tenant}/jobs',
'/api/tenant/{tenant}/job/{job_name}',
'/api/tenant/{tenant}/projects',
'/api/tenant/{tenant}/project/{project}',
('/api/tenant/{tenant}/pipeline/{pipeline}/'
'project/{project}/branch/{branch}/freeze-jobs'),
'/api/tenant/{tenant}/pipelines',
'/api/tenant/{tenant}/semaphores',
'/api/tenant/{tenant}/labels',
'/api/tenant/{tenant}/nodes',
'/api/tenant/{tenant}/key/{project}.pub',
'/api/tenant/{tenant}/project-ssh-key/{project}.pub',
'/api/tenant/{tenant}/console-stream',
'/api/tenant/{tenant}/badge',
'/api/tenant/{tenant}/builds',
'/api/tenant/{tenant}/build/{uuid}',
'/api/tenant/{tenant}/buildsets',
'/api/tenant/{tenant}/buildset/{uuid}',
'/api/tenant/{tenant}/config-errors',
'/api/tenant/{tenant}/authorizations',
'/api/tenant/{tenant}/project/{project}/autohold',
'/api/tenant/{tenant}/autohold',
'/api/tenant/{tenant}/autohold/{request_id}',
'/api/tenant/{tenant}/autohold/{request_id}',
'/api/tenant/{tenant}/project/{project}/enqueue',
'/api/tenant/{tenant}/project/{project}/dequeue',
'/api/tenant/{tenant}/promote',
]
info_routes = [
'/api/info',
'/api/tenant/{tenant}/info',
]
def test_read_routes_no_token(self):
for route in self.routes:
url = route.format(tenant='tenant-one',
project='org/project',
change='1,1',
job_name='testjob',
pipeline='check',
branch='master',
uuid='1',
request_id='1')
resp = self.get_url(url)
self.assertEqual(
401,
resp.status_code,
"get %s failed: %s" % (url, resp.text))
def test_read_info_routes_no_token(self):
for route in self.info_routes:
url = route.format(tenant='tenant-one',
project='org/project',
change='1,1',
job_name='testjob',
pipeline='check',
branch='master',
uuid='1',
request_id='1')
resp = self.get_url(url)
self.assertEqual(
200,
resp.status_code,
"get %s failed: %s" % (url, resp.text))
info = resp.json()
self.assertTrue(
info['info']['capabilities']['auth']['read_protected'])

View File

@ -1519,13 +1519,15 @@ class ApiRootParser(object):
def getSchema(self):
api_root = {
'authentication-realm': str
'authentication-realm': str,
'access-rules': to_list(str),
}
return vs.Schema(api_root)
def fromYaml(self, conf):
self.schema(conf)
api_root = model.ApiRoot(conf.get('authentication-realm'))
api_root.access_rules = conf.get('access-rules', [])
api_root.freeze()
return api_root
@ -1645,6 +1647,7 @@ class TenantParser(object):
'allow-circular-dependencies': bool,
'default-parent': str,
'default-ansible-version': vs.Any(str, float),
'access-rules': to_list(str),
'admin-rules': to_list(str),
'semaphores': to_list(str),
'authentication-realm': str,
@ -1677,6 +1680,8 @@ class TenantParser(object):
conf['exclude-unprotected-branches']
if conf.get('admin-rules') is not None:
tenant.admin_rules = conf['admin-rules']
if conf.get('access-rules') is not None:
tenant.access_rules = conf['access-rules']
if conf.get('authentication-realm') is not None:
tenant.default_auth_realm = conf['authentication-realm']
if conf.get('semaphores') is not None:

View File

@ -1252,6 +1252,7 @@ class ApiRoot(ConfigObject):
def __init__(self, default_auth_realm=None):
super().__init__()
self.default_auth_realm = default_auth_realm
self.access_rules = []
def __ne__(self, other):
return not self.__eq__(other)
@ -1259,7 +1260,8 @@ class ApiRoot(ConfigObject):
def __eq__(self, other):
if not isinstance(other, ApiRoot):
return False
return (self.default_auth_realm == other.default_auth_realm)
return (self.default_auth_realm == other.default_auth_realm,
self.access_rules == other.access_rules)
def __repr__(self):
return f'<ApiRoot realm={self.default_auth_realm}>'
@ -7991,6 +7993,7 @@ class Tenant(object):
# The per tenant default ansible version
self.default_ansible_version = None
self.access_rules = []
self.admin_rules = []
self.default_auth_realm = None
self.global_semaphores = set()

View File

@ -102,6 +102,24 @@ def get_request_logger(logger=None):
return get_annotated_logger(logger, None, request=request.zuul_request_id)
class APIError(cherrypy.HTTPError):
def __init__(self, code, json_doc=None):
self._json_doc = json_doc
super().__init__(code)
def set_response(self):
super().set_response()
resp = cherrypy.response
if self._json_doc:
ret = json.dumps(self._json_doc).encode('utf8')
resp.body = ret
resp.headers['Content-Type'] = 'application/json'
resp.headers["Content-Length"] = len(ret)
else:
resp.body = b''
resp.headers["Content-Length"] = '0'
class SaveParamsTool(cherrypy.Tool):
"""
Save the URL parameters to allow them to take precedence over query
@ -109,7 +127,7 @@ class SaveParamsTool(cherrypy.Tool):
"""
def __init__(self):
cherrypy.Tool.__init__(self, 'on_start_resource',
self.saveParams)
self.saveParams, priority=10)
def _setup(self):
cherrypy.Tool._setup(self)
@ -149,7 +167,8 @@ def handle_options(allowed_methods=None):
cherrypy.tools.handle_options = cherrypy.Tool('on_start_resource',
handle_options)
handle_options,
priority=50)
class AuthInfo:
@ -158,39 +177,67 @@ class AuthInfo:
self.admin = admin
def check_auth(require_admin=False, require_auth=False):
def _check_auth(require_admin=False, require_auth=False, tenant=None):
if require_admin:
require_auth = True
request = cherrypy.serving.request
zuulweb = request.app.root
if tenant:
if not require_auth and tenant.access_rules:
# This tenant requires auth for read-only access
require_auth = True
else:
if not require_auth and zuulweb.zuulweb.abide.api_root.access_rules:
# The API root requires auth for read-only access
require_auth = True
# Always set the auth variable
request.params['auth'] = None
basic_error = zuulweb._basic_auth_header_check(required=require_auth)
if basic_error is not None:
return
claims, token_error = zuulweb._auth_token_check(required=require_auth)
if token_error is not None:
return
access, admin = zuulweb._isAuthorized(tenant, claims)
if (require_auth and not access) or (require_admin and not admin):
raise APIError(403)
request.params['auth'] = AuthInfo(claims['__zuul_uid_claim'],
admin)
def check_root_auth(**kw):
"""Use this for root-level (non-tenant) methods"""
request = cherrypy.serving.request
if request.handler is None:
# handle_options has already aborted the request.
return
return _check_auth(**kw)
def check_tenant_auth(**kw):
"""Use this for tenant-scoped methods"""
request = cherrypy.serving.request
zuulweb = request.app.root
if request.handler is None:
# handle_options has already aborted the request.
return
# Always set the tenant and uid variables
tenant_name = request.params.get('tenant_name')
# Always set the tenant variable
tenant = zuulweb._getTenantOrRaise(tenant_name)
request.params['tenant'] = tenant
request.params['auth'] = None
basic_error = zuulweb._basic_auth_header_check()
if basic_error is not None and require_auth:
request.handler = None
return basic_error
claims, token_error = zuulweb._auth_token_check()
if token_error is not None and require_auth:
request.handler = None
return token_error
admin = zuulweb._is_authorized(tenant, claims)
if not admin and require_admin:
raise cherrypy.HTTPError(403)
request.params['auth'] = AuthInfo(claims['__zuul_uid_claim'],
admin)
return _check_auth(**kw, tenant=tenant)
cherrypy.tools.check_auth = cherrypy.Tool('before_request_body',
check_auth)
cherrypy.tools.check_root_auth = cherrypy.Tool('on_start_resource',
check_root_auth,
priority=90)
cherrypy.tools.check_tenant_auth = cherrypy.Tool('on_start_resource',
check_tenant_auth,
priority=90)
class StatsTool(cherrypy.Tool):
@ -418,17 +465,15 @@ class ZuulWebAPI(object):
def log(self):
return get_request_logger()
def _basic_auth_header_check(self):
def _basic_auth_header_check(self, required=True):
"""make sure protected endpoints have a Authorization header with the
bearer token."""
token = cherrypy.request.headers.get('Authorization', None)
# Add basic checks here
if token is None:
status = 401
e = 'Missing "Authorization" header'
e_desc = e
elif not token.lower().startswith('bearer '):
status = 401
e = 'Invalid Authorization header format'
e_desc = '"Authorization" header must start with "Bearer"'
else:
@ -438,21 +483,24 @@ class ZuulWebAPI(object):
error_description="%s"''' % (self.zuulweb.authenticators.default_realm,
e,
e_desc)
cherrypy.response.status = status
cherrypy.response.headers["WWW-Authenticate"] = error_header
return {'description': e_desc,
'error': e,
'realm': self.zuulweb.authenticators.default_realm}
error_data = {'description': e_desc,
'error': e,
'realm': self.zuulweb.authenticators.default_realm}
if required:
cherrypy.response.headers["WWW-Authenticate"] = error_header
raise APIError(401, error_data)
return error_data
def _auth_token_check(self):
def _auth_token_check(self, required=True):
rawToken = \
cherrypy.request.headers['Authorization'][len('Bearer '):]
try:
claims = self.zuulweb.authenticators.authenticate(rawToken)
except exceptions.AuthTokenException as e:
for header, contents in e.getAdditionalHeaders().items():
cherrypy.response.headers[header] = contents
cherrypy.response.status = e.HTTPError
if required:
for header, contents in e.getAdditionalHeaders().items():
cherrypy.response.headers[header] = contents
raise APIError(e.HTTPError)
return ({},
{'description': e.error_description,
'error': e.error,
@ -463,8 +511,8 @@ class ZuulWebAPI(object):
@cherrypy.tools.json_in()
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
@cherrypy.tools.handle_options(allowed_methods=['POST', ])
@cherrypy.tools.check_auth(require_admin=True)
def dequeue(self, tenant_name, project_name, tenant, auth):
@cherrypy.tools.check_tenant_auth(require_admin=True)
def dequeue(self, tenant_name, tenant, auth, project_name):
if cherrypy.request.method != 'POST':
raise cherrypy.HTTPError(405)
self.log.info(f'User {auth.uid} requesting dequeue on '
@ -499,8 +547,8 @@ class ZuulWebAPI(object):
@cherrypy.tools.json_in()
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
@cherrypy.tools.handle_options(allowed_methods=['POST', ])
@cherrypy.tools.check_auth(require_admin=True)
def enqueue(self, tenant_name, project_name, tenant, auth):
@cherrypy.tools.check_tenant_auth(require_admin=True)
def enqueue(self, tenant_name, tenant, auth, project_name):
if cherrypy.request.method != 'POST':
raise cherrypy.HTTPError(405)
self.log.info(f'User {auth.uid} requesting enqueue on '
@ -555,7 +603,7 @@ class ZuulWebAPI(object):
@cherrypy.tools.json_in()
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
@cherrypy.tools.handle_options(allowed_methods=['POST', ])
@cherrypy.tools.check_auth(require_admin=True)
@cherrypy.tools.check_tenant_auth(require_admin=True)
def promote(self, tenant_name, tenant, auth):
if cherrypy.request.method != 'POST':
raise cherrypy.HTTPError(405)
@ -584,8 +632,9 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
def autohold_list(self, tenant_name, *args, **kwargs):
_ = self._getTenantOrRaise(tenant_name)
@cherrypy.tools.handle_options()
@cherrypy.tools.check_tenant_auth()
def autohold_list(self, tenant_name, tenant, auth, *args, **kwargs):
# filter by project if passed as a query string
project_name = cherrypy.request.params.get('project', None)
return self._autohold_list(tenant_name, project_name)
@ -593,7 +642,8 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
@cherrypy.tools.handle_options(allowed_methods=['GET', 'POST'])
def autohold_project_get(self, tenant_name, project_name):
@cherrypy.tools.check_tenant_auth()
def autohold_project_get(self, tenant_name, tenant, auth, project_name):
# Note: GET handling is redundant with autohold_list
# and could be removed.
return self._autohold_list(tenant_name, project_name)
@ -601,8 +651,9 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.json_in()
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
@cherrypy.tools.check_auth(require_admin=True)
def autohold_project_post(self, tenant_name, project_name, tenant, auth):
# Options handled by _get method
@cherrypy.tools.check_tenant_auth(require_admin=True)
def autohold_project_post(self, tenant_name, tenant, auth, project_name):
project = self._getProjectOrRaise(tenant, project_name)
self.log.info(f'User {auth.uid} requesting autohold on '
f'{tenant_name}/{project_name}')
@ -700,7 +751,8 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
@cherrypy.tools.handle_options(allowed_methods=['GET', 'DELETE', ])
def autohold_get(self, tenant_name, request_id):
@cherrypy.tools.check_tenant_auth()
def autohold_get(self, tenant_name, tenant, auth, request_id):
request = self._getAutoholdRequest(tenant_name, request_id)
resp = cherrypy.response
resp.headers['Access-Control-Allow-Origin'] = '*'
@ -720,8 +772,9 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
@cherrypy.tools.check_auth(require_admin=True)
def autohold_delete(self, tenant_name, request_id, tenant, auth):
# Options handled by get method
@cherrypy.tools.check_tenant_auth(require_admin=True)
def autohold_delete(self, tenant_name, tenant, auth, request_id):
request = self._getAutoholdRequest(tenant_name, request_id)
self.log.info(f'User {auth.uid} requesting autohold-delete on '
f'{request.tenant}/{request.project}')
@ -756,7 +809,9 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
def index(self):
@cherrypy.tools.handle_options()
@cherrypy.tools.check_root_auth()
def index(self, auth):
return {
'info': '/api/info',
'connections': '/api/connections',
@ -800,33 +855,38 @@ class ZuulWebAPI(object):
}
@cherrypy.expose
@cherrypy.tools.handle_options()
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
# Info endpoints never require authentication because they supply
# authentication information.
def info(self):
info = self.zuulweb.info.copy()
auth_info = info.capabilities.capabilities['auth']
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
auth_info['default_realm'] = root_realm
read_protected = bool(self.zuulweb.abide.api_root.access_rules)
auth_info['read_protected'] = read_protected
return self._handleInfo(info)
@cherrypy.expose
@cherrypy.tools.save_params()
@cherrypy.tools.handle_options()
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
# Info endpoints never require authentication because they supply
# authentication information.
def tenant_info(self, tenant_name):
info = self.zuulweb.info.copy()
auth_info = info.capabilities.capabilities['auth']
info.tenant = tenant_name
tenant_config = self.zuulweb.unparsed_abide.tenants.get(tenant_name)
if tenant_config is not None:
tenant = self.zuulweb.abide.tenants.get(tenant_name)
if tenant 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
if tenant.default_auth_realm is not None:
auth_info['default_realm'] = tenant.default_auth_realm
read_protected = bool(tenant.access_rules)
auth_info['read_protected'] = read_protected
return self._handleInfo(info)
def _handleInfo(self, info):
@ -839,35 +899,74 @@ class ZuulWebAPI(object):
resp.last_modified = self.zuulweb.start_time
return ret
def _is_authorized(self, tenant, claims):
def _isAuthorized(self, tenant, claims):
# First, check for zuul.admin override
if tenant:
tenant_name = tenant.name
admin_rules = tenant.admin_rules
access_rules = tenant.access_rules
else:
tenant_name = '*'
admin_rules = []
access_rules = self.zuulweb.api_root.access_rules
override = claims.get('zuul', {}).get('admin', [])
if (override == '*' or
(isinstance(override, list) and tenant.name in override)):
return True
(isinstance(override, list) and tenant_name in override)):
return (True, True)
for rule_name in tenant.admin_rules:
if not tenant:
tenant_name = '<root>'
if access_rules:
access = False
else:
access = True
for rule_name in access_rules:
rule = self.zuulweb.abide.authz_rules.get(rule_name)
if not rule:
self.log.error('Undefined rule "%s"', rule_name)
continue
self.log.debug('Applying rule "%s" from tenant "%s" to claims %s',
rule_name, tenant.name, json.dumps(claims))
self.log.debug('Applying access rule "%s" from '
'tenant "%s" to claims %s',
rule_name, tenant_name, json.dumps(claims))
authorized = rule(claims, tenant)
if authorized:
if '__zuul_uid_claim' in claims:
uid = claims['__zuul_uid_claim']
else:
uid = json.dumps(claims)
self.log.info('%s authorized on tenant "%s" by rule "%s"',
uid, tenant.name, rule_name)
return True
return False
self.log.info('%s authorized access on '
'tenant "%s" by rule "%s"',
uid, tenant_name, rule_name)
access = True
break
admin = False
for rule_name in admin_rules:
rule = self.zuulweb.abide.authz_rules.get(rule_name)
if not rule:
self.log.error('Undefined rule "%s"', rule_name)
continue
self.log.debug('Applying admin rule "%s" from '
'tenant "%s" to claims %s',
rule_name, tenant_name, json.dumps(claims))
authorized = rule(claims, tenant)
if authorized:
if '__zuul_uid_claim' in claims:
uid = claims['__zuul_uid_claim']
else:
uid = json.dumps(claims)
self.log.info('%s authorized admin on '
'tenant "%s" by rule "%s"',
uid, tenant_name, rule_name)
access = admin = True
break
return (access, admin)
@cherrypy.expose
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
@cherrypy.tools.handle_options(allowed_methods=['GET', ])
@cherrypy.tools.check_auth(require_auth=True)
@cherrypy.tools.handle_options()
@cherrypy.tools.check_tenant_auth(require_auth=True)
def tenant_authorizations(self, tenant_name, tenant, auth):
resp = cherrypy.response
resp.headers['Access-Control-Allow-Origin'] = '*'
@ -895,7 +994,9 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
def tenants(self):
@cherrypy.tools.handle_options()
@cherrypy.tools.check_root_auth()
def tenants(self, auth):
cache_time = self.tenants_cache_time
if time.time() - cache_time > self.cache_expiry:
with self.tenants_cache_lock:
@ -914,7 +1015,9 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
def connections(self):
@cherrypy.tools.handle_options()
@cherrypy.tools.check_root_auth()
def connections(self, auth):
ret = [s.connection.toDict()
for s in self.zuulweb.connections.getSources()]
resp = cherrypy.response
@ -923,7 +1026,9 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.json_out(content_type="application/json; charset=utf-8")
def components(self):
@cherrypy.tools.handle_options()
@cherrypy.tools.check_root_auth()
def components(self, auth):
ret = {}
for kind, components in self.zuulweb.component_registry.all():
for comp in components:
@ -937,33 +1042,32 @@ class ZuulWebAPI(object):
resp.headers["Access-Control-Allow-Origin"] = "*"
return ret
def _getStatus(self, tenant_name):
tenant = self._getTenantOrRaise(tenant_name)
cache_time = self.status_cache_times.get(tenant_name, 0)
if tenant_name not in self.status_cache_locks or \
def _getStatus(self, tenant):
cache_time = self.status_cache_times.get(tenant.name, 0)
if tenant.name not in self.status_cache_locks or \
(time.time() - cache_time) > self.cache_expiry:
if self.status_cache_locks[tenant_name].acquire(
if self.status_cache_locks[tenant.name].acquire(
blocking=False
):
try:
self.status_caches[tenant_name] =\
self.status_caches[tenant.name] =\
self.formatStatus(tenant)
self.status_cache_times[tenant_name] =\
self.status_cache_times[tenant.name] =\
time.time()
finally:
self.status_cache_locks[tenant_name].release()
if not self.status_caches.get(tenant_name):
self.status_cache_locks[tenant.name].release()
if not self.status_caches.get(tenant.name):
# If the cache is empty at this point it means that we didn't
# get the lock but another thread is initializing the cache
# for the first time. In this case we just wait for the lock
# to wait for it to finish.
with self.status_cache_locks[tenant_name]:
with self.status_cache_locks[tenant.name]:
pass
payload = self.status_caches[tenant_name]
payload = self.status_caches[tenant.name]
resp = cherrypy.response
resp.headers["Cache-Control"] = f"public, max-age={self.cache_expiry}"
last_modified = datetime.utcfromtimestamp(
self.status_cache_times[tenant_name]
self.status_cache_times[tenant.name]
)
last_modified_header = last_modified.strftime('%a, %d %b %Y %X GMT')
resp.headers["Last-modified"] = last_modified_header
@ -1027,14 +1131,18 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.save_params()
def status(self, tenant_name):
return self._getStatus(tenant_name)[1]
@cherrypy.tools.handle_options()
@cherrypy.tools.check_tenant_auth()
def status(self, tenant_name, tenant, auth):
return self._getStatus(tenant)[1]
@cherrypy.expose
@cherrypy.tools.save_params()
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
def status_change(self, tenant_name, change):
payload = self._getStatus(tenant_name)[0]
@cherrypy.tools.handle_options()
@cherrypy.tools.check_tenant_auth()
def status_change(self, tenant_name, tenant, auth, change):
payload = self._getStatus(tenant)[0]
result_filter = ChangeFilter(change)
return result_filter.filterPayload(payload)
@ -1043,8 +1151,9 @@ class ZuulWebAPI(object):
@cherrypy.tools.json_out(
content_type='application/json; charset=utf-8', handler=json_handler,
)
def jobs(self, tenant_name):
tenant = self._getTenantOrRaise(tenant_name)
@cherrypy.tools.handle_options()
@cherrypy.tools.check_tenant_auth()
def jobs(self, tenant_name, tenant, auth):
result = []
for job_name in sorted(tenant.layout.jobs):
desc = None
@ -1083,8 +1192,9 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.save_params()
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
def config_errors(self, tenant_name):
tenant = self._getTenantOrRaise(tenant_name)
@cherrypy.tools.handle_options()
@cherrypy.tools.check_tenant_auth()
def config_errors(self, tenant_name, tenant, auth):
ret = [
{'source_context': e.key.context.toDict(),
'error': e.error}
@ -1098,9 +1208,10 @@ class ZuulWebAPI(object):
@cherrypy.tools.save_params()
@cherrypy.tools.json_out(
content_type='application/json; charset=utf-8', handler=json_handler)
def job(self, tenant_name, job_name):
@cherrypy.tools.handle_options()
@cherrypy.tools.check_tenant_auth()
def job(self, tenant_name, tenant, auth, job_name):
job_name = urllib.parse.unquote_plus(job_name)
tenant = self._getTenantOrRaise(tenant_name)
job_variants = tenant.layout.jobs.get(job_name)
result = []
for job in job_variants:
@ -1113,8 +1224,9 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.save_params()
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
def projects(self, tenant_name):
tenant = self._getTenantOrRaise(tenant_name)
@cherrypy.tools.handle_options()
@cherrypy.tools.check_tenant_auth()
def projects(self, tenant_name, tenant, auth):
result = []
for project in tenant.config_projects:
pobj = project.toDict()
@ -1133,8 +1245,9 @@ class ZuulWebAPI(object):
@cherrypy.tools.save_params()
@cherrypy.tools.json_out(
content_type='application/json; charset=utf-8', handler=json_handler)
def project(self, tenant_name, project_name):
tenant = self._getTenantOrRaise(tenant_name)
@cherrypy.tools.handle_options()
@cherrypy.tools.check_tenant_auth()
def project(self, tenant_name, tenant, auth, project_name):
project = self._getProjectOrRaise(tenant, project_name)
result = project.toDict()
@ -1163,8 +1276,9 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.save_params()
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
def pipelines(self, tenant_name):
tenant = self._getTenantOrRaise(tenant_name)
@cherrypy.tools.handle_options()
@cherrypy.tools.check_tenant_auth()
def pipelines(self, tenant_name, tenant, auth):
ret = []
for pipeline, pipeline_config in tenant.layout.pipelines.items():
triggers = []
@ -1187,8 +1301,9 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.save_params()
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
def labels(self, tenant_name):
tenant = self._getTenantOrRaise(tenant_name)
@cherrypy.tools.handle_options()
@cherrypy.tools.check_tenant_auth()
def labels(self, tenant_name, tenant, auth):
allowed_labels = tenant.allowed_labels or []
disallowed_labels = tenant.disallowed_labels or []
labels = set()
@ -1204,7 +1319,9 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.save_params()
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
def nodes(self, tenant_name):
@cherrypy.tools.handle_options()
@cherrypy.tools.check_tenant_auth()
def nodes(self, tenant_name, tenant, auth):
ret = []
for node_id in self.zk_nodepool.getNodes(cached=True):
node = self.zk_nodepool.getNode(node_id)
@ -1228,8 +1345,9 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.save_params()
def key(self, tenant_name, project_name):
tenant = self._getTenantOrRaise(tenant_name)
@cherrypy.tools.handle_options()
@cherrypy.tools.check_tenant_auth()
def key(self, tenant_name, tenant, auth, project_name):
project = self._getProjectOrRaise(tenant, project_name)
key = encryption.serialize_rsa_public_key(project.public_secrets_key)
@ -1240,8 +1358,9 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.save_params()
def project_ssh_key(self, tenant_name, project_name):
tenant = self._getTenantOrRaise(tenant_name)
@cherrypy.tools.handle_options()
@cherrypy.tools.check_tenant_auth()
def project_ssh_key(self, tenant_name, tenant, auth, project_name):
project = self._getProjectOrRaise(tenant, project_name)
key = f"{project.public_ssh_key}\n"
@ -1320,12 +1439,14 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.save_params()
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
def builds(self, tenant_name, project=None, pipeline=None, change=None,
branch=None, patchset=None, ref=None, newrev=None,
uuid=None, job_name=None, voting=None, nodeset=None,
result=None, final=None, held=None, complete=None,
limit=50, skip=0, idx_min=None, idx_max=None,
exclude_result=None):
@cherrypy.tools.handle_options()
@cherrypy.tools.check_tenant_auth()
def builds(self, tenant_name, tenant, auth, project=None,
pipeline=None, change=None, branch=None, patchset=None,
ref=None, newrev=None, uuid=None, job_name=None,
voting=None, nodeset=None, result=None, final=None,
held=None, complete=None, limit=50, skip=0,
idx_min=None, idx_max=None, exclude_result=None):
connection = self._get_connection()
if tenant_name not in self.zuulweb.abide.tenants.keys():
@ -1361,7 +1482,9 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.save_params()
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
def build(self, tenant_name, uuid):
@cherrypy.tools.handle_options()
@cherrypy.tools.check_tenant_auth()
def build(self, tenant_name, tenant, auth, uuid):
connection = self._get_connection()
data = connection.getBuilds(tenant=tenant_name, uuid=uuid, limit=1)
@ -1402,7 +1525,10 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.save_params()
def badge(self, tenant_name, project=None, pipeline=None, branch=None):
@cherrypy.tools.handle_options()
@cherrypy.tools.check_tenant_auth()
def badge(self, tenant_name, tenant, auth, project=None,
pipeline=None, branch=None):
connection = self._get_connection()
buildsets = connection.getBuildsets(
@ -1426,9 +1552,12 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.save_params()
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
def buildsets(self, tenant_name, project=None, pipeline=None, change=None,
branch=None, patchset=None, ref=None, newrev=None,
uuid=None, result=None, complete=None, limit=50, skip=0,
@cherrypy.tools.handle_options()
@cherrypy.tools.check_tenant_auth()
def buildsets(self, tenant_name, tenant, auth, project=None,
pipeline=None, change=None, branch=None,
patchset=None, ref=None, newrev=None, uuid=None,
result=None, complete=None, limit=50, skip=0,
idx_min=None, idx_max=None):
connection = self._get_connection()
@ -1454,7 +1583,9 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.save_params()
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
def buildset(self, tenant_name, uuid):
@cherrypy.tools.handle_options()
@cherrypy.tools.check_tenant_auth()
def buildset(self, tenant_name, tenant, auth, uuid):
connection = self._get_connection()
data = connection.getBuildset(tenant_name, uuid)
@ -1470,8 +1601,9 @@ class ZuulWebAPI(object):
@cherrypy.tools.json_out(
content_type='application/json; charset=utf-8', handler=json_handler,
)
def semaphores(self, tenant_name):
tenant = self._getTenantOrRaise(tenant_name)
@cherrypy.tools.handle_options()
@cherrypy.tools.check_tenant_auth()
def semaphores(self, tenant_name, tenant, auth):
result = []
names = set(tenant.layout.semaphores.keys())
names = names.union(tenant.global_semaphores)
@ -1503,19 +1635,30 @@ class ZuulWebAPI(object):
resp.headers['Access-Control-Allow-Origin'] = '*'
return result
@cherrypy.expose
@cherrypy.tools.save_params()
@cherrypy.tools.handle_options()
# We don't check auth here since we would never fall through to it
def console_stream_options(self, tenant_name):
cherrypy.request.ws_handler.zuulweb = self.zuulweb
@cherrypy.expose
@cherrypy.tools.save_params()
@cherrypy.tools.websocket(handler_cls=LogStreamHandler)
def console_stream(self, tenant_name):
# Options handling in _options method
@cherrypy.tools.check_tenant_auth()
def console_stream_get(self, tenant_name, tenant, auth):
cherrypy.request.ws_handler.zuulweb = self.zuulweb
@cherrypy.expose
@cherrypy.tools.save_params()
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
def project_freeze_jobs(self, tenant_name, pipeline_name, project_name,
branch_name):
@cherrypy.tools.handle_options()
@cherrypy.tools.check_tenant_auth()
def project_freeze_jobs(self, tenant_name, tenant, auth,
pipeline_name, project_name, branch_name):
item = self._freeze_jobs(
tenant_name, pipeline_name, project_name, branch_name)
tenant, pipeline_name, project_name, branch_name)
output = []
for job in item.current_build_set.job_graph.getJobs():
@ -1533,12 +1676,15 @@ class ZuulWebAPI(object):
@cherrypy.expose
@cherrypy.tools.save_params()
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
def project_freeze_job(self, tenant_name, pipeline_name, project_name,
branch_name, job_name):
@cherrypy.tools.handle_options()
@cherrypy.tools.check_tenant_auth()
def project_freeze_job(self, tenant_name, tenant, auth,
pipeline_name, project_name, branch_name,
job_name):
# TODO(jhesketh): Allow a canonical change/item to be passed in which
# would return the job with any in-change modifications.
item = self._freeze_jobs(
tenant_name, pipeline_name, project_name, branch_name)
tenant, pipeline_name, project_name, branch_name)
job = item.current_build_set.jobs.get(job_name)
if not job:
raise cherrypy.HTTPError(404)
@ -1573,10 +1719,9 @@ class ZuulWebAPI(object):
resp.headers['Access-Control-Allow-Origin'] = '*'
return ret
def _freeze_jobs(self, tenant_name, pipeline_name, project_name,
def _freeze_jobs(self, tenant, pipeline_name, project_name,
branch_name):
tenant = self._getTenantOrRaise(tenant_name)
project = self._getProjectOrRaise(tenant, project_name)
pipeline = tenant.layout.pipelines.get(pipeline_name)
if not pipeline:
@ -1921,7 +2066,11 @@ class ZuulWeb(object):
'project-ssh-key/{project_name:.*}.pub',
controller=api, action='project_ssh_key')
route_map.connect('api', '/api/tenant/{tenant_name}/console-stream',
controller=api, action='console_stream')
controller=api, action='console_stream_get',
conditions=dict(method=['GET']))
route_map.connect('api', '/api/tenant/{tenant_name}/console-stream',
controller=api, action='console_stream_options',
conditions=dict(method=['OPTIONS']))
route_map.connect('api', '/api/tenant/{tenant_name}/builds',
controller=api, action='builds')
route_map.connect('api', '/api/tenant/{tenant_name}/badge',