summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--doc/source/authentication.rst46
-rw-r--r--doc/source/tenants.rst32
-rw-r--r--releasenotes/notes/access-rules-096ecf9ae747188e.yaml7
-rw-r--r--tests/fixtures/config/access-rules/git/common-config/playbooks/run.yaml1
-rw-r--r--tests/fixtures/config/access-rules/git/common-config/zuul.yaml61
-rw-r--r--tests/fixtures/config/access-rules/git/org_project/README1
-rw-r--r--tests/fixtures/config/access-rules/main.yaml17
-rw-r--r--tests/unit/test_web.py87
-rw-r--r--zuul/configloader.py7
-rw-r--r--zuul/model.py5
-rwxr-xr-xzuul/web/__init__.py419
11 files changed, 524 insertions, 159 deletions
diff --git a/doc/source/authentication.rst b/doc/source/authentication.rst
index 5175e8b9f..7519f6468 100644
--- a/doc/source/authentication.rst
+++ b/doc/source/authentication.rst
@@ -2,26 +2,32 @@
.. _authentication:
-Authenticated Actions
-=====================
-
-Users can perform some privileged actions at the tenant level through protected
-endpoints of the REST API, if these endpoints are activated.
-
-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 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>`_.
+Authenticated Access
+====================
+
+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 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>`_.
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>`.
diff --git a/doc/source/tenants.rst b/doc/source/tenants.rst
index 2d28603dc..b43b0bedf 100644
--- a/doc/source/tenants.rst
+++ b/doc/source/tenants.rst
@@ -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`.
diff --git a/releasenotes/notes/access-rules-096ecf9ae747188e.yaml b/releasenotes/notes/access-rules-096ecf9ae747188e.yaml
new file mode 100644
index 000000000..e9d00b42e
--- /dev/null
+++ b/releasenotes/notes/access-rules-096ecf9ae747188e.yaml
@@ -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.
diff --git a/tests/fixtures/config/access-rules/git/common-config/playbooks/run.yaml b/tests/fixtures/config/access-rules/git/common-config/playbooks/run.yaml
new file mode 100644
index 000000000..ed97d539c
--- /dev/null
+++ b/tests/fixtures/config/access-rules/git/common-config/playbooks/run.yaml
@@ -0,0 +1 @@
+---
diff --git a/tests/fixtures/config/access-rules/git/common-config/zuul.yaml b/tests/fixtures/config/access-rules/git/common-config/zuul.yaml
new file mode 100644
index 000000000..f4d9df729
--- /dev/null
+++ b/tests/fixtures/config/access-rules/git/common-config/zuul.yaml
@@ -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
diff --git a/tests/fixtures/config/access-rules/git/org_project/README b/tests/fixtures/config/access-rules/git/org_project/README
new file mode 100644
index 000000000..9daeafb98
--- /dev/null
+++ b/tests/fixtures/config/access-rules/git/org_project/README
@@ -0,0 +1 @@
+test
diff --git a/tests/fixtures/config/access-rules/main.yaml b/tests/fixtures/config/access-rules/main.yaml
new file mode 100644
index 000000000..cec4bd778
--- /dev/null
+++ b/tests/fixtures/config/access-rules/main.yaml
@@ -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
diff --git a/tests/unit/test_web.py b/tests/unit/test_web.py
index af3309593..c9abf7f1c 100644
--- a/tests/unit/test_web.py
+++ b/tests/unit/test_web.py
@@ -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'])
diff --git a/zuul/configloader.py b/zuul/configloader.py
index a839ed137..49a0b95b9 100644
--- a/zuul/configloader.py
+++ b/zuul/configloader.py
@@ -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:
diff --git a/zuul/model.py b/zuul/model.py
index c90589363..7269dee24 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -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()
diff --git a/zuul/web/__init__.py b/zuul/web/__init__.py
index cd8e2956b..eb30e1d28 100755
--- a/zuul/web/__init__.py
+++ b/zuul/web/__init__.py
@@ -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)
+ return _check_auth(**kw, tenant=tenant)
- request.params['auth'] = AuthInfo(claims['__zuul_uid_claim'],
- admin)
-
-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}
-
- def _auth_token_check(self):
+ 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, 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)
@@ -1505,17 +1637,28 @@ class ZuulWebAPI(object):
@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',