Add partial support for out-of-tree client authentication providers (token only, no caching)
This commit is contained in:
parent
c9b3113216
commit
becae56634
90
config/exec_provider.py
Normal file
90
config/exec_provider.py
Normal file
@ -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']
|
||||
140
config/exec_provider_test.py
Normal file
140
config/exec_provider_test.py
Normal file
@ -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()
|
||||
@ -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.
|
||||
@ -16,6 +16,7 @@ import atexit
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
@ -30,6 +31,7 @@ from requests_oauthlib import OAuth2Session
|
||||
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 +174,10 @@ class KubeConfigLoader(object):
|
||||
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 +185,8 @@ class KubeConfigLoader(object):
|
||||
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 +343,19 @@ class KubeConfigLoader(object):
|
||||
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
|
||||
try:
|
||||
status = ExecProvider(self._user['exec']).run()
|
||||
if 'token' not in status:
|
||||
logging.error('exec: missing token field in plugin output')
|
||||
return None
|
||||
self.token = "Bearer %s" % status['token']
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(str(e))
|
||||
|
||||
def _load_user_token(self):
|
||||
token = FileOrData(
|
||||
self._user, 'tokenFile', 'token',
|
||||
|
||||
@ -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 @@ class TestKubeConfigLoader(BaseTestCase):
|
||||
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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user