summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJames E. Blair <jim@acmegating.com>2022-09-29 13:47:17 -0700
committerJames E. Blair <jim@acmegating.com>2022-10-25 20:22:33 -0700
commitc22f2c98e0af910b5d7a58966a16741fdb53cb0b (patch)
treeda6cbd877f26d34d1ee1fa67d13b11f36fc8fde3
parentc2f2891bd33411db1989a6a1a1c575ce41046a33 (diff)
downloadzuul-c22f2c98e0af910b5d7a58966a16741fdb53cb0b.tar.gz
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
-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',