diff --git a/setup.cfg b/setup.cfg index 7a8d2ac..5590349 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,4 +38,5 @@ where = src [options.entry_points] console_scripts = pybritive = pybritive.cli_interface:safe_cli - pybritive-aws-cred-process = pybritive.helpers.aws_credential_process:main \ No newline at end of file + pybritive-aws-cred-process = pybritive.helpers.aws_credential_process:main + pybritive-kube-exec = pybritive.helpers.k8s_exec:main \ No newline at end of file diff --git a/src/pybritive/britive_cli.py b/src/pybritive/britive_cli.py index 015366c..da4d93d 100644 --- a/src/pybritive/britive_cli.py +++ b/src/pybritive/britive_cli.py @@ -44,6 +44,16 @@ def __init__(self, tenant_name: str = None, token: str = None, silent: bool = Fa self.credential_manager = None self.verbose_checkout = False self.checkout_progress_previous_message = None + self.cachable_modes = { + 'awscredentialprocess': { + 'app_type': 'AWS', + 'expiration_jmespath': 'expirationTime' + }, + 'kube-exec': { + 'app_type': 'Kubernetes', + 'expiration_jmespath': 'expirationTime' + } + } self.browser = None def set_output_format(self, output_format: str): @@ -342,7 +352,7 @@ def _get_app_type(self, application_id): raise click.ClickException(f'Application {application_id} not found') def __get_cloud_credential_printer(self, app_type, console, mode, profile, silent, credentials, - aws_credentials_file, gcloud_key_file): + aws_credentials_file, gcloud_key_file, k8s_processor): if app_type in ['AWS', 'AWS Standalone']: return printer.AwsCloudCredentialPrinter( console=console, @@ -372,14 +382,25 @@ def __get_cloud_credential_printer(self, app_type, console, mode, profile, silen cli=self, gcloud_key_file=gcloud_key_file ) - return printer.GenericCloudCredentialPrinter( - console=console, - mode=mode, - profile=profile, - credentials=credentials, - silent=silent, - cli=self - ) + elif app_type in ['Kubernetes']: + return printer.KubernetesCredentialPrinter( + console=console, + mode=mode, + profile=profile, + credentials=credentials, + silent=silent, + cli=self, + k8s_processor=k8s_processor + ) + else: + return printer.GenericCloudCredentialPrinter( + console=console, + mode=mode, + profile=profile, + credentials=credentials, + silent=silent, + cli=self + ) def checkin(self, profile, console): self.login() @@ -474,9 +495,18 @@ def checkout(self, alias, blocktime, console, justification, mode, maxpolltime, force_renew, aws_credentials_file, gcloud_key_file, verbose): credentials = None app_type = None - credential_process_creds_found = False + cached_credentials_found = False + k8s_processor = None self.verbose_checkout = verbose + # handle kube-exec since the profile is actually going to be passed in via another method + # and perform some basic validation so we don't waste time performing a checkout when we + # will not be able to return a response back to kubectl via the exec command + if mode == 'kube-exec' or 'KUBERNETES_EXEC_INFO' in os.environ: + mode = 'kube-exec' # set for downstream processes if we are basing this only on the env var being present + from .helpers.k8s_exec_credential_builder import KubernetesExecCredentialProcessor + k8s_processor = KubernetesExecCredentialProcessor() + # these 2 modes implicitly say that console access should be checked out without having to provide # the --console flag if mode and (mode == 'console' or mode.startswith('browser')): @@ -488,22 +518,23 @@ def checkout(self, alias, blocktime, console, justification, mode, maxpolltime, self._validate_justification(justification) - if mode == 'awscredentialprocess': - self.silent = True # the aws credential process CANNOT output anything other than the expected JSON - # we need to check the credential process cache for the credentials first - # then check to see if they are expired - # if not simply return those credentials - # if they are expired - app_type = 'AWS' # just hardcode as we know for sure this is for AWS - credentials = Cache(passphrase=passphrase).get_awscredentialprocess(profile_name=alias or profile) + if mode in self.cachable_modes: + self.silent = True # CANNOT output anything other than the expected JSON + # we need to check the cache for the credentials first and then check to see if they are expired + # if not simply return those credentials, if they are expired, continue to do an actual checkout + app_type = self.cachable_modes[mode]['app_type'] + credentials = Cache(passphrase=passphrase).get_credentials(profile_name=alias or profile, mode=mode) if credentials: - expiration_timestamp_str = credentials['expirationTime'].replace('Z', '') + expiration_timestamp_str = jmespath.search( + expression=self.cachable_modes[mode]['expiration_jmespath'], + data=credentials + ).replace('Z', '') expires = datetime.fromisoformat(expiration_timestamp_str) now = datetime.utcnow() if now >= expires: # check to ensure the credentials are still valid, if not, set to None and get new credentials = None else: - credential_process_creds_found = True + cached_credentials_found = True parts = self._split_profile_into_parts(profile) @@ -518,7 +549,7 @@ def checkout(self, alias, blocktime, console, justification, mode, maxpolltime, 'justification': justification } - if not credential_process_creds_found: # nothing found via aws cred process or not aws cred process mode + if not cached_credentials_found: # nothing found in cache, cache is expired, or not a cachable mode response = self._checkout(**params) app_type = self._get_app_type(response['appContainerId']) credentials = response['credentials'] @@ -533,16 +564,17 @@ def checkout(self, alias, blocktime, console, justification, mode, maxpolltime, self.print('checking in the profile to get renewed credentials....standby') self.checkin(profile=profile) response = self._checkout(**params) - credential_process_creds_found = False # need to write new creds to cache + cached_credentials_found = False # need to write new creds to cache credentials = response['credentials'] if alias: # do this down here, so we know that the profile is valid and a checkout was successful self.config.save_profile_alias(alias=alias, profile=profile) - if mode == 'awscredentialprocess' and not credential_process_creds_found: - Cache(passphrase=passphrase).save_awscredentialprocess( + if mode in self.cachable_modes and not cached_credentials_found: + Cache(passphrase=passphrase).save_credentials( profile_name=alias or profile, - credentials=credentials + credentials=credentials, + mode=mode ) self.__get_cloud_credential_printer( @@ -553,7 +585,8 @@ def checkout(self, alias, blocktime, console, justification, mode, maxpolltime, self.silent, credentials, aws_credentials_file, - gcloud_key_file + gcloud_key_file, + k8s_processor ).print() def import_existing_npm_config(self): diff --git a/src/pybritive/choices/mode.py b/src/pybritive/choices/mode.py index 30befda..355554b 100644 --- a/src/pybritive/choices/mode.py +++ b/src/pybritive/choices/mode.py @@ -23,7 +23,9 @@ 'browser-macosx', 'browser-safari', 'browser-chrome', - 'browser-chromium' + 'browser-chromium', + 'kube-exec', # bake into kubeconfig with oidc exec output and additional caching to make kubectl more performant + 'kube-config', # create a kubeconfig file with user oidc credentials and optionally cluster/context in ~/.britive/kubeconfig/ ], case_sensitive=False ) diff --git a/src/pybritive/helpers/aws_credential_process.py b/src/pybritive/helpers/aws_credential_process.py index 94bf6bc..7b54244 100644 --- a/src/pybritive/helpers/aws_credential_process.py +++ b/src/pybritive/helpers/aws_credential_process.py @@ -66,7 +66,10 @@ def main(): creds = None if not args['force_renew']: # if force renew let's defer to that the full package vs. this helper from .cache import Cache # lazy load - creds = Cache(passphrase=args['passphrase']).get_awscredentialprocess(profile_name=args['profile']) + creds = Cache(passphrase=args['passphrase']).get_credentials( + profile_name=args['profile'], + mode='awscredentialprocess' + ) if creds: from datetime import datetime # lazy load expiration = datetime.fromisoformat(creds['expirationTime'].replace('Z', '')) diff --git a/src/pybritive/helpers/cache.py b/src/pybritive/helpers/cache.py index 244ceda..603e1f9 100644 --- a/src/pybritive/helpers/cache.py +++ b/src/pybritive/helpers/cache.py @@ -1,7 +1,6 @@ import json import os from pathlib import Path - from .encryption import StringEncryption, InvalidPassphraseException @@ -12,6 +11,11 @@ def __init__(self, passphrase: str = None): home = os.getenv('PYBRITIVE_HOME_DIR', str(Path.home())) self.path = str(Path(home) / '.britive' / 'pybritive.cache') # handle os specific separators properly self.cache = {} + self.default_key_values = { + 'profiles': [], + 'awscredentialprocess': {}, + 'kube-exec': {} + } self.load() def load(self): @@ -29,10 +33,7 @@ def load(self): except json.decoder.JSONDecodeError: self.cache = {} - if 'profiles' not in self.cache.keys(): - self.cache['profiles'] = [] - if 'awscredentialprocess' not in self.cache.keys(): - self.cache['awscredentialprocess'] = {} + self.cache = {**self.default_key_values, **self.cache} def write(self): # write the new cache file @@ -49,24 +50,23 @@ def save_profiles(self, profiles: list): self.write() def clear(self): - self.cache['profiles'] = [] - self.cache['awscredentialprocess'] = {} + self.cache = self.default_key_values self.write() - def get_awscredentialprocess(self, profile_name: str): + def get_credentials(self, profile_name: str, mode: str = 'awscredentialprocess'): try: - ciphertext = self.cache['awscredentialprocess'].get(profile_name.lower()) + ciphertext = self.cache[mode].get(profile_name.lower()) if not ciphertext: return None return json.loads(self.string_encryptor.decrypt(ciphertext)) except InvalidPassphraseException: # if we cannot decrypt don't error - just make the API call to get the creds return None - def save_awscredentialprocess(self, profile_name: str, credentials: dict): + def save_credentials(self, profile_name: str, credentials: dict, mode: str = 'awscredentialprocess'): ciphertext = self.string_encryptor.encrypt(json.dumps(credentials, default=str)) - self.cache['awscredentialprocess'][profile_name.lower()] = ciphertext + self.cache[mode][profile_name.lower()] = ciphertext self.write() - def clear_awscredentialprocess(self, profile_name: str): - self.cache['awscredentialprocess'].pop(profile_name.lower(), None) + def clear_credentials(self, profile_name: str, mode: str = 'awscredentialprocess'): + self.cache[mode].pop(profile_name.lower(), None) self.write() diff --git a/src/pybritive/helpers/cloud_credential_printer.py b/src/pybritive/helpers/cloud_credential_printer.py index 372d839..ddbb248 100644 --- a/src/pybritive/helpers/cloud_credential_printer.py +++ b/src/pybritive/helpers/cloud_credential_printer.py @@ -62,6 +62,8 @@ def print(self): self.print_azps() if self.mode == 'gcloudauth': self.print_gcloudauth() + if self.mode == 'kube': + self.print_kube() def print_console(self): url = self.credentials.get('url', self.credentials) @@ -95,6 +97,9 @@ def print_azps(self): def print_gcloudauth(self): self._not_implemented() + def print_kube(self): + self._not_implemented() + def _not_implemented(self): raise click.ClickException(f'Application type {self.app_type} does not support the specified mode.') @@ -253,3 +258,26 @@ def print_gcloudauth(self): f"gcloud auth activate-service-account {self.credentials['client_email']} --key-file {str(path)}", ignore_silent=True ) + + +class KubernetesCredentialPrinter(CloudCredentialPrinter): + def __init__(self, console, mode, profile, silent, credentials, cli, k8s_processor): + self.k8s_processor = k8s_processor + super().__init__('Kubernetes', console, mode, profile, silent, credentials, cli) + + def print_json(self): + try: + self.cli.print(json.dumps(self.credentials, indent=2), ignore_silent=True) + except json.JSONDecodeError: + self.cli.print(self.credentials, ignore_silent=True) + + def print_kube(self): + if self.mode_modifier == 'exec': + self.cli.print(self.k8s_processor.construct_exec_credential(self.credentials), ignore_silent=True) + elif self.mode_modifier == 'config': + # write file to ~/.britive/kubeconfig/... + # clean up any older config file that are no longer required + # print out `export KUBECONFIG=~/.britive/kubeconfig/...` + pass + else: + raise ValueError(f'--mode modifier {self.mode_modifier} for mode {self.mode} not supported') diff --git a/src/pybritive/helpers/k8s_exec.py b/src/pybritive/helpers/k8s_exec.py new file mode 100644 index 0000000..7a7ae97 --- /dev/null +++ b/src/pybritive/helpers/k8s_exec.py @@ -0,0 +1,110 @@ +import sys + + +sys.tracebacklimit = 0 + + +def get_args(): + from getopt import getopt # lazy load + from sys import argv # lazy load + options, non_options = getopt(argv[1:], 't:T:p:F:hv', [ + 'tenant=', + 'token=', + 'passphrase=', + 'federation-provider=', + 'help', + 'version' + ]) + + args = { + 'tenant': None, + 'token': None, + 'passphrase': None, + 'federation_provider': None + } + + for opt, arg in options: + if opt in ('-t', '--tenant'): + args['tenant'] = arg + if opt in ('-T', '--token'): + args['token'] = arg + if opt in ('-p', '--passphrase'): + args['passphrase'] = arg + if opt in ('-F', '--federation-provider'): + args['federation_provider'] = arg + if opt in ('-h', '--help'): + usage() + if opt in ('-v', '--version'): + from platform import platform, python_version # lazy load + from pkg_resources import get_distribution # lazy load + cli_version = get_distribution('pybritive').version + print( + f'pybritive: {cli_version} / platform: {platform()} / python: {python_version()}' + ) + exit() + + return args + + +def usage(): + from sys import argv # lazy load + print(f"Usage : {argv[0]} [-t/--tenant, -T/--token, -t/--passphrase, -F/--federation-provider]") + exit() + + +def process(): + args = get_args() + + from .k8s_exec_credential_builder import KubernetesExecCredentialProcessor + + k8s_processor = KubernetesExecCredentialProcessor() + + from .cache import Cache # lazy load + creds = Cache(passphrase=args['passphrase']).get_credentials( + profile_name=k8s_processor.profile, + mode='kube-exec' + ) + if creds: + from datetime import datetime # lazy load + expiration = datetime.fromisoformat(creds['expirationTime'].replace('Z', '')) + now = datetime.utcnow() + if now > expiration: # creds have expired so set to none so new one get checked out + creds = None + else: + print(k8s_processor.construct_exec_credential(creds)) + exit() + + if not creds: + from ..britive_cli import BritiveCli # lazy load for performance purposes + + b = BritiveCli( + tenant_name=args['tenant'], + token=args['token'], + passphrase=args['passphrase'], + federation_provider=args['federation_provider'], + silent=True + ) + b.config.get_tenant() # have to load the config here as that work is generally done elsewhere + b.checkout( + alias=None, + blocktime=None, + console=False, + justification=None, + mode='kube-exec', + maxpolltime=None, + profile=k8s_processor.profile, + passphrase=args['passphrase'], + force_renew=None, + aws_credentials_file=None, + gcloud_key_file=None, + verbose=None + ) + exit() + + +def main(): + process() + + +if __name__ == '__main__': + main() diff --git a/src/pybritive/helpers/k8s_exec_credential_builder.py b/src/pybritive/helpers/k8s_exec_credential_builder.py new file mode 100644 index 0000000..f8959f7 --- /dev/null +++ b/src/pybritive/helpers/k8s_exec_credential_builder.py @@ -0,0 +1,55 @@ +import json +import os + + +class KubernetesExecCredentialProcessor: + def __init__(self): + self.api_version = None + self.profile = None + self.exec_data = None + self._parse() + + def _parse(self): + # parse the information provided by kube exec + self.exec_data = json.loads(os.environ.get('KUBERNETES_EXEC_INFO', 'null')) + + if not self.exec_data: # this env var HAS to exist if we are being invoked by k8s kubeconfig exec command + raise Exception( + 'could not find environment variable KUBERNETES_EXEC_INFO - is this command being run ' + 'within a kubeconfig context?' + ) + + self.api_version = self.exec_data.get('apiVersion') + + if not self.api_version: + raise ValueError('apiVersion not found. Cannot continue.') + + if self.api_version == 'client.authentication.k8s.io/v1alpha1': + raise ValueError(f'apiVersion {self.api_version} is not supported.') + + if self.api_version not in ['client.authentication.k8s.io/v1', 'client.authentication.k8s.io/v1beta1']: + raise Exception(f'apiVersion {self.api_version} not accounted for.') + + self.profile = self.exec_data.get('spec', {}).get('cluster', {}).get('config', {}).get('britive-profile') + + if not self.profile: + raise ValueError( + 'kubeconfig cluster extension named britive-profile not found or no profile value specified' + ) + + def construct_exec_credential(self, credentials: dict): + response = { + 'kind': 'ExecCredential', + 'apiVersion': self.api_version, + 'spec': { + 'cluster': { + 'server': 'https://AFEA586304825E6AE82B38EA8B8665D2.gr7.us-west-1.eks.amazonaws.com', + 'certificate-authority-data': 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUMvakNDQWVhZ0F3SUJBZ0lCQURBTkJna3Foa2lHOXcwQkFRc0ZBREFWTVJNd0VRWURWUVFERXdwcmRXSmwKY201bGRHVnpNQjRYRFRJek1EUXhNREUwTkRRd01Wb1hEVE16TURRd056RTBORFF3TVZvd0ZURVRNQkVHQTFVRQpBeE1LYTNWaVpYSnVaWFJsY3pDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTTZUCk93Q25NclkzK0lhY1ZqUFpGYitxdi9GT1ZtQVJhOGVjZXNvN0VlblhOT1hod05sNjF6Ykk0UGhVcjRwU2U5QU8KTGwwd0lBUkZheWdtdTk4YVc0bU44UjcyYkVLc041WlYvL2FNbTdwTVJab2dYRDdNWDFseVNqRzdYUDgzRzBVYQpXMGlhS0JPdE96Q0F6dzBOVXAzd0E5ZzV5bUNIeGk2V3ZMZHRWOU9PSnlKYjkzM3NDZGhsM3phWlN6QmlzL1daClJsU0xIODlveUZXY0w5NGF2UU1WeGRGZHg2TlFLZ0ZnMERsMXNGTlFYbFlTbXkyUS8zd3lubU1RSlZqcnV6a2gKUW1yNDRoSUNrUmtZQVd5OU1wR2kwSmswa0IrekkwQzZnWWhqOG1OTS9wZEk3Skd0OXBCWWMrOWdnYVRuQWN5SQpGMWxDYjNVYlQ0VU4vcXZqMFkwQ0F3RUFBYU5aTUZjd0RnWURWUjBQQVFIL0JBUURBZ0trTUE4R0ExVWRFd0VCCi93UUZNQU1CQWY4d0hRWURWUjBPQkJZRUZPQi94ZXdrRENVaDA0Y2QvcXpHN0JJbFAzOGdNQlVHQTFVZEVRUU8KTUF5Q0NtdDFZbVZ5Ym1WMFpYTXdEUVlKS29aSWh2Y05BUUVMQlFBRGdnRUJBSTV5T2FrMzNpanhhakZ4TkloNQpkek1iNVlxY1c1RlNUMmJOQlhITzQvSjRjNUduVWFvV1FDZnNJK0FHQ0tBMXl5WUxIZmJwVEt2TjNGUW9rWGlSCi9oSG1reGJqMTk2MlEwZDFWSkExdk1nUktLa3lBOTZYQ1UrN3hxci9HeExzN1huTWphNHhrcEJKaEw0RSt6b1kKNy9PcEZxR2ZpUitoSmtncWN1MTlQenczZmN4VVdDZHlLSjNNZVMwN1psTzZLMjBONnoxUUFrNXA3Mi9BTERnQgpHWTVUVFhmU2tHakhyU1pMWnVjb25ZM1BSci9iWmRGNEhUdWhHZzJ2aXA2SytCWjJLQUpURDBlUkQvYTRpY1pMCjl6R09YU21Hc1krakpDY0l5VnBrenpsdGczMVNHc0NCOEtPTlU4YzRhb0RJWVhOSnFybjRmYm5YZnI0VVEwam4KZmtZPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==' + } + }, + 'status': { + 'expirationTimestamp': credentials['expirationTime'], + 'token': credentials['jwt'] + } + } + return json.dumps(response) diff --git a/src/pybritive/helpers/profile_argument_decorator.py b/src/pybritive/helpers/profile_argument_decorator.py index ed5fa5d..a84d295 100644 --- a/src/pybritive/helpers/profile_argument_decorator.py +++ b/src/pybritive/helpers/profile_argument_decorator.py @@ -1,13 +1,38 @@ import click import pkg_resources from ..completers.profile import profile_completer +import os +import json + click_major_version = int(pkg_resources.get_distribution('click').version.split('.')[0]) +def validate_profile(ctx, param, value): + if 'KUBERNETES_EXEC_INFO' in os.environ: + try: + return json.loads(os.getenv('KUBERNETES_EXEC_INFO'))['spec']['cluster']['config']['britive-profile'] + except: + raise ValueError('unable to find britive profile via cluster exec-extension with name britive-profile') + return value + + +def is_required(): + return 'KUBERNETES_EXEC_INFO' not in os.environ + + def click_smart_profile_argument(func): + required = is_required() + kwargs = { + 'required': required + } + if not required: + kwargs['callback'] = validate_profile if click_major_version >= 8: - dec = click.argument('profile', shell_complete=profile_completer) - else: - dec = click.argument('profile') + kwargs['shell_complete'] = profile_completer + + dec = click.argument( + 'profile', + **kwargs + ) return dec(func)