web: add tenant and project scoped, JWT-protected actions

A user with the right JSON Web Token (JWT) can trigger a autohold,
reenqueue or dequeue a buildset from the web API.

The Token is expected to include a key called "zuul.admin" that
contains a list of the tenants the user is allowed to perform
these actions on.

The Token must be passed as a bearer token in an Authorization header.

The Token is validated thanks to authenticator declarations in Zuul's
configuration file.

Change-Id: Ief9088812f44368f14234ddfa25ba872526b8735
This commit is contained in:
Matthieu Huin 2019-06-21 12:38:19 +02:00
parent 86f071464d
commit 6a7235fb50
16 changed files with 1258 additions and 4 deletions

View File

@ -1,5 +1,7 @@
:title: Zuul Client
.. _zuul-client:
Zuul Client
===========

View File

@ -758,6 +758,8 @@ To enable or disable running Ansible in verbose mode (with the
``-vvv`` argument to ansible-playbook) run ``zuul-executor verbose``
and ``zuul-executor unverbose``.
.. _web-server:
Web Server
----------
@ -828,6 +830,114 @@ sections of ``zuul.conf`` are used by the web server:
The Cache-Control max-age response header value for static files served
by the zuul-web. Set to 0 during development to disable Cache-Control.
Enabling tenant-scoped access to privileged actions
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A user can be granted access to protected REST API endpoints by providing a
valid JWT (JSON Web Token) as a bearer token when querying the API endpoints.
JWTs are signed and therefore Zuul must be configured so that signatures can be
verified. More information about the JWT standard can be found on the `IETF's
RFC page <https://tools.ietf.org/html/rfc7519>`_.
This optional section of ``zuul.conf``, if present, will activate the
protected endpoints and configure JWT validation:
.. attr:: auth <authenticator name>
.. attr:: driver
The signing algorithm to use. Accepted values are ``HS256``, ``RS256`` or
``RS256withJWKS``. See below for driver-specific configuration options.
.. attr:: allow_authz_override
:default: false
Allow a JWT to override predefined access rules. Since predefined access
rules are not supported yet, this should be set to ``true``.
.. attr:: realm
The authentication realm.
.. attr:: default
:default: false
If set to ``true``, use this realm as the default authentication realm
when handling HTTP authentication errors.
.. attr:: client_id
The expected value of the "aud" claim in the JWT. This is required for
validation.
.. attr:: issuer_id
The expected value of the "iss" claim in the JWT. This is required for
validation.
.. attr:: uid_claim
:default: sub
The JWT claim that Zuul will use as a unique identifier for the bearer of
a token. This is "sub" by default, as it is usually the purpose of this
claim in a JWT. This identifier is used in audit logs.
.. attr:: max_validity_time
Optional value to ensure a JWT cannot be valid for more than this amount
of time in seconds. This is useful if the Zuul operator has no control
over the service issueing JWTs, and the tokens are too long-lived.
This section can be repeated as needed with different authenticators, allowing
access to privileged API actions from several JWT issuers.
Driver-specific attributes
..........................
HS256
,,,,,
This is a symmetrical encryption algorithm that only requires a shared secret
between the JWT issuer and the JWT consumer (ie Zuul). This driver should be
used in test deployments only, or in deployments where JWTs will be issued
manually.
.. attr:: secret
:noindex:
The shared secret used to sign JWTs and validate signatures.
RS256
,,,,,
This is an asymmetrical encryption algorithm that requires an RSA key pair. Only
the public key is needed by Zuul for signature validation.
.. attr:: public_key
The path to the public key of the RSA key pair. It must be readable by Zuul.
.. attr:: private_key
Optional. The path to the private key of the RSA key pair. It must be
readable by Zuul.
RS256withJWKS
,,,,,,,,,,,,,
Some Identity Providers use key sets (also known as **JWKS**), therefore the key to
use when verifying the Authentication Token's signatures cannot be known in
advance; the key's id is stored in the JWT's header and the key must then be
found in the remote key set.
The key set is usually available at a specific URL that can be found in the
"well-known" configuration of an OpenID Connect Identity Provider.
.. attr:: keys_url
The URL where the Identity Provider's key set can be found. For example, for
Google's OAuth service: https://www.googleapis.com/oauth2/v3/certs
Operation
~~~~~~~~~

View File

@ -19,4 +19,5 @@ provides to in-project configuration.
tenants
monitoring
client
tenant-scoped-rest-api
troubleshooting

View File

@ -0,0 +1,104 @@
:title: Tenant Scoped REST API
Tenant Scoped REST API
======================
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** and
**dequeue/dequeue-ref**. 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.
As a mitigation, Tokens should be generated with a short time to
live, like 10 minutes or less. If the Token contains authorization Information
(see the ``zuul.admin`` claim below), it should be generated with as little a scope
as possible (one tenant only) to reduce the surface of attack should the
Token be compromised.
Exposing administration tasks can impact build results (dequeue-ing buildsets),
and pose potential resources problems with Nodepool if the ``autohold`` feature
is abused, leading to a significant number of nodes remaining in "hold" state for
extended periods of time. As always, "with great power comes great responsibility"
and Tokens should be handed over with discernment.
Configuration
-------------
See the Zuul Web Server component's section about enabling tenant-scoped access to
privileged actions.
JWT Format
----------
Zuul can consume JWTs with the following minimal set of claims:
.. code-block:: javascript
{
'iss': 'jwt_provider',
'aud': 'my_zuul_deployment',
'exp': 1234567890,
'iat': 1234556780,
'sub': 'venkman'
}
* **iss** is the issuer of the Token. It can be used to filter
Identity Providers.
* **aud**, as the intended audience, is usually the client id as registered on
the Identity Provider.
* **exp** is the Token's expiry timestamp.
* **iat** is the Token's date of issuance timestamp.
* **sub** is the default, unique identifier of the user.
JWTs can be extended arbitrarily with other claims. Zuul however can look for a
specific **zuul** claim, if the ``allow_authz_override`` option was set to True
in the authenticator's configuration. This claim has the following format:
.. code-block:: javascript
{
'zuul': {
'admin': ['tenant-one', 'tenant-two']
}
}
The **admin** field is a list of tenants on which the Token's bearer is granted
the right to perform privileged actions.
Manually Generating a JWT
-------------------------
An operator can generate a JWT by using the settings of a configured authenticator
in ``zuul.conf``.
For example, in Python, and for an authenticator using the ``HS256`` algorithm:
.. code-block:: python
>>> import jwt
>>> import time
>>> jwt.encode({'sub': 'user1',
'iss': <issuer_id>,
'aud': <client_id>,
'iat': time.time(),
'exp': time.time() + 300,
'zuul': {
'admin': ['tenant-one']
}
}, <secret>, algorithm='HS256')
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ6dXVsIjp7ImFkbWluIjpbInRlbmFudC1vbmUiXX0sInN1YiI6InZlbmttYW4iLCJpc3MiOiJtYW51YWwiLCJleHAiOjE1NjAzNTQxOTcuMTg5NzIyLCJpYXQiOjE1NjAzNTM4OTcuMTg5NzIxLCJhdWQiOiJ6dXVsIn0.Qqb-ANmYv8slNUVSqjCJDL8HlH9L7nnLtLU2HBGzQJk'
Online resources like https://jwt.io are also available to generate, decode and
debug JWTs.

View File

@ -40,6 +40,15 @@ port=9000
static_cache_expiry=0
status_url=https://zuul.example.com/status
[auth zuul_operator]
driver=HS256
allow_authz_override=true
realm=zuul.example.com
default=true
client_id=zuul.example.com
issuer_id=zuul_operator
secret=NoDanaOnlyZuul
[connection gerrit]
driver=gerrit
server=review.example.com

View File

@ -0,0 +1,9 @@
---
features:
- |
Add an endpoint protection mechanism to zuul-web's REST API, based on the JWT
standard. A user can access protected endpoints with a valid bearer token.
The actions associated to these endpoints are tenant-scoped via a token claim.
Zuul supports token signatures using the HS256 or RS256 algorithms. External
JWKS are also supported.
Current protected endpoints are "autohold", "enqueue" and "dequeue".

View File

@ -69,6 +69,7 @@ import zuul.executor.server
import zuul.executor.client
import zuul.lib.ansible
import zuul.lib.connections
import zuul.lib.auth
import zuul.merger.client
import zuul.merger.merger
import zuul.merger.server
@ -2528,6 +2529,8 @@ class ZuulWebFixture(fixtures.Fixture):
include_drivers=[zuul.driver.sql.SQLDriver,
zuul.driver.github.GithubDriver,
zuul.driver.pagure.PagureDriver])
self.auths = zuul.lib.auth.AuthenticatorRegistry()
self.auths.configure(config)
if info is None:
self.info = zuul.model.WebInfo()
else:
@ -2543,7 +2546,8 @@ class ZuulWebFixture(fixtures.Fixture):
info=self.info,
connections=self.connections,
zk_hosts=self.zk_hosts,
command_socket=os.path.join(self.test_root, 'web.socket'))
command_socket=os.path.join(self.test_root, 'web.socket'),
auths=self.auths)
self.web.start()
self.addCleanup(self.stop)

31
tests/fixtures/zuul-admin-web.conf vendored Normal file
View File

@ -0,0 +1,31 @@
[gearman]
server=127.0.0.1
[scheduler]
tenant_config=main.yaml
relative_priority=true
[merger]
git_dir=/tmp/zuul-test/merger-git
git_user_email=zuul@example.com
git_user_name=zuul
[executor]
git_dir=/tmp/zuul-test/executor-git
[connection gerrit]
driver=gerrit
server=review.example.com
user=jenkins
sshkey=fake_id_rsa_path
[web]
static_cache_expiry=1200
[auth zuul_operator]
driver=HS256
allow_authz_override=true
realm=zuul.example.com
client_id=zuul.example.com
issuer_id=zuul_operator
secret=NoDanaOnlyZuul

View File

@ -17,10 +17,13 @@ import json
import os
import urllib.parse
import socket
import time
import jwt
import requests
import zuul.web
import zuul.rpcclient
from tests.base import ZuulTestCase, ZuulDBTestCase, AnsibleZuulTestCase
from tests.base import ZuulWebFixture, FIXTURE_DIR
@ -46,7 +49,6 @@ class BaseTestWeb(ZuulTestCase):
super(BaseTestWeb, self).setUp()
self.zuul_ini_config = FakeConfig(self.config_ini_data)
# Start the web server
self.web = self.useFixture(
ZuulWebFixture(
@ -83,6 +85,10 @@ class BaseTestWeb(ZuulTestCase):
return requests.get(
urllib.parse.urljoin(self.base_url, url), *args, **kwargs)
def post_url(self, url, *args, **kwargs):
return requests.post(
urllib.parse.urljoin(self.base_url, url), *args, **kwargs)
def tearDown(self):
self.executor_server.hold_jobs_in_build = False
self.executor_server.release()
@ -740,6 +746,77 @@ class TestWeb(BaseTestWeb):
resp = self.get_url("api/tenant/non-tenant/status")
self.assertEqual(404, resp.status_code)
def test_autohold_list(self):
"""test listing autoholds through zuul-web"""
client = zuul.rpcclient.RPCClient('127.0.0.1',
self.gearman_server.port)
self.addCleanup(client.shutdown)
r = client.autohold('tenant-one', 'org/project', 'project-test2',
"", "", "reason text", 1)
self.assertTrue(r)
resp = self.get_url(
"api/tenant/tenant-one/autohold")
self.assertEqual(200, resp.status_code, resp.text)
autohold_requests = resp.json()
self.assertNotEqual([], autohold_requests)
self.assertEqual(1, len(autohold_requests))
# The single dict key should be a CSV string value
ah_request = autohold_requests[0]
self.assertEqual('tenant-one', ah_request['tenant'])
self.assertIn('org/project', ah_request['project'])
self.assertEqual('project-test2', ah_request['job'])
self.assertEqual(".*", ah_request['ref_filter'])
self.assertEqual(1, ah_request['count'])
self.assertEqual("reason text", ah_request['reason'])
# filter by project
resp = self.get_url(
"api/tenant/tenant-one/autohold?project=org/project2")
self.assertEqual(200, resp.status_code, resp.text)
autohold_requests = resp.json()
self.assertEqual([], autohold_requests)
resp = self.get_url(
"api/tenant/tenant-one/autohold?project=org/project")
self.assertEqual(200, resp.status_code, resp.text)
autohold_requests = resp.json()
self.assertNotEqual([], autohold_requests)
self.assertEqual(1, len(autohold_requests))
# The single dict key should be a CSV string value
ah_request = autohold_requests[0]
self.assertEqual('tenant-one', ah_request['tenant'])
self.assertIn('org/project', ah_request['project'])
self.assertEqual('project-test2', ah_request['job'])
self.assertEqual(".*", ah_request['ref_filter'])
self.assertEqual(1, ah_request['count'])
self.assertEqual("reason text", ah_request['reason'])
def test_admin_routes_404_by_default(self):
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/autohold",
json={'job': 'project-test1',
'count': 1,
'reason': 'because',
'node_hold_expiration': 36000})
self.assertEqual(404, resp.status_code)
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/enqueue",
json={'trigger': 'gerrit',
'change': '2,1',
'pipeline': 'check'})
self.assertEqual(404, resp.status_code)
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/enqueue",
json={'trigger': 'gerrit',
'ref': 'abcd',
'newrev': 'aaaa',
'oldrev': 'bbbb',
'pipeline': 'check'})
self.assertEqual(404, resp.status_code)
def test_jobs_list(self):
jobs = self.get_url("api/tenant/tenant-one/jobs").json()
self.assertEqual(len(jobs), 10)
@ -1055,3 +1132,284 @@ class TestArtifacts(ZuulDBTestCase, BaseTestWeb, AnsibleZuulTestCase):
{'url': 'http://example.com/tarball',
'name': 'tarball'},
], test1_build['artifacts'])
class TestTenantScopedWebApi(BaseTestWeb):
config_file = 'zuul-admin-web.conf'
def test_admin_routes_no_token(self):
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/autohold",
json={'job': 'project-test1',
'count': 1,
'reason': 'because',
'node_hold_expiration': 36000})
self.assertEqual(401, resp.status_code)
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/enqueue",
json={'trigger': 'gerrit',
'change': '2,1',
'pipeline': 'check'})
self.assertEqual(401, resp.status_code)
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/enqueue",
json={'trigger': 'gerrit',
'ref': 'abcd',
'newrev': 'aaaa',
'oldrev': 'bbbb',
'pipeline': 'check'})
self.assertEqual(401, resp.status_code)
def test_bad_key_JWT_token(self):
authz = {'iss': 'zuul_operator',
'aud': 'zuul.example.com',
'sub': 'testuser',
'zuul': {
'admin': ['tenant-one', ],
},
'exp': time.time() + 3600}
token = jwt.encode(authz, key='OnlyZuulNoDana',
algorithm='HS256').decode('utf-8')
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/autohold",
headers={'Authorization': 'Bearer %s' % token},
json={'job': 'project-test1',
'count': 1,
'reason': 'because',
'node_hold_expiration': 36000})
self.assertEqual(401, resp.status_code)
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/enqueue",
headers={'Authorization': 'Bearer %s' % token},
json={'trigger': 'gerrit',
'change': '2,1',
'pipeline': 'check'})
self.assertEqual(401, resp.status_code)
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/enqueue",
headers={'Authorization': 'Bearer %s' % token},
json={'trigger': 'gerrit',
'ref': 'abcd',
'newrev': 'aaaa',
'oldrev': 'bbbb',
'pipeline': 'check'})
self.assertEqual(401, resp.status_code)
def test_expired_JWT_token(self):
authz = {'iss': 'zuul_operator',
'sub': 'testuser',
'aud': 'zuul.example.com',
'zuul': {
'admin': ['tenant-one', ]
},
'exp': time.time() - 3600}
token = jwt.encode(authz, key='NoDanaOnlyZuul',
algorithm='HS256').decode('utf-8')
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/autohold",
headers={'Authorization': 'Bearer %s' % token},
json={'job': 'project-test1',
'count': 1,
'reason': 'because',
'node_hold_expiration': 36000})
self.assertEqual(401, resp.status_code)
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/enqueue",
headers={'Authorization': 'Bearer %s' % token},
json={'trigger': 'gerrit',
'change': '2,1',
'pipeline': 'check'})
self.assertEqual(401, resp.status_code)
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/enqueue",
headers={'Authorization': 'Bearer %s' % token},
json={'trigger': 'gerrit',
'ref': 'abcd',
'newrev': 'aaaa',
'oldrev': 'bbbb',
'pipeline': 'check'})
self.assertEqual(401, resp.status_code)
def test_valid_JWT_bad_tenants(self):
authz = {'iss': 'zuul_operator',
'sub': 'testuser',
'aud': 'zuul.example.com',
'zuul': {
'admin': ['tenant-six', 'tenant-ten', ]
},
'exp': time.time() + 3600}
token = jwt.encode(authz, key='NoDanaOnlyZuul',
algorithm='HS256').decode('utf-8')
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/autohold",
headers={'Authorization': 'Bearer %s' % token},
json={'job': 'project-test1',
'count': 1,
'reason': 'because',
'node_hold_expiration': 36000})
self.assertEqual(403, resp.status_code)
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/enqueue",
headers={'Authorization': 'Bearer %s' % token},
json={'trigger': 'gerrit',
'change': '2,1',
'pipeline': 'check'})
self.assertEqual(403, resp.status_code)
resp = self.post_url(
"api/tenant/tenant-one/project/org/project/enqueue",
headers={'Authorization': 'Bearer %s' % token},
json={'trigger': 'gerrit',
'ref': 'abcd',
'newrev': 'aaaa',
'oldrev': 'bbbb',
'pipeline': 'check'})
self.assertEqual(403, resp.status_code)
def test_autohold(self):
"""Test that autohold can be set through the admin web interface"""
args = {"reason": "some reason",
"count": 1,
'job': 'project-test2',
'change': None,
'ref': None,
'node_hold_expiration': None}
authz = {'iss': 'zuul_operator',
'aud': 'zuul.example.com',
'sub': 'testuser',
'zuul': {
'admin': ['tenant-one', ]
},
'exp': time.time() + 3600}
token = jwt.encode(authz, key='NoDanaOnlyZuul',
algorithm='HS256').decode('utf-8')
req = self.post_url(
'api/tenant/tenant-one/project/org/project/autohold',
headers={'Authorization': 'Bearer %s' % token},
json=args)
self.assertEqual(200, req.status_code, req.text)
data = req.json()
self.assertEqual(True, data)
# Check result in rpc client
client = zuul.rpcclient.RPCClient('127.0.0.1',
self.gearman_server.port)
self.addCleanup(client.shutdown)
autohold_requests = client.autohold_list()
self.assertNotEqual({}, autohold_requests)
self.assertEqual(1, len(autohold_requests.keys()))
key = list(autohold_requests.keys())[0]
tenant, project, job, ref_filter = key.split(',')
self.assertEqual('tenant-one', tenant)
self.assertIn('org/project', project)
self.assertEqual('project-test2', job)
self.assertEqual(".*", ref_filter)
# Note: the value is converted from set to list by json.
self.assertEqual([1, "some reason", None], autohold_requests[key],
autohold_requests[key])
def test_enqueue(self):
"""Test that the admin web interface can enqueue a change"""
A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
A.addApproval('Code-Review', 2)
A.addApproval('Approved', 1)
authz = {'iss': 'zuul_operator',
'aud': 'zuul.example.com',
'sub': 'testuser',
'zuul': {
'admin': ['tenant-one', ]
},
'exp': time.time() + 3600}
token = jwt.encode(authz, key='NoDanaOnlyZuul',
algorithm='HS256').decode('utf-8')
path = "api/tenant/%(tenant)s/project/%(project)s/enqueue"
enqueue_args = {'tenant': 'tenant-one',
'project': 'org/project', }
change = {'trigger': 'gerrit',
'change': '1,1',
'pipeline': 'gate', }
req = self.post_url(path % enqueue_args,
headers={'Authorization': 'Bearer %s' % token},
json=change)
# The JSON returned is the same as the client's output
self.assertEqual(200, req.status_code, req.text)
data = req.json()
self.assertEqual(True, data)
self.waitUntilSettled()
def test_enqueue_ref(self):
"""Test that the admin web interface can enqueue a ref"""
p = "review.example.com/org/project"
upstream = self.getUpstreamRepos([p])
A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
A.setMerged()
A_commit = str(upstream[p].commit('master'))
self.log.debug("A commit: %s" % A_commit)
path = "api/tenant/%(tenant)s/project/%(project)s/enqueue"
enqueue_args = {'tenant': 'tenant-one',
'project': 'org/project', }
ref = {'trigger': 'gerrit',
'ref': 'master',
'oldrev': '90f173846e3af9154517b88543ffbd1691f31366',
'newrev': A_commit,
'pipeline': 'post', }
authz = {'iss': 'zuul_operator',
'aud': 'zuul.example.com',
'sub': 'testuser',
'zuul': {
'admin': ['tenant-one', ]
},
'exp': time.time() + 3600}
token = jwt.encode(authz, key='NoDanaOnlyZuul',
algorithm='HS256').decode('utf-8')
req = self.post_url(path % enqueue_args,
headers={'Authorization': 'Bearer %s' % token},
json=ref)
self.assertEqual(200, req.status_code, req.text)
# The JSON returned is the same as the client's output
data = req.json()
self.assertEqual(True, data)
self.waitUntilSettled()
def test_dequeue(self):
"""Test that the admin web interface can dequeue a change"""
self.create_branch('org/project', 'stable')
self.executor_server.hold_jobs_in_build = True
self.commitConfigUpdate('common-config', 'layouts/timer.yaml')
self.sched.reconfigure(self.config)
self.waitUntilSettled()
time.sleep(5)
authz = {'iss': 'zuul_operator',
'aud': 'zuul.example.com',
'sub': 'testuser',
'zuul': {
'admin': ['tenant-one', ]
},
'exp': time.time() + 3600}
token = jwt.encode(authz, key='NoDanaOnlyZuul',
algorithm='HS256').decode('utf-8')
path = "api/tenant/%(tenant)s/project/%(project)s/dequeue"
dequeue_args = {'tenant': 'tenant-one',
'project': 'org/project', }
change = {'ref': 'refs/heads/stable',
'pipeline': 'periodic', }
req = self.post_url(path % dequeue_args,
headers={'Authorization': 'Bearer %s' % token},
json=change)
# The JSON returned is the same as the client's output
self.assertEqual(200, req.status_code, req.text)
data = req.json()
self.assertEqual(True, data)
self.waitUntilSettled()
self.commitConfigUpdate('common-config',
'layouts/no-timer.yaml')
self.sched.reconfigure(self.config)
self.waitUntilSettled()
self.executor_server.hold_jobs_in_build = False
self.executor_server.release()
self.waitUntilSettled()
self.assertEqual(self.countJobResults(self.history, 'ABORTED'), 1)

View File

@ -22,6 +22,7 @@ import zuul.model
import zuul.web
import zuul.driver.sql
import zuul.driver.github
import zuul.lib.auth
from zuul.lib.config import get_default
@ -72,6 +73,7 @@ class WebServer(zuul.cmd.ZuulDaemonApp):
'/var/lib/zuul/web.socket')
params['connections'] = self.connections
params['auths'] = self.auths
# Validate config here before we spin up the ZuulWeb object
for conn_name, connection in self.connections.connections.items():
try:
@ -106,6 +108,10 @@ class WebServer(zuul.cmd.ZuulDaemonApp):
self.web.stop()
self.log.info("Zuul Web Server stopped")
def configure_auth(self):
self.auths = zuul.lib.auth.AuthenticatorRegistry()
self.auths.configure(self.config)
def run(self):
if self.args.command in zuul.web.COMMANDS:
self.send_command(self.args.command)
@ -119,6 +125,7 @@ class WebServer(zuul.cmd.ZuulDaemonApp):
include_drivers=[zuul.driver.sql.SQLDriver,
zuul.driver.github.GithubDriver,
zuul.driver.pagure.PagureDriver])
self.configure_auth()
self._run()
except Exception:
self.log.exception("Exception from WebServer:")

View File

@ -279,3 +279,22 @@ class WrapperInterface(object, metaclass=abc.ABCMeta):
"""
pass
class AuthenticatorInterface(object, metaclass=abc.ABCMeta):
"""The Authenticator interface to be implemented by a driver."""
@abc.abstractmethod
def authenticate(self, **kwargs):
"""verify an Authentication Token and if correct, return the user id
and the authorization claim if present (or an empty dictionary).
This method is required by the interface
:arg string rawToken: the base64-encoded Auth Token as passed in the
"Authorization" header.
:returns: a string and a dictionary
:rtype: list
"""
raise NotImplementedError

View File

@ -0,0 +1,14 @@
# Copyright 2019 OpenStack Foundation
# Copyright 2019 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

183
zuul/driver/auth/jwt.py Normal file
View File

@ -0,0 +1,183 @@
# Copyright 2019 OpenStack Foundation
# Copyright 2019 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import logging
import time
import jwt
import requests
import json
from zuul import exceptions
from zuul.driver import AuthenticatorInterface
logger = logging.getLogger("zuul.auth.jwt")
class JWTAuthenticator(AuthenticatorInterface):
"""The base class for JWT-based authentication."""
def __init__(self, **conf):
# Common configuration for all authenticators
self.uid_claim = conf.get('uid_claim', 'sub')
self.issuer_id = conf.get('issuer_id')
self.audience = conf.get('client_id')
self.realm = conf.get('realm')
self.allow_authz_override = conf.get('allow_authz_override', False)
def _decode(self, rawToken):
raise NotImplementedError
def decodeToken(self, rawToken):
"""Verify the raw token and return the decoded dictionary of claims"""
try:
decoded = self._decode(rawToken)
except jwt.exceptions.InvalidSignatureError:
raise exceptions.AuthTokenInvalidSignatureException(
realm=self.realm)
except jwt.DecodeError:
raise exceptions.AuthTokenUndecodedException(
realm=self.realm)
except jwt.exceptions.ExpiredSignatureError:
raise exceptions.TokenExpiredError(
realm=self.realm)
except jwt.InvalidIssuerError:
raise exceptions.IssuerUnknownError(
realm=self.realm)
except jwt.InvalidAudienceError:
raise exceptions.IncorrectAudienceError(
realm=self.realm)
except Exception as e:
raise exceptions.AuthTokenUnauthorizedException(
realm=self.realm,
msg=e)
if not all(x in decoded for x in ['aud', 'iss', 'exp', 'sub']):
raise exceptions.MissingClaimError(realm=self.realm)
if self.uid_claim not in decoded:
raise exceptions.MissingUIDClaimError(realm=self.realm)
expires = decoded.get('exp', 0)
if expires < time.time():
raise exceptions.TokenExpiredError(realm=self.realm)
zuul_claims = decoded.get('zuul', {})
admin_tenants = zuul_claims.get('admin', [])
if not isinstance(admin_tenants, list):
raise exceptions.IncorrectZuulAdminClaimError(realm=self.realm)
if admin_tenants and not self.allow_authz_override:
msg = ('Issuer "%s" attempt to override User "%s" '
'authorization denied')
logger.info(msg % (decoded['iss'], decoded[self.uid_claim]))
logger.debug('%r' % admin_tenants)
raise exceptions.UnauthorizedZuulAdminClaimError(
realm=self.realm)
if admin_tenants and self.allow_authz_override:
msg = ('Issuer "%s" attempt to override User "%s" '
'authorization granted')
logger.info(msg % (decoded['iss'], decoded[self.uid_claim]))
logger.debug('%r' % admin_tenants)
return decoded
def authenticate(self, rawToken):
decoded = self.decodeToken(rawToken)
return (decoded[self.uid_claim],
decoded.get('zuul', {}).get('admin', []))
class HS256Authenticator(JWTAuthenticator):
"""JWT authentication using the HS256 algorithm.
Requires a shared secret between Zuul and the identity provider."""
name = algorithm = 'HS256'
def __init__(self, **conf):
super(HS256Authenticator, self).__init__(**conf)
self.secret = conf.get('secret')
def _decode(self, rawToken):
return jwt.decode(rawToken, self.secret, issuer=self.issuer_id,
audience=self.audience,
algorithms=self.algorithm)
class RS256Authenticator(JWTAuthenticator):
"""JWT authentication using the RS256 algorithm.
Requires a copy of the public key of the identity provider."""
name = algorithm = 'RS256'
def __init__(self, **conf):
super(RS256Authenticator, self).__init__(**conf)
with open(conf.get('public_key')) as pk:
self.public_key = pk.read()
def _decode(self, rawToken):
return jwt.decode(rawToken, self.public_key, issuer=self.issuer_id,
audience=self.audience,
algorithms=self.algorithm)
class RS256withJWKSAuthenticator(JWTAuthenticator):
"""JWT authentication using the RS256 algorithm.
Requires the URL of the certificates used by the Identity Provier. It can
be found usually under the key "jwks_uri" at the provider's
.well-known/openid-configuration URL."""
algorithm = 'RS256'
name = 'RS256withJWKS'
def __init__(self, **conf):
super(RS256withJWKSAuthenticator, self).__init__(**conf)
self.keys_url = conf.get('keys_url', None)
def _decode(self, rawToken):
unverified_headers = jwt.get_unverified_header(rawToken)
key_id = unverified_headers.get('kid', None)
if key_id is None:
raise exceptions.JWKSException(
self.realm, 'No key ID in token header')
# TODO keys can probably be cached
try:
certs = requests.get(self.keys_url).json()
except Exception as e:
msg = 'Could not fetch Identity Provider keys at %s: %s'
logger.error(msg % (self.keys_url, e))
raise exceptions.JWKSException(
realm=self.realm,
msg='There was an error while fetching '
'keys for Identity Provider')
for key_dict in certs['keys']:
if key_dict.get('kid') == key_id:
key = jwt.algorithms.RSAAlgorithm.from_jwk(
json.dumps(key_dict))
return jwt.decode(rawToken, key, issuer=self.issuer_id,
audience=self.audience,
algorithms=self.algorithm)
raise exceptions.JWKSException(
self.realm,
'Cannot verify token: public key %s '
'not listed by Identity Provider' % key_id)
AUTHENTICATORS = {
'HS256': HS256Authenticator,
'RS256': RS256Authenticator,
'RS256withJWKS': RS256withJWKSAuthenticator,
}
def get_authenticator_by_name(name):
return AUTHENTICATORS[name]

View File

@ -37,3 +37,82 @@ class MergeFailure(Exception):
class ConfigurationError(Exception):
pass
# Authentication Exceptions
class AuthTokenException(Exception):
defaultMsg = 'Unknown Error'
HTTPError = 400
def __init__(self, realm=None, msg=None):
super(AuthTokenException, self).__init__(msg or self.defaultMsg)
self.realm = realm
self.error = self.__class__.__name__
self.error_description = msg or self.defaultMsg
def getAdditionalHeaders(self):
return {}
class JWKSException(AuthTokenException):
defaultMsg = 'Unknown error involving JSON Web Key Set'
class AuthTokenForbiddenException(AuthTokenException):
defaultMsg = 'Insufficient privileges'
HTTPError = 403
class AuthTokenUnauthorizedException(AuthTokenException):
defaultMsg = 'This action requires authentication'
HTTPError = 401
def getAdditionalHeaders(self):
error_header = '''Bearer realm="%s"
error="%s"
error_description="%s"'''
return {"WWW-Authenticate": error_header % (self.realm,
self.error,
self.error_description)}
class AuthTokenUndecodedException(AuthTokenUnauthorizedException):
default_msg = 'Auth Token could not be decoded'
class AuthTokenInvalidSignatureException(AuthTokenUnauthorizedException):
default_msg = 'Invalid signature'
class BearerTokenRequiredError(AuthTokenUnauthorizedException):
defaultMsg = 'Authorization with bearer token required'
class IssuerUnknownError(AuthTokenUnauthorizedException):
defaultMsg = 'Issuer unknown'
class MissingClaimError(AuthTokenUnauthorizedException):
defaultMsg = 'Token is missing claims'
class IncorrectAudienceError(AuthTokenUnauthorizedException):
defaultMsg = 'Incorrect audience'
class TokenExpiredError(AuthTokenUnauthorizedException):
defaultMsg = 'Token has expired'
class MissingUIDClaimError(MissingClaimError):
defaultMsg = 'Token is missing id claim'
class IncorrectZuulAdminClaimError(AuthTokenUnauthorizedException):
defaultMsg = (
'The "zuul.admin" claim is expected to be a list of tenants')
class UnauthorizedZuulAdminClaimError(AuthTokenUnauthorizedException):
defaultMsg = 'Issuer is not allowed to set "zuul.admin" claim'

66
zuul/lib/auth.py Normal file
View File

@ -0,0 +1,66 @@
# Copyright 2019 OpenStack Foundation
# Copyright 2019 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import logging
import re
import jwt
from zuul import exceptions
import zuul.driver.auth.jwt as auth_jwt
class AuthenticatorRegistry(object):
"""Registry of authenticators as they are declared in the configuration"""
log = logging.getLogger("Zuul.AuthenticatorRegistry")
def __init__(self):
self.authenticators = {}
self.default_realm = None
def configure(self, config):
for section_name in config.sections():
auth_match = re.match(r'^auth ([\'\"]?)(.*)(\1)$',
section_name, re.I)
if not auth_match:
continue
auth_name = auth_match.group(2)
auth_config = dict(config.items(section_name))
if 'driver' not in auth_config:
raise Exception("Auth driver needed for %s" % auth_name)
auth_driver = auth_config['driver']
try:
driver = auth_jwt.get_authenticator_by_name(auth_driver)
except IndexError:
raise Exception(
"Unknown driver %s for auth %s" % (auth_driver,
auth_name))
# TODO catch config specific errors (missing fields)
self.authenticators[auth_name] = driver(**auth_config)
if auth_config.get('default', 'false').lower() == 'true':
self.default_realm = auth_config.get('realm', 'DEFAULT')
if self.default_realm is None:
self.default_realm = 'DEFAULT'
def authenticate(self, rawToken):
unverified = jwt.decode(rawToken, verify=False)
for auth_name in self.authenticators:
authenticator = self.authenticators[auth_name]
if authenticator.issuer_id == unverified.get('iss', ''):
return authenticator.authenticate(rawToken)
# No known issuer found, use default realm
raise exceptions.IssuerUnknownError(self.default_realm)

View File

@ -33,6 +33,7 @@ import re2
import zuul.lib.repl
import zuul.model
from zuul import exceptions
import zuul.rpcclient
import zuul.zk
from zuul.lib import commandsocket
@ -43,6 +44,16 @@ cherrypy.tools.websocket = WebSocketTool()
COMMANDS = ['stop', 'repl', 'norepl']
def is_authorized(uid, tenant, authN=None):
"""Simple authorization checker. For now, relies on the passed authN
dictionary to figure out whether 'uid' is allowed 'action' on
'tenant/project'.
This is just a stub that will be expanded in subsequent patches."""
if authN is None:
authN = []
return (tenant in authN)
class SaveParamsTool(cherrypy.Tool):
"""
Save the URL parameters to allow them to take precedence over query
@ -212,6 +223,231 @@ class ZuulWebAPI(object):
self.static_cache_expiry = zuulweb.static_cache_expiry
self.status_lock = threading.Lock()
def _basic_auth_header_check(self):
"""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:
return None
error_header = '''Bearer realm="%s"
error="%s"
error_description="%s"''' % (self.zuulweb.auths.default_realm,
e,
e_desc)
cherrypy.response.status = status
cherrypy.response.headers["WWW-Authenticate"] = error_header
return '<h1>%s</h1>' % e_desc
@cherrypy.expose
@cherrypy.tools.json_in()
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
def dequeue(self, tenant, project):
basic_error = self._basic_auth_header_check()
if basic_error is not None:
return basic_error
if cherrypy.request.method != 'POST':
raise cherrypy.HTTPError(405)
# AuthN/AuthZ
rawToken = cherrypy.request.headers['Authorization'][len('Bearer '):]
try:
uid, authz = self.zuulweb.auths.authenticate(rawToken)
except exceptions.AuthTokenException as e:
for header, contents in e.getAdditionalHeaders().items():
cherrypy.response.headers[header] = contents
cherrypy.response.status = e.HTTPError
return '<h1>%s</h1>' % e.error_description
# TODO plug an actual authorization mechanism, for now rely on token
# content
if not is_authorized(uid, tenant, authz):
raise cherrypy.HTTPError(403)
self.log.info(
'User "%s" requesting "%s" on %s/%s' % (uid, 'dequeue',
tenant, project))
body = cherrypy.request.json
if 'pipeline' in body and (
('change' in body and 'ref' not in body) or
('change' not in body and 'ref' in body)):
job = self.rpc.submitJob('zuul:dequeue',
{'tenant': tenant,
'pipeline': body['pipeline'],
'project': project,
'change': body.get('change', None),
'ref': body.get('ref', None)})
result = not job.failure
resp = cherrypy.response
resp.headers['Access-Control-Allow-Origin'] = '*'
return result
else:
raise cherrypy.HTTPError(400,
'Invalid request body')
@cherrypy.expose
@cherrypy.tools.json_in()
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
def enqueue(self, tenant, project):
basic_error = self._basic_auth_header_check()
if basic_error is not None:
return basic_error
if cherrypy.request.method != 'POST':
raise cherrypy.HTTPError(405)
# AuthN/AuthZ
rawToken = cherrypy.request.headers['Authorization'][len('Bearer '):]
try:
uid, authz = self.zuulweb.auths.authenticate(rawToken)
except exceptions.AuthTokenException as e:
for header, contents in e.getAdditionalHeaders().items():
cherrypy.response.headers[header] = contents
cherrypy.response.status = e.HTTPError
return '<h1>%s</h1>' % e.error_description
# TODO plug an actual authorization mechanism, for now rely on token
# content
if not is_authorized(uid, tenant, authz):
raise cherrypy.HTTPError(403)
self.log.info(
'User "%s" requesting "%s" on %s/%s' % (uid, 'enqueue',
tenant, project))
body = cherrypy.request.json
if all(p in body for p in ['trigger', 'change', 'pipeline']):
return self._enqueue(tenant, project, **body)
elif all(p in body for p in ['trigger', 'ref', 'oldrev',
'newrev', 'pipeline']):
return self._enqueue_ref(tenant, project, **body)
else:
raise cherrypy.HTTPError(400,
'Invalid request body')
def _enqueue(self, tenant, project, trigger, change, pipeline, **kwargs):
job = self.rpc.submitJob('zuul:enqueue',
{'tenant': tenant,
'pipeline': pipeline,
'project': project,
'trigger': trigger,
'change': change, })
result = not job.failure
resp = cherrypy.response
resp.headers['Access-Control-Allow-Origin'] = '*'
return result
def _enqueue_ref(self, tenant, project, trigger, ref,
oldrev, newrev, pipeline, **kwargs):
job = self.rpc.submitJob('zuul:enqueue_ref',
{'tenant': tenant,
'pipeline': pipeline,
'project': project,
'trigger': trigger,
'ref': ref,
'oldrev': oldrev,
'newrev': newrev, })
result = not job.failure
resp = cherrypy.response
resp.headers['Access-Control-Allow-Origin'] = '*'
return result
@cherrypy.expose
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
def autohold_list(self, tenant, *args, **kwargs):
# we don't use json_in because a payload is not mandatory with GET
if cherrypy.request.method != 'GET':
raise cherrypy.HTTPError(405)
# filter by project if passed as a query string
project = cherrypy.request.params.get('project', None)
return self._autohold_list(tenant, project)
@cherrypy.expose
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
def autohold(self, tenant, project=None):
# we don't use json_in because a payload is not mandatory with GET
# Note: GET handling is redundant with autohold_list
# and could be removed.
if cherrypy.request.method == 'GET':
return self._autohold_list(tenant, project)
elif cherrypy.request.method == 'POST':
basic_error = self._basic_auth_header_check()
if basic_error is not None:
return basic_error
# AuthN/AuthZ
rawToken = \
cherrypy.request.headers['Authorization'][len('Bearer '):]
try:
uid, authz = self.zuulweb.auths.authenticate(rawToken)
except exceptions.AuthTokenException as e:
for header, contents in e.getAdditionalHeaders().items():
cherrypy.response.headers[header] = contents
cherrypy.response.status = e.HTTPError
return '<h1>%s</h1>' % e.error_description
# TODO plug an actual authorization mechanism, for now rely on
# token content
if not is_authorized(uid, tenant, authz):
raise cherrypy.HTTPError(403)
self.log.info(
'User "%s" requesting "%s" on %s/%s' % (uid, 'autohold',
tenant, project))
length = int(cherrypy.request.headers['Content-Length'])
body = cherrypy.request.body.read(length)
try:
jbody = json.loads(body.decode('utf-8'))
except ValueError:
raise cherrypy.HTTPError(406, 'JSON body required')
if (jbody.get('change') and jbody.get('ref')):
raise cherrypy.HTTPError(400,
'change and ref are '
'mutually exclusive')
else:
jbody['change'] = jbody.get('change', None)
jbody['ref'] = jbody.get('ref', None)
if all(p in jbody for p in ['job', 'change', 'ref',
'count', 'reason',
'node_hold_expiration']):
data = {'tenant': tenant,
'project': project,
'job': jbody['job'],
'change': jbody['change'],
'ref': jbody['ref'],
'reason': jbody['reason'],
'count': jbody['count'],
'node_hold_expiration': jbody['node_hold_expiration']}
result = self.rpc.submitJob('zuul:autohold', data)
return not result.failure
else:
raise cherrypy.HTTPError(400,
'Invalid request body')
else:
raise cherrypy.HTTPError(405)
def _autohold_list(self, tenant, project=None):
job = self.rpc.submitJob('zuul:autohold_list', {})
if job.failure:
raise cherrypy.HTTPError(500, 'autohold-list failed')
else:
payload = json.loads(job.data[0])
result = []
for key in payload:
_tenant, _project, job, ref_filter = key.split(',')
count, reason, hold_expiration = payload[key]
if tenant == _tenant:
if project is None or _project.endswith(project):
result.append(
{'tenant': _tenant,
'project': _project,
'job': job,
'ref_filter': ref_filter,
'count': count,
'reason': reason,
'node_hold_expiration': hold_expiration})
return result
@cherrypy.expose
@cherrypy.tools.json_out(content_type='application/json; charset=utf-8')
def index(self):
@ -731,7 +967,8 @@ class ZuulWeb(object):
info=None,
static_path=None,
zk_hosts=None,
command_socket=None):
command_socket=None,
auths=None):
self.start_time = time.time()
self.listen_address = listen_address
self.listen_port = listen_port
@ -748,6 +985,7 @@ class ZuulWeb(object):
if zk_hosts:
self.zk.connect(hosts=zk_hosts, read_only=True)
self.connections = connections
self.auths = auths
self.stream_manager = StreamManager()
self.command_socket = commandsocket.CommandSocket(command_socket)
@ -780,6 +1018,24 @@ class ZuulWeb(object):
controller=api, action='jobs')
route_map.connect('api', '/api/tenant/{tenant}/job/{job_name}',
controller=api, action='job')
# if no auth configured, deactivate admin routes
if self.auths.authenticators:
# route order is important, put project actions before the more
# generic tenant/{tenant}/project/{project} route
route_map.connect(
'api',
'/api/tenant/{tenant}/project/{project:.*}/autohold',
controller=api, action='autohold')
route_map.connect(
'api',
'/api/tenant/{tenant}/project/{project:.*}/enqueue',
controller=api, action='enqueue')
route_map.connect(
'api',
'/api/tenant/{tenant}/project/{project:.*}/dequeue',
controller=api, action='dequeue')
route_map.connect('api', '/api/tenant/{tenant}/autohold',
controller=api, action='autohold_list')
route_map.connect('api', '/api/tenant/{tenant}/projects',
controller=api, action='projects')
route_map.connect('api', '/api/tenant/{tenant}/project/{project:.*}',
@ -902,9 +1158,11 @@ class ZuulWeb(object):
if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG)
import zuul.lib.connections
import zuul.lib.authenticators
connections = zuul.lib.connections.ConnectionRegistry()
auths = zuul.lib.authenticators.AuthenticatorRegistry()
z = ZuulWeb(listen_address="127.0.0.1", listen_port=9000,
gear_server="127.0.0.1", gear_port=4730,
connections=connections)
connections=connections, auths=auths)
z.start()
cherrypy.engine.block()