Skip to content

Commit ea7d03d

Browse files
ruhuliosevignyj
authored andcommitted
feat: Add multiple profiles support (#168)
* feat: Add multiple profiles support * chore: Better requirement docs, bug fix with test * chore: Reset singleton and deepcopy defaults * chore: Remove side effects from processing * chore: Only warn if AWS profile is set
1 parent 8957662 commit ea7d03d

File tree

4 files changed

+157
-53
lines changed

4 files changed

+157
-53
lines changed

docs/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ options:
9191
password to log in to Okta. You can also use the TOKENDITO_OKTA_PASSWORD environment variable.
9292
--profile USER_CONFIG_PROFILE
9393
Tokendito configuration profile to use.
94+
--multi-profiles USER_CONFIG_PROFILE
95+
Similar to --profile, but can be specified multiple times.
96+
Note: Using this will override --profile and cause --aws-profile to be ignored and replaced with this value.
9497
--config-file USER_CONFIG_FILE
9598
Use an alternative configuration file. Defaults to tokendito.ini with location depending on the OS.
9699
--loglevel {DEBUG,INFO,WARN,ERROR}, -l {DEBUG,INFO,WARN,ERROR}

tests/unit/test_user.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -727,8 +727,7 @@ def test_process_interactive_input(mocker):
727727
pytest_config.user["quiet"] = True
728728
pytest_config.okta["username"] = ""
729729
ret = user.process_interactive_input(pytest_config)
730-
pytest_config.update(ret)
731-
assert pytest_config.okta["username"] == ""
730+
assert ret is None
732731

733732
# Check that a bad object raises an exception
734733
with pytest.raises(AttributeError) as error:
@@ -955,3 +954,51 @@ def test_extract_arns(saml, expected):
955954
from tokendito import user
956955

957956
assert user.extract_arns(saml) == expected
957+
958+
959+
def test_single_profile(mocker):
960+
"""Test single profile support."""
961+
from tokendito import user
962+
963+
patched = mocker.patch("tokendito.user.process_args", return_value=None)
964+
965+
args = [
966+
"--profile",
967+
"profile-1",
968+
]
969+
user.cmd_interface(args)
970+
971+
assert patched.call_count == 1
972+
973+
assert patched.call_args_list[0].args[0].multi_profiles is None
974+
975+
# skip_auth parameter should only be called with False
976+
assert patched.call_args_list[0].args[1] is False
977+
978+
979+
def test_multiple_profiles(mocker):
980+
"""Test multiple profiles support."""
981+
from tokendito import user
982+
983+
patched = mocker.patch("tokendito.user.process_args", return_value=None)
984+
985+
profile_1 = "profile-1"
986+
profile_2 = "profile-2"
987+
profile_3 = "profile-3"
988+
989+
args = [
990+
"--multi-profiles",
991+
profile_1,
992+
"--multi-profiles",
993+
profile_2,
994+
"--multi-profiles",
995+
profile_3,
996+
]
997+
user.cmd_interface(args)
998+
999+
assert patched.call_count == 3
1000+
1001+
# skip_auth parameter should only be called with False the first time
1002+
assert patched.call_args_list[0].args[1] is False
1003+
assert patched.call_args_list[1].args[1] is True
1004+
assert patched.call_args_list[2].args[1] is True

tokendito/config.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# vim: set filetype=python ts=4 sw=4
22
# -*- coding: utf-8 -*-
33
"""Tokendito configuration class."""
4+
import copy
45
import json
56
import os
67
from os.path import expanduser
@@ -119,7 +120,7 @@ def _check_constraints(self, **kwargs):
119120
def set_defaults(self):
120121
"""Update the object to default settings."""
121122
for key in self._defaults.keys():
122-
setattr(self, key, self._defaults[key])
123+
setattr(self, key, copy.deepcopy(self._defaults[key]))
123124

124125
def get_defaults(self):
125126
"""Retrieve default settings."""

tokendito/user.py

Lines changed: 103 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,32 @@ def cmd_interface(args):
4646
# Early logging, in case the user requests debugging via env/CLI
4747
setup_early_logging(args)
4848

49+
if args.multi_profiles:
50+
if args.aws_profile or ("TOKENDITO_AWS_PROFILE" in os.environ):
51+
logger.warning(
52+
"Multiple profiles have been specified so the AWS profile value "
53+
"will be overridden by each profile."
54+
)
55+
56+
skip_auth = False
57+
for profile in args.multi_profiles:
58+
args.user_config_profile = profile
59+
args.aws_profile = profile
60+
61+
process_args(args, skip_auth)
62+
63+
# Reset config singleton but retain auth
64+
auth = config.okta["password"]
65+
config.set_defaults()
66+
config.okta["password"] = auth
67+
68+
skip_auth = True
69+
else:
70+
process_args(args, False)
71+
72+
73+
def process_args(args, skip_auth=False):
74+
"""Process the args and allow for skipping auth with multiple profiles."""
4975
# Set some required initial values
5076
process_options(args)
5177

@@ -78,7 +104,7 @@ def cmd_interface(args):
78104
)
79105

80106
# get authentication and authorization cookies from okta
81-
okta.access_control(config)
107+
_ = skip_auth or okta.access_control(config)
82108

83109
if config.okta["tile"]:
84110
tile_label = ""
@@ -166,6 +192,13 @@ def parse_cli_args(args):
166192
default=config.user["config_profile"],
167193
help="Tokendito configuration profile to use.",
168194
)
195+
parser.add_argument(
196+
"--multi-profiles",
197+
action="append",
198+
help="Tokendito configuration profiles to use. Can be specified multiple times. "
199+
"Using this will override --profile and cause --aws-profile to be ignored and "
200+
"replaced with this value.",
201+
)
169202
parser.add_argument(
170203
"--config-file",
171204
dest="user_config_file",
@@ -669,17 +702,11 @@ def add_sensitive_value_to_be_masked(value, key=None):
669702
mask_items.append(value)
670703

671704

672-
def process_ini_file(file, profile):
673-
"""Process options from a ConfigParser ini file.
674-
675-
:param file: filename
676-
:param profile: profile to read
677-
:return: Config object with configuration values
678-
"""
705+
def _read_ini(file, profile, default_section):
679706
res = dict()
680707
pattern = re.compile(r"^(.*?)_(.*)")
681708

682-
ini = configparser.RawConfigParser(default_section=config.user["config_profile"])
709+
ini = configparser.RawConfigParser(default_section=default_section)
683710
# Here, group(1) is the dictionary key, and group(2) the configuration element
684711
try:
685712
ini.read(file)
@@ -693,26 +720,33 @@ def process_ini_file(file, profile):
693720
except configparser.Error as err:
694721
logger.error(f"Could not load profile '{profile}': {str(err)}")
695722
sys.exit(2)
723+
724+
return res
725+
726+
727+
def process_ini_file(file, profile):
728+
"""Process options from a ConfigParser ini file.
729+
730+
:param file: filename
731+
:param profile: profile to read
732+
:return: Config object with configuration values
733+
"""
734+
res = _read_ini(file, profile, config.user["config_profile"])
696735
logger.debug(f"Found ini directives: {res}")
736+
if not res:
737+
return None
697738

698739
try:
699-
config_ini = Config(**res)
700-
740+
return Config(**res)
701741
except (AttributeError, KeyError, ValueError) as err:
702742
logger.error(
703743
f"The configuration file {file} in [{profile}] is incorrect: {err}"
704744
". Please check your settings and try again."
705745
)
706746
sys.exit(1)
707-
return config_ini
708747

709748

710-
def process_arguments(args):
711-
"""Process command-line arguments.
712-
713-
:param args: argparse object
714-
:return: Config object with configuration values
715-
"""
749+
def _read_arguments(args):
716750
res = dict()
717751
pattern = re.compile(r"^(.*?)_(.*)")
718752

@@ -726,18 +760,29 @@ def process_arguments(args):
726760
if val:
727761
res[match.group(1)][match.group(2)] = val
728762
add_sensitive_value_to_be_masked(val, match.group(2))
763+
764+
return res
765+
766+
767+
def process_arguments(args):
768+
"""Process command-line arguments.
769+
770+
:param args: argparse object
771+
:return: Config object with configuration values
772+
"""
773+
res = _read_arguments(args)
729774
logger.debug(f"Found arguments: {res}")
775+
if not res:
776+
return None
730777

731778
try:
732-
config_args = Config(**res)
733-
779+
return Config(**res)
734780
except (AttributeError, KeyError, ValueError) as err:
735781
logger.error(
736782
f"Command line arguments not correct: {err}"
737783
". This should not happen, please contact the package maintainers."
738784
)
739785
sys.exit(1)
740-
return config_args
741786

742787

743788
def process_environment(prefix="tokendito"):
@@ -759,21 +804,42 @@ def process_environment(prefix="tokendito"):
759804
add_sensitive_value_to_be_masked(val, match.group(3))
760805
logger.debug(f"Found environment variables: {res}")
761806

762-
try:
763-
config_env = Config(**res)
807+
if not res:
808+
return None
764809

810+
try:
811+
return Config(**res)
765812
except (AttributeError, KeyError, ValueError) as err:
766813
logger.error(
767814
f"The environment variables are incorrectly set: {err}"
768815
". Please check your settings and try again."
769816
)
770817
sys.exit(1)
771-
return config_env
818+
819+
820+
def _build_interactive_config(details, skip_password):
821+
res = dict(okta=dict())
822+
# Copy the values set by get_interactive_config
823+
if "okta_tile" in details:
824+
res["okta"]["tile"] = details["okta_tile"]
825+
if "okta_org" in details:
826+
res["okta"]["org"] = details["okta_org"]
827+
if "okta_username" in details:
828+
res["okta"]["username"] = details["okta_username"]
829+
830+
if ("password" not in config.okta or config.okta["password"] == "") and not skip_password:
831+
logger.debug("No password set, will try to get one interactively")
832+
res["okta"]["password"] = get_secret_input()
833+
add_sensitive_value_to_be_masked(res["okta"]["password"])
834+
835+
logger.debug(f"Interactive configuration is: {res}")
836+
837+
return Config(**res)
772838

773839

774840
def process_interactive_input(config, skip_password=False):
775841
"""
776-
Request input interactively interactively for elements that are not proesent.
842+
Request input interactively interactively for elements that are not present.
777843
778844
:param config: Config object with some values set.
779845
:param skip_password: Whether or not ask the user for a password.
@@ -782,7 +848,7 @@ def process_interactive_input(config, skip_password=False):
782848
# Return quickly if the user attempts to run in quiet (non-interactive) mode.
783849
if config.user["quiet"] is True:
784850
logger.debug(f"Skipping interactive config: quiet mode is {config.user['quiet']}")
785-
return config
851+
return None
786852

787853
# Reuse interactive config. It will only request the portions needed.
788854
try:
@@ -795,25 +861,10 @@ def process_interactive_input(config, skip_password=False):
795861
logger.error(f"Interactive arguments are not correct: {err}")
796862
sys.exit(1)
797863

798-
# Create a dict that can be passed to Config later
799-
res = dict(okta=dict())
800-
# Copy the values set by get_interactive_config
801-
if "okta_tile" in details:
802-
res["okta"]["tile"] = details["okta_tile"]
803-
if "okta_org" in details:
804-
res["okta"]["org"] = details["okta_org"]
805-
if "okta_username" in details:
806-
res["okta"]["username"] = details["okta_username"]
807-
808-
if ("password" not in config.okta or config.okta["password"] == "") and not skip_password:
809-
logger.debug("No password set, will try to get one interactively")
810-
res["okta"]["password"] = get_secret_input()
811-
add_sensitive_value_to_be_masked(res["okta"]["password"])
864+
if not details:
865+
return None
812866

813-
config_int = Config(**res)
814-
logger.debug(f"Interactive configuration is: {config_int}")
815-
config.update(config_int)
816-
return config_int
867+
return _build_interactive_config(details, skip_password)
817868

818869

819870
def get_interactive_config(tile=None, org=None, username=""):
@@ -1190,23 +1241,25 @@ def process_options(args):
11901241
sys.exit(0)
11911242

11921243
# 1: read ini file (if it exists)
1193-
config_ini = Config()
11941244
if not args.configure:
11951245
config_ini = process_ini_file(args.user_config_file, args.user_config_profile)
1246+
if config_ini:
1247+
config.update(config_ini)
11961248

11971249
# 2: override with ENV
11981250
config_env = process_environment()
1251+
if config_env:
1252+
config.update(config_env)
11991253

12001254
# 3: override with args
12011255
config_args = process_arguments(args)
1202-
1203-
config.update(config_ini)
1204-
config.update(config_env)
1205-
config.update(config_args)
1256+
if config_args:
1257+
config.update(config_args)
12061258

12071259
# 4: Get missing data from the user, if necessary
12081260
config_int = process_interactive_input(config, args.configure)
1209-
config.update(config_int)
1261+
if config_int:
1262+
config.update(config_int)
12101263

12111264
sanitize_config_values(config)
12121265
logger.debug(f"Final configuration is {config}")

0 commit comments

Comments
 (0)