Skip to content
Merged
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,28 @@

* As of v1.4.0 release candidates will be published in an effort to get new features out faster while still allowing time for full QA testing before moving the release candidate to a full release.

## v1.6.0rc1 [2023-10-25]
#### What's New
* Initial support for Kubernetes - this functionality is not yet available publicly on the Britive Platform - this is a beta feature for internal use only

#### Enhancements
* Add command `cache kubeconfig`
* Update command `cache clear` to delete the kube config file if it exists
* Add global config flag `auto-refresh-kube-config` set by `configure update global auto-refresh-kube-config true`
* Add checkout mode `k8s-exec` for use exclusively inside an `exec` command of a kube config file
* Add console helper script `pybritive-kube-exec` for use exclusively inside an `exec` command of a kube config file
* Add the `pybritive` package version into the `User-Agent` string used by the Britive Python SDK (`britive` package)

#### Bug Fixes
* None

#### Dependencies
* None

#### Other
* Documentation update on bash command to add the python `bin` path to your `PATH` environment variable.


## v1.5.0 [2023-10-20]
#### What's New
* None
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ You will need to add this location to your path. The following command will do t
this command into your `.bashrc, .zshrc, etc.` file, so it will always get executed on new terminal windows.

~~~
export PATH=\"`python3 -m site --user-base`/bin:\$PATH\"
export PATH=`python3 -m site --user-base`/bin:$PATH
~~~

## Tenant Configuration
Expand Down
6 changes: 3 additions & 3 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[metadata]
name = pybritive
version = 1.5.0
version = 1.6.0rc1
author = Britive Inc.
author_email = support@britive.com
description = A pure Python CLI for Britive
Expand All @@ -18,7 +18,6 @@ package_dir =
packages = find:
python_requires = >=3.7

# urllib3 version logic due to https://github.com/britive/python-cli/security/dependabot/6
install_requires =
click
requests>=2.31.0
Expand All @@ -38,4 +37,5 @@ where = src
[options.entry_points]
console_scripts =
pybritive = pybritive.cli_interface:safe_cli
pybritive-aws-cred-process = pybritive.helpers.aws_credential_process:main
pybritive-aws-cred-process = pybritive.helpers.aws_credential_process:main
pybritive-kube-exec = pybritive.helpers.k8s_exec:main
168 changes: 127 additions & 41 deletions src/pybritive/britive_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
from pathlib import Path
import sys
import uuid
import pkg_resources
import yaml

import click
import jmespath
from britive.britive import Britive
Expand All @@ -28,9 +28,10 @@


class BritiveCli:
def __init__(self, tenant_name: str = None, token: str = None, silent: bool = False,
passphrase: str = None, federation_provider: str = None):
def __init__(self, tenant_name: str = None, token: str = None, silent: bool = False,passphrase: str = None,
federation_provider: str = None, from_helper_console_script: bool = False):
self.silent = silent
self.from_helper_console_script = from_helper_console_script
self.output_format = None
self.tenant_name = None
self.tenant_alias = None
Expand All @@ -44,6 +45,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):
Expand Down Expand Up @@ -102,10 +113,25 @@ def login(self, explicit: bool = False, browser: str = None):
except exceptions.UnauthorizedRequest:
self._cleanup_credentials()

# if user called `pybritive login` and we should refresh the profile cache...do so
if explicit and self.config.auto_refresh_profile_cache():
self._set_available_profiles()
self.cache_profiles()
self._update_sdk_user_agent()

# if user called `pybritive login` and we should get profiles...do so
should_get_profiles = any([self.config.auto_refresh_profile_cache(), self.config.auto_refresh_kube_config()])
if explicit and should_get_profiles:
self._set_available_profiles() # will handle calling cache_profiles() and construct_kube_config()

def _update_sdk_user_agent(self):
# update the user agent to include the pybritive cli version
user_agent = self.b.session.headers.get('User-Agent')

try:
version = pkg_resources.get_distribution('pybritive').version
except Exception:
version = 'unknown'

self.b.session.headers.update({
'User-Agent': f'pybritive/{version} {user_agent}'
})

def _cleanup_credentials(self):
self.set_credential_manager()
Expand Down Expand Up @@ -307,7 +333,7 @@ def list_environments(self):
data.append(row)
self.print(data, ignore_silent=True)

def _set_available_profiles(self):
def _set_available_profiles(self, from_cache_command=False):
if not self.available_profiles:
data = []
for app in self.b.my_access.list_profiles():
Expand All @@ -327,12 +353,46 @@ def _set_available_profiles(self):
'profile_allows_console': profile['consoleAccess'],
'profile_allows_programmatic': profile['programmaticAccess'],
'profile_description': profile['profileDescription'],
'2_part_profile_format_allowed': app['requiresHierarchicalModel']
'2_part_profile_format_allowed': app['requiresHierarchicalModel'],
'env_properties': env.get('profileEnvironmentProperties', {})
}
data.append(row)
self.available_profiles = data
if self.config.auto_refresh_profile_cache():
self.cache_profiles(load=False)

if not from_cache_command and self.config.auto_refresh_profile_cache():
self.cache_profiles()
if not from_cache_command and self.config.auto_refresh_kube_config():
self.construct_kube_config()

def construct_kube_config(self, from_cache_command=False):
if self.from_helper_console_script:
return

if from_cache_command:
self.login()
self._set_available_profiles(from_cache_command=from_cache_command)

profiles = []
for p in self.available_profiles:
if p['app_type'].lower() == 'kubernetes':
props = p['env_properties']
url = props.get('apiServerUrl')
cert = props.get('certificateAuthorityData')
if props and all([url, cert]):
profiles.append({
'app': p['app_name'],
'env': p['env_name'],
'profile': p['profile_name'],
'url': url,
'cert': cert,
})
from .helpers.kube_config_builder import build_kube_config # lazy import as not everyone will want this
build_kube_config(
profiles=profiles,
config=self.config,
username=self.b.my_access.whoami()['username'],
cli=self
)

def _get_app_type(self, application_id):
self._set_available_profiles()
Expand All @@ -342,7 +402,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,
Expand Down Expand Up @@ -372,14 +432,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()
Expand Down Expand Up @@ -474,9 +545,17 @@ 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':
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')):
Expand All @@ -488,22 +567,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)

Expand All @@ -518,7 +598,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']
Expand All @@ -533,16 +613,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(
Expand All @@ -553,7 +634,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):
Expand Down Expand Up @@ -659,11 +741,15 @@ def downloadsecret(self, path, blocktime, justification, maxpolltime, file):
f.write(content)
self.print(f'wrote contents of secret file to {path}')

def cache_profiles(self, load=True):
if load:
self.login()
self._set_available_profiles()
def cache_profiles(self, from_cache_command=False):
if self.from_helper_console_script:
return
profiles = []

if from_cache_command:
self.login()
self._set_available_profiles(from_cache_command=from_cache_command)

for p in self.available_profiles:
profile = self.escape_profile_element(p['app_name'])
profile += '/'
Expand Down
3 changes: 2 additions & 1 deletion src/pybritive/choices/mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
'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
],
case_sensitive=False
)
10 changes: 9 additions & 1 deletion src/pybritive/commands/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,15 @@ def cache():
@britive_options(names='tenant,token,silent,passphrase,federation_provider')
def profiles(ctx, tenant, token, silent, passphrase, federation_provider):
"""Cache profiles locally to facilitate auto-completion of profile names on checkin/checkout."""
ctx.obj.britive.cache_profiles()
ctx.obj.britive.cache_profiles(from_cache_command=True)


@cache.command()
@build_britive
@britive_options(names='tenant,token,silent,passphrase,federation_provider')
def kubeconfig(ctx, tenant, token, silent, passphrase, federation_provider):
"""Cache a Britive managed kube config file based on the profiles to which the caller has access."""
ctx.obj.britive.construct_kube_config(from_cache_command=True)


@cache.command()
Expand Down
8 changes: 6 additions & 2 deletions src/pybritive/helpers/aws_credential_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -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', ''))
Expand All @@ -90,7 +93,8 @@ def main():
token=args['token'],
passphrase=args['passphrase'],
federation_provider=args['federation_provider'],
silent=True
silent=True,
from_helper_console_script=True
)
b.config.get_tenant() # have to load the config here as that work is generally done
b.checkout(
Expand Down
Loading