Allow operator to generate auth tokens through the CLI

Add the "create-auth-token" subcommand to the zuul CLI; this subcommand
allows an operator to create an authentication token for a user with
customized authorizations.

This requires at least one auth section with a signing key to be specified in
Zuul's configuration file.

This is meant as a way to provide authorizations "manually" on test
deployments, until a proper authorization engine is plugged into Zuul,
in a subsequent patch.

Change-Id: I039e70cd8d5e502795772af0ea2a336c08316f2c
This commit is contained in:
mhuin 2019-02-11 19:04:10 +01:00 committed by Matthieu Huin
parent 6a7235fb50
commit 6ca6aff138
3 changed files with 191 additions and 2 deletions

View File

@ -135,3 +135,18 @@ Example::
This command validates the tenant configuration schema. It exits '-1' in This command validates the tenant configuration schema. It exits '-1' in
case of errors detected. 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

View File

@ -12,27 +12,32 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import io
import os import os
import sys import sys
import subprocess import subprocess
import time
import configparser import configparser
import fixtures import fixtures
import jwt
from tests.base import BaseTestCase from tests.base import BaseTestCase
from tests.base import FIXTURE_DIR from tests.base import FIXTURE_DIR
class TestTenantValidationClient(BaseTestCase): class BaseClientTestCase(BaseTestCase):
config_file = 'zuul.conf' config_file = 'zuul.conf'
def setUp(self): def setUp(self):
super(TestTenantValidationClient, self).setUp() super(BaseClientTestCase, self).setUp()
self.test_root = self.useFixture(fixtures.TempDir( self.test_root = self.useFixture(fixtures.TempDir(
rootdir=os.environ.get("ZUUL_TEST_ROOT"))).path rootdir=os.environ.get("ZUUL_TEST_ROOT"))).path
self.config = configparser.ConfigParser() self.config = configparser.ConfigParser()
self.config.read(os.path.join(FIXTURE_DIR, self.config_file)) self.config.read(os.path.join(FIXTURE_DIR, self.config_file))
class TestTenantValidationClient(BaseClientTestCase):
def test_client_tenant_conf_check(self): def test_client_tenant_conf_check(self):
self.config.set( self.config.set(
@ -61,3 +66,86 @@ class TestTenantValidationClient(BaseTestCase):
self.assertIn( self.assertIn(
b"expected a dictionary for dictionary", out, b"expected a dictionary for dictionary", out,
"Expected error message not found") "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))

View File

@ -17,8 +17,10 @@
import argparse import argparse
import babel.dates import babel.dates
import datetime import datetime
import jwt
import logging import logging
import prettytable import prettytable
import re
import sys import sys
import time import time
import textwrap import textwrap
@ -160,6 +162,44 @@ class Client(zuul.cmd.ZuulApp):
help='validate the tenant configuration') help='validate the tenant configuration')
cmd_conf_check.set_defaults(func=self.validate) 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 return parser
def parseArguments(self, args=None): def parseArguments(self, args=None):
@ -286,6 +326,52 @@ class Client(zuul.cmd.ZuulApp):
ref=self.args.ref) ref=self.args.ref)
return r 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): def promote(self):
client = zuul.rpcclient.RPCClient( client = zuul.rpcclient.RPCClient(
self.server, self.port, self.ssl_key, self.ssl_cert, self.ssl_ca) self.server, self.port, self.ssl_key, self.ssl_cert, self.ssl_ca)