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:
parent
6a7235fb50
commit
6ca6aff138
|
@ -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
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in New Issue