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:
parent
86f071464d
commit
6a7235fb50
|
@ -1,5 +1,7 @@
|
|||
:title: Zuul Client
|
||||
|
||||
.. _zuul-client:
|
||||
|
||||
Zuul Client
|
||||
===========
|
||||
|
||||
|
|
|
@ -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
|
||||
~~~~~~~~~
|
||||
|
||||
|
|
|
@ -19,4 +19,5 @@ provides to in-project configuration.
|
|||
tenants
|
||||
monitoring
|
||||
client
|
||||
tenant-scoped-rest-api
|
||||
troubleshooting
|
||||
|
|
|
@ -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.
|
|
@ -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
|
||||
|
|
|
@ -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".
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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:")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
|
@ -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]
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
|
@ -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()
|
||||
|
|
Loading…
Reference in New Issue