diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d4f78b..05d8d3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All changes to the package starting with v0.3.1 will be logged here. +## v1.2.0 [2023-03-03] +#### What's New +* Support for Azure Managed Identities as federation providers. + +#### Enhancements +* Fall back to reduced functionality (no shell completion) when the python environment is using `click<8.0.0`. + +#### Bug Fixes +* If a justification for checkout/secrets viewing is provided, ensure it is <=255 characters. +* Fix issue with extraction of OIDC token expiration time. Moved to `jwt` library to perform the token decode. + +#### Dependencies +* Switching `britive` dependency from a compatible version requirement to a `>=` requirement to capture minor updates. +* `britive>=2.16.0` from britive~=2.15.1 + +#### Other +* Modify the error handling and reporting process to not raise `click.ClickException` exceptions in the `safe_cli` method. Instead, raise the underlying exception so a better error message is provided. + ## v1.1.1 [2023-02-16] #### What's New * None diff --git a/docs/index.md b/docs/index.md index da30403..743e69f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,6 +30,21 @@ pip install $(curl -s https://api.github.com/repos/britive/python-cli/releases/l The end user is free to install the CLI into a virtual environment or in the global scope, so it is available everywhere. +If the `pybritive` executable is not found when attempting to invoke the program, you may need to add the location +of the `pybritive` executable to your path. This location can differ by OS and whether you are using a virtualenv or +not. The easiest way to see where executables get installed via `pip install ...` is to run the following command. + +~~~ +echo `python3 -m site --user-base`/bin +~~~ + +You will need to add this location to your path. The following command will do that but it is recommended to add +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\" +~~~ + ## Tenant Configuration Before `pybritive` can connect to a Britive tenant, it needs to know some details about that tenant. @@ -129,6 +144,12 @@ At feature launch the following types of identity providers are supported for wo * Github Actions * AWS * Bitbucket +* Azure System Assigned Managed Identities +* Azure User Assigned Managed Identities + +For more information on Azure Managed Identities reference the below link. + +https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview It is possible to source an identity token from a different OIDC provider and explicitly set it via the `--token\-T` flag. However, if you are using one of the above providers, a shortcut is provided to abstract away the complexity of sourcing these tokens. @@ -154,6 +175,18 @@ pybritive checkout "profile" --federation-provider aws_expirationseconds # use # bitbucket (note that no additional options are available for bitbucket) pybritive checkout "profile" --federation-provider bitbucket + +# azure system assigned managed identities +pybritive checkout "profile" --federation-provider azuresmi # use system assigned managed identities with the default OIDC audience +pybritive checkout "profile" --federation-provider azuresmi-audience # use system assigned managed identities with a custom OIDC audience +pybritive checkout "profile" --federation-provider azuresmi-audience_expirationseconds # use system assigned managed identities with a custom OIDC audience and set the Britive expiration (in seconds) of the generated token +pybritive checkout "profile" --federation-provider azuresmi_expirationseconds # use system assigned managed identities with the default OIDC audience and set the Britive expiration (in seconds) of the generated token + +# azure user assigned managed identities (note that a client id is a required field) +pybritive checkout "profile" --federation-provider azuresmi-clientid # use user assigned managed identities with the default OIDC audience +pybritive checkout "profile" --federation-provider azuresmi-clientid|audience # use user assigned managed identities with a custom OIDC audience +pybritive checkout "profile" --federation-provider azuresmi-clientid|audience_expirationseconds # use user assigned managed identities with a custom OIDC audience and set the Britive expiration (in seconds) of the generated token +pybritive checkout "profile" --federation-provider azuresmi-clientid_expirationseconds # use user assigned managed identities with the default OIDC audience and set the Britive expiration (in seconds) of the generated token ~~~ In general the field format for `--federation-provider` is `provider-[something provider specific]_[duration in seconds]`. diff --git a/mkdocs.yml b/mkdocs.yml index c18f8e5..5d290a7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -8,7 +8,7 @@ nav: theme: readthedocs repo_url: https://github.com/britive/python-cli edit_uri: '' -copyright: 2022 Britive, Inc. +copyright: 2023 Britive, Inc. markdown_extensions: - attr_list - mkdocs-click diff --git a/requirements.txt b/requirements.txt index fa87caf..708f10b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -britive~=2.15.0 +britive>=2.16.0 certifi>=2022.12.7 charset-normalizer==2.1.0 click==8.1.3 @@ -17,4 +17,5 @@ mkdocs-click==0.8.0 twine~=4.0.1 python-dateutil~=2.8.2 boto3 -jmespath \ No newline at end of file +jmespath +pyjwt \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 3355cfe..68a3396 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = pybritive -version = 1.1.1 +version = 1.2.0 author = Britive Inc. author_email = support@britive.com description = A pure Python CLI for Britive @@ -26,8 +26,9 @@ install_requires = toml cryptography~=39.0.1 python-dateutil - britive~=2.15.0 + britive>=2.16.0 jmespath + pyjwt [options.packages.find] where = src diff --git a/src/pybritive/britive_cli.py b/src/pybritive/britive_cli.py index e211a3f..f565380 100644 --- a/src/pybritive/britive_cli.py +++ b/src/pybritive/britive_cli.py @@ -420,6 +420,8 @@ def checkout(self, alias, blocktime, console, justification, mode, maxpolltime, response = None self.verbose_checkout = verbose + 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 @@ -524,7 +526,8 @@ def configure_global(self, default_tenant_name, output_format, backend): backend=backend ) - def viewsecret(self, path, blocktime, justification,maxpolltime): + def viewsecret(self, path, blocktime, justification, maxpolltime): + self._validate_justification(justification) self.login() try: @@ -554,7 +557,8 @@ def viewsecret(self, path, blocktime, justification,maxpolltime): # and finally print the secret data self.print(value) - def downloadsecret(self, path, blocktime, justification,maxpolltime, file): + def downloadsecret(self, path, blocktime, justification, maxpolltime, file): + self._validate_justification(justification) self.login() try: @@ -617,6 +621,7 @@ def configure_update(self, section, field, value): self.config.update(section=section, field=field, value=value) def request_submit(self, profile, justification): + self._validate_justification(justification) self.login() parts = self._split_profile_into_parts(profile) @@ -762,6 +767,11 @@ def _convert_names_to_ids(self, profile_name: str, environment_name: str, applic 'environment_id': possible_environments[0] } + @staticmethod + def _validate_justification(justification: str): + if justification and len(justification) > 255: + raise ValueError('justification cannot be longer than 255 characters.') + diff --git a/src/pybritive/cli_interface.py b/src/pybritive/cli_interface.py index 037eeac..e21ccc6 100644 --- a/src/pybritive/cli_interface.py +++ b/src/pybritive/cli_interface.py @@ -24,9 +24,9 @@ def safe_cli(): cli() except Exception as e: if debug: - raise click.ClickException(str(e)) + raise e else: - raise click.ClickException(str(e)) from None + raise e from None # this is the "main" app - it really does nothing but print the overview/help section diff --git a/src/pybritive/commands/checkin.py b/src/pybritive/commands/checkin.py index e797626..cde98c4 100644 --- a/src/pybritive/commands/checkin.py +++ b/src/pybritive/commands/checkin.py @@ -1,13 +1,13 @@ import click from ..helpers.build_britive import build_britive from ..options.britive_options import britive_options -from ..completers.profile import profile_completer +from ..helpers.profile_argument_dectorator import click_smart_profile_argument @click.command() @build_britive @britive_options(names='tenant,token,passphrase,federation_provider') -@click.argument('profile', shell_complete=profile_completer) +@click_smart_profile_argument def checkin(ctx, tenant, token, passphrase, federation_provider, profile): """Checkin a profile. diff --git a/src/pybritive/commands/checkout.py b/src/pybritive/commands/checkout.py index e8e6e17..ae2a864 100644 --- a/src/pybritive/commands/checkout.py +++ b/src/pybritive/commands/checkout.py @@ -1,14 +1,14 @@ import click from ..helpers.build_britive import build_britive from ..options.britive_options import britive_options -from ..completers.profile import profile_completer +from ..helpers.profile_argument_dectorator import click_smart_profile_argument @click.command() @build_britive @britive_options(names='alias,blocktime,console,justification,mode,maxpolltime,silent,force_renew,aws_credentials_file,' 'gcloud_key_file,verbose,tenant,token,passphrase,federation_provider') -@click.argument('profile', shell_complete=profile_completer) +@click_smart_profile_argument def checkout(ctx, alias, blocktime, console, justification, mode, maxpolltime, silent, force_renew, aws_credentials_file, gcloud_key_file, verbose, tenant, token, passphrase, federation_provider, profile): """Checkout a profile. diff --git a/src/pybritive/commands/request.py b/src/pybritive/commands/request.py index e6371fd..411b79e 100644 --- a/src/pybritive/commands/request.py +++ b/src/pybritive/commands/request.py @@ -1,7 +1,8 @@ import click from ..helpers.build_britive import build_britive from ..options.britive_options import britive_options -from ..completers.profile import profile_completer +from ..helpers.profile_argument_dectorator import click_smart_profile_argument + @click.group() def request(): @@ -12,7 +13,7 @@ def request(): @request.command() @build_britive @britive_options(names='justification,tenant,token,passphrase,federation_provider') -@click.argument('profile', shell_complete=profile_completer) +@click_smart_profile_argument def submit(ctx, justification, tenant, token, passphrase, federation_provider, profile): """Submit a request to checkout a profile. @@ -33,7 +34,7 @@ def submit(ctx, justification, tenant, token, passphrase, federation_provider, p @request.command() @build_britive @britive_options(names='tenant,token,passphrase,federation_provider') -@click.argument('profile', shell_complete=profile_completer) +@click_smart_profile_argument def withdraw(ctx, tenant, token, passphrase, federation_provider, profile): """Withdraw a request to checkout a profile. @@ -45,4 +46,4 @@ def withdraw(ctx, tenant, token, passphrase, federation_provider, profile): ctx.obj.britive.request_withdraw( profile=profile - ) \ No newline at end of file + ) diff --git a/src/pybritive/completers/__init__.py b/src/pybritive/completers/__init__.py index 2154464..3641161 100644 --- a/src/pybritive/completers/__init__.py +++ b/src/pybritive/completers/__init__.py @@ -1,3 +1,12 @@ # register the completion scripts since they are not required to be registered anywhere else -from . import powershell_completion -from .import bash_gte_42 + +import pkg_resources + +# click < 8.0.0 does shell completion different... +# not all the classes/decorators are available, so we cannot +# create custom shell completions like we can with click > 8.0.0 +click_major_version = int(pkg_resources.get_distribution('click').version.split('.')[0]) +if click_major_version >= 8: + from . import powershell_completion + from . import bash_gte_42 + diff --git a/src/pybritive/helpers/credentials.py b/src/pybritive/helpers/credentials.py index 7cf6f54..3f21011 100644 --- a/src/pybritive/helpers/credentials.py +++ b/src/pybritive/helpers/credentials.py @@ -13,6 +13,7 @@ from .encryption import StringEncryption, InvalidPassphraseException from britive.britive import Britive from dateutil import parser +import jwt interactive_login_fields_to_pop = [ @@ -144,7 +145,7 @@ def perform_federation_provider_authentication(self): provider = provider.lower() token = str(token) - expiration_time = (time.time_ns() // 1000000) + federation_provider_default_expiration_seconds + expiration_time = (int(time.time()) + federation_provider_default_expiration_seconds) * 1000 try: if provider == 'aws': @@ -152,10 +153,17 @@ def perform_federation_provider_authentication(self): token_expires = json.loads(token)['iam_request_headers']['x-britive-expires'] expiration_time = int(parser.parse(token_expires).timestamp() * 1000) if provider == 'oidc': - ignore1, token, ignore2 = token.split('.') - token = base64.b64decode(token.encode('utf-8')) - token = json.loads(token) - expiration_time = int(token['exp'] * 1000) + expiration_time = jwt.decode( + token, + # validation of the token will occur on the Britive backend + # so not verifying everything here is okay since we are just + # trying to extract the token expiration time so we can store + # it in the ~/.britive/pybritive.credentials[.encrypted] file + options={ + 'verify_signature': False, + 'verify_aud': False + } + )['exp'] * 1000 except Exception as e: self.cli.print(f'Cannot obtain token expiration time for {self.federation_provider}. Defaulting to ' f'{federation_provider_default_expiration_seconds} seconds.') diff --git a/src/pybritive/helpers/profile_argument_dectorator.py b/src/pybritive/helpers/profile_argument_dectorator.py new file mode 100644 index 0000000..3500dca --- /dev/null +++ b/src/pybritive/helpers/profile_argument_dectorator.py @@ -0,0 +1,15 @@ +import click +import pkg_resources +from ..completers.profile import profile_completer + +click_major_version = int(pkg_resources.get_distribution('click').version.split('.')[0]) + + +def click_smart_profile_argument(func): + if click_major_version >= 8: + dec = click.argument('profile', shell_complete=profile_completer) + else: + dec = click.argument('profile') + return dec(func) + + diff --git a/src/pybritive/options/justification.py b/src/pybritive/options/justification.py index 6b6ed75..c9d0905 100644 --- a/src/pybritive/options/justification.py +++ b/src/pybritive/options/justification.py @@ -5,6 +5,6 @@ '--justification', '-j', default=None, show_default=True, - help='Justification for the checkout approval process, if the profile checkout requires approval.' + help='Justification for the checkout approval process, if the profile checkout requires approval.', ) diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..03b7463 --- /dev/null +++ b/test.sh @@ -0,0 +1,4 @@ +pip install click==7.1.2 +pytest tests/ -vvv +pip install click --upgrade +pytest tests/ -vvv \ No newline at end of file diff --git a/tests/test_0450_api.py b/tests/test_0450_api.py new file mode 100644 index 0000000..ba3e06b --- /dev/null +++ b/tests/test_0450_api.py @@ -0,0 +1,23 @@ +import os +from pathlib import Path +import json + + +def common_asserts(result, substring=None, exit_code=0): + assert result.exit_code == exit_code + if isinstance(substring, str): + substring = [substring] + for sub in substring: + assert sub in result.output + + +def test_api(runner, cli): + result = runner.invoke(cli, 'api users.list'.split(' ')) + common_asserts(result, ['userId', 'status', 'email', 'identityProvider']) + + + + + + + diff --git a/tests/test_0600_cache.py b/tests/test_0600_cache.py index 6a2fb11..6c4c4af 100644 --- a/tests/test_0600_cache.py +++ b/tests/test_0600_cache.py @@ -12,7 +12,7 @@ def test_cache_profiles(runner, cli): assert result.exit_code == 0 assert 'profiles' in data.keys() assert len(data['profiles']) > 0 - assert len(data['profiles'][0].split('/')) == 3 + assert len(data['profiles'][0].split('/')) in [2, 3]