diff options
-rw-r--r-- | doc/source/authentication.rst | 46 | ||||
-rw-r--r-- | doc/source/tenants.rst | 32 | ||||
-rw-r--r-- | releasenotes/notes/access-rules-096ecf9ae747188e.yaml | 7 | ||||
-rw-r--r-- | tests/fixtures/config/access-rules/git/common-config/playbooks/run.yaml | 1 | ||||
-rw-r--r-- | tests/fixtures/config/access-rules/git/common-config/zuul.yaml | 61 | ||||
-rw-r--r-- | tests/fixtures/config/access-rules/git/org_project/README | 1 | ||||
-rw-r--r-- | tests/fixtures/config/access-rules/main.yaml | 17 | ||||
-rw-r--r-- | tests/unit/test_web.py | 87 | ||||
-rw-r--r-- | zuul/configloader.py | 7 | ||||
-rw-r--r-- | zuul/model.py | 5 | ||||
-rwxr-xr-x | zuul/web/__init__.py | 419 |
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', |