diff --git a/doc/source/admin/client.rst b/doc/source/admin/client.rst index b2a2f89489..c2419b2906 100644 --- a/doc/source/admin/client.rst +++ b/doc/source/admin/client.rst @@ -135,3 +135,18 @@ Example:: This command validates the tenant configuration schema. It exits '-1' in case of errors detected. + +create-auth-token +^^^^^^^^^^^^^^^^^ +.. program-output:: zuul create-auth-token --help + +Example:: + + zuul create-auth-token --auth-config zuul-operator --user venkman --tenant tenantA --expires-in 1800 + +The return value is the value of the ``Authorization`` header the user must set +when querying a protected endpoint on Zuul's REST API. + +Example:: + + bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwOi8vbWFuYWdlc2Yuc2ZyZG90ZXN0aW5zdGFuY2Uub3JnIiwienV1bC50ZW5hbnRzIjp7ImxvY2FsIjoiKiJ9LCJleHAiOjE1Mzc0MTcxOTguMzc3NTQ0fQ.DLbKx1J84wV4Vm7sv3zw9Bw9-WuIka7WkPQxGDAHz7s diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index bc794f272b..03178304d6 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -12,27 +12,32 @@ # License for the specific language governing permissions and limitations # under the License. +import io import os import sys import subprocess +import time import configparser import fixtures +import jwt from tests.base import BaseTestCase from tests.base import FIXTURE_DIR -class TestTenantValidationClient(BaseTestCase): +class BaseClientTestCase(BaseTestCase): config_file = 'zuul.conf' def setUp(self): - super(TestTenantValidationClient, self).setUp() + super(BaseClientTestCase, self).setUp() self.test_root = self.useFixture(fixtures.TempDir( rootdir=os.environ.get("ZUUL_TEST_ROOT"))).path self.config = configparser.ConfigParser() self.config.read(os.path.join(FIXTURE_DIR, self.config_file)) + +class TestTenantValidationClient(BaseClientTestCase): def test_client_tenant_conf_check(self): self.config.set( @@ -61,3 +66,86 @@ class TestTenantValidationClient(BaseTestCase): self.assertIn( b"expected a dictionary for dictionary", out, "Expected error message not found") + + +class TestWebTokenClient(BaseClientTestCase): + config_file = 'zuul-admin-web.conf' + + def test_no_authenticator(self): + """Test that token generation is not possible without authenticator""" + old_conf = io.StringIO() + self.config.write(old_conf) + self.config.remove_section('auth zuul_operator') + self.config.write( + open(os.path.join(self.test_root, 'no_zuul_operator.conf'), 'w')) + p = subprocess.Popen( + [os.path.join(sys.prefix, 'bin/zuul'), + '-c', os.path.join(self.test_root, 'no_zuul_operator.conf'), + 'create-auth-token', + '--auth-config', 'zuul_operator', + '--user', 'marshmallow_man', + '--tenant', 'tenant_one', ], + stdout=subprocess.PIPE) + out, _ = p.communicate() + old_conf.seek(0) + self.config = configparser.ConfigParser() + self.config.read_file(old_conf) + self.assertEqual(p.returncode, 1, 'The command must exit 1') + + def test_unsupported_driver(self): + """Test that token generation is not possible with wrong driver""" + old_conf = io.StringIO() + self.config.write(old_conf) + self.config.add_section('auth someauth') + self.config.set('auth someauth', 'driver', 'RS256withJWKS') + self.config.write( + open(os.path.join(self.test_root, 'JWKS.conf'), 'w')) + p = subprocess.Popen( + [os.path.join(sys.prefix, 'bin/zuul'), + '-c', os.path.join(self.test_root, 'JWKS.conf'), + 'create-auth-token', + '--auth-config', 'someauth', + '--user', 'marshmallow_man', + '--tenant', 'tenant_one', ], + stdout=subprocess.PIPE) + out, _ = p.communicate() + old_conf.seek(0) + self.config = configparser.ConfigParser() + self.config.read_file(old_conf) + self.assertEqual(p.returncode, 1, 'The command must exit 1') + + def test_token_generation(self): + """Test token generation""" + self.config.write( + open(os.path.join(self.test_root, 'good.conf'), 'w')) + p = subprocess.Popen( + [os.path.join(sys.prefix, 'bin/zuul'), + '-c', os.path.join(self.test_root, 'good.conf'), + 'create-auth-token', + '--auth-conf', 'zuul_operator', + '--user', 'marshmallow_man', + '--tenant', 'tenant_one', ], + stdout=subprocess.PIPE) + now = time.time() + out, _ = p.communicate() + self.assertEqual(p.returncode, 0, 'The command must exit 0') + self.assertTrue(out.startswith(b"Bearer "), out) + # there is a trailing carriage return in the output + token = jwt.decode(out[len("Bearer "):-1], + key=self.config.get( + 'auth zuul_operator', + 'secret'), + algorithm=self.config.get( + 'auth zuul_operator', + 'driver'), + audience=self.config.get( + 'auth zuul_operator', + 'client_id'),) + self.assertEqual('marshmallow_man', token.get('sub')) + self.assertEqual('zuul_operator', token.get('iss')) + self.assertEqual('zuul.example.com', token.get('aud')) + admin_tenants = token.get('zuul', {}).get('admin', []) + self.assertTrue('tenant_one' in admin_tenants, admin_tenants) + # allow one minute for the process to run + self.assertTrue(600 <= int(token['exp']) - now < 660, + (token['exp'], now)) diff --git a/zuul/cmd/client.py b/zuul/cmd/client.py index e08008304f..e9f3dd0ba1 100755 --- a/zuul/cmd/client.py +++ b/zuul/cmd/client.py @@ -17,8 +17,10 @@ import argparse import babel.dates import datetime +import jwt import logging import prettytable +import re import sys import time import textwrap @@ -160,6 +162,44 @@ class Client(zuul.cmd.ZuulApp): help='validate the tenant configuration') cmd_conf_check.set_defaults(func=self.validate) + cmd_create_auth_token = subparsers.add_parser( + 'create-auth-token', + help='create an Authentication Token for the web API', + formatter_class=argparse.RawDescriptionHelpFormatter, + description=textwrap.dedent('''\ + Create an Authentication Token for the administration web API + + Create a bearer token that can be used to access Zuul's + administration web API. This is typically used to delegate + privileged actions such as enqueueing and autoholding to + third parties, scoped to a single tenant. + At least one authenticator must be configured with a secret + that can be used to sign the token.''')) + cmd_create_auth_token.add_argument( + '--auth-config', + help=('The authenticator to use. ' + 'Must match an authenticator defined in zuul\'s ' + 'configuration file.'), + default='zuul_operator', + required=True) + cmd_create_auth_token.add_argument( + '--tenant', + help='tenant name', + required=True) + cmd_create_auth_token.add_argument( + '--user', + help=("The user's name. Used for traceability in logs."), + default=None, + required=True) + cmd_create_auth_token.add_argument( + '--expires-in', + help=('Token validity duration in seconds ' + '(default: %i)' % 600), + type=int, + default=600, + required=False) + cmd_create_auth_token.set_defaults(func=self.create_auth_token) + return parser def parseArguments(self, args=None): @@ -286,6 +326,52 @@ class Client(zuul.cmd.ZuulApp): ref=self.args.ref) return r + def create_auth_token(self): + auth_section = '' + for section_name in self.config.sections(): + if re.match(r'^auth ([\'\"]?)%s(\1)$' % self.args.auth_config, + section_name, re.I): + auth_section = section_name + break + if auth_section == '': + print('"%s" authenticator configuration not found.' + % self.args.auth_config) + sys.exit(1) + token = {'exp': time.time() + self.args.expires_in, + 'iss': get_default(self.config, auth_section, 'issuer_id'), + 'aud': get_default(self.config, auth_section, 'client_id'), + 'sub': self.args.user, + 'zuul': {'admin': [self.args.tenant, ]}, + } + driver = get_default( + self.config, auth_section, 'driver') + if driver == 'HS256': + key = get_default(self.config, auth_section, 'secret') + elif driver == 'RS256': + private_key = get_default(self.config, auth_section, 'private_key') + try: + with open(private_key, 'r') as pk: + key = pk.read() + except Exception as e: + print('Could not read private key at "%s": %s' % (private_key, + e)) + sys.exit(1) + else: + print('Unknown or unsupported authenticator driver "%s"' % driver) + sys.exit(1) + try: + auth_token = jwt.encode(token, + key=key, + algorithm=driver).decode('utf-8') + print("Bearer %s" % auth_token) + err_code = 0 + except Exception as e: + print("Error when generating Auth Token") + print(e) + err_code = 1 + finally: + sys.exit(err_code) + def promote(self): client = zuul.rpcclient.RPCClient( self.server, self.port, self.ssl_key, self.ssl_cert, self.ssl_ca)