From c1bb5ccfcab927337ae771243b0b11c1a3043dc8 Mon Sep 17 00:00:00 2001 From: Dov Reshef Date: Mon, 3 Sep 2018 11:01:21 +0300 Subject: [PATCH] Add partial support for out-of-tree client authentication providers (token only, no caching) --- config/exec_provider.py | 90 ++++++++++++++++++++++ config/exec_provider_test.py | 140 +++++++++++++++++++++++++++++++++++ config/kube_config.py | 24 ++++-- config/kube_config_test.py | 33 ++++++++- 4 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 config/exec_provider.py create mode 100644 config/exec_provider_test.py diff --git a/config/exec_provider.py b/config/exec_provider.py new file mode 100644 index 00000000..9b8b645c --- /dev/null +++ b/config/exec_provider.py @@ -0,0 +1,90 @@ +# Copyright 2018 The Kubernetes Authors. +# +# 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 json +import os +import subprocess +import sys + +from .config_exception import ConfigException + + +class ExecProvider(object): + """ + Implementation of the proposal for out-of-tree client authentication providers + as described here -- + https://github.com/kubernetes/community/blob/master/contributors/design-proposals/auth/kubectl-exec-plugins.md + + Missing from implementation: + + * TLS cert support + * caching + """ + + def __init__(self, exec_config): + for key in ['command', 'apiVersion']: + if key not in exec_config: + raise ConfigException( + 'exec: malformed request. missing key \'%s\'' % key) + self.api_version = exec_config['apiVersion'] + self.args = [exec_config['command']] + if 'args' in exec_config: + self.args.extend(exec_config['args']) + self.env = os.environ.copy() + if 'env' in exec_config: + additional_vars = {} + for item in exec_config['env']: + name = item['name'] + value = item['value'] + additional_vars[name] = value + self.env.update(additional_vars) + + def run(self, previous_response=None): + kubernetes_exec_info = { + 'apiVersion': self.api_version, + 'kind': 'ExecCredential', + 'spec': { + 'interactive': sys.stdout.isatty() + } + } + if previous_response: + kubernetes_exec_info['spec']['response'] = previous_response + self.env['KUBERNETES_EXEC_INFO'] = json.dumps(kubernetes_exec_info) + process = subprocess.Popen( + self.args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=self.env, + universal_newlines=True) + (stdout, stderr) = process.communicate() + exit_code = process.wait() + if exit_code != 0: + msg = 'exec: process returned %d' % exit_code + stderr = stderr.strip() + if stderr: + msg += '. %s' % stderr + raise ConfigException(msg) + try: + data = json.loads(stdout) + except ValueError as de: + raise ConfigException( + 'exec: failed to decode process output: %s' % de) + for key in ('apiVersion', 'kind', 'status'): + if key not in data: + raise ConfigException( + 'exec: malformed response. missing key \'%s\'' % key) + if data['apiVersion'] != self.api_version: + raise ConfigException( + 'exec: plugin api version %s does not match %s' % + (data['apiVersion'], self.api_version)) + return data['status'] diff --git a/config/exec_provider_test.py b/config/exec_provider_test.py new file mode 100644 index 00000000..a564e766 --- /dev/null +++ b/config/exec_provider_test.py @@ -0,0 +1,140 @@ +# Copyright 2018 The Kubernetes Authors. +# +# 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 os +import unittest + +import mock + +from .config_exception import ConfigException +from .exec_provider import ExecProvider + + +class ExecProviderTest(unittest.TestCase): + + def setUp(self): + self.input_ok = { + 'command': 'aws-iam-authenticator token -i dummy', + 'apiVersion': 'client.authentication.k8s.io/v1beta1' + } + self.output_ok = """ + { + "apiVersion": "client.authentication.k8s.io/v1beta1", + "kind": "ExecCredential", + "status": { + "token": "dummy" + } + } + """ + + def test_missing_input_keys(self): + exec_configs = [{}, {'command': ''}, {'apiVersion': ''}] + for exec_config in exec_configs: + with self.assertRaises(ConfigException) as context: + ExecProvider(exec_config) + self.assertIn('exec: malformed request. missing key', + context.exception.args[0]) + + @mock.patch('subprocess.Popen') + def test_error_code_returned(self, mock): + instance = mock.return_value + instance.wait.return_value = 1 + instance.communicate.return_value = ('', '') + with self.assertRaises(ConfigException) as context: + ep = ExecProvider(self.input_ok) + ep.run() + self.assertIn('exec: process returned %d' % + instance.wait.return_value, context.exception.args[0]) + + @mock.patch('subprocess.Popen') + def test_nonjson_output_returned(self, mock): + instance = mock.return_value + instance.wait.return_value = 0 + instance.communicate.return_value = ('', '') + with self.assertRaises(ConfigException) as context: + ep = ExecProvider(self.input_ok) + ep.run() + self.assertIn('exec: failed to decode process output', + context.exception.args[0]) + + @mock.patch('subprocess.Popen') + def test_missing_output_keys(self, mock): + instance = mock.return_value + instance.wait.return_value = 0 + outputs = [ + """ + { + "kind": "ExecCredential", + "status": { + "token": "dummy" + } + } + """, """ + { + "apiVersion": "client.authentication.k8s.io/v1beta1", + "status": { + "token": "dummy" + } + } + """, """ + { + "apiVersion": "client.authentication.k8s.io/v1beta1", + "kind": "ExecCredential" + } + """ + ] + for output in outputs: + instance.communicate.return_value = (output, '') + with self.assertRaises(ConfigException) as context: + ep = ExecProvider(self.input_ok) + ep.run() + self.assertIn('exec: malformed response. missing key', + context.exception.args[0]) + + @mock.patch('subprocess.Popen') + def test_mismatched_api_version(self, mock): + instance = mock.return_value + instance.wait.return_value = 0 + wrong_api_version = 'client.authentication.k8s.io/v1' + output = """ + { + "apiVersion": "%s", + "kind": "ExecCredential", + "status": { + "token": "dummy" + } + } + """ % wrong_api_version + instance.communicate.return_value = (output, '') + with self.assertRaises(ConfigException) as context: + ep = ExecProvider(self.input_ok) + ep.run() + self.assertIn( + 'exec: plugin api version %s does not match' % + wrong_api_version, + context.exception.args[0]) + + @mock.patch('subprocess.Popen') + def test_ok_01(self, mock): + instance = mock.return_value + instance.wait.return_value = 0 + instance.communicate.return_value = (self.output_ok, '') + ep = ExecProvider(self.input_ok) + result = ep.run() + self.assertTrue(isinstance(result, dict)) + self.assertTrue('token' in result) + + +if __name__ == '__main__': + unittest.main() diff --git a/config/kube_config.py b/config/kube_config.py index ddd3d02b..ffcc57ce 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -1,4 +1,4 @@ -# Copyright 2016 The Kubernetes Authors. +# Copyright 2018 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -30,6 +30,7 @@ from six import PY3 from kubernetes.client import ApiClient, Configuration +from kubernetes.config.exec_provider import ExecProvider from .config_exception import ConfigException from .dateutil import UTC, format_rfc3339, parse_rfc3339 @@ -172,11 +173,10 @@ def _load_authentication(self): section of kube-config and stops if it finds a valid authentication method. The order of authentication methods is: - 1. GCP auth-provider - 2. token_data - 3. token field (point to a token file) - 4. oidc auth-provider - 5. username/password + 1. auth-provider (gcp, azure, oidc) + 2. token field (point to a token file) + 3. exec provided plugin + 4. username/password """ if not self._user: return @@ -184,6 +184,8 @@ def _load_authentication(self): return if self._load_user_token(): return + if self._load_from_exec_plugin(): + return self._load_user_pass_token() def _load_auth_provider_token(self): @@ -340,6 +342,16 @@ def _refresh_oidc(self, provider): provider['config'].value['id-token'] = refresh['id_token'] provider['config'].value['refresh-token'] = refresh['refresh_token'] + def _load_from_exec_plugin(self): + if 'exec' not in self._user: + return + status = ExecProvider(self._user['exec']).run() + if 'token' not in status: + raise ConfigException( + 'Missing token field in exec plugin response') + self.token = "Bearer %s" % status['token'] + return True + def _load_user_token(self): token = FileOrData( self._user, 'tokenFile', 'token', diff --git a/config/kube_config_test.py b/config/kube_config_test.py index a79efb9a..cd64f91b 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -1,4 +1,4 @@ -# Copyright 2016 The Kubernetes Authors. +# Copyright 2018 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -422,6 +422,13 @@ class TestKubeConfigLoader(BaseTestCase): "user": "non_existing_user" } }, + { + "name": "exec_cred_user", + "context": { + "cluster": "default", + "user": "exec_cred_user" + } + }, ], "clusters": [ { @@ -573,6 +580,16 @@ class TestKubeConfigLoader(BaseTestCase): "client-key-data": TEST_CLIENT_KEY_BASE64, } }, + { + "name": "exec_cred_user", + "user": { + "exec": { + "apiVersion": "client.authentication.k8s.io/v1beta1", + "command": "aws-iam-authenticator", + "args": ["token", "-i", "dummy-cluster"] + } + } + }, ] } @@ -849,6 +866,20 @@ def test_non_existing_user(self): active_context="non_existing_user").load_and_set(actual) self.assertEqual(expected, actual) + @mock.patch('kubernetes.config.kube_config.ExecProvider.run') + def test_user_exec_auth(self, mock): + token = "dummy" + mock.return_value = { + "token": token + } + expected = FakeConfig(host=TEST_HOST, api_key={ + "authorization": BEARER_TOKEN_FORMAT % token}) + actual = FakeConfig() + KubeConfigLoader( + config_dict=self.TEST_KUBE_CONFIG, + active_context="exec_cred_user").load_and_set(actual) + self.assertEqual(expected, actual) + if __name__ == '__main__': unittest.main()