From bad99e34b50f6cc8f43461530d0976b2464f972d Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Wed, 12 Nov 2025 15:49:39 +0530 Subject: [PATCH 01/37] Generate command added --- .../password/advanced_password_generation.py | 127 ++++++ .../password/basic_password_generation.py | 124 ++++++ .../comprehensive_password_generation.py | 403 ++++++++++++++++++ .../password/crypto_password_generation.py | 125 ++++++ .../password/diceware_password_generation.py | 125 ++++++ .../password/recovery_phrase_generation.py | 125 ++++++ examples/wallet_backup.txt | 4 + .../keepercli/commands/password_generate.py | 335 +++++++++++++++ .../keepercli/commands/share_management.py | 23 +- .../src/keepercli/helpers/password_utils.py | 351 +++++++++++++++ .../src/keepercli/register_commands.py | 5 +- keepersdk-package/README.md | 137 ------ 12 files changed, 1733 insertions(+), 151 deletions(-) create mode 100644 examples/password/advanced_password_generation.py create mode 100644 examples/password/basic_password_generation.py create mode 100644 examples/password/comprehensive_password_generation.py create mode 100644 examples/password/crypto_password_generation.py create mode 100644 examples/password/diceware_password_generation.py create mode 100644 examples/password/recovery_phrase_generation.py create mode 100644 examples/wallet_backup.txt create mode 100644 keepercli-package/src/keepercli/commands/password_generate.py create mode 100644 keepercli-package/src/keepercli/helpers/password_utils.py diff --git a/examples/password/advanced_password_generation.py b/examples/password/advanced_password_generation.py new file mode 100644 index 00000000..57c3930a --- /dev/null +++ b/examples/password/advanced_password_generation.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + +def login_to_keeper_with_config(filename: str) -> KeeperParams: + """ + Login to Keeper with a configuration file. + + This function logs in to Keeper using the provided configuration file. + It reads the configuration file, extracts the username, + and returns a Authenticated KeeperParams Context object. + """ + if not os.path.exists(filename): + raise FileNotFoundError(f'Config file {filename} not found') + with open(filename, 'r') as f: + config_data = json.load(f) + username = config_data.get('user', config_data.get('username')) + password = config_data.get('password', '') + if not username: + raise ValueError('Username not found in config file') + context = KeeperParams(config_filename=filename, config=config_data) + if username: + context.username = username + if password: + context.password = password + logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) + if not logged_in: + raise Exception('Failed to authenticate with Keeper') + return context + +def generate_advanced_passwords(context: KeeperParams): + """ + Generate advanced passwords with complexity rules and BreachWatch scanning. + + This function uses the Keeper CLI `PasswordGenerateCommand` to generate passwords + with specific complexity requirements and BreachWatch scanning. + """ + try: + command = PasswordGenerateCommand() + command.execute(context=context, number=3, length=24, symbols=3, digits=3, uppercase=3, lowercase=3) + return True + + except Exception as e: + logger.error(f'Error generating advanced passwords: {str(e)}') + return False + + +if __name__ == '__main__': + parser = argparse.ArgumentParser( + description='Generate advanced passwords with complexity rules using Keeper SDK', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +Examples: + python advanced_password_generation.py + ''' + ) + + default_config = get_default_config_path() + parser.add_argument( + '-c', '--config', + default=default_config, + help=f'Configuration file (default: {default_config})' + ) + + args = parser.parse_args() + + if not os.path.exists(args.config): + logger.error(f'Config file {args.config} not found') + sys.exit(1) + + context = None + try: + context = login_to_keeper_with_config(args.config) + + logger.info("Generating 3 advanced passwords of length 24...") + logger.info("Complexity: 3+ symbols, 3+ digits, 3+ uppercase, 3+ lowercase") + logger.info('BreachWatch scanning: Enabled') + + generate_advanced_passwords(context) + + except Exception as e: + logger.error(f'Error: {str(e)}') + sys.exit(1) + finally: + if context: + context.clear_session() diff --git a/examples/password/basic_password_generation.py b/examples/password/basic_password_generation.py new file mode 100644 index 00000000..bb46c550 --- /dev/null +++ b/examples/password/basic_password_generation.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + +def login_to_keeper_with_config(filename: str) -> KeeperParams: + """ + Login to Keeper with a configuration file. + + This function logs in to Keeper using the provided configuration file. + It reads the configuration file, extracts the username, + and returns a Authenticated KeeperParams Context object. + """ + if not os.path.exists(filename): + raise FileNotFoundError(f'Config file {filename} not found') + with open(filename, 'r') as f: + config_data = json.load(f) + username = config_data.get('user', config_data.get('username')) + password = config_data.get('password', '') + if not username: + raise ValueError('Username not found in config file') + context = KeeperParams(config_filename=filename, config=config_data) + if username: + context.username = username + if password: + context.password = password + logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) + if not logged_in: + raise Exception('Failed to authenticate with Keeper') + return context + +def generate_basic_passwords(context: KeeperParams): + """ + Generate basic random passwords. + + This function uses the Keeper CLI `PasswordGenerateCommand` to generate basic random passwords + with default settings. + """ + try: + command = PasswordGenerateCommand() + command.execute(context=context, number=1, length=20) + return True + + except Exception as e: + logger.error(f'Error generating passwords: {str(e)}') + return False + + +if __name__ == '__main__': + parser = argparse.ArgumentParser( + description='Generate basic passwords using Keeper SDK', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +Examples: + python basic_password_generation.py + ''' + ) + + default_config = get_default_config_path() + parser.add_argument( + '-c', '--config', + default=default_config, + help=f'Configuration file (default: {default_config})' + ) + + args = parser.parse_args() + + if not os.path.exists(args.config): + logger.error(f'Config file {args.config} not found') + sys.exit(1) + + context = None + try: + context = login_to_keeper_with_config(args.config) + + logger.info("Generating 1 basic password of length 20...") + + generate_basic_passwords(context) + + except Exception as e: + logger.error(f'Error: {str(e)}') + sys.exit(1) + finally: + if context: + context.clear_session() diff --git a/examples/password/comprehensive_password_generation.py b/examples/password/comprehensive_password_generation.py new file mode 100644 index 00000000..89085fe0 --- /dev/null +++ b/examples/password/comprehensive_password_generation.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + +def login_to_keeper_with_config(filename: str) -> KeeperParams: + """ + Login to Keeper with a configuration file. + + This function logs in to Keeper using the provided configuration file. + It reads the configuration file, extracts the username, + and returns a Authenticated KeeperParams Context object. + """ + if not os.path.exists(filename): + raise FileNotFoundError(f'Config file {filename} not found') + with open(filename, 'r') as f: + config_data = json.load(f) + username = config_data.get('user', config_data.get('username')) + password = config_data.get('password', '') + if not username: + raise ValueError('Username not found in config file') + context = KeeperParams(config_filename=filename, config=config_data) + if username: + context.username = username + if password: + context.password = password + logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) + if not logged_in: + raise Exception('Failed to authenticate with Keeper') + return context + +def demonstrate_all_password_types(context: KeeperParams): + """ + Demonstrate all available password generation types and features. + """ + command = PasswordGenerateCommand() + + print("\n" + "="*80) + print("COMPREHENSIVE PASSWORD GENERATION DEMONSTRATION") + print("="*80) + + # 1. Basic Random Passwords + print("\n1. BASIC RANDOM PASSWORDS (Default)") + print("-" * 40) + kwargs = { + 'number': 3, + 'length': 16, + 'output_format': 'table', + 'no_breachwatch': True, # Skip for demo speed + } + command.execute(context=context, **kwargs) + + # 2. Advanced Random with Complexity Rules + print("\n2. ADVANCED RANDOM WITH COMPLEXITY RULES") + print("-" * 40) + print("Rules: 3 uppercase, 3 lowercase, 3 digits, 2 symbols") + kwargs = { + 'number': 2, + 'length': 20, + 'uppercase': 3, + 'lowercase': 3, + 'digits': 3, + 'symbols': 2, + 'output_format': 'table', + 'no_breachwatch': True, + } + command.execute(context=context, **kwargs) + + # 3. Using Rules String Format + print("\n3. USING RULES STRING FORMAT") + print("-" * 40) + print("Rules string: '4,4,4,3' (uppercase,lowercase,digits,symbols)") + kwargs = { + 'number': 2, + 'length': 24, + 'rules': '4,4,4,3', + 'output_format': 'table', + 'no_breachwatch': True, + } + command.execute(context=context, **kwargs) + + # 4. Diceware Passwords + print("\n4. DICEWARE PASSWORDS") + print("-" * 40) + print("Using 6 dice rolls with space delimiter") + kwargs = { + 'number': 3, + 'dice_rolls': 6, + 'delimiter': ' ', + 'output_format': 'table', + 'no_breachwatch': True, + } + command.execute(context=context, **kwargs) + + # 5. Diceware with Different Delimiter + print("\n5. DICEWARE WITH DASH DELIMITER") + print("-" * 40) + print("Using 5 dice rolls with dash delimiter") + kwargs = { + 'number': 2, + 'dice_rolls': 5, + 'delimiter': '-', + 'output_format': 'table', + 'no_breachwatch': True, + } + command.execute(context=context, **kwargs) + + # 6. Crypto-style Passwords + print("\n6. CRYPTO-STYLE PASSWORDS") + print("-" * 40) + print("High-entropy passwords for cryptocurrency applications") + kwargs = { + 'crypto': True, + 'number': 2, + 'output_format': 'table', + 'no_breachwatch': True, + } + command.execute(context=context, **kwargs) + + # 7. Recovery Phrases + print("\n7. RECOVERY PHRASES (24-word)") + print("-" * 40) + print("Mnemonic phrases for wallet recovery") + kwargs = { + 'recoveryphrase': True, + 'number': 1, + 'output_format': 'table', + 'no_breachwatch': True, + } + command.execute(context=context, **kwargs) + + # 8. JSON Output Format + print("\n8. JSON OUTPUT FORMAT") + print("-" * 40) + print("Same data in JSON format with indentation") + kwargs = { + 'number': 2, + 'length': 16, + 'output_format': 'json', + 'json_indent': 2, + 'no_breachwatch': True, + } + command.execute(context=context, **kwargs) + + # 9. With BreachWatch Scanning (if available) + print("\n9. WITH BREACHWATCH SCANNING") + print("-" * 40) + print("Scanning passwords against known breaches") + kwargs = { + 'number': 2, + 'length': 16, + 'output_format': 'table', + 'no_breachwatch': False, # Enable BreachWatch + } + try: + command.execute(context=context, **kwargs) + except Exception as e: + logger.warning(f"BreachWatch scanning failed: {e}") + logger.info("This may occur if BreachWatch is not enabled or configured") + + print("\n" + "="*80) + print("DEMONSTRATION COMPLETE") + print("="*80) + +def generate_custom_passwords( + context: KeeperParams, + password_type: str, + **kwargs +): + """ + Generate passwords based on specified type and parameters. + """ + try: + command = PasswordGenerateCommand() + + # Set default parameters based on type + if password_type == 'basic': + default_kwargs = { + 'number': 3, + 'length': 20, + 'output_format': 'table', + } + elif password_type == 'advanced': + default_kwargs = { + 'number': 3, + 'length': 24, + 'symbols': 3, + 'digits': 3, + 'uppercase': 3, + 'lowercase': 3, + 'output_format': 'table', + } + elif password_type == 'diceware': + default_kwargs = { + 'number': 3, + 'dice_rolls': 6, + 'delimiter': ' ', + 'output_format': 'table', + } + elif password_type == 'crypto': + default_kwargs = { + 'crypto': True, + 'number': 3, + 'output_format': 'table', + } + elif password_type == 'recovery': + default_kwargs = { + 'recoveryphrase': True, + 'number': 2, + 'output_format': 'table', + } + else: + raise ValueError(f"Unknown password type: {password_type}") + + # Merge user parameters with defaults + final_kwargs = {**default_kwargs, **kwargs} + + command.execute(context=context, **final_kwargs) + return True + + except Exception as e: + logger.error(f'Error generating {password_type} passwords: {str(e)}') + return False + + +if __name__ == '__main__': + parser = argparse.ArgumentParser( + description='Comprehensive password generation example using Keeper SDK', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +Examples: + python comprehensive_password_generation.py --demo + python comprehensive_password_generation.py --type basic --number 5 --length 16 + python comprehensive_password_generation.py --type advanced --symbols 4 --digits 4 + python comprehensive_password_generation.py --type diceware --dice-rolls 8 --delimiter "-" + python comprehensive_password_generation.py --type crypto --number 3 --format json + python comprehensive_password_generation.py --type recovery --output recovery.txt + ''' + ) + + default_config = get_default_config_path() + parser.add_argument( + '-c', '--config', + default=default_config, + help=f'Configuration file (default: {default_config})' + ) + parser.add_argument( + '--demo', + action='store_true', + help='Run comprehensive demonstration of all password types' + ) + parser.add_argument( + '--type', + choices=['basic', 'advanced', 'diceware', 'crypto', 'recovery'], + help='Type of password to generate' + ) + parser.add_argument( + '-n', '--number', + type=int, + help='Number of passwords to generate' + ) + parser.add_argument( + '-l', '--length', + type=int, + help='Password length (for basic/advanced types)' + ) + parser.add_argument( + '--symbols', + type=int, + help='Minimum number of symbol characters' + ) + parser.add_argument( + '--digits', + type=int, + help='Minimum number of digit characters' + ) + parser.add_argument( + '--uppercase', + type=int, + help='Minimum number of uppercase characters' + ) + parser.add_argument( + '--lowercase', + type=int, + help='Minimum number of lowercase characters' + ) + parser.add_argument( + '--dice-rolls', + type=int, + help='Number of dice rolls for diceware generation' + ) + parser.add_argument( + '--delimiter', + choices=['-', '+', ':', '.', '/', '_', '=', ' '], + help='Word delimiter for diceware' + ) + parser.add_argument( + '-f', '--format', + choices=['table', 'json'], + default='table', + help='Output format (default: table)' + ) + parser.add_argument( + '-o', '--output', + help='Write output to specified file' + ) + parser.add_argument( + '--no-breachwatch', + action='store_true', + help='Skip BreachWatch scanning' + ) + + args = parser.parse_args() + + if not os.path.exists(args.config): + logger.error(f'Config file {args.config} not found') + sys.exit(1) + + # If no arguments provided, default to demo mode + if not args.demo and not args.type: + args.demo = True + logger.info("No arguments provided, running comprehensive demonstration") + logger.info("Use --help to see all available options") + + context = None + try: + context = login_to_keeper_with_config(args.config) + + if args.demo: + demonstrate_all_password_types(context) + else: + # Build kwargs from command line arguments + kwargs = { + 'output_format': args.format, + 'no_breachwatch': args.no_breachwatch, + } + + if args.number: + kwargs['number'] = args.number + if args.length: + kwargs['length'] = args.length + if args.symbols: + kwargs['symbols'] = args.symbols + if args.digits: + kwargs['digits'] = args.digits + if args.uppercase: + kwargs['uppercase'] = args.uppercase + if args.lowercase: + kwargs['lowercase'] = args.lowercase + if args.dice_rolls: + kwargs['dice_rolls'] = args.dice_rolls + if args.delimiter: + kwargs['delimiter'] = args.delimiter + if args.output: + kwargs['output_file'] = args.output + + generate_custom_passwords(context, args.type, **kwargs) + + except Exception as e: + logger.error(f'Error: {str(e)}') + sys.exit(1) + finally: + if context: + context.clear_session() diff --git a/examples/password/crypto_password_generation.py b/examples/password/crypto_password_generation.py new file mode 100644 index 00000000..24353959 --- /dev/null +++ b/examples/password/crypto_password_generation.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + +def login_to_keeper_with_config(filename: str) -> KeeperParams: + """ + Login to Keeper with a configuration file. + + This function logs in to Keeper using the provided configuration file. + It reads the configuration file, extracts the username, + and returns a Authenticated KeeperParams Context object. + """ + if not os.path.exists(filename): + raise FileNotFoundError(f'Config file {filename} not found') + with open(filename, 'r') as f: + config_data = json.load(f) + username = config_data.get('user', config_data.get('username')) + password = config_data.get('password', '') + if not username: + raise ValueError('Username not found in config file') + context = KeeperParams(config_filename=filename, config=config_data) + if username: + context.username = username + if password: + context.password = password + logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) + if not logged_in: + raise Exception('Failed to authenticate with Keeper') + return context + +def generate_crypto_passwords(context: KeeperParams): + """ + Generate crypto-style strong passwords. + + This function uses the Keeper CLI `PasswordGenerateCommand` to generate crypto-style passwords + that are optimized for high security applications like cryptocurrency wallets. + """ + try: + command = PasswordGenerateCommand() + command.execute(context=context, crypto=True, number=3) + return True + + except Exception as e: + logger.error(f'Error generating crypto passwords: {str(e)}') + return False + + +if __name__ == '__main__': + parser = argparse.ArgumentParser( + description='Generate crypto-style passwords using Keeper SDK', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +Examples: + python crypto_password_generation.py + ''' + ) + + default_config = get_default_config_path() + parser.add_argument( + '-c', '--config', + default=default_config, + help=f'Configuration file (default: {default_config})' + ) + + args = parser.parse_args() + + if not os.path.exists(args.config): + logger.error(f'Config file {args.config} not found') + sys.exit(1) + + context = None + try: + context = login_to_keeper_with_config(args.config) + + logger.info("Generating 3 crypto-style passwords...") + logger.info("These passwords are optimized for high-security applications like cryptocurrency") + + generate_crypto_passwords(context) + + except Exception as e: + logger.error(f'Error: {str(e)}') + sys.exit(1) + finally: + if context: + context.clear_session() diff --git a/examples/password/diceware_password_generation.py b/examples/password/diceware_password_generation.py new file mode 100644 index 00000000..9fc0b9e1 --- /dev/null +++ b/examples/password/diceware_password_generation.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + +def login_to_keeper_with_config(filename: str) -> KeeperParams: + """ + Login to Keeper with a configuration file. + + This function logs in to Keeper using the provided configuration file. + It reads the configuration file, extracts the username, + and returns a Authenticated KeeperParams Context object. + """ + if not os.path.exists(filename): + raise FileNotFoundError(f'Config file {filename} not found') + with open(filename, 'r') as f: + config_data = json.load(f) + username = config_data.get('user', config_data.get('username')) + password = config_data.get('password', '') + if not username: + raise ValueError('Username not found in config file') + context = KeeperParams(config_filename=filename, config=config_data) + if username: + context.username = username + if password: + context.password = password + logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) + if not logged_in: + raise Exception('Failed to authenticate with Keeper') + return context + +def generate_diceware_passwords(context: KeeperParams): + """ + Generate diceware passwords. + + This function uses the Keeper CLI `PasswordGenerateCommand` to generate diceware-style passwords + using dice rolls to select words from a word list with hyphen (-) delimiter. + """ + try: + command = PasswordGenerateCommand() + command.execute(context=context, number=1, dice_rolls=6, delimiter='-') + return True + + except Exception as e: + logger.error(f'Error generating diceware passwords: {str(e)}') + return False + + +if __name__ == '__main__': + parser = argparse.ArgumentParser( + description='Generate diceware passwords with hyphen delimiter using Keeper SDK', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +Examples: + python diceware_password_generation.py + ''' + ) + + default_config = get_default_config_path() + parser.add_argument( + '-c', '--config', + default=default_config, + help=f'Configuration file (default: {default_config})' + ) + + args = parser.parse_args() + + if not os.path.exists(args.config): + logger.error(f'Config file {args.config} not found') + sys.exit(1) + + context = None + try: + context = login_to_keeper_with_config(args.config) + + logger.info("Generating 1 diceware password...") + logger.info("Using 6 dice rolls with hyphen (-) delimiter and default word list") + + generate_diceware_passwords(context) + + except Exception as e: + logger.error(f'Error: {str(e)}') + sys.exit(1) + finally: + if context: + context.clear_session() diff --git a/examples/password/recovery_phrase_generation.py b/examples/password/recovery_phrase_generation.py new file mode 100644 index 00000000..2b648ea5 --- /dev/null +++ b/examples/password/recovery_phrase_generation.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + +def login_to_keeper_with_config(filename: str) -> KeeperParams: + """ + Login to Keeper with a configuration file. + + This function logs in to Keeper using the provided configuration file. + It reads the configuration file, extracts the username, + and returns a Authenticated KeeperParams Context object. + """ + if not os.path.exists(filename): + raise FileNotFoundError(f'Config file {filename} not found') + with open(filename, 'r') as f: + config_data = json.load(f) + username = config_data.get('user', config_data.get('username')) + password = config_data.get('password', '') + if not username: + raise ValueError('Username not found in config file') + context = KeeperParams(config_filename=filename, config=config_data) + if username: + context.username = username + if password: + context.password = password + logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) + if not logged_in: + raise Exception('Failed to authenticate with Keeper') + return context + +def generate_recovery_phrases(context: KeeperParams): + """ + Generate 24-word recovery phrases. + + This function uses the Keeper CLI `PasswordGenerateCommand` to generate recovery phrases + suitable for cryptocurrency wallets and other applications requiring mnemonic phrases. + """ + try: + command = PasswordGenerateCommand() + command.execute(context=context, recoveryphrase=True, number=2, no_breachwatch=True) + return True + + except Exception as e: + logger.error(f'Error generating recovery phrases: {str(e)}') + return False + + +if __name__ == '__main__': + parser = argparse.ArgumentParser( + description='Generate 24-word recovery phrases using Keeper SDK', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +Examples: + python recovery_phrase_generation.py + ''' + ) + + default_config = get_default_config_path() + parser.add_argument( + '-c', '--config', + default=default_config, + help=f'Configuration file (default: {default_config})' + ) + + args = parser.parse_args() + + if not os.path.exists(args.config): + logger.error(f'Config file {args.config} not found') + sys.exit(1) + + context = None + try: + context = login_to_keeper_with_config(args.config) + + logger.info("Generating 2 recovery phrases...") + logger.info("These are 24-word phrases suitable for cryptocurrency wallet recovery") + + generate_recovery_phrases(context) + + except Exception as e: + logger.error(f'Error: {str(e)}') + sys.exit(1) + finally: + if context: + context.clear_session() diff --git a/examples/wallet_backup.txt b/examples/wallet_backup.txt new file mode 100644 index 00000000..27bcc839 --- /dev/null +++ b/examples/wallet_backup.txt @@ -0,0 +1,4 @@ + Strength(%) BreachWatch Password +1 100 Skipped helmet prosper artefact paddle style cannon slow left original logic hawk loyal middle sugar enable carbon asset hood patient apple ability seminar fold family +2 100 Skipped diet join choose indoor sponsor use item suggest front hour feature tribe butter kitchen give art awkward seven negative refuse man wise act collect +3 100 Skipped canyon boss vibrant share suspect ladder mimic spring filter scene level chuckle predict hood vintage claim train draw smoke pave neutral possible jungle enable \ No newline at end of file diff --git a/keepercli-package/src/keepercli/commands/password_generate.py b/keepercli-package/src/keepercli/commands/password_generate.py new file mode 100644 index 00000000..bf7a192d --- /dev/null +++ b/keepercli-package/src/keepercli/commands/password_generate.py @@ -0,0 +1,335 @@ +""" +Password generation command for Keeper CLI. + +This module provides the CLI interface for generating passwords with +optional BreachWatch scanning and various output formats. +""" + +import argparse +import json +from typing import Any, Optional, List, Dict + +import pyperclip + +MAX_PASSWORD_COUNT = 1000 +MAX_PASSWORD_LENGTH = 256 +MAX_DICE_ROLLS = 40 +MIN_PASSWORD_COUNT = 1 +MIN_PASSWORD_LENGTH = 1 +MIN_DICE_ROLLS = 1 +DEFAULT_JSON_INDENT = 2 +COMPLEXITY_RULES_COUNT = 4 + +from . import base +from .. import api +from ..helpers.password_utils import ( + PasswordGenerationService, GenerationRequest, GeneratedPassword, + BreachStatus, PasswordStrength +) +from ..params import KeeperParams + +logger = api.get_logger() + + +class PasswordGenerateCommand(base.ArgparseCommand): + """Command for generating passwords with optional BreachWatch scanning.""" + + def __init__(self): + """Initialize the password generate command.""" + self.parser = argparse.ArgumentParser( + prog='generate', + description='Generate secure passwords with optional BreachWatch scanning' + ) + PasswordGenerateCommand.add_arguments_to_parser(self.parser) + super().__init__(self.parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser) -> None: + """Add password generation arguments to parser.""" + parser.add_argument('--clipboard', '-cc', dest='clipboard', action='store_true', + help='Copy generated passwords to clipboard') + parser.add_argument('--quiet', '-q', dest='quiet', action='store_true', + help='Only print password list (minimal output)') + parser.add_argument('--password-list', '-p', dest='password_list', action='store_true', + help='Include password list in addition to formatted output') + parser.add_argument('--output', '-o', dest='output_file', action='store', + help='Write output to specified file') + parser.add_argument('--format', '-f', dest='output_format', action='store', + choices=['table', 'json'], default='table', + help='Output format (default: table)') + parser.add_argument('--json-indent', '-i', dest='json_indent', action='store', type=int, default=DEFAULT_JSON_INDENT, + help='JSON format indent level (default: 2)') + + parser.add_argument('--number', '-n', dest='number', type=int, default=1, + help='Number of passwords to generate (default: 1)') + parser.add_argument('--no-breachwatch', '-nb', dest='no_breachwatch', action='store_true', + help='Skip BreachWatch scanning') + + random_group = parser.add_argument_group('Random Password Options') + random_group.add_argument('--length', dest='length', type=int, default=20, + help='Password length (default: 20)') + random_group.add_argument('--count', '-c', dest='length', type=int, metavar='LENGTH', + help='Length of password') + random_group.add_argument('--rules', '-r', dest='rules', action='store', + help='Complexity rules as comma-separated integers: uppercase,lowercase,digits,symbols') + random_group.add_argument('--symbols', '-s', dest='symbols', type=int, + help='Minimum number of symbol characters') + random_group.add_argument('--digits', '-d', dest='digits', type=int, + help='Minimum number of digit characters') + random_group.add_argument('--uppercase', '-u', dest='uppercase', type=int, + help='Minimum number of uppercase characters') + random_group.add_argument('--lowercase', '-l', dest='lowercase', type=int, + help='Minimum number of lowercase characters') + + special_group = parser.add_argument_group('Special Password Types') + special_group.add_argument('--crypto', dest='crypto', action='store_true', + help='Generate crypto-style strong password') + special_group.add_argument('--recoveryphrase', dest='recoveryphrase', action='store_true', + help='Generate 24-word recovery phrase') + + diceware_group = parser.add_argument_group('Diceware Options') + diceware_group.add_argument('--dice-rolls', '-dr', dest='dice_rolls', type=int, + help='Number of dice rolls for diceware generation') + diceware_group.add_argument('--delimiter', '-dl', dest='delimiter', + choices=['-', '+', ':', '.', '/', '_', '=', ' '], default=' ', + help='Word delimiter for diceware (default: space)') + diceware_group.add_argument('--word-list', dest='word_list', + help='Path to custom word list file for diceware') + + def execute(self, context: KeeperParams, **kwargs) -> Any: + """Execute the password generation command.""" + if not context.vault: + raise base.CommandError('Vault is not initialized. Please log in to initialize the vault.') + + try: + request = self._create_generation_request(**kwargs) + service = self._create_password_service(context.vault, request) + passwords = self._generate_passwords(service, request) + self._output_results(passwords, **kwargs) + + except Exception as e: + logger.error(f"Password generation failed: {e}") + raise base.CommandError(f"Password generation failed: {e}") + + def _create_password_service(self, vault, request: GenerationRequest) -> PasswordGenerationService: + """Create password generation service with optional BreachWatch.""" + breach_watch = None + + if not request.enable_breach_scan: + logger.debug("BreachWatch scanning disabled by user") + elif vault.breach_watch_plugin(): + breach_watch_plugin = vault.breach_watch_plugin() + breach_watch = breach_watch_plugin.breach_watch + logger.debug("Using BreachWatch for password scanning") + else: + logger.warning("BreachWatch plugin not available, enable it to use") + request.enable_breach_scan = False + + return PasswordGenerationService(breach_watch) + + def _generate_passwords(self, service: PasswordGenerationService, request: GenerationRequest) -> List[GeneratedPassword]: + """Generate passwords using the service.""" + if request.enable_breach_scan and service.breach_watch: + logger.info(f"Generating {request.count} password(s) with BreachWatch scanning...") + else: + logger.info(f"Generating {request.count} password(s)...") + + return service.generate_passwords(request) + + def _create_generation_request(self, **kwargs) -> GenerationRequest: + """Create a GenerationRequest from command line arguments.""" + count = self._validate_count(kwargs.get('number', 1)) + length = self._validate_length(kwargs.get('length', 20)) + algorithm = self._determine_algorithm(kwargs) + + symbols, digits, uppercase, lowercase = self._validate_complexity_parameters(kwargs) + rules = self._validate_rules(kwargs.get('rules')) + dice_rolls = self._validate_dice_rolls(kwargs.get('dice_rolls')) + + return GenerationRequest( + length=length, + count=count, + algorithm=algorithm, + symbols=symbols, + digits=digits, + uppercase=uppercase, + lowercase=lowercase, + rules=rules, + dice_rolls=dice_rolls, + delimiter=kwargs.get('delimiter', ' '), + word_list_file=kwargs.get('word_list'), + enable_breach_scan=not kwargs.get('no_breachwatch', False) + # max_breach_attempts uses GenerationRequest default value + ) + + def _validate_count(self, count: int) -> int: + """Validate password count parameter.""" + if count < MIN_PASSWORD_COUNT: + raise base.CommandError(f'Number of passwords must be at least {MIN_PASSWORD_COUNT}') + if count > MAX_PASSWORD_COUNT: + raise base.CommandError(f'Number of passwords cannot exceed {MAX_PASSWORD_COUNT}') + return count + + def _validate_length(self, length: int) -> int: + """Validate password length parameter.""" + if length < MIN_PASSWORD_LENGTH: + raise base.CommandError(f'Password length must be at least {MIN_PASSWORD_LENGTH}') + if length > MAX_PASSWORD_LENGTH: + raise base.CommandError(f'Password length cannot exceed {MAX_PASSWORD_LENGTH}') + return length + + def _determine_algorithm(self, kwargs: Dict[str, Any]) -> str: + """Determine password generation algorithm from arguments.""" + if kwargs.get('crypto'): + return 'crypto' + elif kwargs.get('recoveryphrase'): + return 'recovery' + elif kwargs.get('dice_rolls'): + return 'diceware' + else: + return 'random' # default + + def _validate_complexity_parameters(self, kwargs: Dict[str, Any]) -> tuple: + """Validate complexity parameters (symbols, digits, uppercase, lowercase).""" + symbols = kwargs.get('symbols') + digits = kwargs.get('digits') + uppercase = kwargs.get('uppercase') + lowercase = kwargs.get('lowercase') + + # Ensure complexity parameters are non-negative + for param_name, param_value in [('symbols', symbols), ('digits', digits), + ('uppercase', uppercase), ('lowercase', lowercase)]: + if param_value is not None and param_value < 0: + raise base.CommandError(f'{param_name.capitalize()} count cannot be negative') + + return symbols, digits, uppercase, lowercase + + def _validate_rules(self, rules: Optional[str]) -> Optional[str]: + """Validate complexity rules format.""" + if not rules: + return rules + + try: + rule_parts = [x.strip() for x in rules.split(',')] + if len(rule_parts) != COMPLEXITY_RULES_COUNT: + raise ValueError(f"Rules must have exactly {COMPLEXITY_RULES_COUNT} comma-separated values") + for part in rule_parts: + if not part.isdigit(): + raise ValueError("All rule values must be non-negative integers") + except ValueError as e: + raise base.CommandError(f'Invalid rules format: {e}. Expected format: "upper,lower,digits,symbols"') + + return rules + + def _validate_dice_rolls(self, dice_rolls: Optional[int]) -> Optional[int]: + """Validate diceware dice rolls parameter.""" + if dice_rolls is None: + return dice_rolls + + if dice_rolls < MIN_DICE_ROLLS: + raise base.CommandError(f'Dice rolls must be at least {MIN_DICE_ROLLS}') + if dice_rolls > MAX_DICE_ROLLS: + raise base.CommandError(f'Dice rolls cannot exceed {MAX_DICE_ROLLS}') + + return dice_rolls + + def _output_results(self, passwords: List[GeneratedPassword], **kwargs) -> None: + """Format and output the generated passwords.""" + output_format = kwargs.get('output_format', 'table') + quiet = kwargs.get('quiet', False) + password_list = kwargs.get('password_list', False) + output_file = kwargs.get('output_file') + clipboard = kwargs.get('clipboard', False) + + if quiet: + output = self._format_password_list(passwords) + elif output_format == 'json': + output = self._format_json(passwords, kwargs.get('json_indent', DEFAULT_JSON_INDENT)) + else: + output = self._format_table(passwords) + + if password_list and not quiet: + output += '\n\n' + self._format_password_list(passwords) + + if clipboard: + try: + pyperclip.copy(output) + logger.info("Generated passwords copied to clipboard") + except Exception as e: + logger.warning(f"Failed to copy to clipboard: {e}") + + if output_file: + try: + with open(output_file, 'w', encoding='utf-8') as f: + f.write(output) + logger.info(f"Output written to: {output_file}") + except Exception as e: + logger.error(f"Failed to write to file {output_file}: {e}") + raise base.CommandError('generate', f"File write error: {e}") + else: + print(output) + + def _format_table(self, passwords: List[GeneratedPassword]) -> str: + """Format passwords as a table with Keeper-style formatting.""" + if not passwords: + return "No passwords generated." + + lines = [] + + has_breach_info = any(pwd.breach_status is not None for pwd in passwords) + + if has_breach_info: + scan_count = len([pwd for pwd in passwords if pwd.breach_status != BreachStatus.SKIPPED]) + if scan_count > 0: + lines.append(f"Breachwatch: {scan_count} password{'s' if scan_count != 1 else ''} to scan") + + if has_breach_info: + header = f" {'Strength(%)':<12} {'BreachWatch':<12} {'Password'}" + else: + header = f" {'Strength(%)':<12} {'Password'}" + lines.append(header) + for i, pwd in enumerate(passwords, 1): + strength_display = str(pwd.strength_score) + + if has_breach_info: + breach_display = self._get_breach_display(pwd) + line = f"{i:<5}{strength_display:<12} {breach_display:<12} {pwd.password}" + else: + line = f"{i:<5}{strength_display:<12} {pwd.password}" + + lines.append(line) + + return '\n'.join(lines) + + def _format_json(self, passwords: List[GeneratedPassword], indent: int) -> str: + """Format passwords as JSON.""" + data = [] + for pwd in passwords: + entry = { + 'password': pwd.password, + 'strength': pwd.strength_score + } + + if pwd.breach_status is not None: + entry['breach_watch'] = self._get_breach_display(pwd) + + data.append(entry) + + return json.dumps(data, indent=indent if indent > 0 else None, ensure_ascii=False) + + def _format_password_list(self, passwords: List[GeneratedPassword]) -> str: + """Format as simple password list.""" + return '\n'.join(pwd.password for pwd in passwords) + + def _get_breach_display(self, password: GeneratedPassword) -> str: + """Get display string for breach status.""" + if password.breach_status == BreachStatus.PASSED: + return "Passed" + elif password.breach_status == BreachStatus.FAILED: + return "Failed" + elif password.breach_status == BreachStatus.SKIPPED: + return "Skipped" + elif password.breach_status == BreachStatus.ERROR: + return "Error" + else: + return "Unknown" diff --git a/keepercli-package/src/keepercli/commands/share_management.py b/keepercli-package/src/keepercli/commands/share_management.py index 4843b8da..ab628f57 100644 --- a/keepercli-package/src/keepercli/commands/share_management.py +++ b/keepercli-package/src/keepercli/commands/share_management.py @@ -130,7 +130,7 @@ def execute(self, context: KeeperParams, **kwargs) -> None: emails = kwargs.get('email') or [] if not emails: - raise ValueError('share-record', '\'email\' parameter is missing') + raise ValueError('\'email\' parameter is missing') force = kwargs.get('force') action = kwargs.get('action', ShareAction.GRANT.value) @@ -270,7 +270,7 @@ def prep_request(context: KeeperParams, pass if record_uid is None and folder_uid is None and shared_folder_uid is None: - raise ValueError('share-record', 'Enter name or uid of existing record or shared folder') + raise ValueError('Enter name or uid of existing record or shared folder') # Collect record UIDs record_uids = set() @@ -288,7 +288,7 @@ def prep_request(context: KeeperParams, record_uids = {uid for uid in folders if uid in record_cache} elif shared_folder_uid: if not recursive: - raise ValueError('share-record', '--recursive parameter is required') + raise ValueError('--recursive parameter is required') if isinstance(shared_folder_uid, str): sf = vault.vault_data.load_shared_folder(shared_folder_uid=shared_folder_uid) if sf and sf.record_permissions: @@ -301,10 +301,10 @@ def prep_request(context: KeeperParams, record_uids.update(x.record_uid for x in sf.record_permissions) if not record_uids: - raise ValueError('share-record', 'There are no records to share selected') + raise ValueError('There are no records to share selected') if action == 'owner' and len(emails) > 1: - raise ValueError('share-record', 'You can transfer ownership to a single account only') + raise ValueError('You can transfer ownership to a single account only') all_users = {email.casefold() for email in emails} @@ -321,7 +321,7 @@ def prep_request(context: KeeperParams, all_users.intersection_update(vault.keeper_auth._key_cache.keys()) if not all_users: - raise ValueError('share-record', 'Nothing to do.') + raise ValueError('Nothing to do.') # Load records in shared folders if shared_folder_uid: @@ -675,7 +675,7 @@ def get_folder_by_uid(uid): shared_folder_uids.update(share_admin_folder_uids or []) if not shared_folder_uids: - raise ValueError('share-folder', 'Enter name of at least one existing folder') + raise ValueError('Enter name of at least one existing folder') action = kwargs.get('action') or ShareAction.GRANT.value @@ -1059,7 +1059,7 @@ def execute(self, context: KeeperParams, **kwargs): record_uids = self._resolve_record_uids(context, vault, records, kwargs.get('recursive', False)) if not record_uids: - raise base.CommandError('one-time-share', 'No records found') + raise base.CommandError('No records found') applications = self._get_applications(vault, record_uids) table_data = self._build_share_table(applications, kwargs) @@ -1234,11 +1234,10 @@ def execute(self, context: KeeperParams, **kwargs): record_names = [record_names] if not record_names: self.get_parser().print_help() - return None + raise base.CommandError('No records provided') if not period_str: - logger.warning('URL expiration period parameter \"--expire\" is required.') self.get_parser().print_help() - return None + raise base.CommandError('URL expiration period parameter \"--expire\" is required.') period = self._validate_and_parse_expiration(period_str) @@ -1251,7 +1250,7 @@ def _validate_and_parse_expiration(self, period_str): period = timeout_utils.parse_timeout(period_str) SIX_MONTHS_IN_SECONDS = 182 * 24 * 60 * 60 if period.total_seconds() > SIX_MONTHS_IN_SECONDS: - raise base.CommandError('one-time-share', 'URL expiration period cannot be greater than 6 months.') + raise base.CommandError('URL expiration period cannot be greater than 6 months.') return period def _create_share_urls(self, context: KeeperParams, vault, record_names: list, period, name: str, is_editable: bool): diff --git a/keepercli-package/src/keepercli/helpers/password_utils.py b/keepercli-package/src/keepercli/helpers/password_utils.py new file mode 100644 index 00000000..f1d723ab --- /dev/null +++ b/keepercli-package/src/keepercli/helpers/password_utils.py @@ -0,0 +1,351 @@ +""" +Password generation utilities for Keeper CLI. + +This module provides password generation functionality with optional BreachWatch integration. +""" + +import dataclasses +from typing import Optional, List, Dict, Any, Union, Iterator, Tuple +from enum import Enum + +import os +import secrets + +from keepersdk import generator, utils + +BREACHWATCH_MAX = 5 +DEFAULT_PASSWORD_LENGTH = 20 +DEFAULT_DICEWARE_ROLLS = 5 +RECOVERY_PHRASE_WORDS = 24 +STRENGTH_WEAK_THRESHOLD = 40 +STRENGTH_FAIR_THRESHOLD = 60 +STRENGTH_GOOD_THRESHOLD = 80 +CRYPTO_MIN_CHAR_RATIO = 6 +DEFAULT_DICEWARE_FILE = 'diceware.wordlist.asc.txt' +RECOVERY_WORDLIST_FILE = 'bip-39.english.txt' + + +class CustomDicewareGenerator(generator.PasswordGenerator): + """Custom Diceware generator with delimiter support.""" + + def __init__(self, number_of_rolls: int, word_list_file: Optional[str] = None, delimiter: str = ' '): + self._number_of_rolls = number_of_rolls if number_of_rolls > 0 else DEFAULT_DICEWARE_ROLLS + self._delimiter = delimiter + self._vocabulary = self._load_word_list(word_list_file) + + def _load_word_list(self, word_list_file: Optional[str]) -> List[str]: + """Load and validate diceware word list from file.""" + dice_path = self._get_word_list_path(word_list_file) + + if not os.path.isfile(dice_path): + raise FileNotFoundError(f'Word list file "{dice_path}" not found.') + + return self._parse_word_list_file(dice_path) + + def _get_word_list_path(self, word_list_file: Optional[str]) -> str: + """Get the full path to the word list file.""" + if word_list_file: + dice_path = os.path.join(os.path.dirname(generator.__file__), 'resources', word_list_file) + if not os.path.isfile(dice_path): + dice_path = os.path.expanduser(word_list_file) + return dice_path + else: + return os.path.join(os.path.dirname(generator.__file__), 'resources', DEFAULT_DICEWARE_FILE) + + def _parse_word_list_file(self, dice_path: str) -> List[str]: + """Parse word list file and validate uniqueness.""" + vocabulary = [] + line_count = 0 + unique_words = set() + + with open(dice_path, 'r') as dw: + for line in dw.readlines(): + if not line or line.startswith('--'): + continue + + line_count += 1 + words = [x.strip() for x in line.split()] + word = words[1] if len(words) >= 2 else words[0] + vocabulary.append(word) + unique_words.add(word.lower()) + + if line_count != len(unique_words): + raise Exception(f'Word list file "{dice_path}" contains non-unique words.') + + return vocabulary + + def generate(self) -> str: + if not self._vocabulary: + raise Exception('Diceware word list was not loaded') + + words = [secrets.choice(self._vocabulary) for _ in range(self._number_of_rolls)] + self.shuffle(words) + return self._delimiter.join(words) + + +class PasswordStrength(Enum): + """Password strength levels.""" + WEAK = "WEAK" + FAIR = "FAIR" + GOOD = "GOOD" + STRONG = "STRONG" + + +class BreachStatus(Enum): + """BreachWatch scan results.""" + PASSED = "PASSED" + FAILED = "FAILED" + SKIPPED = "SKIPPED" + ERROR = "ERROR" + + +@dataclasses.dataclass +class GeneratedPassword: + """Data model for a generated password with analysis results.""" + password: str + strength_score: int + strength_level: PasswordStrength + breach_status: Optional[BreachStatus] = None + breach_details: Optional[str] = None + + +@dataclasses.dataclass +class GenerationRequest: + """Configuration for password generation request.""" + length: int = 20 + count: int = 1 + algorithm: str = 'random' + + symbols: Optional[int] = None + digits: Optional[int] = None + uppercase: Optional[int] = None + lowercase: Optional[int] = None + rules: Optional[str] = None + + dice_rolls: Optional[int] = None + delimiter: str = ' ' + word_list_file: Optional[str] = None + + enable_breach_scan: bool = True + max_breach_attempts: int = BREACHWATCH_MAX + + +class PasswordGenerationService: + """ + Service for generating passwords with optional BreachWatch integration. + + This service provides a unified interface for password generation, + strength analysis, and breach detection. + """ + + def __init__(self, breach_watch=None): + """ + Initialize the password generation service. + + Args: + breach_watch: Optional BreachWatch instance for breach scanning + """ + self.breach_watch = breach_watch + + def generate_passwords(self, request: GenerationRequest) -> List[GeneratedPassword]: + """ + Generate passwords according to the provided request. + + Args: + request: Configuration for password generation + + Returns: + List of generated passwords with analysis results + + Raises: + ValueError: If generation parameters are invalid + """ + password_generator = self._create_generator(request) + + if not request.enable_breach_scan or not self.breach_watch: + return self._generate_passwords_without_breach_scan(password_generator, request.count) + else: + return self._generate_passwords_with_breach_scan(password_generator, request) + + def _generate_passwords_without_breach_scan(self, generator: generator.PasswordGenerator, count: int) -> List[GeneratedPassword]: + """Generate passwords with strength analysis only.""" + new_passwords = [generator.generate() for _ in range(count)] + return [self._analyze_password(p, enable_breach_scan=False) for p in new_passwords] + + def _generate_passwords_with_breach_scan(self, password_generator: generator.PasswordGenerator, request: GenerationRequest) -> List[GeneratedPassword]: + """Generate passwords with BreachWatch scanning and retry logic.""" + passwords = [] + breachwatch_count = 0 + + while len(passwords) < request.count: + new_passwords = [password_generator.generate() for _ in range(request.count - len(passwords))] + breachwatch_count += 1 + breachwatch_maxed = breachwatch_count >= request.max_breach_attempts + + try: + scanned_passwords = self._scan_passwords_for_breaches(new_passwords, breachwatch_maxed) + passwords.extend(scanned_passwords) + except Exception: + fallback_passwords = [self._analyze_password(p, enable_breach_scan=False) for p in new_passwords] + passwords.extend(fallback_passwords) + break + + return passwords[:request.count] + + def _scan_passwords_for_breaches(self, passwords_to_scan: List[str], accept_breached: bool) -> List[GeneratedPassword]: + """Scan passwords using BreachWatch and return analyzed results.""" + scanned_passwords = [] + euids_to_cleanup = [] + + try: + for breach_result in self.breach_watch.scan_passwords(passwords_to_scan): + password = breach_result[0] + status = breach_result[1] if len(breach_result) > 1 else None + + if status and hasattr(status, 'euid') and status.euid: + euids_to_cleanup.append(status.euid) + + analyzed_password = self._process_breach_scan_result(password, status, accept_breached) + if analyzed_password: + scanned_passwords.append(analyzed_password) + finally: + self._cleanup_breach_scan_euids(euids_to_cleanup) + + return scanned_passwords + + def _process_breach_scan_result(self, password: str, status: Any, accept_breached: bool) -> Optional[GeneratedPassword]: + """Process a single breach scan result and return analyzed password if acceptable.""" + strength_score = utils.password_score(password) + strength_level = self._get_strength_level(strength_score) + + if status and hasattr(status, 'breachDetected'): + if status.breachDetected: + if accept_breached: + return GeneratedPassword( + password=password, + strength_score=strength_score, + strength_level=strength_level, + breach_status=BreachStatus.FAILED + ) + return None + else: + return GeneratedPassword( + password=password, + strength_score=strength_score, + strength_level=strength_level, + breach_status=BreachStatus.PASSED + ) + else: + if accept_breached: + return GeneratedPassword( + password=password, + strength_score=strength_score, + strength_level=strength_level, + breach_status=BreachStatus.ERROR + ) + return None + + def _cleanup_breach_scan_euids(self, euids: List[str]) -> None: + """Clean up BreachWatch scan EUIDs.""" + if euids and self.breach_watch: + try: + self.breach_watch.delete_euids(euids) + except Exception: + pass + + def _create_generator(self, request: GenerationRequest) -> generator.PasswordGenerator: + """Create appropriate password generator based on request.""" + algorithm = request.algorithm.lower() + + if algorithm == 'crypto': + crypto_length = request.length or DEFAULT_PASSWORD_LENGTH + min_each = max(1, crypto_length // CRYPTO_MIN_CHAR_RATIO) + return generator.KeeperPasswordGenerator( + length=crypto_length, + symbols=min_each, + digits=min_each, + caps=min_each, + lower=min_each + ) + elif algorithm == 'recovery': + return CustomDicewareGenerator( + RECOVERY_PHRASE_WORDS, word_list_file=RECOVERY_WORDLIST_FILE, delimiter=' ' + ) + elif algorithm == 'diceware': + dice_rolls = request.dice_rolls or DEFAULT_DICEWARE_ROLLS + return CustomDicewareGenerator( + dice_rolls, + word_list_file=request.word_list_file, + delimiter=request.delimiter + ) + else: + if request.rules and all(i is None for i in (request.symbols, request.digits, request.uppercase, request.lowercase)): + kpg = generator.KeeperPasswordGenerator.create_from_rules(request.rules, request.length) + if kpg is None: + return generator.KeeperPasswordGenerator(length=request.length) + return kpg + else: + return generator.KeeperPasswordGenerator( + length=request.length, + symbols=request.symbols, + digits=request.digits, + caps=request.uppercase, + lower=request.lowercase + ) + + def _analyze_password(self, password: str, enable_breach_scan: bool = True) -> GeneratedPassword: + """Analyze a single password for strength and breaches.""" + strength_score = utils.password_score(password) + strength_level = self._get_strength_level(strength_score) + + breach_status = None + breach_details = None + if enable_breach_scan and self.breach_watch: + try: + scan_results = list(self.breach_watch.scan_passwords([password])) + if scan_results: + _, status = scan_results[0] + + if status and hasattr(status, 'euid') and status.euid: + try: + self.breach_watch.delete_euids([status.euid]) + except Exception: + pass + + if status and hasattr(status, 'breachDetected'): + breach_status = ( + BreachStatus.FAILED if status.breachDetected + else BreachStatus.PASSED + ) + else: + breach_status = BreachStatus.ERROR + breach_details = "Scan result incomplete" + else: + breach_status = BreachStatus.ERROR + breach_details = "No scan results returned" + except Exception as e: + breach_status = BreachStatus.ERROR + breach_details = f"Scan failed: {str(e)}" + else: + breach_status = BreachStatus.SKIPPED + + return GeneratedPassword( + password=password, + strength_score=strength_score, + strength_level=strength_level, + breach_status=breach_status, + breach_details=breach_details + ) + + + @staticmethod + def _get_strength_level(score: int) -> PasswordStrength: + """Convert numeric score to strength level.""" + if score < STRENGTH_WEAK_THRESHOLD: + return PasswordStrength.WEAK + elif score < STRENGTH_FAIR_THRESHOLD: + return PasswordStrength.FAIR + elif score < STRENGTH_GOOD_THRESHOLD: + return PasswordStrength.GOOD + else: + return PasswordStrength.STRONG diff --git a/keepercli-package/src/keepercli/register_commands.py b/keepercli-package/src/keepercli/register_commands.py index a98fc542..0c8ad236 100644 --- a/keepercli-package/src/keepercli/register_commands.py +++ b/keepercli-package/src/keepercli/register_commands.py @@ -28,8 +28,8 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS if not scopes or bool(scopes & base.CommandScope.Vault): from .commands import (vault_folder, vault, vault_record, record_edit, importer_commands, breachwatch, record_type, secrets_manager, share_management, password_report, trash, record_file_report, - record_handling_commands, register) - + record_handling_commands, register, password_generate) + commands.register_command('sync-down', vault.SyncDownCommand(), base.CommandScope.Vault, 'd') commands.register_command('cd', vault_folder.FolderCdCommand(), base.CommandScope.Vault) commands.register_command('ls', vault_folder.FolderListCommand(), base.CommandScope.Vault) @@ -59,6 +59,7 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS commands.register_command('file-report', record_file_report.RecordFileReportCommand(), base.CommandScope.Vault) commands.register_command('import', importer_commands.ImportCommand(), base.CommandScope.Vault) commands.register_command('export', importer_commands.ExportCommand(), base.CommandScope.Vault) + commands.register_command('generate', password_generate.PasswordGenerateCommand(), base.CommandScope.Vault, 'gen') commands.register_command('breachwatch', breachwatch.BreachWatchCommand(), base.CommandScope.Vault, 'bw') commands.register_command('password-report', password_report.PasswordReportCommand(), base.CommandScope.Vault) commands.register_command('record-type-add', record_type.RecordTypeAddCommand(), base.CommandScope.Vault) diff --git a/keepersdk-package/README.md b/keepersdk-package/README.md index a79e48a0..fbec39de 100644 --- a/keepersdk-package/README.md +++ b/keepersdk-package/README.md @@ -245,140 +245,3 @@ if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): - Consider using environment variables or secure vaults for credential management --- - -## Keeper CLI - -### About Keeper CLI - -Keeper CLI is a powerful command-line interface that provides direct access to Keeper Vault and Enterprise Console features. It enables users to: - -- Manage vault records, folders, and attachments from the terminal -- Perform enterprise administration tasks (user management, team operations, role assignments) -- Execute batch operations and automation scripts -- Generate audit reports and monitor security events -- Configure Secrets Manager applications -- Import and export vault data - -Keeper CLI is ideal for system administrators, DevOps engineers, and power users who prefer terminal-based workflows. - -### CLI Installation - -#### From Source - -```bash -# Clone the repository -git clone https://github.com/Keeper-Security/keeper-sdk-python -cd keeper-sdk-python/keepercli-package - -# Install dependencies -pip install -e . -``` - -### CLI Environment Setup - -**Complete Setup from Source:** - -**Step 1: Create and Activate Virtual Environment** - -```bash -# Create virtual environment -python3 -m venv venv - -# Activate virtual environment -# On macOS/Linux: -source venv/bin/activate -# On Windows: -venv\Scripts\activate -``` - -**Step 2: Install Keeper SDK (Required Dependency)** - -```bash -cd keepersdk-package -pip install -e . -``` - -**Step 3: Install Keeper CLI** - -```bash -cd ../keepercli-package -pip install -e . -``` - -### CLI Usage - -Once installed, launch Keeper CLI: - -```bash -# Run Keeper CLI -python -m keepercli -``` - -**Common CLI Commands:** - -```bash -# Login to your Keeper account -Not Logged In> login - -# List all vault records -My Vault> list - -# Search for a specific record -My Vault> search - -# Display record details -My Vault> get - -# Add a new record -My Vault> add-record - -# Sync vault with server -My Vault> sync-down - -# Enterprise user management -My Vault> enterprise-user list -My Vault> enterprise-user add -My Vault> enterprise-user edit - -# Team management -My Vault> enterprise-team list -My Vault> enterprise-team add - -# Generate audit report -My Vault> audit-report - -# Exit CLI -My Vault> quit -``` - -**Interactive Mode:** - -Keeper CLI provides an interactive shell with command history, tab completion, and contextual help: - -```bash -My Vault> help # Display all available commands -My Vault> help # Get help for a specific command -My Vault> my-command --help # Display command-specific options -``` - ---- - -## Contributing - -We welcome contributions from the community! Please feel free to submit pull requests, report issues, or suggest enhancements through our [GitHub repository](https://github.com/Keeper-Security/keeper-sdk-python). - ---- - -## License - -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. - ---- - -## Support - -For support, documentation, and additional resources: - -- **Documentation**: [Keeper Security Developer Portal](https://docs.keeper.io/) -- **Support**: [Keeper Security Support](https://www.keepersecurity.com/support.html) -- **Community**: [Keeper Security GitHub](https://github.com/Keeper-Security) \ No newline at end of file From 43c5a105b3e46ac40b9a8f1381a9a483ffcb7057 Mon Sep 17 00:00:00 2001 From: Alexis <42094254+m-k8s@users.noreply.github.com> Date: Thu, 13 Nov 2025 21:41:16 +0100 Subject: [PATCH 02/37] Fix asyncio event loop cleanup with SSO auth (#92) * Fix asyncio event loop cleanup with SSO auth Previously, when applications using SSO authentication exited, the asyncio event loop was closed while background tasks were still running, resulting in: - ERROR: Task was destroyed but it is pending - RuntimeError: Event loop is closed This issue occurred specifically with SSO authentication flows that use push notifications, but not with password-only authentication. This fix ensures proper cleanup by: 1. Cancelling all pending tasks before stopping the event loop 2. Giving tasks time (0.3s) to handle CancelledError gracefully 3. Waiting for the event loop thread to finish before closing This prevents "Task was destroyed but it is pending" errors when shutting down applications that use SSO with push notifications. * Revert "Fix asyncio event loop cleanup with SSO auth" This reverts commit 8364e1c4d53dccadbf0b613efe352136c62333b9. * Close login websocket after authentication During SSO authentication flows, a push notification websocket (LoginPushNotifications) is created to handle 2FA, device approval, and SSO data key requests. This websocket was never closed after successful login, causing it to remain active until application shutdown. This resulted in asyncio errors about pending tasks being destroyed. Fix: Close login.push_notifications in _on_logged_in() immediately after authentication completes and before any post-login setup. --- keepersdk-package/src/keepersdk/authentication/login_auth.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/keepersdk-package/src/keepersdk/authentication/login_auth.py b/keepersdk-package/src/keepersdk/authentication/login_auth.py index 8578cbc8..c31c3ba4 100644 --- a/keepersdk-package/src/keepersdk/authentication/login_auth.py +++ b/keepersdk-package/src/keepersdk/authentication/login_auth.py @@ -701,6 +701,11 @@ def _on_logged_in(login: LoginAuth, response: APIRequest_pb2.LoginResponse, auth_context.device_private_key = login.context.device_private_key auth_context.message_session_uid = login.context.message_session_uid + # Close login-time push notifications + if login.push_notifications: + login.push_notifications.shutdown() + login.push_notifications = None + keeper_endpoint = login.keeper_endpoint logged_auth = keeper_auth.KeeperAuth(keeper_endpoint, auth_context) logged_auth.post_login() From 1bede62bbe653ea07c0ad41381d8233fb329184e Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Fri, 14 Nov 2025 09:47:10 -0800 Subject: [PATCH 03/37] push notification cleanup --- keepercli-package/src/keepercli/cli.py | 4 ++- .../keepersdk/authentication/notifications.py | 35 +++++++++++-------- keepersdk-package/src/keepersdk/background.py | 4 +++ 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/keepercli-package/src/keepercli/cli.py b/keepercli-package/src/keepercli/cli.py index 9cc9a4dd..c3e8f79c 100644 --- a/keepercli-package/src/keepercli/cli.py +++ b/keepercli-package/src/keepercli/cli.py @@ -48,7 +48,7 @@ def do_command(command_line: str, context: KeeperParams, commands: base.CliComma return None -def loop(keeper_config: KeeperConfig, commands: base.CliCommands): +def loop(keeper_config: KeeperConfig, commands: base.CliCommands) -> int: prompt_session: Optional[PromptSession] = None command_queue: List[str] = [] context_stack: List[KeeperParams] = [] @@ -166,6 +166,8 @@ def get_prompt() -> str: if keeper_config.batch_mode and error_no != 0 and not suppress_errno: break + context.clear_session() + return 0 def display_command_help(commands: base.CliCommands): alias_lookup = {x[1]: x[0] for x in commands.aliases.items()} diff --git a/keepersdk-package/src/keepersdk/authentication/notifications.py b/keepersdk-package/src/keepersdk/authentication/notifications.py index 2d941e6d..7e3f03ea 100644 --- a/keepersdk-package/src/keepersdk/authentication/notifications.py +++ b/keepersdk-package/src/keepersdk/authentication/notifications.py @@ -85,6 +85,7 @@ class BasePushNotifications(abc.ABC, FanOut[Dict[str, Any]]): def __init__(self) -> None: super().__init__() self._ws_app: Optional[websockets.ClientConnection] = None + self._running_task: Optional[asyncio.Future] = None self.use_pushes = False @abc.abstractmethod @@ -101,22 +102,17 @@ def get_connection_parameters(self) -> Optional[PushConnectionParameters]: async def main_loop(self) -> None: logger = utils.get_logger() - try: - await self.close_ws() - except Exception as e: - logger.debug('Push notification close error: %s', e) ssl_context: Optional[ssl.SSLContext] = None - while self.use_pushes: - push_parameters = self.get_connection_parameters() + push_parameters: Optional[PushConnectionParameters] = self.get_connection_parameters() if push_parameters is None: break if not push_parameters.url: break - url: str = push_parameters.url - headers: Dict[str, str] = push_parameters.headers or {} + url = push_parameters.url + headers = push_parameters.headers or {} if url.startswith('wss://'): ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) @@ -135,6 +131,7 @@ async def main_loop(self) -> None: logger.debug('Push notification: decrypt error: ', e) except Exception as e: logger.debug('Push notification: exception: %s', e) + self.use_pushes = False logger.debug('Push notification: exit.') if self._ws_app == ws_app: @@ -145,16 +142,26 @@ async def send_message(self, message: Union[str, bytes]): await self._ws_app.send(message) async def close_ws(self): - ws_app = self._ws_app - if ws_app and ws_app.state == websockets.protocol.State.OPEN: - try: + try: + ws_app = self._ws_app + if ws_app and ws_app.state == websockets.protocol.State.OPEN: await ws_app.close(websockets.frames.CloseCode.GOING_AWAY) - except Exception: - pass + running_task = self._running_task + if running_task and not running_task.done(): + running_task.cancel() + await running_task + except Exception: + pass + finally: + self._running_task = None def connect_to_push_channel(self) -> None: + running_task = self._running_task + if running_task and not running_task.done(): + raise Exception("Already running") + self.use_pushes = True - asyncio.run_coroutine_threadsafe(self.main_loop(), background.get_loop()) + self._running_task = asyncio.run_coroutine_threadsafe(self.main_loop(), background.get_loop()) def shutdown(self): self.use_pushes = False diff --git a/keepersdk-package/src/keepersdk/background.py b/keepersdk-package/src/keepersdk/background.py index cdd0bb81..95dcdaf3 100644 --- a/keepersdk-package/src/keepersdk/background.py +++ b/keepersdk-package/src/keepersdk/background.py @@ -2,6 +2,7 @@ import atexit import threading import time +import warnings from typing import Optional @@ -26,6 +27,9 @@ def init() -> None: async def _stop_loop(): if _loop and _loop.is_running(): + tasks = asyncio.all_tasks(_loop) + if len(tasks) > 0: + warnings.warn("Keeper asyncio resources are not properly cleaned up.", ResourceWarning) _loop.stop() From b587b882a6bb143ce5cebeeb34c730a1345f9835 Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Fri, 14 Nov 2025 17:44:14 +0530 Subject: [PATCH 04/37] team approve command added --- .../src/keepercli/commands/enterprise_team.py | 362 +++++++++++++++++- .../src/keepercli/register_commands.py | 1 + 2 files changed, 361 insertions(+), 2 deletions(-) diff --git a/keepercli-package/src/keepercli/commands/enterprise_team.py b/keepercli-package/src/keepercli/commands/enterprise_team.py index e9745db9..5d04fec7 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_team.py +++ b/keepercli-package/src/keepercli/commands/enterprise_team.py @@ -1,8 +1,8 @@ import argparse import json -from typing import Dict, List, Optional, Any +from typing import Dict, List, Optional, Any, Tuple -from keepersdk import utils +from keepersdk import utils, crypto from keepersdk.enterprise import enterprise_types, batch_management, enterprise_management from . import base, enterprise_utils from .. import api, prompt_utils @@ -10,6 +10,9 @@ from ..params import KeeperParams +logger = api.get_logger() + + class EnterpriseTeamCommand(base.GroupCommand): def __init__(self): super().__init__('Manage an enterprise team(s)') @@ -397,3 +400,358 @@ def execute(self, context: KeeperParams, **kwargs) -> None: team_uid=queued_team.team_uid, enterprise_user_id=x.enterprise_user_id) for x in users_to_remove]) batch.apply() + + +class TeamApproveCommand(base.ArgparseCommand): + + def __init__(self): + self.parser = argparse.ArgumentParser( + prog='team-approve', parents=[base.report_output_parser], + description='Enable or disable automated team and user approvals' + ) + TeamApproveCommand.add_arguments_to_parser(self.parser) + super().__init__(self.parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser) -> None: + parser.add_argument('--team', dest='team', action='store_true', help='Approve teams only.') + parser.add_argument('--email', dest='user', action='store_true', help='Approve team users only.') + parser.add_argument('--restrict-edit', dest='restrict_edit', choices=['on', 'off'], action='store', + help='disable record edits') + parser.add_argument('--restrict-share', dest='restrict_share', choices=['on', 'off'], action='store', + help='disable record re-shares') + parser.add_argument('--restrict-view', dest='restrict_view', choices=['on', 'off'], action='store', + help='disable view/copy passwords') + parser.add_argument('--dry-run', dest='dry_run', action='store_true', + help='Report on run approval commands only. Do not run.') + + def execute(self, context: KeeperParams, **kwargs) -> None: + self._validate_vault(context) + + approve_teams, approve_users = self._determine_approval_flags(kwargs) + teams = self._build_teams_lookup(context.enterprise_data) + active_users = self._build_active_users_lookup(context.enterprise_data) + + request_batch = [] + added_team_keys = {} + added_teams = {} + + if approve_teams: + team_requests, team_keys, new_teams = self._build_team_approval_requests( + context, kwargs, teams + ) + request_batch.extend(team_requests) + added_team_keys.update(team_keys) + added_teams.update(new_teams) + teams.update(new_teams) + + if approve_users: + user_requests = self._build_user_approval_requests( + context, teams, added_teams, added_team_keys, active_users + ) + request_batch.extend(user_requests) + + if request_batch: + if kwargs.get('dry_run'): + self._generate_dry_run_report(request_batch, teams, active_users, kwargs) + else: + self._execute_batch_and_report(context, request_batch) + + def _determine_approval_flags(self, kwargs: Dict[str, Any]) -> Tuple[bool, bool]: + """Determine which approval types to process based on kwargs.""" + approve_teams = True + approve_users = True + if kwargs.get('team') or kwargs.get('user'): + approve_teams = kwargs.get('team', False) + approve_users = kwargs.get('user', False) + return approve_teams, approve_users + + def _build_teams_lookup(self, enterprise_data) -> Dict[str, Any]: + """Build a dictionary mapping team_uid to team objects.""" + return {team.team_uid: team for team in enterprise_data.teams.get_all_entities()} + + def _build_active_users_lookup(self, enterprise_data) -> Dict[int, str]: + """Build a dictionary mapping user_id to username for active users.""" + return { + x.enterprise_user_id: x.username + for x in enterprise_data.users.get_all_entities() + if x.status == 'active' and x.lock == 0 + } + + def _build_team_approval_requests( + self, context: KeeperParams, kwargs: Dict[str, Any], teams: Dict[str, Any] + ) -> Tuple[List[Dict[str, Any]], Dict[str, bytes], Dict[str, Any]]: + """Build approval requests for queued teams.""" + request_batch = [] + added_team_keys = {} + added_teams = {} + enterprise_data = context.enterprise_data + + queued_teams = enterprise_data.queued_teams.get_all_entities() + if not queued_teams: + return request_batch, added_team_keys, added_teams + + tree_key = enterprise_data.enterprise_info.tree_key + data_key = context.auth.auth_context.data_key + forbid_rsa = context.auth.auth_context.forbid_rsa + + for queued_team in queued_teams: + team_uid = queued_team.team_uid + team_key = utils.generate_aes_key() + added_team_keys[team_uid] = team_key + added_teams[team_uid] = queued_team + + request = self._create_team_add_request( + queued_team, team_key, tree_key, data_key, forbid_rsa, kwargs + ) + request_batch.append(request) + + return request_batch, added_team_keys, added_teams + + def _create_team_add_request( + self, queued_team, team_key: bytes, tree_key: bytes, + data_key: bytes, forbid_rsa: bool, kwargs: Dict[str, Any] + ) -> Dict[str, Any]: + """Create a single team_add request with all required encryption.""" + encrypted_team_key = crypto.encrypt_aes_v1(team_key, data_key) + + request = { + 'command': 'team_add', + 'team_uid': queued_team.team_uid, + 'team_name': queued_team.name, + 'restrict_edit': kwargs.get('restrict_edit') == 'on', + 'restrict_share': kwargs.get('restrict_share') == 'on', + 'restrict_view': kwargs.get('restrict_view') == 'on', + 'node_id': queued_team.node_id, + 'team_key': utils.base64_url_encode(encrypted_team_key), + 'encrypted_team_key': utils.base64_url_encode(crypto.encrypt_aes_v2(team_key, tree_key)), + 'manage_only': True + } + + ec_private_key, ec_public_key = crypto.generate_ec_key() + encrypted_ec_private_key = crypto.encrypt_aes_v2( + crypto.unload_ec_private_key(ec_private_key), team_key + ) + request['ecc_private_key'] = utils.base64_url_encode(encrypted_ec_private_key) + request['ecc_public_key'] = utils.base64_url_encode(crypto.unload_ec_public_key(ec_public_key)) + + if not forbid_rsa: + rsa_pri_key, rsa_pub_key = crypto.generate_rsa_key() + encrypted_rsa_private_key = crypto.encrypt_aes_v1( + crypto.unload_rsa_private_key(rsa_pri_key), team_key + ) + request['private_key'] = utils.base64_url_encode(encrypted_rsa_private_key) + request['public_key'] = utils.base64_url_encode(crypto.unload_rsa_public_key(rsa_pub_key)) + + return request + + def _build_user_approval_requests( + self, context: KeeperParams, teams: Dict[str, Any], + added_teams: Dict[str, Any], added_team_keys: Dict[str, bytes], + active_users: Dict[int, str] + ) -> List[Dict[str, Any]]: + """Build approval requests for queued team users.""" + enterprise_data = context.enterprise_data + vault = context.vault + + queued_team_users = enterprise_data.queued_team_users.get_all_links() + if not queued_team_users or not enterprise_data.teams.get_all_entities() or not enterprise_data.users.get_all_entities(): + return [] + + team_keys, all_users = self._collect_team_keys_and_users( + queued_team_users, teams, added_teams, active_users + ) + + if not team_keys or not all_users: + return [] + + self._load_team_and_user_keys(vault, team_keys, added_team_keys, all_users) + + return self._create_user_add_requests( + context, queued_team_users, team_keys, active_users + ) + + def _collect_team_keys_and_users( + self, queued_team_users, teams: Dict[str, Any], + added_teams: Dict[str, Any], active_users: Dict[int, str] + ) -> Tuple[Dict[str, Any], set]: + """Collect team UIDs that need keys loaded and all user emails.""" + team_keys = {} + all_users = set() + + for qtu in queued_team_users: + team_uid = qtu.team_uid + if team_uid not in teams and team_uid not in added_teams: + continue + + email = active_users.get(qtu.enterprise_user_id) + if email: + email = email.lower() + if team_uid in teams and team_uid not in team_keys: + team_keys[team_uid] = None + if email not in all_users: + all_users.add(email) + + return team_keys, all_users + + def _load_team_and_user_keys( + self, vault, team_keys: Dict[str, Any], + added_team_keys: Dict[str, bytes], all_users: set + ) -> None: + """Load team keys and user public keys from the vault.""" + vault.keeper_auth.load_team_keys(list(team_keys.keys())) + + for team_uid in team_keys.keys(): + team_key = vault.keeper_auth.get_team_keys(team_uid) + if team_key and team_key.aes: + team_keys[team_uid] = team_key.aes + + team_keys.update(added_team_keys) + vault.keeper_auth.load_user_public_keys(list(all_users), False) + + def _create_user_add_requests( + self, context: KeeperParams, queued_team_users, + team_keys: Dict[str, bytes], active_users: Dict[int, str] + ) -> List[Dict[str, Any]]: + """Create user add requests for queued team users.""" + request_batch = [] + forbid_rsa = context.auth.auth_context.forbid_rsa + vault = context.vault + + for qtu in queued_team_users: + team_uid = qtu.team_uid + team_key = team_keys.get(team_uid) + if not team_key: + continue + + for u_id in qtu.get('users') or []: + username = active_users.get(u_id) + if not username: + continue + + keys = vault.keeper_auth.get_user_keys(username.lower()) + if not keys: + continue + + request = self._create_single_user_add_request( + team_uid, u_id, team_key, keys, username, forbid_rsa + ) + if request: + request_batch.append(request) + + return request_batch + + def _create_single_user_add_request( + self, team_uid: str, user_id: int, team_key: bytes, + keys, username: str, forbid_rsa: bool + ) -> Optional[Dict[str, Any]]: + """Create a single user add request with appropriate encryption.""" + request = { + 'command': 'team_enterprise_user_add', + 'team_uid': team_uid, + 'enterprise_user_id': user_id, + 'user_type': 0, + } + + try: + if forbid_rsa: + if not keys.ec: + logger.warning('User %s does not have EC key. Skipping', username) + return None + ec_key = crypto.load_ec_public_key(keys.ec) + encrypted_team_key = crypto.encrypt_ec(team_key, ec_key) + request['team_key'] = utils.base64_url_encode(encrypted_team_key) + request['team_key_type'] = 'encrypted_by_public_key_ecc' + else: + if not keys.rsa: + logger.warning('User %s does not have RSA key. Skipping', username) + return None + rsa_key = crypto.load_rsa_public_key(keys.rsa) + encrypted_team_key = crypto.encrypt_rsa(team_key, rsa_key) + request['team_key'] = utils.base64_url_encode(encrypted_team_key) + request['team_key_type'] = 'encrypted_by_public_key' + + return request + except Exception as e: + logger.warning('Cannot approve user "%s" to team "%s": %s', username, team_uid, e) + return None + + def _execute_batch_and_report(self, context: KeeperParams, request_batch: List[Dict[str, Any]]) -> None: + """Execute the batch request and report results.""" + vault = context.vault + rs = vault.keeper_auth.execute_batch(request_batch) + + if rs: + stats = self._calculate_batch_stats(rs) + self._log_batch_results(stats) + + context.enterprise_loader.load(reset=True) + + def _calculate_batch_stats(self, results: List[Dict[str, Any]]) -> Dict[str, int]: + """Calculate success/failure statistics from batch results.""" + stats = { + 'team_add_success': 0, + 'team_add_failure': 0, + 'user_add_success': 0, + 'user_add_failure': 0 + } + + for status in results: + is_team = status['command'] == 'team_add' + if 'result' in status: + if status['result'] == 'success': + if is_team: + stats['team_add_success'] += 1 + else: + stats['user_add_success'] += 1 + else: + if is_team: + stats['team_add_failure'] += 1 + else: + stats['user_add_failure'] += 1 + + return stats + + def _log_batch_results(self, stats: Dict[str, int]) -> None: + """Log batch execution results.""" + if stats['team_add_success'] or stats['team_add_failure']: + logger.info( + 'Team approval: success %s; failure %s', + stats['team_add_success'], stats['team_add_failure'] + ) + if stats['user_add_success'] or stats['user_add_failure']: + logger.info( + 'Team User approval: success %s; failure %s', + stats['user_add_success'], stats['user_add_failure'] + ) + + def _generate_dry_run_report( + self, request_batch: List[Dict[str, Any]], + teams: Dict[str, Any], active_users: Dict[int, str], kwargs: Dict[str, Any] + ) -> None: + """Generate and display dry-run report.""" + table = [] + for rq in request_batch: + team_uid = rq['team_uid'] + team_name = team_uid + if team_uid in teams: + team_name = teams[team_uid].name + + username = '' + action = 'Approve Team' + if rq['command'] == 'team_enterprise_user_add': + action = 'Approve User' + user_id = rq['enterprise_user_id'] + username = active_users.get(user_id, user_id) + + table.append([action, team_name, username]) + + headers = ['Action', 'Team', 'User'] + report_utils.dump_report_data( + table, headers, fmt=kwargs.get('format'), filename=kwargs.get('output') + ) + + def _validate_vault(self, context: KeeperParams): + """Validate that vault is initialized.""" + if not context.vault: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') \ No newline at end of file diff --git a/keepercli-package/src/keepercli/register_commands.py b/keepercli-package/src/keepercli/register_commands.py index 0c8ad236..66b72cb7 100644 --- a/keepercli-package/src/keepercli/register_commands.py +++ b/keepercli-package/src/keepercli/register_commands.py @@ -100,3 +100,4 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS commands.register_command('device-approve', enterprise_user.EnterpriseDeviceApprovalCommand(), base.CommandScope.Enterprise) commands.register_command('pedm', pedm_admin.PedmCommand(), base.CommandScope.Enterprise) commands.register_command('switch-to-mc', msp.SwitchToManagedCompanyCommand(), base.CommandScope.Enterprise) + commands.register_command('team-approve', enterprise_team.TeamApproveCommand(), base.CommandScope.Enterprise) From 288393830607d1a48c320f2974bd18f85d5c36a7 Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Fri, 14 Nov 2025 23:26:30 +0530 Subject: [PATCH 05/37] reset password command added --- .../keepercli/commands/account_commands.py | 311 +++++++++++++++++- .../src/keepercli/register_commands.py | 1 + 2 files changed, 309 insertions(+), 3 deletions(-) diff --git a/keepercli-package/src/keepercli/commands/account_commands.py b/keepercli-package/src/keepercli/commands/account_commands.py index 1af31029..a4fd919d 100644 --- a/keepercli-package/src/keepercli/commands/account_commands.py +++ b/keepercli-package/src/keepercli/commands/account_commands.py @@ -1,13 +1,38 @@ import argparse import datetime +import getpass +import os +import re from typing import Tuple, Optional, List, Any -from keepersdk.authentication import keeper_auth -from keepersdk.proto import AccountSummary_pb2 +from keepersdk import crypto, utils, errors +from keepersdk.authentication import keeper_auth +from keepersdk.proto import AccountSummary_pb2, APIRequest_pb2, enterprise_pb2 +from keepersdk.vault import vault_online from . import base -from .. import params, login, api +from .. import params, login, api, constants from ..helpers import parse_utils, timeout_utils, report_utils +# Reset Password Command Constants +RESET_PASSWORD_NOT_LOGGED_IN = 'Not logged in' +RESET_PASSWORD_SUCCESS = 'Master Password has been changed successfully' +RESET_PASSWORD_CANCELLED = 'Password change was cancelled or failed' +RESET_PASSWORD_ERROR = 'Error changing password: {}' +RESET_PASSWORD_CHANGING = 'Changing Master Password for "{}"' +RESET_PASSWORD_CURRENT_INCORRECT = 'Current password incorrect' +RESET_PASSWORD_BREACHWATCH_SCAN = 'Breachwatch: 1 passwords to scan' +RESET_PASSWORD_BREACHWATCH_RESULT = 'Breachwatch password scan result: {}' +RESET_PASSWORD_SSO_DELETING = 'Deleting SSO Master Password for "{}"' +RESET_PASSWORD_SSO_DELETED = 'SSO Master Password has been deleted' +RESET_PASSWORD_SSO_NOT_FOUND = 'SSO Master password is not found' +RESET_PASSWORD_NOT_SSO_ACCOUNT = '"{}" is not SSO account' +RESET_PASSWORD_NO_PRIVILEGE = 'You do not have the required privilege to perform this operation.' +RESET_PASSWORD_SSO_CHANGING = '{} SSO Master Password for "{}"' +RESET_PASSWORD_SSO_CHANGED = 'SSO Master Password has been {}' + + +logger = api.get_logger() + class LoginCommand(base.ArgparseCommand): login_parser = argparse.ArgumentParser(prog='login', description='Login to Keeper') @@ -269,3 +294,283 @@ def execute(self, context: params.KeeperParams, **kwargs): report_utils.dump_report_data(table, ('key', 'value'), no_header=True, right_align=(0,)) else: logger.warning('Not logged in') + + +class ResetPasswordCommand(base.ArgparseCommand): + """Command for resetting master password using LoginAPI.""" + + def __init__(self): + parser = argparse.ArgumentParser( + prog='reset-password', + description='Reset master password for Keeper account' + ) + parser.add_argument( + '--delete-sso', + dest='delete_sso', + action='store_true', + help='deletes SSO master password' + ) + parser.add_argument( + '--current', '-c', + dest='current_password', + metavar='CURRENT_PASSWORD', + help='current password' + ) + parser.add_argument( + '--new', '-n', + dest='new_password', + metavar='NEW_PASSWORD', + help='new password' + ) + super().__init__(parser) + + def execute(self, context: params.KeeperParams, **kwargs): + """Execute the password reset command.""" + + if not self._validate_authentication(context): + return + + auth = context.auth + is_sso_user = self._check_sso_user(auth) + if is_sso_user and not self._check_sso_privileges(auth): + logger.warning(RESET_PASSWORD_NO_PRIVILEGE) + return + + current_salt = self._get_current_salt(auth, is_sso_user) + + if self._handle_sso_deletion(kwargs, auth, is_sso_user, current_salt): + return + + try: + current_password = kwargs.get('current_password') + new_password = kwargs.get('new_password') + + self._display_changing_message(auth, is_sso_user, current_salt) + + if current_password and new_password: + self._change_password_non_interactive(context, current_password, new_password, is_sso_user, current_salt) + else: + self._change_password_interactive(context) + + except Exception as e: + logger.error(RESET_PASSWORD_ERROR.format(str(e))) + + def _validate_authentication(self, context: params.KeeperParams) -> bool: + """Validate that user is authenticated.""" + if not context.auth: + logger.warning(RESET_PASSWORD_NOT_LOGGED_IN) + return False + return True + + def _check_sso_user(self, auth: keeper_auth.KeeperAuth) -> bool: + """Check if user is an SSO user.""" + try: + acct_summary = keeper_auth.load_account_summary(auth) + return acct_summary.settings.ssoUser + except Exception: + return False + + def _check_sso_privileges(self, auth: keeper_auth.KeeperAuth) -> bool: + """Check if SSO user has privilege to change alternate passwords.""" + try: + acct_summary = keeper_auth.load_account_summary(auth) + if hasattr(acct_summary, 'Enforcements') and hasattr(acct_summary.Enforcements, 'booleans'): + return next((x.value for x in acct_summary.Enforcements.booleans + if x.key == 'allow_alternate_passwords'), False) + return False + except Exception: + return False + + def _get_current_salt(self, auth: keeper_auth.KeeperAuth, is_sso_user: bool): + """Get current salt and iterations.""" + try: + return auth.execute_auth_rest( + 'authentication/get_salt_and_iterations', + None, + response_type=APIRequest_pb2.Salt + ) + except errors.KeeperApiError as kae: + if is_sso_user and kae.result_code == 'doesnt_exist': + return None + else: + raise kae + + def _handle_sso_deletion(self, kwargs: dict, auth: keeper_auth.KeeperAuth, is_sso_user: bool, current_salt) -> bool: + """Handle SSO password deletion request.""" + if not kwargs.get('delete_sso'): + return False + + if is_sso_user: + logger.info(RESET_PASSWORD_SSO_DELETING.format(auth.auth_context.username)) + if current_salt: + uid_rq = APIRequest_pb2.UidRequest() + uid_rq.uid.append(current_salt.uid) + auth.execute_auth_rest('authentication/delete_v2_alternate_password', uid_rq) + logger.info(RESET_PASSWORD_SSO_DELETED) + else: + logger.info(RESET_PASSWORD_SSO_NOT_FOUND) + else: + logger.warning(RESET_PASSWORD_NOT_SSO_ACCOUNT.format(auth.auth_context.username)) + return True + + def _display_changing_message(self, auth: keeper_auth.KeeperAuth, is_sso_user: bool, current_salt): + """Display appropriate changing password message.""" + username = auth.auth_context.username + if is_sso_user: + action = 'Changing' if current_salt else 'Setting' + logger.info(RESET_PASSWORD_SSO_CHANGING.format(action, username)) + else: + logger.info(RESET_PASSWORD_CHANGING.format(username)) + + def _change_password_non_interactive(self, context: params.KeeperParams, current_password: str, new_password: str, is_sso_user: bool, current_salt): + """Change password using provided arguments.""" + auth = context.auth + + if current_salt and not self._validate_current_password_with_salt(current_password, current_salt, auth): + return + elif not current_salt: + current_password = '' + + if not self._validate_password_rules(auth, new_password): + return + + self._perform_breachwatch_scan(context.vault, new_password) + + if is_sso_user: + self._change_sso_password(auth, new_password, current_salt) + else: + self._change_regular_password(auth, new_password, current_salt) + + self._update_context_password(context, new_password) + + def _change_password_interactive(self, context: params.KeeperParams): + """Change password using interactive prompts.""" + new_password = login.LoginAPI.change_master_password(context.auth) + if new_password: + logger.info(RESET_PASSWORD_SUCCESS) + self._update_context_password(context, new_password) + else: + logger.warning(RESET_PASSWORD_CANCELLED) + + def _update_context_password(self, context: params.KeeperParams, new_password: str): + """Update context with new password.""" + context.password = new_password + + def _validate_current_password_with_salt(self, current_password: str, current_salt, auth: keeper_auth.KeeperAuth) -> bool: + """Validate the current password using provided salt.""" + try: + auth_hash = crypto.derive_keyhash_v1(current_password, current_salt.salt, current_salt.iterations) + + rq = APIRequest_pb2.MasterPasswordReentryRequest() + rq.pbkdf2Password = utils.base64_url_encode(auth_hash) + rq.action = APIRequest_pb2.UNMASK + + rs = auth.execute_auth_rest( + 'authentication/validate_master_password', + rq, + response_type=APIRequest_pb2.MasterPasswordReentryResponse, + payload_version=1 + ) + + if rs.status != APIRequest_pb2.MP_SUCCESS: + logger.warning(RESET_PASSWORD_CURRENT_INCORRECT) + return False + + return True + + except Exception: + logger.warning(RESET_PASSWORD_CURRENT_INCORRECT) + return False + + def _validate_password_rules(self, auth: keeper_auth.KeeperAuth, new_password: str) -> bool: + """Validate new password against domain rules.""" + try: + rules_rq = enterprise_pb2.DomainPasswordRulesRequest() + rules_rq.username = auth.auth_context.username + + rules_rs = auth.execute_auth_rest( + 'authentication/get_domain_password_rules', + rules_rq, + response_type=APIRequest_pb2.NewUserMinimumParams + ) + + failed_rules = [] + for i in range(len(rules_rs.passwordMatchRegex)): + rule = rules_rs.passwordMatchRegex[i] + is_match = re.match(rule, new_password) + if not is_match: + failed_rules.append(rules_rs.passwordMatchDescription[i]) + + if failed_rules: + logger.warning('Password rules:\n%s', '\n'.join((f' {x}' for x in failed_rules))) + return False + + return True + + except Exception: + return True + + def _change_sso_password(self, auth: keeper_auth.KeeperAuth, new_password: str, current_salt): + """Change SSO user alternate password.""" + iterations = current_salt.iterations if current_salt else constants.PBKDF2_ITERATIONS + iterations = max(iterations, constants.PBKDF2_ITERATIONS) + + auth_salt = os.urandom(16) + + ap_rq = APIRequest_pb2.UserAuthRequest() + ap_rq.uid = current_salt.uid if current_salt else os.urandom(16) + ap_rq.salt = auth_salt + ap_rq.iterations = iterations + ap_rq.authHash = crypto.derive_keyhash_v1(new_password, auth_salt, iterations) + + key = crypto.derive_keyhash_v2('data_key', new_password, auth_salt, iterations) + ap_rq.encryptedDataKey = crypto.encrypt_aes_v2(auth.auth_context.data_key, key) + ap_rq.encryptedClientKey = crypto.encrypt_aes_v2(auth.auth_context.client_key, key) + + ap_rq.loginType = APIRequest_pb2.ALTERNATE + ap_rq.name = current_salt.name if current_salt else 'alternate' + + auth.execute_auth_rest('authentication/set_v2_alternate_password', ap_rq) + action = "changed" if current_salt else "set" + logger.info(RESET_PASSWORD_SSO_CHANGED.format(action)) + + def _change_regular_password(self, auth: keeper_auth.KeeperAuth, new_password: str, current_salt): + """Change regular user master password.""" + iterations = current_salt.iterations if current_salt else constants.PBKDF2_ITERATIONS + iterations = max(iterations, constants.PBKDF2_ITERATIONS) + + auth_salt = os.urandom(16) + auth_verifier = utils.create_auth_verifier(new_password, auth_salt, iterations) + + data_salt = os.urandom(16) + encryption_params = utils.create_encryption_params( + new_password, data_salt, iterations, auth.auth_context.data_key + ) + + mp_rq = { + 'command': 'change_master_password', + 'auth_verifier': utils.base64_url_encode(auth_verifier), + 'encryption_params': utils.base64_url_encode(encryption_params) + } + + auth.execute_auth_command(mp_rq) + logger.info(RESET_PASSWORD_SUCCESS) + + def _perform_breachwatch_scan(self, vault: vault_online.VaultOnline, password: str): + """Perform BreachWatch scan on the new password.""" + try: + if vault and vault.breach_watch_plugin(): + logger.info(RESET_PASSWORD_BREACHWATCH_SCAN) + + breach_watch = vault.breach_watch_plugin().breach_watch + scan_results = breach_watch.scan_passwords([password]) + + if scan_results: + for result in scan_results: + status = 'WEAK' if result[1].breachDetected else 'GOOD' + logger.info(RESET_PASSWORD_BREACHWATCH_RESULT.format(status)) + + if result[1].euid: + breach_watch.delete_euids([result[1].euid]) + except Exception: + pass diff --git a/keepercli-package/src/keepercli/register_commands.py b/keepercli-package/src/keepercli/register_commands.py index 66b72cb7..a4178251 100644 --- a/keepercli-package/src/keepercli/register_commands.py +++ b/keepercli-package/src/keepercli/register_commands.py @@ -23,6 +23,7 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS commands.register_command('logout', account_commands.LogoutCommand(), base.CommandScope.Account) commands.register_command('this-device', account_commands.ThisDeviceCommand(), base.CommandScope.Account) commands.register_command('whoami', account_commands.WhoamiCommand(), base.CommandScope.Account) + commands.register_command('reset-password', account_commands.ResetPasswordCommand(), base.CommandScope.Account) if not scopes or bool(scopes & base.CommandScope.Vault): From af5e6ac04bfdb848313f44958758d2752f6f4880 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Wed, 19 Nov 2025 19:05:40 +0530 Subject: [PATCH 06/37] verify-records and verify-shared-folder commands added --- .../src/keepercli/commands/verify_records.py | 703 ++++++++++++++++++ .../src/keepercli/register_commands.py | 4 +- 2 files changed, 706 insertions(+), 1 deletion(-) create mode 100644 keepercli-package/src/keepercli/commands/verify_records.py diff --git a/keepercli-package/src/keepercli/commands/verify_records.py b/keepercli-package/src/keepercli/commands/verify_records.py new file mode 100644 index 00000000..cd98f1f0 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/verify_records.py @@ -0,0 +1,703 @@ +import argparse +import itertools +import json +from typing import Tuple, List, Optional, Set, Dict + +from .base import ArgparseCommand, CommandError +from ..params import KeeperParams +from ..prompt_utils import user_choice +from ..helpers.report_utils import dump_report_data +from ..helpers.record_utils import get_totp_code +from .. import api +from keepersdk import crypto, utils +from keepersdk.proto import record_pb2, folder_pb2 +from keepersdk.vault import vault_record, vault_online + + +logger = api.get_logger() + + +# Constants +MAX_DISPLAY_RECORDS = 99 +MAX_RECORDS_CHUNK = 999 +MAX_SF_CHUNK = 990 +MIN_BATCH_THRESHOLD = 10 +V3_RECORD_KEY_LENGTH = 60 + +class VerifySharedFoldersCommand(ArgparseCommand): + def __init__(self): + self.parser = argparse.ArgumentParser(prog='verify-shared-folders', + description='Verify and fix shared folder record key issues') + VerifySharedFoldersCommand.add_arguments_to_parser(self.parser) + super().__init__(self.parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser): + parser.add_argument('--dry-run', dest='dry_run', action='store_true', + help='Display the found problems without fixing') + parser.add_argument('target', nargs='*', help='Shared folder UID or name.') + + def execute(self, context: KeeperParams, **kwargs): + self._validate_context(context) + vault = context.vault + shared_folders = self._resolve_target_folders(vault, kwargs.get('target')) + sf_v3_keys, sf_v2_keys = self._find_problematic_keys(vault, shared_folders) + + if not sf_v3_keys and not sf_v2_keys: + if kwargs.get('dry_run'): + logger.info('There are no record keys to be corrected') + return + + self._display_problems(vault, sf_v3_keys, sf_v2_keys) + + if not kwargs.get('dry_run') and self._get_user_confirmation(): + self._fix_record_keys(vault, sf_v3_keys, sf_v2_keys) + vault.sync_down() + + def _validate_context(self, context: KeeperParams) -> None: + if not context.vault or not context.auth: + raise CommandError("Vault is not initialized, authentication is required") + + def _resolve_target_folders(self, vault: vault_online.VaultOnline, target) -> Set[str]: + shared_folders = set() + all_shared_folders = {sf.shared_folder_uid: sf for sf in vault.vault_data.shared_folders()} + + if isinstance(target, list) and len(target) > 0: + sf_names = {sf.name.lower(): sf.shared_folder_uid for sf in all_shared_folders.values()} + for name in target: + if name in all_shared_folders: + shared_folders.add(name) + else: + sf_name = name.lower() + if sf_name in sf_names: + shared_folders.add(sf_names[sf_name]) + else: + raise CommandError(f'Shared folder "{name}" not found') + else: + shared_folders.update(all_shared_folders.keys()) + + if len(shared_folders) == 0: + raise CommandError('No shared folders found') + + return shared_folders + + def _find_problematic_keys(self, vault: vault_online.VaultOnline, shared_folders) -> Tuple[List[Tuple[str, str]], List[Tuple[str, str]]]: + rq = { + 'command': 'get_shared_folders', + 'shared_folders': [{'shared_folder_uid': x} for x in shared_folders], + 'include': ['sfheaders', 'sfusers', 'sfrecords'] + } + rs = vault.keeper_auth.execute_auth_command(rq) + + sf_v3_keys = [] + sf_v2_keys = [] + + if 'shared_folders' in rs: + for sf in rs['shared_folders']: + shared_folder_uid = sf['shared_folder_uid'] + if 'records' in sf: + for rec in sf['records']: + record_uid = rec['record_uid'] + record_info = vault.vault_data.get_record(record_uid) + if not record_info or 'record_key' not in rec: + continue + + record_key = utils.base64_url_decode(rec['record_key']) + version = record_info.version + + if version == 3 and len(record_key) != V3_RECORD_KEY_LENGTH: + sf_v3_keys.append((record_uid, shared_folder_uid)) + elif version == 2 and len(record_key) == V3_RECORD_KEY_LENGTH: + sf_v2_keys.append((record_uid, shared_folder_uid)) + + return sf_v3_keys, sf_v2_keys + + def _display_problems(self, vault: vault_online.VaultOnline, sf_v3_keys, sf_v2_keys): + if sf_v3_keys: + self._display_record_list(vault, sf_v3_keys, "V3") + if sf_v2_keys: + self._display_record_list(vault, sf_v2_keys, "V2") + + def _display_record_list(self, vault: vault_online.VaultOnline, keys, version): + record_uids = list({x[0] for x in keys}) + plural = "are" if len(record_uids) > 1 else "is" + logger.info(f'There {plural} {len(record_uids)} {version} record key(s) to be corrected') + + try: + for record_uid in record_uids[:MAX_DISPLAY_RECORDS]: + record = vault.vault_data.load_record(record_uid) + logger.info(f' {record_uid} {record.title}') + if len(record_uids) > MAX_DISPLAY_RECORDS: + logger.info(f' {(len(record_uids) - MAX_DISPLAY_RECORDS)} more ...') + except Exception: + pass + + def _get_user_confirmation(self) -> bool: + answer = user_choice('Do you want to proceed?', 'yn', 'n') + return answer.lower() == 'y' + + def _fix_record_keys(self, vault: vault_online.VaultOnline, sf_v3_keys, sf_v2_keys): + if sf_v3_keys: + self._fix_v3_keys(vault, sf_v3_keys) + if sf_v2_keys: + self._fix_v2_keys(vault, sf_v2_keys) + + def _fix_v3_keys(self, vault: vault_online.VaultOnline, sf_v3_keys): + sf_v3_keys.sort(key=lambda x: x[0]) + while sf_v3_keys: + chunk = sf_v3_keys[:MAX_RECORDS_CHUNK] + sf_v3_keys = sf_v3_keys[MAX_RECORDS_CHUNK:] + self._process_v3_chunk(vault, chunk) + + def _process_v3_chunk(self, vault: vault_online.VaultOnline, chunk): + rq = record_pb2.RecordsConvertToV3Request() + record_convert = None + last_record_uid = '' + + for record_uid, shared_folder_uid in chunk: + shared_folder_key = vault.vault_data.get_shared_folder_key(shared_folder_uid) + record_key = vault.vault_data.get_record_key(record_uid) + + if not shared_folder_key or not record_key: + continue + + if last_record_uid != record_uid: + if record_convert: + rq.records.append(record_convert) + record_convert = self._create_v3_record_convert(vault, record_uid) + last_record_uid = record_uid + + self._add_folder_key_to_convert(vault, record_convert, record_uid, shared_folder_uid) + + if record_convert: + rq.records.append(record_convert) + + vault.keeper_auth.execute_auth_rest('vault/records_convert3', rq, + response_type=record_pb2.RecordsModifyResponse) + + def _create_v3_record_convert(self, vault: vault_online.VaultOnline, record_uid): + record_info = vault.vault_data.get_record(record_uid) + if not record_info: + return None + + record_convert = record_pb2.RecordConvertToV3() + record_convert.record_uid = utils.base64_url_decode(record_uid) + record_convert.client_modified_time = utils.current_milli_time() + record_convert.revision = record_info.revision + + self._add_audit_data(vault, record_convert, record_uid) + return record_convert + + def _add_audit_data(self, vault: vault_online.VaultOnline, record_convert, record_uid): + auth_context = vault.keeper_auth.auth_context + if not auth_context.enterprise_ec_public_key: + return + + rec = vault.vault_data.load_record(record_uid) + if not isinstance(rec, vault_record.TypedRecord): + return + + audit_data = { + 'title': rec.title or '', + 'record_type': rec.record_type, + } + + field = rec.get_typed_field('url') + if field: + default_value = field.get_default_value(str) + if default_value: + audit_data['url'] = utils.url_strip(default_value) + + record_convert.audit.data = crypto.encrypt_ec( + json.dumps(audit_data).encode('utf-8'), + auth_context.enterprise_ec_public_key + ) + + def _add_folder_key_to_convert(self, vault: vault_online.VaultOnline, record_convert, record_uid, shared_folder_uid): + fk = record_pb2.RecordFolderForConversion() + fk.folder_uid = utils.base64_url_decode(shared_folder_uid) + + record_key = vault.vault_data.get_record_key(record_uid) + shared_folder_key = vault.vault_data.get_shared_folder_key(shared_folder_uid) + + if record_key and shared_folder_key: + fk.record_folder_key = crypto.encrypt_aes_v2(record_key, shared_folder_key) + record_convert.folder_key.append(fk) + + def _fix_v2_keys(self, vault: vault_online.VaultOnline, sf_v2_keys): + sf_v2_keys.sort(key=lambda x: x[1]) + rqs = {} + results = [] + + for record_uid, shared_folder_uid in sf_v2_keys: + if shared_folder_uid not in rqs: + rqs[shared_folder_uid] = [] + + record_key = vault.vault_data.get_record_key(record_uid) + shared_folder_key = vault.vault_data.get_shared_folder_key(shared_folder_uid) + + if not record_key or not shared_folder_key: + continue + + sfur = folder_pb2.SharedFolderUpdateRecord() + sfur.recordUid = utils.base64_url_decode(record_uid) + sfur.sharedFolderUid = utils.base64_url_decode(shared_folder_uid) + sfur.encryptedRecordKey = crypto.encrypt_aes_v1(record_key, shared_folder_key) + sfur.canEdit = folder_pb2.BOOLEAN_FALSE + sfur.canShare = folder_pb2.BOOLEAN_TRUE + rqs[shared_folder_uid].append(sfur) + + self._process_v2_updates(vault, rqs, results) + + if results: + headers = ['Shared Folder UID', 'Record UID', 'Record Owner', 'Error code'] + dump_report_data(results, headers=headers, title='V2 Record key errors') + + def _process_v2_updates(self, vault: vault_online.VaultOnline, rqs, results): + sfu_rqs = None + left = 0 + + while len(rqs) > 0 or sfu_rqs is not None: + if sfu_rqs is None: + sfu_rqs = folder_pb2.SharedFolderUpdateV3RequestV2() + left = MAX_SF_CHUNK + + shared_folder_uid = next(iter(rqs.keys())) + sfu_records = rqs.pop(shared_folder_uid) + + sfu_rq = folder_pb2.SharedFolderUpdateV3Request() + sfu_rq.sharedFolderUid = utils.base64_url_decode(shared_folder_uid) + sfu_rq.forceUpdate = True + + if len(sfu_records) < left: + sfu_rq.sharedFolderAddRecord.extend(sfu_records) + left -= len(sfu_records) + if left > MIN_BATCH_THRESHOLD: + continue + else: + chunk = sfu_records[:left] + sfu_records = sfu_records[left:] + sfu_rq.sharedFolderAddRecord.extend(chunk) + rqs[shared_folder_uid] = sfu_records + + try: + sfu_rss = vault.keeper_auth.execute_auth_rest( + 'vault/shared_folder_update_v3', sfu_rqs, + response_type=folder_pb2.SharedFolderUpdateV3ResponseV2 + ) + + for sfu_rs in sfu_rss.sharedFoldersUpdateV3Response: + shared_folder_uid = utils.base64_url_encode(sfu_rs.sharedFolderUid) + for sfu_status in sfu_rs.sharedFolderAddRecordStatus: + if sfu_status.status.lower() != 'success': + record_uid = utils.base64_url_encode(sfu_status.recordUid) + results.append([shared_folder_uid, record_uid, '', sfu_status.status]) + except Exception: + pass + finally: + sfu_rqs = None + + +class VerifyRecordsCommand(ArgparseCommand): + def __init__(self): + parser = argparse.ArgumentParser(prog='verify-records', description='Verify and fix record data issues') + super().__init__(parser) + + def execute(self, context: KeeperParams, **kwargs): + self._validate_context(context) + vault = context.vault + records_v3_to_fix, records_v2_to_fix = self._analyze_records(vault) + + if not records_v2_to_fix and not records_v3_to_fix: + return + + total_records = len(records_v2_to_fix) + len(records_v3_to_fix) + logger.info(f'There are {total_records} record(s) to be corrected') + + if self._get_user_confirmation(): + success, failed = self._fix_records(vault, records_v2_to_fix, records_v3_to_fix) + self._report_results(success, failed) + + if success > 0: + vault.sync_down() + + def _validate_context(self, context: KeeperParams) -> None: + if not context.vault or not context.auth: + raise CommandError("Vault is not initialized, authentication is required") + + def _analyze_records(self, vault: vault_online.VaultOnline) -> Tuple[Dict[str, dict], Dict[str, dict]]: + records_v3_to_fix = {} + records_v2_to_fix = {} + + for record_info in vault.vault_data.records(): + record_uid = record_info.record_uid + + try: + record = vault.vault_data.load_record(record_uid) + if not record: + continue + except Exception: + continue + + version = record_info.version + if version == 3: + fixed_data = self._analyze_v3_record(record) + if fixed_data: + records_v3_to_fix[record_uid] = fixed_data + elif version == 2: + fixed_data = self._analyze_v2_record(record) + if fixed_data: + records_v2_to_fix[record_uid] = fixed_data + elif version > 3: + fixed_data = self._analyze_v3_record(record) + if fixed_data: + records_v3_to_fix[record_uid] = fixed_data + + return records_v3_to_fix, records_v2_to_fix + + def _analyze_v3_record(self, record) -> Optional[dict]: + if isinstance(record, vault_record.TypedRecord): + return self._analyze_typed_record(record) + elif hasattr(record, 'fields') and hasattr(record, 'custom'): + return self._analyze_generic_v3_record(record) + else: + return None + + def _analyze_typed_record(self, record: vault_record.TypedRecord) -> Optional[dict]: + is_broken = False + + for field in itertools.chain(record.fields, record.custom): + if self._fix_field_value_format(field): + is_broken = True + if self._fix_payment_card_expiration(field): + is_broken = True + if self._fix_date_field_types(field): + is_broken = True + + if self._fix_custom_otp_fields(record): + is_broken = True + if self._remove_unknown_type_fields(record): + is_broken = True + if self._move_login_otp_to_fields(record): + is_broken = True + + return self._convert_record_to_data(record) if is_broken else None + + def _analyze_generic_v3_record(self, record) -> Optional[dict]: + is_broken = False + + if hasattr(record, 'fields'): + for field in record.fields: + if hasattr(field, 'value') and not isinstance(field.value, list): + if field.value: + field.value = [field.value] + else: + field.value = [] + is_broken = True + + return self._convert_generic_record_to_data(record) if is_broken else None + + def _convert_generic_record_to_data(self, record) -> dict: + data = { + 'type': getattr(record, 'record_type', 'login'), + 'title': getattr(record, 'title', ''), + 'notes': getattr(record, 'notes', ''), + 'fields': [], + 'custom': [] + } + + if hasattr(record, 'fields'): + for field in record.fields: + data['fields'].append({ + 'type': getattr(field, 'type', 'text'), + 'label': getattr(field, 'label', ''), + 'value': getattr(field, 'value', []) + }) + + if hasattr(record, 'custom'): + for field in record.custom: + data['custom'].append({ + 'type': getattr(field, 'type', 'text'), + 'label': getattr(field, 'label', ''), + 'value': getattr(field, 'value', []) + }) + + return data + + def _fix_field_value_format(self, field) -> bool: + if not isinstance(field.value, list): + if field.value: + field.value = [field.value] + else: + field.value = [] + return True + return False + + def _fix_payment_card_expiration(self, field) -> bool: + if field.type != 'paymentCard': + return False + + is_broken = False + for card in field.value: + if isinstance(card, dict) and 'cardExpirationDate' in card: + exp = card['cardExpirationDate'] + if isinstance(exp, str) and exp: + month, sep, year = exp.partition('/') + if not month.isnumeric() or not year.isnumeric(): + card['cardExpirationDate'] = "" + is_broken = True + elif not isinstance(exp, str): + card['cardExpirationDate'] = "" + is_broken = True + elif not isinstance(card, dict): + field.value = [] + is_broken = True + break + return is_broken + + def _fix_date_field_types(self, field) -> bool: + if field.type != 'date': + return False + + orig_dates = field.value + tested_dates = [x for x in orig_dates if isinstance(x, int)] + if len(tested_dates) < len(orig_dates): + field.value = tested_dates + return True + return False + + def _fix_custom_otp_fields(self, record) -> bool: + is_broken = False + for field in record.custom: + if (field.type != 'oneTimeCode' and field.value and + isinstance(field.value, list) and len(field.value) == 1): + value = field.value[0] + if isinstance(value, str) and value.startswith('otpauth'): + try: + code, _, _ = get_totp_code(value) + if code: + field.type = 'oneTimeCode' + is_broken = True + except Exception: + pass + return is_broken + + def _remove_unknown_type_fields(self, record) -> bool: + unknown_fields = [f for f in record.custom if f.type == 'unknownType'] + if unknown_fields: + for f in unknown_fields: + record.custom.remove(f) + return True + return False + + def _move_login_otp_to_fields(self, record) -> bool: + if record.record_type != 'login': + return False + + fields_otp = next((x for x in record.fields if x.type == 'oneTimeCode'), None) + if fields_otp and fields_otp.value: + return False + + custom_otp = next((x for x in record.custom if x.type == 'oneTimeCode'), None) + if not custom_otp or not custom_otp.value: + return False + + if fields_otp: + fields_otp.value = custom_otp.value + else: + record.fields.append(custom_otp) + + try: + record.custom.remove(custom_otp) + except ValueError: + custom_otp.value = [] + + return True + + def _convert_record_to_data(self, record) -> dict: + data = { + 'type': record.record_type, + 'title': record.title, + 'notes': record.notes, + 'fields': [], + 'custom': [] + } + + for field in record.fields: + data['fields'].append({ + 'type': field.type, + 'label': field.label, + 'value': field.value + }) + + for field in record.custom: + data['custom'].append({ + 'type': field.type, + 'label': field.label, + 'value': field.value + }) + + return data + + def _analyze_v2_record(self, record) -> Optional[dict]: + if isinstance(record, vault_record.PasswordRecord): + return self._analyze_password_record(record) + elif hasattr(record, 'title'): + return self._analyze_generic_v2_record(record) + else: + return None + + def _analyze_password_record(self, record: vault_record.PasswordRecord) -> Optional[dict]: + is_broken = False + data = { + 'title': record.title or '', + 'secret1': record.password or '', + 'secret2': record.login or '', + 'link': record.link or '', + 'notes': record.notes or '' + } + + for field_name in ('title', 'secret1', 'secret2', 'link', 'notes'): + value = data[field_name] + if not isinstance(value, str): + data[field_name] = '' if value is None else str(value) + is_broken = True + + return data if is_broken else None + + def _analyze_generic_v2_record(self, record) -> Optional[dict]: + is_broken = False + data = { + 'title': getattr(record, 'title', '') or '', + 'secret1': getattr(record, 'password', '') or getattr(record, 'secret1', '') or '', + 'secret2': getattr(record, 'login', '') or getattr(record, 'secret2', '') or '', + 'link': getattr(record, 'login_url', '') or getattr(record, 'link', '') or '', + 'notes': getattr(record, 'notes', '') or '' + } + + for field_name in ('title', 'secret1', 'secret2', 'link', 'notes'): + value = data[field_name] + if not isinstance(value, str): + data[field_name] = '' if value is None else str(value) + is_broken = True + + return data if is_broken else None + + def _get_user_confirmation(self) -> bool: + answer = user_choice('Do you want to proceed?', 'yn', 'n') + return answer.lower() == 'y' + + def _fix_records(self, vault: vault_online.VaultOnline, records_v2_to_fix, records_v3_to_fix) -> Tuple[int, List[str]]: + success = 0 + failed = [] + + if records_v2_to_fix: + v2_success, v2_failed = self._fix_v2_records(vault, records_v2_to_fix) + success += v2_success + failed.extend(v2_failed) + + if records_v3_to_fix: + v3_success, v3_failed = self._fix_v3_records(vault, records_v3_to_fix) + success += v3_success + failed.extend(v3_failed) + + return success, failed + + def _fix_v2_records(self, vault: vault_online.VaultOnline, records_v2_to_fix) -> Tuple[int, List[str]]: + success = 0 + failed = [] + record_uids = list(records_v2_to_fix.keys()) + + while record_uids: + chunk = record_uids[:MAX_DISPLAY_RECORDS] + record_uids = record_uids[MAX_DISPLAY_RECORDS:] + chunk_success, chunk_failed = self._process_v2_chunk(vault, records_v2_to_fix, chunk) + success += chunk_success + failed.extend(chunk_failed) + + return success, failed + + def _process_v2_chunk(self, vault: vault_online.VaultOnline, records_v2_to_fix, chunk) -> Tuple[int, List[str]]: + rq = { + 'command': 'record_update', + 'client_time': utils.current_milli_time(), + 'pt': 'Commander', + 'update_records': [] + } + + for record_uid in chunk: + record_info = vault.vault_data.get_record(record_uid) + record_key = vault.vault_data.get_record_key(record_uid) + + if not record_info or not record_key: + continue + + data = records_v2_to_fix[record_uid] + encrypted_data = crypto.encrypt_aes_v1(json.dumps(data).encode(), record_key) + + rq['update_records'].append({ + 'record_uid': record_uid, + 'version': 2, + 'data': utils.base64_url_encode(encrypted_data), + 'client_modified_time': utils.current_milli_time(), + 'revision': record_info.revision, + }) + + rs = vault.keeper_auth.execute_auth_command(rq) + success = 0 + failed = [] + + for rs_status in rs.get('update_records') or []: + record_uid = rs_status['record_uid'] + status = rs_status.get('status') + if status == 'success': + success += 1 + else: + failed.append(f'{record_uid}: {rs_status.get("message", status)}') + + return success, failed + + def _fix_v3_records(self, vault: vault_online.VaultOnline, records_v3_to_fix) -> Tuple[int, List[str]]: + rq = record_pb2.RecordsUpdateRequest() + rq.client_time = utils.current_milli_time() + + for record_uid in records_v3_to_fix: + record_info = vault.vault_data.get_record(record_uid) + record_key = vault.vault_data.get_record_key(record_uid) + + if not record_info or not record_key: + continue + + upd_rq = record_pb2.RecordUpdate() + upd_rq.record_uid = utils.base64_url_decode(record_uid) + upd_rq.client_modified_time = utils.current_milli_time() + upd_rq.revision = record_info.revision + data = records_v3_to_fix[record_uid] + upd_rq.data = crypto.encrypt_aes_v2(json.dumps(data).encode('utf-8'), record_key) + rq.records.append(upd_rq) + + if len(rq.records) >= MAX_RECORDS_CHUNK: + break + + rs = vault.keeper_auth.execute_auth_rest( + 'vault/records_update', rq, response_type=record_pb2.RecordsModifyResponse + ) + + success = 0 + failed = [] + + for status in rs.records: + if status.status == record_pb2.RS_SUCCESS: + success += 1 + else: + record_uid = utils.base64_url_encode(status.record_uid) + failed.append(f'{record_uid}: {status.message}') + + return success, failed + + def _report_results(self, success: int, failed: List[str]) -> None: + if success > 0: + logger.info('Successfully corrected %d record(s)', success) + if failed: + logger.warning('Failed to correct %d record(s)', len(failed)) + logger.info('\n'.join(failed)) diff --git a/keepercli-package/src/keepercli/register_commands.py b/keepercli-package/src/keepercli/register_commands.py index a4178251..2264f19c 100644 --- a/keepercli-package/src/keepercli/register_commands.py +++ b/keepercli-package/src/keepercli/register_commands.py @@ -29,7 +29,7 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS if not scopes or bool(scopes & base.CommandScope.Vault): from .commands import (vault_folder, vault, vault_record, record_edit, importer_commands, breachwatch, record_type, secrets_manager, share_management, password_report, trash, record_file_report, - record_handling_commands, register, password_generate) + record_handling_commands, register, password_generate, verify_records) commands.register_command('sync-down', vault.SyncDownCommand(), base.CommandScope.Vault, 'd') commands.register_command('cd', vault_folder.FolderCdCommand(), base.CommandScope.Vault) @@ -79,6 +79,8 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS commands.register_command('share-remove', share_management.OneTimeShareRemoveCommand(), base.CommandScope.Vault) commands.register_command('record-permission', record_handling_commands.RecordPermissionCommand(), base.CommandScope.Vault) commands.register_command('trash', trash.TrashCommand(), base.CommandScope.Vault) + commands.register_command('verify-shared-folders', verify_records.VerifySharedFoldersCommand(), base.CommandScope.Vault) + commands.register_command('verify-records', verify_records.VerifyRecordsCommand(), base.CommandScope.Vault) if not scopes or bool(scopes & base.CommandScope.Enterprise): From 935c9e7a454feaec84ffb3476444dbec58d1437f Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Wed, 19 Nov 2025 19:06:47 +0530 Subject: [PATCH 07/37] Bug fixes --- .../src/keepercli/biometric/client.py | 8 ++++---- .../src/keepercli/biometric/commands/register.py | 7 ++----- .../keepercli/biometric/commands/unregister.py | 8 ++------ .../src/keepercli/commands/record_edit.py | 16 +++++++--------- .../commands/record_handling_commands.py | 4 ++-- .../src/keepercli/commands/share_management.py | 2 +- .../src/keepercli/commands/trash.py | 2 +- .../src/keepersdk/authentication/keeper_auth.py | 6 +++--- .../src/keepersdk/enterprise/batch_management.py | 4 +++- .../src/keepersdk/plugins/pedm/admin_plugin.py | 4 +++- .../src/keepersdk/vault/vault_data.py | 5 ++++- 11 files changed, 32 insertions(+), 34 deletions(-) diff --git a/keepercli-package/src/keepercli/biometric/client.py b/keepercli-package/src/keepercli/biometric/client.py index 53f3949b..844f542c 100644 --- a/keepercli-package/src/keepercli/biometric/client.py +++ b/keepercli-package/src/keepercli/biometric/client.py @@ -124,11 +124,11 @@ def verify_registration(self, context: KeeperParams, registration_options: Dict[ if storage_handler and hasattr(storage_handler, 'store_credential_id'): try: credential_id = credential_response.id - success = storage_handler.store_credential_id(context.username, credential_id) + success = storage_handler.store_credential_id(context.auth.auth_context.username, credential_id) if success: - logging.debug("Stored credential ID for user: %s", context.username) + logging.debug("Stored credential ID for user: %s", context.auth.auth_context.username) else: - logging.warning("Failed to store credential ID for user: %s", context.username) + logging.warning("Failed to store credential ID for user: %s", context.auth.auth_context.username) except Exception as e: logging.warning("Error storing credential ID: %s", str(e)) @@ -141,7 +141,7 @@ def generate_authentication_options(self, context: KeeperParams, purpose: str = rq = APIRequest_pb2.PasskeyAuthenticationRequest() rq.authenticatorAttachment = APIRequest_pb2.AuthenticatorAttachment.PLATFORM rq.clientVersion = context.auth.keeper_endpoint.client_version - rq.username = context.username + rq.username = context.auth.auth_context.username rq.passkeyPurpose = (APIRequest_pb2.PasskeyPurpose.PK_REAUTH if purpose == 'vault' else APIRequest_pb2.PasskeyPurpose.PK_LOGIN) diff --git a/keepercli-package/src/keepercli/biometric/commands/register.py b/keepercli-package/src/keepercli/biometric/commands/register.py index ae1075d1..d0196097 100644 --- a/keepercli-package/src/keepercli/biometric/commands/register.py +++ b/keepercli-package/src/keepercli/biometric/commands/register.py @@ -25,13 +25,10 @@ def __init__(self): help='Friendly name for the biometric method') super().__init__(parser) - # def get_parser(self): - # return self.parser - def execute(self, context: KeeperParams, **kwargs): """Execute registration with improved error handling and method breakdown""" def _register(): - username = context.username + username = context.auth.auth_context.username self._validate_prerequisites(username, kwargs) registration_data = self._prepare_registration(kwargs) credential = self._perform_registration(context, registration_data) @@ -84,7 +81,7 @@ def _perform_registration(self, context: KeeperParams, registration_data): } except Exception as e: - return self._handle_registration_error(e, context.username, registration_data['friendly_name']) + return self._handle_registration_error(e, context.auth.auth_context.username, registration_data['friendly_name']) def _handle_registration_error(self, error, username, friendly_name): """Handle registration errors, including existing credential scenarios""" diff --git a/keepercli-package/src/keepercli/biometric/commands/unregister.py b/keepercli-package/src/keepercli/biometric/commands/unregister.py index c5c6f58c..0ed874f2 100644 --- a/keepercli-package/src/keepercli/biometric/commands/unregister.py +++ b/keepercli-package/src/keepercli/biometric/commands/unregister.py @@ -31,13 +31,10 @@ def __init__(self): help='Skip confirmation prompt') super().__init__(parser) - # def get_parser(self): - # return self.parser - def execute(self, context: KeeperParams, **kwargs): """Disable biometric authentication for the current user""" def _unregister(): - username = context.username + username = context.auth.auth_context.username if not self._check_biometric_flag(username): print(f"Biometric authentication is already disabled for user '{username}'.") return @@ -50,7 +47,6 @@ def _unregister(): self._disable_server_passkeys(context) - context.biometric = False cleanup_success = self._cleanup_local_credentials(rp_id) delete_success = self._delete_biometric_flag(username) @@ -90,7 +86,7 @@ def _get_rp_id_from_server(self, context: KeeperParams) -> Optional[str]: def _disable_server_passkeys(self, context: KeeperParams): """Disable the specific passkey stored for this device""" try: - stored_credential_id = self._get_stored_credential_id(context.username) + stored_credential_id = self._get_stored_credential_id(context.auth.auth_context.username) if stored_credential_id: passkey_result = self._disable_specific_passkey(context.vault, stored_credential_id) diff --git a/keepercli-package/src/keepercli/commands/record_edit.py b/keepercli-package/src/keepercli/commands/record_edit.py index e616c323..81727ed1 100644 --- a/keepercli-package/src/keepercli/commands/record_edit.py +++ b/keepercli-package/src/keepercli/commands/record_edit.py @@ -1392,10 +1392,10 @@ def _display_folder_json(self, vault: vault_data.VaultData, uid: str): def _display_team_json(self, context: KeeperParams, uid: str): """Display team information in JSON format.""" team = context.enterprise_data.teams.get_entity(uid) - user = enterprise_utils.UserUtils.resolve_single_user(context.enterprise_data, context.username) + user = enterprise_utils.UserUtils.resolve_single_user(context.enterprise_data, context.auth.auth_context.username) team_users = {x.team_uid for x in context.enterprise_data.team_users.get_links_by_object(user.enterprise_user_id)} if team.team_uid not in team_users: - logger.info(f'User {context.username} does not belong to team {team.name}') + logger.info(f'User {context.auth.auth_context.username} does not belong to team {team.name}') output = { 'Team UID:': uid, 'Name:': team.name @@ -1626,11 +1626,11 @@ def _display_team_detail(self, context: KeeperParams, uid: str): """Display team information in detailed format.""" team = context.enterprise_data.teams.get_entity(uid) - user = enterprise_utils.UserUtils.resolve_single_user(context.enterprise_data, context.username) + user = enterprise_utils.UserUtils.resolve_single_user(context.enterprise_data, context.auth.auth_context.username) team_users = {x.team_uid for x in context.enterprise_data.team_users.get_links_by_object(user.enterprise_user_id)} team_user = True if team.team_uid not in team_users: - logger.info(f'User {context.username} does not belong to team {team.name}') + logger.info(f'User {context.auth.auth_context.username} does not belong to team {team.name}') team_user = False logger.info('') @@ -1785,7 +1785,7 @@ def _prepare_search_config(self, kwargs: dict) -> dict: def _perform_search(self, vault: vault_online.VaultOnline, config: dict, context: KeeperParams): """Perform the search across all specified categories.""" - # Validate categories + valid_categories = set('rst') requested_categories = set(config['categories']) if not requested_categories.issubset(valid_categories): @@ -1793,12 +1793,10 @@ def _perform_search(self, vault: vault_online.VaultOnline, config: dict, context f"Using valid categories: {requested_categories & valid_categories}") config['categories'] = ''.join(requested_categories & valid_categories) - # Store search results for each category search_results = {} total_found = 0 max_results_per_category = 1000 - # Search in each requested category if 'r' in config['categories']: try: records = context.vault.vault_data.find_records(criteria=config['pattern'], record_type=None, record_version=None) @@ -1826,12 +1824,12 @@ def _perform_search(self, vault: vault_online.VaultOnline, config: dict, context logger.error(f"Error searching teams: {e}") search_results['teams'] = [] - # Check if any objects were found in any of the requested categories if total_found == 0: + if 't' in config['categories']: + logger.error("No teams found matching the pattern or you are not a member of the requested team") categories_str = ', '.join(requested_categories) raise base.CommandError(f"No objects found in any of the requested categories: {categories_str}") - # Display results after all searches are completed self._display_all_search_results(search_results, config, context, vault) def _display_all_search_results(self, search_results: dict, config: dict, context: KeeperParams, vault: vault_online.VaultOnline): diff --git a/keepercli-package/src/keepercli/commands/record_handling_commands.py b/keepercli-package/src/keepercli/commands/record_handling_commands.py index 33ea20fc..0f2b6a20 100644 --- a/keepercli-package/src/keepercli/commands/record_handling_commands.py +++ b/keepercli-package/src/keepercli/commands/record_handling_commands.py @@ -1167,7 +1167,7 @@ def process_direct_shares(self, folders): continue username = up.get('username') - if username == self.context.username: # Skip self + if username == self.context.auth.auth_context.username: # Skip self continue needs_update = self._needs_permission_update( @@ -1299,7 +1299,7 @@ def _has_manage_records_permission(self, shared_folder, shared_folder_uid, is_sh return True user = next( - (x for x in shared_folder.user_permissions if x.name == self.context.username), + (x for x in shared_folder.user_permissions if x.name == self.context.auth.auth_context.username), None ) if user and user.manage_records: diff --git a/keepercli-package/src/keepercli/commands/share_management.py b/keepercli-package/src/keepercli/commands/share_management.py index ab628f57..330b4965 100644 --- a/keepercli-package/src/keepercli/commands/share_management.py +++ b/keepercli-package/src/keepercli/commands/share_management.py @@ -754,7 +754,7 @@ def prep_rq(recs, users, curr_sf): if (all_users or all_records) and sh_fol: if all_users: if sh_fol.user_permissions: - sf_users.update((x.name for x in sh_fol.user_permissions if x.name != context.username)) + sf_users.update((x.name for x in sh_fol.user_permissions if x.name != context.auth.auth_context.username)) if all_records: if sh_fol and sh_fol.record_permissions: sf_records.update((x.record_uid for x in sh_fol.record_permissions)) diff --git a/keepercli-package/src/keepercli/commands/trash.py b/keepercli-package/src/keepercli/commands/trash.py index d05ac17b..286e77e8 100644 --- a/keepercli-package/src/keepercli/commands/trash.py +++ b/keepercli-package/src/keepercli/commands/trash.py @@ -389,7 +389,7 @@ def _display_share_info(self, context: KeeperParams, record: Dict, record_uid: s self._load_record_shares(context.vault, record, record_uid) if 'shares' in record and 'user_permissions' in record['shares']: - self._display_user_permissions(record['shares']['user_permissions'], context.username) + self._display_user_permissions(record['shares']['user_permissions'], context.auth.auth_context.username) def _load_record_shares(self, vault, record: Dict, record_uid: str): """Load record shares if not already present.""" diff --git a/keepersdk-package/src/keepersdk/authentication/keeper_auth.py b/keepersdk-package/src/keepersdk/authentication/keeper_auth.py index 90703802..15fff263 100644 --- a/keepersdk-package/src/keepersdk/authentication/keeper_auth.py +++ b/keepersdk-package/src/keepersdk/authentication/keeper_auth.py @@ -293,13 +293,13 @@ def load_team_keys(self, team_uids: Iterable[str]) -> None: assert self.auth_context.rsa_private_key is not None aes = crypto.decrypt_rsa(encrypted_key, self.auth_context.rsa_private_key) elif key_type == 3: - rsa = encrypted_key + aes = crypto.decrypt_aes_v2(encrypted_key, self.auth_context.data_key) elif key_type == 4: assert self.auth_context.ec_private_key is not None aes = crypto.decrypt_ec(encrypted_key, self.auth_context.ec_private_key) elif key_type == -3: - aes = crypto.decrypt_aes_v2(encrypted_key, self.auth_context.data_key) - elif key_type == -4: + rsa = encrypted_key + elif key_type == -1: ec = encrypted_key self._key_cache[team_uid] = UserKeys(aes=aes,rsa=rsa, ec=ec) except Exception as e: diff --git a/keepersdk-package/src/keepersdk/enterprise/batch_management.py b/keepersdk-package/src/keepersdk/enterprise/batch_management.py index 7abe7098..ece9ec35 100644 --- a/keepersdk-package/src/keepersdk/enterprise/batch_management.py +++ b/keepersdk-package/src/keepersdk/enterprise/batch_management.py @@ -905,7 +905,9 @@ def _to_team_user_requests(self) -> Tuple[List[Dict[str, Any]], List[Dict[str, A if not team_keys: raise Exception('team key is not loaded') if not team_keys.aes: - raise Exception('team key is not loaded') + team = enterprise_data.teams.get_entity(team_user.team_uid) + if team: + team_keys.aes = team.encrypted_team_key user_keys = self.loader.keeper_auth.get_user_keys(u.username) if not user_keys: raise Exception('user key is not loaded') diff --git a/keepersdk-package/src/keepersdk/plugins/pedm/admin_plugin.py b/keepersdk-package/src/keepersdk/plugins/pedm/admin_plugin.py index 5a236e05..113f6606 100644 --- a/keepersdk-package/src/keepersdk/plugins/pedm/admin_plugin.py +++ b/keepersdk-package/src/keepersdk/plugins/pedm/admin_plugin.py @@ -167,7 +167,9 @@ def on_push_message(self, message: Dict[str, Any]): self._need_sync = True def close(self): - self._push_notifications.shutdown() + if self._push_notifications and not self._push_notifications.is_completed: + self._push_notifications.shutdown() + self._push_notifications = None @property def deployments(self) -> storage_types.IEntityReader[admin_types.PedmDeployment, str]: diff --git a/keepersdk-package/src/keepersdk/vault/vault_data.py b/keepersdk-package/src/keepersdk/vault/vault_data.py index 8407de73..3df7f656 100644 --- a/keepersdk-package/src/keepersdk/vault/vault_data.py +++ b/keepersdk-package/src/keepersdk/vault/vault_data.py @@ -129,7 +129,10 @@ def _match_entity(entity_words: Optional[Tuple[str, ...]], words: Optional[Union if isinstance(words, str): words = list(utils.tokenize_searchable_text(words)) elif isinstance(words, list): - return False + search_words = [] + for word in words: + search_words.extend(utils.tokenize_searchable_text(word)) + words = search_words for entity_word in entity_words: for search_word in words: if len(search_word) <= len(entity_word): From a7976d087d30573a85d8d3a423447e9918b6d480 Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Thu, 20 Nov 2025 16:18:25 +0530 Subject: [PATCH 08/37] KeeperParam and Proto update changes --- .../src/keepercli/commands/cli_commands.py | 2 +- .../commands/enterprise_create_user.py | 2 +- .../src/keepercli/commands/enterprise_user.py | 29 ++++---- .../keepercli/commands/enterprise_utils.py | 2 - .../keepercli/commands/password_generate.py | 4 +- .../src/keepercli/commands/password_report.py | 2 +- .../src/keepercli/commands/record_edit.py | 73 ++++++++++--------- .../keepercli/commands/record_file_report.py | 2 +- .../commands/record_handling_commands.py | 7 +- .../src/keepercli/commands/record_type.py | 2 +- .../keepercli/commands/record_type_utils.py | 7 +- .../src/keepercli/commands/secrets_manager.py | 4 +- .../keepercli/commands/share_management.py | 2 +- .../src/keepercli/commands/trash.py | 2 +- .../src/keepercli/helpers/password_utils.py | 2 +- .../src/keepercli/helpers/record_utils.py | 4 +- .../src/keepercli/helpers/share_utils.py | 8 +- .../keepersdk/authentication/keeper_auth.py | 1 - 18 files changed, 76 insertions(+), 79 deletions(-) diff --git a/keepercli-package/src/keepercli/commands/cli_commands.py b/keepercli-package/src/keepercli/commands/cli_commands.py index 0f198263..dbd8da86 100644 --- a/keepercli-package/src/keepercli/commands/cli_commands.py +++ b/keepercli-package/src/keepercli/commands/cli_commands.py @@ -111,7 +111,7 @@ def execute(self, context, **kwargs): table.append(['Operating System', f'{p} ({platform.release()})']) table.append(['Working directory', os.getcwd()]) table.append(['Package directory', os.path.dirname(api.__file__)]) - table.append(['Config. File', context.config_filename]) + table.append(['Config. File', context.keeper_config.config_filename]) table.append(['Executable', sys.executable]) if logger.isEnabledFor(logging.DEBUG) or show_packages: diff --git a/keepercli-package/src/keepercli/commands/enterprise_create_user.py b/keepercli-package/src/keepercli/commands/enterprise_create_user.py index 50e4a3bb..fb477cfe 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_create_user.py +++ b/keepercli-package/src/keepercli/commands/enterprise_create_user.py @@ -217,7 +217,7 @@ def execute(self, context: KeeperParams, **kwargs): try: result = self._create_user(context, email, displayname, node_name) - keeper_url = self._build_keeper_url(context.server, email) + keeper_url = self._build_keeper_url(context.auth.keeper_endpoint.server, email) self._log_results( result, displayname, keeper_url, PASSWORD_CHANGE_NOTE, verbose diff --git a/keepercli-package/src/keepercli/commands/enterprise_user.py b/keepercli-package/src/keepercli/commands/enterprise_user.py index 890b3e69..c382be30 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_user.py +++ b/keepercli-package/src/keepercli/commands/enterprise_user.py @@ -820,22 +820,21 @@ def _get_ecc_data_keys(self, context: KeeperParams, enterprise_data, user_ids: S data_key_rq.enterpriseUserId.extend(user_ids) data_key_rs = context.auth.execute_auth_rest( GET_ENTERPRISE_USER_DATA_KEY_ENDPOINT, data_key_rq, - response_type=APIRequest_pb2.EnterpriseUserDataKeys) + response_type=APIRequest_pb2.EnterpriseUserIdDataKeyPair) - for key in data_key_rs.keys: - enc_data_key = key.userEncryptedDataKey - if enc_data_key: - try: - ephemeral_public_key = ec.EllipticCurvePublicKey.from_encoded_point( - curve, enc_data_key[:ECC_PUBLIC_KEY_LENGTH]) - shared_key = ecc_private_key.exchange(ec.ECDH(), ephemeral_public_key) - digest = hashes.Hash(hashes.SHA256(), backend=default_backend()) - digest.update(shared_key) - enc_key = digest.finalize() - data_key = utils.crypto.decrypt_aes_v2(enc_data_key[ECC_PUBLIC_KEY_LENGTH:], enc_key) - data_keys[key.enterpriseUserId] = data_key - except Exception as e: - logger.debug(e) + enc_data_key = data_key_rs.encryptedDataKey + if enc_data_key: + try: + ephemeral_public_key = ec.EllipticCurvePublicKey.from_encoded_point( + curve, enc_data_key[:ECC_PUBLIC_KEY_LENGTH]) + shared_key = ecc_private_key.exchange(ec.ECDH(), ephemeral_public_key) + digest = hashes.Hash(hashes.SHA256(), backend=default_backend()) + digest.update(shared_key) + enc_key = digest.finalize() + data_key = utils.crypto.decrypt_aes_v2(enc_data_key[ECC_PUBLIC_KEY_LENGTH:], enc_key) + data_keys[data_key_rs.enterpriseUserId] = data_key + except Exception as e: + logger.debug(e) return data_keys diff --git a/keepercli-package/src/keepercli/commands/enterprise_utils.py b/keepercli-package/src/keepercli/commands/enterprise_utils.py index 22505355..03833bd5 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_utils.py +++ b/keepercli-package/src/keepercli/commands/enterprise_utils.py @@ -503,8 +503,6 @@ def get_managed_nodes_for_user(enterprise_data: enterprise_types.IEnterpriseData def is_addon_enabled(context: KeeperParams, addon_name: str) -> bool: keeper_licenses = context.enterprise_data.licenses.get_all_entities() - for license in keeper_licenses: - license.license_status if not keeper_licenses: raise base.CommandError('No licenses found') if next(iter(keeper_licenses), {}).license_status == BUSINESS_TRIAL: diff --git a/keepercli-package/src/keepercli/commands/password_generate.py b/keepercli-package/src/keepercli/commands/password_generate.py index bf7a192d..16cbab89 100644 --- a/keepercli-package/src/keepercli/commands/password_generate.py +++ b/keepercli-package/src/keepercli/commands/password_generate.py @@ -24,7 +24,7 @@ from .. import api from ..helpers.password_utils import ( PasswordGenerationService, GenerationRequest, GeneratedPassword, - BreachStatus, PasswordStrength + BreachStatus ) from ..params import KeeperParams @@ -265,7 +265,7 @@ def _output_results(self, passwords: List[GeneratedPassword], **kwargs) -> None: logger.info(f"Output written to: {output_file}") except Exception as e: logger.error(f"Failed to write to file {output_file}: {e}") - raise base.CommandError('generate', f"File write error: {e}") + raise base.CommandError(f"File write error: {e}") else: print(output) diff --git a/keepercli-package/src/keepercli/commands/password_report.py b/keepercli-package/src/keepercli/commands/password_report.py index 5fb59c8b..8e80d1b8 100644 --- a/keepercli-package/src/keepercli/commands/password_report.py +++ b/keepercli-package/src/keepercli/commands/password_report.py @@ -104,7 +104,7 @@ def _resolve_folder_uid(self, context: KeeperParams, path_or_uid: Optional[str]) if not folder or pattern: raise base.CommandError(f'Folder path {path_or_uid} not found') - return folder.uid or '' + return folder.folder_uid or '' def _extract_password_from_record(self, record: Any) -> str: """Extract password from a vault record. diff --git a/keepercli-package/src/keepercli/commands/record_edit.py b/keepercli-package/src/keepercli/commands/record_edit.py index 81727ed1..928c688a 100644 --- a/keepercli-package/src/keepercli/commands/record_edit.py +++ b/keepercli-package/src/keepercli/commands/record_edit.py @@ -1094,6 +1094,7 @@ def add_arguments_to_parser(parser: argparse.ArgumentParser): def execute(self, context: KeeperParams, **kwargs): """Execute the get command based on the provided parameters.""" self._validate_context(context) + vault = context.vault uid = kwargs.get('uid') output_format = kwargs.get('format', 'detail') @@ -1103,11 +1104,11 @@ def execute(self, context: KeeperParams, **kwargs): record = kwargs.get('record') if folder: - shared_folder = self._find_shared_folder(context.vault, folder) + shared_folder = self._find_shared_folder(vault, folder) if shared_folder: target_object = ('shared_folder', shared_folder) else: - folder = self._find_folder(context.vault, folder) + folder = self._find_folder(vault, folder) if folder: target_object = ('folder', folder) else: @@ -1119,7 +1120,7 @@ def execute(self, context: KeeperParams, **kwargs): else: raise base.CommandError('The given UID or title is not a valid team') elif record: - record = self._find_record(context.vault, record) + record = self._find_record(vault, record) if record: target_object = ('record', record) else: @@ -1161,7 +1162,7 @@ def _find_target_object(self, context: KeeperParams, uid_or_title: str): return None - def _find_record(self, vault: vault_data.VaultData, uid_or_title: str): + def _find_record(self, vault: vault_online.VaultOnline, uid_or_title: str): """Find a record by UID or title.""" return next( (r for r in vault.vault_data.records() @@ -1169,7 +1170,7 @@ def _find_record(self, vault: vault_data.VaultData, uid_or_title: str): None ) - def _find_shared_folder(self, vault: vault_data.VaultData, uid_or_title: str): + def _find_shared_folder(self, vault: vault_online.VaultOnline, uid_or_title: str): """Find a shared folder by UID or name.""" return next( (f for f in vault.vault_data.shared_folders() @@ -1177,7 +1178,7 @@ def _find_shared_folder(self, vault: vault_data.VaultData, uid_or_title: str): None ) - def _find_folder(self, vault: vault_data.VaultData, uid_or_title: str): + def _find_folder(self, vault: vault_online.VaultOnline, uid_or_title: str): """Find a folder by UID or name.""" return next( (f for f in vault.vault_data.folders() @@ -1206,7 +1207,7 @@ def _display_object(self, context: KeeperParams, target_object, output_format: s elif object_type == 'team': self._display_team(context, object_data, output_format) - def _display_record(self, vault: vault_data.VaultData, record, output_format: str, unmask: bool): + def _display_record(self, vault: vault_online.VaultOnline, record, output_format: str, unmask: bool): """Display a record in the specified format.""" record_uid = record.record_uid dispatch = { @@ -1218,14 +1219,14 @@ def _display_record(self, vault: vault_data.VaultData, record, output_format: st display_func = dispatch.get(output_format, lambda: self._display_record_detail(vault, record_uid, unmask)) display_func() - def _display_shared_folder(self, vault: vault_data.VaultData, shared_folder, output_format: str): + def _display_shared_folder(self, vault: vault_online.VaultOnline, shared_folder, output_format: str): """Display a shared folder in the specified format.""" if output_format == 'json': self._display_shared_folder_json(vault, shared_folder.shared_folder_uid) else: # detail format self._display_shared_folder_detail(vault, shared_folder.shared_folder_uid) - def _display_folder(self, vault: vault_data.VaultData, folder, output_format: str): + def _display_folder(self, vault: vault_online.VaultOnline, folder, output_format: str): """Display a folder in the specified format.""" if output_format == 'json': self._display_folder_json(vault, folder.folder_uid) @@ -1239,7 +1240,7 @@ def _display_team(self, context: KeeperParams, team, output_format: str): else: # detail format self._display_team_detail(context, team.team_uid) - def _display_record_json(self, vault: vault_data.VaultData, uid: str, unmask: bool = False): + def _display_record_json(self, vault: vault_online.VaultOnline, uid: str, unmask: bool = False): """Display record information in JSON format.""" record = vault.vault_data.get_record(record_uid=uid) record_data = vault.vault_data.load_record(record_uid=uid) @@ -1285,9 +1286,9 @@ def _add_password_record_json_fields(self, record_data: vault_record.PasswordRec if record_data.attachments: output['Attachments:'] = [{ - 'Id': a.get('id'), - 'Name': a.get('name'), - 'Size': a.get('size') + 'Id': a.id, + 'Name': a.name, + 'Size': a.size } for a in record_data.attachments] if record_data.custom: @@ -1334,7 +1335,7 @@ def _add_file_record_json_fields(self, record_data: vault_record.FileRecord, out output['MIME Type:'] = record_data.mime_type output['Size:'] = record_data.size - def _add_share_info_to_json(self, vault: vault_data.VaultData, uid: str, output: dict): + def _add_share_info_to_json(self, vault: vault_online.VaultOnline, uid: str, output: dict): """Add share information to JSON output.""" share_infos = share_utils.get_record_shares(vault=vault, record_uids=[uid]) if share_infos and len(share_infos) > 0: @@ -1348,7 +1349,7 @@ def _add_share_info_to_json(self, vault: vault_data.VaultData, uid: str, output: if folder_shares: output['Shared Folders:'] = folder_shares - def _display_shared_folder_json(self, vault: vault_data.VaultData, uid: str): + def _display_shared_folder_json(self, vault: vault_online.VaultOnline, uid: str): """Display shared folder information in JSON format.""" shared_folder = vault.vault_data.load_shared_folder(shared_folder_uid=uid) output = { @@ -1378,7 +1379,7 @@ def _display_shared_folder_json(self, vault: vault_data.VaultData, uid: str): logger.info(json.dumps(output, indent=2)) - def _display_folder_json(self, vault: vault_data.VaultData, uid: str): + def _display_folder_json(self, vault: vault_online.VaultOnline, uid: str): """Display folder information in JSON format.""" folder = vault.vault_data.get_folder(folder_uid=uid) output = { @@ -1402,7 +1403,7 @@ def _display_team_json(self, context: KeeperParams, uid: str): } logger.info(json.dumps(output, indent=2)) - def _display_record_detail(self, vault: vault_data.VaultData, uid: str, unmask: bool): + def _display_record_detail(self, vault: vault_online.VaultOnline, uid: str, unmask: bool): """Display record information in detailed format.""" record = vault.vault_data.get_record(record_uid=uid) record_data = vault.vault_data.load_record(record_uid=uid) @@ -1511,7 +1512,7 @@ def _display_totp(self, totp: str, unmask: bool): if code: logger.info('{0:>20s}: {1:<20s} valid for {2} sec'.format('Two Factor Code', code, remain)) - def _display_share_information(self, vault: vault_data.VaultData, uid: str): + def _display_share_information(self, vault: vault_online.VaultOnline, uid: str): """Display share information for a record.""" share_infos = share_utils.get_record_shares(vault=vault, record_uids=[uid]) if not share_infos or len(share_infos) == 0: @@ -1570,7 +1571,7 @@ def _display_folder_permissions(self, folder_shares): logger.info('Can Share: True') logger.info('') - def _display_share_admins(self, vault: vault_data.VaultData, uid: str): + def _display_share_admins(self, vault: vault_online.VaultOnline, uid: str): """Display share admins for a record.""" admins = record_utils.get_share_admins_for_record(vault=vault, record_uid=uid) if admins: @@ -1579,7 +1580,7 @@ def _display_share_admins(self, vault: vault_data.VaultData, uid: str): for admin in admins: logger.info(admin) - def _display_shared_folder_detail(self, vault: vault_data.VaultData, uid: str): + def _display_shared_folder_detail(self, vault: vault_online.VaultOnline, uid: str): """Display shared folder information in detailed format.""" shared_folder = vault.vault_data.load_shared_folder(shared_folder_uid=uid) logger.info('') @@ -1610,7 +1611,7 @@ def _display_shared_folder_detail(self, vault: vault_data.VaultData, uid: str): logger.info('') - def _display_folder_detail(self, vault: vault_data.VaultData, uid: str): + def _display_folder_detail(self, vault: vault_online.VaultOnline, uid: str): """Display folder information in detailed format.""" folder = vault.vault_data.get_folder(folder_uid=uid) logger.info('') @@ -1642,7 +1643,7 @@ def _display_team_detail(self, context: KeeperParams, uid: str): logger.info('{0:>20s}: {1}'.format('Restrict Share', team.restrict_share)) logger.info('') - def _display_record_password(self, vault: vault_data.VaultData, uid: str): + def _display_record_password(self, vault: vault_online.VaultOnline, uid: str): """Display only the password field of a record.""" record_data = vault.vault_data.load_record(record_uid=uid) if isinstance(record_data, vault_record.PasswordRecord): @@ -1654,7 +1655,7 @@ def _display_record_password(self, vault: vault_data.VaultData, uid: str): else: logger.info('No password field found in this record type') - def _display_record_fields(self, vault: vault_data.VaultData, uid: str, unmask: bool): + def _display_record_fields(self, vault: vault_online.VaultOnline, uid: str, unmask: bool): """Display record fields in JSON format.""" record = vault.vault_data.get_record(record_uid=uid) record_data = vault.vault_data.load_record(record_uid=uid) @@ -1847,7 +1848,7 @@ def _display_all_search_results(self, search_results: dict, config: dict, contex if 'teams' in search_results and search_results['teams']: logger.info('') - self._display_teams(search_results['teams'], config['skip_details'], vault) + self._display_teams(search_results['teams'], config['skip_details'], context) def _search_records(self, config: dict, context: KeeperParams): """Search and display records matching the pattern.""" @@ -1872,13 +1873,13 @@ def _search_shared_folders(self, vault: vault_online.VaultOnline, config: dict): except Exception as e: logger.error(f"Error searching shared folders: {e}") - def _search_teams(self, vault: vault_online.VaultOnline, config: dict): + def _search_teams(self, context: KeeperParams, config: dict): """Search and display teams matching the pattern.""" try: - teams = vault.vault_data.find_teams(criteria=config['pattern']) + teams = context.vault.vault_data.find_teams(criteria=config['pattern']) if teams: logger.info('') - self._display_teams(teams, config['skip_details'], vault) + self._display_teams(teams, config['skip_details'], context) except Exception as e: logger.error(f"Error searching teams: {e}") @@ -1910,7 +1911,7 @@ def _display_record_details(self, records: Iterable[vault_record.KeeperRecordInf kwargs = {'uid': record.record_uid, 'record': True} get_command.execute(context, **kwargs) - def _display_shared_folders(self, shared_folders: Iterable[vault_types.SharedFolder], + def _display_shared_folders(self, shared_folders: Iterable[vault_types.SharedFolderInfo], skip_details: bool, vault: vault_online.VaultOnline): """Display shared folders in a formatted table with optional details.""" shared_folders_list = list(shared_folders) @@ -1924,7 +1925,7 @@ def _display_shared_folders(self, shared_folders: Iterable[vault_types.SharedFol if len(shared_folders_list) < self.MAX_DETAILS_THRESHOLD and not skip_details: self._display_shared_folder_details(shared_folders_list, vault) - def _display_shared_folders_table(self, shared_folders: Iterable[vault_types.SharedFolder]): + def _display_shared_folders_table(self, shared_folders: Iterable[vault_types.SharedFolderInfo]): """Display shared folders in a formatted table.""" table = [[i + 1, sf.shared_folder_uid, sf.name] for i, sf in enumerate(shared_folders)] @@ -1933,15 +1934,15 @@ def _display_shared_folders_table(self, shared_folders: Iterable[vault_types.Sha ) logger.info('') - def _display_shared_folder_details(self, shared_folders: Iterable[vault_types.SharedFolder], + def _display_shared_folder_details(self, shared_folders: Iterable[vault_types.SharedFolderInfo], vault: vault_online.VaultOnline): """Display detailed information for shared folders.""" get_command = RecordGetCommand() for sf in shared_folders: get_command._display_shared_folder_detail(vault=vault, uid=sf.shared_folder_uid) - def _display_teams(self, teams: Iterable[vault_types.Team], skip_details: bool, - vault: vault_online.VaultOnline): + def _display_teams(self, teams: Iterable[vault_types.TeamInfo], skip_details: bool, + context: KeeperParams): """Display teams in a formatted table with optional details.""" teams_list = list(teams) @@ -1952,9 +1953,9 @@ def _display_teams(self, teams: Iterable[vault_types.Team], skip_details: bool, # Display details for small result sets if len(teams_list) < self.MAX_DETAILS_THRESHOLD and not skip_details: - self._display_team_details(teams_list, vault) + self._display_team_details(teams_list, context) - def _display_teams_table(self, teams: Iterable[vault_types.Team]): + def _display_teams_table(self, teams: Iterable[vault_types.TeamInfo]): """Display teams in a formatted table.""" table = [[i + 1, team.team_uid, team.name] for i, team in enumerate(teams)] @@ -1963,8 +1964,8 @@ def _display_teams_table(self, teams: Iterable[vault_types.Team]): ) logger.info('') - def _display_team_details(self, teams: Iterable[vault_types.Team], vault: vault_online.VaultOnline): + def _display_team_details(self, teams: Iterable[vault_types.TeamInfo], context: KeeperParams): """Display detailed information for teams.""" get_command = RecordGetCommand() for team in teams: - get_command._display_team_detail(vault=vault, uid=team.team_uid) + get_command._display_team_detail(context=context, uid=team.team_uid) diff --git a/keepercli-package/src/keepercli/commands/record_file_report.py b/keepercli-package/src/keepercli/commands/record_file_report.py index eaa1cc91..8946aa13 100644 --- a/keepercli-package/src/keepercli/commands/record_file_report.py +++ b/keepercli-package/src/keepercli/commands/record_file_report.py @@ -170,7 +170,7 @@ def _add_typed_record_rows(self, record: vault_record.TypedRecord, facade, downl record.record_uid, record.record_type, file_record.record_uid, - file_record.title or file_record.name, + file_record.title or file_record.file_name, file_record.size ] diff --git a/keepercli-package/src/keepercli/commands/record_handling_commands.py b/keepercli-package/src/keepercli/commands/record_handling_commands.py index 0f2b6a20..cb252320 100644 --- a/keepercli-package/src/keepercli/commands/record_handling_commands.py +++ b/keepercli-package/src/keepercli/commands/record_handling_commands.py @@ -305,9 +305,12 @@ def _extract_custom_field_data(self, record, field_name: str) -> tuple[str, str] field_name, field_property = self._parse_field_name(field_name) if isinstance(record, vault_record.PasswordRecord): - return copy_item, record.custom.get(field_name, '') + for field in record.custom: + if field.name.lower() == field_name.lower(): + return copy_item, field.value + return copy_item, '' elif isinstance(record, vault_record.TypedRecord): - return self._extract_typed_field_data(record, field_name, field_property, copy_item) + return self._extract_typed_field_data(record, field_name.lower(), field_property, copy_item) return copy_item, '' diff --git a/keepercli-package/src/keepercli/commands/record_type.py b/keepercli-package/src/keepercli/commands/record_type.py index 47243fa9..3bc79c73 100644 --- a/keepercli-package/src/keepercli/commands/record_type.py +++ b/keepercli-package/src/keepercli/commands/record_type.py @@ -260,7 +260,7 @@ def execute(self, context: KeeperParams, **kwargs) -> None: existing_record_types = record_type_utils.get_record_types(context.vault) if existing_record_types: for existing_record_type in existing_record_types: - loaded_record_types.add(existing_record_type[1].lower()) + loaded_record_types.add(existing_record_type.name.lower()) for record_type in record_types_list: record_type_name = record_type.get('record_type_name') diff --git a/keepercli-package/src/keepercli/commands/record_type_utils.py b/keepercli-package/src/keepercli/commands/record_type_utils.py index 4871df09..5130171a 100644 --- a/keepercli-package/src/keepercli/commands/record_type_utils.py +++ b/keepercli-package/src/keepercli/commands/record_type_utils.py @@ -93,12 +93,11 @@ def get_record_type_example(vault: vault_online.VaultOnline, record_type_name: s ft['sample']['region'] = 'US' rte['fields'].append(val) + result = json.dumps(rte, indent=2) if rte else '' + return result else: raise ValueError(f'No record type found with name {record_type_name}. Use "record-type-info" to list all record types') - result = json.dumps(rte, indent=2) if rte else '' - return result - def get_record_types(vault:vault_online.VaultOnline) -> list[vault_types.RecordType]: records = [] # (recordTypeId, name, scope) @@ -138,7 +137,7 @@ def get_field_type(id): return result -def isEnterpriseRecordType(record_type_id: int) -> bool: +def is_enterprise_record_type(record_type_id: int) -> tuple[bool, int]: num_rts_per_scope = 1_000_000 enterprise_scope = record_pb2.RT_ENTERPRISE min_id = num_rts_per_scope * enterprise_scope diff --git a/keepercli-package/src/keepercli/commands/secrets_manager.py b/keepercli-package/src/keepercli/commands/secrets_manager.py index b5db885e..81c8fabf 100644 --- a/keepercli-package/src/keepercli/commands/secrets_manager.py +++ b/keepercli-package/src/keepercli/commands/secrets_manager.py @@ -476,8 +476,8 @@ def execute(self, context: KeeperParams, **kwargs) -> None: tokens_and_device = SecretsManagerClientCommand.add_client( vault=vault, uid=uid, count=count, client_name=client_name, - unlock_ip=unlock_ip, first_access_expire_duration=first_access_expire_in, - access_expire_in_min=access_expire_in_min, server=context.server + unlock_ip=unlock_ip, first_access_expire_duration=first_access_expire_in, + access_expire_in_min=access_expire_in_min, server=context.auth.keeper_endpoint.server ) tokens_only = [d['oneTimeToken'] for d in tokens_and_device] diff --git a/keepercli-package/src/keepercli/commands/share_management.py b/keepercli-package/src/keepercli/commands/share_management.py index 330b4965..5426c49c 100644 --- a/keepercli-package/src/keepercli/commands/share_management.py +++ b/keepercli-package/src/keepercli/commands/share_management.py @@ -1267,7 +1267,7 @@ def _create_share_urls(self, context: KeeperParams, vault, record_names: list, p def _handle_output(self, context: KeeperParams, urls: dict, kwargs): """Handle different output formats for the URLs.""" - if context.batch_mode: + if context.keeper_config.batch_mode: return '\n'.join(urls.values()) output = kwargs.get('output') or '' diff --git a/keepercli-package/src/keepercli/commands/trash.py b/keepercli-package/src/keepercli/commands/trash.py index 286e77e8..1106e236 100644 --- a/keepercli-package/src/keepercli/commands/trash.py +++ b/keepercli-package/src/keepercli/commands/trash.py @@ -633,7 +633,7 @@ def _add_matching_records(self, pattern: str, orphaned_records: Dict, records_to try: title_pattern = re.compile(fnmatch.translate(pattern), re.IGNORECASE) except re.error as e: - raise base.CommandError("Invalid record name: %s", e) + raise base.CommandError(f"Invalid record name: {e}") for record_uid, record in orphaned_records.items(): if record_uid in records_to_unshare: diff --git a/keepercli-package/src/keepercli/helpers/password_utils.py b/keepercli-package/src/keepercli/helpers/password_utils.py index f1d723ab..a9bdb229 100644 --- a/keepercli-package/src/keepercli/helpers/password_utils.py +++ b/keepercli-package/src/keepercli/helpers/password_utils.py @@ -5,7 +5,7 @@ """ import dataclasses -from typing import Optional, List, Dict, Any, Union, Iterator, Tuple +from typing import Optional, List, Any from enum import Enum import os diff --git a/keepercli-package/src/keepercli/helpers/record_utils.py b/keepercli-package/src/keepercli/helpers/record_utils.py index d6d772e2..b52e9dad 100644 --- a/keepercli-package/src/keepercli/helpers/record_utils.py +++ b/keepercli-package/src/keepercli/helpers/record_utils.py @@ -111,7 +111,7 @@ def process_external_share(context: KeeperParams, expiration_period: timedelta, url = urlunparse(( 'https', - context.server, + context.auth.keeper_endpoint.server, '/vault/share', None, None, @@ -186,4 +186,4 @@ def resolve_record(context: KeeperParams, name: str) -> str: if r.title.lower() == name.lower(): return uid if record_uid is None: - raise CommandError('one-time-share', f'Record not found: {name}') + raise CommandError(f'Record not found: {name}') diff --git a/keepercli-package/src/keepercli/helpers/share_utils.py b/keepercli-package/src/keepercli/helpers/share_utils.py index 498c7b98..37baed7b 100644 --- a/keepercli-package/src/keepercli/helpers/share_utils.py +++ b/keepercli-package/src/keepercli/helpers/share_utils.py @@ -841,9 +841,6 @@ def get_share_admins_for_shared_folder(vault: vault_online.VaultOnline, shared_f def get_folder_uids(context: KeeperParams, name: str) -> set[str]: folder_uids = set() - if not context.vault or not context.vault.vault_data: - return folder_uids - if name in context.vault.vault_data._folders: folder_uids.add(name) return folder_uids @@ -863,9 +860,10 @@ def get_folder_uids(context: KeeperParams, name: str) -> set[str]: return folder_uids -def get_contained_record_uids(vault: vault_online.VaultOnline, name: str, children_only: bool = True) -> Dict[str, Set[str]]: +def get_contained_record_uids(context: KeeperParams, name: str, children_only: bool = True) -> Dict[str, Set[str]]: records_by_folder = dict() - root_folder_uids = get_folder_uids(vault, name) + root_folder_uids = get_folder_uids(context, name) + vault = context.vault def add_child_recs(f_uid): folder = vault.vault_data.get_folder(f_uid) diff --git a/keepersdk-package/src/keepersdk/authentication/keeper_auth.py b/keepersdk-package/src/keepersdk/authentication/keeper_auth.py index 15fff263..4b3d560e 100644 --- a/keepersdk-package/src/keepersdk/authentication/keeper_auth.py +++ b/keepersdk-package/src/keepersdk/authentication/keeper_auth.py @@ -11,7 +11,6 @@ from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey, EllipticCurvePublicKey from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey from google.protobuf.json_format import MessageToJson, MessageToDict -from urllib3.util import url from . import endpoint, notifications from .. import errors, utils, crypto From 7a9859d7e7d6a7fc37c5ffdf2f08a50ac2ad351e Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Mon, 1 Dec 2025 10:42:51 +0530 Subject: [PATCH 09/37] Added persistent-login example --- README.md | 132 ++++++++++++++++++++++++++--- keepersdk-package/README.md | 160 ++++++++++++++++++++++++++++++++---- 2 files changed, 263 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 7d7100f1..ea14a219 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,11 @@ The Keeper SDK uses a configuration storage system to manage authentication sett #### **Requirement for client** -If you are accessing keepersdk from a new device, you need to ensure that there is a config.json file present from which the sdk reads credentials. This ensures that the client doesn't contain any hardcoded credentials. Create the .json file in .keeper folder of current user, you might need to create a .keeper folder. A sample showing the structure of the config.json needed is shown below: +If you are accessing keepersdk from a new device, you need to ensure that there is a config.json file present from which the sdk reads credentials. This ensures that the client doesn't contain any hardcoded credentials. Create the .json file in .keeper folder of current user, you might need to create a .keeper folder. + +Alternatively you can run the sample login script to give username and password during execution as an alternate to keeping it stored. This will turn on persistent login and would not require re-login for the timeout duration. + +A sample showing the structure of the config.json needed is shown below: ``` { @@ -170,25 +174,127 @@ If you are accessing keepersdk from a new device, you need to ensure that there } ``` +### SDK Persistent Login Flow +The persistent login flow allows you to authenticate once and remain logged in for a specified timeout period without requiring session refresh. This is particularly useful for automated scripts and long-running applications. + +**Key Features:** +- **One-time setup**: Configure persistent login on a new device with a single execution +- **Automatic session management**: No need to re-authenticate during the timeout period +- **Configurable timeout**: Default is 30 days, but can be customized +- **Device registration**: Registers the device's data key for secure authentication + +**When to Use:** +- Automated scripts and background services +- Long-running applications that need continuous access +- Development and testing environments +- Applications where user interaction is not always possible + +**Important Notes:** +- Persistent login must be enabled on first-time device setup +- The device data key registration is a one-time operation per device +- Enterprise policies may restrict persistent login usage +- Always follow your organization's security policies when using persistent login + +**Example: Setting Up Persistent Login** + +This example demonstrates how to enable persistent login on a new device. Run this script once to configure persistent login for subsequent sessions: + +```python +import getpass + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + +# Initialize configuration and authentication context +config = configuration.JsonConfigurationStorage() +if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + + config.get().last_server = server +else: + server = config.get().last_server +keeper_endpoint = endpoint.KeeperEndpoint(config, server) +login_auth_context = login_auth.LoginAuth(keeper_endpoint) + +# Authenticate user +username = None +if config.get().last_login: + username = config.get().last_login +if not username: + username = input('Enter username: ') +login_auth_context.resume_session = True +login_auth_context.login(username) + +while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + +# Check if login was successful +if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + # Obtain authenticated session + keeper_auth_context = login_auth_context.login_step.take_keeper_auth() + + # Enable persistent login and register data key for device for the first time + keeper_auth.set_user_setting(keeper_auth_context, 'persistent_login', '1') + keeper_auth.register_data_key_for_device(keeper_auth_context) + mins_per_day = 60*24 + timeout_in_minutes = mins_per_day*30 # 30 days + keeper_auth.set_user_setting(keeper_auth_context, 'logout_timer', str(timeout_in_minutes)) + + print("Persistent login turned on successfully and device registered") + + keeper_auth_context.close() +``` + ### SDK Usage Example Below is a complete example demonstrating authentication, vault synchronization, and record retrieval: ```python -import sqlite3 import getpass +import sqlite3 from keepersdk.authentication import login_auth, configuration, endpoint from keepersdk.vault import sqlite_storage, vault_online, vault_record # Initialize configuration and authentication context config = configuration.JsonConfigurationStorage() -keeper_endpoint = endpoint.KeeperEndpoint(config) +if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + + config.get().last_server = server +else: + server = config.get().last_server +keeper_endpoint = endpoint.KeeperEndpoint(config, server) login_auth_context = login_auth.LoginAuth(keeper_endpoint) # Authenticate user -login_auth_context.login(config.get().users()[0].username, config.get().users()[0].password) - +username = None +if config.get().last_login: + username = config.get().last_login +if not username: + username = input('Enter username: ') +login_auth_context.resume_session = True +login_auth_context.login(username) + +logged_in_with_persistent = True while not login_auth_context.login_step.is_final(): if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) @@ -202,24 +308,28 @@ while not login_auth_context.login_step.is_final(): code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') login_auth_context.login_step.send_code(channel.channel_uid, code) else: - raise NotImplementedError() + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + +if logged_in_with_persistent: + print("Succesfully logged in with persistent login") # Check if login was successful if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): # Obtain authenticated session - keeper_auth = login_auth_context.login_step.take_keeper_auth() + keeper_auth_context = login_auth_context.login_step.take_keeper_auth() # Set up vault storage (using SQLite in-memory database) conn = sqlite3.Connection('file::memory:', uri=True) vault_storage = sqlite_storage.SqliteVaultStorage( lambda: conn, - vault_owner=bytes(keeper_auth.auth_context.username, 'utf-8') + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') ) # Initialize vault and synchronize with Keeper servers - vault = vault_online.VaultOnline(keeper_auth, vault_storage) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) vault.sync_down() - + # Access and display vault records print("Vault Records:") print("-" * 50) @@ -239,7 +349,7 @@ if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): print("-" * 50) vault.close() - keeper_auth.close() + keeper_auth_context.close() ``` **Important Security Notes:** diff --git a/keepersdk-package/README.md b/keepersdk-package/README.md index fbec39de..11172619 100644 --- a/keepersdk-package/README.md +++ b/keepersdk-package/README.md @@ -10,7 +10,6 @@ The Keeper SDK for Python provides developers with a comprehensive toolkit for integrating Keeper Security's password management and secrets management capabilities into Python applications. This repository contains two primary packages: - **Keeper SDK (`keepersdk`)**: A Python library for programmatic access to Keeper Vault, enabling developers to build custom integrations, automate password management workflows, and manage enterprise console operations. -- **Keeper CLI (`keepercli`)**: A modern command-line interface for interacting with Keeper Vault and Enterprise Console, offering efficient commands for vault management, enterprise administration, and automation tasks. ## Table of Contents @@ -20,11 +19,6 @@ The Keeper SDK for Python provides developers with a comprehensive toolkit for i - [SDK Environment Setup](#sdk-environment-setup) - [SDK Configuration](#sdk-configuration) - [SDK Usage Example](#sdk-usage-example) -- [Keeper CLI](#keeper-cli) - - [CLI Installation](#cli-installation) - - [CLI Environment Setup](#cli-environment-setup) - - [CLI Usage](#cli-usage) -- [Development Setup](#development-setup) - [Contributing](#contributing) - [License](#license) @@ -32,7 +26,7 @@ The Keeper SDK for Python provides developers with a comprehensive toolkit for i ## Prerequisites -Before installing the Keeper SDK or CLI, ensure your system meets the following requirements: +Before installing the Keeper SDK, ensure your system meets the following requirements: - **Python Version**: Python 3.10 or higher - **Operating System**: Windows, macOS, or Linux @@ -129,7 +123,11 @@ The Keeper SDK uses a configuration storage system to manage authentication sett #### **Requirement for client** -If you are accessing keepersdk from a new device, you need to ensure that there is a config.json file present from which the sdk reads credentials. This ensures that the client doesn't contain any hardcoded credentials. Create the .json file in .keeper folder of current user, you might need to create a .keeper folder. A sample showing the structure of the config.json needed is shown below: +If you are accessing keepersdk from a new device, you need to ensure that there is a config.json file present from which the sdk reads credentials. This ensures that the client doesn't contain any hardcoded credentials. Create the .json file in .keeper folder of current user, you might need to create a .keeper folder. + +Alternatively you can run the sample login script to give username and password during execution as an alternate to keeping it stored. This will turn on persistent login and would not require re-login for the timeout duration. + +A sample showing the structure of the config.json needed is shown below: ``` { @@ -165,26 +163,128 @@ If you are accessing keepersdk from a new device, you need to ensure that there "last_server": "keepersecurity.com" } ``` +### SDK Persistent Login Flow +The persistent login flow allows you to authenticate once and remain logged in for a specified timeout period without requiring session refresh. This is particularly useful for automated scripts and long-running applications. + +**Key Features:** +- **One-time setup**: Configure persistent login on a new device with a single execution +- **Automatic session management**: No need to re-authenticate during the timeout period +- **Configurable timeout**: Default is 30 days, but can be customized +- **Device registration**: Registers the device's data key for secure authentication + +**When to Use:** +- Automated scripts and background services +- Long-running applications that need continuous access +- Development and testing environments +- Applications where user interaction is not always possible + +**Important Notes:** +- Persistent login must be enabled on first-time device setup +- The device data key registration is a one-time operation per device +- Enterprise policies may restrict persistent login usage +- Always follow your organization's security policies when using persistent login + +**Example: Setting Up Persistent Login** + +This example demonstrates how to enable persistent login on a new device. Run this script once to configure persistent login for subsequent sessions: + +```python +import logging +import getpass + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + +# Initialize configuration and authentication context +config = configuration.JsonConfigurationStorage() +if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + + config.get().last_server = server +else: + server = config.get().last_server +keeper_endpoint = endpoint.KeeperEndpoint(config, server) +login_auth_context = login_auth.LoginAuth(keeper_endpoint) + +# Authenticate user +username = None +if config.get().last_login: + username = config.get().last_login +if not username: + username = input('Enter username: ') +login_auth_context.resume_session = True +login_auth_context.login(username) + +while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + +# Check if login was successful +if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + # Obtain authenticated session + keeper_auth_context = login_auth_context.login_step.take_keeper_auth() + + # Enable persistent login and register data key for device for the first time + keeper_auth.set_user_setting(keeper_auth_context, 'persistent_login', '1') + keeper_auth.register_data_key_for_device(keeper_auth_context) + mins_per_day = 60*24 + timeout_in_minutes = mins_per_day*30 # 30 days + keeper_auth.set_user_setting(keeper_auth_context, 'logout_timer', str(timeout_in_minutes)) + + print("Persistent login turned on successfully and device registered") + + keeper_auth_context.close() +``` ### SDK Usage Example Below is a complete example demonstrating authentication, vault synchronization, and record retrieval: ```python -import sqlite3 import getpass +import sqlite3 from keepersdk.authentication import login_auth, configuration, endpoint from keepersdk.vault import sqlite_storage, vault_online, vault_record # Initialize configuration and authentication context config = configuration.JsonConfigurationStorage() -keeper_endpoint = endpoint.KeeperEndpoint(config) +if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + + config.get().last_server = server +else: + server = config.get().last_server +keeper_endpoint = endpoint.KeeperEndpoint(config, server) login_auth_context = login_auth.LoginAuth(keeper_endpoint) # Authenticate user -login_auth_context.login(config.get().users()[0].username, config.get().users()[0].password) - +username = None +if config.get().last_login: + username = config.get().last_login +if not username: + username = input('Enter username: ') +login_auth_context.resume_session = True +login_auth_context.login(username) + +logged_in_with_persistent = True while not login_auth_context.login_step.is_final(): if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) @@ -198,24 +298,28 @@ while not login_auth_context.login_step.is_final(): code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') login_auth_context.login_step.send_code(channel.channel_uid, code) else: - raise NotImplementedError() + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + +if logged_in_with_persistent: + print("Succesfully logged in with persistent login") # Check if login was successful if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): # Obtain authenticated session - keeper_auth = login_auth_context.login_step.take_keeper_auth() + keeper_auth_context = login_auth_context.login_step.take_keeper_auth() # Set up vault storage (using SQLite in-memory database) conn = sqlite3.Connection('file::memory:', uri=True) vault_storage = sqlite_storage.SqliteVaultStorage( lambda: conn, - vault_owner=bytes(keeper_auth.auth_context.username, 'utf-8') + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') ) # Initialize vault and synchronize with Keeper servers - vault = vault_online.VaultOnline(keeper_auth, vault_storage) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) vault.sync_down() - + # Access and display vault records print("Vault Records:") print("-" * 50) @@ -235,7 +339,7 @@ if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): print("-" * 50) vault.close() - keeper_auth.close() + keeper_auth_context.close() ``` **Important Security Notes:** @@ -245,3 +349,23 @@ if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): - Consider using environment variables or secure vaults for credential management --- + +## Contributing + +We welcome contributions from the community! Please feel free to submit pull requests, report issues, or suggest enhancements through our [GitHub repository](https://github.com/Keeper-Security/keeper-sdk-python). + +--- + +## License + +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. + +--- + +## Support + +For support, documentation, and additional resources: + +- **Documentation**: [Keeper Security Developer Portal](https://docs.keeper.io/) +- **Support**: [Keeper Security Support](https://www.keepersecurity.com/support.html) +- **Community**: [Keeper Security GitHub](https://github.com/Keeper-Security) \ No newline at end of file From 2f5d9a6238f1c2e96ef67b4636e719ef2e144525 Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Mon, 1 Dec 2025 10:52:33 +0530 Subject: [PATCH 10/37] changes --- README.md | 1 + keepersdk-package/README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index ea14a219..91ba1ec2 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,7 @@ import getpass import sqlite3 from keepersdk.authentication import login_auth, configuration, endpoint +from keepersdk.constants import KEEPER_PUBLIC_HOSTS from keepersdk.vault import sqlite_storage, vault_online, vault_record # Initialize configuration and authentication context diff --git a/keepersdk-package/README.md b/keepersdk-package/README.md index 11172619..785b4723 100644 --- a/keepersdk-package/README.md +++ b/keepersdk-package/README.md @@ -259,6 +259,7 @@ import getpass import sqlite3 from keepersdk.authentication import login_auth, configuration, endpoint +from keepersdk.constants import KEEPER_PUBLIC_HOSTS from keepersdk.vault import sqlite_storage, vault_online, vault_record # Initialize configuration and authentication context From c3e85363b5802f0be53e57499416b062480764fa Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Mon, 8 Dec 2025 19:25:35 +0530 Subject: [PATCH 11/37] Python SDK bugfixes --- .../keepercli/commands/account_commands.py | 6 +- .../src/keepercli/commands/audit_alert.py | 23 +++--- .../src/keepercli/commands/audit_log.py | 6 +- .../src/keepercli/commands/audit_report.py | 4 +- .../src/keepercli/commands/base.py | 74 ++++++++++++++++++- .../src/keepercli/commands/breachwatch.py | 6 +- .../commands/enterprise_create_user.py | 4 +- .../src/keepercli/commands/enterprise_info.py | 29 ++++++-- .../src/keepercli/commands/enterprise_node.py | 24 +++--- .../src/keepercli/commands/enterprise_role.py | 23 +++--- .../src/keepercli/commands/enterprise_team.py | 21 +++--- .../src/keepercli/commands/enterprise_user.py | 24 +++--- .../keepercli/commands/enterprise_utils.py | 3 +- .../keepercli/commands/importer_commands.py | 22 +++--- .../src/keepercli/commands/msp.py | 4 +- .../src/keepercli/commands/notification.py | 13 ++-- .../src/keepercli/commands/pedm_admin.py | 9 +++ .../src/keepercli/commands/pedm_aram.py | 14 ++-- .../src/keepercli/commands/record_edit.py | 6 +- .../commands/record_handling_commands.py | 10 +-- .../src/keepercli/commands/register.py | 3 +- .../src/keepercli/commands/secrets_manager.py | 32 ++++++-- .../keepercli/commands/share_management.py | 2 +- .../src/keepercli/commands/vault.py | 2 +- .../src/keepercli/commands/vault_folder.py | 24 ++++-- .../src/keepercli/commands/vault_record.py | 9 ++- .../src/keepercli/helpers/folder_utils.py | 4 +- .../src/keepercli/helpers/record_utils.py | 6 +- .../keepersdk/enterprise/batch_management.py | 6 +- 29 files changed, 275 insertions(+), 138 deletions(-) diff --git a/keepercli-package/src/keepercli/commands/account_commands.py b/keepercli-package/src/keepercli/commands/account_commands.py index a4fd919d..347a2ee4 100644 --- a/keepercli-package/src/keepercli/commands/account_commands.py +++ b/keepercli-package/src/keepercli/commands/account_commands.py @@ -81,7 +81,7 @@ def __init__(self): super().__init__(ThisDeviceCommand.this_device_parser) def execute(self, context: params.KeeperParams, **kwargs): - assert context.auth is not None + base.require_login(context) logger = api.get_logger() ops = kwargs.get('ops') if not isinstance(ops, list): @@ -170,7 +170,7 @@ def execute(self, context: params.KeeperParams, **kwargs): @staticmethod def is_persistent_login_disabled(context: params.KeeperParams) -> bool: - assert context.auth is not None + base.require_login(context) enforcements = context.auth.auth_context.enforcements if enforcements and 'booleans' in enforcements: return next( @@ -181,7 +181,7 @@ def is_persistent_login_disabled(context: params.KeeperParams) -> bool: @staticmethod def get_account_summary_and_this_device(context: params.KeeperParams) \ -> Tuple[AccountSummary_pb2.AccountSummaryElements, AccountSummary_pb2.DeviceInfo]: - assert context.auth is not None + base.require_login(context) acct_summary = keeper_auth.load_account_summary(context.auth) devices = acct_summary.devices current_device_token = context.auth.auth_context.device_token diff --git a/keepercli-package/src/keepercli/commands/audit_alert.py b/keepercli-package/src/keepercli/commands/audit_alert.py index ff11307b..2e4d45a4 100644 --- a/keepercli-package/src/keepercli/commands/audit_alert.py +++ b/keepercli-package/src/keepercli/commands/audit_alert.py @@ -176,8 +176,9 @@ def get_alert_configuration(auth: keeper_auth.KeeperAuth, alert_name: Any) -> Di @staticmethod def apply_alert_options(context: KeeperParams, alert: Dict[str, Any], **kwargs) -> None: - assert context.enterprise_data - assert context.vault + base.require_enterprise_admin(context) + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') alert_name = kwargs.get('name') if alert_name: @@ -271,7 +272,7 @@ def __init__(self) -> None: super().__init__(parser) def execute(self, context: KeeperParams, **kwargs) -> Any: - assert context.auth + base.require_login(context) alerts = self.load_settings(context.auth, kwargs.get('reload') or False) if not isinstance(alerts, dict): raise base.CommandError('No alerts found') @@ -325,8 +326,8 @@ def __init__(self) -> None: super().__init__(parser) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.auth - assert context.enterprise_data + base.require_login(context) + base.require_enterprise_admin(context) show_recipient = True show_filter = True @@ -416,7 +417,7 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs) -> Any: - assert context.auth + base.require_login(context) alert = AuditSettingMixin.get_alert_configuration(context.auth, kwargs.get('target')) @@ -451,7 +452,7 @@ def __init__(self): description='Delete audit alert.') super().__init__(parser) def execute(self, context: KeeperParams, **kwargs) -> Any: - assert context.auth + base.require_login(context) alert = AuditSettingMixin.get_alert_configuration(context.auth, kwargs.get('target')) if not alert: @@ -490,7 +491,7 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs) -> Any: - assert context.auth + base.require_login(context) name = kwargs.get('name') if not name: raise base.CommandError('Alert name is required parameter') @@ -549,7 +550,7 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs) -> Any: - assert context.auth + base.require_login(context) alert = AuditSettingMixin.get_alert_configuration(context.auth, kwargs.get('target')) self.apply_alert_options(context, alert, **kwargs) @@ -592,7 +593,7 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs) -> Any: - assert context.auth + base.require_login(context) alert = AuditSettingMixin.get_alert_configuration(context.auth, kwargs.get('target')) rq = { 'command': 'put_enterprise_setting', @@ -648,7 +649,7 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs) -> Any: - assert context.auth + base.require_login(context) alert = AuditSettingMixin.get_alert_configuration(context.auth, kwargs.get('target')) action = kwargs.get('action') skip_update = False diff --git a/keepercli-package/src/keepercli/commands/audit_log.py b/keepercli-package/src/keepercli/commands/audit_log.py index 78a81d0c..def25b47 100644 --- a/keepercli-package/src/keepercli/commands/audit_log.py +++ b/keepercli-package/src/keepercli/commands/audit_log.py @@ -7,7 +7,7 @@ from keepersdk.vault import vault_record, record_management from .. import api -from .base import CommandError, ArgparseCommand +from .base import CommandError, ArgparseCommand, require_enterprise_admin, require_login from ..params import KeeperParams from ..prompt_utils import user_choice @@ -488,8 +488,8 @@ def execute(self, context: KeeperParams, **kwargs): def _validate_context(self, context: KeeperParams) -> None: """Validate that required context components are available.""" - assert context.auth - assert context.enterprise_data + require_login(context) + require_enterprise_admin(context) def _validate_target(self, target: Optional[str]) -> str: """Validate and return the target format.""" diff --git a/keepercli-package/src/keepercli/commands/audit_report.py b/keepercli-package/src/keepercli/commands/audit_report.py index bc39a734..9826c613 100644 --- a/keepercli-package/src/keepercli/commands/audit_report.py +++ b/keepercli-package/src/keepercli/commands/audit_report.py @@ -92,8 +92,8 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs) -> Any: - assert context.auth - assert context.enterprise_data + base.require_login(context) + base.require_enterprise_admin(context) report_type = kwargs.get('report_type') if kwargs.get('syntax_help') is True or not report_type: diff --git a/keepercli-package/src/keepercli/commands/base.py b/keepercli-package/src/keepercli/commands/base.py index 636ce4c4..bb40cc45 100644 --- a/keepercli-package/src/keepercli/commands/base.py +++ b/keepercli-package/src/keepercli/commands/base.py @@ -38,6 +38,18 @@ def __str__(self): return super().__str__() +def require_login(context: KeeperParams): + """Check if the user is logged in. Raises CommandError if not.""" + if context.auth is None: + raise CommandError('Please login first to access these commands') + + +def require_enterprise_admin(context: KeeperParams): + """Check if the user has enterprise admin privileges. Raises CommandError if not.""" + if context.enterprise_data is None: + raise CommandError('This command requires enterprise admin privileges. Please login with an admin account.') + + class ICliCommand(abc.ABC): @abc.abstractmethod def execute_args(self, context: KeeperParams, args: str, **kwargs): @@ -134,14 +146,74 @@ def __init__(self, parser: argparse.ArgumentParser): def execute(self, context: KeeperParams, **kwargs): pass + def _get_all_option_strings(self, parser: argparse.ArgumentParser) -> set: + """Get all valid option strings from the parser and its parents.""" + options = set() + for action in parser._actions: + for opt in action.option_strings: + options.add(opt) + return options + + def _validate_strict_options(self, arg_list: list, valid_options: set) -> None: + """Validate that all option-like arguments are recognized. + + Raises ParseError if: + - An abbreviated long option is used (e.g., --len for --length) + - An unrecognized option is used (e.g., --leo, --xyz) + - An unrecognized short option is used + """ + long_options = {opt for opt in valid_options if opt.startswith('--')} + short_options = {opt for opt in valid_options if opt.startswith('-') and not opt.startswith('--')} + + for arg in arg_list: + if arg.startswith('--'): + opt_name = arg.split('=')[0] if '=' in arg else arg + + if opt_name not in long_options: + matches = [opt for opt in long_options if opt.startswith(opt_name)] + if matches: + raise ParseError( + f'unrecognized argument: {arg} (did you mean {matches[0]}?)' + ) + else: + raise ParseError(f'unrecognized argument: {arg}') + + elif arg.startswith('-') and len(arg) > 1: + + if arg[1].isdigit() or (arg[1] == '.' and len(arg) > 2): + continue + + opt_part = arg.split('=')[0] if '=' in arg else arg + + if opt_part in short_options: + continue + + matched = False + for valid_opt in short_options: + if opt_part.startswith(valid_opt): + matched = True + break + + if not matched: + raise ParseError(f'unrecognized argument: {arg}') + def execute_args(self, context: KeeperParams, args, **kwargs): d = {} d.update(kwargs) self.extra_parameters = '' parser = self.get_parser() try: - opts, extra_args = parser.parse_known_args(shlex.split(args)) + arg_list = shlex.split(args) + + valid_options = self._get_all_option_strings(parser) + self._validate_strict_options(arg_list, valid_options) + + opts, extra_args = parser.parse_known_args(arg_list) if extra_args: + + for extra in extra_args: + if extra.startswith('-') and len(extra) > 1 and not extra[1].isdigit(): + raise ParseError(f'unrecognized argument: {extra}') self.extra_parameters = ' '.join(extra_args) d.update(opts.__dict__) return self.execute(context, **d) diff --git a/keepercli-package/src/keepercli/commands/breachwatch.py b/keepercli-package/src/keepercli/commands/breachwatch.py index d9a34803..83f7e63b 100644 --- a/keepercli-package/src/keepercli/commands/breachwatch.py +++ b/keepercli-package/src/keepercli/commands/breachwatch.py @@ -43,7 +43,8 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs) -> Any: - assert context.vault + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') logger = api.get_logger() owned_only = kwargs.get('owned') is True record_uids = {x.record_uid for x in context.vault.vault_data.breach_watch_records() if x.status in (client_pb2.BWStatus.WEAK, client_pb2.BWStatus.BREACHED)} @@ -84,7 +85,8 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs) -> Any: - assert context.vault + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') vault = context.vault # Parse and resolve record names to UIDs diff --git a/keepercli-package/src/keepercli/commands/enterprise_create_user.py b/keepercli-package/src/keepercli/commands/enterprise_create_user.py index fb477cfe..df53c752 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_create_user.py +++ b/keepercli-package/src/keepercli/commands/enterprise_create_user.py @@ -236,8 +236,8 @@ def execute(self, context: KeeperParams, **kwargs): def _validate_context(self, context: KeeperParams) -> None: """Validate that required context data is available.""" - assert context.enterprise_data is not None - assert context.auth is not None + base.require_login(context) + base.require_enterprise_admin(context) def _create_user( self, diff --git a/keepercli-package/src/keepercli/commands/enterprise_info.py b/keepercli-package/src/keepercli/commands/enterprise_info.py index cd46be21..0676d964 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_info.py +++ b/keepercli-package/src/keepercli/commands/enterprise_info.py @@ -57,8 +57,8 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs): - assert context.enterprise_data is not None - assert context.auth is not None + base.require_login(context) + base.require_enterprise_admin(context) enterprise_data = context.enterprise_data logger = api.get_logger() @@ -84,7 +84,20 @@ def execute(self, context: KeeperParams, **kwargs): root_node = enterprise_utils.NodeUtils.resolve_single_node(enterprise_data, subnode) logger.info('Output is limited to \"%s\" node', subnode) - managed_nodes = enterprise_utils.EnterpriseMixin.filter_managed_nodes(enterprise_data, managed_nodes, root_nodes[0]) + filtered_nodes = enterprise_utils.EnterpriseMixin.filter_managed_nodes(enterprise_data, managed_nodes, root_node.node_id) + + if root_node.node_id in filtered_nodes: + managed_nodes = {root_node.node_id: filtered_nodes[root_node.node_id]} + else: + + nodes_to_expand = [root_node.node_id] + pos = 0 + while pos < len(nodes_to_expand): + n_id = nodes_to_expand[pos] + pos += 1 + if n_id in subnodes: + nodes_to_expand.extend(subnodes[n_id]) + managed_nodes = {root_node.node_id: set(nodes_to_expand)} accessible_nodes.clear() for node_id, node_ids in managed_nodes.items(): @@ -223,7 +236,7 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs): - assert context.enterprise_data is not None + base.require_enterprise_admin(context) enterprise_data = context.enterprise_data columns: Set[str] = set() @@ -349,7 +362,7 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs): - assert context.enterprise_data is not None + base.require_enterprise_admin(context) enterprise_data = context.enterprise_data columns: Set[str] = set() @@ -493,7 +506,7 @@ def restricts(team: enterprise_types.Team) -> str: return rs def execute(self, context: KeeperParams, **kwargs): - assert context.enterprise_data is not None + base.require_enterprise_admin(context) enterprise_data = context.enterprise_data columns: Set[str] = set() @@ -626,7 +639,7 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs): - assert context.enterprise_data is not None + base.require_enterprise_admin(context) enterprise_data = context.enterprise_data columns: Set[str] = set() @@ -718,7 +731,7 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs): - assert context.enterprise_data is not None + base.require_enterprise_admin(context) enterprise_data = context.enterprise_data pattern = (kwargs.get('pattern') or '').lower() diff --git a/keepercli-package/src/keepercli/commands/enterprise_node.py b/keepercli-package/src/keepercli/commands/enterprise_node.py index d06b9a34..947e46dd 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_node.py +++ b/keepercli-package/src/keepercli/commands/enterprise_node.py @@ -36,8 +36,9 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs) -> Any: - assert context.enterprise_data is not None - assert context.vault + base.require_enterprise_admin(context) + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') verbose = kwargs.get('verbose') is True @@ -246,9 +247,8 @@ def warning(self, message: str) -> None: self.logger.warning(message) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.auth is not None - assert context.enterprise_loader is not None - assert context.enterprise_data is not None + base.require_login(context) + base.require_enterprise_admin(context) parent_id: Optional[int] if kwargs.get('parent'): @@ -308,7 +308,7 @@ def warning(self, message: str) -> None: self.logger.warning(message) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.enterprise_data is not None + base.require_enterprise_admin(context) node_list = enterprise_utils.NodeUtils.resolve_existing_nodes(context.enterprise_data, kwargs.get('node')) parent_id: Optional[int] = None if kwargs.get('parent'): @@ -339,7 +339,7 @@ def warning(self, message: str) -> None: self.logger.warning(message) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.enterprise_data is not None + base.require_enterprise_admin(context) node_list = enterprise_utils.NodeUtils.resolve_existing_nodes(context.enterprise_data, kwargs.get('node')) depths: Dict[int, int] = {} @@ -416,8 +416,8 @@ def set_logo(auth: keeper_auth.KeeperAuth, node_id: int, logo_fp: str, logo_type raise Exception(f'HTTP status code: {upload_rs.status_code}, expected {success_status_code}') def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.enterprise_data is not None - assert context.auth is not None + base.require_login(context) + base.require_enterprise_admin(context) node = enterprise_utils.NodeUtils.resolve_single_node(context.enterprise_data, kwargs.get('node')) logo_file = kwargs.get('logo_file') @@ -447,8 +447,8 @@ def warning(self, message: str) -> None: self.logger.warning(message) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.enterprise_data is not None - assert context.auth is not None + base.require_login(context) + base.require_enterprise_admin(context) node = enterprise_utils.NodeUtils.resolve_single_node(context.enterprise_data, kwargs.get('node')) email_template = kwargs.get('invite_email') @@ -582,7 +582,7 @@ def warning(self, message: str) -> None: self.logger.warning(message) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.enterprise_data is not None + base.require_enterprise_admin(context) node = enterprise_utils.NodeUtils.resolve_single_node(context.enterprise_data, kwargs.get('node')) if node.node_id == context.enterprise_data.root_node.node_id: diff --git a/keepercli-package/src/keepercli/commands/enterprise_role.py b/keepercli-package/src/keepercli/commands/enterprise_role.py index 65a2441c..b366c9f0 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_role.py +++ b/keepercli-package/src/keepercli/commands/enterprise_role.py @@ -30,8 +30,9 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs) -> Any: - assert context.enterprise_data is not None - assert context.vault + base.require_enterprise_admin(context) + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') verbose = kwargs.get('verbose') is True @@ -253,9 +254,8 @@ def warning(self, message: str) -> None: self.logger.warning(message) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.auth is not None - assert context.enterprise_loader is not None - assert context.enterprise_data is not None + base.require_login(context) + base.require_enterprise_admin(context) parent_id: Optional[int] if kwargs.get('parent'): @@ -336,9 +336,8 @@ def warning(self, message: str) -> None: self.logger.warning(message) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.auth is not None - assert context.enterprise_loader is not None - assert context.enterprise_data is not None + base.require_login(context) + base.require_enterprise_admin(context) role_list = enterprise_utils.RoleUtils.resolve_existing_roles(context.enterprise_data, kwargs.get('role')) role_name: Optional[str] = kwargs.get('displayname') @@ -395,7 +394,7 @@ def warning(self, message: str) -> None: self.logger.warning(message) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.enterprise_data is not None + base.require_enterprise_admin(context) role_list = enterprise_utils.RoleUtils.resolve_existing_roles(context.enterprise_data, kwargs.get('role')) batch = batch_management.BatchManagement(loader=context.enterprise_loader, logger=self) @@ -418,7 +417,7 @@ def warning(self, message: str) -> None: self.logger.warning(message) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.enterprise_data is not None + base.require_enterprise_admin(context) role = enterprise_utils.RoleUtils.resolve_single_role(context.enterprise_data, kwargs.get('role')) node = enterprise_utils.NodeUtils.resolve_single_node(context.enterprise_data, kwargs.get('node')) @@ -454,7 +453,7 @@ def warning(self, message: str) -> None: self.logger.warning(message) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.enterprise_data is not None + base.require_enterprise_admin(context) role_list = enterprise_utils.RoleUtils.resolve_existing_roles(context.enterprise_data, kwargs.get('role')) users_to_add: Optional[List[enterprise_types.User]] = None @@ -531,7 +530,7 @@ def warning(self, message: str) -> None: self.logger.warning(message) def execute(self, context: KeeperParams, **kwargs): - assert context.enterprise_data is not None + base.require_enterprise_admin(context) role = enterprise_utils.RoleUtils.resolve_single_role(context.enterprise_data, kwargs.get('role')) nodes_to_add: Optional[List[enterprise_types.Node]] = None diff --git a/keepercli-package/src/keepercli/commands/enterprise_team.py b/keepercli-package/src/keepercli/commands/enterprise_team.py index 5d04fec7..6aeaabfc 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_team.py +++ b/keepercli-package/src/keepercli/commands/enterprise_team.py @@ -31,8 +31,9 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs) -> Any: - assert context.enterprise_data is not None - assert context.vault + base.require_enterprise_admin(context) + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') verbose = kwargs.get('verbose') is True @@ -150,9 +151,8 @@ def warning(self, message: str) -> None: self.logger.warning(message) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.auth is not None - assert context.enterprise_loader is not None - assert context.enterprise_data is not None + base.require_login(context) + base.require_enterprise_admin(context) parent_id: Optional[int] if kwargs.get('parent'): @@ -240,9 +240,8 @@ def warning(self, message: str) -> None: self.logger.warning(message) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.auth is not None - assert context.enterprise_loader is not None - assert context.enterprise_data is not None + base.require_login(context) + base.require_enterprise_admin(context) team_list, missing_names = enterprise_utils.TeamUtils.resolve_existing_teams(context.enterprise_data, kwargs.get('team')) if isinstance(missing_names, list) and len(missing_names) > 0: @@ -261,7 +260,7 @@ def execute(self, context: KeeperParams, **kwargs) -> None: parent_node = enterprise_utils.NodeUtils.resolve_single_node(context.enterprise_data, kwargs.get('parent')) parent_id = parent_node.node_id else: - parent_id = context.enterprise_data.root_node.node_id + parent_id = team_list[0].node_id restrict_edit: Optional[bool] = None r_edit = kwargs.get('restrict_edit') @@ -296,7 +295,7 @@ def warning(self, message: str) -> None: self.logger.warning(message) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.enterprise_data is not None + base.require_enterprise_admin(context) team_list, missing_names = enterprise_utils.TeamUtils.resolve_existing_teams(context.enterprise_data, kwargs.get('team')) if isinstance(missing_names, list) and len(missing_names) > 0: @@ -322,7 +321,7 @@ def warning(self, message: str) -> None: self.logger.warning(message) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.enterprise_data is not None + base.require_enterprise_admin(context) team_list, missing_names = enterprise_utils.TeamUtils.resolve_existing_teams(context.enterprise_data, kwargs.get('team')) queued_team_list: List[enterprise_types.QueuedTeam] diff --git a/keepercli-package/src/keepercli/commands/enterprise_user.py b/keepercli-package/src/keepercli/commands/enterprise_user.py index c382be30..fe0e1506 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_user.py +++ b/keepercli-package/src/keepercli/commands/enterprise_user.py @@ -88,9 +88,7 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs) -> Any: - assert context.enterprise_data is not None - assert context.vault - assert context.auth + base.require_enterprise_admin(context) verbose = kwargs.get('verbose') is True @@ -244,9 +242,8 @@ def warning(self, message: str) -> None: self.logger.warning(message) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.auth is not None - assert context.enterprise_loader is not None - assert context.enterprise_data is not None + base.require_login(context) + base.require_enterprise_admin(context) parent_id: Optional[int] if kwargs.get('parent'): @@ -342,7 +339,7 @@ def warning(self, message: str) -> None: self.logger.warning(message) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.enterprise_data is not None + base.require_enterprise_admin(context) parent_id: Optional[int] if kwargs.get('parent'): @@ -464,7 +461,7 @@ def warning(self, message: str) -> None: self.logger.warning(message) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.enterprise_data is not None + base.require_enterprise_admin(context) users = enterprise_utils.UserUtils.resolve_existing_users(context.enterprise_data, kwargs.get('email')) if len(users) == 0: @@ -508,7 +505,7 @@ def warning(self, message: str) -> None: self.logger.warning(message) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.enterprise_data is not None + base.require_enterprise_admin(context) users = enterprise_utils.UserUtils.resolve_existing_users(context.enterprise_data, kwargs.get('email')) if len(users) == 0: @@ -552,8 +549,8 @@ def __init__(self): self.logger = api.get_logger() def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.enterprise_data is not None - assert context.auth + base.require_login(context) + base.require_enterprise_admin(context) user = enterprise_utils.UserUtils.resolve_single_user(context.enterprise_data, kwargs.get('email')) aliases = context.enterprise_data.user_aliases.get_links_by_subject(user.enterprise_user_id) @@ -628,9 +625,8 @@ def token_to_string(token: bytes) -> str: def execute(self, context: KeeperParams, **kwargs) -> None: """Main execution method for device approval command.""" - assert context.enterprise_data is not None - assert context.auth - assert context.enterprise_loader is not None + base.require_login(context) + base.require_enterprise_admin(context) if kwargs.get('reload'): context.enterprise_loader.load() diff --git a/keepercli-package/src/keepercli/commands/enterprise_utils.py b/keepercli-package/src/keepercli/commands/enterprise_utils.py index 03833bd5..41c6df9e 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_utils.py +++ b/keepercli-package/src/keepercli/commands/enterprise_utils.py @@ -307,7 +307,8 @@ def get_share_administrators(auth: keeper_auth.KeeperAuth, username: str) -> Lis rq = enterprise_pb2.GetSharingAdminsRequest() rq.username = username rs = auth.execute_auth_rest('enterprise/get_sharing_admins', rq, response_type=enterprise_pb2.GetSharingAdminsResponse) - assert rs is not None + if rs is None: + raise base.CommandError('This command requires enterprise admin privileges. Please login with an admin account.') return [x.email for x in rs.userProfileExts] diff --git a/keepercli-package/src/keepercli/commands/importer_commands.py b/keepercli-package/src/keepercli/commands/importer_commands.py index 4f503030..d2522a62 100644 --- a/keepercli-package/src/keepercli/commands/importer_commands.py +++ b/keepercli-package/src/keepercli/commands/importer_commands.py @@ -82,8 +82,9 @@ def __init__(self): super().__init__(parser) def execute(self, context: params.KeeperParams, **kwargs): - assert context.auth - assert context.vault + base.require_login(context) + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') if 'restrict_import' in context.auth.auth_context.enforcements: if context.auth.auth_context.enforcements.get('restrict_import') is True: raise base.CommandError('"import" is restricted by Keeper Administrator') @@ -160,8 +161,9 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs): - assert context.auth - assert context.vault + base.require_login(context) + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') logger = api.get_logger() @@ -368,8 +370,9 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs): - assert context.vault is not None - assert context.enterprise_data is not None + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') + base.require_enterprise_admin(context) source = kwargs.get('source') or 'keeper' file_name = kwargs.get('name') or 'shared_folder_membership.json' folders_only = kwargs.get('folders_only') is True @@ -496,9 +499,10 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs): - assert context.vault is not None - assert context.enterprise_data is not None - assert context.auth is not None + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') + base.require_login(context) + base.require_enterprise_admin(context) logger = api.get_logger() file_name = kwargs.get('name') or 'shared_folder_membership.json' diff --git a/keepercli-package/src/keepercli/commands/msp.py b/keepercli-package/src/keepercli/commands/msp.py index 11929568..2c7138a3 100644 --- a/keepercli-package/src/keepercli/commands/msp.py +++ b/keepercli-package/src/keepercli/commands/msp.py @@ -14,8 +14,8 @@ def __init__(self): super().__init__(SwitchToManagedCompanyCommand.parser) def execute(self, context: KeeperParams, **kwargs): - assert context.enterprise_data is not None - assert context.auth is not None + base.require_login(context) + base.require_enterprise_admin(context) logger = api.get_logger() mc_id = kwargs.get('mc_id') diff --git a/keepercli-package/src/keepercli/commands/notification.py b/keepercli-package/src/keepercli/commands/notification.py index b55ba31d..64bb517c 100644 --- a/keepercli-package/src/keepercli/commands/notification.py +++ b/keepercli-package/src/keepercli/commands/notification.py @@ -47,9 +47,11 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs): - assert context.auth is not None - assert context.vault is not None - assert context.auth.auth_context.ec_private_key is not None + base.require_login(context) + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') + if context.auth.auth_context.ec_private_key is None: + raise base.CommandError('EC private key is not present') is_read_only = kwargs.get('unread_only') is True storage = context.vault.vault_data.storage @@ -88,5 +90,6 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs): - assert context.auth is not None - assert context.vault is not None \ No newline at end of file + base.require_login(context) + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') diff --git a/keepercli-package/src/keepercli/commands/pedm_admin.py b/keepercli-package/src/keepercli/commands/pedm_admin.py index 91706b45..576609a0 100644 --- a/keepercli-package/src/keepercli/commands/pedm_admin.py +++ b/keepercli-package/src/keepercli/commands/pedm_admin.py @@ -175,6 +175,7 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs): + base.require_enterprise_admin(context) plugin = context.pedm_plugin plugin.sync_down(reload=kwargs.get('reload') is True) @@ -198,6 +199,7 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs): + base.require_enterprise_admin(context) plugin = context.pedm_plugin verbose = kwargs.get('verbose') is True @@ -235,6 +237,7 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs): + base.require_enterprise_admin(context) enterprise_data = context.enterprise_data assert enterprise_data is not None plugin = context.pedm_plugin @@ -285,6 +288,7 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs): + base.require_enterprise_admin(context) plugin = context.pedm_plugin deployment = PedmUtils.resolve_single_deployment(plugin, kwargs.get('deployment')) name = kwargs.get('name') @@ -328,6 +332,7 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs): + base.require_enterprise_admin(context) plugin = context.pedm_plugin deployment_names = kwargs.get('deployment') if isinstance(deployment_names, str): @@ -372,6 +377,7 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs): + base.require_enterprise_admin(context) auth = context.auth assert auth is not None enterprise_data = context.enterprise_data @@ -465,6 +471,7 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs) -> Any: + base.require_enterprise_admin(context) plugin = context.pedm_plugin agents = kwargs['agent'] @@ -498,6 +505,7 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs) -> Any: + base.require_enterprise_admin(context) plugin = context.pedm_plugin deployment_uid = kwargs.get('deployment') @@ -549,6 +557,7 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs) -> Any: + base.require_enterprise_admin(context) plugin = context.pedm_plugin verbose = kwargs.get('verbose') is True diff --git a/keepercli-package/src/keepercli/commands/pedm_aram.py b/keepercli-package/src/keepercli/commands/pedm_aram.py index abd99a34..9267f210 100644 --- a/keepercli-package/src/keepercli/commands/pedm_aram.py +++ b/keepercli-package/src/keepercli/commands/pedm_aram.py @@ -183,8 +183,8 @@ def execute(self, context: KeeperParams, **kwargs) -> Any: print_formatted_text(HTML(audit_column_description)) return - assert context.enterprise_data is not None - assert context.auth is not None + base.require_login(context) + base.require_enterprise_admin(context) auth = context.auth AuditMixin.load_audit_metadata(auth) @@ -292,8 +292,8 @@ def execute(self, context: KeeperParams, **kwargs) -> Any: if kwargs.get("syntax_help") is True: print_formatted_text(HTML(audit_report_description)) return - assert context.enterprise_data is not None - assert context.auth is not None + base.require_login(context) + base.require_enterprise_admin(context) auth = context.auth AuditMixin.load_audit_metadata(auth) @@ -439,8 +439,8 @@ def execute(self, context: KeeperParams, **kwargs) -> Any: if kwargs.get("syntax_help") is True: return audit_summary_report_description - assert context.enterprise_data is not None - assert context.auth is not None + base.require_login(context) + base.require_enterprise_admin(context) auth = context.auth AuditMixin.load_audit_metadata(auth) @@ -529,8 +529,8 @@ def __init__(self): super().__init__(parser) def execute(self, context: KeeperParams, **kwargs) -> Any: + base.require_login(context) plugin = context.pedm_plugin - assert context.auth is not None auth = context.auth is_summary = kwargs.get('summary') is True diff --git a/keepercli-package/src/keepercli/commands/record_edit.py b/keepercli-package/src/keepercli/commands/record_edit.py index 928c688a..b80e9f76 100644 --- a/keepercli-package/src/keepercli/commands/record_edit.py +++ b/keepercli-package/src/keepercli/commands/record_edit.py @@ -630,7 +630,8 @@ def __init__(self): super().__init__(RecordAddCommand.parser) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.vault is not None + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') if kwargs.get('syntax_help') is True: prompt_utils.output_text(record_fields_description) return @@ -739,7 +740,8 @@ def execute(self, context: KeeperParams, **kwargs) -> None: prompt_utils.output_text(record_fields_description) return - assert context.vault is not None + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') self.warnings.clear() record_name = kwargs.get('record') if not record_name: diff --git a/keepercli-package/src/keepercli/commands/record_handling_commands.py b/keepercli-package/src/keepercli/commands/record_handling_commands.py index cb252320..fe6b4155 100644 --- a/keepercli-package/src/keepercli/commands/record_handling_commands.py +++ b/keepercli-package/src/keepercli/commands/record_handling_commands.py @@ -505,7 +505,7 @@ def execute(self, context: KeeperParams, **kwargs): return action = kwargs.get('action') or 'list' - self._execute_action(action, vault, history, kwargs) + return self._execute_action(action, vault, history, kwargs) def _validate_vault(self, context: KeeperParams): """Validate that vault is initialized.""" @@ -538,13 +538,13 @@ def _find_record_in_folder(self, vault: vault_online.VaultOnline, folder, record def _execute_action(self, action: str, vault: vault_online.VaultOnline, history: list, kwargs: dict): """Execute the specified history action.""" if action == 'list': - self._list_history(history, kwargs) + return self._list_history(history, kwargs) elif action == 'view': - self._view_revision(history, kwargs) + return self._view_revision(history, kwargs) elif action == 'diff': - self._show_diff(history, kwargs) + return self._show_diff(history, kwargs) elif action == 'restore': - self._restore_revision(vault, history, kwargs) + return self._restore_revision(vault, history, kwargs) def _list_history(self, history: list, kwargs: dict): """List record history revisions.""" diff --git a/keepercli-package/src/keepercli/commands/register.py b/keepercli-package/src/keepercli/commands/register.py index c3fdf749..1d77dcaf 100644 --- a/keepercli-package/src/keepercli/commands/register.py +++ b/keepercli-package/src/keepercli/commands/register.py @@ -61,7 +61,8 @@ def add_arguments_to_parser(parser: argparse.ArgumentParser): def execute(self, context: KeeperParams, **kwargs): """Execute the find-ownerless command.""" - assert context.vault is not None + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') vault = context.vault claim_records = kwargs.get('claim', False) diff --git a/keepercli-package/src/keepercli/commands/secrets_manager.py b/keepercli-package/src/keepercli/commands/secrets_manager.py index 81c8fabf..59821262 100644 --- a/keepercli-package/src/keepercli/commands/secrets_manager.py +++ b/keepercli-package/src/keepercli/commands/secrets_manager.py @@ -43,7 +43,7 @@ class SecretsManagerAppCommand(base.ArgparseCommand): def __init__(self): self.parser = argparse.ArgumentParser( prog='secrets-manager-app', - description='Keeper Secrets Manager (KSM) App Commands', + description='Keeper Secrets Manager (KSM) App Commands' ) SecretsManagerAppCommand.add_arguments_to_parser(self.parser) super().__init__(self.parser) @@ -394,7 +394,7 @@ class SecretsManagerClientCommand(base.ArgparseCommand): def __init__(self): self.parser = argparse.ArgumentParser( prog='secrets-manager-client', - description='Keeper Secrets Manager (KSM) Client Commands', + description='Keeper Secrets Manager (KSM) Client Commands' ) SecretsManagerClientCommand.add_arguments_to_parser(self.parser) super().__init__(self.parser) @@ -800,7 +800,7 @@ class SecretsManagerShareCommand(base.ArgparseCommand): def __init__(self): self.parser = argparse.ArgumentParser( prog='secrets-manager-share', - description='Keeper Secrets Manager (KSM) Share Commands', + description='Keeper Secrets Manager (KSM) Share Commands' ) SecretsManagerShareCommand.add_arguments_to_parser(self.parser) super().__init__(self.parser) @@ -928,7 +928,7 @@ def _process_secret(vault: vault_online.VaultOnline, secret_uid: str, share_type = ApplicationShareType.SHARE_TYPE_FOLDER secret_type_name = SHARED_FOLDER else: - logger.error( + logger.warning( f"UID='{secret_uid}' is not a Record nor Shared Folder. " "Only individual records or Shared Folders can be added to the application. " "Make sure your local cache is up to date by running 'sync-down' command and trying again." @@ -936,7 +936,7 @@ def _process_secret(vault: vault_online.VaultOnline, secret_uid: str, return None if not share_key_decrypted: - logger.error(f"Could not retrieve key for secret {secret_uid}") + logger.warning(f"Could not retrieve key for secret {secret_uid}") return None app_share = AppShareAdd() @@ -983,9 +983,29 @@ def remove_share(vault: vault_online.VaultOnline, app_uid: str, secret_uids: lis logger.warning("No secret UIDs provided for removal.") return + app_infos = ksm_management.get_app_info(vault=vault, app_uid=app_uid) + if not app_infos: + raise ValueError(f"Could not retrieve application info for UID: {app_uid}") + + app_info = app_infos[0] + current_shared_uids = { + utils.base64_url_encode(share.secretUid) + for share in getattr(app_info, 'shares', []) + } + + valid_uids = [uid for uid in secret_uids if uid in current_shared_uids] + invalid_uids = [uid for uid in secret_uids if uid not in current_shared_uids] + + for uid in invalid_uids: + logger.warning(f"Secret UID '{uid}' is not shared with this application. Skipping.") + + if not valid_uids: + logger.warning("None of the provided secret UIDs are shared with this application. Nothing to remove.") + return + request = RemoveAppSharesRequest() request.appRecordUid = utils.base64_url_decode(app_uid) - request.shares.extend(utils.base64_url_decode(uid) for uid in secret_uids) + request.shares.extend(utils.base64_url_decode(uid) for uid in valid_uids) vault.keeper_auth.execute_auth_rest(rest_endpoint=SHARE_REMOVE_URL, request=request) logger.info("Shared secrets were successfully removed from the application\n") \ No newline at end of file diff --git a/keepercli-package/src/keepercli/commands/share_management.py b/keepercli-package/src/keepercli/commands/share_management.py index 5426c49c..bdc4334f 100644 --- a/keepercli-package/src/keepercli/commands/share_management.py +++ b/keepercli-package/src/keepercli/commands/share_management.py @@ -70,7 +70,7 @@ class ShareRecordCommand(base.ArgparseCommand): def __init__(self): self.parser = argparse.ArgumentParser( prog='share-record', - description='Change the sharing permissions of an individual record', + description='Change the sharing permissions of an individual record' ) ShareRecordCommand.add_arguments_to_parser(self.parser) super().__init__(self.parser) diff --git a/keepercli-package/src/keepercli/commands/vault.py b/keepercli-package/src/keepercli/commands/vault.py index c4e2b55c..fbac5c59 100644 --- a/keepercli-package/src/keepercli/commands/vault.py +++ b/keepercli-package/src/keepercli/commands/vault.py @@ -9,7 +9,7 @@ def __init__(self): super().__init__(SyncDownCommand.parser) def execute(self, context, **kwargs): - assert context.auth is not None + base.require_login(context) force = kwargs.get('force') is True context.vault.sync_down(force) diff --git a/keepercli-package/src/keepercli/commands/vault_folder.py b/keepercli-package/src/keepercli/commands/vault_folder.py index bc63d79c..b1f09cff 100644 --- a/keepercli-package/src/keepercli/commands/vault_folder.py +++ b/keepercli-package/src/keepercli/commands/vault_folder.py @@ -26,7 +26,8 @@ class _FolderMixin: def resolve_single_folder(folder_name: Optional[str], context: KeeperParams): if not folder_name: raise base.CommandError('Folder cannot be empty') - assert context.vault is not None + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') folder = context.vault.vault_data.get_folder(folder_name) if not folder: folder, pattern = folder_utils.try_resolve_path(context, folder_name) @@ -77,7 +78,8 @@ def chunk_list(names: List[str], n: int) -> List[List[str]]: return rows def execute(self, context: KeeperParams, **kwargs): - assert context.vault is not None + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') show_folders = kwargs['folders'] if 'folders' in kwargs else None show_records = kwargs['records'] if 'records' in kwargs else None show_detail = kwargs['detail'] if 'detail' in kwargs else False @@ -202,7 +204,8 @@ def execute(self, context: KeeperParams, **kwargs): show_shares: bool = kwargs.get('shares') is True def tree_node(node: vault_types.Folder) -> Tuple[str, dict]: - assert context.vault is not None + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') name = node.name children: dict = OrderedDict() if verbose and node.folder_uid: @@ -301,7 +304,8 @@ def __init__(self) -> None: super().__init__(FolderMakeCommand.parser) def execute(self, context: KeeperParams, **kwargs): - assert context.vault is not None + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') name = kwargs.get('folder') if not name: raise base.CommandError('Folder cannot be empty') @@ -373,7 +377,8 @@ def __init__(self) -> None: super().__init__(FolderRemoveCommand.parser) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.vault is not None + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') folder_uids = set() pattern_list = kwargs.get('pattern') if not isinstance(pattern_list, (tuple, list, set)): @@ -441,7 +446,8 @@ def __init__(self) -> None: super().__init__(FolderRenameCommand.parser) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.vault is not None + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') folder = self.resolve_single_folder(kwargs.get('folder'), context) if not folder: raise base.CommandError('Enter the path or UID of existing folder.') @@ -478,7 +484,8 @@ def __init__(self) -> None: super().__init__(FolderMoveCommand.parser) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.vault is not None + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') logger = api.get_logger() src_paths = kwargs.get('src') dst_path = kwargs.get('dst') @@ -1251,7 +1258,8 @@ def _execute_transformation_steps(self, vault, source_folder_uids, src_to_dst_ma def execute(self, context: KeeperParams, **kwargs): """Execute the folder transformation command.""" - assert context.vault is not None + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') vault = context.vault # Resolve target and source folders diff --git a/keepercli-package/src/keepercli/commands/vault_record.py b/keepercli-package/src/keepercli/commands/vault_record.py index ebb767c8..ed963026 100644 --- a/keepercli-package/src/keepercli/commands/vault_record.py +++ b/keepercli-package/src/keepercli/commands/vault_record.py @@ -28,7 +28,8 @@ def __init__(self) -> None: super().__init__(RecordListCommand.parser) def execute(self, context: KeeperParams, **kwargs): - assert context.vault is not None + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') verbose = kwargs.get('verbose') is True fmt = kwargs.get('format', 'table') search_text = kwargs.get('search_text') @@ -362,7 +363,8 @@ def __init__(self): super().__init__(ShortcutListCommand.parser) def execute(self, context: KeeperParams, **kwargs): - assert context.vault is not None + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') records = ShortcutCommand.get_record_shortcuts(context.vault.vault_data) if len(records) == 0: raise base.CommandError('Vault does not have shortcuts') @@ -464,7 +466,8 @@ def __init__(self): super().__init__(ShortcutKeepCommand.parser) def execute(self, context: KeeperParams, **kwargs) -> None: - assert context.vault is not None + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize the vault.') target = kwargs.get('target') if not target: raise base.CommandError('Target parameter cannot be empty') diff --git a/keepercli-package/src/keepercli/helpers/folder_utils.py b/keepercli-package/src/keepercli/helpers/folder_utils.py index bc88099b..08de3ed2 100644 --- a/keepercli-package/src/keepercli/helpers/folder_utils.py +++ b/keepercli-package/src/keepercli/helpers/folder_utils.py @@ -2,6 +2,7 @@ from keepersdk.vault import vault_types from ..params import KeeperParams +from ..commands.base import CommandError def try_resolve_path(context: KeeperParams, path: str) -> Tuple[vault_types.Folder, str]: @@ -11,7 +12,8 @@ def try_resolve_path(context: KeeperParams, path: str) -> Tuple[vault_types.Fold If existent folder(s), the final component is ''. If a non-existent folder, the final component is the folders, joined with /, that do not (yet) exist.. """ - assert context.vault is not None + if context.vault is None: + raise CommandError('Vault is not initialized. Login to initialize the vault.') if not isinstance(path, str): path = '' diff --git a/keepercli-package/src/keepercli/helpers/record_utils.py b/keepercli-package/src/keepercli/helpers/record_utils.py index b52e9dad..614129c9 100644 --- a/keepercli-package/src/keepercli/helpers/record_utils.py +++ b/keepercli-package/src/keepercli/helpers/record_utils.py @@ -27,7 +27,8 @@ def try_resolve_single_record(record_name: Optional[str], context: KeeperParams) -> Optional[vault_record.KeeperRecordInfo]: - assert context.vault is not None + if context.vault is None: + raise CommandError('Vault is not initialized. Login to initialize the vault.') if not record_name: return None @@ -46,7 +47,8 @@ def try_resolve_single_record(record_name: Optional[str], context: KeeperParams) def resolve_records(pattern: str, context: KeeperParams, *, recursive: bool=False) -> Iterator[str]: - assert context.vault is not None + if context.vault is None: + raise CommandError('Vault is not initialized. Login to initialize the vault.') record_info = context.vault.vault_data.get_record(pattern) if record_info: yield record_info.record_uid diff --git a/keepersdk-package/src/keepersdk/enterprise/batch_management.py b/keepersdk-package/src/keepersdk/enterprise/batch_management.py index ece9ec35..f85c0b03 100644 --- a/keepersdk-package/src/keepersdk/enterprise/batch_management.py +++ b/keepersdk-package/src/keepersdk/enterprise/batch_management.py @@ -588,7 +588,7 @@ def _to_team_requests(self) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]] if isinstance(team.restrict_view, bool): rq['restrict_view'] = team.restrict_view if action == EntityAction.Add: - rq['manage_only'] = False + rq['manage_only'] = True team_keys = keeper_auth.UserKeys() if not auth.auth_context.forbid_rsa: rsa_private_key, rsa_public_key = crypto.generate_rsa_key() @@ -685,7 +685,7 @@ def _to_user_actions(self) -> List[Dict[str, Any]]: rq: Dict[str, Any] = {} if user_action == UserAction.ExpirePassword: rq['command'] = 'set_master_password_expire' - rq['username'] = u.username + rq['email'] = u.username else: rq['enterprise_user_id'] = enterprise_user_id if user_action in {UserAction.Lock, UserAction.Unlock}: @@ -1069,7 +1069,7 @@ def _execute_batch(self, requests: List[Dict[str, Any]]) -> None: values['User ID'] = rq.get('enterprise_user_id') elif command == 'set_master_password_expire': command_action = 'User Expire Master Password' - values['User Email'] = rq.get('username') + values['User Email'] = rq.get('email') elif command.startswith('role_managed_node_'): command_action = 'Managed Node ' + command[len('role_managed_node_'):].capitalize() values['Role ID'] = rq.get('role_id') From 82712157e004632a55197f6977cd37cb226f7e85 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Tue, 9 Dec 2025 18:57:16 +0530 Subject: [PATCH 12/37] Sdk examples Implemented --- .../audit_alert/audit_alert_add.py | 4 - .../audit_alert/audit_alert_delete.py | 4 - .../audit_alert/audit_alert_edit.py | 4 - .../audit_alert/audit_alert_history.py | 4 - .../audit_alert/audit_alert_list.py | 4 - .../audit_alert/audit_alert_view.py | 4 - .../audit_report/audit_log.py | 4 - .../audit_report/audit_report.py | 4 - .../breachwatch/breachwatch_ignore.py | 4 - .../breachwatch/breachwatch_list.py | 4 - .../breachwatch/breachwatch_password.py | 4 - .../breachwatch/breachwatch_scan.py | 4 - .../create_custom_record_type.py | 4 - .../custom_record_type_info.py | 4 - .../delete_custom_record_type.py | 4 - .../download_record_types.py | 4 - .../edit_custom_record_type.py | 4 - .../custom_record_type/load_record_types.py | 4 - .../enterprise_info/enterprise_info_node.py | 4 - .../enterprise_info/enterprise_info_role.py | 4 - .../enterprise_info/enterprise_info_team.py | 4 - .../enterprise_info/enterprise_info_tree.py | 4 - .../enterprise_info/enterprise_info_user.py | 4 - .../enterprise_node/enterprise_node_add.py | 4 - .../enterprise_node/enterprise_node_delete.py | 4 - .../enterprise_node/enterprise_node_edit.py | 4 - .../enterprise_node_invite_email.py | 4 - .../enterprise_node_set_logo.py | 4 - .../enterprise_node/enterprise_node_view.py | 4 - .../enterprise_node_wipe_out.py | 4 - .../enterprise_role/enterprise_role_add.py | 4 - .../enterprise_role/enterprise_role_admin.py | 4 - .../enterprise_role/enterprise_role_copy.py | 4 - .../enterprise_role/enterprise_role_delete.py | 4 - .../enterprise_role/enterprise_role_edit.py | 4 - .../enterprise_role_membership.py | 4 - .../enterprise_role/enterprise_role_view.py | 4 - .../enterprise_team/enterprise_team_add.py | 4 - .../enterprise_team/enterprise_team_delete.py | 4 - .../enterprise_team/enterprise_team_edit.py | 4 - .../enterprise_team_membership.py | 4 - .../enterprise_team/enterprise_team_view.py | 4 - .../enterprise_user/create_user.py | 4 - .../enterprise_user/enterprise_user_action.py | 4 - .../enterprise_user/enterprise_user_add.py | 4 - .../enterprise_user/enterprise_user_alias.py | 4 - .../enterprise_user/enterprise_user_delete.py | 4 - .../enterprise_user/enterprise_user_edit.py | 4 - .../enterprise_user/enterprise_user_view.py | 4 - .../enterprise_user/transfer_user.py | 4 - examples/{ => cli_examples}/folder/list_sf.py | 4 - .../{ => cli_examples}/folder/share_folder.py | 4 - .../folder/transform_folder.py | 4 - .../importing_exporting/apply_membership.py | 4 - .../download_membership.py | 4 - .../importing_exporting/export_data.py | 4 - .../importing_exporting/import_data.py | 4 - .../miscellaneous/clipboard_copy.py | 4 - .../miscellaneous/find_ownerless.py | 4 - .../miscellaneous/list_team.py | 4 - .../miscellaneous/password-report.py | 4 - .../one_time_share/create_one_time_share.py | 4 - .../one_time_share/list_one_time_shares.py | 4 - .../one_time_share/remove_one_time_share.py | 4 - .../password/advanced_password_generation.py | 4 - .../password/basic_password_generation.py | 4 - .../comprehensive_password_generation.py | 4 - .../password/crypto_password_generation.py | 4 - .../password/diceware_password_generation.py | 4 - .../password/recovery_phrase_generation.py | 4 - .../{ => cli_examples}/record/add_record.py | 4 - .../record/delete_attachment.py | 4 - .../record/delete_record.py | 4 - .../record/download_attachment.py | 4 - .../{ => cli_examples}/record/file_report.py | 4 - .../record/find_duplicate.py | 4 - .../{ => cli_examples}/record/get_command.py | 4 - .../{ => cli_examples}/record/list_records.py | 4 - .../record/record_history.py | 4 - .../record/search_record.py | 4 - .../{ => cli_examples}/record/share_record.py | 4 - .../record/share_record_permissions.py | 4 - .../record/update_record.py | 4 - .../record/upload_attachment.py | 4 - .../create_secrets_manager_app.py | 4 - .../get_secrets_manager_app.py | 4 - .../list_secrets_manager_apps.py | 4 - .../remove_secrets_manager_app.py | 4 - .../secrets_manager_app_add_record.py | 4 - .../secrets_manager_app_remove_record.py | 4 - .../secrets_manager_client_add.py | 4 - .../secrets_manager_client_remove.py | 4 - .../share_secrets_manager_app.py | 4 - .../unshare_secrets_manager_app.py | 4 - .../{ => cli_examples}/trash/trash_get.py | 4 - .../{ => cli_examples}/trash/trash_list.py | 4 - .../{ => cli_examples}/trash/trash_purge.py | 4 - .../{ => cli_examples}/trash/trash_restore.py | 4 - .../{ => cli_examples}/trash/trash_unshare.py | 4 - .../sdk_examples/audit_alert/list_alerts.py | 146 ++++++++++ .../sdk_examples/audit_alert/view_alert.py | 168 ++++++++++++ .../sdk_examples/audit_report/audit_log.py | 159 +++++++++++ .../audit_report/audit_summary.py | 155 +++++++++++ examples/sdk_examples/auth/login.py | 99 +++++++ examples/sdk_examples/auth/logout.py | 101 +++++++ examples/sdk_examples/auth/whoami.py | 118 +++++++++ .../sdk_examples/breachwatch/breach_status.py | 151 +++++++++++ .../sdk_examples/breachwatch/list_breaches.py | 149 +++++++++++ .../sdk_examples/breachwatch/scan_password.py | 158 +++++++++++ .../sdk_examples/breachwatch/scan_records.py | 180 +++++++++++++ .../enterprise_info/enterprise_info_node.py | 147 ++++++++++ .../enterprise_info/enterprise_info_role.py | 140 ++++++++++ .../enterprise_info/enterprise_info_team.py | 139 ++++++++++ .../enterprise_info/enterprise_info_tree.py | 146 ++++++++++ .../enterprise_info/enterprise_info_user.py | 138 ++++++++++ .../enterprise_node/enterprise_node_view.py | 168 ++++++++++++ .../enterprise_role_membership.py | 178 +++++++++++++ .../enterprise_role/enterprise_role_view.py | 190 +++++++++++++ .../enterprise_team_membership.py | 167 ++++++++++++ .../enterprise_team/enterprise_team_view.py | 165 ++++++++++++ .../enterprise_user/device_approve.py | 250 ++++++++++++++++++ .../enterprise_user/enterprise_user_view.py | 184 +++++++++++++ .../enterprise_user/transfer_user.py | 187 +++++++++++++ examples/sdk_examples/folder/add_folder.py | 145 ++++++++++ examples/sdk_examples/folder/delete_folder.py | 144 ++++++++++ examples/sdk_examples/folder/list_folders.py | 133 ++++++++++ .../folder/list_shared_folders.py | 135 ++++++++++ examples/sdk_examples/folder/move_folder.py | 161 +++++++++++ examples/sdk_examples/folder/update_folder.py | 139 ++++++++++ .../import_export/export_vault.py | 88 ++++++ .../import_export/vault_summary.py | 80 ++++++ .../sdk_examples/miscellaneous/copy_field.py | 119 +++++++++ .../sdk_examples/miscellaneous/get_totp.py | 90 +++++++ .../sdk_examples/miscellaneous/list_teams.py | 76 ++++++ .../miscellaneous/password_strength.py | 96 +++++++ .../one_time_share/list_shares.py | 108 ++++++++ .../record_types/list_record_types.py | 90 +++++++ examples/sdk_examples/records/add_record.py | 131 +++++++++ .../sdk_examples/records/delete_attachment.py | 198 ++++++++++++++ .../sdk_examples/records/delete_record.py | 151 +++++++++++ .../records/download_attachment.py | 167 ++++++++++++ examples/sdk_examples/records/file_report.py | 204 ++++++++++++++ .../sdk_examples/records/find_duplicate.py | 198 ++++++++++++++ examples/sdk_examples/records/get_record.py | 233 ++++++++++++++++ examples/sdk_examples/records/list_records.py | 116 ++++++++ .../sdk_examples/records/record_history.py | 166 ++++++++++++ .../sdk_examples/records/search_record.py | 139 ++++++++++ .../sdk_examples/records/update_record.py | 161 +++++++++++ .../sdk_examples/records/upload_attachment.py | 150 +++++++++++ .../secrets_manager/create_app.py | 107 ++++++++ .../sdk_examples/secrets_manager/get_app.py | 135 ++++++++++ .../sdk_examples/secrets_manager/list_apps.py | 110 ++++++++ examples/sdk_examples/trash/list_trash.py | 158 +++++++++++ .../sdk_examples/trash/restore_records.py | 140 ++++++++++ .../sdk_examples/trash/view_trash_record.py | 126 +++++++++ 155 files changed, 8177 insertions(+), 396 deletions(-) rename examples/{ => cli_examples}/audit_alert/audit_alert_add.py (96%) rename examples/{ => cli_examples}/audit_alert/audit_alert_delete.py (96%) rename examples/{ => cli_examples}/audit_alert/audit_alert_edit.py (96%) rename examples/{ => cli_examples}/audit_alert/audit_alert_history.py (96%) rename examples/{ => cli_examples}/audit_alert/audit_alert_list.py (96%) rename examples/{ => cli_examples}/audit_alert/audit_alert_view.py (96%) rename examples/{ => cli_examples}/audit_report/audit_log.py (97%) rename examples/{ => cli_examples}/audit_report/audit_report.py (97%) rename examples/{ => cli_examples}/breachwatch/breachwatch_ignore.py (96%) rename examples/{ => cli_examples}/breachwatch/breachwatch_list.py (96%) rename examples/{ => cli_examples}/breachwatch/breachwatch_password.py (96%) rename examples/{ => cli_examples}/breachwatch/breachwatch_scan.py (96%) rename examples/{ => cli_examples}/custom_record_type/create_custom_record_type.py (97%) rename examples/{ => cli_examples}/custom_record_type/custom_record_type_info.py (96%) rename examples/{ => cli_examples}/custom_record_type/delete_custom_record_type.py (97%) rename examples/{ => cli_examples}/custom_record_type/download_record_types.py (96%) rename examples/{ => cli_examples}/custom_record_type/edit_custom_record_type.py (97%) rename examples/{ => cli_examples}/custom_record_type/load_record_types.py (97%) rename examples/{ => cli_examples}/enterprise_info/enterprise_info_node.py (97%) rename examples/{ => cli_examples}/enterprise_info/enterprise_info_role.py (97%) rename examples/{ => cli_examples}/enterprise_info/enterprise_info_team.py (97%) rename examples/{ => cli_examples}/enterprise_info/enterprise_info_tree.py (96%) rename examples/{ => cli_examples}/enterprise_info/enterprise_info_user.py (97%) rename examples/{ => cli_examples}/enterprise_node/enterprise_node_add.py (96%) rename examples/{ => cli_examples}/enterprise_node/enterprise_node_delete.py (96%) rename examples/{ => cli_examples}/enterprise_node/enterprise_node_edit.py (96%) rename examples/{ => cli_examples}/enterprise_node/enterprise_node_invite_email.py (97%) rename examples/{ => cli_examples}/enterprise_node/enterprise_node_set_logo.py (97%) rename examples/{ => cli_examples}/enterprise_node/enterprise_node_view.py (96%) rename examples/{ => cli_examples}/enterprise_node/enterprise_node_wipe_out.py (96%) rename examples/{ => cli_examples}/enterprise_role/enterprise_role_add.py (97%) rename examples/{ => cli_examples}/enterprise_role/enterprise_role_admin.py (97%) rename examples/{ => cli_examples}/enterprise_role/enterprise_role_copy.py (97%) rename examples/{ => cli_examples}/enterprise_role/enterprise_role_delete.py (96%) rename examples/{ => cli_examples}/enterprise_role/enterprise_role_edit.py (97%) rename examples/{ => cli_examples}/enterprise_role/enterprise_role_membership.py (97%) rename examples/{ => cli_examples}/enterprise_role/enterprise_role_view.py (96%) rename examples/{ => cli_examples}/enterprise_team/enterprise_team_add.py (96%) rename examples/{ => cli_examples}/enterprise_team/enterprise_team_delete.py (96%) rename examples/{ => cli_examples}/enterprise_team/enterprise_team_edit.py (96%) rename examples/{ => cli_examples}/enterprise_team/enterprise_team_membership.py (97%) rename examples/{ => cli_examples}/enterprise_team/enterprise_team_view.py (96%) rename examples/{ => cli_examples}/enterprise_user/create_user.py (98%) rename examples/{ => cli_examples}/enterprise_user/enterprise_user_action.py (97%) rename examples/{ => cli_examples}/enterprise_user/enterprise_user_add.py (96%) rename examples/{ => cli_examples}/enterprise_user/enterprise_user_alias.py (97%) rename examples/{ => cli_examples}/enterprise_user/enterprise_user_delete.py (96%) rename examples/{ => cli_examples}/enterprise_user/enterprise_user_edit.py (96%) rename examples/{ => cli_examples}/enterprise_user/enterprise_user_view.py (96%) rename examples/{ => cli_examples}/enterprise_user/transfer_user.py (97%) rename examples/{ => cli_examples}/folder/list_sf.py (96%) rename examples/{ => cli_examples}/folder/share_folder.py (97%) rename examples/{ => cli_examples}/folder/transform_folder.py (97%) rename examples/{ => cli_examples}/importing_exporting/apply_membership.py (97%) rename examples/{ => cli_examples}/importing_exporting/download_membership.py (97%) rename examples/{ => cli_examples}/importing_exporting/export_data.py (97%) rename examples/{ => cli_examples}/importing_exporting/import_data.py (97%) rename examples/{ => cli_examples}/miscellaneous/clipboard_copy.py (97%) rename examples/{ => cli_examples}/miscellaneous/find_ownerless.py (97%) rename examples/{ => cli_examples}/miscellaneous/list_team.py (97%) rename examples/{ => cli_examples}/miscellaneous/password-report.py (97%) rename examples/{ => cli_examples}/one_time_share/create_one_time_share.py (97%) rename examples/{ => cli_examples}/one_time_share/list_one_time_shares.py (97%) rename examples/{ => cli_examples}/one_time_share/remove_one_time_share.py (96%) rename examples/{ => cli_examples}/password/advanced_password_generation.py (97%) rename examples/{ => cli_examples}/password/basic_password_generation.py (97%) rename examples/{ => cli_examples}/password/comprehensive_password_generation.py (99%) rename examples/{ => cli_examples}/password/crypto_password_generation.py (97%) rename examples/{ => cli_examples}/password/diceware_password_generation.py (97%) rename examples/{ => cli_examples}/password/recovery_phrase_generation.py (97%) rename examples/{ => cli_examples}/record/add_record.py (97%) rename examples/{ => cli_examples}/record/delete_attachment.py (97%) rename examples/{ => cli_examples}/record/delete_record.py (97%) rename examples/{ => cli_examples}/record/download_attachment.py (97%) rename examples/{ => cli_examples}/record/file_report.py (96%) rename examples/{ => cli_examples}/record/find_duplicate.py (98%) rename examples/{ => cli_examples}/record/get_command.py (97%) rename examples/{ => cli_examples}/record/list_records.py (97%) rename examples/{ => cli_examples}/record/record_history.py (97%) rename examples/{ => cli_examples}/record/search_record.py (97%) rename examples/{ => cli_examples}/record/share_record.py (97%) rename examples/{ => cli_examples}/record/share_record_permissions.py (98%) rename examples/{ => cli_examples}/record/update_record.py (98%) rename examples/{ => cli_examples}/record/upload_attachment.py (97%) rename examples/{ => cli_examples}/secrets_manager_app/create_secrets_manager_app.py (97%) rename examples/{ => cli_examples}/secrets_manager_app/get_secrets_manager_app.py (98%) rename examples/{ => cli_examples}/secrets_manager_app/list_secrets_manager_apps.py (97%) rename examples/{ => cli_examples}/secrets_manager_app/remove_secrets_manager_app.py (97%) rename examples/{ => cli_examples}/secrets_manager_app/secrets_manager_app_add_record.py (97%) rename examples/{ => cli_examples}/secrets_manager_app/secrets_manager_app_remove_record.py (97%) rename examples/{ => cli_examples}/secrets_manager_app/secrets_manager_client_add.py (98%) rename examples/{ => cli_examples}/secrets_manager_app/secrets_manager_client_remove.py (97%) rename examples/{ => cli_examples}/secrets_manager_app/share_secrets_manager_app.py (97%) rename examples/{ => cli_examples}/secrets_manager_app/unshare_secrets_manager_app.py (97%) rename examples/{ => cli_examples}/trash/trash_get.py (96%) rename examples/{ => cli_examples}/trash/trash_list.py (97%) rename examples/{ => cli_examples}/trash/trash_purge.py (96%) rename examples/{ => cli_examples}/trash/trash_restore.py (97%) rename examples/{ => cli_examples}/trash/trash_unshare.py (97%) create mode 100644 examples/sdk_examples/audit_alert/list_alerts.py create mode 100644 examples/sdk_examples/audit_alert/view_alert.py create mode 100644 examples/sdk_examples/audit_report/audit_log.py create mode 100644 examples/sdk_examples/audit_report/audit_summary.py create mode 100644 examples/sdk_examples/auth/login.py create mode 100644 examples/sdk_examples/auth/logout.py create mode 100644 examples/sdk_examples/auth/whoami.py create mode 100644 examples/sdk_examples/breachwatch/breach_status.py create mode 100644 examples/sdk_examples/breachwatch/list_breaches.py create mode 100644 examples/sdk_examples/breachwatch/scan_password.py create mode 100644 examples/sdk_examples/breachwatch/scan_records.py create mode 100644 examples/sdk_examples/enterprise_info/enterprise_info_node.py create mode 100644 examples/sdk_examples/enterprise_info/enterprise_info_role.py create mode 100644 examples/sdk_examples/enterprise_info/enterprise_info_team.py create mode 100644 examples/sdk_examples/enterprise_info/enterprise_info_tree.py create mode 100644 examples/sdk_examples/enterprise_info/enterprise_info_user.py create mode 100644 examples/sdk_examples/enterprise_node/enterprise_node_view.py create mode 100644 examples/sdk_examples/enterprise_role/enterprise_role_membership.py create mode 100644 examples/sdk_examples/enterprise_role/enterprise_role_view.py create mode 100644 examples/sdk_examples/enterprise_team/enterprise_team_membership.py create mode 100644 examples/sdk_examples/enterprise_team/enterprise_team_view.py create mode 100644 examples/sdk_examples/enterprise_user/device_approve.py create mode 100644 examples/sdk_examples/enterprise_user/enterprise_user_view.py create mode 100644 examples/sdk_examples/enterprise_user/transfer_user.py create mode 100644 examples/sdk_examples/folder/add_folder.py create mode 100644 examples/sdk_examples/folder/delete_folder.py create mode 100644 examples/sdk_examples/folder/list_folders.py create mode 100644 examples/sdk_examples/folder/list_shared_folders.py create mode 100644 examples/sdk_examples/folder/move_folder.py create mode 100644 examples/sdk_examples/folder/update_folder.py create mode 100644 examples/sdk_examples/import_export/export_vault.py create mode 100644 examples/sdk_examples/import_export/vault_summary.py create mode 100644 examples/sdk_examples/miscellaneous/copy_field.py create mode 100644 examples/sdk_examples/miscellaneous/get_totp.py create mode 100644 examples/sdk_examples/miscellaneous/list_teams.py create mode 100644 examples/sdk_examples/miscellaneous/password_strength.py create mode 100644 examples/sdk_examples/one_time_share/list_shares.py create mode 100644 examples/sdk_examples/record_types/list_record_types.py create mode 100644 examples/sdk_examples/records/add_record.py create mode 100644 examples/sdk_examples/records/delete_attachment.py create mode 100644 examples/sdk_examples/records/delete_record.py create mode 100644 examples/sdk_examples/records/download_attachment.py create mode 100644 examples/sdk_examples/records/file_report.py create mode 100644 examples/sdk_examples/records/find_duplicate.py create mode 100644 examples/sdk_examples/records/get_record.py create mode 100644 examples/sdk_examples/records/list_records.py create mode 100644 examples/sdk_examples/records/record_history.py create mode 100644 examples/sdk_examples/records/search_record.py create mode 100644 examples/sdk_examples/records/update_record.py create mode 100644 examples/sdk_examples/records/upload_attachment.py create mode 100644 examples/sdk_examples/secrets_manager/create_app.py create mode 100644 examples/sdk_examples/secrets_manager/get_app.py create mode 100644 examples/sdk_examples/secrets_manager/list_apps.py create mode 100644 examples/sdk_examples/trash/list_trash.py create mode 100644 examples/sdk_examples/trash/restore_records.py create mode 100644 examples/sdk_examples/trash/view_trash_record.py diff --git a/examples/audit_alert/audit_alert_add.py b/examples/cli_examples/audit_alert/audit_alert_add.py similarity index 96% rename from examples/audit_alert/audit_alert_add.py rename to examples/cli_examples/audit_alert/audit_alert_add.py index c68ffc14..26af4a88 100644 --- a/examples/audit_alert/audit_alert_add.py +++ b/examples/cli_examples/audit_alert/audit_alert_add.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/audit_alert/audit_alert_delete.py b/examples/cli_examples/audit_alert/audit_alert_delete.py similarity index 96% rename from examples/audit_alert/audit_alert_delete.py rename to examples/cli_examples/audit_alert/audit_alert_delete.py index bdfe7d9a..bb4f3234 100644 --- a/examples/audit_alert/audit_alert_delete.py +++ b/examples/cli_examples/audit_alert/audit_alert_delete.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/audit_alert/audit_alert_edit.py b/examples/cli_examples/audit_alert/audit_alert_edit.py similarity index 96% rename from examples/audit_alert/audit_alert_edit.py rename to examples/cli_examples/audit_alert/audit_alert_edit.py index 0e763bca..34e6add4 100644 --- a/examples/audit_alert/audit_alert_edit.py +++ b/examples/cli_examples/audit_alert/audit_alert_edit.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/audit_alert/audit_alert_history.py b/examples/cli_examples/audit_alert/audit_alert_history.py similarity index 96% rename from examples/audit_alert/audit_alert_history.py rename to examples/cli_examples/audit_alert/audit_alert_history.py index 9040fc41..9c018282 100644 --- a/examples/audit_alert/audit_alert_history.py +++ b/examples/cli_examples/audit_alert/audit_alert_history.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/audit_alert/audit_alert_list.py b/examples/cli_examples/audit_alert/audit_alert_list.py similarity index 96% rename from examples/audit_alert/audit_alert_list.py rename to examples/cli_examples/audit_alert/audit_alert_list.py index fb314b98..5b3a846f 100644 --- a/examples/audit_alert/audit_alert_list.py +++ b/examples/cli_examples/audit_alert/audit_alert_list.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/audit_alert/audit_alert_view.py b/examples/cli_examples/audit_alert/audit_alert_view.py similarity index 96% rename from examples/audit_alert/audit_alert_view.py rename to examples/cli_examples/audit_alert/audit_alert_view.py index fd4f7573..12271d1e 100644 --- a/examples/audit_alert/audit_alert_view.py +++ b/examples/cli_examples/audit_alert/audit_alert_view.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/audit_report/audit_log.py b/examples/cli_examples/audit_report/audit_log.py similarity index 97% rename from examples/audit_report/audit_log.py rename to examples/cli_examples/audit_report/audit_log.py index 50847ec7..727a8cdd 100644 --- a/examples/audit_report/audit_log.py +++ b/examples/cli_examples/audit_report/audit_log.py @@ -44,10 +44,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/audit_report/audit_report.py b/examples/cli_examples/audit_report/audit_report.py similarity index 97% rename from examples/audit_report/audit_report.py rename to examples/cli_examples/audit_report/audit_report.py index 17592332..fd9358e1 100644 --- a/examples/audit_report/audit_report.py +++ b/examples/cli_examples/audit_report/audit_report.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/breachwatch/breachwatch_ignore.py b/examples/cli_examples/breachwatch/breachwatch_ignore.py similarity index 96% rename from examples/breachwatch/breachwatch_ignore.py rename to examples/cli_examples/breachwatch/breachwatch_ignore.py index bf08f3e9..8ee2d5ba 100644 --- a/examples/breachwatch/breachwatch_ignore.py +++ b/examples/cli_examples/breachwatch/breachwatch_ignore.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/breachwatch/breachwatch_list.py b/examples/cli_examples/breachwatch/breachwatch_list.py similarity index 96% rename from examples/breachwatch/breachwatch_list.py rename to examples/cli_examples/breachwatch/breachwatch_list.py index 42f5c13d..f9958336 100644 --- a/examples/breachwatch/breachwatch_list.py +++ b/examples/cli_examples/breachwatch/breachwatch_list.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/breachwatch/breachwatch_password.py b/examples/cli_examples/breachwatch/breachwatch_password.py similarity index 96% rename from examples/breachwatch/breachwatch_password.py rename to examples/cli_examples/breachwatch/breachwatch_password.py index 4119274d..e4334699 100644 --- a/examples/breachwatch/breachwatch_password.py +++ b/examples/cli_examples/breachwatch/breachwatch_password.py @@ -40,10 +40,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/breachwatch/breachwatch_scan.py b/examples/cli_examples/breachwatch/breachwatch_scan.py similarity index 96% rename from examples/breachwatch/breachwatch_scan.py rename to examples/cli_examples/breachwatch/breachwatch_scan.py index e6439711..e357231b 100644 --- a/examples/breachwatch/breachwatch_scan.py +++ b/examples/cli_examples/breachwatch/breachwatch_scan.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/custom_record_type/create_custom_record_type.py b/examples/cli_examples/custom_record_type/create_custom_record_type.py similarity index 97% rename from examples/custom_record_type/create_custom_record_type.py rename to examples/cli_examples/custom_record_type/create_custom_record_type.py index 44449386..c8ef9e76 100644 --- a/examples/custom_record_type/create_custom_record_type.py +++ b/examples/cli_examples/custom_record_type/create_custom_record_type.py @@ -40,10 +40,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/custom_record_type/custom_record_type_info.py b/examples/cli_examples/custom_record_type/custom_record_type_info.py similarity index 96% rename from examples/custom_record_type/custom_record_type_info.py rename to examples/cli_examples/custom_record_type/custom_record_type_info.py index 31779a52..4db98ac4 100644 --- a/examples/custom_record_type/custom_record_type_info.py +++ b/examples/cli_examples/custom_record_type/custom_record_type_info.py @@ -41,10 +41,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/custom_record_type/delete_custom_record_type.py b/examples/cli_examples/custom_record_type/delete_custom_record_type.py similarity index 97% rename from examples/custom_record_type/delete_custom_record_type.py rename to examples/cli_examples/custom_record_type/delete_custom_record_type.py index e83b9ae3..0321e513 100644 --- a/examples/custom_record_type/delete_custom_record_type.py +++ b/examples/cli_examples/custom_record_type/delete_custom_record_type.py @@ -39,10 +39,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/custom_record_type/download_record_types.py b/examples/cli_examples/custom_record_type/download_record_types.py similarity index 96% rename from examples/custom_record_type/download_record_types.py rename to examples/cli_examples/custom_record_type/download_record_types.py index 353935bf..c5fca2dc 100644 --- a/examples/custom_record_type/download_record_types.py +++ b/examples/cli_examples/custom_record_type/download_record_types.py @@ -39,10 +39,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/custom_record_type/edit_custom_record_type.py b/examples/cli_examples/custom_record_type/edit_custom_record_type.py similarity index 97% rename from examples/custom_record_type/edit_custom_record_type.py rename to examples/cli_examples/custom_record_type/edit_custom_record_type.py index 8419d4f8..b16489c7 100644 --- a/examples/custom_record_type/edit_custom_record_type.py +++ b/examples/cli_examples/custom_record_type/edit_custom_record_type.py @@ -42,10 +42,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/custom_record_type/load_record_types.py b/examples/cli_examples/custom_record_type/load_record_types.py similarity index 97% rename from examples/custom_record_type/load_record_types.py rename to examples/cli_examples/custom_record_type/load_record_types.py index 16f8ee8e..c65b80f4 100644 --- a/examples/custom_record_type/load_record_types.py +++ b/examples/cli_examples/custom_record_type/load_record_types.py @@ -39,10 +39,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_info/enterprise_info_node.py b/examples/cli_examples/enterprise_info/enterprise_info_node.py similarity index 97% rename from examples/enterprise_info/enterprise_info_node.py rename to examples/cli_examples/enterprise_info/enterprise_info_node.py index 64f7b65f..1cdac14b 100644 --- a/examples/enterprise_info/enterprise_info_node.py +++ b/examples/cli_examples/enterprise_info/enterprise_info_node.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_info/enterprise_info_role.py b/examples/cli_examples/enterprise_info/enterprise_info_role.py similarity index 97% rename from examples/enterprise_info/enterprise_info_role.py rename to examples/cli_examples/enterprise_info/enterprise_info_role.py index 093d4fe0..bfdc9cbc 100644 --- a/examples/enterprise_info/enterprise_info_role.py +++ b/examples/cli_examples/enterprise_info/enterprise_info_role.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_info/enterprise_info_team.py b/examples/cli_examples/enterprise_info/enterprise_info_team.py similarity index 97% rename from examples/enterprise_info/enterprise_info_team.py rename to examples/cli_examples/enterprise_info/enterprise_info_team.py index 388d9c02..f5c816a8 100644 --- a/examples/enterprise_info/enterprise_info_team.py +++ b/examples/cli_examples/enterprise_info/enterprise_info_team.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_info/enterprise_info_tree.py b/examples/cli_examples/enterprise_info/enterprise_info_tree.py similarity index 96% rename from examples/enterprise_info/enterprise_info_tree.py rename to examples/cli_examples/enterprise_info/enterprise_info_tree.py index 502e45c3..f0e9dd6c 100644 --- a/examples/enterprise_info/enterprise_info_tree.py +++ b/examples/cli_examples/enterprise_info/enterprise_info_tree.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_info/enterprise_info_user.py b/examples/cli_examples/enterprise_info/enterprise_info_user.py similarity index 97% rename from examples/enterprise_info/enterprise_info_user.py rename to examples/cli_examples/enterprise_info/enterprise_info_user.py index 8849e309..79310055 100644 --- a/examples/enterprise_info/enterprise_info_user.py +++ b/examples/cli_examples/enterprise_info/enterprise_info_user.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_node/enterprise_node_add.py b/examples/cli_examples/enterprise_node/enterprise_node_add.py similarity index 96% rename from examples/enterprise_node/enterprise_node_add.py rename to examples/cli_examples/enterprise_node/enterprise_node_add.py index 890143a4..753beb7d 100644 --- a/examples/enterprise_node/enterprise_node_add.py +++ b/examples/cli_examples/enterprise_node/enterprise_node_add.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_node/enterprise_node_delete.py b/examples/cli_examples/enterprise_node/enterprise_node_delete.py similarity index 96% rename from examples/enterprise_node/enterprise_node_delete.py rename to examples/cli_examples/enterprise_node/enterprise_node_delete.py index 661d5dd3..9ecc8765 100644 --- a/examples/enterprise_node/enterprise_node_delete.py +++ b/examples/cli_examples/enterprise_node/enterprise_node_delete.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_node/enterprise_node_edit.py b/examples/cli_examples/enterprise_node/enterprise_node_edit.py similarity index 96% rename from examples/enterprise_node/enterprise_node_edit.py rename to examples/cli_examples/enterprise_node/enterprise_node_edit.py index 33be9389..aba89933 100644 --- a/examples/enterprise_node/enterprise_node_edit.py +++ b/examples/cli_examples/enterprise_node/enterprise_node_edit.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_node/enterprise_node_invite_email.py b/examples/cli_examples/enterprise_node/enterprise_node_invite_email.py similarity index 97% rename from examples/enterprise_node/enterprise_node_invite_email.py rename to examples/cli_examples/enterprise_node/enterprise_node_invite_email.py index e687b298..a4e546b3 100644 --- a/examples/enterprise_node/enterprise_node_invite_email.py +++ b/examples/cli_examples/enterprise_node/enterprise_node_invite_email.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_node/enterprise_node_set_logo.py b/examples/cli_examples/enterprise_node/enterprise_node_set_logo.py similarity index 97% rename from examples/enterprise_node/enterprise_node_set_logo.py rename to examples/cli_examples/enterprise_node/enterprise_node_set_logo.py index 84071b28..c682da7a 100644 --- a/examples/enterprise_node/enterprise_node_set_logo.py +++ b/examples/cli_examples/enterprise_node/enterprise_node_set_logo.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_node/enterprise_node_view.py b/examples/cli_examples/enterprise_node/enterprise_node_view.py similarity index 96% rename from examples/enterprise_node/enterprise_node_view.py rename to examples/cli_examples/enterprise_node/enterprise_node_view.py index f4163175..a150f099 100644 --- a/examples/enterprise_node/enterprise_node_view.py +++ b/examples/cli_examples/enterprise_node/enterprise_node_view.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_node/enterprise_node_wipe_out.py b/examples/cli_examples/enterprise_node/enterprise_node_wipe_out.py similarity index 96% rename from examples/enterprise_node/enterprise_node_wipe_out.py rename to examples/cli_examples/enterprise_node/enterprise_node_wipe_out.py index f117c7ce..9ea0d209 100644 --- a/examples/enterprise_node/enterprise_node_wipe_out.py +++ b/examples/cli_examples/enterprise_node/enterprise_node_wipe_out.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_role/enterprise_role_add.py b/examples/cli_examples/enterprise_role/enterprise_role_add.py similarity index 97% rename from examples/enterprise_role/enterprise_role_add.py rename to examples/cli_examples/enterprise_role/enterprise_role_add.py index de4be22f..d51c79af 100644 --- a/examples/enterprise_role/enterprise_role_add.py +++ b/examples/cli_examples/enterprise_role/enterprise_role_add.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_role/enterprise_role_admin.py b/examples/cli_examples/enterprise_role/enterprise_role_admin.py similarity index 97% rename from examples/enterprise_role/enterprise_role_admin.py rename to examples/cli_examples/enterprise_role/enterprise_role_admin.py index bfdb0417..e67eda12 100644 --- a/examples/enterprise_role/enterprise_role_admin.py +++ b/examples/cli_examples/enterprise_role/enterprise_role_admin.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_role/enterprise_role_copy.py b/examples/cli_examples/enterprise_role/enterprise_role_copy.py similarity index 97% rename from examples/enterprise_role/enterprise_role_copy.py rename to examples/cli_examples/enterprise_role/enterprise_role_copy.py index 739c6b67..5f34a018 100644 --- a/examples/enterprise_role/enterprise_role_copy.py +++ b/examples/cli_examples/enterprise_role/enterprise_role_copy.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_role/enterprise_role_delete.py b/examples/cli_examples/enterprise_role/enterprise_role_delete.py similarity index 96% rename from examples/enterprise_role/enterprise_role_delete.py rename to examples/cli_examples/enterprise_role/enterprise_role_delete.py index a508422a..dcd629b0 100644 --- a/examples/enterprise_role/enterprise_role_delete.py +++ b/examples/cli_examples/enterprise_role/enterprise_role_delete.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_role/enterprise_role_edit.py b/examples/cli_examples/enterprise_role/enterprise_role_edit.py similarity index 97% rename from examples/enterprise_role/enterprise_role_edit.py rename to examples/cli_examples/enterprise_role/enterprise_role_edit.py index a534917f..2112c2a9 100644 --- a/examples/enterprise_role/enterprise_role_edit.py +++ b/examples/cli_examples/enterprise_role/enterprise_role_edit.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_role/enterprise_role_membership.py b/examples/cli_examples/enterprise_role/enterprise_role_membership.py similarity index 97% rename from examples/enterprise_role/enterprise_role_membership.py rename to examples/cli_examples/enterprise_role/enterprise_role_membership.py index 9d82ccbd..39978244 100644 --- a/examples/enterprise_role/enterprise_role_membership.py +++ b/examples/cli_examples/enterprise_role/enterprise_role_membership.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_role/enterprise_role_view.py b/examples/cli_examples/enterprise_role/enterprise_role_view.py similarity index 96% rename from examples/enterprise_role/enterprise_role_view.py rename to examples/cli_examples/enterprise_role/enterprise_role_view.py index b363e64e..91f6e935 100644 --- a/examples/enterprise_role/enterprise_role_view.py +++ b/examples/cli_examples/enterprise_role/enterprise_role_view.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_team/enterprise_team_add.py b/examples/cli_examples/enterprise_team/enterprise_team_add.py similarity index 96% rename from examples/enterprise_team/enterprise_team_add.py rename to examples/cli_examples/enterprise_team/enterprise_team_add.py index 7c9783de..7973636b 100644 --- a/examples/enterprise_team/enterprise_team_add.py +++ b/examples/cli_examples/enterprise_team/enterprise_team_add.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_team/enterprise_team_delete.py b/examples/cli_examples/enterprise_team/enterprise_team_delete.py similarity index 96% rename from examples/enterprise_team/enterprise_team_delete.py rename to examples/cli_examples/enterprise_team/enterprise_team_delete.py index 0bbc1e1d..d6823a41 100644 --- a/examples/enterprise_team/enterprise_team_delete.py +++ b/examples/cli_examples/enterprise_team/enterprise_team_delete.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_team/enterprise_team_edit.py b/examples/cli_examples/enterprise_team/enterprise_team_edit.py similarity index 96% rename from examples/enterprise_team/enterprise_team_edit.py rename to examples/cli_examples/enterprise_team/enterprise_team_edit.py index 9521b93c..7488592a 100644 --- a/examples/enterprise_team/enterprise_team_edit.py +++ b/examples/cli_examples/enterprise_team/enterprise_team_edit.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_team/enterprise_team_membership.py b/examples/cli_examples/enterprise_team/enterprise_team_membership.py similarity index 97% rename from examples/enterprise_team/enterprise_team_membership.py rename to examples/cli_examples/enterprise_team/enterprise_team_membership.py index 6b473aa0..1ef70045 100644 --- a/examples/enterprise_team/enterprise_team_membership.py +++ b/examples/cli_examples/enterprise_team/enterprise_team_membership.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_team/enterprise_team_view.py b/examples/cli_examples/enterprise_team/enterprise_team_view.py similarity index 96% rename from examples/enterprise_team/enterprise_team_view.py rename to examples/cli_examples/enterprise_team/enterprise_team_view.py index 623f2622..8fc9945d 100644 --- a/examples/enterprise_team/enterprise_team_view.py +++ b/examples/cli_examples/enterprise_team/enterprise_team_view.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_user/create_user.py b/examples/cli_examples/enterprise_user/create_user.py similarity index 98% rename from examples/enterprise_user/create_user.py rename to examples/cli_examples/enterprise_user/create_user.py index c06ff9be..f9de4e05 100644 --- a/examples/enterprise_user/create_user.py +++ b/examples/cli_examples/enterprise_user/create_user.py @@ -44,10 +44,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_user/enterprise_user_action.py b/examples/cli_examples/enterprise_user/enterprise_user_action.py similarity index 97% rename from examples/enterprise_user/enterprise_user_action.py rename to examples/cli_examples/enterprise_user/enterprise_user_action.py index e00b7271..7d4f182d 100644 --- a/examples/enterprise_user/enterprise_user_action.py +++ b/examples/cli_examples/enterprise_user/enterprise_user_action.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_user/enterprise_user_add.py b/examples/cli_examples/enterprise_user/enterprise_user_add.py similarity index 96% rename from examples/enterprise_user/enterprise_user_add.py rename to examples/cli_examples/enterprise_user/enterprise_user_add.py index ae129d33..cf9259bd 100644 --- a/examples/enterprise_user/enterprise_user_add.py +++ b/examples/cli_examples/enterprise_user/enterprise_user_add.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_user/enterprise_user_alias.py b/examples/cli_examples/enterprise_user/enterprise_user_alias.py similarity index 97% rename from examples/enterprise_user/enterprise_user_alias.py rename to examples/cli_examples/enterprise_user/enterprise_user_alias.py index 5afa7815..38559787 100644 --- a/examples/enterprise_user/enterprise_user_alias.py +++ b/examples/cli_examples/enterprise_user/enterprise_user_alias.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_user/enterprise_user_delete.py b/examples/cli_examples/enterprise_user/enterprise_user_delete.py similarity index 96% rename from examples/enterprise_user/enterprise_user_delete.py rename to examples/cli_examples/enterprise_user/enterprise_user_delete.py index 11701c13..7a7a1dbe 100644 --- a/examples/enterprise_user/enterprise_user_delete.py +++ b/examples/cli_examples/enterprise_user/enterprise_user_delete.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_user/enterprise_user_edit.py b/examples/cli_examples/enterprise_user/enterprise_user_edit.py similarity index 96% rename from examples/enterprise_user/enterprise_user_edit.py rename to examples/cli_examples/enterprise_user/enterprise_user_edit.py index a2682849..7dbe4993 100644 --- a/examples/enterprise_user/enterprise_user_edit.py +++ b/examples/cli_examples/enterprise_user/enterprise_user_edit.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_user/enterprise_user_view.py b/examples/cli_examples/enterprise_user/enterprise_user_view.py similarity index 96% rename from examples/enterprise_user/enterprise_user_view.py rename to examples/cli_examples/enterprise_user/enterprise_user_view.py index 679f1f44..e306f0e5 100644 --- a/examples/enterprise_user/enterprise_user_view.py +++ b/examples/cli_examples/enterprise_user/enterprise_user_view.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/enterprise_user/transfer_user.py b/examples/cli_examples/enterprise_user/transfer_user.py similarity index 97% rename from examples/enterprise_user/transfer_user.py rename to examples/cli_examples/enterprise_user/transfer_user.py index 474d9f0b..2d7cefc9 100644 --- a/examples/enterprise_user/transfer_user.py +++ b/examples/cli_examples/enterprise_user/transfer_user.py @@ -39,10 +39,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/folder/list_sf.py b/examples/cli_examples/folder/list_sf.py similarity index 96% rename from examples/folder/list_sf.py rename to examples/cli_examples/folder/list_sf.py index 573ed2f4..ecdbad54 100644 --- a/examples/folder/list_sf.py +++ b/examples/cli_examples/folder/list_sf.py @@ -44,10 +44,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/folder/share_folder.py b/examples/cli_examples/folder/share_folder.py similarity index 97% rename from examples/folder/share_folder.py rename to examples/cli_examples/folder/share_folder.py index 6c7ca676..04934c34 100644 --- a/examples/folder/share_folder.py +++ b/examples/cli_examples/folder/share_folder.py @@ -40,10 +40,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/folder/transform_folder.py b/examples/cli_examples/folder/transform_folder.py similarity index 97% rename from examples/folder/transform_folder.py rename to examples/cli_examples/folder/transform_folder.py index 373f9def..5c6a5e60 100644 --- a/examples/folder/transform_folder.py +++ b/examples/cli_examples/folder/transform_folder.py @@ -44,10 +44,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/importing_exporting/apply_membership.py b/examples/cli_examples/importing_exporting/apply_membership.py similarity index 97% rename from examples/importing_exporting/apply_membership.py rename to examples/cli_examples/importing_exporting/apply_membership.py index 6a96729c..8e5516be 100644 --- a/examples/importing_exporting/apply_membership.py +++ b/examples/cli_examples/importing_exporting/apply_membership.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/importing_exporting/download_membership.py b/examples/cli_examples/importing_exporting/download_membership.py similarity index 97% rename from examples/importing_exporting/download_membership.py rename to examples/cli_examples/importing_exporting/download_membership.py index a62e0e2e..bb00b8f3 100644 --- a/examples/importing_exporting/download_membership.py +++ b/examples/cli_examples/importing_exporting/download_membership.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/importing_exporting/export_data.py b/examples/cli_examples/importing_exporting/export_data.py similarity index 97% rename from examples/importing_exporting/export_data.py rename to examples/cli_examples/importing_exporting/export_data.py index 0b8c6f1c..5d4af0b7 100644 --- a/examples/importing_exporting/export_data.py +++ b/examples/cli_examples/importing_exporting/export_data.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/importing_exporting/import_data.py b/examples/cli_examples/importing_exporting/import_data.py similarity index 97% rename from examples/importing_exporting/import_data.py rename to examples/cli_examples/importing_exporting/import_data.py index 0a470283..d5d422a7 100644 --- a/examples/importing_exporting/import_data.py +++ b/examples/cli_examples/importing_exporting/import_data.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/miscellaneous/clipboard_copy.py b/examples/cli_examples/miscellaneous/clipboard_copy.py similarity index 97% rename from examples/miscellaneous/clipboard_copy.py rename to examples/cli_examples/miscellaneous/clipboard_copy.py index 2b0cebdd..4d8d9a89 100644 --- a/examples/miscellaneous/clipboard_copy.py +++ b/examples/cli_examples/miscellaneous/clipboard_copy.py @@ -44,10 +44,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/miscellaneous/find_ownerless.py b/examples/cli_examples/miscellaneous/find_ownerless.py similarity index 97% rename from examples/miscellaneous/find_ownerless.py rename to examples/cli_examples/miscellaneous/find_ownerless.py index 05a1274b..f51cb16c 100644 --- a/examples/miscellaneous/find_ownerless.py +++ b/examples/cli_examples/miscellaneous/find_ownerless.py @@ -44,10 +44,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/miscellaneous/list_team.py b/examples/cli_examples/miscellaneous/list_team.py similarity index 97% rename from examples/miscellaneous/list_team.py rename to examples/cli_examples/miscellaneous/list_team.py index 6490b8e5..3f422211 100644 --- a/examples/miscellaneous/list_team.py +++ b/examples/cli_examples/miscellaneous/list_team.py @@ -44,10 +44,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/miscellaneous/password-report.py b/examples/cli_examples/miscellaneous/password-report.py similarity index 97% rename from examples/miscellaneous/password-report.py rename to examples/cli_examples/miscellaneous/password-report.py index 96f165ba..94919480 100644 --- a/examples/miscellaneous/password-report.py +++ b/examples/cli_examples/miscellaneous/password-report.py @@ -40,10 +40,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/one_time_share/create_one_time_share.py b/examples/cli_examples/one_time_share/create_one_time_share.py similarity index 97% rename from examples/one_time_share/create_one_time_share.py rename to examples/cli_examples/one_time_share/create_one_time_share.py index 83caeef6..27dd2f84 100644 --- a/examples/one_time_share/create_one_time_share.py +++ b/examples/cli_examples/one_time_share/create_one_time_share.py @@ -39,10 +39,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/one_time_share/list_one_time_shares.py b/examples/cli_examples/one_time_share/list_one_time_shares.py similarity index 97% rename from examples/one_time_share/list_one_time_shares.py rename to examples/cli_examples/one_time_share/list_one_time_shares.py index 1ae31518..83d84865 100644 --- a/examples/one_time_share/list_one_time_shares.py +++ b/examples/cli_examples/one_time_share/list_one_time_shares.py @@ -39,10 +39,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/one_time_share/remove_one_time_share.py b/examples/cli_examples/one_time_share/remove_one_time_share.py similarity index 96% rename from examples/one_time_share/remove_one_time_share.py rename to examples/cli_examples/one_time_share/remove_one_time_share.py index 8226c2d1..0ef96c68 100644 --- a/examples/one_time_share/remove_one_time_share.py +++ b/examples/cli_examples/one_time_share/remove_one_time_share.py @@ -39,10 +39,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/password/advanced_password_generation.py b/examples/cli_examples/password/advanced_password_generation.py similarity index 97% rename from examples/password/advanced_password_generation.py rename to examples/cli_examples/password/advanced_password_generation.py index 57c3930a..62a50ce5 100644 --- a/examples/password/advanced_password_generation.py +++ b/examples/cli_examples/password/advanced_password_generation.py @@ -60,10 +60,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/password/basic_password_generation.py b/examples/cli_examples/password/basic_password_generation.py similarity index 97% rename from examples/password/basic_password_generation.py rename to examples/cli_examples/password/basic_password_generation.py index bb46c550..250d0e80 100644 --- a/examples/password/basic_password_generation.py +++ b/examples/cli_examples/password/basic_password_generation.py @@ -59,10 +59,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/password/comprehensive_password_generation.py b/examples/cli_examples/password/comprehensive_password_generation.py similarity index 99% rename from examples/password/comprehensive_password_generation.py rename to examples/cli_examples/password/comprehensive_password_generation.py index 89085fe0..7d6e3a87 100644 --- a/examples/password/comprehensive_password_generation.py +++ b/examples/cli_examples/password/comprehensive_password_generation.py @@ -59,10 +59,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/password/crypto_password_generation.py b/examples/cli_examples/password/crypto_password_generation.py similarity index 97% rename from examples/password/crypto_password_generation.py rename to examples/cli_examples/password/crypto_password_generation.py index 24353959..ba5c802d 100644 --- a/examples/password/crypto_password_generation.py +++ b/examples/cli_examples/password/crypto_password_generation.py @@ -59,10 +59,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/password/diceware_password_generation.py b/examples/cli_examples/password/diceware_password_generation.py similarity index 97% rename from examples/password/diceware_password_generation.py rename to examples/cli_examples/password/diceware_password_generation.py index 9fc0b9e1..99e2fdc2 100644 --- a/examples/password/diceware_password_generation.py +++ b/examples/cli_examples/password/diceware_password_generation.py @@ -59,10 +59,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/password/recovery_phrase_generation.py b/examples/cli_examples/password/recovery_phrase_generation.py similarity index 97% rename from examples/password/recovery_phrase_generation.py rename to examples/cli_examples/password/recovery_phrase_generation.py index 2b648ea5..0ddac9fb 100644 --- a/examples/password/recovery_phrase_generation.py +++ b/examples/cli_examples/password/recovery_phrase_generation.py @@ -59,10 +59,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/record/add_record.py b/examples/cli_examples/record/add_record.py similarity index 97% rename from examples/record/add_record.py rename to examples/cli_examples/record/add_record.py index a494ca72..1f85a1f3 100644 --- a/examples/record/add_record.py +++ b/examples/cli_examples/record/add_record.py @@ -40,10 +40,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/record/delete_attachment.py b/examples/cli_examples/record/delete_attachment.py similarity index 97% rename from examples/record/delete_attachment.py rename to examples/cli_examples/record/delete_attachment.py index 63aa6a87..5851a6d8 100644 --- a/examples/record/delete_attachment.py +++ b/examples/cli_examples/record/delete_attachment.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/record/delete_record.py b/examples/cli_examples/record/delete_record.py similarity index 97% rename from examples/record/delete_record.py rename to examples/cli_examples/record/delete_record.py index 525879ba..48fd1575 100644 --- a/examples/record/delete_record.py +++ b/examples/cli_examples/record/delete_record.py @@ -40,10 +40,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/record/download_attachment.py b/examples/cli_examples/record/download_attachment.py similarity index 97% rename from examples/record/download_attachment.py rename to examples/cli_examples/record/download_attachment.py index 0d7d6bd4..dc21a465 100644 --- a/examples/record/download_attachment.py +++ b/examples/cli_examples/record/download_attachment.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/record/file_report.py b/examples/cli_examples/record/file_report.py similarity index 96% rename from examples/record/file_report.py rename to examples/cli_examples/record/file_report.py index 2414df41..5bed6cc0 100644 --- a/examples/record/file_report.py +++ b/examples/cli_examples/record/file_report.py @@ -44,10 +44,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/record/find_duplicate.py b/examples/cli_examples/record/find_duplicate.py similarity index 98% rename from examples/record/find_duplicate.py rename to examples/cli_examples/record/find_duplicate.py index aaef8da1..047120ae 100644 --- a/examples/record/find_duplicate.py +++ b/examples/cli_examples/record/find_duplicate.py @@ -44,10 +44,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/record/get_command.py b/examples/cli_examples/record/get_command.py similarity index 97% rename from examples/record/get_command.py rename to examples/cli_examples/record/get_command.py index eab723a1..0ae9c653 100644 --- a/examples/record/get_command.py +++ b/examples/cli_examples/record/get_command.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/record/list_records.py b/examples/cli_examples/record/list_records.py similarity index 97% rename from examples/record/list_records.py rename to examples/cli_examples/record/list_records.py index 315915b6..da31745c 100644 --- a/examples/record/list_records.py +++ b/examples/cli_examples/record/list_records.py @@ -40,10 +40,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/record/record_history.py b/examples/cli_examples/record/record_history.py similarity index 97% rename from examples/record/record_history.py rename to examples/cli_examples/record/record_history.py index 60daf6f3..ca1f68fd 100644 --- a/examples/record/record_history.py +++ b/examples/cli_examples/record/record_history.py @@ -44,10 +44,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/record/search_record.py b/examples/cli_examples/record/search_record.py similarity index 97% rename from examples/record/search_record.py rename to examples/cli_examples/record/search_record.py index 02b54df4..412f34ae 100644 --- a/examples/record/search_record.py +++ b/examples/cli_examples/record/search_record.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/record/share_record.py b/examples/cli_examples/record/share_record.py similarity index 97% rename from examples/record/share_record.py rename to examples/cli_examples/record/share_record.py index c16f7dff..0dbc2de6 100644 --- a/examples/record/share_record.py +++ b/examples/cli_examples/record/share_record.py @@ -39,10 +39,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/record/share_record_permissions.py b/examples/cli_examples/record/share_record_permissions.py similarity index 98% rename from examples/record/share_record_permissions.py rename to examples/cli_examples/record/share_record_permissions.py index 8bc255ab..b25c89c6 100644 --- a/examples/record/share_record_permissions.py +++ b/examples/cli_examples/record/share_record_permissions.py @@ -44,10 +44,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/record/update_record.py b/examples/cli_examples/record/update_record.py similarity index 98% rename from examples/record/update_record.py rename to examples/cli_examples/record/update_record.py index a93caea2..cac52871 100644 --- a/examples/record/update_record.py +++ b/examples/cli_examples/record/update_record.py @@ -40,10 +40,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/record/upload_attachment.py b/examples/cli_examples/record/upload_attachment.py similarity index 97% rename from examples/record/upload_attachment.py rename to examples/cli_examples/record/upload_attachment.py index c2bbd201..f9c68517 100644 --- a/examples/record/upload_attachment.py +++ b/examples/cli_examples/record/upload_attachment.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/secrets_manager_app/create_secrets_manager_app.py b/examples/cli_examples/secrets_manager_app/create_secrets_manager_app.py similarity index 97% rename from examples/secrets_manager_app/create_secrets_manager_app.py rename to examples/cli_examples/secrets_manager_app/create_secrets_manager_app.py index 27b2ec13..dcd1c5a5 100644 --- a/examples/secrets_manager_app/create_secrets_manager_app.py +++ b/examples/cli_examples/secrets_manager_app/create_secrets_manager_app.py @@ -39,10 +39,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/secrets_manager_app/get_secrets_manager_app.py b/examples/cli_examples/secrets_manager_app/get_secrets_manager_app.py similarity index 98% rename from examples/secrets_manager_app/get_secrets_manager_app.py rename to examples/cli_examples/secrets_manager_app/get_secrets_manager_app.py index ed12f5db..d1ec11ae 100644 --- a/examples/secrets_manager_app/get_secrets_manager_app.py +++ b/examples/cli_examples/secrets_manager_app/get_secrets_manager_app.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/secrets_manager_app/list_secrets_manager_apps.py b/examples/cli_examples/secrets_manager_app/list_secrets_manager_apps.py similarity index 97% rename from examples/secrets_manager_app/list_secrets_manager_apps.py rename to examples/cli_examples/secrets_manager_app/list_secrets_manager_apps.py index 07a0cb74..07ada892 100644 --- a/examples/secrets_manager_app/list_secrets_manager_apps.py +++ b/examples/cli_examples/secrets_manager_app/list_secrets_manager_apps.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/secrets_manager_app/remove_secrets_manager_app.py b/examples/cli_examples/secrets_manager_app/remove_secrets_manager_app.py similarity index 97% rename from examples/secrets_manager_app/remove_secrets_manager_app.py rename to examples/cli_examples/secrets_manager_app/remove_secrets_manager_app.py index 5854b577..2ec4d557 100644 --- a/examples/secrets_manager_app/remove_secrets_manager_app.py +++ b/examples/cli_examples/secrets_manager_app/remove_secrets_manager_app.py @@ -39,10 +39,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/secrets_manager_app/secrets_manager_app_add_record.py b/examples/cli_examples/secrets_manager_app/secrets_manager_app_add_record.py similarity index 97% rename from examples/secrets_manager_app/secrets_manager_app_add_record.py rename to examples/cli_examples/secrets_manager_app/secrets_manager_app_add_record.py index d48dca7b..b49c388d 100644 --- a/examples/secrets_manager_app/secrets_manager_app_add_record.py +++ b/examples/cli_examples/secrets_manager_app/secrets_manager_app_add_record.py @@ -40,10 +40,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/secrets_manager_app/secrets_manager_app_remove_record.py b/examples/cli_examples/secrets_manager_app/secrets_manager_app_remove_record.py similarity index 97% rename from examples/secrets_manager_app/secrets_manager_app_remove_record.py rename to examples/cli_examples/secrets_manager_app/secrets_manager_app_remove_record.py index cb99fbb7..8471ee53 100644 --- a/examples/secrets_manager_app/secrets_manager_app_remove_record.py +++ b/examples/cli_examples/secrets_manager_app/secrets_manager_app_remove_record.py @@ -41,10 +41,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/secrets_manager_app/secrets_manager_client_add.py b/examples/cli_examples/secrets_manager_app/secrets_manager_client_add.py similarity index 98% rename from examples/secrets_manager_app/secrets_manager_client_add.py rename to examples/cli_examples/secrets_manager_app/secrets_manager_client_add.py index 284c88e4..9713efc6 100644 --- a/examples/secrets_manager_app/secrets_manager_client_add.py +++ b/examples/cli_examples/secrets_manager_app/secrets_manager_client_add.py @@ -40,10 +40,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/secrets_manager_app/secrets_manager_client_remove.py b/examples/cli_examples/secrets_manager_app/secrets_manager_client_remove.py similarity index 97% rename from examples/secrets_manager_app/secrets_manager_client_remove.py rename to examples/cli_examples/secrets_manager_app/secrets_manager_client_remove.py index 6200886b..93ef1314 100644 --- a/examples/secrets_manager_app/secrets_manager_client_remove.py +++ b/examples/cli_examples/secrets_manager_app/secrets_manager_client_remove.py @@ -41,10 +41,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/secrets_manager_app/share_secrets_manager_app.py b/examples/cli_examples/secrets_manager_app/share_secrets_manager_app.py similarity index 97% rename from examples/secrets_manager_app/share_secrets_manager_app.py rename to examples/cli_examples/secrets_manager_app/share_secrets_manager_app.py index 9e94dbd4..0a866bc3 100644 --- a/examples/secrets_manager_app/share_secrets_manager_app.py +++ b/examples/cli_examples/secrets_manager_app/share_secrets_manager_app.py @@ -39,10 +39,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/secrets_manager_app/unshare_secrets_manager_app.py b/examples/cli_examples/secrets_manager_app/unshare_secrets_manager_app.py similarity index 97% rename from examples/secrets_manager_app/unshare_secrets_manager_app.py rename to examples/cli_examples/secrets_manager_app/unshare_secrets_manager_app.py index 39520ba0..2a463b6c 100644 --- a/examples/secrets_manager_app/unshare_secrets_manager_app.py +++ b/examples/cli_examples/secrets_manager_app/unshare_secrets_manager_app.py @@ -41,10 +41,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/trash/trash_get.py b/examples/cli_examples/trash/trash_get.py similarity index 96% rename from examples/trash/trash_get.py rename to examples/cli_examples/trash/trash_get.py index 62b1880d..bd3dcd5e 100644 --- a/examples/trash/trash_get.py +++ b/examples/cli_examples/trash/trash_get.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/trash/trash_list.py b/examples/cli_examples/trash/trash_list.py similarity index 97% rename from examples/trash/trash_list.py rename to examples/cli_examples/trash/trash_list.py index 81322c08..55e086d8 100644 --- a/examples/trash/trash_list.py +++ b/examples/cli_examples/trash/trash_list.py @@ -40,10 +40,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/trash/trash_purge.py b/examples/cli_examples/trash/trash_purge.py similarity index 96% rename from examples/trash/trash_purge.py rename to examples/cli_examples/trash/trash_purge.py index f97d5bd8..e92997ca 100644 --- a/examples/trash/trash_purge.py +++ b/examples/cli_examples/trash/trash_purge.py @@ -43,10 +43,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/trash/trash_restore.py b/examples/cli_examples/trash/trash_restore.py similarity index 97% rename from examples/trash/trash_restore.py rename to examples/cli_examples/trash/trash_restore.py index 1355c06a..fc862ece 100644 --- a/examples/trash/trash_restore.py +++ b/examples/cli_examples/trash/trash_restore.py @@ -44,10 +44,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/trash/trash_unshare.py b/examples/cli_examples/trash/trash_unshare.py similarity index 97% rename from examples/trash/trash_unshare.py rename to examples/cli_examples/trash/trash_unshare.py index eb6a6056..efa01c05 100644 --- a/examples/trash/trash_unshare.py +++ b/examples/cli_examples/trash/trash_unshare.py @@ -44,10 +44,6 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: if not username: raise ValueError('Username not found in config file') context = KeeperParams(config_filename=filename, config=config_data) - if username: - context.username = username - if password: - context.password = password logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) if not logged_in: raise Exception('Failed to authenticate with Keeper') diff --git a/examples/sdk_examples/audit_alert/list_alerts.py b/examples/sdk_examples/audit_alert/list_alerts.py new file mode 100644 index 00000000..dd84e1c5 --- /dev/null +++ b/examples/sdk_examples/audit_alert/list_alerts.py @@ -0,0 +1,146 @@ +import getpass + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.constants import KEEPER_PUBLIC_HOSTS +from keepersdk.errors import KeeperApiError + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def list_alerts(keeper_auth_context: keeper_auth.KeeperAuth): + """ + List all audit alerts. + + Args: + keeper_auth_context: The authenticated Keeper context with enterprise admin privileges. + """ + if not keeper_auth_context.auth_context.is_enterprise_admin: + print("ERROR: This operation requires enterprise admin privileges.") + print("The current user is not an enterprise administrator.") + keeper_auth_context.close() + return + + try: + rq = { + 'command': 'get_enterprise_setting', + 'include': ['AuditAlertContext', 'AuditAlertFilter'] + } + settings = keeper_auth_context.execute_auth_command(rq) + + alert_filters = settings.get('AuditAlertFilter', []) + alert_context = settings.get('AuditAlertContext', []) + + if not alert_filters: + print("\nNo audit alerts configured") + else: + print("\nAudit Alerts") + print("=" * 120) + print(f"{'ID':<8} {'Name':<30} {'Frequency':<20} {'Active':<10} {'Recipients':<20}") + print("-" * 120) + + for alert in alert_filters: + alert_id = alert.get('id', 'N/A') + alert_name = alert.get('name', 'N/A') + + frequency_data = alert.get('frequency', {}) + if isinstance(frequency_data, dict): + period = frequency_data.get('period', 'event') + count = frequency_data.get('count', '') + frequency = f"{count} {period}" if count else period + else: + frequency = 'event' + + active = 'Yes' if alert.get('active', False) else 'No' + + context_entry = next((x for x in alert_context if x.get('id') == alert_id), None) + recipients_count = 0 + if context_entry: + recipients = context_entry.get('recipients', []) + recipients_count = len(recipients) if isinstance(recipients, list) else 0 + + print(f"{str(alert_id):<8} {alert_name[:29]:<30} {frequency[:19]:<20} {active:<10} {recipients_count:<20}") + + print("-" * 120) + print(f"Total alerts: {len(alert_filters)}") + print("=" * 120) + + keeper_auth_context.close() + + except KeeperApiError as e: + print(f"\nAPI Error: {e}") + keeper_auth_context.close() + except Exception as e: + print(f"\nError loading audit alerts: {e}") + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the list alerts script. + Performs login and lists audit alerts. + """ + keeper_auth_context = login() + + if keeper_auth_context: + list_alerts(keeper_auth_context) + else: + print("Login failed. Unable to list alerts.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/audit_alert/view_alert.py b/examples/sdk_examples/audit_alert/view_alert.py new file mode 100644 index 00000000..2a014a0c --- /dev/null +++ b/examples/sdk_examples/audit_alert/view_alert.py @@ -0,0 +1,168 @@ +import getpass + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.constants import KEEPER_PUBLIC_HOSTS +from keepersdk.errors import KeeperApiError + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def view_alert(keeper_auth_context: keeper_auth.KeeperAuth): + """ + View details of a specific audit alert. + + Args: + keeper_auth_context: The authenticated Keeper context with enterprise admin privileges. + """ + if not keeper_auth_context.auth_context.is_enterprise_admin: + print("ERROR: This operation requires enterprise admin privileges.") + print("The current user is not an enterprise administrator.") + keeper_auth_context.close() + return + + try: + rq = { + 'command': 'get_enterprise_setting', + 'include': ['AuditAlertContext', 'AuditAlertFilter'] + } + settings = keeper_auth_context.execute_auth_command(rq) + + alert_filters = settings.get('AuditAlertFilter', []) + alert_context = settings.get('AuditAlertContext', []) + + alert_id_or_name = input('Enter alert ID or name: ').strip() + + if not alert_id_or_name: + print("Alert identifier cannot be empty") + else: + alert_found = None + alert_id_num = int(alert_id_or_name) if alert_id_or_name.isnumeric() else 0 + + for alert in alert_filters: + if alert_id_num > 0 and alert.get('id') == alert_id_num: + alert_found = alert + break + elif alert.get('name', '').lower() == alert_id_or_name.lower(): + alert_found = alert + break + + if not alert_found: + print(f"Alert '{alert_id_or_name}' not found") + else: + print("\nAudit Alert Details") + print("=" * 100) + print(f"ID: {alert_found.get('id', 'N/A')}") + print(f"Name: {alert_found.get('name', 'N/A')}") + print(f"Active: {'Yes' if alert_found.get('active', False) else 'No'}") + + frequency_data = alert_found.get('frequency', {}) + if isinstance(frequency_data, dict): + period = frequency_data.get('period', 'event') + count = frequency_data.get('count', '') + print(f"Frequency: {count} {period}" if count else f"Frequency: {period}") + + alert_filter = alert_found.get('filter', {}) + if alert_filter: + print("\nFilters:") + for key, value in alert_filter.items(): + print(f" {key}: {value}") + + context_entry = next((x for x in alert_context if x.get('id') == alert_found.get('id')), None) + if context_entry: + recipients = context_entry.get('recipients', []) + if recipients and isinstance(recipients, list): + print(f"\nRecipients ({len(recipients)}):") + for recipient in recipients: + print(f" - {recipient}") + + triggers = context_entry.get('triggers', []) + if triggers and isinstance(triggers, list): + print(f"\nTriggers ({len(triggers)}):") + for trigger in triggers[:5]: + timestamp = trigger.get('timestamp', 'N/A') + print(f" - {timestamp}") + if len(triggers) > 5: + print(f" ... and {len(triggers) - 5} more") + + print("=" * 100) + + keeper_auth_context.close() + + except KeeperApiError as e: + print(f"\nAPI Error: {e}") + keeper_auth_context.close() + except Exception as e: + print(f"\nError loading audit alert: {e}") + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the view alert script. + Performs login and displays alert details. + """ + keeper_auth_context = login() + + if keeper_auth_context: + view_alert(keeper_auth_context) + else: + print("Login failed. Unable to view alert.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/audit_report/audit_log.py b/examples/sdk_examples/audit_report/audit_log.py new file mode 100644 index 00000000..c179ecf5 --- /dev/null +++ b/examples/sdk_examples/audit_report/audit_log.py @@ -0,0 +1,159 @@ +import getpass + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.constants import KEEPER_PUBLIC_HOSTS +from keepersdk.enterprise import audit_report +from keepersdk.errors import KeeperApiError + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def view_audit_log(keeper_auth_context: keeper_auth.KeeperAuth): + """ + View the audit log. + + Args: + keeper_auth_context: The authenticated Keeper context with enterprise admin privileges. + """ + if not keeper_auth_context.auth_context.is_enterprise_admin: + print("ERROR: This operation requires enterprise admin privileges.") + print("The current user is not an enterprise administrator.") + keeper_auth_context.close() + return + + try: + report = audit_report.RawAuditReport(keeper_auth_context) + + limit_input = input('Enter number of events to retrieve (default 50, -1 for all): ').strip() + if limit_input: + report.limit = int(limit_input) + else: + report.limit = 50 + + use_filter = input('Apply filters? (y/n): ').strip().lower() == 'y' + + if use_filter: + filter_obj = audit_report.AuditReportFilter() + + time_range = input('Time range (today/yesterday/last_7_days/last_30_days or press Enter to skip): ').strip() + if time_range: + filter_obj.created = time_range + + event_type = input('Event type (e.g., login, or press Enter to skip): ').strip() + if event_type: + filter_obj.event_type = event_type + + username_filter = input('Username filter (or press Enter to skip): ').strip() + if username_filter: + filter_obj.username = username_filter + + report.filter = filter_obj + + print("\nFetching audit log events...") + print("=" * 120) + + event_count = 0 + for event in report.execute_audit_report(): + event_count += 1 + + created = event.get('created', 'N/A') + username = event.get('username', 'N/A') + audit_event_type = event.get('audit_event_type', 'N/A') + ip_address = event.get('ip_address', 'N/A') + keeper_version = event.get('keeper_version', 'N/A') + + print(f"\nEvent #{event_count}") + print(f" Timestamp: {created}") + print(f" User: {username}") + print(f" Event Type: {audit_event_type}") + print(f" IP Address: {ip_address}") + print(f" Keeper Version: {keeper_version}") + + if event_count >= 10 and report.limit > 10: + more = input(f"\nShowing {event_count} of {report.limit if report.limit > 0 else 'all'} events. Continue? (y/n): ").strip().lower() + if more != 'y': + break + + print("\n" + "=" * 120) + print(f"Total events displayed: {event_count}") + + keeper_auth_context.close() + + except KeeperApiError as e: + print(f"\nAPI Error: {e}") + keeper_auth_context.close() + except Exception as e: + print(f"\nError retrieving audit log: {e}") + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the audit log script. + Performs login and displays audit log events. + """ + keeper_auth_context = login() + + if keeper_auth_context: + view_audit_log(keeper_auth_context) + else: + print("Login failed. Unable to view audit log.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/audit_report/audit_summary.py b/examples/sdk_examples/audit_report/audit_summary.py new file mode 100644 index 00000000..967093eb --- /dev/null +++ b/examples/sdk_examples/audit_report/audit_summary.py @@ -0,0 +1,155 @@ +import getpass + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.constants import KEEPER_PUBLIC_HOSTS +from keepersdk.enterprise import audit_report +from keepersdk.errors import KeeperApiError + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def generate_audit_summary(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Generate an audit summary report. + + Args: + keeper_auth_context: The authenticated Keeper context with enterprise admin privileges. + """ + if not keeper_auth_context.auth_context.is_enterprise_admin: + print("ERROR: This operation requires enterprise admin privileges.") + print("The current user is not an enterprise administrator.") + keeper_auth_context.close() + return + + try: + report = audit_report.SummaryAuditReport(keeper_auth_context) + + print("Available summary types: hour, day, week, month, span") + summary_type = input('Enter summary type (default: day): ').strip() or 'day' + report.summary_type = summary_type + + limit_input = input('Enter number of summary rows (default 100, max 2000): ').strip() + if limit_input: + report.limit = int(limit_input) + else: + report.limit = 100 + + use_filter = input('Apply filters? (y/n): ').strip().lower() == 'y' + + if use_filter: + filter_obj = audit_report.AuditReportFilter() + + time_range = input('Time range (today/yesterday/last_7_days/last_30_days or press Enter to skip): ').strip() + if time_range: + filter_obj.created = time_range + + event_type = input('Event type (e.g., login, or press Enter to skip): ').strip() + if event_type: + filter_obj.event_type = event_type + + report.filter = filter_obj + + add_aggregates = input('Add aggregates? (y/n): ').strip().lower() == 'y' + if add_aggregates: + report.aggregates = ['occurrences', 'first_created', 'last_created'] + + add_columns = input('Add grouping columns? (y/n): ').strip().lower() == 'y' + if add_columns: + columns_input = input('Enter columns (comma-separated, e.g., audit_event_type,username): ').strip() + if columns_input: + report.columns = [col.strip() for col in columns_input.split(',')] + + print(f"\nFetching {summary_type} summary report...") + print("=" * 120) + + row_count = 0 + for row in report.execute_summary_report(): + row_count += 1 + + print(f"\nRow #{row_count}") + for key, value in row.items(): + print(f" {key}: {value}") + + print("\n" + "=" * 120) + print(f"Total summary rows: {row_count}") + + keeper_auth_context.close() + + except KeeperApiError as e: + print(f"\nAPI Error: {e}") + keeper_auth_context.close() + except Exception as e: + print(f"\nError generating summary report: {e}") + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the audit summary script. + Performs login and generates audit summary report. + """ + keeper_auth_context = login() + + if keeper_auth_context: + generate_audit_summary(keeper_auth_context) + else: + print("Login failed. Unable to generate audit summary.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/auth/login.py b/examples/sdk_examples/auth/login.py new file mode 100644 index 00000000..b2404fda --- /dev/null +++ b/examples/sdk_examples/auth/login.py @@ -0,0 +1,99 @@ +import getpass + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + tuple: (keeper_auth_context, keeper_endpoint) on success, or (None, None) if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth(), keeper_endpoint + + return None, None + + +def display_login_info(keeper_auth_context: keeper_auth.KeeperAuth, keeper_endpoint: endpoint.KeeperEndpoint): + """ + Display login success information. + + Args: + keeper_auth_context: The authenticated Keeper context. + keeper_endpoint: The Keeper endpoint with server information. + """ + print("\n" + "=" * 50) + print("LOGIN SUCCESSFUL") + print("=" * 50) + print(f"Username: {keeper_auth_context.auth_context.username}") + print(f"Server: {keeper_endpoint.server}") + print(f"Enterprise Admin: {keeper_auth_context.auth_context.is_enterprise_admin}") + if keeper_auth_context.auth_context.enterprise_id: + print(f"Enterprise ID: {keeper_auth_context.auth_context.enterprise_id}") + print("=" * 50) + + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the login script. + Performs login and displays login information. + """ + keeper_auth_context, keeper_endpoint = login() + + if keeper_auth_context: + display_login_info(keeper_auth_context, keeper_endpoint) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/auth/logout.py b/examples/sdk_examples/auth/logout.py new file mode 100644 index 00000000..109bf84f --- /dev/null +++ b/examples/sdk_examples/auth/logout.py @@ -0,0 +1,101 @@ +import getpass + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def perform_logout(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Perform logout after user confirmation. + + Args: + keeper_auth_context: The authenticated Keeper context. + """ + print(f"\nLogged in as: {keeper_auth_context.auth_context.username}") + + confirm = input("\nDo you want to logout? (yes/no): ").strip().lower() + + if confirm == 'yes': + keeper_auth_context.close() + print("\n" + "=" * 50) + print("LOGOUT SUCCESSFUL") + print("=" * 50) + print("Session has been closed.") + print("=" * 50) + else: + print("Logout cancelled. Session still active.") + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the logout script. + Performs login and then offers logout option. + """ + keeper_auth_context = login() + + if keeper_auth_context: + perform_logout(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/auth/whoami.py b/examples/sdk_examples/auth/whoami.py new file mode 100644 index 00000000..81fe6203 --- /dev/null +++ b/examples/sdk_examples/auth/whoami.py @@ -0,0 +1,118 @@ +import getpass + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk import utils + + +def login(): + """ + Handle the login process including authentication and multi-factor authentication steps. + + Returns: + tuple: (keeper_auth_context, keeper_endpoint) on success, or (None, None) if login fails. + """ + config = configuration.JsonConfigurationStorage() + keeper_endpoint = endpoint.KeeperEndpoint(config) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).name}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth(), keeper_endpoint + + return None, None + + +def display_whoami(keeper_auth_context: keeper_auth.KeeperAuth, keeper_endpoint: endpoint.KeeperEndpoint): + """ + Display current user information. + + Args: + keeper_auth_context: The authenticated Keeper context. + keeper_endpoint: The Keeper endpoint with server information. + """ + auth = keeper_auth_context.auth_context + + print("\n" + "=" * 60) + print("WHO AM I - Current User Information") + print("=" * 60) + + print(f"\nUsername: {auth.username}") + print(f"Server: {keeper_endpoint.server}") + + if auth.account_uid: + print(f"Account UID: {utils.base64_url_encode(auth.account_uid)}") + + print(f"\nEnterprise Admin: {'Yes' if auth.is_enterprise_admin else 'No'}") + if auth.enterprise_id: + print(f"Enterprise ID: {auth.enterprise_id}") + + if hasattr(auth, 'is_mc_superadmin') and auth.is_mc_superadmin: + print(f"MC Superadmin: Yes") + + if auth.license: + print(f"\nLicense Information:") + for key, value in auth.license.items(): + if isinstance(value, (str, int, bool)): + print(f" {key}: {value}") + + if auth.settings: + print(f"\nAccount Settings:") + for key, value in auth.settings.items(): + if isinstance(value, (str, int, bool)): + print(f" {key}: {value}") + + if auth.sso_login_info: + print(f"\nSSO Login: Enabled") + print(f" SSO Provider: {auth.sso_login_info.sso_provider if hasattr(auth.sso_login_info, 'sso_provider') else 'N/A'}") + + session_restriction = auth.session_token_restriction + print(f"\nSession Status: {session_restriction.name if hasattr(session_restriction, 'name') else str(session_restriction)}") + + print("\n" + "=" * 60) + + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the whoami script. + Performs login and displays current user information. + """ + keeper_auth_context, keeper_endpoint = login() + + if keeper_auth_context: + display_whoami(keeper_auth_context, keeper_endpoint) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/breachwatch/breach_status.py b/examples/sdk_examples/breachwatch/breach_status.py new file mode 100644 index 00000000..48cc08c7 --- /dev/null +++ b/examples/sdk_examples/breachwatch/breach_status.py @@ -0,0 +1,151 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def show_breach_status(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Display BreachWatch status for the vault. + + Args: + keeper_auth_context: The authenticated Keeper context. + """ + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + print("\nBreachWatch Status") + print("=" * 100) + + if not vault.breach_watch_plugin(): + print("Status: DISABLED") + print("\nBreachWatch is not enabled for this account.") + print("Contact your administrator to enable BreachWatch.") + else: + print("Status: ENABLED") + + breach_watch = vault.breach_watch_plugin().breach_watch + if not breach_watch: + print("Scanner: UNAVAILABLE") + print("\nUnable to initialize BreachWatch scanner.") + else: + print("Scanner: READY") + + total_records = 0 + breached_records = 0 + secure_records = 0 + + for record_info in vault.vault_data.records(): + if record_info.version not in (2, 3): + continue + + total_records += 1 + + bw_record = vault.vault_data.storage.breach_watch_records.get_entity(record_info.record_uid) + if bw_record: + breached_records += 1 + else: + secure_records += 1 + + print(f"\nVault Statistics:") + print(f" Total Records: {total_records}") + print(f" Breached Records: {breached_records}") + print(f" Secure Records: {secure_records}") + + if total_records > 0: + breach_percentage = (breached_records / total_records) * 100 + print(f" Breach Percentage: {breach_percentage:.1f}%") + + if breached_records > 0: + print(f"\nWARNING: {breached_records} record(s) with breached passwords detected!") + print(" Run 'list_breaches.py' to see details and update affected passwords.") + else: + print("\nAll passwords are secure! No breaches detected.") + else: + print("\n No records found in vault.") + + print("=" * 100) + + vault.close() + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the breach status script. + Performs login and displays BreachWatch status. + """ + keeper_auth_context = login() + + if keeper_auth_context: + show_breach_status(keeper_auth_context) + else: + print("Login failed. Unable to show breach status.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/breachwatch/list_breaches.py b/examples/sdk_examples/breachwatch/list_breaches.py new file mode 100644 index 00000000..3fe6a3bc --- /dev/null +++ b/examples/sdk_examples/breachwatch/list_breaches.py @@ -0,0 +1,149 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, vault_record +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def list_breaches(keeper_auth_context: keeper_auth.KeeperAuth): + """ + List all breached passwords in the vault. + + Args: + keeper_auth_context: The authenticated Keeper context. + """ + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + if not vault.breach_watch_plugin(): + print("BreachWatch is not enabled for this account.") + print("Please contact your administrator to enable BreachWatch.") + else: + breached_records = [] + + for record_info in vault.vault_data.records(): + if record_info.version not in (2, 3): + continue + + bw_record = vault.vault_data.storage.breach_watch_records.get_entity(record_info.record_uid) + if bw_record: + record = vault.vault_data.load_record(record_info.record_uid) + + password = None + if isinstance(record, vault_record.PasswordRecord): + password = record.password + elif isinstance(record, vault_record.TypedRecord): + for field in record.fields: + if field.field_type == 'password' and field.value: + password = field.value[0] if isinstance(field.value, list) else field.value + break + + if password: + breached_records.append({ + 'uid': record_info.record_uid, + 'title': record_info.title, + 'record': record + }) + + if not breached_records: + print("\nNo breached passwords found in your vault!") + print("Your passwords are secure.") + else: + print("\nBreached Passwords Detected") + print("=" * 100) + print(f"Found {len(breached_records)} record(s) with breached passwords") + print("=" * 100) + print(f"{'#':<5} {'Title':<40} {'Record UID':<40}") + print("-" * 100) + + for idx, br in enumerate(breached_records, 1): + title = br['title'][:39] if br['title'] else '(Untitled)' + uid = br['uid'] + print(f"{idx:<5} {title:<40} {uid:<40}") + + print("-" * 100) + print(f"\nTotal breached records: {len(breached_records)}") + print("\nRecommendation: Update these passwords immediately to secure your accounts.") + + vault.close() + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the list breaches script. + Performs login and lists breached passwords. + """ + keeper_auth_context = login() + + if keeper_auth_context: + list_breaches(keeper_auth_context) + else: + print("Login failed. Unable to list breaches.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/breachwatch/scan_password.py b/examples/sdk_examples/breachwatch/scan_password.py new file mode 100644 index 00000000..c10f222b --- /dev/null +++ b/examples/sdk_examples/breachwatch/scan_password.py @@ -0,0 +1,158 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def scan_passwords(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Scan passwords for breaches. + + Args: + keeper_auth_context: The authenticated Keeper context. + """ + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + if not vault.breach_watch_plugin(): + print("BreachWatch is not enabled for this account.") + print("Please contact your administrator to enable BreachWatch.") + else: + breach_watch = vault.breach_watch_plugin().breach_watch + + if not breach_watch: + print("Unable to initialize BreachWatch scanner.") + else: + passwords_to_scan = [] + + print("Enter passwords to scan (one per line, empty line to finish):") + while True: + pwd = input("Password: ").strip() + if not pwd: + break + passwords_to_scan.append(pwd) + + if not passwords_to_scan: + print("No passwords provided for scanning.") + else: + print(f"\nScanning {len(passwords_to_scan)} password(s)...") + print("=" * 100) + + scan_results = [] + for password, status in breach_watch.scan_passwords(passwords_to_scan): + scan_results.append({ + 'password': password, + 'status': status + }) + + if scan_results: + print(f"{'Password':<30} {'Status':<20} {'Details':<50}") + print("-" * 100) + + for result in scan_results: + pwd_display = result['password'][:27] + '***' if len(result['password']) > 30 else result['password'] + status = result['status'] + + if status is None: + status_text = "ERROR" + details = "Unable to scan password" + elif hasattr(status, 'breachDetected') and status.breachDetected: + status_text = "BREACHED" + details = "This password has been found in a data breach" + else: + status_text = "SECURE" + details = "No breach detected" + + print(f"{pwd_display:<30} {status_text:<20} {details:<50}") + + print("-" * 100) + breached_count = sum(1 for r in scan_results if r['status'] and hasattr(r['status'], 'breachDetected') and r['status'].breachDetected) + secure_count = len(scan_results) - breached_count + + print(f"\nSummary: {breached_count} breached, {secure_count} secure") + if breached_count > 0: + print("\nRecommendation: Do not use breached passwords. Choose strong, unique passwords.") + + vault.close() + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the scan password script. + Performs login and scans passwords for breaches. + """ + keeper_auth_context = login() + + if keeper_auth_context: + scan_passwords(keeper_auth_context) + else: + print("Login failed. Unable to scan passwords.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/breachwatch/scan_records.py b/examples/sdk_examples/breachwatch/scan_records.py new file mode 100644 index 00000000..7ea0c8e0 --- /dev/null +++ b/examples/sdk_examples/breachwatch/scan_records.py @@ -0,0 +1,180 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, vault_record +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def scan_records(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Scan records for breached passwords. + + Args: + keeper_auth_context: The authenticated Keeper context. + """ + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + if not vault.breach_watch_plugin(): + print("BreachWatch is not enabled for this account.") + print("Please contact your administrator to enable BreachWatch.") + else: + breach_watch = vault.breach_watch_plugin().breach_watch + + if not breach_watch: + print("Unable to initialize BreachWatch scanner.") + else: + record_search = input('Enter record title or UID to scan (or "all" for all records): ').strip() + + records_to_scan = [] + + if record_search.lower() == 'all': + for record_info in vault.vault_data.records(): + if record_info.version in (2, 3): + records_to_scan.append(record_info) + else: + for record_info in vault.vault_data.records(): + if (record_search.lower() in record_info.title.lower() or + record_search == record_info.record_uid): + records_to_scan.append(record_info) + + if not records_to_scan: + print(f"No records found matching: '{record_search}'") + else: + print(f"\nScanning {len(records_to_scan)} record(s) for breached passwords...") + print("=" * 120) + + scan_results = [] + + for record_info in records_to_scan: + record = vault.vault_data.load_record(record_info.record_uid) + + passwords = [] + if isinstance(record, vault_record.PasswordRecord): + if record.password: + passwords.append(record.password) + elif isinstance(record, vault_record.TypedRecord): + for field in record.fields: + if field.field_type == 'password' and field.value: + pwd = field.value[0] if isinstance(field.value, list) else field.value + if pwd: + passwords.append(pwd) + + if passwords: + for password in passwords: + for pwd, status in breach_watch.scan_passwords([password]): + is_breached = status and hasattr(status, 'breachDetected') and status.breachDetected + scan_results.append({ + 'record_uid': record_info.record_uid, + 'title': record_info.title, + 'breached': is_breached, + 'status': status + }) + break + + if scan_results: + print(f"{'Title':<40} {'Record UID':<40} {'Status':<20}") + print("-" * 120) + + for result in scan_results: + title = result['title'][:39] if result['title'] else '(Untitled)' + uid = result['record_uid'][:39] + status_text = "BREACHED" if result['breached'] else "SECURE" + + print(f"{title:<40} {uid:<40} {status_text:<20}") + + print("-" * 120) + breached_count = sum(1 for r in scan_results if r['breached']) + secure_count = len(scan_results) - breached_count + + print(f"\nSummary: {len(scan_results)} records scanned") + print(f" - {breached_count} with breached passwords") + print(f" - {secure_count} with secure passwords") + + if breached_count > 0: + print("\nRecommendation: Update breached passwords immediately to secure your accounts.") + else: + print("\nNo passwords found in selected records.") + + vault.close() + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the scan records script. + Performs login and scans records for breached passwords. + """ + keeper_auth_context = login() + + if keeper_auth_context: + scan_records(keeper_auth_context) + else: + print("Login failed. Unable to scan records.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/enterprise_info/enterprise_info_node.py b/examples/sdk_examples/enterprise_info/enterprise_info_node.py new file mode 100644 index 00000000..bf0baa87 --- /dev/null +++ b/examples/sdk_examples/enterprise_info/enterprise_info_node.py @@ -0,0 +1,147 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage +from keepersdk.errors import KeeperApiError +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def display_enterprise_nodes(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Load and display enterprise nodes information including user, team, and role counts. + + Args: + keeper_auth_context: The authenticated Keeper context with enterprise admin privileges. + """ + if not keeper_auth_context.auth_context.is_enterprise_admin: + print("ERROR: This operation requires enterprise admin privileges.") + print("The current user is not an enterprise administrator.") + print("\nTo use enterprise info features, you need:") + print(" 1. An enterprise account") + print(" 2. Enterprise administrator role") + keeper_auth_context.close() + return + + try: + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + enterprise_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + + enterprise = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + + print("Enterprise Nodes Information") + print("=" * 100) + print(f"{'Node Name':<40} {'Node ID':<12} {'Users':<10} {'Teams':<10} {'Roles':<10}") + print("-" * 100) + + for node in enterprise.enterprise_data.nodes.get_all_entities(): + node_name = node.name if hasattr(node, 'name') and node.name else 'N/A' + node_id = str(node.node_id) if hasattr(node, 'node_id') else 'N/A' + + user_count = 0 + team_count = 0 + role_count = 0 + + for user in enterprise.enterprise_data.users.get_all_entities(): + if hasattr(user, 'node_id') and user.node_id == node.node_id: + user_count += 1 + + for team in enterprise.enterprise_data.teams.get_all_entities(): + if hasattr(team, 'node_id') and team.node_id == node.node_id: + team_count += 1 + + for role in enterprise.enterprise_data.roles.get_all_entities(): + if hasattr(role, 'node_id') and role.node_id == node.node_id: + role_count += 1 + + print(f"{node_name[:39]:<40} {node_id[:11]:<12} {user_count:<10} {team_count:<10} {role_count:<10}") + + print("=" * 100) + + total_nodes = len(list(enterprise.enterprise_data.nodes.get_all_entities())) + print(f"\nTotal nodes: {total_nodes}") + + enterprise.close() + keeper_auth_context.close() + + except KeeperApiError as e: + print(f"\nAPI Error: {e}") + keeper_auth_context.close() + except Exception as e: + print(f"\nError loading enterprise data: {e}") + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the enterprise info node script. + Performs login and displays enterprise nodes information. + """ + keeper_auth_context = login() + + if keeper_auth_context: + display_enterprise_nodes(keeper_auth_context) + else: + print("Login failed. Unable to retrieve enterprise information.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/enterprise_info/enterprise_info_role.py b/examples/sdk_examples/enterprise_info/enterprise_info_role.py new file mode 100644 index 00000000..02adff4b --- /dev/null +++ b/examples/sdk_examples/enterprise_info/enterprise_info_role.py @@ -0,0 +1,140 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage +from keepersdk.errors import KeeperApiError +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def display_enterprise_roles(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Load and display enterprise roles information. + + Args: + keeper_auth_context: The authenticated Keeper context with enterprise admin privileges. + """ + if not keeper_auth_context.auth_context.is_enterprise_admin: + print("ERROR: This operation requires enterprise admin privileges.") + print("The current user is not an enterprise administrator.") + print("\nTo use enterprise info features, you need:") + print(" 1. An enterprise account") + print(" 2. Enterprise administrator role") + keeper_auth_context.close() + return + + try: + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + enterprise_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + + enterprise = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + + print("Enterprise Roles Information") + print("=" * 100) + print(f"{'Role Name':<35} {'Role ID':<12} {'Users':<10} {'Teams':<10} {'Node':<30}") + print("-" * 100) + + for role in enterprise.enterprise_data.roles.get_all_entities(): + role_name = role.name if hasattr(role, 'name') and role.name else 'N/A' + role_id = str(role.role_id) if hasattr(role, 'role_id') else 'N/A' + + user_count = len(list(enterprise.enterprise_data.role_users.get_links_by_subject(role.role_id))) + team_count = len(list(enterprise.enterprise_data.role_teams.get_links_by_subject(role.role_id))) + + node_name = "" + if hasattr(role, 'node_id') and role.node_id: + node = enterprise.enterprise_data.nodes.get_entity(role.node_id) + if node: + node_name = node.name if hasattr(node, 'name') and node.name else str(role.node_id) + + print(f"{role_name[:34]:<35} {role_id[:11]:<12} {user_count:<10} {team_count:<10} {node_name[:29]:<30}") + + print("=" * 100) + + total_roles = len(list(enterprise.enterprise_data.roles.get_all_entities())) + print(f"\nTotal roles: {total_roles}") + + enterprise.close() + keeper_auth_context.close() + + except KeeperApiError as e: + print(f"\nAPI Error: {e}") + keeper_auth_context.close() + except Exception as e: + print(f"\nError loading enterprise data: {e}") + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the enterprise info role script. + Performs login and displays enterprise roles information. + """ + keeper_auth_context = login() + + if keeper_auth_context: + display_enterprise_roles(keeper_auth_context) + else: + print("Login failed. Unable to retrieve enterprise information.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/enterprise_info/enterprise_info_team.py b/examples/sdk_examples/enterprise_info/enterprise_info_team.py new file mode 100644 index 00000000..3015115f --- /dev/null +++ b/examples/sdk_examples/enterprise_info/enterprise_info_team.py @@ -0,0 +1,139 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage +from keepersdk.errors import KeeperApiError +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def display_enterprise_teams(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Load and display enterprise teams information. + + Args: + keeper_auth_context: The authenticated Keeper context with enterprise admin privileges. + """ + if not keeper_auth_context.auth_context.is_enterprise_admin: + print("ERROR: This operation requires enterprise admin privileges.") + print("The current user is not an enterprise administrator.") + print("\nTo use enterprise info features, you need:") + print(" 1. An enterprise account") + print(" 2. Enterprise administrator role") + keeper_auth_context.close() + return + + try: + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + enterprise_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + + enterprise = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + + print("Enterprise Teams Information") + print("=" * 100) + print(f"{'Team Name':<35} {'Team UID':<25} {'Users':<10} {'Node':<30}") + print("-" * 100) + + for team in enterprise.enterprise_data.teams.get_all_entities(): + team_name = team.name if hasattr(team, 'name') and team.name else 'N/A' + team_uid = team.team_uid if hasattr(team, 'team_uid') else 'N/A' + + user_count = len(list(enterprise.enterprise_data.team_users.get_links_by_subject(team_uid))) + + node_name = "" + if hasattr(team, 'node_id') and team.node_id: + node = enterprise.enterprise_data.nodes.get_entity(team.node_id) + if node: + node_name = node.name if hasattr(node, 'name') and node.name else str(team.node_id) + + print(f"{team_name[:34]:<35} {team_uid[:24]:<25} {user_count:<10} {node_name[:29]:<30}") + + print("=" * 100) + + total_teams = len(list(enterprise.enterprise_data.teams.get_all_entities())) + print(f"\nTotal teams: {total_teams}") + + enterprise.close() + keeper_auth_context.close() + + except KeeperApiError as e: + print(f"\nAPI Error: {e}") + keeper_auth_context.close() + except Exception as e: + print(f"\nError loading enterprise data: {e}") + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the enterprise info team script. + Performs login and displays enterprise teams information. + """ + keeper_auth_context = login() + + if keeper_auth_context: + display_enterprise_teams(keeper_auth_context) + else: + print("Login failed. Unable to retrieve enterprise information.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/enterprise_info/enterprise_info_tree.py b/examples/sdk_examples/enterprise_info/enterprise_info_tree.py new file mode 100644 index 00000000..81d23965 --- /dev/null +++ b/examples/sdk_examples/enterprise_info/enterprise_info_tree.py @@ -0,0 +1,146 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage +from keepersdk.errors import KeeperApiError +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def display_enterprise_tree(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Load and display enterprise node tree structure. + + Args: + keeper_auth_context: The authenticated Keeper context with enterprise admin privileges. + """ + if not keeper_auth_context.auth_context.is_enterprise_admin: + print("ERROR: This operation requires enterprise admin privileges.") + print("The current user is not an enterprise administrator.") + print("\nTo use enterprise info features, you need:") + print(" 1. An enterprise account") + print(" 2. Enterprise administrator role") + keeper_auth_context.close() + return + + try: + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + enterprise_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + + enterprise = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + + def print_node_tree(node, indent=0): + prefix = " " * indent + node_name = node.name if hasattr(node, 'name') and node.name else f"Node {node.node_id}" + + user_count = sum(1 for user in enterprise.enterprise_data.users.get_all_entities() + if hasattr(user, 'node_id') and user.node_id == node.node_id) + team_count = sum(1 for team in enterprise.enterprise_data.teams.get_all_entities() + if hasattr(team, 'node_id') and team.node_id == node.node_id) + role_count = sum(1 for role in enterprise.enterprise_data.roles.get_all_entities() + if hasattr(role, 'node_id') and role.node_id == node.node_id) + + print(f"{prefix}├─ {node_name}") + print(f"{prefix}│ └─ Users: {user_count}, Teams: {team_count}, Roles: {role_count}") + + child_nodes = [n for n in enterprise.enterprise_data.nodes.get_all_entities() + if hasattr(n, 'parent_id') and n.parent_id == node.node_id] + + for child in child_nodes: + print_node_tree(child, indent + 1) + + print("Enterprise Node Tree Structure") + print("=" * 80) + + root_node = enterprise.enterprise_data.root_node + if root_node: + print_node_tree(root_node) + else: + print("No root node found") + + print("=" * 80) + + enterprise.close() + keeper_auth_context.close() + + except KeeperApiError as e: + print(f"\nAPI Error: {e}") + keeper_auth_context.close() + except Exception as e: + print(f"\nError loading enterprise data: {e}") + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the enterprise info tree script. + Performs login and displays enterprise node tree structure. + """ + keeper_auth_context = login() + + if keeper_auth_context: + display_enterprise_tree(keeper_auth_context) + else: + print("Login failed. Unable to retrieve enterprise information.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/enterprise_info/enterprise_info_user.py b/examples/sdk_examples/enterprise_info/enterprise_info_user.py new file mode 100644 index 00000000..5224bd59 --- /dev/null +++ b/examples/sdk_examples/enterprise_info/enterprise_info_user.py @@ -0,0 +1,138 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage +from keepersdk.errors import KeeperApiError +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def display_enterprise_users(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Load and display enterprise users information. + + Args: + keeper_auth_context: The authenticated Keeper context with enterprise admin privileges. + """ + if not keeper_auth_context.auth_context.is_enterprise_admin: + print("ERROR: This operation requires enterprise admin privileges.") + print("The current user is not an enterprise administrator.") + print("\nTo use enterprise info features, you need:") + print(" 1. An enterprise account") + print(" 2. Enterprise administrator role") + keeper_auth_context.close() + return + + try: + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + enterprise_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + + enterprise = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + + print("Enterprise Users Information") + print("=" * 100) + print(f"{'Name':<30} {'Email':<35} {'Status':<15} {'Node':<20}") + print("-" * 100) + + for user in enterprise.enterprise_data.users.get_all_entities(): + user_name = user.full_name if hasattr(user, 'full_name') and user.full_name else user.username + user_email = user.username + user_status = user.status if hasattr(user, 'status') else 'unknown' + + node_name = "" + if hasattr(user, 'node_id') and user.node_id: + node = enterprise.enterprise_data.nodes.get_entity(user.node_id) + if node: + node_name = node.name if hasattr(node, 'name') and node.name else str(user.node_id) + + print(f"{user_name[:29]:<30} {user_email[:34]:<35} {user_status:<15} {node_name[:19]:<20}") + + print("=" * 100) + + total_users = len(list(enterprise.enterprise_data.users.get_all_entities())) + print(f"\nTotal users: {total_users}") + + enterprise.close() + keeper_auth_context.close() + + except KeeperApiError as e: + print(f"\nAPI Error: {e}") + keeper_auth_context.close() + except Exception as e: + print(f"\nError loading enterprise data: {e}") + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the enterprise info user script. + Performs login and displays enterprise users information. + """ + keeper_auth_context = login() + + if keeper_auth_context: + display_enterprise_users(keeper_auth_context) + else: + print("Login failed. Unable to retrieve enterprise information.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/enterprise_node/enterprise_node_view.py b/examples/sdk_examples/enterprise_node/enterprise_node_view.py new file mode 100644 index 00000000..4d2052be --- /dev/null +++ b/examples/sdk_examples/enterprise_node/enterprise_node_view.py @@ -0,0 +1,168 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage +from keepersdk.errors import KeeperApiError +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def view_enterprise_nodes(keeper_auth_context: keeper_auth.KeeperAuth): + """ + View enterprise node details with optional search. + + Args: + keeper_auth_context: The authenticated Keeper context with enterprise admin privileges. + """ + if not keeper_auth_context.auth_context.is_enterprise_admin: + print("ERROR: This operation requires enterprise admin privileges.") + print("The current user is not an enterprise administrator.") + keeper_auth_context.close() + return + + try: + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + enterprise_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + + enterprise = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + + node_search = input('Enter node name or ID (or leave empty for all nodes): ').strip() + + nodes_to_display = [] + + if node_search: + for node in enterprise.enterprise_data.nodes.get_all_entities(): + node_name = node.name if node.name else '' + node_id_str = str(node.node_id) if hasattr(node, 'node_id') else '' + + if (node_search.lower() in node_name.lower() or + node_search == node_id_str): + nodes_to_display.append(node) + + if not nodes_to_display: + print(f'\nNo nodes found matching: "{node_search}"') + else: + nodes_to_display = list(enterprise.enterprise_data.nodes.get_all_entities()) + + if nodes_to_display: + print("\nEnterprise Node Details") + print("=" * 120) + + for node in nodes_to_display: + node_name = node.name if node.name else 'N/A' + node_id = str(node.node_id) if hasattr(node, 'node_id') else 'N/A' + + parent_name = "" + if hasattr(node, 'parent_id') and node.parent_id: + parent_node = enterprise.enterprise_data.nodes.get_entity(node.parent_id) + if parent_node: + parent_name = parent_node.name if parent_node.name else str(node.parent_id) + + user_count = sum(1 for user in enterprise.enterprise_data.users.get_all_entities() + if hasattr(user, 'node_id') and user.node_id == node.node_id) + team_count = sum(1 for team in enterprise.enterprise_data.teams.get_all_entities() + if hasattr(team, 'node_id') and team.node_id == node.node_id) + role_count = sum(1 for role in enterprise.enterprise_data.roles.get_all_entities() + if hasattr(role, 'node_id') and role.node_id == node.node_id) + + print(f"\nNode Name: {node_name}") + print(f"Node ID: {node_id}") + if parent_name: + print(f"Parent Node: {parent_name}") + print(f"Users: {user_count}") + print(f"Teams: {team_count}") + print(f"Roles: {role_count}") + + if hasattr(node, 'license_id') and node.license_id: + print(f"License ID: {node.license_id}") + + print("-" * 120) + + print(f"\nTotal nodes displayed: {len(nodes_to_display)}") + + print("=" * 120) + + enterprise.close() + keeper_auth_context.close() + + except KeeperApiError as e: + print(f"\nAPI Error: {e}") + keeper_auth_context.close() + except Exception as e: + print(f"\nError loading enterprise data: {e}") + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the enterprise node view script. + Performs login and displays enterprise node details. + """ + keeper_auth_context = login() + + if keeper_auth_context: + view_enterprise_nodes(keeper_auth_context) + else: + print("Login failed. Unable to retrieve enterprise information.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/enterprise_role/enterprise_role_membership.py b/examples/sdk_examples/enterprise_role/enterprise_role_membership.py new file mode 100644 index 00000000..dd48ba92 --- /dev/null +++ b/examples/sdk_examples/enterprise_role/enterprise_role_membership.py @@ -0,0 +1,178 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage +from keepersdk.errors import KeeperApiError +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + try: + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + except KeeperApiError as e: + if 'invalid_credentials' in str(e): + print("\nError: Invalid password. Please check your credentials and try again.") + else: + print(f"\nLogin error: {e}") + return None + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def view_role_membership(keeper_auth_context: keeper_auth.KeeperAuth): + """ + View role membership details for a specific role. + + Args: + keeper_auth_context: The authenticated Keeper context with enterprise admin privileges. + """ + if not keeper_auth_context.auth_context.is_enterprise_admin: + print("ERROR: This operation requires enterprise admin privileges.") + print("The current user is not an enterprise administrator.") + keeper_auth_context.close() + return + + try: + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + enterprise_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + + enterprise = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + + role_search = input('Enter role name or ID: ').strip() + + if not role_search: + print('No role specified') + else: + role_found = None + + for role in enterprise.enterprise_data.roles.get_all_entities(): + role_name = role.name if hasattr(role, 'name') and role.name else '' + role_id_str = str(role.role_id) if hasattr(role, 'role_id') else '' + + if (role_search.lower() in role_name.lower() or + role_search == role_id_str): + role_found = role + break + + if role_found: + role_name = role_found.name if hasattr(role_found, 'name') and role_found.name else 'N/A' + + print(f"\nRole Membership for: {role_name}") + print("=" * 100) + + role_users = list(enterprise.enterprise_data.role_users.get_links_by_subject(role_found.role_id)) + role_teams = list(enterprise.enterprise_data.role_teams.get_links_by_subject(role_found.role_id)) + + if role_users: + print(f"\nUsers ({len(role_users)}):") + print("-" * 100) + print(f"{'Username':<40} {'Email':<40} {'Status':<20}") + print("-" * 100) + + for role_user in role_users: + user = enterprise.enterprise_data.users.get_entity(role_user.enterprise_user_id) + if user: + user_name = user.full_name if hasattr(user, 'full_name') and user.full_name else user.username + user_email = user.username + user_status = user.status if hasattr(user, 'status') else 'unknown' + print(f"{user_name[:39]:<40} {user_email[:39]:<40} {user_status:<20}") + else: + print("\nNo users assigned to this role") + + if role_teams: + print(f"\nTeams ({len(role_teams)}):") + print("-" * 100) + print(f"{'Team Name':<50} {'Team UID':<50}") + print("-" * 100) + + for role_team in role_teams: + team = enterprise.enterprise_data.teams.get_entity(role_team.team_uid) + if team: + team_name = team.name if hasattr(team, 'name') else role_team.team_uid + print(f"{team_name[:49]:<50} {role_team.team_uid[:49]:<50}") + else: + print("\nNo teams assigned to this role") + + print("=" * 100) + else: + print(f'\nNo role found matching: "{role_search}"') + + enterprise.close() + keeper_auth_context.close() + + except KeeperApiError as e: + print(f"\nAPI Error: {e}") + keeper_auth_context.close() + except Exception as e: + print(f"\nError loading enterprise data: {e}") + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the enterprise role membership script. + Performs login and displays role membership details. + """ + keeper_auth_context = login() + + if keeper_auth_context: + view_role_membership(keeper_auth_context) + else: + print("Login failed. Unable to retrieve enterprise information.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/enterprise_role/enterprise_role_view.py b/examples/sdk_examples/enterprise_role/enterprise_role_view.py new file mode 100644 index 00000000..11c0a416 --- /dev/null +++ b/examples/sdk_examples/enterprise_role/enterprise_role_view.py @@ -0,0 +1,190 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage +from keepersdk.errors import KeeperApiError +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def view_enterprise_roles(keeper_auth_context: keeper_auth.KeeperAuth): + """ + View enterprise role details with optional search. + + Args: + keeper_auth_context: The authenticated Keeper context with enterprise admin privileges. + """ + if not keeper_auth_context.auth_context.is_enterprise_admin: + print("ERROR: This operation requires enterprise admin privileges.") + print("The current user is not an enterprise administrator.") + keeper_auth_context.close() + return + + try: + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + enterprise_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + + enterprise = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + + role_search = input('Enter role name or ID (or leave empty for all roles): ').strip() + + roles_to_display = [] + + if role_search: + for role in enterprise.enterprise_data.roles.get_all_entities(): + role_name = role.name if hasattr(role, 'name') and role.name else '' + role_id_str = str(role.role_id) if hasattr(role, 'role_id') else '' + + if (role_search.lower() in role_name.lower() or + role_search == role_id_str): + roles_to_display.append(role) + + if not roles_to_display: + print(f'\nNo roles found matching: "{role_search}"') + else: + roles_to_display = list(enterprise.enterprise_data.roles.get_all_entities()) + + if roles_to_display: + print("\nEnterprise Role Details") + print("=" * 120) + + for role in roles_to_display: + role_name = role.name if hasattr(role, 'name') and role.name else 'N/A' + role_id = str(role.role_id) if hasattr(role, 'role_id') else 'N/A' + + node_name = "" + if hasattr(role, 'node_id') and role.node_id: + node = enterprise.enterprise_data.nodes.get_entity(role.node_id) + if node: + node_name = node.name if hasattr(node, 'name') and node.name else str(role.node_id) + + print(f"\nRole Name: {role_name}") + print(f"Role ID: {role_id}") + if node_name: + print(f"Node: {node_name}") + + if hasattr(role, 'visible_below') and role.visible_below: + print(f"Visible Below: Yes") + + if hasattr(role, 'new_user_inherit') and role.new_user_inherit: + print(f"New User Inherit: Yes") + + role_users = list(enterprise.enterprise_data.role_users.get_links_by_subject(role.role_id)) + if role_users: + print(f"\nUsers ({len(role_users)}):") + for role_user in role_users[:10]: + user = enterprise.enterprise_data.users.get_entity(role_user.enterprise_user_id) + if user: + print(f" - {user.username}") + if len(role_users) > 10: + print(f" ... and {len(role_users) - 10} more") + + role_teams = list(enterprise.enterprise_data.role_teams.get_links_by_subject(role.role_id)) + if role_teams: + print(f"\nTeams ({len(role_teams)}):") + for role_team in role_teams[:10]: + team = enterprise.enterprise_data.teams.get_entity(role_team.team_uid) + if team: + team_name = team.name if hasattr(team, 'name') else role_team.team_uid + print(f" - {team_name}") + if len(role_teams) > 10: + print(f" ... and {len(role_teams) - 10} more") + + role_privileges = list(enterprise.enterprise_data.role_privileges.get_links_by_subject(role.role_id)) + if role_privileges: + print(f"\nPrivileges ({len(role_privileges)}):") + for priv in role_privileges: + priv_set = priv.to_set() + for priv_type in priv_set: + print(f" - {priv_type}") + + print("-" * 120) + + print(f"\nTotal roles displayed: {len(roles_to_display)}") + + print("=" * 120) + + enterprise.close() + keeper_auth_context.close() + + except KeeperApiError as e: + print(f"\nAPI Error: {e}") + keeper_auth_context.close() + except Exception as e: + print(f"\nError loading enterprise data: {e}") + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the enterprise role view script. + Performs login and displays enterprise role details. + """ + keeper_auth_context = login() + + if keeper_auth_context: + view_enterprise_roles(keeper_auth_context) + else: + print("Login failed. Unable to retrieve enterprise information.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/enterprise_team/enterprise_team_membership.py b/examples/sdk_examples/enterprise_team/enterprise_team_membership.py new file mode 100644 index 00000000..c3047a03 --- /dev/null +++ b/examples/sdk_examples/enterprise_team/enterprise_team_membership.py @@ -0,0 +1,167 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage +from keepersdk.errors import KeeperApiError +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def view_team_membership(keeper_auth_context: keeper_auth.KeeperAuth): + """ + View team membership details for a specific team. + + Args: + keeper_auth_context: The authenticated Keeper context with enterprise admin privileges. + """ + if not keeper_auth_context.auth_context.is_enterprise_admin: + print("ERROR: This operation requires enterprise admin privileges.") + print("The current user is not an enterprise administrator.") + keeper_auth_context.close() + return + + try: + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + enterprise_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + + enterprise = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + + team_search = input('Enter team name or UID: ').strip() + + if not team_search: + print('No team specified') + else: + team_found = None + + for team in enterprise.enterprise_data.teams.get_all_entities(): + team_name = team.name if hasattr(team, 'name') and team.name else '' + team_uid = team.team_uid if hasattr(team, 'team_uid') else '' + + if (team_search.lower() in team_name.lower() or + team_search == team_uid): + team_found = team + break + + if team_found: + team_name = team_found.name if hasattr(team_found, 'name') and team_found.name else 'N/A' + team_uid = team_found.team_uid if hasattr(team_found, 'team_uid') else 'N/A' + + print(f"\nTeam Membership for: {team_name}") + print(f"Team UID: {team_uid}") + print("=" * 100) + + team_users = list(enterprise.enterprise_data.team_users.get_links_by_subject(team_uid)) + + if team_users: + print(f"\nUsers ({len(team_users)}):") + print("-" * 100) + print(f"{'Username':<40} {'Email':<40} {'Status':<20}") + print("-" * 100) + + for team_user in team_users: + user = enterprise.enterprise_data.users.get_entity(team_user.enterprise_user_id) + if user: + user_name = user.full_name if hasattr(user, 'full_name') and user.full_name else user.username + user_email = user.username + user_status = user.status if hasattr(user, 'status') else 'unknown' + print(f"{user_name[:39]:<40} {user_email[:39]:<40} {user_status:<20}") + else: + print("\nNo users in this team") + + queued_users = list(enterprise.enterprise_data.queued_team_users.get_links_by_subject(team_uid)) + if queued_users: + print(f"\nQueued Users ({len(queued_users)}):") + print("-" * 100) + for queued_user in queued_users: + user = enterprise.enterprise_data.users.get_entity(queued_user.enterprise_user_id) + if user: + print(f" - {user.username}") + + print("=" * 100) + else: + print(f'\nNo team found matching: "{team_search}"') + + enterprise.close() + keeper_auth_context.close() + + except KeeperApiError as e: + print(f"\nAPI Error: {e}") + keeper_auth_context.close() + except Exception as e: + print(f"\nError loading enterprise data: {e}") + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the enterprise team membership script. + Performs login and displays team membership details. + """ + keeper_auth_context = login() + + if keeper_auth_context: + view_team_membership(keeper_auth_context) + else: + print("Login failed. Unable to retrieve enterprise information.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/enterprise_team/enterprise_team_view.py b/examples/sdk_examples/enterprise_team/enterprise_team_view.py new file mode 100644 index 00000000..626e99c8 --- /dev/null +++ b/examples/sdk_examples/enterprise_team/enterprise_team_view.py @@ -0,0 +1,165 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage +from keepersdk.errors import KeeperApiError +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def view_enterprise_teams(keeper_auth_context: keeper_auth.KeeperAuth): + """ + View enterprise team details with optional search. + + Args: + keeper_auth_context: The authenticated Keeper context with enterprise admin privileges. + """ + if not keeper_auth_context.auth_context.is_enterprise_admin: + print("ERROR: This operation requires enterprise admin privileges.") + print("The current user is not an enterprise administrator.") + keeper_auth_context.close() + return + + try: + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + enterprise_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + + enterprise = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + + team_search = input('Enter team name or UID (or leave empty for all teams): ').strip() + + teams_to_display = [] + + if team_search: + for team in enterprise.enterprise_data.teams.get_all_entities(): + team_name = team.name if hasattr(team, 'name') and team.name else '' + team_uid = team.team_uid if hasattr(team, 'team_uid') else '' + + if (team_search.lower() in team_name.lower() or + team_search == team_uid): + teams_to_display.append(team) + + if not teams_to_display: + print(f'\nNo teams found matching: "{team_search}"') + else: + teams_to_display = list(enterprise.enterprise_data.teams.get_all_entities()) + + if teams_to_display: + print("\nEnterprise Team Details") + print("=" * 120) + + for team in teams_to_display: + team_name = team.name if hasattr(team, 'name') and team.name else 'N/A' + team_uid = team.team_uid if hasattr(team, 'team_uid') else 'N/A' + + node_name = "" + if hasattr(team, 'node_id') and team.node_id: + node = enterprise.enterprise_data.nodes.get_entity(team.node_id) + if node: + node_name = node.name if node.name else str(team.node_id) + + user_count = len(list(enterprise.enterprise_data.team_users.get_links_by_subject(team_uid))) + + print(f"\nTeam Name: {team_name}") + print(f"Team UID: {team_uid}") + if node_name: + print(f"Node: {node_name}") + print(f"User Count: {user_count}") + + if hasattr(team, 'restrict_edit'): + print(f"Restrict Edit: {team.restrict_edit}") + if hasattr(team, 'restrict_share'): + print(f"Restrict Share: {team.restrict_share}") + if hasattr(team, 'restrict_view'): + print(f"Restrict View: {team.restrict_view}") + + print("-" * 120) + + print(f"\nTotal teams displayed: {len(teams_to_display)}") + + print("=" * 120) + + enterprise.close() + keeper_auth_context.close() + + except KeeperApiError as e: + print(f"\nAPI Error: {e}") + keeper_auth_context.close() + except Exception as e: + print(f"\nError loading enterprise data: {e}") + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the enterprise team view script. + Performs login and displays enterprise team details. + """ + keeper_auth_context = login() + + if keeper_auth_context: + view_enterprise_teams(keeper_auth_context) + else: + print("Login failed. Unable to retrieve enterprise information.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/enterprise_user/device_approve.py b/examples/sdk_examples/enterprise_user/device_approve.py new file mode 100644 index 00000000..59c6e982 --- /dev/null +++ b/examples/sdk_examples/enterprise_user/device_approve.py @@ -0,0 +1,250 @@ +import getpass +import sqlite3 +import time + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage +from keepersdk.proto import enterprise_pb2, APIRequest_pb2 +from keepersdk import utils, crypto +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.backends import default_backend +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def approve_devices(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Manage device approval requests for enterprise users. + + Args: + keeper_auth_context: The authenticated Keeper context with enterprise admin privileges. + """ + if not keeper_auth_context.auth_context.is_enterprise_admin: + print("Error: You must be an enterprise admin to approve devices") + keeper_auth_context.close() + return + + try: + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage( + lambda: conn, + enterprise_id + ) + + loader = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + loader.load() + + enterprise_data = loader.enterprise_data + + approval_requests = list(enterprise_data.device_approval_requests.get_all_entities()) + + if not approval_requests: + print("\nNo pending device approval requests") + else: + print("\n" + "=" * 80) + print("PENDING DEVICE APPROVAL REQUESTS") + print("=" * 80) + + user_lookup = {u.enterprise_user_id: u.username + for u in enterprise_data.users.get_all_entities()} + + for i, req in enumerate(approval_requests, 1): + username_display = user_lookup.get(req.enterprise_user_id, 'Unknown') + date_str = time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(req.date / 1000)) + device_id_short = req.encrypted_device_token[:16] if req.encrypted_device_token else 'N/A' + + print(f"\n{i}. Request Details:") + print(f" User: {username_display}") + print(f" User ID: {req.enterprise_user_id}") + print(f" Device Name: {req.device_name}") + print(f" Device Type: {req.device_type}") + print(f" Client Version: {req.client_version}") + print(f" IP Address: {req.ip_address}") + print(f" Location: {req.location}") + print(f" Date: {date_str}") + print(f" Device ID: {device_id_short}...") + + print("\n" + "-" * 80) + print("\nOptions:") + print(" 1. Approve all pending devices") + print(" 2. Deny all pending devices") + print(" 3. Approve specific device (by number)") + print(" 4. Deny specific device (by number)") + print(" 5. Exit without action") + + choice = input("\nEnter choice (1-5): ").strip() + + if choice in ['1', '2', '3', '4']: + approve_rq = enterprise_pb2.ApproveUserDevicesRequest() + devices_to_process = [] + is_deny = choice in ['2', '4'] + + if choice in ['1', '2']: + devices_to_process = approval_requests + elif choice in ['3', '4']: + try: + num = int(input("Enter device number: ").strip()) + if 1 <= num <= len(approval_requests): + devices_to_process = [approval_requests[num - 1]] + else: + print("Invalid device number") + except ValueError: + print("Invalid input") + + if devices_to_process: + for req in devices_to_process: + device_rq = enterprise_pb2.ApproveUserDeviceRequest() + device_rq.enterpriseUserId = req.enterprise_user_id + device_rq.encryptedDeviceToken = utils.base64_url_decode(req.encrypted_device_token) + device_rq.denyApproval = is_deny + + if not is_deny and req.device_public_key: + try: + curve = ec.SECP256R1() + data_key_rq = APIRequest_pb2.UserDataKeyRequest() + data_key_rq.enterpriseUserId.append(req.enterprise_user_id) + + data_key_rs = keeper_auth_context.execute_auth_rest( + 'enterprise/get_enterprise_user_data_key', + data_key_rq, + response_type=APIRequest_pb2.EnterpriseUserIdDataKeyPair + ) + + if data_key_rs and data_key_rs.encryptedDataKey: + if enterprise_data.enterprise_info.ec_private_key: + ecc_priv_data = utils.base64_url_decode(enterprise_data.enterprise_info.ec_private_key) + ecc_priv_data = crypto.decrypt_aes_v2( + ecc_priv_data, + enterprise_data.enterprise_info.tree_key + ) + private_value = int.from_bytes(ecc_priv_data, byteorder='big', signed=False) + ecc_private_key = ec.derive_private_key(private_value, curve, default_backend()) + + enc_data_key = data_key_rs.encryptedDataKey + ephemeral_public_key = ec.EllipticCurvePublicKey.from_encoded_point( + curve, enc_data_key[:65]) + shared_key = ecc_private_key.exchange(ec.ECDH(), ephemeral_public_key) + digest = hashes.Hash(hashes.SHA256(), backend=default_backend()) + digest.update(shared_key) + enc_key = digest.finalize() + data_key = crypto.decrypt_aes_v2(enc_data_key[65:], enc_key) + + ephemeral_key = ec.generate_private_key(curve, default_backend()) + device_public_key = ec.EllipticCurvePublicKey.from_encoded_point( + curve, utils.base64_url_decode(req.device_public_key)) + shared_device_key = ephemeral_key.exchange(ec.ECDH(), device_public_key) + digest2 = hashes.Hash(hashes.SHA256(), backend=default_backend()) + digest2.update(shared_device_key) + enc_device_key = digest2.finalize() + encrypted_data_key = crypto.encrypt_aes_v2(data_key, enc_device_key) + ephemeral_public = ephemeral_key.public_key().public_bytes( + serialization.Encoding.X962, + serialization.PublicFormat.UncompressedPoint + ) + device_rq.encryptedDeviceDataKey = ephemeral_public + encrypted_data_key + except Exception as e: + print(f"Warning: Could not encrypt data key for device: {e}") + continue + + approve_rq.deviceRequests.append(device_rq) + + if approve_rq.deviceRequests: + action = "Denying" if is_deny else "Approving" + print(f"\n{action} {len(approve_rq.deviceRequests)} device(s)...") + + try: + keeper_auth_context.execute_auth_rest( + 'enterprise/approve_user_devices', + approve_rq, + response_type=enterprise_pb2.ApproveUserDevicesResponse + ) + + action_done = "denied" if is_deny else "approved" + print(f"\nSuccessfully {action_done} {len(approve_rq.deviceRequests)} device(s)") + + except Exception as e: + print(f"\nError processing devices: {e}") + else: + print("\nNo devices to process") + else: + print("Exiting without action") + + except Exception as e: + print(f"Error: {e}") + finally: + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the device approval script. + Performs login and manages device approval requests. + """ + keeper_auth_context = login() + + if keeper_auth_context: + approve_devices(keeper_auth_context) + else: + print("Login failed. Unable to manage device approvals.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/enterprise_user/enterprise_user_view.py b/examples/sdk_examples/enterprise_user/enterprise_user_view.py new file mode 100644 index 00000000..8b88998c --- /dev/null +++ b/examples/sdk_examples/enterprise_user/enterprise_user_view.py @@ -0,0 +1,184 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage +from keepersdk.errors import KeeperApiError +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def view_enterprise_users(keeper_auth_context: keeper_auth.KeeperAuth): + """ + View enterprise user details with optional search. + + Args: + keeper_auth_context: The authenticated Keeper context with enterprise admin privileges. + """ + if not keeper_auth_context.auth_context.is_enterprise_admin: + print("ERROR: This operation requires enterprise admin privileges.") + print("The current user is not an enterprise administrator.") + keeper_auth_context.close() + return + + try: + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + enterprise_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + + enterprise = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + + user_search = input('Enter user email or ID (or leave empty for all users): ').strip() + + users_to_display = [] + + if user_search: + for user in enterprise.enterprise_data.users.get_all_entities(): + user_email = user.username if hasattr(user, 'username') else '' + user_id_str = str(user.enterprise_user_id) if hasattr(user, 'enterprise_user_id') else '' + + if (user_search.lower() in user_email.lower() or + user_search == user_id_str): + users_to_display.append(user) + + if not users_to_display: + print(f'\nNo users found matching: "{user_search}"') + else: + users_to_display = list(enterprise.enterprise_data.users.get_all_entities()) + + if users_to_display: + print("\nEnterprise User Details") + print("=" * 120) + + for user in users_to_display: + user_name = user.full_name if hasattr(user, 'full_name') and user.full_name else user.username + user_email = user.username if hasattr(user, 'username') else 'N/A' + user_id = str(user.enterprise_user_id) if hasattr(user, 'enterprise_user_id') else 'N/A' + user_status = user.status if hasattr(user, 'status') else 'unknown' + + node_name = "" + if hasattr(user, 'node_id') and user.node_id: + node = enterprise.enterprise_data.nodes.get_entity(user.node_id) + if node: + node_name = node.name if hasattr(node, 'name') and node.name else str(user.node_id) + + print(f"\nUser Name: {user_name}") + print(f"Email: {user_email}") + print(f"User ID: {user_id}") + print(f"Status: {user_status}") + if node_name: + print(f"Node: {node_name}") + + if hasattr(user, 'account_share_expiration') and user.account_share_expiration: + print(f"Account Share Expiration: {user.account_share_expiration}") + + user_teams = [tu for tu in enterprise.enterprise_data.team_users.get_links_by_object(user.enterprise_user_id)] + if user_teams: + print(f"\nTeams ({len(user_teams)}):") + for team_user in user_teams[:10]: + team = enterprise.enterprise_data.teams.get_entity(team_user.team_uid) + if team: + team_name = team.name if hasattr(team, 'name') else team_user.team_uid + print(f" - {team_name}") + if len(user_teams) > 10: + print(f" ... and {len(user_teams) - 10} more") + + user_roles = [ru for ru in enterprise.enterprise_data.role_users.get_links_by_object(user.enterprise_user_id)] + if user_roles: + print(f"\nRoles ({len(user_roles)}):") + for role_user in user_roles[:10]: + role = enterprise.enterprise_data.roles.get_entity(role_user.role_id) + if role: + role_name = role.name if hasattr(role, 'name') and role.name else str(role_user.role_id) + print(f" - {role_name}") + if len(user_roles) > 10: + print(f" ... and {len(user_roles) - 10} more") + + print("-" * 120) + + print(f"\nTotal users displayed: {len(users_to_display)}") + + print("=" * 120) + + enterprise.close() + keeper_auth_context.close() + + except KeeperApiError as e: + print(f"\nAPI Error: {e}") + keeper_auth_context.close() + except Exception as e: + print(f"\nError loading enterprise data: {e}") + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the enterprise user view script. + Performs login and displays enterprise user details. + """ + keeper_auth_context = login() + + if keeper_auth_context: + view_enterprise_users(keeper_auth_context) + else: + print("Login failed. Unable to retrieve enterprise information.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/enterprise_user/transfer_user.py b/examples/sdk_examples/enterprise_user/transfer_user.py new file mode 100644 index 00000000..90f659d8 --- /dev/null +++ b/examples/sdk_examples/enterprise_user/transfer_user.py @@ -0,0 +1,187 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage, account_transfer +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def transfer_user_account(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Transfer a user account from one user to another. + + Args: + keeper_auth_context: The authenticated Keeper context with enterprise admin privileges. + """ + if not keeper_auth_context.auth_context.is_enterprise_admin: + print("Error: You must be an enterprise admin to transfer user accounts") + keeper_auth_context.close() + return + + try: + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage( + lambda: conn, + enterprise_id + ) + + loader = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + loader.load() + + enterprise_data = loader.enterprise_data + + print("\nEnterprise Users:") + print("-" * 60) + users = list(enterprise_data.users.get_all_entities()) + for i, user in enumerate(users[:20], 1): + status = user.status if hasattr(user, 'status') else 'N/A' + print(f"{i}. {user.username} (ID: {user.enterprise_user_id}, Status: {status})") + if len(users) > 20: + print(f"... and {len(users) - 20} more users") + print("-" * 60) + + source_email = input('\nEnter source user email to transfer FROM: ').strip() + target_email = input('Enter target user email to transfer TO: ').strip() + + if not source_email or not target_email: + print("Both source and target emails are required") + elif source_email == target_email: + print("Source and target cannot be the same user") + else: + source_user = None + target_user = None + for user in users: + if user.username.lower() == source_email.lower(): + source_user = user + if user.username.lower() == target_email.lower(): + target_user = user + + if not source_user: + print(f"Source user '{source_email}' not found in enterprise") + elif not target_user: + print(f"Target user '{target_email}' not found in enterprise") + else: + print(f"\nTransfer Details:") + print(f" From: {source_user.username} (ID: {source_user.enterprise_user_id})") + print(f" To: {target_user.username} (ID: {target_user.enterprise_user_id})") + print("\nWARNING: This will transfer ALL vault data (records, shared folders,") + print("teams, user folders) from source user to target user.") + print("The source user account will be deleted after transfer.") + + confirm = input('\nType "TRANSFER" to confirm: ').strip() + + if confirm == "TRANSFER": + try: + target_keys = keeper_auth.UserKeys() + + transfer_manager = account_transfer.AccountTransferManager( + loader, + keeper_auth_context + ) + + result = transfer_manager.transfer_account( + from_username=source_user.username, + to_username=target_user.username, + target_public_keys=target_keys + ) + + print("\n" + "=" * 60) + print("TRANSFER COMPLETED") + print("=" * 60) + print(f"Success: {result.success}") + print(f"Records Transferred: {result.records_transferred}") + print(f"Shared Folders Transferred: {result.shared_folders_transferred}") + print(f"Teams Transferred: {result.teams_transferred}") + print(f"User Folders Transferred: {result.user_folders_transferred}") + if result.corrupted_records > 0: + print(f"Corrupted Records: {result.corrupted_records}") + if result.corrupted_shared_folders > 0: + print(f"Corrupted Shared Folders: {result.corrupted_shared_folders}") + print("=" * 60) + + except account_transfer.AccountTransferError as e: + print(f"\nTransfer Error: {e}") + except Exception as e: + print(f"\nError during transfer: {e}") + else: + print("Transfer cancelled") + + except Exception as e: + print(f"Error: {e}") + finally: + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the user transfer script. + Performs login and manages user account transfers. + """ + keeper_auth_context = login() + + if keeper_auth_context: + transfer_user_account(keeper_auth_context) + else: + print("Login failed. Unable to transfer user accounts.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/folder/add_folder.py b/examples/sdk_examples/folder/add_folder.py new file mode 100644 index 00000000..c5dd6386 --- /dev/null +++ b/examples/sdk_examples/folder/add_folder.py @@ -0,0 +1,145 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, folder_management +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def add_folder(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Add a new folder to the vault. + + Args: + keeper_auth_context: The authenticated Keeper context. + """ + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + + folder_name = input('Enter name for new folder: ').strip() + + if not folder_name: + print("Folder name cannot be empty") + else: + is_shared = input('Create as shared folder? (y/n): ').strip().lower() == 'y' + + parent_uid = None + if not is_shared: + use_parent = input('Add to a parent folder? (y/n): ').strip().lower() == 'y' + if use_parent: + parent_uid = input('Enter parent folder UID: ').strip() + if parent_uid: + parent_folder = vault.vault_data.get_folder(parent_uid) + if not parent_folder: + print(f"Parent folder '{parent_uid}' not found") + parent_uid = None + + try: + if is_shared: + folder_uid = folder_management.add_folder( + vault, + folder_name, + is_shared_folder=True, + manage_records=True, + manage_users=False, + can_share=True, + can_edit=True + ) + print(f"\nShared folder created successfully!") + else: + folder_uid = folder_management.add_folder( + vault, + folder_name, + is_shared_folder=False, + parent_uid=parent_uid + ) + print(f"\nFolder created successfully!") + + print(f"Folder UID: {folder_uid}") + print(f"Folder Name: {folder_name}") + + vault.sync_down() + + except Exception as e: + print(f"Error creating folder: {e}") + + vault.close() + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the add folder script. + Performs login and adds a new folder. + """ + keeper_auth_context = login() + + if keeper_auth_context: + add_folder(keeper_auth_context) + else: + print("Login failed. Unable to add folder.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/folder/delete_folder.py b/examples/sdk_examples/folder/delete_folder.py new file mode 100644 index 00000000..94f9a9f1 --- /dev/null +++ b/examples/sdk_examples/folder/delete_folder.py @@ -0,0 +1,144 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, record_management +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def delete_folder(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Delete a folder from the vault. + + Args: + keeper_auth_context: The authenticated Keeper context. + """ + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + + folder_search = input('Enter folder name or UID to delete: ').strip() + + if not folder_search: + print("Folder identifier cannot be empty") + else: + folder_found = None + + for folder in vault.vault_data.folders(): + if folder.name.lower() == folder_search.lower() or folder.folder_uid == folder_search: + folder_found = folder + break + + if not folder_found: + print(f"Folder '{folder_search}' not found") + else: + print(f"\nFolder to delete:") + print(f"Name: {folder_found.name}") + print(f"Type: {folder_found.folder_type}") + print(f"UID: {folder_found.folder_uid}") + print(f"Records: {len(folder_found.records)}") + print(f"Subfolders: {len(folder_found.subfolders)}") + + confirm = input('\nAre you sure you want to delete this folder? (yes/no): ').strip().lower() + + if confirm == 'yes': + try: + def confirm_deletion(message: str) -> bool: + print(f"\n{message}") + answer = input("\nContinue with deletion? (yes/no): ").strip().lower() + return answer == 'yes' + + record_management.delete_vault_objects( + vault, + [folder_found.folder_uid], + confirm=confirm_deletion + ) + + print(f"\nFolder deleted successfully!") + + vault.sync_down() + + except Exception as e: + print(f"Error deleting folder: {e}") + else: + print("Deletion cancelled") + + vault.close() + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the delete folder script. + Performs login and deletes a folder. + """ + keeper_auth_context = login() + + if keeper_auth_context: + delete_folder(keeper_auth_context) + else: + print("Login failed. Unable to delete folder.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/folder/list_folders.py b/examples/sdk_examples/folder/list_folders.py new file mode 100644 index 00000000..67537ee4 --- /dev/null +++ b/examples/sdk_examples/folder/list_folders.py @@ -0,0 +1,133 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def list_folders(keeper_auth_context: keeper_auth.KeeperAuth): + """ + List all folders in the vault with optional search. + + Args: + keeper_auth_context: The authenticated Keeper context. + """ + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + search_pattern = input('Enter search pattern (or press Enter for all folders): ').strip() + + all_folders = list(vault.vault_data.folders()) + + folders_to_display = [] + if search_pattern: + for folder in all_folders: + if search_pattern.lower() in folder.name.lower() or search_pattern == folder.folder_uid: + folders_to_display.append(folder) + else: + folders_to_display = all_folders + + if folders_to_display: + print("\nFolders in vault") + print("=" * 120) + print(f"{'Folder Name':<40} {'Type':<25} {'Folder UID':<40} {'Records':<15}") + print("-" * 120) + + for folder in folders_to_display: + folder_name = folder.name or '(Unnamed)' + folder_type = folder.folder_type + folder_uid = folder.folder_uid + record_count = len(folder.records) + + print(f"{folder_name[:39]:<40} {folder_type:<25} {folder_uid[:39]:<40} {record_count:<15}") + + print("-" * 120) + print(f"Total folders: {len(folders_to_display)}") + else: + if search_pattern: + print(f"No folders found matching: '{search_pattern}'") + else: + print("No folders found in vault") + + vault.close() + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the list folders script. + Performs login and lists folders. + """ + keeper_auth_context = login() + + if keeper_auth_context: + list_folders(keeper_auth_context) + else: + print("Login failed. Unable to list folders.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/folder/list_shared_folders.py b/examples/sdk_examples/folder/list_shared_folders.py new file mode 100644 index 00000000..a743b7d4 --- /dev/null +++ b/examples/sdk_examples/folder/list_shared_folders.py @@ -0,0 +1,135 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def list_shared_folders(keeper_auth_context: keeper_auth.KeeperAuth): + """ + List all shared folders in the vault with optional search. + + Args: + keeper_auth_context: The authenticated Keeper context. + """ + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + search_pattern = input('Enter search pattern (or press Enter for all shared folders): ').strip() + + if search_pattern: + shared_folders = list(vault.vault_data.find_shared_folders(search_pattern)) + else: + shared_folders = list(vault.vault_data.shared_folders()) + + if shared_folders: + print("\nShared Folders") + print("=" * 120) + print(f"{'Folder Name':<40} {'Shared Folder UID':<40} {'Records':<15} {'Users':<15}") + print("-" * 120) + + for sf_info in shared_folders: + shared_folder = vault.vault_data.load_shared_folder(sf_info.shared_folder_uid) + + folder_name = sf_info.name or '(Unnamed)' + folder_uid = sf_info.shared_folder_uid + + record_count = 0 + user_count = 0 + + if shared_folder: + record_count = len(shared_folder.record_permissions) + user_count = len(shared_folder.user_permissions) + + print(f"{folder_name[:39]:<40} {folder_uid[:39]:<40} {record_count:<15} {user_count:<15}") + + print("-" * 120) + print(f"Total shared folders: {len(shared_folders)}") + else: + if search_pattern: + print(f"No shared folders found matching: '{search_pattern}'") + else: + print("No shared folders found in vault") + + vault.close() + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the list shared folders script. + Performs login and lists shared folders. + """ + keeper_auth_context = login() + + if keeper_auth_context: + list_shared_folders(keeper_auth_context) + else: + print("Login failed. Unable to list shared folders.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/folder/move_folder.py b/examples/sdk_examples/folder/move_folder.py new file mode 100644 index 00000000..4391f428 --- /dev/null +++ b/examples/sdk_examples/folder/move_folder.py @@ -0,0 +1,161 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, record_management +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def move_folder(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Move a folder to a new location in the vault. + + Args: + keeper_auth_context: The authenticated Keeper context. + """ + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + folder_search = input('Enter folder name or UID to move: ').strip() + + if not folder_search: + print("Folder identifier cannot be empty") + else: + folder_found = None + + for folder in vault.vault_data.folders(): + if folder.name.lower() == folder_search.lower() or folder.folder_uid == folder_search: + folder_found = folder + break + + if not folder_found: + print(f"Folder '{folder_search}' not found") + else: + print(f"\nFolder to move:") + print(f"Name: {folder_found.name}") + print(f"Type: {folder_found.folder_type}") + print(f"UID: {folder_found.folder_uid}") + + destination_search = input('\nEnter destination folder name or UID (or leave empty for root): ').strip() + + destination_folder = None + if destination_search: + destination_folder = vault.vault_data.get_folder(destination_search) + if not destination_folder: + for folder in vault.vault_data.folders(): + if folder.name.lower() == destination_search.lower(): + destination_folder = folder + break + if not destination_folder: + print(f"Destination folder '{destination_search}' not found") + else: + print(f"Destination: {destination_folder.name}") + else: + destination_folder = vault.vault_data.root_folder + print("Destination: Root folder") + + if destination_folder: + confirm = input('\nProceed with move? (yes/no): ').strip().lower() + + if confirm == 'yes': + try: + def on_warning(message: str) -> None: + print(f"Warning: {message}") + + record_management.move_vault_objects( + vault, + [folder_found.folder_uid], + dst_folder_uid=destination_folder.folder_uid if destination_folder.folder_uid else '', + on_warning=on_warning + ) + + print(f"\nFolder moved successfully!") + + vault.sync_down() + + except Exception as e: + print(f"Error moving folder: {e}") + else: + print("Move cancelled") + + vault.close() + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the move folder script. + Performs login and moves a folder. + """ + keeper_auth_context = login() + + if keeper_auth_context: + move_folder(keeper_auth_context) + else: + print("Login failed. Unable to move folder.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/folder/update_folder.py b/examples/sdk_examples/folder/update_folder.py new file mode 100644 index 00000000..e7d6e5c6 --- /dev/null +++ b/examples/sdk_examples/folder/update_folder.py @@ -0,0 +1,139 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, folder_management +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def update_folder(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Update a folder's name in the vault. + + Args: + keeper_auth_context: The authenticated Keeper context. + """ + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + folder_search = input('Enter folder name or UID to update: ').strip() + + if not folder_search: + print("Folder identifier cannot be empty") + else: + folder_found = None + + for folder in vault.vault_data.folders(): + if folder.name.lower() == folder_search.lower() or folder.folder_uid == folder_search: + folder_found = folder + break + + if not folder_found: + print(f"Folder '{folder_search}' not found") + else: + print(f"\nCurrent folder name: {folder_found.name}") + print(f"Folder type: {folder_found.folder_type}") + print(f"Folder UID: {folder_found.folder_uid}") + + new_name = input('\nEnter new folder name: ').strip() + + if not new_name: + print("New folder name cannot be empty") + else: + try: + folder_management.update_folder( + vault, + folder_found.folder_uid, + folder_name=new_name + ) + + print(f"\nFolder updated successfully!") + print(f"Old name: {folder_found.name}") + print(f"New name: {new_name}") + + vault.sync_down() + + except Exception as e: + print(f"Error updating folder: {e}") + + vault.close() + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the update folder script. + Performs login and updates a folder. + """ + keeper_auth_context = login() + + if keeper_auth_context: + update_folder(keeper_auth_context) + else: + print("Login failed. Unable to update folder.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/import_export/export_vault.py b/examples/sdk_examples/import_export/export_vault.py new file mode 100644 index 00000000..2d9ede7d --- /dev/null +++ b/examples/sdk_examples/import_export/export_vault.py @@ -0,0 +1,88 @@ +import getpass +import sqlite3 +import json + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, vault_record +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + config = configuration.JsonConfigurationStorage() + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + username = config.get().last_login or input('Enter username: ') + login_auth_context.resume_session = True + login_auth_context.login(username) + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent.") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + login_auth_context.login_step.verify_password(getpass.getpass('Enter password: ')) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + login_auth_context.login_step.send_code(channel.channel_uid, getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ')) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + return None + + +def export_vault(keeper_auth_context: keeper_auth.KeeperAuth): + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage(lambda: conn, vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8')) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + output_file = input('Enter output filename (default: vault_export.json): ').strip() or 'vault_export.json' + try: + export_data = {'records': [], 'folders': [], 'metadata': {'version': '1.0', 'export_type': 'keeper_sdk', 'record_count': 0, 'folder_count': 0}} + print("\nExporting vault data...") + for record_info in vault.vault_data.records(): + if record_info.version not in (2, 3): + continue + record = vault.vault_data.load_record(record_info.record_uid) + record_data = {'uid': record_info.record_uid, 'title': record_info.title, 'type': record_info.record_type if hasattr(record_info, 'record_type') else 'login', 'version': record_info.version} + if isinstance(record, vault_record.PasswordRecord): + record_data['fields'] = {'login': record.login, 'password': record.password, 'url': record.link, 'notes': record.notes} + record_data['custom_fields'] = record.custom + elif isinstance(record, vault_record.TypedRecord): + record_data['fields'] = [{'type': field.field_type, 'label': field.label if hasattr(field, 'label') else '', 'value': field.value} for field in record.fields] + export_data['records'].append(record_data) + for folder in vault.vault_data.folders(): + export_data['folders'].append({'uid': folder.folder_uid, 'name': folder.name, 'type': folder.folder_type, 'parent_uid': folder.parent_uid}) + export_data['metadata']['record_count'] = len(export_data['records']) + export_data['metadata']['folder_count'] = len(export_data['folders']) + with open(output_file, 'w', encoding='utf-8') as f: + json.dump(export_data, f, indent=2, ensure_ascii=False) + print(f"\n✓ Export completed! Output: {output_file}, Records: {export_data['metadata']['record_count']}, Folders: {export_data['metadata']['folder_count']}") + except Exception as e: + print(f"Error exporting vault data: {e}") + vault.close() + keeper_auth_context.close() + + +def main(): + keeper_auth_context = login() + if keeper_auth_context: + export_vault(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/import_export/vault_summary.py b/examples/sdk_examples/import_export/vault_summary.py new file mode 100644 index 00000000..ad0aeb67 --- /dev/null +++ b/examples/sdk_examples/import_export/vault_summary.py @@ -0,0 +1,80 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + config = configuration.JsonConfigurationStorage() + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + username = config.get().last_login or input('Enter username: ') + login_auth_context.resume_session = True + login_auth_context.login(username) + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent.") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + login_auth_context.login_step.verify_password(getpass.getpass('Enter password: ')) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + login_auth_context.login_step.send_code(channel.channel_uid, getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ')) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + return None + + +def show_vault_summary(keeper_auth_context: keeper_auth.KeeperAuth): + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage(lambda: conn, vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8')) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + print("\nVault Summary") + print("=" * 100) + total_records, v2_records, v3_records = 0, 0, 0 + for record_info in vault.vault_data.records(): + total_records += 1 + if record_info.version == 2: + v2_records += 1 + elif record_info.version == 3: + v3_records += 1 + total_folders = len(list(vault.vault_data.folders())) + user_folders = sum(1 for f in vault.vault_data.folders() if f.folder_type == 'user_folder') + shared_folders = vault.vault_data.shared_folder_count + total_teams = len(list(vault.vault_data.teams())) + print(f"\nRecords: Total={total_records}, V2(Legacy)={v2_records}, V3+(Modern)={v3_records}") + print(f"Folders: Total={total_folders}, User={user_folders}, Shared={shared_folders}") + print(f"Teams: {total_teams}") + print("=" * 100) + vault.close() + keeper_auth_context.close() + + +def main(): + keeper_auth_context = login() + if keeper_auth_context: + show_vault_summary(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/miscellaneous/copy_field.py b/examples/sdk_examples/miscellaneous/copy_field.py new file mode 100644 index 00000000..f5e6438c --- /dev/null +++ b/examples/sdk_examples/miscellaneous/copy_field.py @@ -0,0 +1,119 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, vault_record +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + config = configuration.JsonConfigurationStorage() + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + username = config.get().last_login or input('Enter username: ') + login_auth_context.resume_session = True + login_auth_context.login(username) + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent.") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + login_auth_context.login_step.verify_password(getpass.getpass('Enter password: ')) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + login_auth_context.login_step.send_code(channel.channel_uid, getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ')) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + return None + + +def copy_field(keeper_auth_context: keeper_auth.KeeperAuth): + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage(lambda: conn, vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8')) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + record_search = input('Enter record title or UID: ').strip() + if not record_search: + print("Record identifier cannot be empty") + else: + record_found = None + for record_info in vault.vault_data.records(): + if record_search.lower() in record_info.title.lower() or record_search == record_info.record_uid: + record_found = record_info + break + if not record_found: + print(f"Record '{record_search}' not found") + else: + record = vault.vault_data.load_record(record_found.record_uid) + print(f"\nRecord: {record_found.title}\n1. Password\n2. Login/Username\n3. URL\n4. TOTP Code\n5. Record UID") + choice = input("\nEnter choice (1-5): ").strip() + output_value, output_label = None, None + if choice == '1': + if isinstance(record, vault_record.PasswordRecord): + output_value, output_label = record.password, "Password" + elif isinstance(record, vault_record.TypedRecord): + for field in record.fields: + if field.field_type == 'password' and field.value: + output_value, output_label = (field.value[0] if isinstance(field.value, list) else field.value), "Password" + break + elif choice == '2': + if isinstance(record, vault_record.PasswordRecord): + output_value, output_label = record.login, "Login" + elif isinstance(record, vault_record.TypedRecord): + for field in record.fields: + if field.field_type == 'login' and field.value: + output_value, output_label = (field.value[0] if isinstance(field.value, list) else field.value), "Login" + break + elif choice == '3': + if isinstance(record, vault_record.PasswordRecord): + output_value, output_label = record.link, "URL" + elif isinstance(record, vault_record.TypedRecord): + for field in record.fields: + if field.field_type == 'url' and field.value: + output_value, output_label = (field.value[0] if isinstance(field.value, list) else field.value), "URL" + break + elif choice == '4': + totp_url = None + if isinstance(record, vault_record.PasswordRecord) and hasattr(record, 'totp') and record.totp: + totp_url = record.totp + elif isinstance(record, vault_record.TypedRecord): + for field in record.fields: + if field.field_type == 'oneTimeCode' and field.value: + totp_url = field.value[0] if isinstance(field.value, list) else field.value + break + if totp_url: + print(f"TOTP URL: {totp_url}") + #pending for totp + elif choice == '5': + output_value, output_label = record_found.record_uid, "Record UID" + if output_value and output_label: + print(f"\n{output_label}:\n{'=' * 80}\n{output_value}\n{'=' * 80}") + vault.close() + keeper_auth_context.close() + + +def main(): + keeper_auth_context = login() + if keeper_auth_context: + copy_field(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/miscellaneous/get_totp.py b/examples/sdk_examples/miscellaneous/get_totp.py new file mode 100644 index 00000000..c5d4833d --- /dev/null +++ b/examples/sdk_examples/miscellaneous/get_totp.py @@ -0,0 +1,90 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, vault_record +from keepersdk import utils +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + config = configuration.JsonConfigurationStorage() + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + username = config.get().last_login or input('Enter username: ') + login_auth_context.resume_session = True + login_auth_context.login(username) + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent.") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + login_auth_context.login_step.verify_password(getpass.getpass('Enter password: ')) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + login_auth_context.login_step.send_code(channel.channel_uid, getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ')) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + return None + + +def get_totp(keeper_auth_context: keeper_auth.KeeperAuth): + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage(lambda: conn, vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8')) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + record_search = input('Enter record title or UID to get TOTP code: ').strip() + if not record_search: + print("Record identifier cannot be empty") + else: + record_found = None + for record_info in vault.vault_data.records(): + if record_search.lower() in record_info.title.lower() or record_search == record_info.record_uid: + record_found = record_info + break + if not record_found: + print(f"Record '{record_search}' not found") + else: + record = vault.vault_data.load_record(record_found.record_uid) + totp_url = None + if isinstance(record, vault_record.PasswordRecord) and hasattr(record, 'totp') and record.totp: + totp_url = record.totp + elif isinstance(record, vault_record.TypedRecord): + for field in record.fields: + if field.field_type == 'oneTimeCode' and field.value: + totp_url = field.value[0] if isinstance(field.value, list) else field.value + break + if not totp_url: + print(f"\nNo TOTP configured for record: {record_found.title}") + else: + #pending for totp + print(f"\nRecord: {record_found.title}\n{'=' * 60}\nTOTP Code: {totp_url}\n{'=' * 60}") + vault.close() + keeper_auth_context.close() + + +def main(): + keeper_auth_context = login() + if keeper_auth_context: + get_totp(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/miscellaneous/list_teams.py b/examples/sdk_examples/miscellaneous/list_teams.py new file mode 100644 index 00000000..ade7ebb4 --- /dev/null +++ b/examples/sdk_examples/miscellaneous/list_teams.py @@ -0,0 +1,76 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + config = configuration.JsonConfigurationStorage() + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + username = config.get().last_login or input('Enter username: ') + login_auth_context.resume_session = True + login_auth_context.login(username) + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent.") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + login_auth_context.login_step.verify_password(getpass.getpass('Enter password: ')) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + login_auth_context.login_step.send_code(channel.channel_uid, getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ')) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + return None + + +def list_teams(keeper_auth_context: keeper_auth.KeeperAuth): + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage(lambda: conn, vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8')) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + teams = list(vault.vault_data.teams()) + if not teams: + print("\nNo teams found in vault") + else: + print(f"\nTeams in Vault ({len(teams)})\n{'=' * 120}") + print(f"{'Team Name':<40} {'Team UID':<40} {'Records Shared':<20}\n{'-' * 120}") + for team in teams: + team_name = team.name if team.name else '(Unnamed)' + #ToDo: shared_records_count + shared_records_count = sum(1 for r in vault.vault_data.records() if hasattr(r, 'shared') and r.shared) + print(f"{team_name[:39]:<40} {team.team_uid[:39]:<40} {shared_records_count:<20}") + print(f"{'-' * 120}\nTotal teams: {len(teams)}") + print("=" * 120) + vault.close() + keeper_auth_context.close() + + +def main(): + keeper_auth_context = login() + if keeper_auth_context: + list_teams(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/miscellaneous/password_strength.py b/examples/sdk_examples/miscellaneous/password_strength.py new file mode 100644 index 00000000..5c28ad8a --- /dev/null +++ b/examples/sdk_examples/miscellaneous/password_strength.py @@ -0,0 +1,96 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, vault_record +from keepersdk import utils +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + config = configuration.JsonConfigurationStorage() + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + username = config.get().last_login or input('Enter username: ') + login_auth_context.resume_session = True + login_auth_context.login(username) + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent.") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + login_auth_context.login_step.verify_password(getpass.getpass('Enter password: ')) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + login_auth_context.login_step.send_code(channel.channel_uid, getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ')) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + return None + + +def password_strength_report(keeper_auth_context: keeper_auth.KeeperAuth): + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage(lambda: conn, vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8')) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + print("Password Strength Report\n" + "=" * 120) + password_stats = {'weak': [], 'medium': [], 'strong': []} + for record_info in vault.vault_data.records(): + if record_info.version not in (2, 3): + continue + record = vault.vault_data.load_record(record_info.record_uid) + passwords = [] + if isinstance(record, vault_record.PasswordRecord) and record.password: + passwords.append(record.password) + elif isinstance(record, vault_record.TypedRecord): + for field in record.fields: + if field.field_type == 'password' and field.value: + passwords.append(field.value[0] if isinstance(field.value, list) else field.value) + for password in passwords: + score = utils.password_score(password) + record_data = {'uid': record_info.record_uid, 'title': record_info.title, 'score': score} + if score < 40: + password_stats['weak'].append(record_data) + elif score < 70: + password_stats['medium'].append(record_data) + else: + password_stats['strong'].append(record_data) + total = len(password_stats['weak']) + len(password_stats['medium']) + len(password_stats['strong']) + if total == 0: + print("\nNo records with passwords found") + else: + print(f"\nTotal: {total} | Strong: {len(password_stats['strong'])} | Medium: {len(password_stats['medium'])} | Weak: {len(password_stats['weak'])}") + if password_stats['weak']: + print(f"\nWeak Passwords ({len(password_stats['weak'])} records):\n{'-' * 120}") + for rec in password_stats['weak'][:10]: + print(f" {rec['title'][:49]:<50} Score: {rec['score']}") + print("=" * 120) + vault.close() + keeper_auth_context.close() + + +def main(): + keeper_auth_context = login() + if keeper_auth_context: + password_strength_report(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/one_time_share/list_shares.py b/examples/sdk_examples/one_time_share/list_shares.py new file mode 100644 index 00000000..25a7dc09 --- /dev/null +++ b/examples/sdk_examples/one_time_share/list_shares.py @@ -0,0 +1,108 @@ +import getpass +import sqlite3 +from datetime import datetime + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, ksm_management +from keepersdk.constants import KEEPER_PUBLIC_HOSTS +from keepersdk import utils + + +def login(): + config = configuration.JsonConfigurationStorage() + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + username = config.get().last_login or input('Enter username: ') + login_auth_context.resume_session = True + login_auth_context.login(username) + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent.") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + login_auth_context.login_step.verify_password(getpass.getpass('Enter password: ')) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + login_auth_context.login_step.send_code(channel.channel_uid, getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ')) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + return None + + +def list_one_time_shares(keeper_auth_context: keeper_auth.KeeperAuth): + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage(lambda: conn, vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8')) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + try: + record_search = input('Enter record name/UID to check for shares (or leave empty for all records): ').strip() + record_uids = [] + for record_info in vault.vault_data.records(): + if record_info.version not in (2, 3): + continue + if not record_search or record_search.lower() in record_info.title.lower() or record_search == record_info.record_uid: + record_uids.append(record_info.record_uid) + if not record_uids: + print("\nNo records found to check for one-time shares") + else: + app_infos = ksm_management.get_app_info(vault=vault, app_uid=record_uids[:100]) + shares_found = [] + now = utils.current_milli_time() + for app_info in app_infos: + if not app_info.isExternalShare: + continue + record_uid = utils.base64_url_encode(app_info.appRecordUid) + record_info = vault.vault_data.get_record(record_uid) + record_title = record_info.title if record_info else 'Unknown' + for client in app_info.clients: + shares_found.append({ + 'record_title': record_title, + 'share_name': client.id if client.id else 'Unnamed', + 'created': datetime.fromtimestamp(client.createdOn / 1000) if client.createdOn else None, + 'expires': datetime.fromtimestamp(client.accessExpireOn / 1000) if client.accessExpireOn else None, + 'expired': now > client.accessExpireOn if client.accessExpireOn else False, + 'opened': datetime.fromtimestamp(client.firstAccess / 1000) if client.firstAccess else None + }) + if not shares_found: + print("\nNo one-time shares found") + else: + print(f"\nOne-Time Shares ({len(shares_found)})\n{'=' * 130}") + print(f"{'Record Title':<30} {'Share Name':<20} {'Created':<20} {'Expires':<20} {'Status':<15}\n{'-' * 130}") + for share in shares_found: + status = 'Expired' if share['expired'] else ('Opened' if share['opened'] else 'Active') + created = share['created'].strftime('%Y-%m-%d %H:%M') if share['created'] else 'N/A' + expires = share['expires'].strftime('%Y-%m-%d %H:%M') if share['expires'] else 'N/A' + print(f"{share['record_title'][:29]:<30} {share['share_name'][:19]:<20} {created:<20} {expires:<20} {status:<15}") + print(f"{'-' * 130}\nTotal: {len(shares_found)}") + print("=" * 130) + except Exception as e: + print(f"Error retrieving one-time shares: {e}") + vault.close() + keeper_auth_context.close() + + +def main(): + keeper_auth_context = login() + if keeper_auth_context: + list_one_time_shares(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/record_types/list_record_types.py b/examples/sdk_examples/record_types/list_record_types.py new file mode 100644 index 00000000..464da599 --- /dev/null +++ b/examples/sdk_examples/record_types/list_record_types.py @@ -0,0 +1,90 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + config = configuration.JsonConfigurationStorage() + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + username = config.get().last_login or input('Enter username: ') + login_auth_context.resume_session = True + login_auth_context.login(username) + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent.") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + login_auth_context.login_step.verify_password(getpass.getpass('Enter password: ')) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + login_auth_context.login_step.send_code(channel.channel_uid, getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ')) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + return None + + +def list_record_types(keeper_auth_context: keeper_auth.KeeperAuth): + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage(lambda: conn, vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8')) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + print("\nAvailable Record Types\n" + "=" * 100) + record_types_list = list(vault.vault_data.get_record_types()) + if not record_types_list: + print("\nNo record types found") + else: + print(f"\nFound {len(record_types_list)} record type(s)\n{'-' * 100}") + print(f"{'ID':<10} {'Name':<40} {'Scope':<15} {'Description':<30}\n{'-' * 100}") + record_types_dict = {} + for rt in record_types_list: + rt_id = str(rt.id) if hasattr(rt, 'id') else 'N/A' + rt_name = rt.name if hasattr(rt, 'name') else 'N/A' + rt_scope = str(rt.scope) if hasattr(rt, 'scope') else 'N/A' + rt_desc = (rt.description[:29] if rt.description else '') if hasattr(rt, 'description') else '' + record_types_dict[rt_name.lower()] = rt + print(f"{rt_id[:9]:<10} {rt_name[:39]:<40} {rt_scope[:14]:<15} {rt_desc:<30}") + print("-" * 100) + rt_name_to_view = input('\nEnter record type name to view details (or press Enter to skip): ').strip() + if rt_name_to_view and rt_name_to_view.lower() in record_types_dict: + rt = record_types_dict[rt_name_to_view.lower()] + print(f"\nRecord Type Details: {rt.name if hasattr(rt, 'name') else rt_name_to_view}\n{'=' * 100}") + if hasattr(rt, 'fields') and rt.fields: + print(f"\nFields ({len(rt.fields)}):\n{'-' * 100}") + print(f"{'Field Type':<25} {'Label':<30} {'Required':<10}\n{'-' * 100}") + for field in rt.fields: + print(f"{(field.type if hasattr(field, 'type') else 'N/A')[:24]:<25} {(field.label if hasattr(field, 'label') else '')[:29]:<30} {'Yes' if (hasattr(field, 'required') and field.required) else 'No':<10}") + print("=" * 100) + print("\n" + "=" * 100) + vault.close() + keeper_auth_context.close() + + +def main(): + keeper_auth_context = login() + if keeper_auth_context: + list_record_types(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/records/add_record.py b/examples/sdk_examples/records/add_record.py new file mode 100644 index 00000000..4e015030 --- /dev/null +++ b/examples/sdk_examples/records/add_record.py @@ -0,0 +1,131 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, vault_record, record_management +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def add_record(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Add a new record to the vault. + + Args: + keeper_auth_context: The authenticated Keeper context. + """ + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + print("Adding new record to vault...") + print("-" * 50) + + new_record = vault_record.PasswordRecord() + new_record.title = "Example Record" + new_record.login = "user@example.com" + new_record.password = "SecurePassword123!" + new_record.link = "https://example.com" + new_record.notes = "This is an example record created using the Keeper SDK" + + folder_uid = None + + try: + record_uid = record_management.add_record_to_folder(vault, new_record, folder_uid) + print(f'Successfully added record!') + print(f'Record UID: {record_uid}') + print(f'Title: {new_record.title}') + print(f'Username: {new_record.login}') + print(f'URL: {new_record.link}') + print(f'Notes: {new_record.notes}') + print("-" * 50) + + vault.sync_down() + + added_record = vault.vault_data.get_record(record_uid) + if added_record: + print(f'Verified: Record "{added_record.title}" exists in vault') + + except Exception as e: + print(f'Error adding record: {str(e)}') + + vault.close() + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the add record script. + Performs login and adds a new record. + """ + keeper_auth_context = login() + + if keeper_auth_context: + add_record(keeper_auth_context) + else: + print("Login failed. Unable to add record.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/records/delete_attachment.py b/examples/sdk_examples/records/delete_attachment.py new file mode 100644 index 00000000..bef650c2 --- /dev/null +++ b/examples/sdk_examples/records/delete_attachment.py @@ -0,0 +1,198 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, vault_record, record_management +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def delete_attachment(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Delete an attachment from a record. + + Args: + keeper_auth_context: The authenticated Keeper context. + """ + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + print("Deleting attachment from record...") + print("-" * 50) + + record_uid = input('Enter record UID (or leave empty to search): ').strip() + + if not record_uid: + print('\nSearching for records with attachments...') + for record_info in vault.vault_data.records(): + record = vault.vault_data.load_record(record_info.record_uid) + has_attachments = False + + if hasattr(record, 'attachments') and record.attachments: + has_attachments = True + elif hasattr(record, 'file_ref') and record.file_ref: + has_attachments = True + + if has_attachments: + print(f' - {record_info.title} ({record_info.record_uid})') + record_uid = record_info.record_uid + break + + if not record_uid: + print('No records with attachments found') + + if record_uid: + try: + record = vault.vault_data.load_record(record_uid) + + if isinstance(record, vault_record.PasswordRecord): + if record.attachments: + print(f'\nAttachments in record "{record.title}":') + for i, atta in enumerate(record.attachments, 1): + print(f'{i}. {atta.title or atta.name} (ID: {atta.id}, Size: {atta.size} bytes)') + + attachment_name = input('\nEnter attachment name or ID to delete: ').strip() + + if attachment_name: + attachment_found = False + for atta in record.attachments[:]: + if atta.id == attachment_name or atta.title == attachment_name or atta.name == attachment_name: + print(f'\nDeleting attachment: {atta.title or atta.name}') + record.attachments.remove(atta) + attachment_found = True + break + + if attachment_found: + record_management.update_record(vault, record) + + print("-" * 50) + print('Successfully deleted attachment!') + print("-" * 50) + + vault.sync_down() + else: + print(f'Attachment "{attachment_name}" not found') + else: + print('No attachment name provided') + else: + print(f'Record "{record.title}" has no attachments') + + elif isinstance(record, vault_record.TypedRecord): + from keepersdk.vault.record_facades import FileRefRecordFacade + + facade = FileRefRecordFacade() + facade.record = record + + if isinstance(facade.file_ref, list) and facade.file_ref: + print(f'\nFile references in record "{record.title}":') + for i, file_uid in enumerate(facade.file_ref, 1): + file_record = vault.vault_data.load_record(file_uid) + if isinstance(file_record, vault_record.FileRecord): + print(f'{i}. {file_record.title} (UID: {file_uid})') + + file_ref_input = input('\nEnter file UID to delete: ').strip() + + if file_ref_input in facade.file_ref: + print(f'\nDeleting file reference: {file_ref_input}') + facade.file_ref.remove(file_ref_input) + + record_management.update_record(vault, record) + + print("-" * 50) + print('Successfully deleted file reference!') + print("-" * 50) + + vault.sync_down() + else: + print(f'File reference "{file_ref_input}" not found') + else: + print(f'Record "{record.title}" has no file references') + else: + print(f'Record type {type(record)} does not support attachments') + + except Exception as e: + print(f'Error deleting attachment: {str(e)}') + import traceback + traceback.print_exc() + + vault.close() + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the delete attachment script. + Performs login and deletes an attachment. + """ + keeper_auth_context = login() + + if keeper_auth_context: + delete_attachment(keeper_auth_context) + else: + print("Login failed. Unable to delete attachment.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/records/delete_record.py b/examples/sdk_examples/records/delete_record.py new file mode 100644 index 00000000..64930b95 --- /dev/null +++ b/examples/sdk_examples/records/delete_record.py @@ -0,0 +1,151 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, vault_types, record_management +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def delete_record(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Delete a record from the vault. + + Args: + keeper_auth_context: The authenticated Keeper context. + """ + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + print("Deleting record from vault...") + print("-" * 50) + + search_title = "Example Record" + + record_to_delete = None + for record_info in vault.vault_data.records(): + if record_info.title.lower() == search_title.lower(): + record_to_delete = record_info + break + + if record_to_delete: + print(f'Found record to delete: {record_to_delete.title}') + print(f'Record UID: {record_to_delete.record_uid}') + + response = input(f'Are you sure you want to delete "{record_to_delete.title}"? (yes/no): ') + + if response.lower() in ['yes', 'y']: + try: + record_path = vault_types.RecordPath(record_uid=record_to_delete.record_uid, folder_uid='') + + def confirm_deletion(summary: str) -> bool: + print("\nDeletion Summary:") + print(summary) + return True + + record_management.delete_vault_objects(vault, [record_path], confirm=confirm_deletion) + + print("-" * 50) + print(f'Successfully deleted record: {record_to_delete.title}') + print("-" * 50) + + vault.sync_down() + + deleted_record = vault.vault_data.get_record(record_to_delete.record_uid) + if not deleted_record: + print('Verified: Record was deleted from vault') + + except Exception as e: + print(f'Error deleting record: {str(e)}') + else: + print('Deletion cancelled') + else: + print(f'No record found with title: "{search_title}"') + print('Available records:') + for i, record_info in enumerate(vault.vault_data.records(), 1): + if i > 10: + print(f'... and more') + break + print(f' - {record_info.title} ({record_info.record_uid})') + + vault.close() + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the delete record script. + Performs login and deletes a record. + """ + keeper_auth_context = login() + + if keeper_auth_context: + delete_record(keeper_auth_context) + else: + print("Login failed. Unable to delete record.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/records/download_attachment.py b/examples/sdk_examples/records/download_attachment.py new file mode 100644 index 00000000..3d45906f --- /dev/null +++ b/examples/sdk_examples/records/download_attachment.py @@ -0,0 +1,167 @@ +import getpass +import sqlite3 +import os + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, attachment +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def download_attachments(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Download attachments from a record. + + Args: + keeper_auth_context: The authenticated Keeper context. + """ + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + print("Downloading attachments from record...") + print("-" * 50) + + record_uid = input('Enter record UID (or leave empty to search): ').strip() + + if not record_uid: + print('\nSearching for records with attachments...') + for record_info in vault.vault_data.records(): + record = vault.vault_data.load_record(record_info.record_uid) + has_attachments = False + + if hasattr(record, 'attachments') and record.attachments: + has_attachments = True + elif hasattr(record, 'file_ref') and record.file_ref: + has_attachments = True + + if has_attachments: + print(f' - {record_info.title} ({record_info.record_uid})') + record_uid = record_info.record_uid + break + + if not record_uid: + print('No records with attachments found') + + if record_uid: + try: + output_dir = input('Enter output directory (or leave empty for current dir): ').strip() or '.' + + if not os.path.exists(output_dir): + os.makedirs(output_dir) + print(f'Created directory: {output_dir}') + + attachment_name = None + + attachments_to_download = list(attachment.prepare_attachment_download( + vault, + record_uid, + attachment_name + )) + + if attachments_to_download: + print(f'\nFound {len(attachments_to_download)} attachment(s) to download') + print("-" * 50) + + for atta in attachments_to_download: + file_name = atta.title + file_path = os.path.join(output_dir, file_name) + + if os.path.isfile(file_path): + base_name, ext = os.path.splitext(file_name) + file_path = os.path.join(output_dir, f'{base_name}_{atta.file_id}{ext}') + + print(f'Downloading: {file_name}') + atta.download_to_file(file_path) + print(f'Saved to: {file_path}') + print("-" * 50) + + print(f'\nSuccessfully downloaded {len(attachments_to_download)} attachment(s)!') + else: + print(f'No attachments found for record: {record_uid}') + + except Exception as e: + print(f'Error downloading attachments: {str(e)}') + import traceback + traceback.print_exc() + + vault.close() + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the download attachment script. + Performs login and downloads attachments. + """ + keeper_auth_context = login() + + if keeper_auth_context: + download_attachments(keeper_auth_context) + else: + print("Login failed. Unable to download attachments.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/records/file_report.py b/examples/sdk_examples/records/file_report.py new file mode 100644 index 00000000..abc51751 --- /dev/null +++ b/examples/sdk_examples/records/file_report.py @@ -0,0 +1,204 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, vault_record +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def generate_file_report(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Generate a report of all file attachments in the vault. + + Args: + keeper_auth_context: The authenticated Keeper context. + """ + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + print("File Attachment Report") + print("=" * 50) + + total_records = 0 + records_with_files = 0 + total_files = 0 + total_size = 0 + + file_details = [] + + for record_info in vault.vault_data.records(): + total_records += 1 + + try: + record = vault.vault_data.load_record(record_info.record_uid) + record_has_files = False + record_file_count = 0 + record_file_size = 0 + + if isinstance(record, vault_record.PasswordRecord): + if record.attachments: + record_has_files = True + record_file_count = len(record.attachments) + + for atta in record.attachments: + file_size = atta.size if hasattr(atta, 'size') and atta.size else 0 + file_name = atta.title or atta.name + file_details.append({ + 'record_title': record.title, + 'record_uid': record_info.record_uid, + 'file_name': file_name, + 'file_id': atta.id, + 'file_size': file_size + }) + record_file_size += file_size + + elif isinstance(record, vault_record.TypedRecord): + from keepersdk.vault.record_facades import FileRefRecordFacade + facade = FileRefRecordFacade() + facade.record = record + + if isinstance(facade.file_ref, list) and facade.file_ref: + record_has_files = True + record_file_count = len(facade.file_ref) + + for file_uid in facade.file_ref: + file_record = vault.vault_data.load_record(file_uid) + if isinstance(file_record, vault_record.FileRecord): + file_size = file_record.size if hasattr(file_record, 'size') and file_record.size else 0 + file_details.append({ + 'record_title': record.title, + 'record_uid': record_info.record_uid, + 'file_name': file_record.title, + 'file_id': file_uid, + 'file_size': file_size + }) + record_file_size += file_size + + if record_has_files: + records_with_files += 1 + total_files += record_file_count + total_size += record_file_size + + except Exception as e: + print(f"Warning: Error processing record {record_info.record_uid}: {str(e)}") + continue + + def format_size(size_bytes: int) -> str: + for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + if size_bytes < 1024.0: + return f"{size_bytes:.2f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.2f} PB" + + print("\nSummary:") + print("-" * 50) + print(f"Total records in vault: {total_records}") + print(f"Records with attachments: {records_with_files}") + print(f"Total attachments: {total_files}") + print(f"Total storage used: {format_size(total_size)}") + + if file_details: + print("\n" + "=" * 50) + print("Detailed File List:") + print("=" * 50) + + file_details.sort(key=lambda x: x['file_size'], reverse=True) + + for i, file_info in enumerate(file_details, 1): + print(f"\n{i}. {file_info['file_name']}") + print(f" Record: {file_info['record_title']}") + print(f" Record UID: {file_info['record_uid']}") + print(f" File Size: {format_size(file_info['file_size'])}") + print(f" File ID: {file_info['file_id']}") + + if i >= 20 and len(file_details) > 20: + remaining = len(file_details) - 20 + print(f"\n... and {remaining} more file(s)") + break + else: + print("\nNo file attachments found in vault.") + + print("\n" + "=" * 50) + + vault.close() + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the file report script. + Performs login and generates file attachment report. + """ + keeper_auth_context = login() + + if keeper_auth_context: + generate_file_report(keeper_auth_context) + else: + print("Login failed. Unable to generate file report.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/records/find_duplicate.py b/examples/sdk_examples/records/find_duplicate.py new file mode 100644 index 00000000..6decc90c --- /dev/null +++ b/examples/sdk_examples/records/find_duplicate.py @@ -0,0 +1,198 @@ +import getpass +import sqlite3 +from typing import Dict, List + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, vault_record +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def find_duplicates(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Find duplicate records in the vault. + + Args: + keeper_auth_context: The authenticated Keeper context. + """ + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + print("Finding Duplicate Records") + print("-" * 50) + + print("\nDuplicate detection options:") + print("1. By title") + print("2. By title and username") + print("3. By title and URL") + print("4. By all fields (title, username, URL)") + + choice = input("\nSelect option (1-4, default=1): ").strip() or "1" + + match_title = True + match_login = choice in ["2", "4"] + match_url = choice in ["3", "4"] + + print(f"\nSearching for duplicates...") + print(f"Matching by: Title={match_title}, Username={match_login}, URL={match_url}") + print("-" * 50) + + duplicates_map: Dict[str, List[vault_record.KeeperRecordInfo]] = {} + + for record_info in vault.vault_data.records(): + try: + record = vault.vault_data.load_record(record_info.record_uid) + + key_parts = [] + + if match_title: + title = record.title.lower().strip() if record.title else "" + key_parts.append(f"title:{title}") + + if match_login and isinstance(record, vault_record.PasswordRecord): + login = record.login.lower().strip() if record.login else "" + key_parts.append(f"login:{login}") + + if match_url: + url = "" + if isinstance(record, vault_record.PasswordRecord): + url = record.link.lower().strip() if record.link else "" + key_parts.append(f"url:{url}") + + if key_parts: + key = "|".join(key_parts) + + if key not in duplicates_map: + duplicates_map[key] = [] + duplicates_map[key].append(record_info) + + except Exception as e: + print(f"Warning: Error processing record {record_info.record_uid}: {str(e)}") + continue + + duplicate_groups = {k: v for k, v in duplicates_map.items() if len(v) > 1} + + if duplicate_groups: + print(f"\nFound {len(duplicate_groups)} duplicate group(s):") + print("=" * 50) + + total_duplicates = 0 + for i, (key, records) in enumerate(duplicate_groups.items(), 1): + total_duplicates += len(records) + print(f"\nDuplicate Group {i} ({len(records)} records):") + print("-" * 50) + + first_record = vault.vault_data.load_record(records[0].record_uid) + print(f"Common attributes:") + if match_title: + print(f" Title: {first_record.title}") + if match_login and isinstance(first_record, vault_record.PasswordRecord): + print(f" Username: {first_record.login or '(empty)'}") + if match_url and isinstance(first_record, vault_record.PasswordRecord): + print(f" URL: {first_record.link or '(empty)'}") + + print(f"\nDuplicate records:") + for record_info in records: + record = vault.vault_data.load_record(record_info.record_uid) + print(f" - {record.title}") + print(f" UID: {record_info.record_uid}") + print(f" Type: {record_info.record_type}") + if isinstance(record, vault_record.PasswordRecord): + print(f" Username: {record.login or '(empty)'}") + print(f" URL: {record.link or '(empty)'}") + print() + + print("=" * 50) + print(f"\nSummary:") + print(f" Total duplicate groups: {len(duplicate_groups)}") + print(f" Total duplicate records: {total_duplicates}") + print(f" Potential records to remove: {total_duplicates - len(duplicate_groups)}") + print("\nNote: Review duplicates carefully before deletion.") + print("Use delete_record.py to remove unwanted duplicates.") + else: + print("\nNo duplicate records found!") + print("Your vault is clean.") + + print("-" * 50) + + vault.close() + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the find duplicate script. + Performs login and finds duplicate records. + """ + keeper_auth_context = login() + + if keeper_auth_context: + find_duplicates(keeper_auth_context) + else: + print("Login failed. Unable to find duplicates.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/records/get_record.py b/examples/sdk_examples/records/get_record.py new file mode 100644 index 00000000..80482996 --- /dev/null +++ b/examples/sdk_examples/records/get_record.py @@ -0,0 +1,233 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, vault_record +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def get_record(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Get and display record details from the vault. + + Args: + keeper_auth_context: The authenticated Keeper context. + """ + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + print("Get Record Details") + print("-" * 50) + + search_input = input('Enter record UID or title: ').strip() + + if not search_input: + print('No input provided. Showing first record as example...') + for record_info in vault.vault_data.records(): + search_input = record_info.record_uid + break + + if search_input: + try: + record_info = vault.vault_data.get_record(search_input) + + if not record_info: + for rec_info in vault.vault_data.records(): + if rec_info.title.lower() == search_input.lower(): + record_info = rec_info + break + + if record_info: + record = vault.vault_data.load_record(record_info.record_uid) + + print("\n" + "=" * 50) + print(f"Record Details: {record_info.title}") + print("=" * 50) + print(f'Record UID: {record_info.record_uid}') + print(f'Record Type: {record_info.record_type}') + print(f'Record Version: {record_info.version}') + print("-" * 50) + + if isinstance(record, vault_record.PasswordRecord): + print('Type: Password Record (Legacy)') + print(f'Title: {record.title}') + print(f'Username: {record.login or "(empty)"}') + print(f'Password: {"*" * len(record.password) if record.password else "(empty)"}') + print(f'URL: {record.link or "(empty)"}') + + if record.notes: + notes_preview = record.notes[:100] + '...' if len(record.notes) > 100 else record.notes + print(f'Notes: {notes_preview}') + else: + print('Notes: (empty)') + + if record.custom: + print('\nCustom Fields:') + for custom_field in record.custom: + print(f' - {custom_field.name}: {custom_field.value}') + + if record.attachments: + print('\nAttachments:') + for atta in record.attachments: + print(f' - {atta.title or atta.name} ({atta.size} bytes)') + + elif isinstance(record, vault_record.TypedRecord): + print('Type: Typed Record (Modern)') + print(f'Title: {record.title}') + print(f'Record Type: {record.record_type}') + + if record.notes: + notes_preview = record.notes[:100] + '...' if len(record.notes) > 100 else record.notes + print(f'Notes: {notes_preview}') + else: + print('Notes: (empty)') + + if record.fields: + print('\nFields:') + for field in record.fields: + field_type = field.type if hasattr(field, 'type') else 'unknown' + field_label = field.label if hasattr(field, 'label') else '' + field_value = field.value if hasattr(field, 'value') else '' + + if isinstance(field_value, list): + if field_value: + field_value = ', '.join(str(v) for v in field_value) + else: + field_value = '(empty)' + + if field_type in ['password', 'secret']: + field_value = '*' * 10 if field_value else '(empty)' + + label_text = f' ({field_label})' if field_label else '' + print(f' - {field_type}{label_text}: {field_value}') + + if record.custom: + print('\nCustom Fields:') + for custom_field in record.custom: + field_type = custom_field.type if hasattr(custom_field, 'type') else 'unknown' + field_label = custom_field.label if hasattr(custom_field, 'label') else '' + field_value = custom_field.value if hasattr(custom_field, 'value') else '' + + if isinstance(field_value, list): + if field_value: + field_value = ', '.join(str(v) for v in field_value) + else: + field_value = '(empty)' + + label_text = f' ({field_label})' if field_label else '' + print(f' - {field_type}{label_text}: {field_value}') + + from keepersdk.vault.record_facades import FileRefRecordFacade + facade = FileRefRecordFacade() + facade.record = record + if isinstance(facade.file_ref, list) and facade.file_ref: + print('\nFile References:') + for file_uid in facade.file_ref: + file_record = vault.vault_data.load_record(file_uid) + if isinstance(file_record, vault_record.FileRecord): + print(f' - {file_record.title} (UID: {file_uid})') + + else: + print(f'Record Type: {type(record).__name__}') + print(f'Title: {record.title if hasattr(record, "title") else "N/A"}') + + print("=" * 50) + + else: + print(f'Record not found: "{search_input}"') + print('\nAvailable records:') + for i, rec_info in enumerate(vault.vault_data.records(), 1): + if i > 10: + print(f'... and more') + break + print(f' - {rec_info.title} ({rec_info.record_uid})') + + except Exception as e: + print(f'Error getting record details: {str(e)}') + import traceback + traceback.print_exc() + else: + print('No records found in vault') + + vault.close() + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the get record script. + Performs login and displays record details. + """ + keeper_auth_context = login() + + if keeper_auth_context: + get_record(keeper_auth_context) + else: + print("Login failed. Unable to get record.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/records/list_records.py b/examples/sdk_examples/records/list_records.py new file mode 100644 index 00000000..811aa102 --- /dev/null +++ b/examples/sdk_examples/records/list_records.py @@ -0,0 +1,116 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, vault_record +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Succesfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def list_records(keeper_auth_context: keeper_auth.KeeperAuth): + """ + List all records in the vault. + + Args: + keeper_auth_context: The authenticated Keeper context. + """ + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + print("Vault Records:") + print("-" * 50) + for record in vault.vault_data.records(): + print(f'Title: {record.title}') + + if record.version == 2: + legacy_record = vault.vault_data.load_record(record.record_uid) + if isinstance(legacy_record, vault_record.PasswordRecord): + print(f'Username: {legacy_record.login}') + print(f'URL: {legacy_record.link}') + + elif record.version >= 3: + print(f'Record Type: {record.record_type}') + + print("-" * 50) + + vault.close() + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the list records script. + Performs login and lists all records. + """ + keeper_auth_context = login() + + if keeper_auth_context: + list_records(keeper_auth_context) + else: + print("Login failed. Unable to list records.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/records/record_history.py b/examples/sdk_examples/records/record_history.py new file mode 100644 index 00000000..3593d5c3 --- /dev/null +++ b/examples/sdk_examples/records/record_history.py @@ -0,0 +1,166 @@ +import getpass +import sqlite3 +from datetime import datetime + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online +from keepersdk import utils +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def view_record_history(keeper_auth_context: keeper_auth.KeeperAuth): + """ + View the revision history of a record. + + Args: + keeper_auth_context: The authenticated Keeper context. + """ + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + record_search = input('Enter record title or UID to view history: ').strip() + + if not record_search: + print("Record identifier cannot be empty") + else: + record_found = None + + for record_info in vault.vault_data.records(): + if (record_search.lower() in record_info.title.lower() or + record_search == record_info.record_uid): + record_found = record_info + break + + if not record_found: + print(f"Record '{record_search}' not found") + else: + try: + current_rec = vault.vault_data._records[record_found.record_uid] + record_key = current_rec.record_key + + request = { + 'command': 'get_record_history', + 'record_uid': record_found.record_uid, + 'client_time': utils.current_milli_time() + } + + response = keeper_auth_context.execute_auth_command(request) + history = response.get('history', []) + + if not history: + print(f"\nNo history found for record: {record_found.title}") + else: + history.sort(key=lambda x: x.get('revision', 0), reverse=True) + + print(f"\nRecord History for: {record_found.title}") + print(f"Record UID: {record_found.record_uid}") + print("=" * 120) + print(f"{'Version':<15} {'Modified By':<35} {'Modified Time':<30} {'Revision':<15}") + print("-" * 120) + + length = len(history) + for i, version in enumerate(history): + version_label = 'Current' if i == 0 else f'V.{length - i}' + + modified_by = version.get('user_name', 'Unknown')[:34] + + modified_time = version.get('client_modified_time', 0) + if modified_time: + dt = datetime.fromtimestamp(modified_time / 1000.0) + time_str = dt.strftime('%Y-%m-%d %H:%M:%S') + else: + time_str = 'N/A' + + revision = version.get('revision', 'N/A') + + print(f"{version_label:<15} {modified_by:<35} {time_str:<30} {revision:<15}") + + print("-" * 120) + print(f"Total revisions: {length}") + print("\nNote: Use the revision number to restore to a previous version.") + print("=" * 120) + + except Exception as e: + print(f"Error loading record history: {e}") + + vault.close() + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the record history script. + Performs login and displays record history. + """ + keeper_auth_context = login() + + if keeper_auth_context: + view_record_history(keeper_auth_context) + else: + print("Login failed. Unable to view record history.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/records/search_record.py b/examples/sdk_examples/records/search_record.py new file mode 100644 index 00000000..0045e96a --- /dev/null +++ b/examples/sdk_examples/records/search_record.py @@ -0,0 +1,139 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def search_records(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Search for records in the vault. + + Args: + keeper_auth_context: The authenticated Keeper context. + """ + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + print("Searching for records...") + print("-" * 50) + + search_pattern = input('Enter search pattern (e.g., "example", "login", or record UID): ') or "example" + + record_type = None + record_version = None + + print(f'\nSearching for records matching: "{search_pattern}"') + if record_type: + print(f'Record type filter: {record_type}') + if record_version: + print(f'Record version filter: {record_version}') + print("-" * 50) + + try: + matching_records = list(vault.vault_data.find_records( + criteria=search_pattern, + record_type=record_type, + record_version=record_version + )) + + if matching_records: + print(f'\nFound {len(matching_records)} matching record(s):') + print("-" * 50) + + for record_info in matching_records: + print(f'Title: {record_info.title}') + print(f'Record UID: {record_info.record_uid}') + print(f'Record Type: {record_info.record_type}') + print(f'Record Version: {record_info.version}') + print("-" * 50) + else: + print(f'\nNo records found matching: "{search_pattern}"') + print('\nTip: Try searching for a different term or check available records using list_records.py') + + except Exception as e: + print(f'Error searching for records: {str(e)}') + + vault.close() + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the search record script. + Performs login and searches for records. + """ + keeper_auth_context = login() + + if keeper_auth_context: + search_records(keeper_auth_context) + else: + print("Login failed. Unable to search records.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/records/update_record.py b/examples/sdk_examples/records/update_record.py new file mode 100644 index 00000000..02fc7458 --- /dev/null +++ b/examples/sdk_examples/records/update_record.py @@ -0,0 +1,161 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, vault_record, record_management +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def update_record(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Update a record in the vault. + + Args: + keeper_auth_context: The authenticated Keeper context. + """ + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + print("Updating record in vault...") + print("-" * 50) + + record_to_update = None + for record_info in vault.vault_data.records(): + record_to_update = record_info + break + + if record_to_update: + record = vault.vault_data.load_record(record_to_update.record_uid) + + print(f'Updating record: {record_to_update.title}') + print(f'Record UID: {record_to_update.record_uid}') + print(f'Record Version: {record_to_update.version}') + + if isinstance(record, vault_record.PasswordRecord): + print('Original values:') + print(f' Title: {record.title}') + print(f' Username: {record.login}') + print(f' URL: {record.link}') + print(f' Notes: {record.notes[:50] if record.notes else ""}...') + + record.title = f'{record.title} (Updated)' + record.login = f'updated_{record.login}' if record.login else 'updated@example.com' + record.password = 'UpdatedPassword123!' + record.link = 'https://updated-example.com' + record.notes = f'{record.notes}\n\nUpdated on: SDK Example' + + print('\nNew values:') + print(f' Title: {record.title}') + print(f' Username: {record.login}') + print(f' URL: {record.link}') + + elif isinstance(record, vault_record.TypedRecord): + print('Original values:') + print(f' Title: {record.title}') + print(f' Record Type: {record.record_type}') + + record.title = f'{record.title} (Updated)' + record.notes = f'{record.notes}\n\nUpdated on: SDK Example' if record.notes else 'Updated on: SDK Example' + + print('\nNew values:') + print(f' Title: {record.title}') + + try: + record_management.update_record(vault, record) + print("\n" + "-" * 50) + print('Successfully updated record!') + print("-" * 50) + + vault.sync_down() + + updated_record_info = vault.vault_data.get_record(record_to_update.record_uid) + if updated_record_info: + print(f'Verified: Record "{updated_record_info.title}" was updated') + + except Exception as e: + print(f'Error updating record: {str(e)}') + else: + print('No records found in vault to update') + + vault.close() + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the update record script. + Performs login and updates a record. + """ + keeper_auth_context = login() + + if keeper_auth_context: + update_record(keeper_auth_context) + else: + print("Login failed. Unable to update record.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/records/upload_attachment.py b/examples/sdk_examples/records/upload_attachment.py new file mode 100644 index 00000000..71c1e63b --- /dev/null +++ b/examples/sdk_examples/records/upload_attachment.py @@ -0,0 +1,150 @@ +import getpass +import sqlite3 +import os + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, vault_record, attachment, record_management +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth_context: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def upload_attachment(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Upload an attachment to a record. + + Args: + keeper_auth_context: The authenticated Keeper context. + """ + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + print("Uploading attachment to record...") + print("-" * 50) + + record_uid = input('Enter record UID (or leave empty to use first record): ').strip() + + if not record_uid: + for record_info in vault.vault_data.records(): + record_uid = record_info.record_uid + print(f'Using record: {record_info.title} ({record_uid})') + break + + if record_uid: + file_path = input('Enter file path to upload: ').strip() + + if not file_path: + print('No file path provided. Example: /path/to/file.txt') + elif not os.path.isfile(file_path): + print(f'File not found: {file_path}') + else: + try: + record = vault.vault_data.load_record(record_uid) + + if not isinstance(record, (vault_record.PasswordRecord, vault_record.TypedRecord)): + print(f'Record type {type(record)} does not support attachments') + else: + print(f'Uploading file: {os.path.basename(file_path)}') + print(f'File size: {os.path.getsize(file_path)} bytes') + + upload_task = attachment.FileUploadTask(file_path) + + attachment.upload_attachments(vault, record, [upload_task]) + + record_management.update_record(vault, record) + + print("-" * 50) + print('Successfully uploaded attachment!') + print(f'File: {os.path.basename(file_path)}') + print(f'Record: {record.title}') + print("-" * 50) + + vault.sync_down() + + except Exception as e: + print(f'Error uploading attachment: {str(e)}') + import traceback + traceback.print_exc() + else: + print('No records found in vault') + + vault.close() + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the upload attachment script. + Performs login and uploads an attachment. + """ + keeper_auth_context = login() + + if keeper_auth_context: + upload_attachment(keeper_auth_context) + else: + print("Login failed. Unable to upload attachment.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/secrets_manager/create_app.py b/examples/sdk_examples/secrets_manager/create_app.py new file mode 100644 index 00000000..63ebd4ad --- /dev/null +++ b/examples/sdk_examples/secrets_manager/create_app.py @@ -0,0 +1,107 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, ksm_management +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """Handle server selection, username input, and authentication steps.""" + config = configuration.JsonConfigurationStorage() + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def create_secrets_manager_application(keeper_auth_context: keeper_auth.KeeperAuth): + """Create a new Secrets Manager application.""" + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + + try: + app_name = input('Enter name for new Secrets Manager application: ').strip() + + if not app_name: + print("Application name cannot be empty") + else: + force_add = input('Allow duplicate names? (y/n): ').strip().lower() == 'y' + + print(f"\nCreating Secrets Manager application: {app_name}") + + app_uid = ksm_management.create_secrets_manager_app(vault, app_name, force_add=force_add) + + print(f"\n✓ Secrets Manager application created successfully!") + print(f"Application Name: {app_name}") + print(f"Application UID: {app_uid}") + print("\nNext steps:") + print(" 1. Share records or folders with this application") + print(" 2. Generate client devices for access") + print(" 3. Use the application in your integrations") + + vault.sync_down() + + except ValueError as e: + print(f"Error: {e}") + except Exception as e: + print(f"Error creating application: {e}") + + vault.close() + keeper_auth_context.close() + + +def main(): + """Main function to orchestrate login and Secrets Manager application creation.""" + keeper_auth_context = login() + if keeper_auth_context: + create_secrets_manager_application(keeper_auth_context) + + +if __name__ == '__main__': + main() diff --git a/examples/sdk_examples/secrets_manager/get_app.py b/examples/sdk_examples/secrets_manager/get_app.py new file mode 100644 index 00000000..a943c2d0 --- /dev/null +++ b/examples/sdk_examples/secrets_manager/get_app.py @@ -0,0 +1,135 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, ksm_management +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """Handle server selection, username input, and authentication steps.""" + config = configuration.JsonConfigurationStorage() + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def get_secrets_manager_application(keeper_auth_context: keeper_auth.KeeperAuth): + """Get details of a Secrets Manager application.""" + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + try: + app_search = input('Enter application name or UID: ').strip() + + if not app_search: + print("Application identifier cannot be empty") + else: + app = ksm_management.get_secrets_manager_app(vault, app_search) + + print(f"\nSecrets Manager Application Details") + print("=" * 100) + print(f"App Name: {app.name}") + print(f"App UID: {app.uid}") + print(f"Records Shared: {app.records}") + print(f"Folders Shared: {app.folders}") + print(f"Client Devices: {app.count}") + + if app.client_devices: + print(f"\nClient Devices ({len(app.client_devices)}):") + print("-" * 100) + print(f"{'Name':<25} {'Short ID':<15} {'Created':<20} {'Last Access':<20} {'IP Address':<20}") + print("-" * 100) + + for client in app.client_devices: + name = client.name[:24] if client.name else 'N/A' + short_id = client.short_id[:14] if client.short_id else 'N/A' + created = client.created_on.strftime('%Y-%m-%d %H:%M') if client.created_on else 'N/A' + last_access = client.last_access.strftime('%Y-%m-%d %H:%M') if client.last_access else 'Never' + ip_address = client.ip_address[:19] if client.ip_address else 'N/A' + + print(f"{name:<25} {short_id:<15} {created:<20} {last_access:<20} {ip_address:<20}") + + if app.shared_secrets: + print(f"\nShared Secrets ({len(app.shared_secrets)}):") + print("-" * 100) + print(f"{'Type':<15} {'Name':<45} {'UID':<40}") + print("-" * 100) + + for secret in app.shared_secrets[:20]: + secret_type = secret.type[:14] if secret.type else 'N/A' + secret_name = secret.name[:44] if secret.name else 'N/A' + secret_uid = secret.uid[:39] if secret.uid else 'N/A' + + print(f"{secret_type:<15} {secret_name:<45} {secret_uid:<40}") + + if len(app.shared_secrets) > 20: + print(f" ... and {len(app.shared_secrets) - 20} more") + + print("=" * 100) + + except ValueError as e: + print(f"Error: {e}") + except Exception as e: + print(f"Error getting application details: {e}") + + vault.close() + keeper_auth_context.close() + + +def main(): + """Main function to orchestrate login and get Secrets Manager application details.""" + keeper_auth_context = login() + if keeper_auth_context: + get_secrets_manager_application(keeper_auth_context) + + +if __name__ == '__main__': + main() diff --git a/examples/sdk_examples/secrets_manager/list_apps.py b/examples/sdk_examples/secrets_manager/list_apps.py new file mode 100644 index 00000000..a225dfbf --- /dev/null +++ b/examples/sdk_examples/secrets_manager/list_apps.py @@ -0,0 +1,110 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, ksm_management +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """Handle server selection, username input, and authentication steps.""" + config = configuration.JsonConfigurationStorage() + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def list_secrets_manager_applications(keeper_auth_context: keeper_auth.KeeperAuth): + """List all Secrets Manager applications.""" + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + try: + apps = ksm_management.list_secrets_manager_apps(vault) + + if not apps: + print("\nNo Secrets Manager applications found") + else: + print(f"\nSecrets Manager Applications ({len(apps)})") + print("=" * 120) + print(f"{'App Name':<30} {'App UID':<40} {'Records':<10} {'Folders':<10} {'Clients':<10} {'Last Access':<20}") + print("-" * 120) + + for app in apps: + app_name = app.name[:29] if app.name else '(Unnamed)' + app_uid = app.uid[:39] + records = app.records + folders = app.folders + clients = app.count + last_access = app.last_access.strftime('%Y-%m-%d %H:%M') if app.last_access else 'Never' + + print(f"{app_name:<30} {app_uid:<40} {records:<10} {folders:<10} {clients:<10} {last_access:<20}") + + print("-" * 120) + print(f"Total applications: {len(apps)}") + + print("=" * 120) + + except Exception as e: + print(f"Error listing Secrets Manager applications: {e}") + + vault.close() + keeper_auth_context.close() + + +def main(): + """Main function to orchestrate login and list Secrets Manager applications.""" + keeper_auth_context = login() + if keeper_auth_context: + list_secrets_manager_applications(keeper_auth_context) + + +if __name__ == '__main__': + main() diff --git a/examples/sdk_examples/trash/list_trash.py b/examples/sdk_examples/trash/list_trash.py new file mode 100644 index 00000000..3aaabe64 --- /dev/null +++ b/examples/sdk_examples/trash/list_trash.py @@ -0,0 +1,158 @@ +import getpass +import sqlite3 +from datetime import datetime + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, trash_management +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """Handle server selection, username input, and authentication steps.""" + config = configuration.JsonConfigurationStorage() + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def list_trash_items(keeper_auth_context: keeper_auth.KeeperAuth): + """List all items in trash.""" + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + try: + trash_management.TrashManagement._ensure_deleted_records_loaded(vault) + + deleted_records = trash_management.TrashManagement.get_deleted_records() + orphaned_records = trash_management.TrashManagement.get_orphaned_records() + deleted_shared_folders = trash_management.TrashManagement.get_shared_folders() + + total_items = len(deleted_records) + len(orphaned_records) + len(deleted_shared_folders) + + if total_items == 0: + print("\nTrash is empty") + print("No deleted records or folders found.") + else: + print(f"\nDeleted Items in Trash ({total_items} total)") + print("=" * 120) + + if deleted_records: + print(f"\nDeleted Records ({len(deleted_records)})") + print("-" * 120) + print(f"{'Title':<40} {'Record UID':<40} {'Deleted On':<30}") + print("-" * 120) + + for record_uid, record in list(deleted_records.items())[:20]: + title = record.get('title', '(Untitled)')[:39] + uid = record_uid[:39] + + deleted_time = record.get('client_modified_time', 0) + deleted_str = datetime.fromtimestamp(deleted_time / 1000).strftime('%Y-%m-%d %H:%M:%S') if deleted_time else 'N/A' + + print(f"{title:<40} {uid:<40} {deleted_str:<30}") + + if len(deleted_records) > 20: + print(f" ... and {len(deleted_records) - 20} more") + + if orphaned_records: + print(f"\nOrphaned Records ({len(orphaned_records)})") + print("-" * 120) + print(f"{'Title':<40} {'Record UID':<40} {'Status':<30}") + print("-" * 120) + + for record_uid, record in list(orphaned_records.items())[:10]: + title = record.get('title', '(Untitled)')[:39] + uid = record_uid[:39] + status = 'No Access' + + print(f"{title:<40} {uid:<40} {status:<30}") + + if len(orphaned_records) > 10: + print(f" ... and {len(orphaned_records) - 10} more") + + if deleted_shared_folders: + print(f"\nDeleted Shared Folders ({len(deleted_shared_folders)})") + print("-" * 120) + print(f"{'Folder Name':<40} {'Folder UID':<40} {'Records':<20}") + print("-" * 120) + + for folder_uid, folder in list(deleted_shared_folders.items())[:10]: + folder_name = folder.get('name', '(Unnamed)')[:39] + uid = folder_uid[:39] + records_count = len(folder.get('records', [])) + + print(f"{folder_name:<40} {uid:<40} {records_count:<20}") + + if len(deleted_shared_folders) > 10: + print(f" ... and {len(deleted_shared_folders) - 10} more") + + print("=" * 120) + print(f"\nSummary:") + print(f" Deleted Records: {len(deleted_records)}") + print(f" Orphaned Records: {len(orphaned_records)}") + print(f" Deleted Shared Folders: {len(deleted_shared_folders)}") + print(f"\nUse restore operations to recover items from trash.") + + except Exception as e: + print(f"Error listing trash: {e}") + + vault.close() + keeper_auth_context.close() + + +def main(): + """Main function to orchestrate login and list trash items.""" + keeper_auth_context = login() + if keeper_auth_context: + list_trash_items(keeper_auth_context) + + +if __name__ == '__main__': + main() diff --git a/examples/sdk_examples/trash/restore_records.py b/examples/sdk_examples/trash/restore_records.py new file mode 100644 index 00000000..307bdfba --- /dev/null +++ b/examples/sdk_examples/trash/restore_records.py @@ -0,0 +1,140 @@ +import getpass +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, trash_management +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """Handle server selection, username input, and authentication steps.""" + config = configuration.JsonConfigurationStorage() + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def restore_trash_items(keeper_auth_context: keeper_auth.KeeperAuth): + """Restore records from trash.""" + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + + try: + record_search = input('Enter record UID or pattern to restore (or "all" for all): ').strip() + + if not record_search: + print("Record identifier cannot be empty") + else: + trash_management.TrashManagement._ensure_deleted_records_loaded(vault) + + deleted_records = trash_management.TrashManagement.get_deleted_records() + orphaned_records = trash_management.TrashManagement.get_orphaned_records() + + if len(deleted_records) == 0 and len(orphaned_records) == 0: + print("\nTrash is empty - no records to restore") + else: + records_to_restore = [] + + if record_search.lower() == 'all': + records_to_restore = list(deleted_records.keys()) + list(orphaned_records.keys()) + else: + if record_search in deleted_records or record_search in orphaned_records: + records_to_restore.append(record_search) + else: + pattern = record_search.lower() + for uid, record in deleted_records.items(): + title = record.get('title', '').lower() + if pattern in title or pattern in uid: + records_to_restore.append(uid) + + for uid, record in orphaned_records.items(): + title = record.get('title', '').lower() + if pattern in title or pattern in uid: + records_to_restore.append(uid) + + if not records_to_restore: + print(f"No records found matching: '{record_search}'") + else: + print(f"\nFound {len(records_to_restore)} record(s) to restore") + + if len(records_to_restore) <= 5: + for uid in records_to_restore: + record = deleted_records.get(uid) or orphaned_records.get(uid) + title = record.get('title', '(Untitled)') if record else 'Unknown' + print(f" - {title} ({uid})") + + confirm = input(f"\nRestore {len(records_to_restore)} record(s)? (yes/no): ").strip().lower() + + if confirm == 'yes': + print("\nRestoring records...") + trash_management.restore_trash_records(vault, records_to_restore) + + print(f"\n✓ Successfully restored {len(records_to_restore)} record(s)") + print("Records have been moved back to your vault.") + + vault.sync_down() + else: + print("Restore cancelled") + + except Exception as e: + print(f"Error restoring records: {e}") + + vault.close() + keeper_auth_context.close() + + +def main(): + """Main function to orchestrate login and restore trash items.""" + keeper_auth_context = login() + if keeper_auth_context: + restore_trash_items(keeper_auth_context) + + +if __name__ == '__main__': + main() diff --git a/examples/sdk_examples/trash/view_trash_record.py b/examples/sdk_examples/trash/view_trash_record.py new file mode 100644 index 00000000..8ce4ecf2 --- /dev/null +++ b/examples/sdk_examples/trash/view_trash_record.py @@ -0,0 +1,126 @@ +import getpass +import sqlite3 +from datetime import datetime + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import sqlite_storage, vault_online, trash_management +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login(): + """Handle server selection, username input, and authentication steps.""" + config = configuration.JsonConfigurationStorage() + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = None + if config.get().last_login: + username = config.get().last_login + if not username: + username = input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def view_trash_record(keeper_auth_context: keeper_auth.KeeperAuth): + """View details of a record in trash.""" + conn = sqlite3.Connection('file::memory:', uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + ) + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + vault.sync_down() + + try: + record_uid = input('Enter record UID to view: ').strip() + + if not record_uid: + print("Record UID cannot be empty") + else: + record, is_shared = trash_management.get_trash_record(vault, record_uid) + + if not record: + print(f"Record '{record_uid}' not found in trash") + else: + print("\nDeleted Record Details") + print("=" * 100) + + title = record.get('title', '(Untitled)') + print(f"Title: {title}") + print(f"Record UID: {record_uid}") + print(f"Type: {'Orphaned/Shared' if is_shared else 'Deleted'}") + + if 'version' in record: + print(f"Version: {record['version']}") + + if 'revision' in record: + print(f"Revision: {record['revision']}") + + if 'client_modified_time' in record: + modified_time = record['client_modified_time'] + modified_str = datetime.fromtimestamp(modified_time / 1000).strftime('%Y-%m-%d %H:%M:%S') + print(f"Last Modified: {modified_str}") + + if 'record_type' in record: + print(f"Record Type: {record['record_type']}") + + if is_shared: + print("\n⚠️ This is an orphaned record (no access)") + print(" Restoration may require special permissions") + else: + print("\n✓ This record can be restored") + + print("=" * 100) + + except Exception as e: + print(f"Error viewing trash record: {e}") + + vault.close() + keeper_auth_context.close() + + +def main(): + """Main function to orchestrate login and view trash record details.""" + keeper_auth_context = login() + if keeper_auth_context: + view_trash_record(keeper_auth_context) + + +if __name__ == '__main__': + main() From 3e3fa92838a519917b1306e9a774d4d0b4bf074e Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Wed, 10 Dec 2025 18:13:17 +0530 Subject: [PATCH 13/37] Record and Folder Share Functionality added to SDK --- .../keepercli/commands/account_commands.py | 1 - .../commands/enterprise_create_user.py | 2 +- .../src/keepercli/commands/password_report.py | 1 - .../src/keepercli/commands/record_edit.py | 10 +- .../commands/record_handling_commands.py | 22 +- .../src/keepercli/commands/record_type.py | 2 + .../src/keepercli/commands/register.py | 8 +- .../src/keepercli/commands/secrets_manager.py | 930 +++++++---- .../keepercli/commands/share_management.py | 1401 ----------------- .../src/keepercli/commands/shares.py | 1074 +++++++++++++ .../src/keepercli/commands/trash.py | 8 +- .../src/keepercli/commands/vault_record.py | 6 +- .../src/keepercli/helpers/share_record.py | 85 - .../src/keepercli/register_commands.py | 12 +- .../keepersdk/vault/share_management_utils.py | 802 ++++++---- .../src/keepersdk/vault/shared_record.py | 118 ++ .../src/keepersdk/vault/shares_management.py | 809 ++++++++++ 17 files changed, 3150 insertions(+), 2141 deletions(-) delete mode 100644 keepercli-package/src/keepercli/commands/share_management.py create mode 100644 keepercli-package/src/keepercli/commands/shares.py delete mode 100644 keepercli-package/src/keepercli/helpers/share_record.py rename keepercli-package/src/keepercli/helpers/share_utils.py => keepersdk-package/src/keepersdk/vault/share_management_utils.py (51%) create mode 100644 keepersdk-package/src/keepersdk/vault/shared_record.py create mode 100644 keepersdk-package/src/keepersdk/vault/shares_management.py diff --git a/keepercli-package/src/keepercli/commands/account_commands.py b/keepercli-package/src/keepercli/commands/account_commands.py index 347a2ee4..c13b72d0 100644 --- a/keepercli-package/src/keepercli/commands/account_commands.py +++ b/keepercli-package/src/keepercli/commands/account_commands.py @@ -1,6 +1,5 @@ import argparse import datetime -import getpass import os import re from typing import Tuple, Optional, List, Any diff --git a/keepercli-package/src/keepercli/commands/enterprise_create_user.py b/keepercli-package/src/keepercli/commands/enterprise_create_user.py index df53c752..870434c1 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_create_user.py +++ b/keepercli-package/src/keepercli/commands/enterprise_create_user.py @@ -7,7 +7,7 @@ from ..params import KeeperParams from keepersdk.vault import vault_record from keepersdk.enterprise.enterprise_user_management import EnterpriseUserManager, CreateUserResponse -from .share_management import OneTimeShareCreateCommand +from .shares import OneTimeShareCreateCommand # Constants DEFAULT_ONE_TIME_SHARE_EXPIRY = '7d' diff --git a/keepercli-package/src/keepercli/commands/password_report.py b/keepercli-package/src/keepercli/commands/password_report.py index 8e80d1b8..5ccbddd4 100644 --- a/keepercli-package/src/keepercli/commands/password_report.py +++ b/keepercli-package/src/keepercli/commands/password_report.py @@ -1,4 +1,3 @@ - import argparse from collections import namedtuple from typing import Optional, Dict, Tuple, Any diff --git a/keepercli-package/src/keepercli/commands/record_edit.py b/keepercli-package/src/keepercli/commands/record_edit.py index b80e9f76..784d2122 100644 --- a/keepercli-package/src/keepercli/commands/record_edit.py +++ b/keepercli-package/src/keepercli/commands/record_edit.py @@ -12,12 +12,12 @@ from cryptography.hazmat.primitives.asymmetric import ed25519 from keepersdk.vault import (record_types, typed_field_utils, vault_record, attachment, record_facades, - record_management, vault_online, vault_data, vault_types, vault_utils, vault_extensions) + record_management, vault_online, vault_data, vault_types, vault_utils, vault_extensions, share_management_utils) from keepersdk import crypto, generator from . import base, enterprise_utils from .. import prompt_utils, api, constants -from ..helpers import folder_utils, record_utils, report_utils, share_utils, timeout_utils +from ..helpers import folder_utils, record_utils, report_utils, timeout_utils from ..params import KeeperParams @@ -1339,7 +1339,7 @@ def _add_file_record_json_fields(self, record_data: vault_record.FileRecord, out def _add_share_info_to_json(self, vault: vault_online.VaultOnline, uid: str, output: dict): """Add share information to JSON output.""" - share_infos = share_utils.get_record_shares(vault=vault, record_uids=[uid]) + share_infos = share_management_utils.get_record_shares(vault=vault, record_uids=[uid]) if share_infos and len(share_infos) > 0: share_info = share_infos[0] shares = share_info.get('shares', {}) @@ -1516,7 +1516,7 @@ def _display_totp(self, totp: str, unmask: bool): def _display_share_information(self, vault: vault_online.VaultOnline, uid: str): """Display share information for a record.""" - share_infos = share_utils.get_record_shares(vault=vault, record_uids=[uid]) + share_infos = share_management_utils.get_record_shares(vault=vault, record_uids=[uid]) if not share_infos or len(share_infos) == 0: return @@ -1666,7 +1666,7 @@ def _display_record_fields(self, vault: vault_online.VaultOnline, uid: str, unma normalize_titles = {} # Get share information - share_infos = share_utils.get_record_shares(vault=vault, record_uids=[uid]) + share_infos = share_management_utils.get_record_shares(vault=vault, record_uids=[uid]) record_shares = [] folder_shares = [] if share_infos and len(share_infos) > 0: diff --git a/keepercli-package/src/keepercli/commands/record_handling_commands.py b/keepercli-package/src/keepercli/commands/record_handling_commands.py index fe6b4155..0e549e5f 100644 --- a/keepercli-package/src/keepercli/commands/record_handling_commands.py +++ b/keepercli-package/src/keepercli/commands/record_handling_commands.py @@ -9,11 +9,11 @@ from colorama import Fore, Back, Style from keepersdk.proto import record_pb2, folder_pb2 -from keepersdk.vault import (record_types, vault_record, vault_online, record_management) +from keepersdk.vault import (record_types, vault_record, vault_online, record_management, share_management_utils) from keepersdk import crypto, utils from . import base -from ..helpers import folder_utils, record_utils, report_utils, share_utils +from ..helpers import folder_utils, record_utils, report_utils from .. import api, prompt_utils from ..params import KeeperParams @@ -203,8 +203,6 @@ def _search_records_in_vault(self, context: KeeperParams, record_name: str, user if len(records) > 1: raise base.CommandError(f'More than one record are found for search criteria: {record_name}') - if context.vault and 'output' in context.vault.__dict__ and context.vault.output == 'clipboard': - logger.info('Record Title: %s', records[0].title) return records[0].record_uid def _filter_exact_matches(self, records: List, record_name: str) -> List: @@ -263,6 +261,8 @@ def _load_record_with_revision(self, context: KeeperParams, record_uid: str, rev def _extract_record_data(self, record, kwargs) -> tuple[str, str]: """Extract data from record based on command options.""" if kwargs.get('copy_uid'): + if kwargs.get('output', '') == 'clipboard': + logger.info(f'Record title: {record.title}') return 'Record UID', record.record_uid elif kwargs.get('login'): return 'Login', self._get_record_login(record) @@ -842,7 +842,7 @@ def _apply_share_partitioning(self, context, partitions, match_fields): return partitions r_uids = [rec_uid for duplicates in partitions for rec_uid in duplicates] - shared_records_lookup = share_utils.get_shared_records(context, r_uids, cache_only=True) + shared_records_lookup = share_management_utils.get_shared_records(context.vault, context.enterprise_data, r_uids, cache_only=True) return self._partition_by_shares(partitions, shared_records_lookup) @@ -898,8 +898,9 @@ def _build_report_headers(self, match_fields, out_fmt): return [report_utils.field_to_title(h) for h in headers] if out_fmt != 'json' else headers def _build_report_data(self, context, vault, partitions, match_fields): - shared_records_lookup = share_utils.get_shared_records( - context, + shared_records_lookup = share_management_utils.get_shared_records( + context.vault, + context.enterprise_data, [rec_uid for duplicates in partitions for rec_uid in duplicates], cache_only=True ) @@ -1157,7 +1158,7 @@ def process_direct_shares(self, folders): if not record_uids: return updates, skipped - shared_records = share_utils.get_record_shares(self.vault, record_uids) + shared_records = share_management_utils.get_record_shares(self.vault, list(record_uids)) if not shared_records: return updates, skipped @@ -1366,8 +1367,7 @@ def _report_skipped_direct_shares(self, skipped): for cmd in skipped: record_uid = utils.base64_url_encode(cmd['recordUid']) record = self.vault.vault_data.get_record(record_uid=record_uid) - record_owners = [x['username'] for x in record['shares']['user_permissions'] if x['owner']] - record_owner = record_owners[0] if len(record_owners) > 0 else '' + record_owner = record.flags.IsOwner rec = self.vault.vault_data.get_record(record_uid=record_uid) row = [record_uid, rec.title[:32], record_owner, cmd['to_username']] table.append(row) @@ -1633,7 +1633,7 @@ def add_arguments_to_parser(parser: argparse.ArgumentParser): parser.error = base.ArgparseCommand.raise_parse_exception parser.exit = base.ArgparseCommand.suppress_exit - def _resolve_folder(self, context, folder_name): + def _resolve_folder(self, context: KeeperParams, folder_name: str): """Resolve folder from name or UID.""" vault = context.vault diff --git a/keepercli-package/src/keepercli/commands/record_type.py b/keepercli-package/src/keepercli/commands/record_type.py index 3bc79c73..f2310d2e 100644 --- a/keepercli-package/src/keepercli/commands/record_type.py +++ b/keepercli-package/src/keepercli/commands/record_type.py @@ -355,6 +355,8 @@ def execute(self, context: KeeperParams, **kwargs) -> None: if source == 'keeper': plugin = keeper_format.KeeperRecordTypeDownload(vault=context.vault) + else: + raise base.CommandError(f'Method not implemented. Use keeper instead: {source}') #elif to be added for any other methods (currently only keeper is implemented) record_types = [] diff --git a/keepercli-package/src/keepercli/commands/register.py b/keepercli-package/src/keepercli/commands/register.py index 1d77dcaf..80d331fe 100644 --- a/keepercli-package/src/keepercli/commands/register.py +++ b/keepercli-package/src/keepercli/commands/register.py @@ -2,11 +2,11 @@ from keepersdk import crypto, utils from keepersdk.proto import APIRequest_pb2 -from keepersdk.vault import vault_utils +from keepersdk.vault import vault_utils, share_management_utils from . import base from .. import api -from ..helpers import report_utils, share_utils +from ..helpers import report_utils from ..params import KeeperParams CHUNK_SIZE = 1000 @@ -124,7 +124,7 @@ def _filter_records_by_folders(self, context, records, folders): """Filter records to only include those in the specified folders.""" folder_record_uids = set() for folder_path in folders: - contained_records = share_utils.get_contained_record_uids(context, folder_path, False) + contained_records = share_management_utils.get_contained_record_uids(context, folder_path, False) for record_uids in contained_records.values(): folder_record_uids.update(record_uids) @@ -187,7 +187,7 @@ def _dump_record_details(self, context, records, output_file, output_format): return None record_uids = {record.record_uid for record in records} - shared_records = share_utils.get_shared_records(context, record_uids).values() + shared_records = share_management_utils.get_shared_records(context.vault, context.enterprise_data, record_uids).values() headers = ['record_uid', 'title', 'shared_with', 'folder_path'] table_data = [] diff --git a/keepercli-package/src/keepercli/commands/secrets_manager.py b/keepercli-package/src/keepercli/commands/secrets_manager.py index 59821262..d15e7c1b 100644 --- a/keepercli-package/src/keepercli/commands/secrets_manager.py +++ b/keepercli-package/src/keepercli/commands/secrets_manager.py @@ -8,27 +8,65 @@ from urllib import parse from keepersdk import crypto, utils -from keepersdk.proto.APIRequest_pb2 import AddAppClientRequest, Device, RemoveAppClientsRequest, AppShareAdd, ApplicationShareType, AddAppSharesRequest, RemoveAppSharesRequest +from keepersdk.proto.APIRequest_pb2 import ( + AddAppClientRequest, + AddAppSharesRequest, + AppShareAdd, + ApplicationShareType, + Device, + RemoveAppClientsRequest, + RemoveAppSharesRequest +) from keepersdk.proto.enterprise_pb2 import GENERAL -from keepersdk.vault import ksm_management, vault_online +from keepersdk.vault import ksm_management, vault_online, share_management_utils, shares_management from keepersdk.vault.vault_record import TypedRecord from . import base -from .share_management import ShareAction, ShareFolderCommand, ShareRecordCommand +from .shares import ShareAction, ShareRecordCommand from .. import api, constants, prompt_utils -from ..helpers import ksm_utils, report_utils, share_utils +from ..helpers import ksm_utils, report_utils from ..params import KeeperParams logger = api.get_logger() + + CLIENT_ADD_URL = 'vault/app_client_add' CLIENT_REMOVE_URL = 'vault/app_client_remove' SHARE_ADD_URL = 'vault/app_share_add' SHARE_REMOVE_URL = 'vault/app_share_remove' + + RECORD = 'Record' SHARED_FOLDER = 'Shared Folder' +CLIENT_ID_COUNTER_BYTES = b'KEEPER_SECRETS_MANAGER_CLIENT_ID' +CLIENT_ID_DIGEST = 'sha512' + +MILLISECONDS_PER_MINUTE = 60 * 1000 +MILLISECONDS_PER_SECOND = 1000 +DEFAULT_FIRST_ACCESS_EXPIRES_IN_MINUTES = 60 +MAX_FIRST_ACCESS_EXPIRES_IN_MINUTES = 1440 +DEFAULT_TOKEN_COUNT = 1 + + +SHARE_ACTION_GRANT = ShareAction.GRANT.value +SHARE_ACTION_REVOKE = ShareAction.REVOKE.value +SHARE_ACTION_REMOVE = ShareAction.REMOVE.value + + +DATE_FORMAT = '%Y-%m-%d %H:%M:%S' + + +USER_CHOICE_DEFAULT_NO = 'n' +USER_CHOICE_YES = 'y' + + +WILDCARD_ALL = '*' +WILDCARD_ALL_ALIAS = 'all' + + class SecretsManagerCommand(Enum): LIST = "list" GET = "get" @@ -70,59 +108,61 @@ def add_arguments_to_parser(parser: argparse.ArgumentParser): ) def execute(self, context: KeeperParams, **kwargs) -> None: + self._validate_vault(context) + + command = kwargs.get('command') + if not command: + return self.get_parser().print_help() + + self._validate_app_parameter(command, kwargs.get('app')) + + command_handler = self._get_command_handler(context, command, kwargs) + if command_handler: + return command_handler() + + available_commands = ', '.join([cmd.value for cmd in SecretsManagerCommand]) + raise ValueError(f"Unknown command '{command}'. Available commands: {available_commands}") + + def _validate_vault(self, context: KeeperParams) -> None: + """Validate that vault is initialized.""" if not context.vault: raise ValueError("Vault is not initialized.") + def _validate_app_parameter(self, command: str, uid_or_name: Optional[str]) -> None: + """Validate that app parameter is provided when required.""" + if command != SecretsManagerCommand.LIST.value and not uid_or_name: + raise ValueError("Application name or UID is required. Use --app='example' to set it.") + + def _get_command_handler(self, context: KeeperParams, command: str, kwargs: dict): + """Get the appropriate command handler function.""" vault = context.vault - command = kwargs.get('command') uid_or_name = kwargs.get('app') force = kwargs.get('force') email = kwargs.get('email') is_admin = kwargs.get('admin', False) - if not command: - return self.get_parser().print_help() - - if command != SecretsManagerCommand.LIST.value and not uid_or_name: - raise ValueError("Application name or UID is required. Use --app='example' to set it.") + command_handlers = { + SecretsManagerCommand.LIST.value: lambda: self.list_app(vault=vault), + SecretsManagerCommand.GET.value: lambda: self.get_app(vault=vault, uid_or_name=uid_or_name), + SecretsManagerCommand.CREATE.value: lambda: self._handle_create_app(context, vault, uid_or_name, force), + SecretsManagerCommand.ADD.value: lambda: self._handle_create_app(context, vault, uid_or_name, force), + SecretsManagerCommand.REMOVE.value: lambda: self.remove_app(vault=vault, uid_or_name=uid_or_name, force=force), + SecretsManagerCommand.SHARE.value: lambda: self._handle_share_app(context, uid_or_name, email, is_admin, unshare=False), + SecretsManagerCommand.UNSHARE.value: lambda: self._handle_share_app(context, uid_or_name, email, is_admin, unshare=True) + } + + return command_handlers.get(command) - def list_app(): - return self.list_app(vault=vault) + def _handle_create_app(self, context: KeeperParams, vault, name: str, force: bool) -> None: + """Handle app creation and sync vault.""" + self.create_app(vault=vault, name=name, force=force) + context.vault_down() - def get_app(): - return self.get_app(vault=vault, uid_or_name=uid_or_name) - - def create_app(): - self.create_app(vault=vault, name=uid_or_name, force=force) - return context.vault_down() - - def remove_app(): - self.remove_app(vault=vault, uid_or_name=uid_or_name, force=force) - return - - def share_app(): - self.share_app(context=context, uid_or_name=uid_or_name, unshare=False, email=email, is_admin=is_admin) - return context.vault_down() - - def unshare_app(): - self.share_app(context=context, uid_or_name=uid_or_name, unshare=True, email=email, is_admin=is_admin) - return context.vault_down() - - command_map = { - SecretsManagerCommand.LIST.value: list_app, - SecretsManagerCommand.GET.value: get_app, - SecretsManagerCommand.CREATE.value: create_app, - SecretsManagerCommand.ADD.value: create_app, - SecretsManagerCommand.REMOVE.value: remove_app, - SecretsManagerCommand.SHARE.value: share_app, - SecretsManagerCommand.UNSHARE.value: unshare_app - } - - action = command_map.get(command) - if action: - return action() - else: - raise ValueError(f"Unknown command '{command}'. Available commands: {', '.join([cmd.value for cmd in SecretsManagerCommand])}") + def _handle_share_app(self, context: KeeperParams, uid_or_name: str, email: Optional[str], + is_admin: bool, unshare: bool) -> None: + """Handle app sharing/unsharing and sync vault.""" + self.share_app(context=context, uid_or_name=uid_or_name, unshare=unshare, email=email, is_admin=is_admin) + context.vault_down() def list_app(self, vault: vault_online.VaultOnline): @@ -164,33 +204,53 @@ def remove_app(self, vault: vault_online.VaultOnline, uid_or_name: str, force: O def share_app(self, context: KeeperParams, uid_or_name: str, unshare: bool = False, email: Optional[str] = None, is_admin: Optional[bool] = False): + """Share or unshare an application with a user.""" + self._validate_email_parameter(email) + + app_uid = self._find_app_uid(context.vault, uid_or_name) + share_args = self._build_share_args(app_uid, email, is_admin, unshare) + + self._execute_share_record(context, share_args) + context.vault.sync_down() + + SecretsManagerAppCommand.update_shares_user_permissions(context=context, uid=app_uid, removed=unshare) + + def _validate_email_parameter(self, email: Optional[str]) -> None: + """Validate that email parameter is provided.""" if not email: raise ValueError("Email parameter is required for sharing. Use --email='user@example.com' to set it.") - - app_record = next((r for r in context.vault.vault_data.records() if r.record_uid == uid_or_name or r.title == uid_or_name), None) + + def _find_app_uid(self, vault, uid_or_name: str) -> str: + """Find application UID by name or UID.""" + app_record = next( + (r for r in vault.vault_data.records() + if r.record_uid == uid_or_name or r.title == uid_or_name), + None + ) if not app_record: raise ValueError(f'No application found with UID/Name: {uid_or_name}') - app_uid = app_record.record_uid - action = ShareAction.REVOKE.value if unshare else ShareAction.GRANT.value - emails = [email] - can_edit=is_admin and not unshare - can_share=is_admin and not unshare - args = { + return app_record.record_uid + + def _build_share_args(self, app_uid: str, email: str, is_admin: bool, unshare: bool) -> dict: + """Build arguments for share record command.""" + action = SHARE_ACTION_REVOKE if unshare else SHARE_ACTION_GRANT + can_edit = is_admin and not unshare + can_share = is_admin and not unshare + + return { "action": action, - "email": emails, + "email": [email], "record": app_uid, "can_edit": can_edit, "can_share": can_share } - + + def _execute_share_record(self, context: KeeperParams, share_args: dict) -> None: + """Execute share record command.""" share_record_command = ShareRecordCommand() - share_record_command.execute(context=context, **args) - - context.vault.sync_down() - - SecretsManagerAppCommand.update_shares_user_permissions(context=context, uid=app_uid, removed=unshare) + share_record_command.execute(context=context, **share_args) @staticmethod def update_shares_user_permissions(context: KeeperParams, uid: str, removed: bool): @@ -219,7 +279,7 @@ def update_shares_user_permissions(context: KeeperParams, uid: str, removed: boo @staticmethod def _get_app_user_permissions(vault: vault_online.VaultOnline, uid: str) -> list: """Get user permissions for the application.""" - share_info = share_utils.get_record_shares(vault=vault, record_uids=[uid], is_share_admin=False) + share_info = share_management_utils.get_record_shares(vault=vault, record_uids=[uid], is_share_admin=False) user_perms = [] if share_info: for record_info in share_info: @@ -231,20 +291,24 @@ def _get_app_user_permissions(vault: vault_online.VaultOnline, uid: str) -> list @staticmethod def _separate_shared_items(vault: vault_online.VaultOnline, shared_secrets): """Separate shared secrets into records and folders.""" - from keepersdk.proto.APIRequest_pb2 import ApplicationShareType - from keepersdk import utils shared_recs = [] shared_folders = [] + for share in shared_secrets: uid_str = utils.base64_url_encode(share.secretUid) share_type = ApplicationShareType.Name(share.shareType) + if share_type == ApplicationShareType.SHARE_TYPE_RECORD: shared_recs.append(uid_str) elif share_type == ApplicationShareType.SHARE_TYPE_FOLDER: shared_folders.append(uid_str) if shared_recs: - share_utils.get_record_shares(vault=vault, record_uids=shared_recs, is_share_admin=False) + share_management_utils.get_record_shares( + vault=vault, + record_uids=shared_recs, + is_share_admin=False + ) return shared_recs, shared_folders @@ -252,76 +316,133 @@ def _separate_shared_items(vault: vault_online.VaultOnline, shared_secrets): def _process_share_updates(context: KeeperParams, vault: vault_online.VaultOnline, user_perms: list, shared_recs: list, shared_folders: list, removed: bool): """Process share updates for users.""" - # Get admin and viewer users - admins = [up.get('username') for up in user_perms if up.get('editable')] - admins = [x for x in admins if x != vault.keeper_auth.auth_context.username] - viewers = [up.get('username') for up in user_perms if not up.get('editable')] - app_users_map = dict(admins=admins, viewers=viewers) + app_users_map = SecretsManagerAppCommand._categorize_app_users(vault, user_perms) + + sf_requests, rec_requests = SecretsManagerAppCommand._build_share_requests( + context, vault, app_users_map, shared_recs, shared_folders, removed + ) - # Create share requests + SecretsManagerAppCommand._send_share_requests(vault, sf_requests, rec_requests) + logger.info("Share updates processed successfully") + + @staticmethod + def _categorize_app_users(vault: vault_online.VaultOnline, user_perms: list) -> dict: + """Categorize users into admins and viewers.""" + current_username = vault.keeper_auth.auth_context.username + admins = [ + up.get('username') for up in user_perms + if up.get('editable') and up.get('username') != current_username + ] + viewers = [ + up.get('username') for up in user_perms + if not up.get('editable') + ] + return dict(admins=admins, viewers=viewers) + + @staticmethod + def _build_share_requests(context: KeeperParams, vault: vault_online.VaultOnline, + app_users_map: dict, shared_recs: list, shared_folders: list, + removed: bool) -> tuple: + """Build share requests for folders and records.""" sf_requests = [] rec_requests = [] + all_share_uids = shared_recs + shared_folders - for group, users in app_users_map.items(): + for users in app_users_map.values(): users_needing_update = [ u for u in users - if SecretsManagerAppCommand._user_needs_update(vault, u, shared_recs + shared_folders, removed) + if SecretsManagerAppCommand._user_needs_update(vault, u, all_share_uids, removed) ] if not users_needing_update: continue - # Process folder share requests folder_requests = SecretsManagerAppCommand._create_folder_share_requests( vault, shared_folders, users_needing_update, removed ) - sf_requests.append(folder_requests) + if folder_requests: + sf_requests.append(folder_requests) - # Process record share requests record_requests = SecretsManagerAppCommand._create_record_share_requests( context, shared_recs, users_needing_update, removed ) rec_requests.extend(record_requests) + + return sf_requests, rec_requests + @staticmethod + def _send_share_requests(vault: vault_online.VaultOnline, sf_requests: list, rec_requests: list) -> None: + """Send share requests to the server.""" + success_responses = [] + failed_responses = [] if sf_requests: - ShareFolderCommand.send_requests(vault, sf_requests) + success_responses, failed_responses = shares_management.FolderShares.send_requests(vault, sf_requests) if rec_requests: - ShareRecordCommand.send_requests(vault, rec_requests) - logger.info("Share updates processed successfully") + success_responses_rec, failed_responses_rec = shares_management.RecordShares.send_requests(vault, rec_requests) + success_responses.extend(success_responses_rec) + failed_responses.extend(failed_responses_rec) + if success_responses: + logger.info(f'{len(success_responses)} share requests were successfully processed') + if failed_responses: + logger.error(f'{len(failed_responses)} share requests failed to process') + for failed_response in failed_responses: + logger.error(f'Failed to process share request: {failed_response}') + vault.sync_down() @staticmethod def _user_needs_update(vault: vault_online.VaultOnline, user: str, share_uids: list, removed: bool) -> bool: """Check if a user needs share permission updates.""" - # Get the share information for records - record_share_info = share_utils.get_record_shares(vault=vault, record_uids=share_uids, is_share_admin=False) - record_permissions = {} - if record_share_info: - for record_info in record_share_info: - record_uid = record_info.get('record_uid') - if record_uid: - record_permissions[record_uid] = record_info.get('shares', {}).get('user_permissions', []) - + record_permissions = SecretsManagerAppCommand._get_record_permissions(vault, share_uids) record_cache = {x.record_uid: x for x in vault.vault_data.records()} for share_uid in share_uids: - is_rec_share = share_uid in record_cache + share_user_permissions = SecretsManagerAppCommand._get_share_user_permissions( + vault, share_uid, record_cache, record_permissions + ) + + user_permissions_set = { + up.get('username') for up in share_user_permissions + if isinstance(up, dict) + } - if is_rec_share: - # Use the permissions we fetched above - share_user_permissions = record_permissions.get(share_uid, []) - else: - # For shared folders, get users from the folder object - shared_folder_obj = vault.vault_data.load_shared_folder(shared_folder_uid=share_uid) - if shared_folder_obj and shared_folder_obj.user_permissions: - share_user_permissions = shared_folder_obj.user_permissions - else: - share_user_permissions = [] - - user_permissions_set = {up.get('username') for up in share_user_permissions if isinstance(up, dict)} if user not in user_permissions_set: return True return False + @staticmethod + def _get_record_permissions(vault: vault_online.VaultOnline, share_uids: list) -> dict: + """Get record permissions for given share UIDs.""" + record_share_info = share_management_utils.get_record_shares( + vault=vault, + record_uids=share_uids, + is_share_admin=False + ) + + record_permissions = {} + if record_share_info: + for record_info in record_share_info: + record_uid = record_info.get('record_uid') + if record_uid: + record_permissions[record_uid] = ( + record_info.get('shares', {}).get('user_permissions', []) + ) + return record_permissions + + @staticmethod + def _get_share_user_permissions(vault: vault_online.VaultOnline, share_uid: str, + record_cache: dict, record_permissions: dict) -> list: + """Get user permissions for a share (record or folder).""" + is_record_share = share_uid in record_cache + + if is_record_share: + return record_permissions.get(share_uid, []) + + shared_folder_obj = vault.vault_data.load_shared_folder(shared_folder_uid=share_uid) + if shared_folder_obj and shared_folder_obj.user_permissions: + return shared_folder_obj.user_permissions + + return [] + @staticmethod def _create_folder_share_requests(vault: vault_online.VaultOnline, shared_folders: list, users: list, removed: bool) -> list: @@ -329,39 +450,50 @@ def _create_folder_share_requests(vault: vault_online.VaultOnline, shared_folder if not shared_folders: return [] - sf_action = ShareAction.REMOVE.value if removed else ShareAction.GRANT.value - + sf_action = SHARE_ACTION_REMOVE if removed else SHARE_ACTION_GRANT requests = [] + for folder_uid in shared_folders: for user in users: if SecretsManagerAppCommand._user_needs_update(vault, user, [folder_uid], removed): - sh_fol = vault.vault_data.load_shared_folder(folder_uid) - shared_folder_revision = vault.vault_data.storage.shared_folders.get_entity(folder_uid).revision - sf_unencrypted_key = vault.vault_data.get_shared_folder_key(shared_folder_uid=folder_uid) - sf_info = { - 'shared_folder_uid': folder_uid, - 'users': sh_fol.user_permissions, - 'teams': [], - 'records': sh_fol.record_permissions, - 'shared_folder_key_unencrypted': sf_unencrypted_key, - 'default_manage_users': sh_fol.default_can_share, - 'default_manage_records': sh_fol.default_can_edit, - 'revision': shared_folder_revision - } - request = ShareFolderCommand.prepare_request( - vault=vault, - kwargs={'action': sf_action}, - curr_sf=sf_info, - users=[user], - teams=[], - rec_uids=[], - default_record=False, - default_account=False, - share_expiration=-1 + request = SecretsManagerAppCommand._build_folder_share_request( + vault, folder_uid, user, sf_action ) requests.append(request) + return requests + @staticmethod + def _build_folder_share_request(vault: vault_online.VaultOnline, folder_uid: str, + user: str, action: str) -> dict: + """Build a single folder share request.""" + shared_folder = vault.vault_data.load_shared_folder(folder_uid) + shared_folder_revision = vault.vault_data.storage.shared_folders.get_entity(folder_uid).revision + sf_unencrypted_key = vault.vault_data.get_shared_folder_key(shared_folder_uid=folder_uid) + + sf_info = { + 'shared_folder_uid': folder_uid, + 'users': shared_folder.user_permissions, + 'teams': [], + 'records': shared_folder.record_permissions, + 'shared_folder_key_unencrypted': sf_unencrypted_key, + 'default_manage_users': shared_folder.default_can_share, + 'default_manage_records': shared_folder.default_can_edit, + 'revision': shared_folder_revision + } + + return shares_management.FolderShares.prepare_request( + vault=vault, + kwargs={'action': action}, + curr_sf=sf_info, + users=[user], + teams=[], + rec_uids=[], + default_record=False, + default_account=False, + share_expiration=-1 + ) + @staticmethod def _create_record_share_requests(context: KeeperParams, shared_recs: list, users: list, removed: bool) -> list: @@ -369,23 +501,25 @@ def _create_record_share_requests(context: KeeperParams, shared_recs: list, if not shared_recs or not context.vault: return [] - rec_action = ShareAction.REVOKE.value if removed else ShareAction.GRANT.value - + rec_action = SHARE_ACTION_REVOKE if removed else SHARE_ACTION_GRANT requests = [] + for record_uid in shared_recs: for user in users: if SecretsManagerAppCommand._user_needs_update(context.vault, user, [record_uid], removed): - request = ShareRecordCommand.prep_request( - context=context, + request = shares_management.RecordShares.prep_request( + vault=context.vault, emails=[user], action=rec_action, uid_or_name=record_uid, share_expiration=-1, dry_run=False, + enterprise=context.enterprise_data, can_edit=False, can_share=False ) requests.append(request) + return requests @@ -448,57 +582,84 @@ def execute(self, context: KeeperParams, **kwargs) -> None: if not context.vault: raise ValueError("Vault is not initialized.") - vault = context.vault command = kwargs.get('command') - uid_or_name = kwargs.get('app') - if not command: return self.get_parser().print_help() + uid = self._get_app_uid(context.vault, kwargs.get('app')) + + if command == SecretsManagerCommand.ADD.value: + return self._handle_add_client(context, uid, kwargs) + elif command == SecretsManagerCommand.REMOVE.value: + return self._handle_remove_client(context.vault, uid, kwargs) + else: + available_commands = f"{SecretsManagerCommand.ADD.value}, {SecretsManagerCommand.REMOVE.value}" + raise base.CommandError(f"Unknown command '{command}'. Available commands: {available_commands}") + + def _get_app_uid(self, vault, uid_or_name: Optional[str]) -> str: + """Get application UID from name or UID.""" if not uid_or_name: raise ValueError('Application UID or name is required. Use --app="uid_or_name".') - ksm_app = next((r for r in vault.vault_data.records() if r.record_uid == uid_or_name or r.title == uid_or_name), None) + ksm_app = next( + (r for r in vault.vault_data.records() + if r.record_uid == uid_or_name or r.title == uid_or_name), + None + ) + if not ksm_app: raise ValueError(f'No application found with UID/Name: {uid_or_name}') - uid = ksm_app.record_uid - if command == SecretsManagerCommand.ADD.value: - count = kwargs.get('count', 1) - unlock_ip = kwargs.get('unlockIp', False) - - client_name = kwargs.get('name') - - first_access_expire_in = kwargs.get('firstAccessExpiresIn', 60) - access_expire_in_min = kwargs.get('accessExpireInMin') - - is_return_tokens = kwargs.get('returnTokens', False) - - tokens_and_device = SecretsManagerClientCommand.add_client( - vault=vault, uid=uid, count=count, client_name=client_name, - unlock_ip=unlock_ip, first_access_expire_duration=first_access_expire_in, - access_expire_in_min=access_expire_in_min, server=context.auth.keeper_endpoint.server - ) + return ksm_app.record_uid + + def _handle_add_client(self, context: KeeperParams, uid: str, kwargs: dict) -> Optional[str]: + """Handle add client command.""" + count = kwargs.get('count', DEFAULT_TOKEN_COUNT) + unlock_ip = kwargs.get('unlockIp', False) + client_name = kwargs.get('name') + first_access_expire_in = kwargs.get('firstAccessExpiresIn', DEFAULT_FIRST_ACCESS_EXPIRES_IN_MINUTES) + access_expire_in_min = kwargs.get('accessExpireInMin') + is_return_tokens = kwargs.get('returnTokens', False) + + tokens_and_device = SecretsManagerClientCommand.add_client( + vault=context.vault, + uid=uid, + count=count, + client_name=client_name, + unlock_ip=unlock_ip, + first_access_expire_duration=first_access_expire_in, + access_expire_in_min=access_expire_in_min, + server=context.auth.keeper_endpoint.server + ) + if is_return_tokens: tokens_only = [d['oneTimeToken'] for d in tokens_and_device] - - return ', '.join(tokens_only) if is_return_tokens else None + return ', '.join(tokens_only) - elif command == SecretsManagerCommand.REMOVE.value: - client_names_or_ids = kwargs.get('client_names_or_ids') - if not client_names_or_ids: - raise ValueError('Client name or id is required. Example: --client="new client"') - - force = kwargs.get('force', False) + return None - if len(client_names_or_ids) == 1 and client_names_or_ids[0] in ['*', 'all']: - SecretsManagerClientCommand.remove_all_clients(vault=vault, uid=uid, force=force) - else: - SecretsManagerClientCommand.remove_client(vault=vault, uid=uid, client_names_and_ids=client_names_or_ids, force=force) + def _handle_remove_client(self, vault, uid: str, kwargs: dict) -> None: + """Handle remove client command.""" + client_names_or_ids = kwargs.get('client_names_or_ids') + if not client_names_or_ids: + raise ValueError('Client name or id is required. Example: --client="new client"') + + force = kwargs.get('force', False) - return + if self._is_remove_all_clients(client_names_or_ids): + SecretsManagerClientCommand.remove_all_clients(vault=vault, uid=uid, force=force) else: - raise base.CommandError(f"Unknown command '{command}'. Available commands: {SecretsManagerCommand.ADD.value}, {SecretsManagerCommand.REMOVE.value}") + SecretsManagerClientCommand.remove_client( + vault=vault, + uid=uid, + client_names_and_ids=client_names_or_ids, + force=force + ) + + def _is_remove_all_clients(self, client_names_or_ids: list) -> bool: + """Check if remove all clients is requested.""" + return (len(client_names_or_ids) == 1 and + client_names_or_ids[0] in [WILDCARD_ALL, WILDCARD_ALL_ALIAS]) @staticmethod @@ -511,16 +672,18 @@ def add_client( first_access_expire_duration: int, access_expire_in_min: Optional[int], server: str): + """Add client devices to a KSM application.""" + current_time_ms = int(time.time() * MILLISECONDS_PER_SECOND) - current_time_ms = int(time.time( ) * 1000) - - first_access_expire_duration_ms = current_time_ms + first_access_expire_duration * 60 * 1000 - access_expire_in_ms = None - if access_expire_in_min: - access_expire_in_ms = access_expire_in_min * 60 * 1000 + first_access_expire_duration_ms = ( + current_time_ms + first_access_expire_duration * MILLISECONDS_PER_MINUTE + ) + access_expire_in_ms = ( + access_expire_in_min * MILLISECONDS_PER_MINUTE + if access_expire_in_min else None + ) master_key = vault.vault_data.get_record_key(record_uid=uid) - tokens = [] output_lines = [] @@ -608,11 +771,12 @@ def _generate_single_client( @staticmethod def _generate_client_id(secret_bytes: bytes) -> bytes: """Generate client ID using HMAC.""" - counter_bytes = b'KEEPER_SECRETS_MANAGER_CLIENT_ID' - digest = 'sha512' - try: - return hmac.new(secret_bytes, counter_bytes, digest).digest() + return hmac.new( + secret_bytes, + CLIENT_ID_COUNTER_BYTES, + CLIENT_ID_DIGEST + ).digest() except Exception as e: logger.error(e) raise @@ -679,7 +843,6 @@ def _create_output_string( first_access_expire_duration_ms: int, access_expire_in_ms: Optional[int]) -> str: """Create formatted output string for logging.""" - output_lines = [f'\nOne-Time Access Token: {token_with_prefix}'] if client_name: @@ -688,25 +851,29 @@ def _create_output_string( ip_lock = 'Disabled' if unlock_ip else 'Enabled' output_lines.append(f'IP Lock: {ip_lock}') - try: - exp_date_str = datetime.datetime.fromtimestamp( - first_access_expire_duration_ms / 1000 - ).strftime('%Y-%m-%d %H:%M:%S') - except (OSError, ValueError) as e: - exp_date_str = 'Invalid timestamp' + exp_date_str = SecretsManagerClientCommand._format_timestamp( + first_access_expire_duration_ms + ) output_lines.append(f'Token Expires On: {exp_date_str}') - if access_expire_in_ms: - app_expire_on_str = datetime.datetime.fromtimestamp( - access_expire_in_ms / 1000 - ).strftime('%Y-%m-%d %H:%M:%S') - else: - app_expire_on_str = "Never" - + app_expire_on_str = ( + SecretsManagerClientCommand._format_timestamp(access_expire_in_ms) + if access_expire_in_ms else "Never" + ) output_lines.append(f'App Access Expires on: {app_expire_on_str}') return '\n'.join(output_lines) + @staticmethod + def _format_timestamp(timestamp_ms: int) -> str: + """Format timestamp in milliseconds to date string.""" + try: + return datetime.datetime.fromtimestamp( + timestamp_ms / MILLISECONDS_PER_SECOND + ).strftime(DATE_FORMAT) + except (OSError, ValueError): + return 'Invalid timestamp' + @staticmethod def _log_success_message(output_string: str) -> None: """Log success message with generated client information.""" @@ -723,9 +890,8 @@ def _log_ip_lock_warning() -> None: @staticmethod def remove_all_clients(vault: vault_online.VaultOnline, uid: str, force: bool): - + """Remove all clients from a KSM application.""" app_info = ksm_management.get_app_info(vault=vault, app_uid=uid) - clients_count = len(app_info[0].clients) if clients_count == 0: @@ -733,66 +899,112 @@ def remove_all_clients(vault: vault_online.VaultOnline, uid: str, force: bool): return if not force: - logger.info(f"This app has {clients_count} client(s) connections.") - uc = prompt_utils.user_choice('Are you sure you want to delete all clients from this application?', 'yn', default='n') - if uc.lower() != 'y': + if not SecretsManagerClientCommand._confirm_remove_all_clients(clients_count): return - client_ids_to_remove = [utils.base64_url_encode(c.clientId) for ai in app_info - for c in ai.clients if c.appClientType == GENERAL] + client_ids_to_remove = [ + utils.base64_url_encode(c.clientId) + for ai in app_info + for c in ai.clients + if c.appClientType == GENERAL + ] - if len(client_ids_to_remove) > 0: - SecretsManagerClientCommand.remove_client(vault=vault, uid=uid, client_names_and_ids=client_ids_to_remove, force=force) + if client_ids_to_remove: + SecretsManagerClientCommand.remove_client( + vault=vault, + uid=uid, + client_names_and_ids=client_ids_to_remove, + force=force + ) @staticmethod - def remove_client(vault: vault_online.VaultOnline, uid: str, client_names_and_ids: list[str], force=False): - - def convert_ids_and_hashes_to_hashes(client_names_and_ids, uid): - exact_matches = set() - partial_matches = set() - - for name in client_names_and_ids: - if len(name) >= ksm_management.CLIENT_SHORT_ID_LENGTH: - partial_matches.add(name) - else: - exact_matches.add(name) - - client_id_hashes_bytes = [] - app_infos = ksm_management.get_app_info(vault=vault, app_uid=uid) - app_info = app_infos[0] - - for client in app_info.clients: - if client.id in exact_matches: - client_id_hashes_bytes.append(client.clientId) - continue - - if partial_matches: - client_id = utils.base64_url_encode(client.clientId) - for partial_name in partial_matches: - if client_id.startswith(partial_name): - client_id_hashes_bytes.append(client.clientId) - break - - return client_id_hashes_bytes + def _confirm_remove_all_clients(clients_count: int) -> bool: + """Confirm removal of all clients.""" + logger.info(f"This app has {clients_count} client(s) connections.") + user_choice = prompt_utils.user_choice( + 'Are you sure you want to delete all clients from this application?', + 'yn', + default=USER_CHOICE_DEFAULT_NO + ) + return user_choice.lower() == USER_CHOICE_YES - client_hashes = convert_ids_and_hashes_to_hashes(client_names_and_ids=client_names_and_ids, uid=uid) + @staticmethod + def remove_client(vault: vault_online.VaultOnline, uid: str, client_names_and_ids: list[str], force=False): + """Remove client devices from a KSM application.""" + client_hashes = SecretsManagerClientCommand._convert_to_client_hashes( + vault, uid, client_names_and_ids + ) found_clients_count = len(client_hashes) if found_clients_count == 0: logger.warning('No Client Devices found with given name or ID\n') return + if not force: - uc = prompt_utils.user_choice(f'Are you sure you want to delete {found_clients_count} matching client(s) from this application?', - 'yn', default='n') - if uc.lower() != 'y': + if not SecretsManagerClientCommand._confirm_remove_clients(found_clients_count): return - request = RemoveAppClientsRequest() + SecretsManagerClientCommand._send_remove_client_request(vault, uid, client_hashes) + logger.info('\nClient removal was successful\n') + + @staticmethod + def _convert_to_client_hashes(vault: vault_online.VaultOnline, uid: str, + client_names_and_ids: list[str]) -> list[bytes]: + """Convert client names/IDs to client ID hashes.""" + exact_matches, partial_matches = SecretsManagerClientCommand._categorize_client_matches( + client_names_and_ids + ) + + app_infos = ksm_management.get_app_info(vault=vault, app_uid=uid) + app_info = app_infos[0] + client_id_hashes_bytes = [] + + for client in app_info.clients: + if client.id in exact_matches: + client_id_hashes_bytes.append(client.clientId) + continue + + if partial_matches: + client_id = utils.base64_url_encode(client.clientId) + for partial_name in partial_matches: + if client_id.startswith(partial_name): + client_id_hashes_bytes.append(client.clientId) + break + + return client_id_hashes_bytes + + @staticmethod + def _categorize_client_matches(client_names_and_ids: list[str]) -> tuple[set, set]: + """Categorize client names/IDs into exact and partial matches.""" + exact_matches = set() + partial_matches = set() + + for name in client_names_and_ids: + if len(name) >= ksm_management.CLIENT_SHORT_ID_LENGTH: + partial_matches.add(name) + else: + exact_matches.add(name) + + return exact_matches, partial_matches + + @staticmethod + def _confirm_remove_clients(clients_count: int) -> bool: + """Confirm removal of clients.""" + user_choice = prompt_utils.user_choice( + f'Are you sure you want to delete {clients_count} matching client(s) from this application?', + 'yn', + default=USER_CHOICE_DEFAULT_NO + ) + return user_choice.lower() == USER_CHOICE_YES + @staticmethod + def _send_remove_client_request(vault: vault_online.VaultOnline, uid: str, + client_hashes: list[bytes]) -> None: + """Send remove client request to server.""" + request = RemoveAppClientsRequest() request.appRecordUid = utils.base64_url_decode(uid) request.clients.extend(client_hashes) vault.keeper_auth.execute_auth_rest(rest_endpoint=CLIENT_REMOVE_URL, request=request) - logger.info('\nClient removal was successful\n') class SecretsManagerShareCommand(base.ArgparseCommand): @@ -828,17 +1040,28 @@ def execute(self, context: KeeperParams, **kwargs) -> None: if not context.vault: raise ValueError("Vault is not initialized.") - vault = context.vault command = kwargs.get('command') - app_uid_or_name = kwargs.get('app') - secret_uids_str = kwargs.get('secret') - secret_uids = [] - if secret_uids_str: - secret_uids = [uid.strip() for uid in secret_uids_str.split() if uid.strip()] - if not command: return self.get_parser().print_help() + app_uid = self._get_app_uid_from_kwargs(context.vault, kwargs.get('app')) + secret_uids = self._parse_secret_uids(kwargs.get('secret')) + + if command == SecretsManagerCommand.ADD.value: + is_editable = kwargs.get('editable', False) + self._handle_add_share(context, app_uid, secret_uids, is_editable) + elif command == SecretsManagerCommand.REMOVE.value: + SecretsManagerShareCommand.remove_share( + vault=context.vault, + app_uid=app_uid, + secret_uids=secret_uids + ) + else: + available_commands = f"{SecretsManagerCommand.ADD.value}, {SecretsManagerCommand.REMOVE.value}" + raise base.CommandError(f"Unknown command '{command}'. Available commands: {available_commands}") + + def _get_app_uid_from_kwargs(self, vault, app_uid_or_name: Optional[str]) -> str: + """Get application UID from kwargs.""" if not app_uid_or_name: raise ValueError('Application UID or name is required. Use --app="uid_or_name".') @@ -846,15 +1069,13 @@ def execute(self, context: KeeperParams, **kwargs) -> None: if not ksm_app: raise ValueError(f'No application found with UID/Name: {app_uid_or_name}') - app_uid = ksm_app.record_uid + return ksm_app.record_uid - if command == SecretsManagerCommand.ADD.value: - is_editable = kwargs.get('editable', False) - self._handle_add_share(context, app_uid, secret_uids, is_editable) - elif command == SecretsManagerCommand.REMOVE.value: - SecretsManagerShareCommand.remove_share(vault=vault, app_uid=app_uid, secret_uids=secret_uids) - else: - raise base.CommandError(f"Unknown command '{command}'. Available commands: {SecretsManagerCommand.ADD.value}, {SecretsManagerCommand.REMOVE.value}") + def _parse_secret_uids(self, secret_uids_str: Optional[str]) -> list[str]: + """Parse secret UIDs from string.""" + if not secret_uids_str: + return [] + return [uid.strip() for uid in secret_uids_str.split() if uid.strip()] def _find_ksm_application(self, vault: vault_online.VaultOnline, app_uid_or_name: str): return next( @@ -864,13 +1085,11 @@ def _find_ksm_application(self, vault: vault_online.VaultOnline, app_uid_or_name ) def _handle_add_share(self, context: KeeperParams, app_uid: str, secret_uids: list[str], is_editable: bool) -> None: + """Handle adding shares to a KSM application.""" if not context.vault: raise ValueError("Vault is not initialized.") - master_key = context.vault.vault_data.get_record_key(record_uid=app_uid) - if not master_key: - raise ValueError(f"Could not retrieve master key for application {app_uid}") - + master_key = self._get_master_key(context.vault, app_uid) success = SecretsManagerShareCommand.share_secret( vault=context.vault, app_uid=app_uid, @@ -881,15 +1100,43 @@ def _handle_add_share(self, context: KeeperParams, app_uid: str, secret_uids: li if success: context.vault.sync_down() - SecretsManagerAppCommand.update_shares_user_permissions(context=context, uid=app_uid, removed=False) + SecretsManagerAppCommand.update_shares_user_permissions( + context=context, + uid=app_uid, + removed=False + ) + + def _get_master_key(self, vault, app_uid: str) -> bytes: + """Get master key for application.""" + master_key = vault.vault_data.get_record_key(record_uid=app_uid) + if not master_key: + raise ValueError(f"Could not retrieve master key for application {app_uid}") + return master_key @staticmethod def share_secret(vault: vault_online.VaultOnline, app_uid: str, master_key: bytes, secret_uids: list[str], is_editable: bool = False) -> bool: + """Share secrets with a KSM application.""" if not secret_uids: logger.warning("No secret UIDs provided for sharing.") return False + app_shares, added_secret_info = SecretsManagerShareCommand._process_all_secrets( + vault, secret_uids, master_key, is_editable + ) + + if not added_secret_info: + logger.warning("No valid secrets found to share.") + return False + + return SecretsManagerShareCommand._send_share_request( + vault, app_uid, app_shares, added_secret_info, is_editable + ) + + @staticmethod + def _process_all_secrets(vault: vault_online.VaultOnline, secret_uids: list[str], + master_key: bytes, is_editable: bool) -> tuple[list, list]: + """Process all secrets and build share requests.""" app_shares = [] added_secret_info = [] @@ -902,79 +1149,128 @@ def share_secret(vault: vault_online.VaultOnline, app_uid: str, master_key: byte app_shares.append(share_info['app_share']) added_secret_info.append(share_info['secret_info']) - if not added_secret_info: - logger.warning("No valid secrets found to share.") - return False - - return SecretsManagerShareCommand._send_share_request( - vault, app_uid, app_shares, added_secret_info, is_editable - ) + return app_shares, added_secret_info @staticmethod def _process_secret(vault: vault_online.VaultOnline, secret_uid: str, master_key: bytes, is_editable: bool) -> Optional[dict]: + """Process a single secret and create share request.""" + secret_info = SecretsManagerShareCommand._get_secret_info(vault, secret_uid) + + if not secret_info: + return None + + share_key_decrypted, share_type, secret_type_name = secret_info + + if not share_key_decrypted: + logger.warning(f"Could not retrieve key for secret {secret_uid}") + return None + + app_share = SecretsManagerShareCommand._build_app_share( + secret_uid, share_key_decrypted, master_key, share_type, is_editable + ) + + return { + 'app_share': app_share, + 'secret_info': (secret_uid, secret_type_name) + } + + @staticmethod + def _get_secret_info(vault: vault_online.VaultOnline, secret_uid: str) -> Optional[tuple]: + """Get secret information (key, type, name) for a given UID.""" is_record = secret_uid in vault.vault_data._records is_shared_folder = secret_uid in vault.vault_data._shared_folders if is_record: - record = vault.vault_data.load_record(record_uid=secret_uid) - if not isinstance(record, TypedRecord): - raise ValueError("Unable to share application secret, only typed records can be shared") - share_key_decrypted = vault.vault_data.get_record_key(record_uid=secret_uid) - share_type = ApplicationShareType.SHARE_TYPE_RECORD - secret_type_name = RECORD + return SecretsManagerShareCommand._get_record_secret_info(vault, secret_uid) elif is_shared_folder: - share_key_decrypted = vault.vault_data.get_shared_folder_key(shared_folder_uid=secret_uid) - share_type = ApplicationShareType.SHARE_TYPE_FOLDER - secret_type_name = SHARED_FOLDER + return SecretsManagerShareCommand._get_folder_secret_info(vault, secret_uid) else: - logger.warning( - f"UID='{secret_uid}' is not a Record nor Shared Folder. " - "Only individual records or Shared Folders can be added to the application. " - "Make sure your local cache is up to date by running 'sync-down' command and trying again." - ) + SecretsManagerShareCommand._log_invalid_secret_warning(secret_uid) return None - if not share_key_decrypted: - logger.warning(f"Could not retrieve key for secret {secret_uid}") - return None + @staticmethod + def _get_record_secret_info(vault: vault_online.VaultOnline, secret_uid: str) -> Optional[tuple]: + """Get secret info for a record.""" + record = vault.vault_data.load_record(record_uid=secret_uid) + if not isinstance(record, TypedRecord): + raise ValueError("Unable to share application secret, only typed records can be shared") + + share_key_decrypted = vault.vault_data.get_record_key(record_uid=secret_uid) + share_type = ApplicationShareType.SHARE_TYPE_RECORD + secret_type_name = RECORD + + return share_key_decrypted, share_type, secret_type_name + @staticmethod + def _get_folder_secret_info(vault: vault_online.VaultOnline, secret_uid: str) -> tuple: + """Get secret info for a shared folder.""" + share_key_decrypted = vault.vault_data.get_shared_folder_key(shared_folder_uid=secret_uid) + share_type = ApplicationShareType.SHARE_TYPE_FOLDER + secret_type_name = SHARED_FOLDER + + return share_key_decrypted, share_type, secret_type_name + + @staticmethod + def _log_invalid_secret_warning(secret_uid: str) -> None: + """Log warning for invalid secret UID.""" + logger.warning( + f"UID='{secret_uid}' is not a Record nor Shared Folder. " + "Only individual records or Shared Folders can be added to the application. " + "Make sure your local cache is up to date by running 'sync-down' command and trying again." + ) + + @staticmethod + def _build_app_share(secret_uid: str, share_key_decrypted: bytes, master_key: bytes, + share_type: int, is_editable: bool) -> AppShareAdd: + """Build AppShareAdd object.""" app_share = AppShareAdd() app_share.secretUid = utils.base64_url_decode(secret_uid) app_share.shareType = share_type app_share.encryptedSecretKey = crypto.encrypt_aes_v2(share_key_decrypted, master_key) app_share.editable = is_editable - - return { - 'app_share': app_share, - 'secret_info': (secret_uid, secret_type_name) - } + return app_share @staticmethod def _send_share_request(vault: vault_online.VaultOnline, app_uid: str, app_shares: list, added_secret_info: list, is_editable: bool) -> bool: """Send the share request to the server.""" - request = AddAppSharesRequest() - request.appRecordUid = utils.base64_url_decode(app_uid) - request.shares.extend(app_shares) + request = SecretsManagerShareCommand._build_share_request(app_uid, app_shares) try: vault.keeper_auth.execute_auth_rest(rest_endpoint=SHARE_ADD_URL, request=request) - - logger.info(f'\nSuccessfully added secrets to app uid={app_uid}, editable={is_editable}:') - for secret_uid, secret_type in added_secret_info: - logger.info(f'{secret_uid} \t{secret_type}') + SecretsManagerShareCommand._log_share_success(app_uid, is_editable, added_secret_info) return True except base.errors.KeeperApiError as kae: - if kae.message == 'Duplicate share, already added': - logger.error( - "One of the secret UIDs is already shared to this application. " - "Please remove already shared UIDs from your command and try again." - ) - else: - raise ValueError(f"Failed to share secrets: {kae}") + return SecretsManagerShareCommand._handle_share_error(kae) + + @staticmethod + def _build_share_request(app_uid: str, app_shares: list) -> AddAppSharesRequest: + """Build share request object.""" + request = AddAppSharesRequest() + request.appRecordUid = utils.base64_url_decode(app_uid) + request.shares.extend(app_shares) + return request + + @staticmethod + def _log_share_success(app_uid: str, is_editable: bool, added_secret_info: list) -> None: + """Log successful share operation.""" + logger.info(f'\nSuccessfully added secrets to app uid={app_uid}, editable={is_editable}:') + for secret_uid, secret_type in added_secret_info: + logger.info(f'{secret_uid} \t{secret_type}') + + @staticmethod + def _handle_share_error(kae: base.errors.KeeperApiError) -> bool: + """Handle share request errors.""" + if kae.message == 'Duplicate share, already added': + logger.error( + "One of the secret UIDs is already shared to this application. " + "Please remove already shared UIDs from your command and try again." + ) return False + else: + raise ValueError(f"Failed to share secrets: {kae}") @staticmethod def remove_share(vault: vault_online.VaultOnline, app_uid: str, secret_uids: list[str]) -> None: @@ -983,29 +1279,53 @@ def remove_share(vault: vault_online.VaultOnline, app_uid: str, secret_uids: lis logger.warning("No secret UIDs provided for removal.") return + current_shared_uids = SecretsManagerShareCommand._get_current_shared_uids(vault, app_uid) + valid_uids, invalid_uids = SecretsManagerShareCommand._validate_share_uids( + secret_uids, current_shared_uids + ) + + SecretsManagerShareCommand._log_invalid_uids(invalid_uids) + + if not valid_uids: + logger.warning( + "None of the provided secret UIDs are shared with this application. Nothing to remove." + ) + return + + SecretsManagerShareCommand._send_remove_share_request(vault, app_uid, valid_uids) + logger.info("Shared secrets were successfully removed from the application\n") + + @staticmethod + def _get_current_shared_uids(vault: vault_online.VaultOnline, app_uid: str) -> set: + """Get currently shared UIDs for the application.""" app_infos = ksm_management.get_app_info(vault=vault, app_uid=app_uid) if not app_infos: raise ValueError(f"Could not retrieve application info for UID: {app_uid}") app_info = app_infos[0] - current_shared_uids = { + return { utils.base64_url_encode(share.secretUid) for share in getattr(app_info, 'shares', []) } + @staticmethod + def _validate_share_uids(secret_uids: list[str], current_shared_uids: set) -> tuple[list, list]: + """Validate secret UIDs against currently shared UIDs.""" valid_uids = [uid for uid in secret_uids if uid in current_shared_uids] invalid_uids = [uid for uid in secret_uids if uid not in current_shared_uids] + return valid_uids, invalid_uids + @staticmethod + def _log_invalid_uids(invalid_uids: list[str]) -> None: + """Log warnings for invalid UIDs.""" for uid in invalid_uids: logger.warning(f"Secret UID '{uid}' is not shared with this application. Skipping.") - if not valid_uids: - logger.warning("None of the provided secret UIDs are shared with this application. Nothing to remove.") - return - + @staticmethod + def _send_remove_share_request(vault: vault_online.VaultOnline, app_uid: str, + valid_uids: list[str]) -> None: + """Send remove share request to server.""" request = RemoveAppSharesRequest() request.appRecordUid = utils.base64_url_decode(app_uid) request.shares.extend(utils.base64_url_decode(uid) for uid in valid_uids) - - vault.keeper_auth.execute_auth_rest(rest_endpoint=SHARE_REMOVE_URL, request=request) - logger.info("Shared secrets were successfully removed from the application\n") \ No newline at end of file + vault.keeper_auth.execute_auth_rest(rest_endpoint=SHARE_REMOVE_URL, request=request) \ No newline at end of file diff --git a/keepercli-package/src/keepercli/commands/share_management.py b/keepercli-package/src/keepercli/commands/share_management.py deleted file mode 100644 index bdc4334f..00000000 --- a/keepercli-package/src/keepercli/commands/share_management.py +++ /dev/null @@ -1,1401 +0,0 @@ -import argparse -import datetime -import json -import math -import re -from enum import Enum -from typing import Optional - -from keepersdk import crypto, utils -from keepersdk.proto import folder_pb2, record_pb2, APIRequest_pb2 -from keepersdk.vault import ksm_management, vault_online, vault_utils - -from . import base -from .. import api, prompt_utils, constants -from ..helpers import folder_utils, record_utils, report_utils, share_utils, timeout_utils -from ..params import KeeperParams - - -class ApiUrl(Enum): - SHARE_ADMIN = 'vault/am_i_share_admin' - SHARE_UPDATE = 'vault/records_share_update' - SHARE_FOLDER_UPDATE = 'vault/shared_folder_update_v3' - REMOVE_EXTERNAL_SHARE = 'vault/external_share_remove' - - -class ShareAction(Enum): - GRANT = 'grant' - REVOKE = 'revoke' - OWNER = 'owner' - CANCEL = 'cancel' - REMOVE = 'remove' - - -class ManagePermission(Enum): - ON = 'on' - OFF = 'off' - - -logger = api.get_logger() - - -TIMESTAMP_MILLISECONDS_FACTOR = 1000 -TRUNCATE_SUFFIX = '...' - -# Constants for FindDuplicatesCommand -URL_TRUNCATE_LENGTH = 30 -NON_SHARED_DEFAULT = 'non-shared' -CUSTOM_FIELD_TYPE_PREFIX = 'type:' -TOTP_FIELD_NAME = 'totp' -LIST_SEPARATOR = '|' -DICT_SEPARATOR = ';' -KEY_VALUE_SEPARATOR = '=' -PERMISSION_SEPARATOR = '=' -SHARE_NAMES_SEPARATOR = ', ' -SUPPORTED_RECORD_VERSIONS = {2, 3} -DEFAULT_SEARCH_FIELDS = ['by_title', 'by_login', 'by_password'] - -def set_expiration_fields(obj, expiration): - """Set expiration and timerNotificationType fields on proto object if expiration is provided.""" - if isinstance(expiration, int): - if expiration > 0: - obj.expiration = expiration * TIMESTAMP_MILLISECONDS_FACTOR - obj.timerNotificationType = record_pb2.NOTIFY_OWNER - elif expiration < 0: - obj.expiration = -1 - - -class ShareRecordCommand(base.ArgparseCommand): - - def __init__(self): - self.parser = argparse.ArgumentParser( - prog='share-record', - description='Change the sharing permissions of an individual record' - ) - ShareRecordCommand.add_arguments_to_parser(self.parser) - super().__init__(self.parser) - - @staticmethod - def add_arguments_to_parser(parser: argparse.ArgumentParser): - - parser.add_argument( - '-e', '--email', dest='email', action='append', help='account email' - ) - parser.add_argument( - '--contacts-only', action='store_true', - help="Share only to known targets; Allows routing to alternate domains with matching usernames if needed" - ) - parser.add_argument( - '-f', '--force', action='store_true', help='Skip confirmation prompts' - ) - parser.add_argument( - '-a', '--action', dest='action', choices=[action.value for action in ShareAction], - default=ShareAction.GRANT.value, action='store', help='user share action. \'grant\' if omitted' - ) - parser.add_argument( - '-s', '--share', dest='can_share', action='store_true', help='can re-share record' - ) - parser.add_argument( - '-w', '--write', dest='can_edit', action='store_true', help='can modify record' - ) - parser.add_argument( - '-R', '--recursive', dest='recursive', action='store_true', - help='apply command to shared folder hierarchy' - ) - parser.add_argument( - '--dry-run', dest='dry_run', action='store_true', - help='display the permissions changes without committing them' - ) - expiration = parser.add_mutually_exclusive_group() - expiration.add_argument( - '--expire-at', dest='expire_at', action='store', help='share expiration: never or UTC datetime' - ) - expiration.add_argument( - '--expire-in', dest='expire_in', action='store', - metavar='[(mi)nutes|(h)ours|(d)ays|(mo)nths|(y)ears]', - help='share expiration: never or period' - ) - parser.add_argument( - 'record', nargs='?', type=str, action='store', help='record/shared folder path/UID' - ) - - def execute(self, context: KeeperParams, **kwargs) -> None: - if not context.vault: - raise ValueError("Vault is not initialized.") - vault = context.vault - - uid_or_name = kwargs.get('record') - if not uid_or_name: - return self.get_parser().print_help() - - emails = kwargs.get('email') or [] - if not emails: - raise ValueError('\'email\' parameter is missing') - - force = kwargs.get('force') - action = kwargs.get('action', ShareAction.GRANT.value) - contacts_only = kwargs.get('contacts_only') - dry_run = kwargs.get('dry_run') - can_edit = kwargs.get('can_edit') - can_share = kwargs.get('can_share') - recursive = kwargs.get('recursive') - - if contacts_only: - shared_objects = share_utils.get_share_objects(vault=vault) - known_users = shared_objects.get('users', {}) - known_emails = [u.casefold() for u in known_users.keys()] - def is_unknown(e): - return e.casefold() not in known_emails and utils.is_email(e) - unknowns = [e for e in emails if is_unknown(e)] - if unknowns: - username_map = { - e: ShareRecordCommand.get_contact(e, known_users) - for e in unknowns - } - table = [[k, v] for k, v in username_map.items()] - logger.info(f'{len(unknowns)} unrecognized share recipient(s) and closest matching contact(s)') - report_utils.dump_report_data(table, ['Username', 'From Contacts']) - confirmed = force or prompt_utils.user_choice('\tReplace with known matching contact(s)?', 'yn', default='n') == 'y' - if confirmed: - good_emails = [e for e in emails if e not in unknowns] - replacements = [e for e in username_map.values() if e] - emails = [*good_emails, *replacements] - - if action == ShareAction.CANCEL.value: - ShareRecordCommand.cancel_share(vault, emails) - vault.sync_down() - return - else: - share_expiration = share_utils.get_share_expiration(kwargs.get('expire_at'), kwargs.get('expire_in')) - - request = ShareRecordCommand.prep_request( - context=context, - uid_or_name=uid_or_name, - emails=emails, - share_expiration=share_expiration, - action=action, - dry_run=dry_run or False, - can_edit=can_edit, - can_share=can_share, - recursive=recursive - ) - if request: - ShareRecordCommand.send_requests(vault, [request]) - - @staticmethod - def get_contact(user, contacts): - if not user or not contacts: - return None - - user_username = user.split('@')[0].casefold() - - for contact in contacts: - contact_username = contact.split('@')[0].casefold() - if user_username == contact_username: - return contact - - return None - - @staticmethod - def prep_request(context: KeeperParams, - emails: list[str], - action: str, - uid_or_name: str, - share_expiration: int, - dry_run: bool, - recursive: Optional[bool] = False, - can_edit: Optional[bool] = False, - can_share: Optional[bool] = False): - if not context or not hasattr(context, 'vault') or not context.vault or not hasattr(context.vault, 'vault_data') or not context.vault.vault_data: - raise ValueError("Vault or vault data is not initialized") - vault = context.vault - record_uid = None - folder_uid = None - shared_folder_uid = None - record_cache = {x.record_uid: x for x in vault.vault_data.records()} - shared_folder_cache = {x.shared_folder_uid: x for x in vault.vault_data.shared_folders()} - folder_cache = {x: x for x in getattr(vault.vault_data, '_folders', [])} - - if uid_or_name in record_cache: - record_uid = uid_or_name - elif uid_or_name in shared_folder_cache: - shared_folder_uid = uid_or_name - elif uid_or_name in folder_cache: - folder_uid = uid_or_name - else: - for sf_info in vault.vault_data.shared_folders(): - if uid_or_name == sf_info.name: - shared_folder_uid = sf_info.shared_folder_uid - break - - if shared_folder_uid is None and record_uid is None: - rs = folder_utils.try_resolve_path(context, uid_or_name) - if rs is not None: - folder, name = rs - if name: - for record in vault.vault_data.records(): - if record.title.lower() == name.lower(): - record_uid = record.record_uid - break - else: - # Handle shared folder types - if folder.folder_type == 'shared_folder': - folder_uid = folder.folder_uid - shared_folder_uid = folder_uid - elif folder.folder_type == 'shared_folder_folder': - folder_uid = folder.folder_uid - shared_folder_uid = folder.subfolders - - # Check share admin status - is_share_admin = False - if record_uid is None and folder_uid is None and shared_folder_uid is None: - if context._enterprise_loader: - try: - uid = utils.base64_url_decode(uid_or_name) - if isinstance(uid, bytes) and len(uid) == 16: - request = record_pb2.AmIShareAdmin() - obj_share_admin = record_pb2.IsObjectShareAdmin() - obj_share_admin.uid = uid - obj_share_admin.objectType = record_pb2.CHECK_SA_ON_RECORD - request.isObjectShareAdmin.append(obj_share_admin) - response = vault.keeper_auth.execute_auth_rest( - request=request, - response_type=record_pb2.AmIShareAdmin, - rest_endpoint=ApiUrl.SHARE_ADMIN.value - ) - if response and response.isObjectShareAdmin and response.isObjectShareAdmin[0].isAdmin: - is_share_admin = True - record_uid = uid_or_name - except Exception: - pass - - if record_uid is None and folder_uid is None and shared_folder_uid is None: - raise ValueError('Enter name or uid of existing record or shared folder') - - # Collect record UIDs - record_uids = set() - if record_uid: - record_uids.add(record_uid) - elif folder_uid: - folders = {folder_uid} - folder = vault.vault_data.get_folder(folder_uid) - if recursive and folder: - vault_utils.traverse_folder_tree( - vault=vault.vault_data, - folder=folder, - callback=lambda x: folders.add(x.folder_uid) - ) - record_uids = {uid for uid in folders if uid in record_cache} - elif shared_folder_uid: - if not recursive: - raise ValueError('--recursive parameter is required') - if isinstance(shared_folder_uid, str): - sf = vault.vault_data.load_shared_folder(shared_folder_uid=shared_folder_uid) - if sf and sf.record_permissions: - record_uids.update(x.record_uid for x in sf.record_permissions) - elif isinstance(shared_folder_uid, list): - for sf_uid in shared_folder_uid: - if isinstance(sf_uid, str): - sf = vault.vault_data.load_shared_folder(shared_folder_uid=sf_uid) - if sf and sf.record_permissions: - record_uids.update(x.record_uid for x in sf.record_permissions) - - if not record_uids: - raise ValueError('There are no records to share selected') - - if action == 'owner' and len(emails) > 1: - raise ValueError('You can transfer ownership to a single account only') - - all_users = {email.casefold() for email in emails} - - # Handle user invitations and key loading - if not dry_run and action in (ShareAction.GRANT.value, ShareAction.OWNER.value): - invited = vault.keeper_auth.load_user_public_keys(list(all_users), send_invites=True) - if invited: - for email in invited: - logger.warning('Share invitation has been sent to \'%s\'', email) - logger.warning('Please repeat this command when invitation is accepted.') - all_users.difference_update(invited) - - if vault.keeper_auth._key_cache: - all_users.intersection_update(vault.keeper_auth._key_cache.keys()) - - if not all_users: - raise ValueError('Nothing to do.') - - # Load records in shared folders - if shared_folder_uid: - if isinstance(shared_folder_uid, str): - share_utils.load_records_in_shared_folder(vault=vault, shared_folder_uid=shared_folder_uid, record_uids=record_uids) - elif isinstance(shared_folder_uid, list): - for sf_uid in shared_folder_uid: - share_utils.load_records_in_shared_folder(vault=vault, shared_folder_uid=sf_uid, record_uids=record_uids) - - # Get share information for records not in cache - not_owned_records = {} if is_share_admin else None - share_info = share_utils.get_record_shares(vault=vault, record_uids=list(record_uids), is_share_admin=False) - if share_info and not_owned_records is not None: - for record_info in share_info: - record_uid = record_info.get('record_uid') - if record_uid: - not_owned_records[record_uid] = record_info - - # Build the request - rq = record_pb2.RecordShareUpdateRequest() - existing_shares = {} - record_titles = {} - transfer_ruids = set() - - for record_uid in record_uids: - # Get record data - if record_uid in record_cache: - rec = record_cache[record_uid] - elif not_owned_records and record_uid in not_owned_records: - rec = not_owned_records[record_uid] - elif is_share_admin: - rec = { - 'record_uid': record_uid, - 'shares': { - 'user_permissions': [{ - 'username': x, - 'owner': False, - 'share_admin': False, - 'shareable': action == 'revoke', - 'editable': action == 'revoke', - } for x in all_users] - } - } - else: - continue - - existing_shares.clear() - if isinstance(rec, dict): - if 'shares' in rec: - shares = rec['shares'] - if 'user_permissions' in shares: - for po in shares['user_permissions']: - existing_shares[po['username'].lower()] = po - del rec['shares'] - - if 'data_unencrypted' in rec: - try: - data = json.loads(rec['data_unencrypted'].decode()) - if isinstance(data, dict) and 'title' in data: - record_titles[record_uid] = data['title'] - except (ValueError, AttributeError): - pass - - record_path = share_utils.resolve_record_share_path(context=context, record_uid=record_uid) - - # Process each user - for email in all_users: - ro = record_pb2.SharedRecord() - ro.toUsername = email - ro.recordUid = utils.base64_url_decode(record_uid) - - if record_path: - if 'shared_folder_uid' in record_path: - ro.sharedFolderUid = utils.base64_url_decode(record_path['shared_folder_uid']) - if 'team_uid' in record_path: - ro.teamUid = utils.base64_url_decode(record_path['team_uid']) - - if action in {ShareAction.GRANT.value, ShareAction.OWNER.value}: - record_uid_to_use = rec.get('record_uid', record_uid) if isinstance(rec, dict) else getattr(rec, 'record_uid', record_uid) - record_key = vault.vault_data.get_record_key(record_uid=record_uid_to_use) - if record_key and email not in existing_shares and vault.keeper_auth._key_cache and email in vault.keeper_auth._key_cache: - keys = vault.keeper_auth._key_cache[email] - if vault.keeper_auth.auth_context.forbid_rsa and keys.ec: - ec_key = crypto.load_ec_public_key(keys.ec) - ro.recordKey = crypto.encrypt_ec(record_key, ec_key) - ro.useEccKey = True - elif not vault.keeper_auth.auth_context.forbid_rsa and keys.rsa: - rsa_key = crypto.load_rsa_public_key(keys.rsa) - ro.recordKey = crypto.encrypt_rsa(record_key, rsa_key) - ro.useEccKey = False - - if action == ShareAction.OWNER.value: - ro.transfer = True - transfer_ruids.add(record_uid) - else: - ro.editable = bool(can_edit) - ro.shareable = bool(can_share) - set_expiration_fields(ro, share_expiration) - elif email in existing_shares: - current = existing_shares[email] - if action == ShareAction.OWNER.value: - ro.transfer = True - transfer_ruids.add(record_uid) - else: - ro.editable = can_edit if can_edit is not None else current.get('editable') - ro.shareable = can_share if can_share is not None else current.get('shareable') - set_expiration_fields(ro, share_expiration) - - if email in existing_shares: - rq.updateSharedRecord.append(ro) - else: - rq.addSharedRecord.append(ro) - else: - if can_share or can_edit: - if email in existing_shares: - current = existing_shares[email] - ro.editable = False if can_edit else current.get('editable') - ro.shareable = False if can_share else current.get('shareable') - set_expiration_fields(ro, share_expiration) - rq.updateSharedRecord.append(ro) - else: - rq.removeSharedRecord.append(ro) - - return rq - - @staticmethod - def cancel_share(vault: vault_online.VaultOnline, emails: list[str]): - for email in emails: - request = { - 'command': 'cancel_share', - 'to_email': email - } - try: - vault.keeper_auth.execute_auth_command(request=request) - except Exception as e: - logger.warning(f'Failed to cancel share for {email}:{e}') - continue - vault.sync_down() - return - - @staticmethod - def send_requests(vault: vault_online.VaultOnline, requests): - MAX_BATCH_SIZE = 990 - STATUS_ATTRIBUTES = { - 'addSharedRecordStatus': ('granted to', 'grant'), - 'updateSharedRecordStatus': ('changed for', 'change'), - 'removeSharedRecordStatus': ('revoked from', 'revoke') - } - - def create_batch_request(request, max_size): - """Create a batch request by taking items from the source request.""" - batch = record_pb2.RecordShareUpdateRequest() - remaining = max_size - - # Process each record type in priority order - for attr_name in ['addSharedRecord', 'updateSharedRecord', 'removeSharedRecord']: - if remaining <= 0: - break - - source_list = getattr(request, attr_name) - if not source_list: - continue - - # Take items from the source list - items_to_take = min(remaining, len(source_list)) - target_list = getattr(batch, attr_name) - target_list.extend(source_list[:items_to_take]) - - # Remove taken items from source - del source_list[:items_to_take] - remaining -= items_to_take - - return batch - - def process_response_statuses(response): - """Process and log the status of each operation in the response.""" - for attr_name, (success_verb, failure_verb) in STATUS_ATTRIBUTES.items(): - if not hasattr(response, attr_name): - continue - - statuses = getattr(response, attr_name) - for status_record in statuses: - record_uid = utils.base64_url_encode(status_record.recordUid) - status = status_record.status - email = status_record.username - - if status == 'success': - logger.info( - 'Record "%s" access permissions has been %s user \'%s\'', - record_uid, success_verb, email - ) - else: - logger.info( - 'Failed to %s record "%s" access permissions for user \'%s\': %s', - failure_verb, record_uid, email, status_record.message - ) - - for request in requests: - # Process request in batches until all records are handled - while (len(request.addSharedRecord) > 0 or - len(request.updateSharedRecord) > 0 or - len(request.removeSharedRecord) > 0): - - # Create a batch request - batch_request = create_batch_request(request, MAX_BATCH_SIZE) - - # Send the batch request - response = vault.keeper_auth.execute_auth_rest( - rest_endpoint=ApiUrl.SHARE_UPDATE.value, - request=batch_request, - response_type=record_pb2.RecordShareUpdateResponse - ) - - process_response_statuses(response) - - -class ShareFolderCommand(base.ArgparseCommand): - def __init__(self): - self.parser = argparse.ArgumentParser( - prog='share-folder', - description='Change the sharing permissions of shared folders' - ) - ShareFolderCommand.add_arguments_to_parser(self.parser) - super().__init__(self.parser) - - @staticmethod - def add_arguments_to_parser(parser: argparse.ArgumentParser): - parser.add_argument( - '-a', '--action', dest='action', choices=[ShareAction.GRANT.value, ShareAction.REMOVE.value], - default=ShareAction.GRANT.value, action='store', - help='shared folder action. \'grant\' if omitted' - ) - parser.add_argument( - '-e', '--email', dest='user', action='append', - help='account email, team, @existing for all users and teams in the folder, or \'*\' as default folder permission' - ) - parser.add_argument( - '-r', '--record', dest='record', action='append', - help='record name, record UID, @existing for all records in the folder, or \'*\' as default folder permission' - ) - parser.add_argument( - '-p', '--manage-records', dest='manage_records', action='store', - choices=[perm.value for perm in ManagePermission], help='account permission: can manage records.' - ) - parser.add_argument( - '-o', '--manage-users', dest='manage_users', action='store', - choices=[perm.value for perm in ManagePermission], help='account permission: can manage users.' - ) - parser.add_argument( - '-s', '--can-share', dest='can_share', action='store', - choices=[perm.value for perm in ManagePermission], help='record permission: can be shared' - ) - parser.add_argument( - '-d', '--can-edit', dest='can_edit', action='store', - choices=[perm.value for perm in ManagePermission], help='record permission: can be modified.' - ) - parser.add_argument( - '-f', '--force', dest='force', action='store_true', - help='Apply permission changes ignoring default folder permissions. Used on the initial sharing action' - ) - expiration = parser.add_mutually_exclusive_group() - expiration.add_argument( - '--expire-at', dest='expire_at', action='store', metavar='TIMESTAMP', - help='share expiration: never or ISO datetime (yyyy-MM-dd[ hh:mm:ss])' - ) - expiration.add_argument( - '--expire-in', dest='expire_in', action='store', metavar='PERIOD', - help='share expiration: never or period ([(y)ears|(mo)nths|(d)ays|(h)ours(mi)nutes]' - ) - parser.add_argument( - 'folder', nargs='+', type=str, action='store', help='shared folder path or UID' - ) - - def execute(self, context: KeeperParams, **kwargs) -> None: - if not context.vault: - raise ValueError('Vault is not initialized.') - - vault = context.vault - - def get_share_admin_obj_uids(vault: vault_online.VaultOnline, obj_names, obj_type): - if not obj_names: - return None - try: - rq = record_pb2.AmIShareAdmin() - for name in obj_names: - try: - uid = utils.base64_url_decode(name) - if isinstance(uid, bytes) and len(uid) == 16: - osa = record_pb2.IsObjectShareAdmin() - osa.uid = uid - osa.objectType = obj_type - rq.isObjectShareAdmin.append(osa) - except: - pass - if len(rq.isObjectShareAdmin) > 0: - rs = vault.keeper_auth.execute_auth_rest(rest_endpoint=ApiUrl.SHARE_ADMIN.value, request=rq, response_type=record_pb2.AmIShareAdmin) - if rs and hasattr(rs, 'isObjectShareAdmin'): - sa_obj_uids = {sa_obj.uid for sa_obj in rs.isObjectShareAdmin if sa_obj.isAdmin} - sa_obj_uids = {utils.base64_url_encode(uid) for uid in sa_obj_uids} - return sa_obj_uids - except (ValueError, AttributeError) as e: - raise ValueError(f'get_share_admin: msg = {e}') from e - - def get_record_uids(context: KeeperParams, name: str) -> set[str]: - """Get record UIDs by name or UID.""" - record_uids = set() - - if not context.vault or not context.vault.vault_data: - return record_uids - - record = context.vault.vault_data.get_record(name) - if record: - record_uids.add(name) - return record_uids - - for record_info in context.vault.vault_data.records(): - if record_info.title == name: - record_uids.add(record_info.record_uid) - - return record_uids - - names = kwargs.get('folder') - if not isinstance(names, list): - names = [names] - - all_folders = any(True for x in names if x == '*') - if all_folders: - names = [x for x in names if x != '*'] - - shared_folder_cache = {x.shared_folder_uid: x for x in vault.vault_data.shared_folders()} - folder_cache = {x.folder_uid: x for x in vault.vault_data.folders()} - shared_folder_uids = set() - if all_folders: - shared_folder_uids.update(shared_folder_cache.keys()) - else: - def get_folder_by_uid(uid): - return folder_cache.get(uid) - folder_uids = { - uid - for name in names if name - for uid in share_utils.get_folder_uids(context, name) - } - folders = {get_folder_by_uid(uid) for uid in folder_uids if get_folder_by_uid(uid)} - shared_folder_uids.update([uid for uid in folder_uids if uid in shared_folder_cache]) - - sf_subfolders = {f for f in folders if f and f.folder_type == 'shared_folder_folder'} - shared_folder_uids.update({f.folder_scope_uid for f in sf_subfolders if f.folder_scope_uid}) - - unresolved_names = [name for name in names if name and not share_utils.get_folder_uids(context, name)] - share_admin_folder_uids = get_share_admin_obj_uids(vault=vault, obj_names=unresolved_names, obj_type=record_pb2.CHECK_SA_ON_SF) - shared_folder_uids.update(share_admin_folder_uids or []) - - if not shared_folder_uids: - raise ValueError('Enter name of at least one existing folder') - - action = kwargs.get('action') or ShareAction.GRANT.value - - share_expiration = None - if action == ShareAction.GRANT.value: - share_expiration = share_utils.get_share_expiration(kwargs.get('expire_at'), kwargs.get('expire_in')) - - as_users = set() - as_teams = set() - - all_users = False - default_account = False - if 'user' in kwargs: - for u in (kwargs.get('user') or []): - if u == '*': - default_account = True - elif u in ('@existing', '@current'): - all_users = True - else: - em = re.match(constants.EMAIL_PATTERN, u) - if em is not None: - as_users.add(u.lower()) - else: - teams = share_utils.get_share_objects(vault=vault).get('teams', {}) - teams_map = {uid: team.get('name') for uid, team in teams.items()} - if len(teams) >= 500: - teams = vault_utils.load_available_teams(auth=vault.keeper_auth) - teams_map.update({t.team_uid: t.name for t in teams}) - - matches = [uid for uid, name in teams_map.items() if u in (name, uid)] - if len(matches) != 1: - logger.warning(f'User "{u}" could not be resolved as email or team' if not matches - else f'Multiple matches were found for team "{u}". Try using its UID -- which can be found via `list-team` -- instead') - else: - [team] = matches - as_teams.add(team) - - record_uids = set() - all_records = False - default_record = False - unresolved_names = [] - if 'record' in kwargs: - records = kwargs.get('record') or [] - for r in records: - if r == '*': - default_record = True - elif r in ('@existing', '@current'): - all_records = True - else: - r_uids = get_record_uids(context, r) - record_uids.update(r_uids) if r_uids else unresolved_names.append(r) - - if unresolved_names: - sa_record_uids = get_share_admin_obj_uids(vault=vault, obj_names=unresolved_names, obj_type=record_pb2.CHECK_SA_ON_RECORD) - record_uids.update(sa_record_uids or {}) - - if len(as_users) == 0 and len(as_teams) == 0 and len(record_uids) == 0 and \ - not default_record and not default_account and \ - not all_users and not all_records: - logger.info('Nothing to do') - return - - rq_groups = [] - - def prep_rq(recs, users, curr_sf): - return self.prepare_request(vault, kwargs, curr_sf, users, sf_teams, recs, default_record=default_record, - default_account=default_account, share_expiration=share_expiration) - - for sf_uid in shared_folder_uids: - sf_users = as_users.copy() - sf_teams = as_teams.copy() - sf_records = record_uids.copy() - - if sf_uid in shared_folder_cache: - sh_fol = vault.vault_data.load_shared_folder(sf_uid) - if (all_users or all_records) and sh_fol: - if all_users: - if sh_fol.user_permissions: - sf_users.update((x.name for x in sh_fol.user_permissions if x.name != context.auth.auth_context.username)) - if all_records: - if sh_fol and sh_fol.record_permissions: - sf_records.update((x.record_uid for x in sh_fol.record_permissions)) - else: - sh_fol = { - 'shared_folder_uid': sf_uid, - 'users': [{'username': x, 'manage_records': action != ShareAction.GRANT.value, 'manage_users': action != ShareAction.GRANT.value} - for x in as_users], - 'teams': [{'team_uid': x, 'manage_records': action != ShareAction.GRANT.value, 'manage_users': action != ShareAction.GRANT.value} - for x in as_teams], - 'records': [{'record_uid': x, 'can_share': action != ShareAction.GRANT.value, 'can_edit': action != ShareAction.GRANT.value} - for x in record_uids] - } - chunk_size = 500 - rec_list = list(sf_records) - user_list = list(sf_users) - num_rec_chunks = math.ceil(len(sf_records) / chunk_size) - num_user_chunks = math.ceil(len(sf_users) / chunk_size) - num_rq_groups = num_user_chunks or 1 * num_rec_chunks or 1 - while len(rq_groups) < num_rq_groups: - rq_groups.append([]) - rec_chunks = [rec_list[i * chunk_size:(i + 1) * chunk_size] for i in range(num_rec_chunks)] or [[]] - user_chunks = [user_list[i * chunk_size:(i + 1) * chunk_size] for i in range(num_user_chunks)] or [[]] - group_idx = 0 - shared_folder_revision = vault.vault_data.storage.shared_folders.get_entity(sf_uid).revision - sf_unencrypted_key = vault.vault_data.get_shared_folder_key(shared_folder_uid=sh_fol.shared_folder_uid) - for r_chunk in rec_chunks: - for u_chunk in user_chunks: - sf_info = sh_fol.copy() if isinstance(sh_fol, dict) else { - 'shared_folder_uid': sf_uid, - 'users': sh_fol.user_permissions, - 'teams': [], - 'records': sh_fol.record_permissions, - 'shared_folder_key_unencrypted': sf_unencrypted_key, - 'default_manage_users': sh_fol.default_can_share, - 'default_manage_records': sh_fol.default_can_edit, - 'revision': shared_folder_revision - } - if group_idx and isinstance(sf_info, dict) and 'revision' in sf_info: - del sf_info['revision'] - rq_groups[group_idx].append(prep_rq(r_chunk, u_chunk, sf_info)) - group_idx += 1 - self.send_requests(vault=vault, partitioned_requests=rq_groups) - - @staticmethod - def prepare_request(vault: vault_online.VaultOnline, kwargs, curr_sf, users, teams, rec_uids, *, - default_record=False, default_account=False, - share_expiration=None): - rq = folder_pb2.SharedFolderUpdateV3Request() - rq.sharedFolderUid = utils.base64_url_decode(curr_sf['shared_folder_uid']) - if 'revision' in curr_sf: - rq.revision = curr_sf['revision'] - else: - rq.forceUpdate = True - action = kwargs.get('action') or ShareAction.GRANT.value - mr = kwargs.get('manage_records') - mu = kwargs.get('manage_users') - if default_account and action == ShareAction.GRANT.value: - if mr is not None: - rq.defaultManageRecords = folder_pb2.BOOLEAN_TRUE if mr == 'on' else folder_pb2.BOOLEAN_FALSE - else: - rq.defaultManageRecords = folder_pb2.BOOLEAN_NO_CHANGE - if mu is not None: - rq.defaultManageUsers = folder_pb2.BOOLEAN_TRUE if mu == 'on' else folder_pb2.BOOLEAN_FALSE - else: - rq.defaultManageUsers = folder_pb2.BOOLEAN_NO_CHANGE - - if len(users) > 0: - existing_users = {x['username'] if isinstance(x, dict) else x.name for x in curr_sf.get('users', [])} - for email in users: - uo = folder_pb2.SharedFolderUpdateUser() - uo.username = email - set_expiration_fields(uo, share_expiration) - if email in existing_users: - if action == ShareAction.GRANT.value: - uo.manageRecords = folder_pb2.BOOLEAN_NO_CHANGE if mr is None else folder_pb2.BOOLEAN_TRUE if mr == ManagePermission.ON.value else folder_pb2.BOOLEAN_FALSE - uo.manageUsers = folder_pb2.BOOLEAN_NO_CHANGE if mu is None else folder_pb2.BOOLEAN_TRUE if mu == ManagePermission.ON.value else folder_pb2.BOOLEAN_FALSE - rq.sharedFolderUpdateUser.append(uo) - elif action == ShareAction.REMOVE.value: - rq.sharedFolderRemoveUser.append(uo.username) - elif action == ShareAction.GRANT.value: - invited = vault.keeper_auth.load_user_public_keys([email], send_invites=True) - if invited: - for username in invited: - logger.warning('Share invitation has been sent to \'%s\'', username) - logger.warning('Please repeat this command when invitation is accepted.') - keys = vault.keeper_auth._key_cache.get(email) if vault.keeper_auth._key_cache else None - if keys and (keys.rsa or keys.ec): - uo.manageRecords = folder_pb2.BOOLEAN_TRUE if curr_sf.get('default_manage_records') is True and mr is None else folder_pb2.BOOLEAN_TRUE if mr == ManagePermission.ON.value else folder_pb2.BOOLEAN_FALSE - uo.manageUsers = folder_pb2.BOOLEAN_TRUE if curr_sf.get('default_manage_users') is True and mu is None else folder_pb2.BOOLEAN_TRUE if mu == ManagePermission.ON.value else folder_pb2.BOOLEAN_FALSE - sf_key = curr_sf.get('shared_folder_key_unencrypted') - if sf_key: - if vault.keeper_auth.auth_context.forbid_rsa and keys.ec: - ec_key = crypto.load_ec_public_key(keys.ec) - uo.typedSharedFolderKey.encryptedKey = crypto.encrypt_ec(sf_key, ec_key) - uo.typedSharedFolderKey.encryptedKeyType = folder_pb2.encrypted_by_public_key_ecc - elif not vault.keeper_auth.auth_context.forbid_rsa and keys.rsa: - rsa_key = crypto.load_rsa_public_key(keys.rsa) - uo.typedSharedFolderKey.encryptedKey = crypto.encrypt_rsa(sf_key, rsa_key) - uo.typedSharedFolderKey.encryptedKeyType = folder_pb2.encrypted_by_public_key - - rq.sharedFolderAddUser.append(uo) - else: - logger.warning('User %s not found', email) - - if len(teams) > 0: - existing_teams = {x['team_uid']: x for x in curr_sf.get('teams', [])} - for team_uid in teams: - to = folder_pb2.SharedFolderUpdateTeam() - to.teamUid = utils.base64_url_decode(team_uid) - set_expiration_fields(to, share_expiration) - if team_uid in existing_teams: - team = existing_teams[team_uid] - if action == ShareAction.GRANT.value: - to.manageRecords = team.get('manage_records') is True if mr is None else mr == ManagePermission.ON.value - to.manageUsers = team.get('manage_users') is True if mu is None else mu == ManagePermission.ON.value - rq.sharedFolderUpdateTeam.append(to) - elif action == ShareAction.REMOVE.value: - rq.sharedFolderRemoveTeam.append(to.teamUid) - elif action == ShareAction.GRANT.value: - to.manageRecords = True if mr else curr_sf.get('default_manage_records') is True - to.manageUsers = True if mu else curr_sf.get('default_manage_users') is True - team_sf_key = curr_sf.get('shared_folder_key_unencrypted') # type: Optional[bytes] - if team_sf_key: - vault.keeper_auth.load_team_keys([team_uid]) - keys = vault.keeper_auth._key_cache.get(team_uid) if vault.keeper_auth._key_cache else None - if keys: - if keys.aes: - if vault.keeper_auth.auth_context.forbid_rsa: - to.typedSharedFolderKey.encryptedKey = crypto.encrypt_aes_v2(team_sf_key, keys.aes) - to.typedSharedFolderKey.encryptedKeyType = folder_pb2.encrypted_by_data_key_gcm - else: - to.typedSharedFolderKey.encryptedKey = crypto.encrypt_aes_v1(team_sf_key, keys.aes) - to.typedSharedFolderKey.encryptedKeyType = folder_pb2.encrypted_by_data_key - elif vault.keeper_auth.auth_context.forbid_rsa and keys.ec: - ec_key = crypto.load_ec_public_key(keys.ec) - to.typedSharedFolderKey.encryptedKey = crypto.encrypt_ec(team_sf_key, ec_key) - to.typedSharedFolderKey.encryptedKeyType = folder_pb2.encrypted_by_public_key_ecc - elif not vault.keeper_auth.auth_context.forbid_rsa and keys.rsa: - rsa_key = crypto.load_rsa_public_key(keys.rsa) - to.typedSharedFolderKey.encryptedKey = crypto.encrypt_rsa(team_sf_key, rsa_key) - to.typedSharedFolderKey.encryptedKeyType = folder_pb2.encrypted_by_public_key - else: - continue - else: - continue - else: - logger.info('Shared folder key is not available.') - rq.sharedFolderAddTeam.append(to) - - ce = kwargs.get('can_edit') - cs = kwargs.get('can_share') - - if default_record and action == ShareAction.GRANT.value: - rq.defaultCanEdit = folder_pb2.BOOLEAN_NO_CHANGE if ce is None else folder_pb2.BOOLEAN_TRUE if ce == ManagePermission.ON.value else folder_pb2.BOOLEAN_FALSE - rq.defaultCanShare = folder_pb2.BOOLEAN_NO_CHANGE if cs is None else folder_pb2.BOOLEAN_TRUE if cs == ManagePermission.ON.value else folder_pb2.BOOLEAN_FALSE - - if len(rec_uids) > 0: - existing_records = {x['record_uid'] for x in curr_sf.get('records', [])} - for record_uid in rec_uids: - ro = folder_pb2.SharedFolderUpdateRecord() - ro.recordUid = utils.base64_url_decode(record_uid) - set_expiration_fields(ro, share_expiration) - - if record_uid in existing_records: - if action == ShareAction.GRANT.value: - ro.canEdit = folder_pb2.BOOLEAN_NO_CHANGE if ce is None else folder_pb2.BOOLEAN_TRUE if ce == ManagePermission.ON.value else folder_pb2.BOOLEAN_FALSE - ro.canShare = folder_pb2.BOOLEAN_NO_CHANGE if cs is None else folder_pb2.BOOLEAN_TRUE if cs == ManagePermission.ON.value else folder_pb2.BOOLEAN_FALSE - rq.sharedFolderUpdateRecord.append(ro) - elif action == ShareAction.REMOVE.value: - rq.sharedFolderRemoveRecord.append(ro.recordUid) - else: - if action == ShareAction.GRANT.value: - ro.canEdit = folder_pb2.BOOLEAN_TRUE if curr_sf.get('default_can_edit') is True and ce is None else folder_pb2.BOOLEAN_TRUE if ce == ManagePermission.ON.value else folder_pb2.BOOLEAN_FALSE - ro.canShare = folder_pb2.BOOLEAN_TRUE if curr_sf.get('default_can_share') is True and cs is None else folder_pb2.BOOLEAN_TRUE if cs == ManagePermission.ON.value else folder_pb2.BOOLEAN_FALSE - sf_key = curr_sf.get('shared_folder_key_unencrypted') - if sf_key: - rec = vault.vault_data.get_record(record_uid) - if rec: - rec_key = vault.vault_data.get_record_key(record_uid) - if rec_key: - if rec.version < 3: - ro.encryptedRecordKey = crypto.encrypt_aes_v1(rec_key, sf_key) - else: - ro.encryptedRecordKey = crypto.encrypt_aes_v2(rec_key, sf_key) - rq.sharedFolderAddRecord.append(ro) - return rq - - @staticmethod - def send_requests(vault:vault_online.VaultOnline, partitioned_requests): - for requests in partitioned_requests: - while requests: - vault.auto_sync = True - chunk = requests[:999] - requests = requests[999:] - rqs = folder_pb2.SharedFolderUpdateV3RequestV2() - rqs.sharedFoldersUpdateV3.extend(chunk) - try: - rss = vault.keeper_auth.execute_auth_rest(rest_endpoint=ApiUrl.SHARE_FOLDER_UPDATE.value, request=rqs, response_type=folder_pb2.SharedFolderUpdateV3ResponseV2, payload_version=1) - if rss and hasattr(rss, 'sharedFoldersUpdateV3Response'): - for rs in rss.sharedFoldersUpdateV3Response: - team_cache = vault.vault_data.teams() - for attr in ( - 'sharedFolderAddTeamStatus', 'sharedFolderUpdateTeamStatus', - 'sharedFolderRemoveTeamStatus'): - if hasattr(rs, attr): - statuses = getattr(rs, attr) - for t in statuses: - team_uid = utils.base64_url_encode(t.teamUid) - team = next((x for x in team_cache if x.team_uid == team_uid), None) - if team: - status = t.status - if status == 'success': - logger.info('Team share \'%s\' %s', team.name, - 'added' if attr == 'sharedFolderAddTeamStatus' else - 'updated' if attr == 'sharedFolderUpdateTeamStatus' else - 'removed') - else: - logger.warning('Team share \'%s\' failed', team.name) - - for attr in ( - 'sharedFolderAddUserStatus', 'sharedFolderUpdateUserStatus', - 'sharedFolderRemoveUserStatus'): - if hasattr(rs, attr): - statuses = getattr(rs, attr) - for s in statuses: - username = s.username - status = s.status - if status == 'success': - logger.info('User share \'%s\' %s', username, - 'added' if attr == 'sharedFolderAddUserStatus' else - 'updated' if attr == 'sharedFolderUpdateUserStatus' else - 'removed') - elif status == 'invited': - logger.info('User \'%s\' invited', username) - else: - logger.warning('User share \'%s\' failed', username) - - for attr in ('sharedFolderAddRecordStatus', 'sharedFolderUpdateRecordStatus', - 'sharedFolderRemoveRecordStatus'): - if hasattr(rs, attr): - statuses = getattr(rs, attr) - for r in statuses: - record_uid = utils.base64_url_encode(r.recordUid) - status = r.status - if record_uid in vault.vault_data._records: - rec = vault.vault_data.get_record(record_uid) - title = rec.title if rec else record_uid - else: - title = record_uid - if status == 'success': - logger.info('Record share \'%s\' %s', title, - 'added' if attr == 'sharedFolderAddRecordStatus' else - 'updated' if attr == 'sharedFolderUpdateRecordStatus' else - 'removed') - else: - logger.warning('Record share \'%s\' failed', title) - except Exception as kae: - logger.error(kae) - return - - -class OneTimeShareListCommand(base.ArgparseCommand): - - def __init__(self): - self.parser = argparse.ArgumentParser( - prog='share-list', - description='Displays a list of one-time shares for a record', - parents=[base.report_output_parser] - ) - OneTimeShareListCommand.add_arguments_to_parser(self.parser) - super().__init__(self.parser) - - @staticmethod - def add_arguments_to_parser(parser: argparse.ArgumentParser): - parser.add_argument( - '-R', '--recursive', dest='recursive', action='store_true', - help='Traverse recursively through subfolders' - ) - parser.add_argument( - '-v', '--verbose', dest='verbose', action='store_true', help='verbose output.' - ) - parser.add_argument( - '-a', '--all', dest='show_all', action='store_true', help='show all one-time shares including expired.' - ) - parser.add_argument( - 'record', nargs='?', type=str, action='store', help='record/folder path/UID' - ) - - def execute(self, context: KeeperParams, **kwargs): - if not context.vault: - raise ValueError('Vault is not initialized.') - - vault = context.vault - - records = kwargs['record'] if 'record' in kwargs else None - if not records: - self.get_parser().print_help() - return - if isinstance(records, str): - records = [records] - - record_uids = self._resolve_record_uids(context, vault, records, kwargs.get('recursive', False)) - if not record_uids: - raise base.CommandError('No records found') - - applications = self._get_applications(vault, record_uids) - table_data = self._build_share_table(applications, kwargs) - - return self._format_output(table_data, kwargs) - - def _resolve_record_uids(self, context: KeeperParams, vault, records: list, recursive: bool) -> set: - """Resolve record names/paths to UIDs.""" - record_uids = set() - - for name in records: - record_uid = None - folder_uid = None - if name in vault.vault_data._records: - record_uid = name - elif name in vault.vault_data._folders: - folder_uid = name - else: - rs = folder_utils.try_resolve_path(context, name) - if rs is not None: - folder, r_name = rs - if r_name: - f_uid = folder.folder_uid or '' - if f_uid in vault.vault_data._folders: - for uid in folder.records: - rec = vault.vault_data.get_record(record_uid=uid) - if rec and rec.version in (2, 3) and rec.title.lower() == r_name.lower(): - record_uid = uid - break - else: - folder_uid = folder.folder_uid or '' - - if record_uid is not None: - record_uids.add(record_uid) - elif folder_uid is not None: - self._add_folder_records(vault, folder_uid, record_uids, recursive) - - return record_uids - - def _add_folder_records(self, vault, folder_uid: str, record_uids: set, recursive: bool): - """Add records from a folder to the record_uids set.""" - def on_folder(f): - f_uid = f.folder_uid or '' - if f_uid in vault.vault_data._folders: - folder = vault.vault_data.get_folder(folder_uid=f_uid) - recs = folder.records - if recs: - record_uids.update(recs) - - folder = vault.vault_data.get_folder(folder_uid=folder_uid) - if recursive: - vault_utils.traverse_folder_tree(vault.vault_data, folder, on_folder) - else: - on_folder(folder) - - def _get_applications(self, vault, record_uids: set): - """Get application info for the given record UIDs.""" - r_uids = list(record_uids) - MAX_BATCH_SIZE = 1000 - if len(r_uids) >= MAX_BATCH_SIZE: - logger.info('Trimming result to %d records', MAX_BATCH_SIZE) - r_uids = r_uids[:MAX_BATCH_SIZE - 1] - return ksm_management.get_app_info(vault=vault, app_uid=r_uids) - - def _build_share_table(self, applications, kwargs): - """Build table data from applications.""" - show_all = kwargs.get('show_all', False) - verbose = kwargs.get('verbose', False) - now = utils.current_milli_time() - - fields = ['record_uid', 'share_link_name', 'share_link_id', 'generated', 'opened', 'expires'] - if show_all: - fields.append('status') - - table = [] - output_format = kwargs.get('format') - - for app_info in applications: - if not app_info.isExternalShare: - continue - - for client in app_info.clients: - if not show_all and now > client.accessExpireOn: - continue - - link = self._create_share_link_data(app_info, client, verbose, output_format, now) - table.append([link.get(x, '') for x in fields]) - - return table, fields - - def _create_share_link_data(self, app_info, client, verbose: bool, output_format: str, now: int): - """Create share link data dictionary.""" - link = { - 'record_uid': utils.base64_url_encode(app_info.appRecordUid), - 'name': client.id, - 'share_link_id': utils.base64_url_encode(client.clientId), - 'generated': datetime.datetime.fromtimestamp(client.createdOn / TIMESTAMP_MILLISECONDS_FACTOR), - 'expires': datetime.datetime.fromtimestamp(client.accessExpireOn / TIMESTAMP_MILLISECONDS_FACTOR), - } - - TRUNCATE_LENGTH = 20 - if output_format == 'table' and not verbose: - link['share_link_id'] = utils.base64_url_encode(client.clientId)[:TRUNCATE_LENGTH] + TRUNCATE_SUFFIX - else: - link['share_link_id'] = utils.base64_url_encode(client.clientId) - - if client.firstAccess > 0: - link['opened'] = datetime.datetime.fromtimestamp(client.firstAccess / TIMESTAMP_MILLISECONDS_FACTOR) - link['accessed'] = datetime.datetime.fromtimestamp(client.lastAccess / TIMESTAMP_MILLISECONDS_FACTOR) - - if now > client.accessExpireOn: - link['status'] = 'Expired' - elif client.firstAccess > 0: - link['status'] = 'Opened' - else: - link['status'] = 'Generated' - - return link - - def _format_output(self, table_data, kwargs): - """Format and return the output.""" - table, fields = table_data - output_format = kwargs.get('format') - - if output_format == 'table': - fields = [report_utils.field_to_title(x) for x in fields] - - return report_utils.dump_report_data(table, fields, fmt=output_format, filename=kwargs.get('output')) - - -class OneTimeShareCreateCommand(base.ArgparseCommand): - - def __init__(self): - self.parser = argparse.ArgumentParser( - prog='share-create', - description='Creates one-time share URL for a record' - ) - OneTimeShareCreateCommand.add_arguments_to_parser(self.parser) - super().__init__(self.parser) - - @staticmethod - def add_arguments_to_parser(parser: argparse.ArgumentParser): - parser.add_argument( - '--output', dest='output', choices=['clipboard', 'stdout'], action='store', - help='URL output destination' - ) - parser.add_argument( - '--name', dest='share_name', action='store', help='one-time share URL name' - ) - parser.add_argument( - '-e', '--expire', dest='expire', action='store', metavar='[(mi)nutes|(h)ours|(d)ays]', - help='time period record share URL is valid.' - ) - parser.add_argument( - '--editable', dest='is_editable', action='store_true', help='allow the user to edit the shared record' - ) - parser.add_argument( - 'record', nargs='?', type=str, action='store', help='record path or UID. Can be repeated' - ) - - def execute(self, context: KeeperParams, **kwargs): - if not context.vault: - raise ValueError('Vault is not initialized.') - - vault = context.vault - - record_names = kwargs.get('record') - period_str = kwargs.get('expire') - name = kwargs.get('share_name', '') - is_editable = kwargs.get('is_editable', False) - if isinstance(record_names, str): - record_names = [record_names] - if not record_names: - self.get_parser().print_help() - raise base.CommandError('No records provided') - if not period_str: - self.get_parser().print_help() - raise base.CommandError('URL expiration period parameter \"--expire\" is required.') - - period = self._validate_and_parse_expiration(period_str) - - urls = self._create_share_urls(context, vault, record_names, period, name, is_editable) - - return self._handle_output(context, urls, kwargs) - - def _validate_and_parse_expiration(self, period_str): - """Validate and parse the expiration period.""" - period = timeout_utils.parse_timeout(period_str) - SIX_MONTHS_IN_SECONDS = 182 * 24 * 60 * 60 - if period.total_seconds() > SIX_MONTHS_IN_SECONDS: - raise base.CommandError('URL expiration period cannot be greater than 6 months.') - return period - - def _create_share_urls(self, context: KeeperParams, vault, record_names: list, period, name: str, is_editable: bool): - """Create share URLs for the given records.""" - urls = {} - for record_name in record_names: - record_uid = record_utils.resolve_record(context=context, name=record_name) - record = vault.vault_data.load_record(record_uid=record_uid) - url = record_utils.process_external_share( - context=context, expiration_period=period, record=record, name=name, is_editable=is_editable, is_self_destruct=False - ) - urls[record_uid] = str(url) - return urls - - def _handle_output(self, context: KeeperParams, urls: dict, kwargs): - """Handle different output formats for the URLs.""" - if context.keeper_config.batch_mode: - return '\n'.join(urls.values()) - - output = kwargs.get('output') or '' - if len(urls) > 1 and not output: - output = 'stdout' - - if output == 'clipboard' and len(urls) == 1: - return self._copy_to_clipboard(urls) - elif output == 'stdout': - return self._output_to_stdout(urls) - else: - return '\n'.join(urls.values()) - - def _copy_to_clipboard(self, urls: dict): - """Copy URL to clipboard.""" - import pyperclip - url = next(iter(urls.values())) - pyperclip.copy(url) - logger.info('One-Time record share URL is copied to clipboard') - return None - - def _output_to_stdout(self, urls: dict): - """Output URLs to stdout in table format.""" - table = [list(x) for x in urls.items()] - headers = ['Record UID', 'URL'] - report_utils.dump_report_data(table, headers) - return None - - -class OneTimeShareRemoveCommand(base.ArgparseCommand): - - def __init__(self): - self.parser = argparse.ArgumentParser( - prog = 'share-remove', - description= 'Removes one-time share URL for a record' - ) - OneTimeShareRemoveCommand.add_arguments_to_parser(self.parser) - super().__init__(self.parser) - - @staticmethod - def add_arguments_to_parser(parser: argparse.ArgumentParser): - parser.add_argument( - 'record', nargs='?', type=str, action='store', help='record path or UID' - ) - parser.add_argument( - 'share', nargs='?', type=str, action='store', help='one-time share name or ID' - ) - - def execute(self, context: KeeperParams, **kwargs): - if not context.vault: - raise ValueError('Vault is not initialized.') - - vault = context.vault - - record_name = kwargs.get('record') - if not record_name: - self.get_parser().print_help() - return - - record_uid = record_utils.resolve_record(context=context, name=record_name) - applications = ksm_management.get_app_info(vault=vault, app_uid=record_uid) - - if len(applications) == 0: - logger.info('There are no one-time shares for record \"%s\"', record_name) - return - - share_name = kwargs.get('share') - if not share_name: - self.get_parser().print_help() - return - - client_id = self._find_client_id(applications, share_name) - if not client_id: - return - - self._remove_share(vault, record_uid, client_id, share_name, record_name) - - def _find_client_id(self, applications, share_name: str) -> Optional[bytes]: - - cleaned_name = share_name[:-len(TRUNCATE_SUFFIX)] if share_name.endswith(TRUNCATE_SUFFIX) else share_name - cleaned_name_lower = cleaned_name.lower() - - partial_matches = [] - - for app_info in applications: - if not app_info.isExternalShare: - continue - - for client in app_info.clients: - if client.id.lower() == cleaned_name_lower: - return client.clientId - - encoded_client_id = utils.base64_url_encode(client.clientId) - if encoded_client_id == cleaned_name: - return client.clientId - - if encoded_client_id.startswith(cleaned_name): - partial_matches.append(client.clientId) - - return self._resolve_partial_matches(partial_matches, share_name) - - def _resolve_partial_matches(self, partial_matches: list[bytes], original_name: str) -> Optional[bytes]: - """ - Resolve partial matches to a single client ID. - - Args: - partial_matches: List of client IDs that partially match - original_name: Original share name for error reporting - - Returns: - bytes: Single client ID if exactly one match, None otherwise - """ - if not partial_matches: - logger.warning('No one-time share found matching "%s"', original_name) - return None - - if len(partial_matches) == 1: - return partial_matches[0] - - # Multiple matches found - logger.warning('Multiple one-time shares found matching "%s". Please use a more specific identifier.', original_name) - return None - - def _remove_share(self, vault, record_uid: str, client_id: bytes, share_name: str, record_name: str): - """Remove the one-time share.""" - rq = APIRequest_pb2.RemoveAppClientsRequest() - rq.appRecordUid = utils.base64_url_decode(record_uid) - rq.clients.append(client_id) - - vault.keeper_auth.execute_auth_rest(request=rq, rest_endpoint=ApiUrl.REMOVE_EXTERNAL_SHARE.value) - logger.info('One-time share \"%s\" is removed from record \"%s\"', share_name, record_name) diff --git a/keepercli-package/src/keepercli/commands/shares.py b/keepercli-package/src/keepercli/commands/shares.py new file mode 100644 index 00000000..40cc05d0 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/shares.py @@ -0,0 +1,1074 @@ +import argparse +import datetime +import math +import re +from enum import Enum +from typing import Optional + +from keepersdk import utils +from keepersdk.authentication import keeper_auth +from keepersdk.proto import record_pb2, APIRequest_pb2 +from keepersdk.vault import ksm_management, vault_online, vault_utils, share_management_utils +from keepersdk.vault.shares_management import RecordShares, FolderShares + +from . import base +from .. import api, prompt_utils, constants +from ..helpers import folder_utils, record_utils, report_utils, timeout_utils +from ..params import KeeperParams + + +class ApiUrl(Enum): + SHARE_ADMIN = 'vault/am_i_share_admin' + SHARE_UPDATE = 'vault/records_share_update' + SHARE_FOLDER_UPDATE = 'vault/shared_folder_update_v3' + REMOVE_EXTERNAL_SHARE = 'vault/external_share_remove' + + +class ShareAction(Enum): + GRANT = 'grant' + REVOKE = 'revoke' + OWNER = 'owner' + CANCEL = 'cancel' + REMOVE = 'remove' + + +class ManagePermission(Enum): + ON = 'on' + OFF = 'off' + + +logger = api.get_logger() + +# Constants +TIMESTAMP_MILLISECONDS_FACTOR = 1000 +TRUNCATE_SUFFIX = '...' +URL_TRUNCATE_LENGTH = 30 +NON_SHARED_DEFAULT = 'non-shared' +CUSTOM_FIELD_TYPE_PREFIX = 'type:' +TOTP_FIELD_NAME = 'totp' +LIST_SEPARATOR = '|' +DICT_SEPARATOR = ';' +KEY_VALUE_SEPARATOR = '=' +PERMISSION_SEPARATOR = '=' +SHARE_NAMES_SEPARATOR = ', ' +SUPPORTED_RECORD_VERSIONS = {2, 3} +DEFAULT_SEARCH_FIELDS = ['by_title', 'by_login', 'by_password'] +CHUNK_SIZE = 500 +MAX_BATCH_SIZE = 1000 +SHARE_LINK_TRUNCATE_LENGTH = 20 +SIX_MONTHS_IN_SECONDS = 182 * 24 * 60 * 60 +TEAMS_THRESHOLD = 500 +ALL_FOLDERS_WILDCARD = '*' +ALL_USERS_WILDCARD = '@existing' +ALL_USERS_WILDCARD_ALT = '@current' +DEFAULT_ACCOUNT_WILDCARD = '*' +DEFAULT_RECORD_WILDCARD = '*' + +def set_expiration_fields(obj, expiration): + """Set expiration and timerNotificationType fields on proto object if expiration is provided.""" + if isinstance(expiration, int): + if expiration > 0: + obj.expiration = expiration * TIMESTAMP_MILLISECONDS_FACTOR + obj.timerNotificationType = record_pb2.NOTIFY_OWNER + elif expiration < 0: + obj.expiration = -1 + + +class ShareRecordCommand(base.ArgparseCommand): + + def __init__(self): + self.parser = argparse.ArgumentParser( + prog='share-record', + description='Change the sharing permissions of an individual record' + ) + ShareRecordCommand.add_arguments_to_parser(self.parser) + super().__init__(self.parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser): + + parser.add_argument( + '-e', '--email', dest='email', action='append', help='account email' + ) + parser.add_argument( + '--contacts-only', action='store_true', + help="Share only to known targets; Allows routing to alternate domains with matching usernames if needed" + ) + parser.add_argument( + '-f', '--force', action='store_true', help='Skip confirmation prompts' + ) + parser.add_argument( + '-a', '--action', dest='action', choices=[action.value for action in ShareAction], + default=ShareAction.GRANT.value, action='store', help='user share action. \'grant\' if omitted' + ) + parser.add_argument( + '-s', '--share', dest='can_share', action='store_true', help='can re-share record' + ) + parser.add_argument( + '-w', '--write', dest='can_edit', action='store_true', help='can modify record' + ) + parser.add_argument( + '-R', '--recursive', dest='recursive', action='store_true', + help='apply command to shared folder hierarchy' + ) + parser.add_argument( + '--dry-run', dest='dry_run', action='store_true', + help='display the permissions changes without committing them' + ) + expiration = parser.add_mutually_exclusive_group() + expiration.add_argument( + '--expire-at', dest='expire_at', action='store', help='share expiration: never or UTC datetime' + ) + expiration.add_argument( + '--expire-in', dest='expire_in', action='store', + metavar='[(mi)nutes|(h)ours|(d)ays|(mo)nths|(y)ears]', + help='share expiration: never or period' + ) + parser.add_argument( + 'record', nargs='?', type=str, action='store', help='record/shared folder path/UID' + ) + + def execute(self, context: KeeperParams, **kwargs) -> None: + if not context.vault: + raise ValueError("Vault is not initialized.") + vault = context.vault + + uid_or_name = kwargs.get('record') + if not uid_or_name: + return self.get_parser().print_help() + + emails = kwargs.get('email') or [] + if not emails: + raise ValueError('\'email\' parameter is missing') + + action = kwargs.get('action', ShareAction.GRANT.value) + + if kwargs.get('contacts_only'): + emails = self._validate_and_replace_contacts(vault, emails, kwargs.get('force')) + + if action == ShareAction.CANCEL.value: + RecordShares.cancel_share(vault, emails) + vault.sync_down() + return + + share_expiration = share_management_utils.get_share_expiration( + kwargs.get('expire_at'), kwargs.get('expire_in') + ) + + request = RecordShares.prep_request( + vault=vault, + enterprise=context.enterprise_data, + uid_or_name=uid_or_name, + emails=emails, + share_expiration=share_expiration, + action=action, + dry_run=kwargs.get('dry_run', False), + can_edit=kwargs.get('can_edit'), + can_share=kwargs.get('can_share'), + recursive=kwargs.get('recursive') + ) + if request: + success_responses, failed_responses = RecordShares.send_requests(vault, [request]) + if success_responses: + logger.info(f'{len(success_responses)} share requests were successfully processed') + if failed_responses: + logger.error(f'{len(failed_responses)} share requests failed to process') + for failed_response in failed_responses: + logger.error(f'Failed to process share request: {failed_response}') + vault.sync_down() + + def _validate_and_replace_contacts(self, vault, emails: list, force: bool) -> list: + """Validate emails against known contacts and optionally replace with matches.""" + shared_objects = share_management_utils.get_share_objects(vault=vault) + known_users = shared_objects.get('users', {}) + known_emails = [u.casefold() for u in known_users.keys()] + + def is_unknown(email): + return email.casefold() not in known_emails and utils.is_email(email) + + unknowns = [e for e in emails if is_unknown(e)] + if not unknowns: + return emails + + username_map = { + e: ShareRecordCommand.get_contact(e, known_users) + for e in unknowns + } + table = [[k, v] for k, v in username_map.items()] + logger.info(f'{len(unknowns)} unrecognized share recipient(s) and closest matching contact(s)') + report_utils.dump_report_data(table, ['Username', 'From Contacts']) + + confirmed = force or prompt_utils.user_choice( + '\tReplace with known matching contact(s)?', 'yn', default='n' + ) == 'y' + + if not confirmed: + return emails + + good_emails = [e for e in emails if e not in unknowns] + replacements = [e for e in username_map.values() if e] + return [*good_emails, *replacements] + + @staticmethod + def get_contact(user, contacts): + if not user or not contacts: + return None + + user_username = user.split('@')[0].casefold() + + for contact in contacts: + contact_username = contact.split('@')[0].casefold() + if user_username == contact_username: + return contact + + return None + + +class ShareFolderCommand(base.ArgparseCommand): + def __init__(self): + self.parser = argparse.ArgumentParser( + prog='share-folder', + description='Change the sharing permissions of shared folders' + ) + ShareFolderCommand.add_arguments_to_parser(self.parser) + super().__init__(self.parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser): + parser.add_argument( + '-a', '--action', dest='action', choices=[ShareAction.GRANT.value, ShareAction.REMOVE.value], + default=ShareAction.GRANT.value, action='store', + help='shared folder action. \'grant\' if omitted' + ) + parser.add_argument( + '-e', '--email', dest='user', action='append', + help='account email, team, @existing for all users and teams in the folder, or \'*\' as default folder permission' + ) + parser.add_argument( + '-r', '--record', dest='record', action='append', + help='record name, record UID, @existing for all records in the folder, or \'*\' as default folder permission' + ) + parser.add_argument( + '-p', '--manage-records', dest='manage_records', action='store', + choices=[perm.value for perm in ManagePermission], help='account permission: can manage records.' + ) + parser.add_argument( + '-o', '--manage-users', dest='manage_users', action='store', + choices=[perm.value for perm in ManagePermission], help='account permission: can manage users.' + ) + parser.add_argument( + '-s', '--can-share', dest='can_share', action='store', + choices=[perm.value for perm in ManagePermission], help='record permission: can be shared' + ) + parser.add_argument( + '-d', '--can-edit', dest='can_edit', action='store', + choices=[perm.value for perm in ManagePermission], help='record permission: can be modified.' + ) + parser.add_argument( + '-f', '--force', dest='force', action='store_true', + help='Apply permission changes ignoring default folder permissions. Used on the initial sharing action' + ) + expiration = parser.add_mutually_exclusive_group() + expiration.add_argument( + '--expire-at', dest='expire_at', action='store', metavar='TIMESTAMP', + help='share expiration: never or ISO datetime (yyyy-MM-dd[ hh:mm:ss])' + ) + expiration.add_argument( + '--expire-in', dest='expire_in', action='store', metavar='PERIOD', + help='share expiration: never or period ([(y)ears|(mo)nths|(d)ays|(h)ours(mi)nutes]' + ) + parser.add_argument( + 'folder', nargs='+', type=str, action='store', help='shared folder path or UID' + ) + + def execute(self, context: KeeperParams, **kwargs) -> None: + if not context.vault: + raise ValueError('Vault is not initialized.') + + vault = context.vault + names = self._normalize_folder_names(kwargs.get('folder')) + shared_folder_uids = self._resolve_shared_folder_uids(vault, names) + + if not shared_folder_uids: + raise ValueError('Enter name of at least one existing folder') + + action = kwargs.get('action') or ShareAction.GRANT.value + share_expiration = self._get_share_expiration(action, kwargs) + + user_data = self._parse_user_arguments(vault, kwargs) + record_data = self._parse_record_arguments(vault, kwargs) + + if self._is_nothing_to_do(user_data, record_data): + logger.info('Nothing to do') + return + + rq_groups = self._prepare_request_groups( + vault, shared_folder_uids, user_data, record_data, + action, share_expiration, kwargs + ) + success_responses, failed_responses = FolderShares.send_requests(vault=vault, partitioned_requests=rq_groups) + if success_responses: + logger.info(f'{len(success_responses)} share requests were successfully processed') + if failed_responses: + logger.error(f'{len(failed_responses)} share requests failed to process') + for failed_response in failed_responses: + logger.error(f'Failed to process share request: {failed_response}') + vault.sync_down() + + def _normalize_folder_names(self, folder_names) -> list: + """Normalize folder names list and check for wildcard.""" + if not folder_names: + return [] + if not isinstance(folder_names, list): + return [folder_names] + return folder_names + + def _resolve_shared_folder_uids(self, vault: vault_online.VaultOnline, names: list) -> set: + """Resolve folder names to shared folder UIDs.""" + all_folders = any(x == ALL_FOLDERS_WILDCARD for x in names) + if all_folders: + names = [x for x in names if x != ALL_FOLDERS_WILDCARD] + + shared_folder_cache = {x.shared_folder_uid: x for x in vault.vault_data.shared_folders()} + folder_cache = {x.folder_uid: x for x in vault.vault_data.folders()} + shared_folder_uids = set() + + if all_folders: + shared_folder_uids.update(shared_folder_cache.keys()) + else: + shared_folder_uids = self._resolve_specific_folders( + vault, names, shared_folder_cache, folder_cache + ) + + return shared_folder_uids + + def _resolve_specific_folders(self, vault: vault_online.VaultOnline, names: list, + shared_folder_cache: dict, folder_cache: dict) -> set: + """Resolve specific folder names to shared folder UIDs.""" + shared_folder_uids = set() + folder_uids = { + uid + for name in names if name + for uid in share_management_utils.get_folder_uids(vault, name) + } + + folders = {folder_cache.get(uid) for uid in folder_uids if folder_cache.get(uid)} + shared_folder_uids.update([uid for uid in folder_uids if uid in shared_folder_cache]) + + sf_subfolders = {f for f in folders if f and f.folder_type == 'shared_folder_folder'} + shared_folder_uids.update({f.folder_scope_uid for f in sf_subfolders if f.folder_scope_uid}) + + unresolved_names = [ + name for name in names + if name and not share_management_utils.get_folder_uids(vault, name) + ] + if unresolved_names: + share_admin_folder_uids = self._get_share_admin_obj_uids( + vault, unresolved_names, record_pb2.CHECK_SA_ON_SF + ) + shared_folder_uids.update(share_admin_folder_uids or []) + + return shared_folder_uids + + def _get_share_admin_obj_uids(self, vault: vault_online.VaultOnline, + obj_names: list, obj_type) -> Optional[set]: + """Get UIDs of objects where user is share admin.""" + if not obj_names: + return None + try: + rq = record_pb2.AmIShareAdmin() + for name in obj_names: + try: + uid = utils.base64_url_decode(name) + if isinstance(uid, bytes) and len(uid) == 16: + osa = record_pb2.IsObjectShareAdmin() + osa.uid = uid + osa.objectType = obj_type + rq.isObjectShareAdmin.append(osa) + except Exception: + pass + + if len(rq.isObjectShareAdmin) == 0: + return None + + rs = vault.keeper_auth.execute_auth_rest( + rest_endpoint=ApiUrl.SHARE_ADMIN.value, + request=rq, + response_type=record_pb2.AmIShareAdmin + ) + if rs and hasattr(rs, 'isObjectShareAdmin'): + sa_obj_uids = {sa_obj.uid for sa_obj in rs.isObjectShareAdmin if sa_obj.isAdmin} + return {utils.base64_url_encode(uid) for uid in sa_obj_uids} + return None + except (ValueError, AttributeError) as e: + raise ValueError(f'get_share_admin: msg = {e}') from e + + def _get_record_uids(self, vault: vault_online.VaultOnline, name: str) -> set[str]: + """Get record UIDs by name or UID.""" + record_uids = set() + if not vault or not vault.vault_data: + return record_uids + + record = vault.vault_data.get_record(name) + if record: + record_uids.add(name) + return record_uids + + for record_info in vault.vault_data.records(): + if record_info.title == name: + record_uids.add(record_info.record_uid) + + return record_uids + + def _get_share_expiration(self, action: str, kwargs: dict): + """Get share expiration if action is grant.""" + if action == ShareAction.GRANT.value: + return share_management_utils.get_share_expiration( + kwargs.get('expire_at'), kwargs.get('expire_in') + ) + return None + + def _parse_user_arguments(self, vault, kwargs: dict) -> dict: + """Parse user arguments and return user data.""" + as_users = set() + as_teams = set() + all_users = False + default_account = False + + if 'user' not in kwargs: + return { + 'users': as_users, + 'teams': as_teams, + 'all_users': all_users, + 'default_account': default_account + } + + for u in (kwargs.get('user') or []): + if u == DEFAULT_ACCOUNT_WILDCARD: + default_account = True + elif u in (ALL_USERS_WILDCARD, ALL_USERS_WILDCARD_ALT): + all_users = True + elif re.match(constants.EMAIL_PATTERN, u): + as_users.add(u.lower()) + else: + team_uid = self._resolve_team_uid(vault, u) + if team_uid: + as_teams.add(team_uid) + + return { + 'users': as_users, + 'teams': as_teams, + 'all_users': all_users, + 'default_account': default_account + } + + def _resolve_team_uid(self, vault, team_identifier: str) -> Optional[str]: + """Resolve team identifier to team UID.""" + teams = share_management_utils.get_share_objects(vault=vault).get('teams', {}) + teams_map = {uid: team.get('name') for uid, team in teams.items()} + + if len(teams) >= TEAMS_THRESHOLD: + teams = vault_utils.load_available_teams(auth=vault.keeper_auth) + teams_map.update({t.team_uid: t.name for t in teams}) + + matches = [uid for uid, name in teams_map.items() if team_identifier in (name, uid)] + + if len(matches) == 0: + logger.warning(f'User "{team_identifier}" could not be resolved as email or team') + return None + elif len(matches) > 1: + logger.warning( + f'Multiple matches were found for team "{team_identifier}". ' + f'Try using its UID -- which can be found via `list-team` -- instead' + ) + return None + + return matches[0] + + def _parse_record_arguments(self, vault, kwargs: dict) -> dict: + """Parse record arguments and return record data.""" + record_uids = set() + all_records = False + default_record = False + + if 'record' not in kwargs: + return { + 'record_uids': record_uids, + 'all_records': all_records, + 'default_record': default_record + } + + records = kwargs.get('record') or [] + unresolved_names = [] + + for r in records: + if r == DEFAULT_RECORD_WILDCARD: + default_record = True + elif r in (ALL_USERS_WILDCARD, ALL_USERS_WILDCARD_ALT): + all_records = True + else: + r_uids = self._get_record_uids(vault, r) + if r_uids: + record_uids.update(r_uids) + else: + unresolved_names.append(r) + + if unresolved_names: + sa_record_uids = self._get_share_admin_obj_uids( + vault, unresolved_names, record_pb2.CHECK_SA_ON_RECORD + ) + record_uids.update(sa_record_uids or {}) + + return { + 'record_uids': record_uids, + 'all_records': all_records, + 'default_record': default_record + } + + def _is_nothing_to_do(self, user_data: dict, record_data: dict) -> bool: + """Check if there's nothing to do based on user and record data.""" + return ( + len(user_data['users']) == 0 and + len(user_data['teams']) == 0 and + len(record_data['record_uids']) == 0 and + not record_data['default_record'] and + not user_data['default_account'] and + not user_data['all_users'] and + not record_data['all_records'] + ) + + def _prepare_request_groups(self, vault: vault_online.VaultOnline, shared_folder_uids: set, + user_data: dict, record_data: dict, action: str, + share_expiration, kwargs: dict) -> list: + """Prepare request groups for all shared folders.""" + rq_groups = [] + shared_folder_cache = {x.shared_folder_uid: x for x in vault.vault_data.shared_folders()} + + for sf_uid in shared_folder_uids: + folder_requests = self._prepare_folder_requests( + vault, sf_uid, shared_folder_cache, user_data, + record_data, action, share_expiration, kwargs + ) + rq_groups.extend(folder_requests) + + return rq_groups + + def _prepare_folder_requests(self, vault: vault_online.VaultOnline, sf_uid: str, + shared_folder_cache: dict, user_data: dict, + record_data: dict, action: str, share_expiration, + kwargs: dict) -> list: + """Prepare requests for a single shared folder.""" + sf_users = user_data['users'].copy() + sf_teams = user_data['teams'].copy() + sf_records = record_data['record_uids'].copy() + + sh_fol = self._load_or_create_shared_folder( + vault, sf_uid, shared_folder_cache, user_data, record_data, action + ) + + if sf_uid in shared_folder_cache and sh_fol: + self._update_from_existing_folder( + sh_fol, vault.keeper_auth, user_data, record_data, sf_users, sf_records + ) + + return self._chunk_and_prepare_requests( + vault, kwargs, sh_fol, sf_uid, sf_users, sf_teams, sf_records, + record_data['default_record'], user_data['default_account'], share_expiration + ) + + def _load_or_create_shared_folder(self, vault: vault_online.VaultOnline, sf_uid: str, shared_folder_cache: dict, + user_data: dict, record_data: dict, action: str): + """Load existing shared folder or create a new one.""" + if sf_uid in shared_folder_cache: + return vault.vault_data.load_shared_folder(sf_uid) + + return { + 'shared_folder_uid': sf_uid, + 'users': [ + { + 'username': x, + 'manage_records': action != ShareAction.GRANT.value, + 'manage_users': action != ShareAction.GRANT.value + } + for x in user_data['users'] + ], + 'teams': [ + { + 'team_uid': x, + 'manage_records': action != ShareAction.GRANT.value, + 'manage_users': action != ShareAction.GRANT.value + } + for x in user_data['teams'] + ], + 'records': [ + { + 'record_uid': x, + 'can_share': action != ShareAction.GRANT.value, + 'can_edit': action != ShareAction.GRANT.value + } + for x in record_data['record_uids'] + ] + } + + def _update_from_existing_folder(self, sh_fol, auth: keeper_auth.KeeperAuth, + user_data: dict, record_data: dict, + sf_users: set, sf_records: set): + """Update user and record sets from existing folder permissions.""" + if not (user_data['all_users'] or record_data['all_records']): + return + + if user_data['all_users'] and sh_fol.user_permissions: + sf_users.update( + x.name for x in sh_fol.user_permissions + if x.name != auth.auth_context.username + ) + + if record_data['all_records'] and sh_fol.record_permissions: + sf_records.update(x.record_uid for x in sh_fol.record_permissions) + + def _chunk_and_prepare_requests(self, vault: vault_online.VaultOnline, kwargs: dict, sh_fol, sf_uid: str, + sf_users: set, sf_teams: set, sf_records: set, + default_record: bool, default_account: bool, + share_expiration) -> list: + """Chunk records and users, then prepare requests.""" + rec_list = list(sf_records) + user_list = list(sf_users) + num_rec_chunks = math.ceil(len(sf_records) / CHUNK_SIZE) or 1 + num_user_chunks = math.ceil(len(sf_users) / CHUNK_SIZE) or 1 + num_rq_groups = num_user_chunks * num_rec_chunks + + rq_groups = [[] for _ in range(num_rq_groups)] + rec_chunks = [ + rec_list[i * CHUNK_SIZE:(i + 1) * CHUNK_SIZE] + for i in range(num_rec_chunks) + ] or [[]] + user_chunks = [ + user_list[i * CHUNK_SIZE:(i + 1) * CHUNK_SIZE] + for i in range(num_user_chunks) + ] or [[]] + + shared_folder_revision = vault.vault_data.storage.shared_folders.get_entity(sf_uid).revision + sf_unencrypted_key = vault.vault_data.get_shared_folder_key(shared_folder_uid=sf_uid) + + group_idx = 0 + for r_chunk in rec_chunks: + for u_chunk in user_chunks: + sf_info = self._build_shared_folder_info( + sh_fol, sf_uid, sf_unencrypted_key, shared_folder_revision, group_idx + ) + request = FolderShares.prepare_request( + vault, kwargs, sf_info, u_chunk, sf_teams, r_chunk, + default_record=default_record, + default_account=default_account, + share_expiration=share_expiration + ) + rq_groups[group_idx].append(request) + group_idx += 1 + + return rq_groups + + def _build_shared_folder_info(self, sh_fol, sf_uid: str, sf_unencrypted_key, + shared_folder_revision: int, group_idx: int) -> dict: + """Build shared folder info dictionary.""" + if isinstance(sh_fol, dict): + sf_info = sh_fol.copy() + if group_idx > 0 and 'revision' in sf_info: + del sf_info['revision'] + return sf_info + + sf_info = { + 'shared_folder_uid': sf_uid, + 'users': sh_fol.user_permissions, + 'teams': [], + 'records': sh_fol.record_permissions, + 'shared_folder_key_unencrypted': sf_unencrypted_key, + 'default_manage_users': sh_fol.default_can_share, + 'default_manage_records': sh_fol.default_can_edit, + 'revision': shared_folder_revision + } + if group_idx > 0: + del sf_info['revision'] + + return sf_info + + +class OneTimeShareListCommand(base.ArgparseCommand): + + def __init__(self): + self.parser = argparse.ArgumentParser( + prog='share-list', + description='Displays a list of one-time shares for a record', + parents=[base.report_output_parser] + ) + OneTimeShareListCommand.add_arguments_to_parser(self.parser) + super().__init__(self.parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser): + parser.add_argument( + '-R', '--recursive', dest='recursive', action='store_true', + help='Traverse recursively through subfolders' + ) + parser.add_argument( + '-v', '--verbose', dest='verbose', action='store_true', help='verbose output.' + ) + parser.add_argument( + '-a', '--all', dest='show_all', action='store_true', help='show all one-time shares including expired.' + ) + parser.add_argument( + 'record', nargs='?', type=str, action='store', help='record/folder path/UID' + ) + + def execute(self, context: KeeperParams, **kwargs): + if not context.vault: + raise ValueError('Vault is not initialized.') + + vault = context.vault + + records = kwargs['record'] if 'record' in kwargs else None + if not records: + self.get_parser().print_help() + return + if isinstance(records, str): + records = [records] + + record_uids = self._resolve_record_uids(context, vault, records, kwargs.get('recursive', False)) + if not record_uids: + raise base.CommandError('No records found') + + applications = self._get_applications(vault, record_uids) + table_data = self._build_share_table(applications, kwargs) + + return self._format_output(table_data, kwargs) + + def _resolve_record_uids(self, context: KeeperParams, vault, records: list, recursive: bool) -> set: + """Resolve record names/paths to UIDs.""" + record_uids = set() + + for name in records: + record_uid = None + folder_uid = None + if name in vault.vault_data._records: + record_uid = name + elif name in vault.vault_data._folders: + folder_uid = name + else: + rs = folder_utils.try_resolve_path(context, name) + if rs is not None: + folder, r_name = rs + if r_name: + f_uid = folder.folder_uid or '' + if f_uid in vault.vault_data._folders: + for uid in folder.records: + rec = vault.vault_data.get_record(record_uid=uid) + if rec and rec.version in SUPPORTED_RECORD_VERSIONS and rec.title.lower() == r_name.lower(): + record_uid = uid + break + else: + folder_uid = folder.folder_uid or '' + + if record_uid is not None: + record_uids.add(record_uid) + elif folder_uid is not None: + self._add_folder_records(vault, folder_uid, record_uids, recursive) + + return record_uids + + def _add_folder_records(self, vault, folder_uid: str, record_uids: set, recursive: bool): + """Add records from a folder to the record_uids set.""" + def on_folder(f): + f_uid = f.folder_uid or '' + if f_uid in vault.vault_data._folders: + folder = vault.vault_data.get_folder(folder_uid=f_uid) + recs = folder.records + if recs: + record_uids.update(recs) + + folder = vault.vault_data.get_folder(folder_uid=folder_uid) + if recursive: + vault_utils.traverse_folder_tree(vault.vault_data, folder, on_folder) + else: + on_folder(folder) + + def _get_applications(self, vault, record_uids: set): + """Get application info for the given record UIDs.""" + r_uids = list(record_uids) + if len(r_uids) >= MAX_BATCH_SIZE: + logger.info('Trimming result to %d records', MAX_BATCH_SIZE) + r_uids = r_uids[:MAX_BATCH_SIZE - 1] + return ksm_management.get_app_info(vault=vault, app_uid=r_uids) + + def _build_share_table(self, applications, kwargs): + """Build table data from applications.""" + show_all = kwargs.get('show_all', False) + verbose = kwargs.get('verbose', False) + now = utils.current_milli_time() + + fields = ['record_uid', 'share_link_name', 'share_link_id', 'generated', 'opened', 'expires'] + if show_all: + fields.append('status') + + table = [] + output_format = kwargs.get('format') + + for app_info in applications: + if not app_info.isExternalShare: + continue + + for client in app_info.clients: + if not show_all and now > client.accessExpireOn: + continue + + link = self._create_share_link_data(app_info, client, verbose, output_format, now) + table.append([link.get(x, '') for x in fields]) + + return table, fields + + def _create_share_link_data(self, app_info, client, verbose: bool, output_format: str, now: int): + """Create share link data dictionary.""" + encoded_client_id = utils.base64_url_encode(client.clientId) + link = { + 'record_uid': utils.base64_url_encode(app_info.appRecordUid), + 'name': client.id, + 'share_link_id': encoded_client_id, + 'generated': datetime.datetime.fromtimestamp(client.createdOn / TIMESTAMP_MILLISECONDS_FACTOR), + 'expires': datetime.datetime.fromtimestamp(client.accessExpireOn / TIMESTAMP_MILLISECONDS_FACTOR), + } + + if output_format == 'table' and not verbose: + link['share_link_id'] = encoded_client_id[:SHARE_LINK_TRUNCATE_LENGTH] + TRUNCATE_SUFFIX + else: + link['share_link_id'] = encoded_client_id + + if client.firstAccess > 0: + link['opened'] = datetime.datetime.fromtimestamp(client.firstAccess / TIMESTAMP_MILLISECONDS_FACTOR) + link['accessed'] = datetime.datetime.fromtimestamp(client.lastAccess / TIMESTAMP_MILLISECONDS_FACTOR) + + if now > client.accessExpireOn: + link['status'] = 'Expired' + elif client.firstAccess > 0: + link['status'] = 'Opened' + else: + link['status'] = 'Generated' + + return link + + def _format_output(self, table_data, kwargs): + """Format and return the output.""" + table, fields = table_data + output_format = kwargs.get('format') + + if output_format == 'table': + fields = [report_utils.field_to_title(x) for x in fields] + + return report_utils.dump_report_data(table, fields, fmt=output_format, filename=kwargs.get('output')) + + +class OneTimeShareCreateCommand(base.ArgparseCommand): + + def __init__(self): + self.parser = argparse.ArgumentParser( + prog='share-create', + description='Creates one-time share URL for a record' + ) + OneTimeShareCreateCommand.add_arguments_to_parser(self.parser) + super().__init__(self.parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser): + parser.add_argument( + '--output', dest='output', choices=['clipboard', 'stdout'], action='store', + help='URL output destination' + ) + parser.add_argument( + '--name', dest='share_name', action='store', help='one-time share URL name' + ) + parser.add_argument( + '-e', '--expire', dest='expire', action='store', metavar='[(mi)nutes|(h)ours|(d)ays]', + help='time period record share URL is valid.' + ) + parser.add_argument( + '--editable', dest='is_editable', action='store_true', help='allow the user to edit the shared record' + ) + parser.add_argument( + 'record', nargs='?', type=str, action='store', help='record path or UID. Can be repeated' + ) + + def execute(self, context: KeeperParams, **kwargs): + if not context.vault: + raise ValueError('Vault is not initialized.') + + vault = context.vault + + record_names = kwargs.get('record') + period_str = kwargs.get('expire') + name = kwargs.get('share_name', '') + is_editable = kwargs.get('is_editable', False) + if isinstance(record_names, str): + record_names = [record_names] + if not record_names: + self.get_parser().print_help() + raise base.CommandError('No records provided') + if not period_str: + self.get_parser().print_help() + raise base.CommandError('URL expiration period parameter \"--expire\" is required.') + + period = self._validate_and_parse_expiration(period_str) + + urls = self._create_share_urls(context, vault, record_names, period, name, is_editable) + + return self._handle_output(context, urls, kwargs) + + def _validate_and_parse_expiration(self, period_str): + """Validate and parse the expiration period.""" + period = timeout_utils.parse_timeout(period_str) + if period.total_seconds() > SIX_MONTHS_IN_SECONDS: + raise base.CommandError('URL expiration period cannot be greater than 6 months.') + return period + + def _create_share_urls(self, context: KeeperParams, vault, record_names: list, period, name: str, is_editable: bool): + """Create share URLs for the given records.""" + urls = {} + for record_name in record_names: + record_uid = record_utils.resolve_record(context=context, name=record_name) + record = vault.vault_data.load_record(record_uid=record_uid) + url = record_utils.process_external_share( + context=context, expiration_period=period, record=record, name=name, is_editable=is_editable, is_self_destruct=False + ) + urls[record_uid] = str(url) + return urls + + def _handle_output(self, context: KeeperParams, urls: dict, kwargs): + """Handle different output formats for the URLs.""" + if context.keeper_config.batch_mode: + return '\n'.join(urls.values()) + + output = kwargs.get('output') or '' + if len(urls) > 1 and not output: + output = 'stdout' + + if output == 'clipboard' and len(urls) == 1: + return self._copy_to_clipboard(urls) + elif output == 'stdout': + return self._output_to_stdout(urls) + else: + return '\n'.join(urls.values()) + + def _copy_to_clipboard(self, urls: dict): + """Copy URL to clipboard.""" + import pyperclip + url = next(iter(urls.values())) + pyperclip.copy(url) + logger.info('One-Time record share URL is copied to clipboard') + return None + + def _output_to_stdout(self, urls: dict): + """Output URLs to stdout in table format.""" + table = [list(x) for x in urls.items()] + headers = ['Record UID', 'URL'] + report_utils.dump_report_data(table, headers) + return None + + +class OneTimeShareRemoveCommand(base.ArgparseCommand): + + def __init__(self): + self.parser = argparse.ArgumentParser( + prog = 'share-remove', + description= 'Removes one-time share URL for a record' + ) + OneTimeShareRemoveCommand.add_arguments_to_parser(self.parser) + super().__init__(self.parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser): + parser.add_argument( + 'record', nargs='?', type=str, action='store', help='record path or UID' + ) + parser.add_argument( + 'share', nargs='?', type=str, action='store', help='one-time share name or ID' + ) + + def execute(self, context: KeeperParams, **kwargs): + if not context.vault: + raise ValueError('Vault is not initialized.') + + vault = context.vault + + record_name = kwargs.get('record') + if not record_name: + self.get_parser().print_help() + return + + record_uid = record_utils.resolve_record(context=context, name=record_name) + applications = ksm_management.get_app_info(vault=vault, app_uid=record_uid) + + if len(applications) == 0: + logger.info('There are no one-time shares for record \"%s\"', record_name) + return + + share_name = kwargs.get('share') + if not share_name: + self.get_parser().print_help() + return + + client_id = self._find_client_id(applications, share_name) + if not client_id: + return + + self._remove_share(vault, record_uid, client_id, share_name, record_name) + + def _find_client_id(self, applications, share_name: str) -> Optional[bytes]: + + cleaned_name = share_name[:-len(TRUNCATE_SUFFIX)] if share_name.endswith(TRUNCATE_SUFFIX) else share_name + cleaned_name_lower = cleaned_name.lower() + + partial_matches = [] + + for app_info in applications: + if not app_info.isExternalShare: + continue + + for client in app_info.clients: + if client.id.lower() == cleaned_name_lower: + return client.clientId + + encoded_client_id = utils.base64_url_encode(client.clientId) + if encoded_client_id == cleaned_name: + return client.clientId + + if encoded_client_id.startswith(cleaned_name): + partial_matches.append(client.clientId) + + return self._resolve_partial_matches(partial_matches, share_name) + + def _resolve_partial_matches(self, partial_matches: list[bytes], original_name: str) -> Optional[bytes]: + """ + Resolve partial matches to a single client ID. + + Args: + partial_matches: List of client IDs that partially match + original_name: Original share name for error reporting + + Returns: + bytes: Single client ID if exactly one match, None otherwise + """ + if not partial_matches: + logger.warning('No one-time share found matching "%s"', original_name) + return None + + if len(partial_matches) == 1: + return partial_matches[0] + + # Multiple matches found + logger.warning('Multiple one-time shares found matching "%s". Please use a more specific identifier.', original_name) + return None + + def _remove_share(self, vault, record_uid: str, client_id: bytes, share_name: str, record_name: str): + """Remove the one-time share.""" + rq = APIRequest_pb2.RemoveAppClientsRequest() + rq.appRecordUid = utils.base64_url_decode(record_uid) + rq.clients.append(client_id) + + vault.keeper_auth.execute_auth_rest(request=rq, rest_endpoint=ApiUrl.REMOVE_EXTERNAL_SHARE.value) + logger.info('One-time share \"%s\" is removed from record \"%s\"', share_name, record_name) diff --git a/keepercli-package/src/keepercli/commands/trash.py b/keepercli-package/src/keepercli/commands/trash.py index 1106e236..38a9be57 100644 --- a/keepercli-package/src/keepercli/commands/trash.py +++ b/keepercli-package/src/keepercli/commands/trash.py @@ -7,12 +7,12 @@ from . import base from .. import api, prompt_utils -from ..helpers import report_utils, share_utils +from ..helpers import report_utils from ..params import KeeperParams from keepersdk import utils from keepersdk.proto import record_pb2 -from keepersdk.vault import trash_management +from keepersdk.vault import trash_management, share_management_utils from keepersdk.vault.trash_management import TrashManagement @@ -394,7 +394,7 @@ def _display_share_info(self, context: KeeperParams, record: Dict, record_uid: s def _load_record_shares(self, vault, record: Dict, record_uid: str): """Load record shares if not already present.""" record['shares'] = {} - shares = share_utils.get_record_shares(vault, [record_uid], True) + shares = share_management_utils.get_record_shares(vault, [record_uid], True) if isinstance(shares, list): record_shares = next( @@ -671,7 +671,7 @@ def _confirm_unshare(self, force: bool, record_count: int) -> bool: def _remove_shares_from_records(self, vault, records_to_unshare: List[str]): """Remove shares from the specified records.""" - record_shares = share_utils.get_record_shares(vault, records_to_unshare, True) + record_shares = share_management_utils.get_record_shares(vault, records_to_unshare, True) if not record_shares: return diff --git a/keepercli-package/src/keepercli/commands/vault_record.py b/keepercli-package/src/keepercli/commands/vault_record.py index ed963026..4557d6c3 100644 --- a/keepercli-package/src/keepercli/commands/vault_record.py +++ b/keepercli-package/src/keepercli/commands/vault_record.py @@ -8,10 +8,10 @@ from . import base from .. import api, prompt_utils from ..params import KeeperParams -from ..helpers import folder_utils, report_utils, share_utils +from ..helpers import folder_utils, report_utils from keepersdk import utils from keepersdk.proto import enterprise_pb2 -from keepersdk.vault import record_management, vault_data, vault_types, vault_record, vault_utils +from keepersdk.vault import record_management, vault_data, vault_types, vault_record, vault_utils, share_management_utils logger = api.get_logger() @@ -196,7 +196,7 @@ def _get_teams(self, context: KeeperParams, kwargs): def _get_teams_from_share_objects(self, context: KeeperParams, show_all_teams: bool): """Get teams from share objects with enterprise filtering.""" - share_objects = share_utils.get_share_objects(vault=context.vault) + share_objects = share_management_utils.get_share_objects(vault=context.vault) teams_data = share_objects.get('teams', {}) orgs = share_objects.get('enterprises', {}) diff --git a/keepercli-package/src/keepercli/helpers/share_record.py b/keepercli-package/src/keepercli/helpers/share_record.py deleted file mode 100644 index 4da213f2..00000000 --- a/keepercli-package/src/keepercli/helpers/share_record.py +++ /dev/null @@ -1,85 +0,0 @@ -from enum import Enum -from typing import Dict - -from keepersdk.vault import vault_online, vault_record - -from .. import api -from ..params import KeeperParams -logger = api.get_logger() - -TEXT_EDIT = 'Edit' -TEXT_SHARE = 'Share' - -class SharePermissions: - SharePermissionsType = Enum('SharePermissionsType', ['USER', 'SF_USER', 'TEAM', 'TEAM_USER']) - bits_text_lookup = {(1 << 0): TEXT_EDIT, (1 << 1): TEXT_SHARE} - - def __init__(self, sp_types=None, to_name='', permissions_text='', types=None): - self.to_uid = '' - self.to_name = to_name - self.can_edit = False - self.can_share = False - self.can_view = True - self.expiration = 0 - self.folder_path = '' - self.types = set() - self.bits = 0 - self.is_admin = False - self.team_members = dict() - self.user_perms: Dict[str, 'SharePermissions'] = {} - self.team_perms: Dict[str, 'SharePermissions'] = {} - self.permissions_text = permissions_text - - if types is not None: - if isinstance(types, list): - self.types.update(types) - else: - self.types.add(types) - - self.update_types(sp_types) - - def update_types(self, sp_types): - if sp_types is not None: - update_types_fn = self.types.update if isinstance(sp_types, set) else self.types.add - update_types_fn(sp_types) - - -class SharedRecord: - """Defines a Keeper Shared Record (shared either via Direct-Share or as a child of a Shared-Folder node)""" - - def __init__(self, context: KeeperParams, record: vault_record.KeeperRecordInfo, sf_sharing_admins=None, team_members=None, role_restricted_members=None): - """Initialize SharedRecord with proper error handling.""" - try: - self.context = context - self.record = record - self.uid = record.record_uid - - self.name = record.title - self.shared_folders = None - self.sf_shares = {} - self.permissions: Dict[str, SharePermissions] = {} - self.team_permissions: Dict[str, SharePermissions] = {} - self.user_permissions: Dict[str, SharePermissions] = {} - self.revision = None - self.folder_uids = [] - self.folder_paths = [] - - self._initialize_folder_info(context.vault) - self.team_members = team_members or {} - - if sf_sharing_admins is None: - sf_sharing_admins = {} - if role_restricted_members is None: - role_restricted_members = set() - - except Exception as e: - logger.error(f"Failed to initialize SharedRecord: {e}") - - def _initialize_folder_info(self, vault: vault_online.VaultOnline): - """Initialize folder information for the record.""" - try: - from keepersdk.vault import vault_utils - folders = vault_utils.get_folders_for_record(vault.vault_data, self.uid) - self.folder_uids = [f.folder_uid for f in folders] - except Exception as e: - logger.debug(f"Failed to initialize folder info: {e}") diff --git a/keepercli-package/src/keepercli/register_commands.py b/keepercli-package/src/keepercli/register_commands.py index 2264f19c..0d5e01b9 100644 --- a/keepercli-package/src/keepercli/register_commands.py +++ b/keepercli-package/src/keepercli/register_commands.py @@ -28,7 +28,7 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS if not scopes or bool(scopes & base.CommandScope.Vault): from .commands import (vault_folder, vault, vault_record, record_edit, importer_commands, breachwatch, - record_type, secrets_manager, share_management, password_report, trash, record_file_report, + record_type, secrets_manager, shares, password_report, trash, record_file_report, record_handling_commands, register, password_generate, verify_records) commands.register_command('sync-down', vault.SyncDownCommand(), base.CommandScope.Vault, 'd') @@ -72,11 +72,11 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS commands.register_command('secrets-manager-app', secrets_manager.SecretsManagerAppCommand(), base.CommandScope.Vault) commands.register_command('secrets-manager-client', secrets_manager.SecretsManagerClientCommand(), base.CommandScope.Vault) commands.register_command('secrets-manager-share', secrets_manager.SecretsManagerShareCommand(), base.CommandScope.Vault) - commands.register_command('share-record', share_management.ShareRecordCommand(), base.CommandScope.Vault, 'sr') - commands.register_command('share-folder', share_management.ShareFolderCommand(), base.CommandScope.Vault, 'sf') - commands.register_command('share-list', share_management.OneTimeShareListCommand(), base.CommandScope.Vault) - commands.register_command('share-create', share_management.OneTimeShareCreateCommand(), base.CommandScope.Vault) - commands.register_command('share-remove', share_management.OneTimeShareRemoveCommand(), base.CommandScope.Vault) + commands.register_command('share-record', shares.ShareRecordCommand(), base.CommandScope.Vault, 'sr') + commands.register_command('share-folder', shares.ShareFolderCommand(), base.CommandScope.Vault, 'sf') + commands.register_command('share-list', shares.OneTimeShareListCommand(), base.CommandScope.Vault) + commands.register_command('share-create', shares.OneTimeShareCreateCommand(), base.CommandScope.Vault) + commands.register_command('share-remove', shares.OneTimeShareRemoveCommand(), base.CommandScope.Vault) commands.register_command('record-permission', record_handling_commands.RecordPermissionCommand(), base.CommandScope.Vault) commands.register_command('trash', trash.TrashCommand(), base.CommandScope.Vault) commands.register_command('verify-shared-folders', verify_records.VerifySharedFoldersCommand(), base.CommandScope.Vault) diff --git a/keepercli-package/src/keepercli/helpers/share_utils.py b/keepersdk-package/src/keepersdk/vault/share_management_utils.py similarity index 51% rename from keepercli-package/src/keepercli/helpers/share_utils.py rename to keepersdk-package/src/keepersdk/vault/share_management_utils.py index 37baed7b..29208f9d 100644 --- a/keepercli-package/src/keepercli/helpers/share_utils.py +++ b/keepersdk-package/src/keepersdk/vault/share_management_utils.py @@ -1,15 +1,17 @@ import datetime import itertools -from typing import Optional, Dict, List, Any, Generator, Iterable, Set +import logging +from re import findall +from typing import Optional, Dict, List, Any, Generator, Iterable, Set, Tuple -from keepersdk import crypto, utils -from keepersdk.proto import enterprise_pb2, record_pb2 -from keepersdk.vault import vault_online, vault_record, vault_utils +from .. import crypto, utils +from ..proto import enterprise_pb2, record_pb2 +from ..vault import vault_online, vault_record, vault_types, vault_utils +from ..enterprise import enterprise_data -from .. import api -from ..commands import enterprise_utils -from ..helpers import timeout_utils, folder_utils -from ..params import KeeperParams + +TIMEOUT_DEFAULT_UNIT = 'minutes' +TIMEOUT_ALLOWED_UNITS = ('days', 'hours', 'minutes') # API Endpoints RECORD_DETAILS_URL = 'vault/get_records_details' @@ -143,7 +145,7 @@ STATUS_GENERATED = 'Generated' -logger = api.get_logger() +logger = logging.getLogger() class ShareManagementError(Exception): @@ -162,19 +164,7 @@ class ShareNotFoundError(ShareManagementError): def get_share_expiration(expire_at: Optional[str], expire_in: Optional[str]) -> int: - """ - Calculate share expiration timestamp from expire_at or expire_in parameters. - - Args: - expire_at: ISO datetime string or 'never' - expire_in: Time period string or 'never' - - Returns: - Unix timestamp for expiration - - Raises: - ShareValidationError: If expiration format is invalid - """ + if not expire_at and not expire_in: return DEFAULT_EXPIRATION @@ -187,7 +177,7 @@ def get_share_expiration(expire_at: Optional[str], expire_in: Optional[str]) -> elif isinstance(expire_in, str): if expire_in == NEVER_EXPIRES_STRING: return NEVER_EXPIRES - td = timeout_utils.parse_timeout(expire_in) + td = parse_timeout(expire_in) dt = datetime.datetime.now() + td if dt is None: @@ -201,15 +191,6 @@ def get_share_expiration(expire_at: Optional[str], expire_in: Optional[str]) -> def get_share_objects(vault: vault_online.VaultOnline) -> Dict[str, Dict[str, Any]]: - """ - Retrieve share objects (users, enterprises, teams) from the vault. - - Args: - vault: VaultOnline instance - - Returns: - Dictionary containing users, enterprises, and teams - """ try: request = record_pb2.GetShareObjectsRequest() @@ -279,18 +260,6 @@ def load_records_in_shared_folder( shared_folder_uid: str, record_uids: Optional[set[str]] = None ) -> None: - """ - Load records from a shared folder into the vault. - - Args: - vault: VaultOnline instance - shared_folder_uid: UID of the shared folder - record_uids: Optional set of specific record UIDs to load - - Raises: - ShareNotFoundError: If shared folder is not found - ShareManagementError: If loading fails - """ try: shared_folder = _find_shared_folder(vault, shared_folder_uid) if not shared_folder: @@ -298,15 +267,11 @@ def load_records_in_shared_folder( shared_folder_key = vault.vault_data._shared_folders[shared_folder_uid].shared_folder_key record_keys = _decrypt_record_keys(vault, shared_folder, shared_folder_key) - - record_cache = [x.record_uid for x in vault.vault_data.records()] - - if record_uids: - record_set = set(record_uids) - record_set.intersection_update(record_keys.keys()) - else: - record_set = set(record_keys.keys()) - record_set.difference_update(record_cache) + + record_cache = {x.record_uid for x in vault.vault_data.records()} + + candidates = record_uids or record_keys.keys() + record_set = {uid for uid in candidates if uid in record_keys and uid not in record_cache} _load_records_in_batches(vault, record_set, record_keys) @@ -324,23 +289,36 @@ def _find_shared_folder(vault: vault_online.VaultOnline, shared_folder_uid: str) return None -def _decrypt_record_keys(vault: vault_online.VaultOnline, shared_folder, shared_folder_key): - """Decrypt record keys for shared folder.""" +def _decode_record_key(record_key_attr) -> bytes: + if isinstance(record_key_attr, bytes): + return utils.base64_url_decode(str(record_key_attr, 'utf-8')) + else: + return utils.base64_url_decode(str(record_key_attr)) + + +def _decrypt_single_record_key(key: bytes, shared_folder_key: bytes) -> bytes: + if len(key) == RECORD_KEY_LENGTH_V2: + return crypto.decrypt_aes_v2(key, shared_folder_key) + else: + return crypto.decrypt_aes_v1(key, shared_folder_key) + + +def _decrypt_record_keys(vault: vault_online.VaultOnline, shared_folder, shared_folder_key: bytes) -> Dict[str, bytes]: + record_keys = {} - sf_record_keys = vault.vault_data.storage.record_keys.get_links_by_object(shared_folder.shared_folder_uid) or [] + sf_record_keys = vault.vault_data.storage.record_keys.get_links_by_object( + shared_folder.shared_folder_uid + ) or [] - for rk in sf_record_keys: - record_uid = getattr(rk, 'record_uid', None) + for record_key_link in sf_record_keys: + record_uid = getattr(record_key_link, 'record_uid', None) + if not record_uid: + continue + try: - key = utils.base64_url_decode( - str(getattr(rk, 'record_key', b''), 'utf-8') - if isinstance(getattr(rk, 'record_key', b''), bytes) - else getattr(rk, 'record_key', '') - ) - if len(key) == RECORD_KEY_LENGTH_V2: - record_key = crypto.decrypt_aes_v2(key, shared_folder_key) - else: - record_key = crypto.decrypt_aes_v1(key, shared_folder_key) + record_key_attr = getattr(record_key_link, 'record_key', b'') + key = _decode_record_key(record_key_attr) + record_key = _decrypt_single_record_key(key, shared_folder_key) record_keys[record_uid] = record_key except Exception as e: logger.error(f'Cannot decrypt record "{record_uid}" key: {e}') @@ -348,23 +326,30 @@ def _decrypt_record_keys(vault: vault_online.VaultOnline, shared_folder, shared_ return record_keys +def _build_record_details_request(record_uids: set) -> record_pb2.GetRecordDataWithAccessInfoRequest: + + request = record_pb2.GetRecordDataWithAccessInfoRequest() + request.clientTime = utils.current_milli_time() + request.recordDetailsInclude = record_pb2.DATA_PLUS_SHARE + + for uid in record_uids: + try: + request.recordUid.append(utils.base64_url_decode(uid)) + except Exception as e: + logger.debug('Incorrect record UID "%s": %s', uid, e) + + return request + + def _load_records_in_batches(vault: vault_online.VaultOnline, record_set: set, record_keys: dict): - """Load records in batches to avoid API limits.""" - while len(record_set) > 0: - rq = record_pb2.GetRecordDataWithAccessInfoRequest() - rq.clientTime = utils.current_milli_time() - rq.recordDetailsInclude = record_pb2.DATA_PLUS_SHARE - - for uid in record_set: - try: - rq.recordUid.append(utils.base64_url_decode(uid)) - except Exception as e: - logger.debug('Incorrect record UID "%s": %s', uid, e) + + while record_set: + request = _build_record_details_request(record_set) record_set.clear() response = vault.keeper_auth.execute_auth_rest( rest_endpoint=RECORD_DETAILS_URL, - request=rq, + request=request, response_type=record_pb2.GetRecordDataWithAccessInfoResponse ) @@ -375,17 +360,25 @@ def _load_records_in_batches(vault: vault_online.VaultOnline, record_set: set, r _process_record_batch(vault, response, record_keys, record_set) +def _process_record_owner_key(record_data, record_uid: str, record_keys: dict): + + if record_data.recordUid and record_data.recordKey: + owner_id = utils.base64_url_encode(record_data.recordUid) + if owner_id in record_keys: + record_keys[record_uid] = crypto.decrypt_aes_v2( + record_data.recordKey, + record_keys[owner_id] + ) + + def _process_record_batch(vault: vault_online.VaultOnline, response, record_keys: dict, record_set: set): - """Process a batch of records from API response.""" + for record_info in response.recordDataWithAccessInfo: record_uid = utils.base64_url_encode(record_info.recordUid) record_data = record_info.recordData try: - if record_data.recordUid and record_data.recordKey: - owner_id = utils.base64_url_encode(record_data.recordUid) - if owner_id in record_keys: - record_keys[record_uid] = crypto.decrypt_aes_v2(record_data.recordKey, record_keys[owner_id]) + _process_record_owner_key(record_data, record_uid, record_keys) if record_uid not in record_keys: continue @@ -415,37 +408,62 @@ def _create_record_dict(record_uid: str, record_data, record_key: bytes, version } -def _handle_record_versions(vault: vault_online.VaultOnline, record: dict, record_data, version: int, record_set: set): - """Handle different record versions and their specific features.""" +def _decrypt_record_data(record_data, record_key: bytes, version: int) -> bytes: + data_decoded = utils.base64_url_decode(record_data.encryptedRecordData) - record_key = record['record_key_unencrypted'] if version <= MAX_V2_VERSION: - record['data_unencrypted'] = crypto.decrypt_aes_v1(data_decoded, record_key) + return crypto.decrypt_aes_v1(data_decoded, record_key) else: - record['data_unencrypted'] = crypto.decrypt_aes_v2(data_decoded, record_key) + return crypto.decrypt_aes_v2(data_decoded, record_key) + + +def _process_v2_extra_data(record: dict, record_data, record_key: bytes): - if record_data.encryptedExtraData and version <= MAX_V2_VERSION: + if record_data.encryptedExtraData: record['extra'] = record_data.encryptedExtraData extra_decoded = utils.base64_url_decode(record_data.encryptedExtraData) record['extra_unencrypted'] = crypto.decrypt_aes_v1(extra_decoded, record_key) + + +def _process_v3_record_references(vault: vault_online.VaultOnline, record: dict, record_set: set): + + v3_record = vault.vault_data.load_record(record_uid=record['record_uid']) + if isinstance(v3_record, vault_record.TypedRecord): + for ref in itertools.chain(v3_record.fields, v3_record.custom): + if ref.type.endswith('Ref') and isinstance(ref.value, list): + record_set.update(ref.value) + + +def _process_v4_record_metadata(record: dict, record_data): + + if record_data.fileSize > 0: + record['file_size'] = record_data.fileSize + if record_data.thumbnailSize > 0: + record['thumbnail_size'] = record_data.thumbnailSize + + +def _process_record_owner_info(record: dict, record_data): + + if record_data.recordUid and record_data.recordKey: + record['owner_uid'] = utils.base64_url_encode(record_data.recordUid) + record['link_key'] = utils.base64_url_encode(record_data.recordKey) + + +def _handle_record_versions(vault: vault_online.VaultOnline, record: dict, record_data, version: int, record_set: set): + + record_key = record['record_key_unencrypted'] + record['data_unencrypted'] = _decrypt_record_data(record_data, record_key, version) + + if version <= MAX_V2_VERSION: + _process_v2_extra_data(record, record_data, record_key) if version == V3_VERSION: - v3_record = vault.vault_data.load_record(record_uid=record['record_uid']) - if isinstance(v3_record, vault_record.TypedRecord): - for ref in itertools.chain(v3_record.fields, v3_record.custom): - if ref.type.endswith('Ref') and isinstance(ref.value, list): - record_set.update(ref.value) - + _process_v3_record_references(vault, record, record_set) elif version == V4_VERSION: - if record_data.fileSize > 0: - record['file_size'] = record_data.fileSize - if record_data.thumbnailSize > 0: - record['thumbnail_size'] = record_data.thumbnailSize + _process_v4_record_metadata(record, record_data) - if record_data.recordUid and record_data.recordKey: - record['owner_uid'] = utils.base64_url_encode(record_data.recordUid) - record['link_key'] = utils.base64_url_encode(record_data.recordKey) + _process_record_owner_info(record, record_data) def _add_share_permissions(record: dict, record_info): @@ -475,17 +493,7 @@ def get_record_shares( record_uids: List[str], is_share_admin: bool = False ) -> Optional[List[Dict[str, Any]]]: - """ - Get share information for records. - - Args: - vault: VaultOnline instance - record_uids: List of record UIDs - is_share_admin: Whether user is share admin - - Returns: - List of record share information or None - """ + try: record_cache = {x.record_uid: x for x in vault.vault_data.records()} @@ -500,8 +508,7 @@ def get_record_shares( return _fetch_record_shares_batch(vault, uids_needing_info) except Exception as e: - logger.error(f"Error fetching record shares: {e}") - return None + raise ValueError(f"Error fetching record shares: {e}") def _needs_share_info(uid: str, record_cache: dict, is_share_admin: bool) -> bool: @@ -589,16 +596,17 @@ def _process_shared_folder_permissions(info) -> List[Dict[str, Any]]: return shared_folder_permissions -def resolve_record_share_path(context: KeeperParams, record_uid: str) -> Optional[Dict[str, str]]: - return resolve_record_permission_path(context=context, record_uid=record_uid, permission=CAN_SHARE_PERMISSION) +def resolve_record_share_path(vault: vault_online.VaultOnline, enterprise: enterprise_data.EnterpriseData, record_uid: str) -> Optional[Dict[str, str]]: + return resolve_record_permission_path(vault=vault, enterprise=enterprise, record_uid=record_uid, permission=CAN_SHARE_PERMISSION) def resolve_record_permission_path( - context: KeeperParams, + vault: vault_online.VaultOnline, + enterprise: enterprise_data.EnterpriseData, record_uid: str, permission: str ) -> Optional[Dict[str, str]]: - for ap in enumerate_record_access_paths(context=context, record_uid=record_uid): + for ap in enumerate_record_access_paths(vault=vault, enterprise=enterprise, record_uid=record_uid): if ap.get(permission): path = { RECORD_UID_FIELD: record_uid @@ -612,271 +620,437 @@ def resolve_record_permission_path( return None -def enumerate_record_access_paths( - context: KeeperParams, - record_uid: str +def _create_access_path( + record_uid: str, + shared_folder_uid: str, + can_edit: bool, + can_share: bool, + team_uid: Optional[str] = None +) -> Dict[str, Any]: + + path = { + RECORD_UID_FIELD: record_uid, + SHARED_FOLDER_UID_FIELD: shared_folder_uid, + CAN_EDIT_FIELD: can_edit, + CAN_SHARE_FIELD: can_share, + CAN_VIEW_FIELD: True + } + if team_uid: + path[TEAM_UID_FIELD] = team_uid + return path + + +def _process_team_permissions_for_shared_folder( + shared_folder: Any, + record_uid: str, + enterprise: enterprise_data.EnterpriseData, + base_can_edit: bool, + base_can_share: bool ) -> Generator[Dict[str, Any], None, None]: - - def create_access_path( - shared_folder_uid: str, - can_edit: bool, - can_share: bool, - team_uid: Optional[str] = None - ) -> Dict[str, Any]: - path = { - RECORD_UID_FIELD: record_uid, - SHARED_FOLDER_UID_FIELD: shared_folder_uid, - CAN_EDIT_FIELD: can_edit, - CAN_SHARE_FIELD: can_share, - CAN_VIEW_FIELD: True - } - if team_uid: - path[TEAM_UID_FIELD] = team_uid - return path - - def process_team_permissions( - shared_folder: Any, - base_can_edit: bool, - base_can_share: bool - ) -> Generator[Dict[str, Any], None, None]: - if not context.enterprise_data: - return + + for user_permission in shared_folder.user_permissions: + if user_permission.user_type != TEAM_USER_TYPE: + continue - for user_permission in shared_folder.user_permissions: - if user_permission.user_type != TEAM_USER_TYPE: - continue - - team_uid = user_permission.user_uid - team = enterprise_utils.TeamUtils.resolve_single_team( - context.enterprise_data, team_uid + team_uid = user_permission.user_uid + team = enterprise.teams.get_entity(team_uid) + + if team: + yield _create_access_path( + record_uid=record_uid, + shared_folder_uid=shared_folder.shared_folder_uid, + can_edit=base_can_edit and not team.restrict_edit, + can_share=base_can_share and not team.restrict_share, + team_uid=team_uid ) - - if team: - yield create_access_path( - shared_folder_uid=shared_folder.shared_folder_uid, - can_edit=base_can_edit and not team.restrict_edit, - can_share=base_can_share and not team.restrict_share, - team_uid=team_uid - ) - - for shared_folder_info in context.vault.vault_data.shared_folders(): + + +def enumerate_record_access_paths( + vault: vault_online.VaultOnline, + enterprise: enterprise_data.EnterpriseData, + record_uid: str +) -> Generator[Dict[str, Any], None, None]: + + record = vault.vault_data.get_record(record_uid) + is_owner = record.flags == vault_record.RecordFlags.IsOwner + + for shared_folder_info in vault.vault_data.shared_folders(): shared_folder_uid = shared_folder_info.shared_folder_uid - - shared_folder = context.vault.vault_data.load_shared_folder( + shared_folder = vault.vault_data.load_shared_folder( shared_folder_uid=shared_folder_uid ) - - is_owner = context.vault.vault_data.get_record(record_uid).flags == vault_record.RecordFlags.IsOwner can_edit, can_share = is_owner, is_owner if hasattr(shared_folder, 'key_type'): - yield create_access_path( + yield _create_access_path( + record_uid=record_uid, shared_folder_uid=shared_folder_uid, can_edit=can_edit, can_share=can_share ) else: - yield from process_team_permissions(shared_folder, can_edit, can_share) + yield from _process_team_permissions_for_shared_folder( + shared_folder, record_uid, enterprise, can_edit, can_share + ) -def get_shared_records(context: KeeperParams, record_uids, cache_only=False): - """ - Get shared record information for the specified record UIDs. - - Args: - context: KeeperParams instance containing vault and enterprise data - record_uids: Collection of record UIDs to process - cache_only: If True, only use cached data without making API calls - - Returns: - Dict mapping record UIDs to SharedRecord instances - """ +def _fetch_team_members_from_api(vault: vault_online.VaultOnline, team_uids: Set[str]) -> Dict[str, Set[str]]: + + members = {} - def _fetch_team_members_from_api(team_uids: Set[str]) -> Dict[str, Set[str]]: - members = {} + if not vault.keeper_auth.auth_context.enterprise_ec_public_key: + return members - if not context.vault.keeper_auth.auth_context.enterprise_ec_public_key: - return members + for team_uid in team_uids: + try: + request = enterprise_pb2.GetTeamMemberRequest() + request.teamUid = utils.base64_url_decode(team_uid) - for team_uid in team_uids: - try: - request = enterprise_pb2.GetTeamMemberRequest() - request.teamUid = utils.base64_url_decode(team_uid) - - response = context.vault.keeper_auth.execute_auth_rest( - rest_endpoint=TEAM_MEMBERS_ENDPOINT, - request=request, - response_type=enterprise_pb2.GetTeamMemberResponse - ) - - if response and response.enterpriseUser: - team_members = {user.email for user in response.enterpriseUser} - members[team_uid] = team_members - - except Exception as e: - logger.debug(f"Failed to fetch team members for {team_uid}: {e}") + response = vault.keeper_auth.execute_auth_rest( + rest_endpoint=TEAM_MEMBERS_ENDPOINT, + request=request, + response_type=enterprise_pb2.GetTeamMemberResponse + ) + + if response and response.enterpriseUser: + team_members = {user.email for user in response.enterpriseUser} + members[team_uid] = team_members - return members + except Exception as e: + logger.debug(f"Failed to fetch team members for {team_uid}: {e}") + + return members - def _get_cached_team_members(team_uids: Set[str], username_lookup: Dict[str, str]) -> Dict[str, Set[str]]: - members = {} - - if not context.enterprise_data: - return members - team_user_links = context.enterprise_data.team_users.get_all_links() or [] - - relevant_team_users = [ - link for link in team_user_links - if link.user_type != 2 and link.team_uid in team_uids - ] +def _get_cached_team_members(enterprise: enterprise_data.EnterpriseData, team_uids: Set[str], username_lookup: Dict[str, str]) -> Dict[str, Set[str]]: - for team_user in relevant_team_users: - username = username_lookup.get(team_user.enterprise_user_id) - if username: - team_uid = team_user.team_uid - if team_uid not in members: - members[team_uid] = set() - members[team_uid].add(username) + members = {} + team_user_links = enterprise.team_users.get_all_links() or [] + + relevant_team_users = [ + link for link in team_user_links + if link.user_type != USER_TYPE_INACTIVE and link.team_uid in team_uids + ] - return members + for team_user in relevant_team_users: + username = username_lookup.get(team_user.enterprise_user_id.__str__()) + if username: + team_uid = team_user.team_uid + if team_uid not in members: + members[team_uid] = set() + members[team_uid].add(username) - def _fetch_shared_folder_admins() -> Dict[str, List[str]]: - sf_uids = list(context.vault.vault_data._shared_folders.keys()) - return { - sf_uid: get_share_admins_for_shared_folder(context.vault, sf_uid) or [] - for sf_uid in sf_uids - } + return members - def _get_restricted_role_members(username_lookup: Dict[str, str]) -> Set[str]: - if not context.enterprise_data: - return set() - role_enforcements = context.enterprise_data.role_enforcements.get_all_links() - restricted_roles = { - re.role_id for re in role_enforcements - if re.enforcement_type == 'enforcements' and re.value == 'restrict_sharing_all' - } +def _fetch_all_shared_folder_admins(vault: vault_online.VaultOnline) -> Dict[str, List[str]]: + sf_uids = list(vault.vault_data._shared_folders.keys()) + return { + sf_uid: get_share_admins_for_shared_folder(vault, sf_uid) or [] + for sf_uid in sf_uids + } - if not restricted_roles: - return set() - restricted_users = context.enterprise_data.role_users.get_links_by_object(restricted_roles) - restricted_teams = context.enterprise_data.role_teams.get_links_by_object(restricted_roles) +def _get_restricted_role_members(enterprise: enterprise_data.EnterpriseData, username_lookup: Dict[str, str]) -> Set[str]: - restricted_members = set() - - for user_link in restricted_users: - username = username_lookup.get(user_link.enterprise_user_id) - if username: - restricted_members.add(username) + role_enforcements = enterprise.role_enforcements.get_all_links() + restricted_roles = { + re.role_id for re in role_enforcements + if re.enforcement_type == KEY_ENFORCEMENTS and re.value == KEY_RESTRICT_SHARING_ALL + } - team_uids = {team_link.team_uid for team_link in restricted_teams} - if team_uids: - team_members = _get_cached_team_members(team_uids, username_lookup) - for members in team_members.values(): - restricted_members.update(members) + if not restricted_roles: + return set() - return restricted_members + restricted_users = enterprise.role_users.get_links_by_object(restricted_roles) + restricted_teams = enterprise.role_teams.get_links_by_object(restricted_roles) - try: - vault = context.vault - shares = get_record_shares(vault, record_uids) - - sf_teams = [share.get('teams', []) for share in shares] if shares else [] - team_uids = { - team.get('team_uid') - for teams in sf_teams - for team in teams - if team.get('team_uid') - } + restricted_members = set() + + for user_link in restricted_users: + username = username_lookup.get(user_link.enterprise_user_id) + if username: + restricted_members.add(username) + + team_uids = {team_link.team_uid for team_link in restricted_teams} + if team_uids: + team_members = _get_cached_team_members(enterprise, team_uids, username_lookup) + for members in team_members.values(): + restricted_members.update(members) + + return restricted_members + + +def _extract_team_uids_from_shares(shares: Optional[List[Dict[str, Any]]]) -> Set[str]: + if not shares: + return set() + + sf_teams = [share.get('teams', []) for share in shares] + return { + team.get('team_uid') + for teams in sf_teams + for team in teams + if team.get('team_uid') + } - enterprise_users = context.enterprise_data.users.get_all_entities() if context.enterprise_data else [] - username_lookup = {user.enterprise_user_id: user.username for user in enterprise_users} - sf_share_admins = _fetch_shared_folder_admins() if not cache_only else {} +def _build_username_lookup(enterprise: enterprise_data.EnterpriseData) -> dict[int, str] | dict[Any, Any]: + if not enterprise: + return {} + + enterprise_users = enterprise.users.get_all_entities() + return {user.enterprise_user_id: user.username for user in enterprise_users} - restricted_role_members = _get_restricted_role_members(username_lookup) - if cache_only or context.enterprise_data: - team_members = _get_cached_team_members(team_uids, username_lookup) +def get_shared_records(vault: vault_online.VaultOnline, enterprise: enterprise_data.EnterpriseData, record_uids, cache_only=False): + try: + shares = get_record_shares(vault, record_uids) + team_uids = _extract_team_uids_from_shares(shares) + username_lookup = _build_username_lookup(enterprise) + + sf_share_admins = _fetch_all_shared_folder_admins(vault) if not cache_only else {} + restricted_role_members = _get_restricted_role_members(enterprise, username_lookup) + + if cache_only or enterprise: + team_members = _get_cached_team_members(enterprise, team_uids, username_lookup) else: - team_members = _fetch_team_members_from_api(team_uids) + team_members = _fetch_team_members_from_api(vault, team_uids) records = [vault.vault_data.get_record(uid) for uid in record_uids] valid_records = [record for record in records if record is not None] - from .share_record import SharedRecord + from .shared_record import SharedRecord shared_records = [ - SharedRecord(context, record, sf_share_admins, team_members, restricted_role_members) + SharedRecord(vault, record, sf_share_admins, team_members, restricted_role_members) for record in valid_records ] return {shared_record.uid: shared_record for shared_record in shared_records} except Exception as e: - logger.error(f"Error in get_shared_records: {e}") - return {} + raise ValueError(f"Error in get_shared_records: {e}") -def get_share_admins_for_shared_folder(vault: vault_online.VaultOnline, shared_folder_uid): - if vault.keeper_auth.auth_context.enterprise_ec_public_key: - try: - rq = enterprise_pb2.GetSharingAdminsRequest() - rq.sharedFolderUid = utils.base64_url_decode(shared_folder_uid) - rs = vault.keeper_auth.execute_auth_rest( - rest_endpoint=SHARING_ADMINS_ENDPOINT, - request=rq, - response_type=enterprise_pb2.GetSharingAdminsResponse - ) - admins = [x.email for x in rs.userProfileExts if x.isShareAdminForSharedFolderOwner and x.isInSharedFolder] - except Exception as e: - logger.debug(e) - return +def get_share_admins_for_shared_folder(vault: vault_online.VaultOnline, shared_folder_uid: str) -> Optional[List[str]]: + + if not vault.keeper_auth.auth_context.enterprise_ec_public_key: + return None + + try: + request = enterprise_pb2.GetSharingAdminsRequest() + request.sharedFolderUid = utils.base64_url_decode(shared_folder_uid) + + response = vault.keeper_auth.execute_auth_rest( + rest_endpoint=SHARING_ADMINS_ENDPOINT, + request=request, + response_type=enterprise_pb2.GetSharingAdminsResponse + ) + + admins = [ + x.email for x in response.userProfileExts + if x.isShareAdminForSharedFolderOwner and x.isInSharedFolder + ] return admins + except Exception as e: + logger.debug(f"Failed to get share admins for shared folder {shared_folder_uid}: {e}") + return None + + +def _find_folders_by_name(vault: vault_online.VaultOnline, name: str) -> Set[str]: + folder_uids = set() + for folder in vault.vault_data.folders(): + if folder.name == name: + folder_uids.add(folder.folder_uid) + return folder_uids + + +def _resolve_folder_by_path(vault: vault_online.VaultOnline, name: str) -> Optional[str]: + try: + folder, _ = try_resolve_path(vault, name) + if folder: + return folder.folder_uid + except Exception: + pass + return None + +def get_folder_uids(vault: vault_online.VaultOnline, name: str) -> Set[str]: -def get_folder_uids(context: KeeperParams, name: str) -> set[str]: folder_uids = set() - if name in context.vault.vault_data._folders: + if name in vault.vault_data._folders: folder_uids.add(name) return folder_uids - for folder in context.vault.vault_data.folders(): - if folder.name == name: - folder_uids.add(folder.folder_uid) + folder_uids = _find_folders_by_name(vault, name) if not folder_uids: - try: - folder, _ = folder_utils.try_resolve_path(context, name) - if folder: - folder_uids.add(folder.folder_uid) - except: - pass + resolved_uid = _resolve_folder_by_path(vault, name) + if resolved_uid: + folder_uids.add(resolved_uid) return folder_uids -def get_contained_record_uids(context: KeeperParams, name: str, children_only: bool = True) -> Dict[str, Set[str]]: - records_by_folder = dict() - root_folder_uids = get_folder_uids(context, name) - vault = context.vault +def _add_folder_records(vault: vault_online.VaultOnline, folder_uid: str, records_by_folder: Dict[str, Set[str]]): + folder = vault.vault_data.get_folder(folder_uid) + if folder: + records_by_folder[folder_uid] = folder.records + + +def _create_folder_traversal_callback( + vault: vault_online.VaultOnline, + root_folder_uids: Set[str], + children_only: bool, + records_by_folder: Dict[str, Set[str]] +): + def on_folder(folder): + folder_uid = folder.folder_uid or '' + if not children_only or folder_uid in root_folder_uids: + _add_folder_records(vault, folder_uid, records_by_folder) + + return on_folder + - def add_child_recs(f_uid): - folder = vault.vault_data.get_folder(f_uid) - child_recs = folder.records - records_by_folder.update({f_uid: child_recs}) +def get_contained_record_uids(vault: vault_online.VaultOnline, name: str, children_only: bool = True) -> Dict[str, Set[str]]: - def on_folder(f): - f_uid = f.folder_uid or '' - if not children_only or f_uid in root_folder_uids: - add_child_recs(f_uid) + records_by_folder = {} + root_folder_uids = get_folder_uids(vault, name) + on_folder = _create_folder_traversal_callback(vault, root_folder_uids, children_only, records_by_folder) for uid in root_folder_uids: folder = vault.vault_data.get_folder(uid) - vault_utils.traverse_folder_tree(vault.vault_data, folder, on_folder) + if folder: + vault_utils.traverse_folder_tree(vault.vault_data, folder, on_folder) return records_by_folder + + +def _normalize_path_input(path: str) -> str: + if not isinstance(path, str): + return '' + return path + + +def _handle_root_path(path: str, folder: Optional[vault_types.Folder], vault: vault_online.VaultOnline) -> Tuple[Optional[vault_types.Folder], str]: + + if path.startswith('/') and not path.startswith('//'): + folder = vault.vault_data.root_folder + path = path[1:] + + if folder is None: + folder = vault.vault_data.root_folder + + return folder, path + + +def _split_path_components(path: str) -> List[str]: + return [s.replace('\0', '/') for s in path.replace('//', '\0').split('/')] + + +def _handle_parent_directory(folder: vault_types.Folder, vault: vault_online.VaultOnline) -> vault_types.Folder: + + parent_uid = folder.parent_uid + if parent_uid: + parent_folder = vault.vault_data.get_folder(parent_uid) + if parent_folder: + return parent_folder + return vault.vault_data.root_folder + + +def _find_subfolder_by_name(folder: vault_types.Folder, component: str, vault: vault_online.VaultOnline) -> Optional[vault_types.Folder]: + + if component in folder.subfolders: + subfolder = vault.vault_data.get_folder(component) + if subfolder: + return subfolder + + folders = [f for f in (vault.vault_data.get_folder(x) for x in folder.subfolders) if f] + + exact_match = next((x for x in folders if x.name.strip() == component), None) + if exact_match: + return exact_match + + case_insensitive_match = next( + (x for x in folders if x.name.strip().casefold() == component.casefold()), + None + ) + return case_insensitive_match + + +def _traverse_path_components( + folder: vault_types.Folder, + components: List[str], + vault: vault_online.VaultOnline +) -> Tuple[vault_types.Folder, List[str]]: + + remaining_components = [] + + for component in components: + component = component.strip() + + if component == '..': + folder = _handle_parent_directory(folder, vault) + elif component in ('', '.'): + continue + else: + subfolder = _find_subfolder_by_name(folder, component, vault) + if subfolder: + folder = subfolder + else: + remaining_components.append(component) + break + + return folder, remaining_components + + +def _reconstruct_remaining_path(components: List[str]) -> str: + return '/'.join(component.replace('/', '//') for component in components) + + +def try_resolve_path(vault: vault_online.VaultOnline, path: str) -> Tuple[vault_types.Folder, str]: + + path = _normalize_path_input(path) + + folder = vault.vault_data.get_folder(path) + if folder is not None: + return folder, '' + + folder, path = _handle_root_path(path, None, vault) + components = _split_path_components(path) + + folder, remaining_components = _traverse_path_components(folder, components, vault) + remaining_path = _reconstruct_remaining_path(remaining_components) + + return folder, remaining_path + + +def _parse_timeout_units(timeout_input: str) -> Dict[str, int]: + + tdelta_kwargs = {} + for value, input_unit in findall(r'(\d+)\s*([a-zA-Z]+)\s*', timeout_input): + matching_units = [unit for unit in TIMEOUT_ALLOWED_UNITS if unit.startswith(input_unit)] + + if not matching_units: + raise ValueError( + f'{input_unit} is not allowed as a unit for the timeout value. ' + f'Valid units for the timeout value are {TIMEOUT_ALLOWED_UNITS}.' + ) + + unit_key = matching_units[0] + tdelta_kwargs[unit_key] = int(value) + + return tdelta_kwargs + + +def parse_timeout(timeout_input: str) -> datetime.timedelta: + + timeout_input = timeout_input.strip() + + if timeout_input.isnumeric(): + return datetime.timedelta(**{TIMEOUT_DEFAULT_UNIT: int(timeout_input)}) + + tdelta_kwargs = _parse_timeout_units(timeout_input) + return datetime.timedelta(**tdelta_kwargs) \ No newline at end of file diff --git a/keepersdk-package/src/keepersdk/vault/shared_record.py b/keepersdk-package/src/keepersdk/vault/shared_record.py new file mode 100644 index 00000000..64013d94 --- /dev/null +++ b/keepersdk-package/src/keepersdk/vault/shared_record.py @@ -0,0 +1,118 @@ +from enum import Enum +from typing import Dict, Optional, Set, Union + +from . import vault_online, vault_record, vault_utils + + +# Constants +TEXT_EDIT = 'Edit' +TEXT_SHARE = 'Share' +BIT_MASK_EDIT = 1 << 0 +BIT_MASK_SHARE = 1 << 1 +BITS_TEXT_LOOKUP = {BIT_MASK_EDIT: TEXT_EDIT, BIT_MASK_SHARE: TEXT_SHARE} +SHARE_PERMISSIONS_TYPE = Enum('SharePermissionsType', ['USER', 'SF_USER', 'TEAM', 'TEAM_USER']) + + +class SharePermissions: + """Manages share permissions for records, including edit, share, and view capabilities.""" + + SharePermissionsType = SHARE_PERMISSIONS_TYPE + bits_text_lookup = BITS_TEXT_LOOKUP + + def __init__(self, sp_types=None, to_name='', permissions_text='', types=None): + """Initialize SharePermissions with default values and process provided types.""" + self._initialize_default_attributes(to_name, permissions_text) + self._process_initial_types(types) + self._process_share_permission_types(sp_types) + + def _initialize_default_attributes(self, to_name: str, permissions_text: str) -> None: + """Initialize all attributes with default values.""" + self.to_uid = '' + self.to_name = to_name + self.can_edit = False + self.can_share = False + self.can_view = True + self.expiration = 0 + self.folder_path = '' + self.types: Set = set() + self.bits = 0 + self.is_admin = False + self.team_members: Dict = {} + self.user_perms: Dict[str, 'SharePermissions'] = {} + self.team_perms: Dict[str, 'SharePermissions'] = {} + self.permissions_text = permissions_text + + def _process_initial_types(self, types: Optional[Union[list, object]]) -> None: + """Process and add initial types to the types set.""" + if types is None: + return + + if isinstance(types, list): + self.types.update(types) + else: + self.types.add(types) + + def _process_share_permission_types(self, sp_types: Optional[Union[Set, object]]) -> None: + """Process and add share permission types to the types set.""" + if sp_types is None: + return + + if isinstance(sp_types, set): + self.types.update(sp_types) + else: + self.types.add(sp_types) + + def update_types(self, sp_types: Optional[Union[Set, object]]) -> None: + """Update the types set with new share permission types.""" + self._process_share_permission_types(sp_types) + + +class SharedRecord: + """Defines a Keeper Shared Record (shared either via Direct-Share or as a child of a Shared-Folder node)""" + + def __init__( + self, + vault: vault_online.VaultOnline, + record: vault_record.KeeperRecordInfo, + sf_sharing_admins: Optional[Dict] = None, + team_members: Optional[Dict] = None, + role_restricted_members: Optional[Set] = None + ): + """Initialize SharedRecord with record information and sharing data.""" + self._initialize_record_attributes(record) + self._initialize_sharing_attributes() + self._initialize_folder_info(vault) + self._initialize_sharing_data(sf_sharing_admins, team_members, role_restricted_members) + + def _initialize_record_attributes(self, record: vault_record.KeeperRecordInfo) -> None: + """Initialize attributes from the record object.""" + self.record = record + self.uid = record.record_uid + self.name = record.title + + def _initialize_sharing_attributes(self) -> None: + """Initialize sharing-related attributes with default values.""" + self.shared_folders = None + self.sf_shares: Dict = {} + self.permissions: Dict[str, SharePermissions] = {} + self.team_permissions: Dict[str, SharePermissions] = {} + self.user_permissions: Dict[str, SharePermissions] = {} + self.revision = None + self.folder_uids: list = [] + self.folder_paths: list = [] + + def _initialize_folder_info(self, vault: vault_online.VaultOnline) -> None: + """Initialize folder information for the record.""" + folders = vault_utils.get_folders_for_record(vault.vault_data, self.uid) + self.folder_uids = [folder.folder_uid for folder in folders] + + def _initialize_sharing_data( + self, + sf_sharing_admins: Optional[Dict], + team_members: Optional[Dict], + role_restricted_members: Optional[Set] + ) -> None: + """Initialize sharing data with provided values or defaults.""" + self.team_members = team_members or {} + _ = sf_sharing_admins or {} + _ = role_restricted_members or set() diff --git a/keepersdk-package/src/keepersdk/vault/shares_management.py b/keepersdk-package/src/keepersdk/vault/shares_management.py new file mode 100644 index 00000000..f800b0df --- /dev/null +++ b/keepersdk-package/src/keepersdk/vault/shares_management.py @@ -0,0 +1,809 @@ +import logging +from enum import Enum +from typing import List + +from .. import crypto, utils +from ..proto import folder_pb2, record_pb2 +from ..vault import vault_online, vault_utils, share_management_utils +from ..enterprise import enterprise_data + + +class ApiUrl(Enum): + SHARE_ADMIN = 'vault/am_i_share_admin' + SHARE_UPDATE = 'vault/records_share_update' + SHARE_FOLDER_UPDATE = 'vault/shared_folder_update_v3' + REMOVE_EXTERNAL_SHARE = 'vault/external_share_remove' + + +class ShareAction(Enum): + GRANT = 'grant' + REVOKE = 'revoke' + OWNER = 'owner' + CANCEL = 'cancel' + REMOVE = 'remove' + + +class ManagePermission(Enum): + ON = 'on' + OFF = 'off' + + +logger = logging.getLogger() + +# Constants +TIMESTAMP_MILLISECONDS_FACTOR = 1000 +MAX_BATCH_SIZE = 990 +FOLDER_CHUNK_SIZE = 999 +UID_BYTE_LENGTH = 16 + +# Status strings +STATUS_SUCCESS = 'success' +STATUS_INVITED = 'invited' + +# Record type attribute names +RECORD_TYPE_ADD = 'addSharedRecord' +RECORD_TYPE_UPDATE = 'updateSharedRecord' +RECORD_TYPE_REMOVE = 'removeSharedRecord' + +# Status attribute mappings +RECORD_STATUS_ATTRIBUTES = { + 'addSharedRecordStatus': ('granted to', 'grant'), + 'updateSharedRecordStatus': ('changed for', 'change'), + 'removeSharedRecordStatus': ('revoked from', 'revoke') +} + +# Folder type strings +FOLDER_TYPE_SHARED_FOLDER = 'shared_folder' +FOLDER_TYPE_SHARED_FOLDER_FOLDER = 'shared_folder_folder' + +def set_expiration_fields(obj, expiration): + """Set expiration and timerNotificationType fields on proto object if expiration is provided.""" + if isinstance(expiration, int): + if expiration > 0: + obj.expiration = expiration * TIMESTAMP_MILLISECONDS_FACTOR + obj.timerNotificationType = record_pb2.NOTIFY_OWNER + elif expiration < 0: + obj.expiration = -1 + + +class RecordShares(): + + @staticmethod + def cancel_share(vault: vault_online.VaultOnline, emails: list[str]): + for email in emails: + request = { + 'command': 'cancel_share', + 'to_email': email + } + vault.keeper_auth.execute_auth_command(request=request) + vault.sync_down() + + @staticmethod + def _resolve_uid_or_name(vault, uid_or_name, record_cache, shared_folder_cache, folder_cache): + """Resolve uid_or_name to record_uid, folder_uid, or shared_folder_uid.""" + record_uid = None + folder_uid = None + shared_folder_uid = None + + if uid_or_name in record_cache: + record_uid = uid_or_name + elif uid_or_name in shared_folder_cache: + shared_folder_uid = uid_or_name + elif uid_or_name in folder_cache: + folder_uid = uid_or_name + else: + for sf_info in vault.vault_data.shared_folders(): + if uid_or_name == sf_info.name: + shared_folder_uid = sf_info.shared_folder_uid + break + + if shared_folder_uid is None and record_uid is None: + rs = share_management_utils.try_resolve_path(vault, uid_or_name) + if rs is not None: + folder, name = rs + if name: + for record in vault.vault_data.records(): + if record.title.lower() == name.lower(): + record_uid = record.record_uid + break + else: + if folder.folder_type == FOLDER_TYPE_SHARED_FOLDER: + folder_uid = folder.folder_uid + shared_folder_uid = folder_uid + elif folder.folder_type == FOLDER_TYPE_SHARED_FOLDER_FOLDER: + folder_uid = folder.folder_uid + shared_folder_uid = folder.subfolders + + return record_uid, folder_uid, shared_folder_uid + + @staticmethod + def _check_share_admin_status(vault, uid_or_name, enterprise_access): + """Check if user is share admin for the given UID.""" + is_share_admin = False + record_uid = None + + if enterprise_access: + try: + uid = utils.base64_url_decode(uid_or_name) + if isinstance(uid, bytes) and len(uid) == UID_BYTE_LENGTH: + request = record_pb2.AmIShareAdmin() + obj_share_admin = record_pb2.IsObjectShareAdmin() + obj_share_admin.uid = uid + obj_share_admin.objectType = record_pb2.CHECK_SA_ON_RECORD + request.isObjectShareAdmin.append(obj_share_admin) + response = vault.keeper_auth.execute_auth_rest( + request=request, + response_type=record_pb2.AmIShareAdmin, + rest_endpoint=ApiUrl.SHARE_ADMIN.value + ) + if response and response.isObjectShareAdmin and response.isObjectShareAdmin[0].isAdmin: + is_share_admin = True + record_uid = uid_or_name + except Exception as e: + logger.error(f'Error checking share admin status: {e}') + + return is_share_admin, record_uid + + @staticmethod + def _collect_record_uids(vault, record_uid, folder_uid, shared_folder_uid, + record_cache, recursive): + """Collect all record UIDs based on the resolved UIDs.""" + record_uids = set() + + if record_uid: + record_uids.add(record_uid) + elif folder_uid: + folders = {folder_uid} + folder = vault.vault_data.get_folder(folder_uid) + if recursive and folder: + vault_utils.traverse_folder_tree( + vault=vault.vault_data, + folder=folder, + callback=lambda x: folders.add(x.folder_uid) + ) + record_uids = {uid for uid in folders if uid in record_cache} + elif shared_folder_uid: + if not recursive: + raise ValueError('--recursive parameter is required') + if isinstance(shared_folder_uid, str): + sf = vault.vault_data.load_shared_folder(shared_folder_uid=shared_folder_uid) + if sf and sf.record_permissions: + record_uids.update(x.record_uid for x in sf.record_permissions) + elif isinstance(shared_folder_uid, list): + for sf_uid in shared_folder_uid: + if isinstance(sf_uid, str): + sf = vault.vault_data.load_shared_folder(shared_folder_uid=sf_uid) + if sf and sf.record_permissions: + record_uids.update(x.record_uid for x in sf.record_permissions) + + return record_uids + + @staticmethod + def _handle_user_invitations(vault, all_users, action, dry_run): + """Handle user invitations and filter users with available keys.""" + if not dry_run and action in (ShareAction.GRANT.value, ShareAction.OWNER.value): + invited = vault.keeper_auth.load_user_public_keys(list(all_users), send_invites=True) + if invited: + for email in invited: + logger.warning('Share invitation has been sent to \'%s\'', email) + logger.warning('Please repeat this command when invitation is accepted.') + all_users.difference_update(invited) + + if vault.keeper_auth._key_cache: + all_users.intersection_update(vault.keeper_auth._key_cache.keys()) + + return all_users + + @staticmethod + def _encrypt_record_key_for_user(vault, record_key, email, ro): + """Encrypt record key for a user using their public key.""" + keys = vault.keeper_auth._key_cache[email] + if vault.keeper_auth.auth_context.forbid_rsa and keys.ec: + ec_key = crypto.load_ec_public_key(keys.ec) + ro.recordKey = crypto.encrypt_ec(record_key, ec_key) + ro.useEccKey = True + elif not vault.keeper_auth.auth_context.forbid_rsa and keys.rsa: + rsa_key = crypto.load_rsa_public_key(keys.rsa) + ro.recordKey = crypto.encrypt_rsa(record_key, rsa_key) + ro.useEccKey = False + + @staticmethod + def _build_shared_record(vault, email, record_uid, record_path, action, + can_edit, can_share, share_expiration, existing_shares): + """Build a SharedRecord proto object for a user.""" + ro = record_pb2.SharedRecord() + ro.toUsername = email + ro.recordUid = utils.base64_url_decode(record_uid) + + if record_path: + if 'shared_folder_uid' in record_path: + ro.sharedFolderUid = utils.base64_url_decode(record_path['shared_folder_uid']) + if 'team_uid' in record_path: + ro.teamUid = utils.base64_url_decode(record_path['team_uid']) + + if action == ShareAction.OWNER.value: + ro.transfer = True + elif action == ShareAction.GRANT.value: + if email in existing_shares: + current = existing_shares[email] + ro.editable = can_edit if can_edit is not None else current.get('editable') + ro.shareable = can_share if can_share is not None else current.get('shareable') + else: + ro.editable = bool(can_edit) + ro.shareable = bool(can_share) + set_expiration_fields(ro, share_expiration) + else: + if can_share or can_edit: + if email in existing_shares: + current = existing_shares[email] + ro.editable = False if can_edit else current.get('editable') + ro.shareable = False if can_share else current.get('shareable') + set_expiration_fields(ro, share_expiration) + + return ro + + @staticmethod + def _process_record_shares(vault, record_uids, all_users, action, can_edit, + can_share, share_expiration, record_cache, + not_owned_records, is_share_admin, enterprise): + """Process shares for all records and users, building the request.""" + rq = record_pb2.RecordShareUpdateRequest() + + for record_uid in record_uids: + # Get record data + if record_uid in record_cache: + rec = record_cache[record_uid] + elif not_owned_records and record_uid in not_owned_records: + rec = not_owned_records[record_uid] + elif is_share_admin: + rec = { + 'record_uid': record_uid, + 'shares': { + 'user_permissions': [{ + 'username': x, + 'owner': False, + 'share_admin': False, + 'shareable': action == 'revoke', + 'editable': action == 'revoke', + } for x in all_users] + } + } + else: + continue + + existing_shares = {} + if isinstance(rec, dict): + if 'shares' in rec: + shares = rec['shares'] + if 'user_permissions' in shares: + for po in shares['user_permissions']: + existing_shares[po['username'].lower()] = po + del rec['shares'] + + record_path = share_management_utils.resolve_record_share_path( + vault=vault, enterprise=enterprise, record_uid=record_uid + ) + + # Process each user + for email in all_users: + ro = RecordShares._build_shared_record( + vault, email, record_uid, record_path, action, + can_edit, can_share, share_expiration, existing_shares + ) + + if action in {ShareAction.GRANT.value, ShareAction.OWNER.value}: + record_uid_to_use = rec.get('record_uid', record_uid) if isinstance(rec, dict) else getattr(rec, 'record_uid', record_uid) + record_key = vault.vault_data.get_record_key(record_uid=record_uid_to_use) + if record_key and email not in existing_shares and vault.keeper_auth._key_cache and email in vault.keeper_auth._key_cache: + RecordShares._encrypt_record_key_for_user(vault, record_key, email, ro) + + if email in existing_shares: + rq.updateSharedRecord.append(ro) + else: + rq.addSharedRecord.append(ro) + else: + if can_share or can_edit: + rq.updateSharedRecord.append(ro) + else: + rq.removeSharedRecord.append(ro) + + return rq + + @staticmethod + def prep_request(vault: vault_online.VaultOnline, + emails: list[str], + action: str, + uid_or_name: str, + share_expiration: int, + dry_run: bool, + enterprise: enterprise_data.EnterpriseData, + enterprise_access: bool = False, + recursive: bool = False, + can_edit: bool = False, + can_share: bool = False): + """Prepare a record share update request.""" + # Build caches + record_cache = {x.record_uid: x for x in vault.vault_data.records()} + shared_folder_cache = {x.shared_folder_uid: x for x in vault.vault_data.shared_folders()} + folder_cache = {x: x for x in getattr(vault.vault_data, '_folders', [])} + + # Resolve UID or name + record_uid, folder_uid, shared_folder_uid = RecordShares._resolve_uid_or_name( + vault, uid_or_name, record_cache, shared_folder_cache, folder_cache + ) + + # Check share admin status if needed + is_share_admin = False + if record_uid is None and folder_uid is None and shared_folder_uid is None: + is_share_admin, record_uid = RecordShares._check_share_admin_status( + vault, uid_or_name, enterprise_access + ) + + if record_uid is None and folder_uid is None and shared_folder_uid is None: + raise ValueError('Enter name or uid of existing record or shared folder') + + # Collect record UIDs + record_uids = RecordShares._collect_record_uids( + vault, record_uid, folder_uid, shared_folder_uid, record_cache, recursive + ) + + if not record_uids: + raise ValueError('There are no records to share selected') + + if action == ShareAction.OWNER.value and len(emails) > 1: + raise ValueError('You can transfer ownership to a single account only') + + # Normalize and handle user invitations + all_users = {email.casefold() for email in emails} + all_users = RecordShares._handle_user_invitations(vault, all_users, action, dry_run) + + if not all_users: + raise ValueError('Nothing to do.') + + # Load records in shared folders + if shared_folder_uid: + if isinstance(shared_folder_uid, str): + share_management_utils.load_records_in_shared_folder( + vault=vault, shared_folder_uid=shared_folder_uid, record_uids=record_uids + ) + elif isinstance(shared_folder_uid, list): + for sf_uid in shared_folder_uid: + share_management_utils.load_records_in_shared_folder( + vault=vault, shared_folder_uid=sf_uid, record_uids=record_uids + ) + + # Get share information for records not in cache + not_owned_records = {} if is_share_admin else None + share_info = share_management_utils.get_record_shares( + vault=vault, record_uids=list(record_uids), is_share_admin=False + ) + if share_info and not_owned_records is not None: + for record_info in share_info: + record_uid = record_info.get('record_uid') + if record_uid: + not_owned_records[record_uid] = record_info + + # Build the request + return RecordShares._process_record_shares( + vault, record_uids, all_users, action, can_edit, can_share, + share_expiration, record_cache, not_owned_records, is_share_admin, enterprise + ) + + @staticmethod + def _create_batch_request(request, max_size): + """Create a batch request by taking items from the source request.""" + batch = record_pb2.RecordShareUpdateRequest() + remaining = max_size + + record_type_attrs = [RECORD_TYPE_ADD, RECORD_TYPE_UPDATE, RECORD_TYPE_REMOVE] + for attr_name in record_type_attrs: + if remaining <= 0: + break + + source_list = getattr(request, attr_name) + if not source_list: + continue + + items_to_take = min(remaining, len(source_list)) + target_list = getattr(batch, attr_name) + target_list.extend(source_list[:items_to_take]) + + del source_list[:items_to_take] + remaining -= items_to_take + + return batch + + @staticmethod + def _process_response_statuses(response): + """Process and log the status of each operation in the response.""" + success_status = [] + failed_status = [] + + for attr_name, (success_verb, failure_verb) in RECORD_STATUS_ATTRIBUTES.items(): + if not hasattr(response, attr_name): + continue + + statuses = getattr(response, attr_name) + for status_record in statuses: + record_uid = utils.base64_url_encode(status_record.recordUid) + status = status_record.status + email = status_record.username + + if status == STATUS_SUCCESS: + success_status.append( + f'Record "{record_uid}" access permissions has been {success_verb} user \'{email}\'' + ) + else: + failed_status.append( + f'Failed to {failure_verb} record "{record_uid}" access permissions for user \'{email}\': {status_record.message}' + ) + + return success_status, failed_status + + @staticmethod + def _has_pending_requests(request): + """Check if request has any pending items.""" + return (len(request.addSharedRecord) > 0 or + len(request.updateSharedRecord) > 0 or + len(request.removeSharedRecord) > 0) + + @staticmethod + def send_requests(vault: vault_online.VaultOnline, requests: list[record_pb2.RecordShareUpdateRequest]): + """Send record share update requests in batches.""" + success_responses = [] + failed_responses = [] + for request in requests: + while RecordShares._has_pending_requests(request): + batch_request = RecordShares._create_batch_request(request, MAX_BATCH_SIZE) + + response = vault.keeper_auth.execute_auth_rest( + rest_endpoint=ApiUrl.SHARE_UPDATE.value, + request=batch_request, + response_type=record_pb2.RecordShareUpdateResponse + ) + + success_response, failed_response = RecordShares._process_response_statuses(response) + success_responses.extend(success_response) + failed_responses.extend(failed_response) + + return success_responses, failed_responses + +class FolderShares(): + + @staticmethod + def _convert_manage_permission(value, default_value=None): + """Convert manage permission value to proto boolean.""" + if value is None: + return folder_pb2.BOOLEAN_NO_CHANGE if default_value is None else folder_pb2.BOOLEAN_TRUE if default_value else folder_pb2.BOOLEAN_FALSE + return folder_pb2.BOOLEAN_TRUE if value == ManagePermission.ON.value else folder_pb2.BOOLEAN_FALSE + + @staticmethod + def _encrypt_shared_folder_key_for_user(vault, sf_key, keys, uo): + """Encrypt shared folder key for a user.""" + if vault.keeper_auth.auth_context.forbid_rsa and keys.ec: + ec_key = crypto.load_ec_public_key(keys.ec) + uo.typedSharedFolderKey.encryptedKey = crypto.encrypt_ec(sf_key, ec_key) + uo.typedSharedFolderKey.encryptedKeyType = folder_pb2.encrypted_by_public_key_ecc + elif not vault.keeper_auth.auth_context.forbid_rsa and keys.rsa: + rsa_key = crypto.load_rsa_public_key(keys.rsa) + uo.typedSharedFolderKey.encryptedKey = crypto.encrypt_rsa(sf_key, rsa_key) + uo.typedSharedFolderKey.encryptedKeyType = folder_pb2.encrypted_by_public_key + + @staticmethod + def _encrypt_shared_folder_key_for_team(vault, team_sf_key, keys, to): + """Encrypt shared folder key for a team.""" + if keys.aes: + if vault.keeper_auth.auth_context.forbid_rsa: + to.typedSharedFolderKey.encryptedKey = crypto.encrypt_aes_v2(team_sf_key, keys.aes) + to.typedSharedFolderKey.encryptedKeyType = folder_pb2.encrypted_by_data_key_gcm + else: + to.typedSharedFolderKey.encryptedKey = crypto.encrypt_aes_v1(team_sf_key, keys.aes) + to.typedSharedFolderKey.encryptedKeyType = folder_pb2.encrypted_by_data_key + elif vault.keeper_auth.auth_context.forbid_rsa and keys.ec: + ec_key = crypto.load_ec_public_key(keys.ec) + to.typedSharedFolderKey.encryptedKey = crypto.encrypt_ec(team_sf_key, ec_key) + to.typedSharedFolderKey.encryptedKeyType = folder_pb2.encrypted_by_public_key_ecc + elif not vault.keeper_auth.auth_context.forbid_rsa and keys.rsa: + rsa_key = crypto.load_rsa_public_key(keys.rsa) + to.typedSharedFolderKey.encryptedKey = crypto.encrypt_rsa(team_sf_key, rsa_key) + to.typedSharedFolderKey.encryptedKeyType = folder_pb2.encrypted_by_public_key + + @staticmethod + def _initialize_request(rq, curr_sf): + """Initialize the shared folder update request with basic fields.""" + rq.sharedFolderUid = utils.base64_url_decode(curr_sf['shared_folder_uid']) + if 'revision' in curr_sf: + rq.revision = curr_sf['revision'] + else: + rq.forceUpdate = True + + @staticmethod + def _process_default_account_permissions(rq, action, mr, mu, default_account): + """Process default account permissions.""" + if default_account and action == ShareAction.GRANT.value: + rq.defaultManageRecords = FolderShares._convert_manage_permission(mr) + rq.defaultManageUsers = FolderShares._convert_manage_permission(mu) + + @staticmethod + def _process_users(vault, rq, curr_sf, users, action, mr, mu, share_expiration): + """Process user shares for the shared folder.""" + if not users: + return + + existing_users = {x['username'] if isinstance(x, dict) else x.name for x in curr_sf.get('users', [])} + + for email in users: + uo = folder_pb2.SharedFolderUpdateUser() + uo.username = email + set_expiration_fields(uo, share_expiration) + + if email in existing_users: + if action == ShareAction.GRANT.value: + uo.manageRecords = FolderShares._convert_manage_permission(mr) + uo.manageUsers = FolderShares._convert_manage_permission(mu) + rq.sharedFolderUpdateUser.append(uo) + elif action == ShareAction.REMOVE.value: + rq.sharedFolderRemoveUser.append(uo.username) + elif action == ShareAction.GRANT.value: + invited = vault.keeper_auth.load_user_public_keys([email], send_invites=True) + if invited: + for username in invited: + logger.warning('Share invitation has been sent to \'%s\'', username) + logger.warning('Please repeat this command when invitation is accepted.') + + keys = vault.keeper_auth._key_cache.get(email) if vault.keeper_auth._key_cache else None + if keys and (keys.rsa or keys.ec): + default_mr = curr_sf.get('default_manage_records') is True if mr is None else None + default_mu = curr_sf.get('default_manage_users') is True if mu is None else None + uo.manageRecords = FolderShares._convert_manage_permission(mr, default_mr) + uo.manageUsers = FolderShares._convert_manage_permission(mu, default_mu) + + sf_key = curr_sf.get('shared_folder_key_unencrypted') + if sf_key: + FolderShares._encrypt_shared_folder_key_for_user(vault, sf_key, keys, uo) + + rq.sharedFolderAddUser.append(uo) + else: + logger.warning('User %s not found', email) + + @staticmethod + def _process_teams(vault, rq, curr_sf, teams, action, mr, mu, share_expiration): + """Process team shares for the shared folder.""" + if not teams: + return + + existing_teams = {x['team_uid']: x for x in curr_sf.get('teams', [])} + + for team_uid in teams: + to = folder_pb2.SharedFolderUpdateTeam() + to.teamUid = utils.base64_url_decode(team_uid) + set_expiration_fields(to, share_expiration) + + if team_uid in existing_teams: + team = existing_teams[team_uid] + if action == ShareAction.GRANT.value: + to.manageRecords = team.get('manage_records') is True if mr is None else mr == ManagePermission.ON.value + to.manageUsers = team.get('manage_users') is True if mu is None else mu == ManagePermission.ON.value + rq.sharedFolderUpdateTeam.append(to) + elif action == ShareAction.REMOVE.value: + rq.sharedFolderRemoveTeam.append(to.teamUid) + elif action == ShareAction.GRANT.value: + to.manageRecords = True if mr else curr_sf.get('default_manage_records') is True + to.manageUsers = True if mu else curr_sf.get('default_manage_users') is True + + team_sf_key = curr_sf.get('shared_folder_key_unencrypted') + if team_sf_key: + vault.keeper_auth.load_team_keys([team_uid]) + keys = vault.keeper_auth._key_cache.get(team_uid) if vault.keeper_auth._key_cache else None + if keys: + FolderShares._encrypt_shared_folder_key_for_team(vault, team_sf_key, keys, to) + else: + continue + else: + logger.info('Shared folder key is not available.') + + rq.sharedFolderAddTeam.append(to) + + @staticmethod + def _process_default_record_permissions(rq, action, ce, cs, default_record): + """Process default record permissions.""" + if default_record and action == ShareAction.GRANT.value: + rq.defaultCanEdit = FolderShares._convert_manage_permission(ce) + rq.defaultCanShare = FolderShares._convert_manage_permission(cs) + + @staticmethod + def _process_records(vault, rq, curr_sf, rec_uids, action, ce, cs, share_expiration): + """Process record shares for the shared folder.""" + if not rec_uids: + return + + existing_records = {x['record_uid'] for x in curr_sf.get('records', [])} + + for record_uid in rec_uids: + ro = folder_pb2.SharedFolderUpdateRecord() + ro.recordUid = utils.base64_url_decode(record_uid) + set_expiration_fields(ro, share_expiration) + + if record_uid in existing_records: + if action == ShareAction.GRANT.value: + ro.canEdit = FolderShares._convert_manage_permission(ce) + ro.canShare = FolderShares._convert_manage_permission(cs) + rq.sharedFolderUpdateRecord.append(ro) + elif action == ShareAction.REMOVE.value: + rq.sharedFolderRemoveRecord.append(ro.recordUid) + else: + if action == ShareAction.GRANT.value: + default_ce = curr_sf.get('default_can_edit') is True if ce is None else None + default_cs = curr_sf.get('default_can_share') is True if cs is None else None + ro.canEdit = FolderShares._convert_manage_permission(ce, default_ce) + ro.canShare = FolderShares._convert_manage_permission(cs, default_cs) + + sf_key = curr_sf.get('shared_folder_key_unencrypted') + if sf_key: + rec = vault.vault_data.get_record(record_uid) + if rec: + rec_key = vault.vault_data.get_record_key(record_uid) + if rec_key: + if rec.version < 3: + ro.encryptedRecordKey = crypto.encrypt_aes_v1(rec_key, sf_key) + else: + ro.encryptedRecordKey = crypto.encrypt_aes_v2(rec_key, sf_key) + + rq.sharedFolderAddRecord.append(ro) + + @staticmethod + def prepare_request(vault: vault_online.VaultOnline, kwargs, curr_sf, users, teams, rec_uids, *, + default_record=False, default_account=False, + share_expiration=None): + """Prepare a shared folder update request.""" + rq = folder_pb2.SharedFolderUpdateV3Request() + FolderShares._initialize_request(rq, curr_sf) + + action = kwargs.get('action') or ShareAction.GRANT.value + mr = kwargs.get('manage_records') + mu = kwargs.get('manage_users') + ce = kwargs.get('can_edit') + cs = kwargs.get('can_share') + + FolderShares._process_default_account_permissions(rq, action, mr, mu, default_account) + FolderShares._process_users(vault, rq, curr_sf, users, action, mr, mu, share_expiration) + FolderShares._process_teams(vault, rq, curr_sf, teams, action, mr, mu, share_expiration) + FolderShares._process_default_record_permissions(rq, action, ce, cs, default_record) + FolderShares._process_records(vault, rq, curr_sf, rec_uids, action, ce, cs, share_expiration) + + return rq + + @staticmethod + def _get_team_action_verb(attr_name): + """Get action verb for team status attribute.""" + if attr_name == 'sharedFolderAddTeamStatus': + return 'added' + elif attr_name == 'sharedFolderUpdateTeamStatus': + return 'updated' + else: + return 'removed' + + @staticmethod + def _get_user_action_verb(attr_name): + """Get action verb for user status attribute.""" + if attr_name == 'sharedFolderAddUserStatus': + return 'added' + elif attr_name == 'sharedFolderUpdateUserStatus': + return 'updated' + else: + return 'removed' + + @staticmethod + def _get_record_action_verb(attr_name): + """Get action verb for record status attribute.""" + if attr_name == 'sharedFolderAddRecordStatus': + return 'added' + elif attr_name == 'sharedFolderUpdateRecordStatus': + return 'updated' + else: + return 'removed' + + @staticmethod + def _process_team_statuses(rs, team_cache, success_status, failed_status): + """Process team status responses.""" + team_attrs = ( + 'sharedFolderAddTeamStatus', 'sharedFolderUpdateTeamStatus', + 'sharedFolderRemoveTeamStatus' + ) + for attr in team_attrs: + if not hasattr(rs, attr): + continue + + statuses = getattr(rs, attr) + for t in statuses: + team_uid = utils.base64_url_encode(t.teamUid) + team = next((x for x in team_cache if x.team_uid == team_uid), None) + if team: + status = t.status + if status == STATUS_SUCCESS: + action_verb = FolderShares._get_team_action_verb(attr) + success_status.append(f'Team share \'{team.name}\' {action_verb}') + else: + failed_status.append(f'Team share \'{team.name}\' failed') + + @staticmethod + def _process_user_statuses(rs, success_status, failed_status): + """Process user status responses.""" + user_attrs = ( + 'sharedFolderAddUserStatus', 'sharedFolderUpdateUserStatus', + 'sharedFolderRemoveUserStatus' + ) + for attr in user_attrs: + if not hasattr(rs, attr): + continue + + statuses = getattr(rs, attr) + for s in statuses: + username = s.username + status = s.status + if status == STATUS_SUCCESS: + action_verb = FolderShares._get_user_action_verb(attr) + success_status.append(f'User share \'{username}\' {action_verb}') + elif status == STATUS_INVITED: + success_status.append(f'User \'{username}\' invited') + else: + failed_status.append(f'User share \'{username}\' failed') + + @staticmethod + def _process_record_statuses(rs, vault, success_status, failed_status): + """Process record status responses.""" + record_attrs = ( + 'sharedFolderAddRecordStatus', 'sharedFolderUpdateRecordStatus', + 'sharedFolderRemoveRecordStatus' + ) + for attr in record_attrs: + if not hasattr(rs, attr): + continue + + statuses = getattr(rs, attr) + for r in statuses: + record_uid = utils.base64_url_encode(r.recordUid) + status = r.status + + if record_uid in vault.vault_data._records: + rec = vault.vault_data.get_record(record_uid) + title = rec.title if rec else record_uid + else: + title = record_uid + + if status == STATUS_SUCCESS: + action_verb = FolderShares._get_record_action_verb(attr) + success_status.append(f'Record share {title} {action_verb}') + else: + failed_status.append(f'Record share \'{title}\' failed') + + @staticmethod + def send_requests(vault: vault_online.VaultOnline, partitioned_requests): + """Send shared folder update requests in chunks.""" + for requests in partitioned_requests: + while requests: + vault.auto_sync = True + chunk = requests[:FOLDER_CHUNK_SIZE] + requests = requests[FOLDER_CHUNK_SIZE:] + + rqs = folder_pb2.SharedFolderUpdateV3RequestV2() + rqs.sharedFoldersUpdateV3.extend(chunk) + + rss = vault.keeper_auth.execute_auth_rest( + rest_endpoint=ApiUrl.SHARE_FOLDER_UPDATE.value, + request=rqs, + response_type=folder_pb2.SharedFolderUpdateV3ResponseV2, + payload_version=1 + ) + + if rss and rss.sharedFoldersUpdateV3Response: + success_status = [] + failed_status = [] + team_cache = vault.vault_data.teams() + + for rs in rss.sharedFoldersUpdateV3Response: + FolderShares._process_team_statuses(rs, team_cache, success_status, failed_status) + FolderShares._process_user_statuses(rs, success_status, failed_status) + FolderShares._process_record_statuses(rs, vault, success_status, failed_status) + + return success_status, failed_status From ab0309083befd0bddfe52d8266f4094bfc4ebe72 Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Thu, 11 Dec 2025 12:42:39 +0530 Subject: [PATCH 14/37] Corrected constant strings --- .../keepersdk/vault/share_management_utils.py | 135 +++--------------- .../src/keepersdk/vault/shared_record.py | 15 +- .../src/keepersdk/vault/shares_management.py | 15 +- 3 files changed, 25 insertions(+), 140 deletions(-) diff --git a/keepersdk-package/src/keepersdk/vault/share_management_utils.py b/keepersdk-package/src/keepersdk/vault/share_management_utils.py index 29208f9d..e0bf115c 100644 --- a/keepersdk-package/src/keepersdk/vault/share_management_utils.py +++ b/keepersdk-package/src/keepersdk/vault/share_management_utils.py @@ -18,21 +18,12 @@ SHARE_OBJECTS_API = 'vault/get_share_objects' TEAM_MEMBERS_ENDPOINT = 'vault/get_team_members' SHARING_ADMINS_ENDPOINT = 'enterprise/get_sharing_admins' -SHARE_ADMIN_API = 'vault/am_i_share_admin' -SHARE_UPDATE_API = 'vault/records_share_update' -SHARE_FOLDER_UPDATE_API = 'vault/shared_folder_update_v3' -REMOVE_EXTERNAL_SHARE_API = 'vault/external_share_remove' # Record Processing Constants CHUNK_SIZE = 999 -MAX_BATCH_SIZE = 990 RECORD_KEY_LENGTH_V2 = 60 DEFAULT_EXPIRATION = 0 NEVER_EXPIRES = -1 -NEVER_EXPIRES_STRING = 'never' -TIMESTAMP_MILLISECONDS_FACTOR = 1000 -TRUNCATE_SUFFIX = '...' -TRUNCATE_LENGTH = 20 # Record Version Constants MAX_V2_VERSION = 2 @@ -44,106 +35,18 @@ USER_TYPE_INACTIVE = 2 # Permission Field Names -CAN_SHARE_PERMISSION = 'can_share' -CAN_EDIT_FIELD = 'can_edit' -CAN_SHARE_FIELD = 'can_share' +CAN_SHARE = 'can_share' +CAN_EDIT = 'can_edit' CAN_VIEW_FIELD = 'can_view' RECORD_UID_FIELD = 'record_uid' SHARED_FOLDER_UID_FIELD = 'shared_folder_uid' TEAM_UID_FIELD = 'team_uid' -# Share Object Categories -RELATIONSHIP_CATEGORY = 'relationship' -FAMILY_CATEGORY = 'family' -ENTERPRISE_CATEGORY = 'enterprise' -MC_CATEGORY = 'mc' - -# Record Field Names -TITLE_FIELD = 'title' -NAME_FIELD = 'name' -IS_SA_FIELD = 'is_sa' -ENTERPRISE_ID_FIELD = 'enterprise_id' -STATUS_FIELD = 'status' -CATEGORY_FIELD = 'category' -SHARES_FIELD = 'shares' -USER_PERMISSIONS_FIELD = 'user_permissions' -SHARED_FOLDER_PERMISSIONS_FIELD = 'shared_folder_permissions' - -# Key Constants for Data Access -KEY_USERNAME = 'username' -KEY_TEAM_UID = 'team_uid' -KEY_RECORD_UID = 'record_uid' -KEY_SHARED_FOLDER_UID = 'shared_folder_uid' -KEY_USER_PERMISSIONS = 'user_permissions' -KEY_TEAM_PERMISSIONS = 'team_permissions' -KEY_SHARED_FOLDER_PERMISSIONS = 'shared_folder_permissions' -KEY_SHARES = 'shares' -KEY_UID = 'uid' -KEY_NAME = 'name' -KEY_EDITABLE = 'editable' -KEY_SHAREABLE = 'shareable' -KEY_MANAGE_RECORDS = 'manage_records' -KEY_MANAGE_USERS = 'manage_users' -KEY_SHARE_ADMIN = 'share_admin' -KEY_IS_ADMIN = 'is_admin' -KEY_EXPIRATION = 'expiration' -KEY_OWNER = 'owner' -KEY_VIEW = 'view' -KEY_TITLE = 'title' - -# Enterprise Keys -KEY_ENTERPRISE = 'enterprise' -KEY_ENTERPRISE_USER_ID = 'enterprise_user_id' -KEY_USER_TYPE = 'user_type' -KEY_ROLE_ID = 'role_id' -KEY_ROLE_ENFORCEMENTS = 'role_enforcements' -KEY_ROLE_USERS = 'role_users' -KEY_ROLE_TEAMS = 'role_teams' -KEY_TEAM_USERS = 'team_users' -KEY_USERS = 'users' -KEY_TEAMS = 'teams' -KEY_ENFORCEMENTS = 'enforcements' - -# Vault Keys -KEY_VAULT = 'vault' -KEY_VAULT_DATA = 'vault_data' -KEY_SHARED_FOLDER_CACHE = 'shared_folder_cache' -KEY_RECORD_CACHE = 'record_cache' -KEY_RECORD_OWNER_CACHE = 'record_owner_cache' - -# Restriction Keys -KEY_RESTRICT_EDIT = 'restrict_edit' -KEY_RESTRICT_SHARING = 'restrict_sharing' -KEY_RESTRICT_VIEW = 'restrict_view' KEY_RESTRICT_SHARING_ALL = 'restrict_sharing_all' -# Permission Constants -PERMISSION_EDIT = 'edit' -PERMISSION_SHARE = 'share' -PERMISSION_VIEW = 'view' - -# Text Constants -TEXT_EDIT = 'Edit' -TEXT_SHARE = 'Share' -TEXT_READ_ONLY = 'Read Only' -TEXT_LAUNCH_ONLY = 'Launch Only' -TEXT_CAN_PREFIX = 'Can ' -TEXT_TEAM_PREFIX = '(Team)' -TEXT_TEAM_USER_PREFIX = '(Team User)' - # Default Values EMPTY_SHARE_OBJECTS = {'users': {}, 'enterprises': {}, 'teams': {}} -# Time Constants -SIX_MONTHS_IN_SECONDS = 182 * 24 * 60 * 60 - -# Status Messages -STATUS_SUCCESS = 'success' -STATUS_INVITED = 'invited' -STATUS_EXPIRED = 'Expired' -STATUS_OPENED = 'Opened' -STATUS_GENERATED = 'Generated' - logger = logging.getLogger() @@ -171,11 +74,11 @@ def get_share_expiration(expire_at: Optional[str], expire_in: Optional[str]) -> try: dt = None if isinstance(expire_at, str): - if expire_at == NEVER_EXPIRES_STRING: + if expire_at == 'never': return NEVER_EXPIRES dt = datetime.datetime.fromisoformat(expire_at) elif isinstance(expire_in, str): - if expire_in == NEVER_EXPIRES_STRING: + if expire_in == 'never': return NEVER_EXPIRES td = parse_timeout(expire_in) dt = datetime.datetime.now() + td @@ -204,10 +107,10 @@ def get_share_objects(vault: vault_online.VaultOnline) -> Dict[str, Dict[str, An return EMPTY_SHARE_OBJECTS users_by_type = { - RELATIONSHIP_CATEGORY: response.shareRelationships, - FAMILY_CATEGORY: response.shareFamilyUsers, - ENTERPRISE_CATEGORY: response.shareEnterpriseUsers, - MC_CATEGORY: response.shareMCEnterpriseUsers, + 'relationship': response.shareRelationships, + 'family': response.shareFamilyUsers, + 'enterprise': response.shareEnterpriseUsers, + 'mc': response.shareMCEnterpriseUsers, } users = {} @@ -236,11 +139,11 @@ def _process_users(users_data: Iterable[Any], category: str) -> Dict[str, Dict[s """Process user data and add category information.""" return { user.username: { - NAME_FIELD: user.fullname, - IS_SA_FIELD: user.isShareAdmin, - ENTERPRISE_ID_FIELD: user.enterpriseId, - STATUS_FIELD: user.status, - CATEGORY_FIELD: category + 'name': user.fullname, + 'is_sa': user.isShareAdmin, + 'enterprise_id': user.enterpriseId, + 'status': user.status, + 'category': category } for user in users_data } @@ -249,8 +152,8 @@ def _process_teams(teams_data: Iterable[Any]) -> Dict[str, Dict[str, Any]]: """Process team data.""" return { utils.base64_url_encode(team.teamUid): { - NAME_FIELD: team.teamname, - ENTERPRISE_ID_FIELD: team.enterpriseId + 'name': team.teamname, + 'enterprise_id': team.enterpriseId } for team in teams_data } @@ -597,7 +500,7 @@ def _process_shared_folder_permissions(info) -> List[Dict[str, Any]]: def resolve_record_share_path(vault: vault_online.VaultOnline, enterprise: enterprise_data.EnterpriseData, record_uid: str) -> Optional[Dict[str, str]]: - return resolve_record_permission_path(vault=vault, enterprise=enterprise, record_uid=record_uid, permission=CAN_SHARE_PERMISSION) + return resolve_record_permission_path(vault=vault, enterprise=enterprise, record_uid=record_uid, permission=CAN_SHARE) def resolve_record_permission_path( @@ -631,8 +534,8 @@ def _create_access_path( path = { RECORD_UID_FIELD: record_uid, SHARED_FOLDER_UID_FIELD: shared_folder_uid, - CAN_EDIT_FIELD: can_edit, - CAN_SHARE_FIELD: can_share, + CAN_EDIT: can_edit, + CAN_SHARE: can_share, CAN_VIEW_FIELD: True } if team_uid: @@ -757,7 +660,7 @@ def _get_restricted_role_members(enterprise: enterprise_data.EnterpriseData, use role_enforcements = enterprise.role_enforcements.get_all_links() restricted_roles = { re.role_id for re in role_enforcements - if re.enforcement_type == KEY_ENFORCEMENTS and re.value == KEY_RESTRICT_SHARING_ALL + if re.enforcement_type == 'enforcements' and re.value == KEY_RESTRICT_SHARING_ALL } if not restricted_roles: diff --git a/keepersdk-package/src/keepersdk/vault/shared_record.py b/keepersdk-package/src/keepersdk/vault/shared_record.py index 64013d94..4483a7a5 100644 --- a/keepersdk-package/src/keepersdk/vault/shared_record.py +++ b/keepersdk-package/src/keepersdk/vault/shared_record.py @@ -1,25 +1,12 @@ -from enum import Enum from typing import Dict, Optional, Set, Union from . import vault_online, vault_record, vault_utils -# Constants -TEXT_EDIT = 'Edit' -TEXT_SHARE = 'Share' -BIT_MASK_EDIT = 1 << 0 -BIT_MASK_SHARE = 1 << 1 -BITS_TEXT_LOOKUP = {BIT_MASK_EDIT: TEXT_EDIT, BIT_MASK_SHARE: TEXT_SHARE} -SHARE_PERMISSIONS_TYPE = Enum('SharePermissionsType', ['USER', 'SF_USER', 'TEAM', 'TEAM_USER']) - - class SharePermissions: """Manages share permissions for records, including edit, share, and view capabilities.""" - SharePermissionsType = SHARE_PERMISSIONS_TYPE - bits_text_lookup = BITS_TEXT_LOOKUP - - def __init__(self, sp_types=None, to_name='', permissions_text='', types=None): + def __init__(self, sp_types=None, to_name='', permissions_text='', types=None) -> None: """Initialize SharePermissions with default values and process provided types.""" self._initialize_default_attributes(to_name, permissions_text) self._process_initial_types(types) diff --git a/keepersdk-package/src/keepersdk/vault/shares_management.py b/keepersdk-package/src/keepersdk/vault/shares_management.py index f800b0df..aec4e3a9 100644 --- a/keepersdk-package/src/keepersdk/vault/shares_management.py +++ b/keepersdk-package/src/keepersdk/vault/shares_management.py @@ -1,6 +1,5 @@ import logging from enum import Enum -from typing import List from .. import crypto, utils from ..proto import folder_pb2, record_pb2 @@ -36,10 +35,6 @@ class ManagePermission(Enum): FOLDER_CHUNK_SIZE = 999 UID_BYTE_LENGTH = 16 -# Status strings -STATUS_SUCCESS = 'success' -STATUS_INVITED = 'invited' - # Record type attribute names RECORD_TYPE_ADD = 'addSharedRecord' RECORD_TYPE_UPDATE = 'updateSharedRecord' @@ -429,7 +424,7 @@ def _process_response_statuses(response): status = status_record.status email = status_record.username - if status == STATUS_SUCCESS: + if status == 'success': success_status.append( f'Record "{record_uid}" access permissions has been {success_verb} user \'{email}\'' ) @@ -720,7 +715,7 @@ def _process_team_statuses(rs, team_cache, success_status, failed_status): team = next((x for x in team_cache if x.team_uid == team_uid), None) if team: status = t.status - if status == STATUS_SUCCESS: + if status == 'success': action_verb = FolderShares._get_team_action_verb(attr) success_status.append(f'Team share \'{team.name}\' {action_verb}') else: @@ -741,10 +736,10 @@ def _process_user_statuses(rs, success_status, failed_status): for s in statuses: username = s.username status = s.status - if status == STATUS_SUCCESS: + if status == 'success': action_verb = FolderShares._get_user_action_verb(attr) success_status.append(f'User share \'{username}\' {action_verb}') - elif status == STATUS_INVITED: + elif status == 'invited': success_status.append(f'User \'{username}\' invited') else: failed_status.append(f'User share \'{username}\' failed') @@ -771,7 +766,7 @@ def _process_record_statuses(rs, vault, success_status, failed_status): else: title = record_uid - if status == STATUS_SUCCESS: + if status == 'success': action_verb = FolderShares._get_record_action_verb(attr) success_status.append(f'Record share {title} {action_verb}') else: From a8a4459cbbace6e9ef0fb4aff4850529f9d12737 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Tue, 16 Dec 2025 14:40:48 +0530 Subject: [PATCH 15/37] Bug Fix and User-Role and Team-Role correlations added --- .../src/keepercli/commands/enterprise_node.py | 1 - .../src/keepercli/commands/enterprise_user.py | 386 ++++++++++++++---- keepersdk-package/requirements.txt | 1 + keepersdk-package/setup.cfg | 1 + .../enterprise/enterprise_user_management.py | 256 ++++++++++-- 5 files changed, 534 insertions(+), 111 deletions(-) diff --git a/keepercli-package/src/keepercli/commands/enterprise_node.py b/keepercli-package/src/keepercli/commands/enterprise_node.py index 947e46dd..92c979ea 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_node.py +++ b/keepercli-package/src/keepercli/commands/enterprise_node.py @@ -234,7 +234,6 @@ class EnterpriseNodeAddCommand(base.ArgparseCommand, enterprise_management.IEnte def __init__(self): parser = argparse.ArgumentParser(prog='enterprise-node add', description='Create enterprise node(s).') parser.add_argument('--parent', dest='parent', action='store', help='Parent node name or ID') - parser.add_argument('--name', dest='displayname', action='store', help='set node display name') parser.add_argument('--set-isolated', dest='set_isolated', action='store', choices=['on', 'off'], help='set node isolated') parser.add_argument('-f', '--force', dest='force', action='store_true', diff --git a/keepercli-package/src/keepercli/commands/enterprise_user.py b/keepercli-package/src/keepercli/commands/enterprise_user.py index fe0e1506..e8c02faa 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_user.py +++ b/keepercli-package/src/keepercli/commands/enterprise_user.py @@ -1,6 +1,8 @@ import argparse +import hmac import json import datetime +import os from keepersdk.enterprise.enterprise_types import DeviceApprovalRequest import time from typing import Dict, List, Optional, Any, Set, TypedDict @@ -8,7 +10,7 @@ from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes, serialization from keepersdk import utils -from keepersdk.enterprise import batch_management, enterprise_management +from keepersdk.enterprise import batch_management, enterprise_management, enterprise_user_management from keepersdk.proto import APIRequest_pb2,enterprise_pb2 from . import base, enterprise_utils from .. import api, prompt_utils @@ -78,13 +80,17 @@ def __init__(self): self.register_command(EnterpriseUserActionCommand(), 'action') self.register_command(EnterpriseUserAliasCommand(), 'alias') self.register_command(EnterpriseDeviceApprovalCommand(), 'device-approve') + self.register_command(EnterpriseUserAddRoleCommand(), 'add-role') + self.register_command(EnterpriseUserRemoveRoleCommand(), 'remove-role') + self.register_command(EnterpriseUserAddTeamCommand(), 'add-team') + self.register_command(EnterpriseUserRemoveTeamCommand(), 'remove-team') class EnterpriseUserViewCommand(base.ArgparseCommand): def __init__(self): parser = argparse.ArgumentParser(prog='enterprise-user view', parents=[base.json_output_parser], description='View enterprise user.') parser.add_argument('-v', '--verbose', dest='verbose', action='store_true', help='print verbose information') - parser.add_argument('team', help='User email or UID') + parser.add_argument('user', help='User email or UID') super().__init__(parser) def execute(self, context: KeeperParams, **kwargs) -> Any: @@ -93,7 +99,7 @@ def execute(self, context: KeeperParams, **kwargs) -> Any: verbose = kwargs.get('verbose') is True enterprise_data = context.enterprise_data - user = enterprise_utils.UserUtils.resolve_single_user(enterprise_data, kwargs.get('team')) + user = enterprise_utils.UserUtils.resolve_single_user(enterprise_data, kwargs.get('user')) node_name = enterprise_utils.NodeUtils.get_node_path(enterprise_data, user.node_id, omit_root=False) user_obj = { @@ -161,7 +167,8 @@ def execute(self, context: KeeperParams, **kwargs) -> Any: if filename is None: return json_text else: - with open(filename, 'w') as f: + abs_path = os.path.abspath(filename) + with open(abs_path, 'w') as f: f.write(json_text) headers = ['user_id', 'email', 'full_name', 'node_name', 'status', 'transfer_status', 'tfa_enabled'] @@ -227,19 +234,19 @@ def execute(self, context: KeeperParams, **kwargs) -> Any: class EnterpriseUserAddCommand(base.ArgparseCommand, enterprise_management.IEnterpriseManagementLogger): def __init__(self): parser = argparse.ArgumentParser(prog='enterprise-user add', description='Create enterprise user(s).') - parser.add_argument('--parent', dest='parent', action='store', help='Parent node name or ID') - parser.add_argument('--full-name', dest='full_name', action='store', help='set user full name') - parser.add_argument('--job-title', dest='job_title', action='store', help='set user job title') - parser.add_argument('--add-role', dest='add_role', action='append', help='role name or role ID') - parser.add_argument('--add-team', dest='add_team', action='append', help='team name or team UID') - parser.add_argument('-hsf', '--hide-shared-folders', dest='hide_shared_folders', action='store', - choices=['on', 'off'], help='User does not see shared folders. --add-team only') - parser.add_argument('email', type=str, nargs='+', help='User email. Can be repeated.') + EnterpriseUserAddCommand.add_arguments_to_parser(parser) super().__init__(parser) self.logger = api.get_logger() def warning(self, message: str) -> None: self.logger.warning(message) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser) -> None: + parser.add_argument('--parent', dest='parent', action='store', help='Parent node name or ID') + parser.add_argument('--full-name', dest='full_name', action='store', help='set user full name') + parser.add_argument('--job-title', dest='job_title', action='store', help='set user job title') + parser.add_argument('email', type=str, nargs='+', help='User email. Can be repeated.') def execute(self, context: KeeperParams, **kwargs) -> None: base.require_login(context) @@ -263,30 +270,11 @@ def execute(self, context: KeeperParams, **kwargs) -> None: unique_emails.add(email) else: self.logger.info('User \"%s\" already exists', u.username) - if len(unique_emails) == 0: + if not unique_emails: raise base.CommandError('No users to add') full_name: Optional[str] = kwargs.get('full_name') job_title: Optional[str] = kwargs.get('job_title') - roles_to_add: Optional[Set[int]] = None - teams_to_add: Optional[Set[str]] = None - add_roles = kwargs.get('add_role') - if isinstance(add_roles, list): - roles = enterprise_utils.RoleUtils.resolve_existing_roles(context.enterprise_data, add_roles) - if len(roles) > 0: - roles_to_add = {x.role_id for x in roles} - add_teams = kwargs.get('add_team') - if isinstance(add_teams, list): - teams, add_teams = enterprise_utils.TeamUtils.resolve_existing_teams(context.enterprise_data, add_teams) - queued_teams, add_teams = enterprise_utils.TeamUtils.resolve_queued_teams(context.enterprise_data, add_teams) - if len(add_teams) > 0: - raise Exception(f'') - if len(teams) > 0 or len(queued_teams) > 0: - teams_to_add = set() - if len(teams) > 0: - teams_to_add.update((x.team_uid for x in teams)) - if len(queued_teams) > 0: - teams_to_add.update((x.team_uid for x in queued_teams)) batch = batch_management.BatchManagement(loader=context.enterprise_loader, logger=self) users_to_add = [enterprise_management.UserEdit( @@ -295,30 +283,237 @@ def execute(self, context: KeeperParams, **kwargs) -> None: for x in unique_emails] batch.modify_users(to_add=users_to_add) - if roles_to_add: - role_membership_to_add: List[enterprise_management.RoleUserEdit] = [] - for user in users_to_add: - for role_id in roles_to_add: - role_membership_to_add.append(enterprise_management.RoleUserEdit(enterprise_user_id=user.enterprise_user_id, role_id=role_id)) - batch.modify_role_users(to_add=role_membership_to_add) - if teams_to_add: - team_membership_to_add: List[enterprise_management.TeamUserEdit] = [] - hide_shared_folders: Optional[bool] = None - hsf = kwargs.get('hide_shared_folders') - if isinstance(hsf, str) and len(hsf) > 0: - hide_shared_folders = True if hsf == 'on' else False - user_type: Optional[int] = None - if isinstance(hide_shared_folders, bool): - user_type = 0 if hide_shared_folders else 2 - for user in users_to_add: - for team_uid in teams_to_add: - team_membership_to_add.append(enterprise_management.TeamUserEdit( - enterprise_user_id=user.enterprise_user_id, team_uid=team_uid, user_type=user_type)) - batch.modify_team_users(to_add=team_membership_to_add) + batch.apply() + + +class EnterpriseUserAddRoleCommand(base.ArgparseCommand, enterprise_management.IEnterpriseManagementLogger): + def __init__(self): + parser = argparse.ArgumentParser(prog='enterprise-user add-role', description='Add role(s) to enterprise user(s).') + EnterpriseUserAddRoleCommand.add_arguments_to_parser(parser) + super().__init__(parser) + self.logger = api.get_logger() + + def warning(self, message: str) -> None: + self.logger.warning(message) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser) -> None: + parser.add_argument('--role', dest='role', action='append', required=True, + help='role name or role ID. Can be repeated.') + parser.add_argument('user', type=str, nargs='+', + help='User email or ID. Can be repeated. Use @all for all users.') + + def execute(self, context: KeeperParams, **kwargs) -> None: + base.require_enterprise_admin(context) + if context.enterprise_loader is None: + raise base.CommandError('Enterprise loader is not initialized') + + roles = enterprise_utils.RoleUtils.resolve_existing_roles(context.enterprise_data, kwargs.get('role')) + if not roles: + raise base.CommandError('No roles to add') + roles_to_add = {x.role_id for x in roles} + + user_names = kwargs.get('user') + has_all_users = isinstance(user_names, list) and any((x == '@all' for x in user_names)) + + if has_all_users: + users = list(context.enterprise_data.users.get_all_entities()) + else: + users = enterprise_utils.UserUtils.resolve_existing_users(context.enterprise_data, user_names) + + if not users: + raise base.CommandError('No users to add role') + + batch = batch_management.BatchManagement(loader=context.enterprise_loader, logger=self) + + role_membership_to_add: List[enterprise_management.RoleUserEdit] = [] + for user in users: + existing_role_ids = {x.role_id for x in context.enterprise_data.role_users.get_links_by_object(user.enterprise_user_id)} + for role_id in roles_to_add: + if role_id not in existing_role_ids: + role_membership_to_add.append( + enterprise_management.RoleUserEdit(enterprise_user_id=user.enterprise_user_id, role_id=role_id)) + + if not role_membership_to_add: + self.logger.info('All specified users already have the specified roles') + return + + batch.modify_role_users(to_add=role_membership_to_add) + batch.apply() + + +class EnterpriseUserRemoveRoleCommand(base.ArgparseCommand, enterprise_management.IEnterpriseManagementLogger): + def __init__(self): + parser = argparse.ArgumentParser(prog='enterprise-user remove-role', description='Remove role(s) from enterprise user(s).') + EnterpriseUserRemoveRoleCommand.add_arguments_to_parser(parser) + super().__init__(parser) + self.logger = api.get_logger() + + def warning(self, message: str) -> None: + self.logger.warning(message) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser) -> None: + parser.add_argument('--role', dest='role', action='append', required=True, + help='role name or role ID. Can be repeated.') + parser.add_argument('user', type=str, nargs='+', + help='User email or ID. Can be repeated. Use @all for all users.') + + def execute(self, context: KeeperParams, **kwargs) -> None: + base.require_enterprise_admin(context) + if context.enterprise_loader is None: + raise base.CommandError('Enterprise loader is not initialized') + + roles = enterprise_utils.RoleUtils.resolve_existing_roles(context.enterprise_data, kwargs.get('role')) + if not roles: + raise base.CommandError('No roles to remove') + roles_to_remove = {x.role_id for x in roles} + + user_names = kwargs.get('user') + has_all_users = isinstance(user_names, list) and any((x == '@all' for x in user_names)) + + if has_all_users: + users = list(context.enterprise_data.users.get_all_entities()) + else: + users = enterprise_utils.UserUtils.resolve_existing_users(context.enterprise_data, user_names) + + if not users: + raise base.CommandError('No users to remove role from') + + batch = batch_management.BatchManagement(loader=context.enterprise_loader, logger=self) + + role_membership_to_remove: List[enterprise_management.RoleUserEdit] = [] + for user in users: + existing_role_ids = {x.role_id for x in context.enterprise_data.role_users.get_links_by_object(user.enterprise_user_id)} + for role_id in roles_to_remove: + if role_id in existing_role_ids: + role_membership_to_remove.append( + enterprise_management.RoleUserEdit(enterprise_user_id=user.enterprise_user_id, role_id=role_id)) + if not role_membership_to_remove: + self.logger.info('None of the specified users have the specified roles') + return + + batch.modify_role_users(to_remove=role_membership_to_remove) batch.apply() +class EnterpriseUserAddTeamCommand(base.ArgparseCommand, enterprise_management.IEnterpriseManagementLogger): + def __init__(self): + parser = argparse.ArgumentParser(prog='enterprise-user add-team', description='Add team(s) to enterprise user(s).') + EnterpriseUserAddTeamCommand.add_arguments_to_parser(parser) + super().__init__(parser) + self.logger = api.get_logger() + + def warning(self, message: str) -> None: + self.logger.warning(message) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser) -> None: + parser.add_argument('--team', dest='team', action='append', required=True, + help='team name or team UID. Can be repeated.') + parser.add_argument('-hsf', '--hide-shared-folders', dest='hide_shared_folders', action='store', + choices=['on', 'off'], help='User does not see shared folders.') + parser.add_argument('user', type=str, nargs='+', + help='User email or ID. Can be repeated.') + + def execute(self, context: KeeperParams, **kwargs) -> None: + base.require_enterprise_admin(context) + if context.enterprise_loader is None: + raise base.CommandError('Enterprise loader is not initialized') + + teams_to_add: Set[str] = set() + add_teams = kwargs.get('team') + if isinstance(add_teams, list): + teams, remaining = enterprise_utils.TeamUtils.resolve_existing_teams(context.enterprise_data, add_teams) + queued_teams, remaining = enterprise_utils.TeamUtils.resolve_queued_teams(context.enterprise_data, remaining) + if len(remaining) > 0: + missing_teams = ', '.join(remaining) + raise base.CommandError(f'Team(s) {missing_teams} cannot be found') + if len(teams) > 0: + teams_to_add.update((x.team_uid for x in teams)) + if len(queued_teams) > 0: + teams_to_add.update((x.team_uid for x in queued_teams)) + + if not teams_to_add: + raise base.CommandError('No teams to add') + + users = enterprise_utils.UserUtils.resolve_existing_users(context.enterprise_data, kwargs.get('user')) + if not users: + raise base.CommandError('No users to add team') + + hide_shared_folders: Optional[bool] = None + hsf = kwargs.get('hide_shared_folders') + if isinstance(hsf, str) and len(hsf) > 0: + hide_shared_folders = True if hsf == 'on' else False + + user_ids = [u.enterprise_user_id for u in users] + result = enterprise_user_management.add_users_to_teams( + loader=context.enterprise_loader, + user_ids=user_ids, + team_uids=teams_to_add, + hide_shared_folders=hide_shared_folders, + logger=self + ) + + if result.message: + self.logger.info(result.message) + + +class EnterpriseUserRemoveTeamCommand(base.ArgparseCommand, enterprise_management.IEnterpriseManagementLogger): + def __init__(self): + parser = argparse.ArgumentParser(prog='enterprise-user remove-team', description='Remove team(s) from enterprise user(s).') + EnterpriseUserRemoveTeamCommand.add_arguments_to_parser(parser) + super().__init__(parser) + self.logger = api.get_logger() + + def warning(self, message: str) -> None: + self.logger.warning(message) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser) -> None: + parser.add_argument('--team', dest='team', action='append', required=True, + help='team name or team UID. Can be repeated.') + parser.add_argument('user', type=str, nargs='+', + help='User email or ID. Can be repeated.') + + def execute(self, context: KeeperParams, **kwargs) -> None: + base.require_enterprise_admin(context) + if context.enterprise_loader is None: + raise base.CommandError('Enterprise loader is not initialized') + + teams_to_remove: Set[str] = set() + remove_teams = kwargs.get('team') + if isinstance(remove_teams, list): + teams, remaining = enterprise_utils.TeamUtils.resolve_existing_teams(context.enterprise_data, remove_teams) + queued_teams, remaining = enterprise_utils.TeamUtils.resolve_queued_teams(context.enterprise_data, remaining) + if len(remaining) > 0: + missing_teams = ', '.join(remaining) + raise base.CommandError(f'Team(s) {missing_teams} cannot be found') + if len(teams) > 0: + teams_to_remove.update((x.team_uid for x in teams)) + if len(queued_teams) > 0: + teams_to_remove.update((x.team_uid for x in queued_teams)) + + if not teams_to_remove: + raise base.CommandError('No teams to remove') + + users = enterprise_utils.UserUtils.resolve_existing_users(context.enterprise_data, kwargs.get('user')) + if not users: + raise base.CommandError('No users to remove team from') + + user_ids = [u.enterprise_user_id for u in users] + result = enterprise_user_management.remove_users_from_teams( + loader=context.enterprise_loader, + user_ids=user_ids, + team_uids=teams_to_remove, + logger=self + ) + + if result.message: + self.logger.info(result.message) + + class EnterpriseUserEditCommand(base.ArgparseCommand, enterprise_management.IEnterpriseManagementLogger): def __init__(self): parser = argparse.ArgumentParser(prog='enterprise-user edit', description='Edit enterprise user(s).') @@ -341,6 +536,10 @@ def warning(self, message: str) -> None: def execute(self, context: KeeperParams, **kwargs) -> None: base.require_enterprise_admin(context) + emails = kwargs.get('email') + if not emails: + raise base.CommandError('No email(s) provided') + parent_id: Optional[int] if kwargs.get('parent'): parent_node = enterprise_utils.NodeUtils.resolve_single_node(context.enterprise_data, kwargs.get('parent')) @@ -348,8 +547,8 @@ def execute(self, context: KeeperParams, **kwargs) -> None: else: parent_id = context.enterprise_data.root_node.node_id - users = enterprise_utils.UserUtils.resolve_existing_users(context.enterprise_data, kwargs.get('email')) - if len(users) == 0: + users = enterprise_utils.UserUtils.resolve_existing_users(context.enterprise_data, emails) + if not users: raise base.CommandError('No users to edit') full_name: Optional[str] = kwargs.get('full_name') @@ -463,8 +662,12 @@ def warning(self, message: str) -> None: def execute(self, context: KeeperParams, **kwargs) -> None: base.require_enterprise_admin(context) - users = enterprise_utils.UserUtils.resolve_existing_users(context.enterprise_data, kwargs.get('email')) - if len(users) == 0: + emails = kwargs.get('email') + if not emails: + raise base.CommandError('No email(s) provided') + + users = enterprise_utils.UserUtils.resolve_existing_users(context.enterprise_data, emails) + if not users: raise base.CommandError('No users to delete') active_users = [x for x in users if x.status == 'active'] @@ -490,14 +693,17 @@ class EnterpriseUserActionCommand(base.ArgparseCommand, enterprise_management.IE def __init__(self): parser = argparse.ArgumentParser(prog='enterprise-user action', description='Enterprise user actions.') actions = parser.add_mutually_exclusive_group(required=True) - actions.add_argument('--expire', dest='expire', action='store_true', help='expire master password') + actions.add_argument('--expire', dest='expire', action='store_true', + help='expire master password') actions.add_argument('--extend', dest='extend', action='store_true', - help='extend vault transfer consent by 7 days. Supports the following pseudo users: @all') - actions.add_argument('--lock', dest='lock', action='store_true', help='lock user') - actions.add_argument('--unlock', dest='unlock', action='store_true', help='unlock user') + help='extend vault transfer consent by 7 days. Supports @all') + actions.add_argument('--lock', dest='lock', action='store_true', + help='lock user') + actions.add_argument('--unlock', dest='unlock', action='store_true', + help='unlock user. Supports @all') actions.add_argument('--disable-2fa', dest='disable_2fa', action='store_true', help='disable 2fa for user') - parser.add_argument('email', type=str, nargs='+', help='User email or ID. Can be repeated.') + parser.add_argument('email', type=str, nargs='+', help='User email or ID. Can be repeated. Use @all for all users.') super().__init__(parser) self.logger = api.get_logger() @@ -507,16 +713,31 @@ def warning(self, message: str) -> None: def execute(self, context: KeeperParams, **kwargs) -> None: base.require_enterprise_admin(context) - users = enterprise_utils.UserUtils.resolve_existing_users(context.enterprise_data, kwargs.get('email')) - if len(users) == 0: - raise base.CommandError('No users to delete') + emails = kwargs.get('email') + has_all_users = isinstance(emails, list) and any((True for x in emails if x == '@all')) + + if has_all_users: + if kwargs.get('expire') is True: + raise base.CommandError('The --expire option does not support @all') + if kwargs.get('lock') is True: + raise base.CommandError('The --lock option does not support @all') + if kwargs.get('disable_2fa') is True: + raise base.CommandError('The --disable-2fa option does not support @all') + + if has_all_users: + users = list(context.enterprise_data.users.get_all_entities()) + else: + users = enterprise_utils.UserUtils.resolve_existing_users(context.enterprise_data, emails) + + if not users: + raise base.CommandError('No users found') inactive_users = [x for x in users if x.status != 'active'] if len(inactive_users) > 0: names = ', '.join((x.username for x in inactive_users)) self.logger.warning(f'Inactive users {names} are skipped') users = [x for x in users if x.status == 'active'] - if len(users) == 0: + if not users: return batch = batch_management.BatchManagement(loader=context.enterprise_loader, logger=self) @@ -552,7 +773,11 @@ def execute(self, context: KeeperParams, **kwargs) -> None: base.require_login(context) base.require_enterprise_admin(context) - user = enterprise_utils.UserUtils.resolve_single_user(context.enterprise_data, kwargs.get('email')) + email = kwargs.get('email') + if not email: + raise base.CommandError('No email provided') + + user = enterprise_utils.UserUtils.resolve_single_user(context.enterprise_data, email) aliases = context.enterprise_data.user_aliases.get_links_by_subject(user.enterprise_user_id) add_user = kwargs.get('add_alias') if isinstance(add_user, str): @@ -572,7 +797,8 @@ def execute(self, context: KeeperParams, **kwargs) -> None: alias_request.primary = True add_rs = context.auth.execute_auth_rest( 'enterprise/enterprise_user_add_alias', alias_request, response_type=APIRequest_pb2.EnterpriseUserAddAliasResponse) - assert add_rs + if not add_rs: + raise base.CommandError(f'Failed to add alias {add_user}: no response') for rs in add_rs.status: if rs.status != 'success': raise base.CommandError(f'Add alias {add_user} failed ({rs.status})') @@ -580,8 +806,9 @@ def execute(self, context: KeeperParams, **kwargs) -> None: remove_alias = kwargs.get('remove_alias') if isinstance(remove_alias, str): remove_alias = remove_alias.lower() - has_alias = remove_alias == user.username or any((True for x in aliases if x.username == add_user)) - if has_alias: + has_alias = remove_alias == user.username or any((True for x in aliases if x.username == remove_alias)) + if not has_alias: + self.logger.info(f'Alias "%s" does not exist for user', remove_alias) return alias_rq = APIRequest_pb2.EnterpriseUserAliasRequest() alias_rq.enterpriseUserId = user.enterprise_user_id @@ -657,7 +884,7 @@ def execute(self, context: KeeperParams, **kwargs) -> None: def _load_approval_requests(self, enterprise_data) -> List[DeviceApprovalRequest]: """Load and return all pending device approval requests.""" approval_requests: List[DeviceApprovalRequest] = list(enterprise_data.device_approval_requests.get_all_entities()) - if len(approval_requests) == 0: + if not approval_requests: logger.info('No pending approval requests') return [] return approval_requests @@ -676,7 +903,7 @@ def _filter_matching_devices(self, approval_requests: List[DeviceApprovalRequest if self._device_matches_filter(device, device_id, enterprise_data, device_filters): matching_devices[device_id] = device - if len(matching_devices) == 0: + if not matching_devices: logger.info('No matching devices found') return matching_devices @@ -710,7 +937,10 @@ def _filter_trusted_ip_devices(self, context: KeeperParams, enterprise_data, for device_id, device in matching_devices.items(): username = emails.get(device.enterprise_user_id) ip_address = device.ip_address - is_trusted = username and ip_address and username in ip_map and ip_address in ip_map[username] + is_trusted = ( + username and ip_address and username in ip_map and + self._is_ip_in_trusted_set(ip_address, ip_map[username]) + ) if is_trusted: trusted_devices[device_id] = device @@ -719,10 +949,20 @@ def _filter_trusted_ip_devices(self, context: KeeperParams, enterprise_data, "To force the approval, run the same command without the --trusted-ip argument", username, ip_address) - if len(trusted_devices) == 0: + if not trusted_devices: logger.info('No matching devices found') return trusted_devices + def _is_ip_in_trusted_set(self, ip_address: str, trusted_ips: Set[str]) -> bool: + """Check if IP address is in trusted set using constant-time comparison. + + Uses hmac.compare_digest to prevent timing attacks on IP address comparison. + """ + for trusted_ip in trusted_ips: + if hmac.compare_digest(ip_address, trusted_ip): + return True + return False + def _get_user_emails(self, enterprise_data, user_ids: Set[int]) -> Dict[int, str]: """Build a mapping of user IDs to usernames.""" emails = {} diff --git a/keepersdk-package/requirements.txt b/keepersdk-package/requirements.txt index 83f3555c..c767bfdf 100644 --- a/keepersdk-package/requirements.txt +++ b/keepersdk-package/requirements.txt @@ -4,3 +4,4 @@ cryptography>=45.0.1 protobuf>=5.28.3 websockets>=13.1 fido2>=2.0.0; python_version>='3.10' +email-validator>=2.0.0 diff --git a/keepersdk-package/setup.cfg b/keepersdk-package/setup.cfg index f57db56c..196fe098 100644 --- a/keepersdk-package/setup.cfg +++ b/keepersdk-package/setup.cfg @@ -31,6 +31,7 @@ install_requires = protobuf>=5.28.3 websockets>=13.1 fido2>=2.0.0; python_version>='3.10' + email-validator>=2.0.0 [options.package_data] diff --git a/keepersdk-package/src/keepersdk/enterprise/enterprise_user_management.py b/keepersdk-package/src/keepersdk/enterprise/enterprise_user_management.py index f30b26ee..4757cf9b 100644 --- a/keepersdk-package/src/keepersdk/enterprise/enterprise_user_management.py +++ b/keepersdk-package/src/keepersdk/enterprise/enterprise_user_management.py @@ -1,29 +1,20 @@ - -"""Enterprise user management functionality for Keeper SDK.""" - import json -import re -from typing import Optional +from typing import Optional, List, Set from dataclasses import dataclass +from email_validator import validate_email as validate_email_address, EmailNotValidError from keepersdk.authentication import keeper_auth -from . import enterprise_types +from . import enterprise_types, enterprise_management, batch_management from .. import utils, crypto, generator from ..proto import enterprise_pb2 # Constants -EMAIL_PATTERN = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' PBKDF2_ITERATIONS = 1_000_000 DEFAULT_PASSWORD_LENGTH = 20 SALT_LENGTH = 16 AUTH_VERIFIER_SALT_LENGTH = 16 -# Error codes -ERROR_CODE_EXISTS = "exists" -ERROR_CODE_SUCCESS = "success" -ERROR_CODE_OK = "ok" - # Documentation URLs DOMAIN_RESERVATION_DOC_URL = ( 'https://docs.keeper.io/enterprise-guide/' @@ -47,6 +38,9 @@ 'To reserve a domain please contact Keeper support. ' 'Learn more about domain reservation here:\n{}' ) +ERROR_MSG_ROLE_NOT_FOUND = "Role '{}' not found" +ERROR_MSG_ROLE_NOT_FOUND_BY_ID = "Role with ID {} not found" +ERROR_MSG_NO_USERS_FOUND = "No users found in enterprise" @dataclass @@ -73,6 +67,26 @@ class CreateUserResponse: verification_code: Optional[str] = None +@dataclass +class AddAllUsersToRoleResponse: + """Response from adding all users to a role.""" + role_id: int + role_name: str + users_added: int + success: bool = True + message: Optional[str] = None + + +@dataclass +class TeamUserResult: + """Result of team user management operation.""" + success: bool + added_count: int = 0 + removed_count: int = 0 + skipped_count: int = 0 + message: Optional[str] = None + + class EnterpriseUserCreationError(Exception): """Exception raised when enterprise user creation fails.""" @@ -82,6 +96,15 @@ def __init__(self, message: str, code: Optional[str] = None): super().__init__(self.message) +class EnterpriseRoleManagementError(Exception): + """Exception raised when enterprise role management fails.""" + + def __init__(self, message: str, code: Optional[str] = None): + self.message = message + self.code = code + super().__init__(self.message) + + class EnterpriseUserManager: """Manages enterprise user creation operations.""" @@ -96,7 +119,7 @@ def __init__(self, loader: enterprise_types.IEnterpriseLoader, auth_context: kee self.auth = auth_context def validate_email(self, email: str) -> bool: - """Validate email format. + """Validate email format using email-validator library. Args: email: Email address to validate @@ -107,7 +130,11 @@ def validate_email(self, email: str) -> bool: if not email: return False - return bool(re.match(EMAIL_PATTERN, email)) + try: + validate_email_address(email, check_deliverability=False) + return True + except EmailNotValidError: + return False def resolve_node_id(self, node_name_or_id: Optional[str] = None) -> int: """Resolve node ID from name or ID string. @@ -159,7 +186,7 @@ def _resolve_node_by_name(self, node_name: str) -> int: if node.name == node_name ] - if len(matching_nodes) == 0: + if not matching_nodes: raise EnterpriseUserCreationError(ERROR_MSG_NODE_NOT_FOUND_BY_NAME.format(node_name)) elif len(matching_nodes) > 1: raise EnterpriseUserCreationError(ERROR_MSG_MULTIPLE_NODES_FOUND.format(node_name)) @@ -351,7 +378,8 @@ def execute_provision_request( provision_request, response_type=enterprise_pb2.EnterpriseUsersProvisionResponse ) - assert rs is not None + if rs is None: + raise EnterpriseUserCreationError('No response received from provisioning API') self._validate_provision_response(rs, email) return rs @@ -368,12 +396,12 @@ def _validate_provision_response( ) -> None: """Validate the provision response and raise appropriate errors.""" for user_rs in response.results: - if user_rs.code == ERROR_CODE_EXISTS: + if user_rs.code == 'exists': raise EnterpriseUserCreationError( ERROR_MSG_USER_EXISTS.format(email), - code=ERROR_CODE_EXISTS + code='exists' ) - if user_rs.code and user_rs.code not in [ERROR_CODE_SUCCESS, ERROR_CODE_OK]: + if user_rs.code and user_rs.code not in ['success', 'ok']: raise EnterpriseUserCreationError( ERROR_MSG_AUTO_CREATE_FAILED.format(email, DOMAIN_RESERVATION_DOC_URL), code=user_rs.code @@ -456,23 +484,7 @@ def create_enterprise_user( password_length: int = DEFAULT_PASSWORD_LENGTH, suppress_email_invite: bool = False ) -> CreateUserResponse: - """Convenience function to create an enterprise user. - - Args: - loader: Enterprise data loader - auth_context: Authentication context - email: User email address - display_name: Optional display name - node_id: Optional node ID (uses root node if None) - password_length: Length of generated password (default 20) - suppress_email_invite: Whether to suppress email invitation - - Returns: - CreateUserResponse with user details - - Raises: - EnterpriseUserCreationError: If user creation fails - """ + request = CreateUserRequest( email=email, display_name=display_name, @@ -482,4 +494,174 @@ def create_enterprise_user( ) manager = EnterpriseUserManager(loader, auth_context) - return manager.create_user(request) \ No newline at end of file + return manager.create_user(request) + + +def resolve_role( + enterprise_data: enterprise_types.IEnterpriseData, + role_name_or_id: str +) -> enterprise_types.Role: + + if role_name_or_id.isnumeric(): + role_id = int(role_name_or_id) + role = enterprise_data.roles.get_entity(role_id) + if role: + return role + raise EnterpriseRoleManagementError(ERROR_MSG_ROLE_NOT_FOUND_BY_ID.format(role_id)) + + role_name_lower = role_name_or_id.lower() + matching_roles = [ + r for r in enterprise_data.roles.get_all_entities() + if r.name.lower() == role_name_lower + ] + + if not matching_roles: + raise EnterpriseRoleManagementError(ERROR_MSG_ROLE_NOT_FOUND.format(role_name_or_id)) + elif len(matching_roles) > 1: + raise EnterpriseRoleManagementError( + f"Multiple roles found with name '{role_name_or_id}'. Use Role ID instead." + ) + + return matching_roles[0] + + +def add_all_users_to_role( + loader: enterprise_types.IEnterpriseLoader, + role_name_or_id: str +) -> AddAllUsersToRoleResponse: + + enterprise_data = loader.enterprise_data + + role = resolve_role(enterprise_data, role_name_or_id) + + users = list(enterprise_data.users.get_all_entities()) + if not users: + raise EnterpriseRoleManagementError(ERROR_MSG_NO_USERS_FOUND) + + batch = batch_management.BatchManagement(loader=loader) + + role_membership_to_add: List[enterprise_management.RoleUserEdit] = [] + for user in users: + role_membership_to_add.append( + enterprise_management.RoleUserEdit( + enterprise_user_id=user.enterprise_user_id, + role_id=role.role_id + ) + ) + + batch.modify_role_users(to_add=role_membership_to_add) + batch.apply() + + return AddAllUsersToRoleResponse( + role_id=role.role_id, + role_name=role.name, + users_added=len(users), + success=True, + message=f"Added {len(users)} users to role '{role.name}'" + ) + + +def add_users_to_teams( + loader: enterprise_types.IEnterpriseLoader, + user_ids: List[int], + team_uids: Set[str], + hide_shared_folders: Optional[bool] = None, + logger: Optional[enterprise_management.IEnterpriseManagementLogger] = None +) -> TeamUserResult: + + if not user_ids or not team_uids: + return TeamUserResult(success=False, message='No users or teams specified') + + enterprise_data = loader.enterprise_data + + user_type: Optional[int] = None + if isinstance(hide_shared_folders, bool): + user_type = 0 if hide_shared_folders else 2 + + batch = batch_management.BatchManagement(loader=loader, logger=logger) + + team_membership_to_add: List[enterprise_management.TeamUserEdit] = [] + skipped = 0 + + for user_id in user_ids: + existing_team_uids = {x.team_uid for x in enterprise_data.team_users.get_links_by_object(user_id)} + queued_team_uids = {x.team_uid for x in enterprise_data.queued_team_users.get_links_by_object(user_id)} + existing_team_uids.update(queued_team_uids) + + for team_uid in team_uids: + if team_uid not in existing_team_uids: + team_membership_to_add.append( + enterprise_management.TeamUserEdit( + enterprise_user_id=user_id, + team_uid=team_uid, + user_type=user_type + ) + ) + else: + skipped += 1 + + if not team_membership_to_add: + return TeamUserResult( + success=True, + skipped_count=skipped, + message='All specified users are already members of the specified teams' + ) + + batch.modify_team_users(to_add=team_membership_to_add) + batch.apply() + + return TeamUserResult( + success=True, + added_count=len(team_membership_to_add), + skipped_count=skipped + ) + + +def remove_users_from_teams( + loader: enterprise_types.IEnterpriseLoader, + user_ids: List[int], + team_uids: Set[str], + logger: Optional[enterprise_management.IEnterpriseManagementLogger] = None +) -> TeamUserResult: + + if not user_ids or not team_uids: + return TeamUserResult(success=False, message='No users or teams specified') + + enterprise_data = loader.enterprise_data + + batch = batch_management.BatchManagement(loader=loader, logger=logger) + + team_membership_to_remove: List[enterprise_management.TeamUserEdit] = [] + skipped = 0 + + for user_id in user_ids: + existing_team_uids = {x.team_uid for x in enterprise_data.team_users.get_links_by_object(user_id)} + queued_team_uids = {x.team_uid for x in enterprise_data.queued_team_users.get_links_by_object(user_id)} + existing_team_uids.update(queued_team_uids) + + for team_uid in team_uids: + if team_uid in existing_team_uids: + team_membership_to_remove.append( + enterprise_management.TeamUserEdit( + enterprise_user_id=user_id, + team_uid=team_uid + ) + ) + else: + skipped += 1 + + if not team_membership_to_remove: + return TeamUserResult( + success=True, + skipped_count=skipped, + message='None of the specified users are members of the specified teams' + ) + + batch.modify_team_users(to_remove=team_membership_to_remove) + batch.apply() + + return TeamUserResult( + success=True, + removed_count=len(team_membership_to_remove), + skipped_count=skipped + ) \ No newline at end of file From 2cf735d9669b8995bb18a6d9ca1fbe48ec5ec7b9 Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Thu, 18 Dec 2025 16:34:43 +0530 Subject: [PATCH 16/37] Ksm command migration --- .../audit_alert/audit_alert_add.py | 43 +- .../audit_alert/audit_alert_delete.py | 42 +- .../audit_alert/audit_alert_edit.py | 42 +- .../audit_alert/audit_alert_history.py | 43 +- .../audit_alert/audit_alert_list.py | 43 +- .../audit_alert/audit_alert_view.py | 43 +- .../cli_examples/audit_report/audit_log.py | 44 +- .../cli_examples/audit_report/audit_report.py | 44 +- .../breachwatch/breachwatch_ignore.py | 44 +- .../breachwatch/breachwatch_list.py | 44 +- .../breachwatch/breachwatch_password.py | 43 +- .../breachwatch/breachwatch_scan.py | 43 +- .../create_custom_record_type.py | 44 +- .../custom_record_type_info.py | 48 +- .../delete_custom_record_type.py | 49 +- .../download_record_types.py | 47 +- .../edit_custom_record_type.py | 51 +- .../custom_record_type/load_record_types.py | 49 +- .../enterprise_info/enterprise_info_node.py | 43 +- .../enterprise_info/enterprise_info_role.py | 43 +- .../enterprise_info/enterprise_info_team.py | 43 +- .../enterprise_info/enterprise_info_tree.py | 43 +- .../enterprise_info/enterprise_info_user.py | 43 +- .../enterprise_node/enterprise_node_add.py | 43 +- .../enterprise_node/enterprise_node_delete.py | 43 +- .../enterprise_node/enterprise_node_edit.py | 43 +- .../enterprise_node_invite_email.py | 43 +- .../enterprise_node_set_logo.py | 43 +- .../enterprise_node/enterprise_node_view.py | 43 +- .../enterprise_node_wipe_out.py | 43 +- .../enterprise_role/enterprise_role_add.py | 43 +- .../enterprise_role/enterprise_role_admin.py | 47 +- .../enterprise_role/enterprise_role_copy.py | 43 +- .../enterprise_role/enterprise_role_delete.py | 43 +- .../enterprise_role/enterprise_role_edit.py | 43 +- .../enterprise_role_membership.py | 43 +- .../enterprise_role/enterprise_role_view.py | 43 +- .../enterprise_team/enterprise_team_add.py | 43 +- .../enterprise_team/enterprise_team_delete.py | 43 +- .../enterprise_team/enterprise_team_edit.py | 43 +- .../enterprise_team_membership.py | 43 +- .../enterprise_team/enterprise_team_view.py | 43 +- .../enterprise_user/create_user.py | 42 +- .../enterprise_user/enterprise_user_action.py | 43 +- .../enterprise_user/enterprise_user_add.py | 43 +- .../enterprise_user/enterprise_user_alias.py | 43 +- .../enterprise_user/enterprise_user_delete.py | 45 +- .../enterprise_user/enterprise_user_edit.py | 43 +- .../enterprise_user/enterprise_user_view.py | 43 +- .../enterprise_user/transfer_user.py | 47 +- examples/cli_examples/folder/list_sf.py | 44 +- examples/cli_examples/folder/share_folder.py | 44 +- .../cli_examples/folder/transform_folder.py | 44 +- .../importing_exporting/apply_membership.py | 43 +- .../download_membership.py | 43 +- .../importing_exporting/export_data.py | 43 +- .../importing_exporting/import_data.py | 43 +- .../miscellaneous/clipboard_copy.py | 43 +- .../miscellaneous/find_ownerless.py | 44 +- .../cli_examples/miscellaneous/list_team.py | 44 +- .../miscellaneous/password-report.py | 47 +- .../one_time_share/create_one_time_share.py | 47 +- .../one_time_share/list_one_time_shares.py | 47 +- .../one_time_share/remove_one_time_share.py | 49 +- .../password/advanced_password_generation.py | 22 +- .../password/basic_password_generation.py | 21 +- .../comprehensive_password_generation.py | 26 +- .../password/crypto_password_generation.py | 21 +- .../password/diceware_password_generation.py | 21 +- .../password/recovery_phrase_generation.py | 21 +- examples/cli_examples/record/add_record.py | 48 +- .../cli_examples/record/delete_attachment.py | 41 +- examples/cli_examples/record/delete_record.py | 48 +- .../record/download_attachment.py | 43 +- examples/cli_examples/record/file_report.py | 44 +- .../cli_examples/record/find_duplicate.py | 42 +- examples/cli_examples/record/get_command.py | 46 +- examples/cli_examples/record/list_records.py | 48 +- .../cli_examples/record/record_history.py | 44 +- examples/cli_examples/record/search_record.py | 45 +- examples/cli_examples/record/share_record.py | 49 +- .../record/share_record_permissions.py | 44 +- examples/cli_examples/record/update_record.py | 48 +- .../cli_examples/record/upload_attachment.py | 43 +- .../create_secrets_manager_app.py | 49 +- .../get_secrets_manager_app.py | 47 +- .../list_secrets_manager_apps.py | 46 +- .../remove_secrets_manager_app.py | 49 +- .../secrets_manager_app_add_record.py | 49 +- .../secrets_manager_app_remove_record.py | 50 +- .../secrets_manager_client_add.py | 49 +- .../secrets_manager_client_remove.py | 50 +- .../share_secrets_manager_app.py | 49 +- .../unshare_secrets_manager_app.py | 51 +- examples/cli_examples/trash/trash_get.py | 44 +- examples/cli_examples/trash/trash_list.py | 48 +- examples/cli_examples/trash/trash_purge.py | 44 +- examples/cli_examples/trash/trash_restore.py | 44 +- examples/cli_examples/trash/trash_unshare.py | 44 +- .../src/keepercli/commands/secrets_manager.py | 764 +----------------- .../src/keepersdk/vault/ksm_management.py | 711 +++++++++++++++- .../src/keepersdk/vault/record_management.py | 30 +- .../src/keepersdk/vault/shares_management.py | 2 +- 103 files changed, 4036 insertions(+), 1765 deletions(-) diff --git a/examples/cli_examples/audit_alert/audit_alert_add.py b/examples/cli_examples/audit_alert/audit_alert_add.py index 26af4a88..76e466eb 100644 --- a/examples/cli_examples/audit_alert/audit_alert_add.py +++ b/examples/cli_examples/audit_alert/audit_alert_add.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.audit_alert import AuditAlertAdd -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_audit_alert_add(context: KeeperParams, **kwargs): """ Execute audit alert add command. @@ -62,6 +83,7 @@ def execute_audit_alert_add(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Add new audit alert using Keeper SDK', @@ -72,10 +94,11 @@ def execute_audit_alert_add(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/audit_alert/audit_alert_delete.py b/examples/cli_examples/audit_alert/audit_alert_delete.py index bb4f3234..a414d4ab 100644 --- a/examples/cli_examples/audit_alert/audit_alert_delete.py +++ b/examples/cli_examples/audit_alert/audit_alert_delete.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.audit_alert import AuditAlertDelete -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,14 +57,15 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context def execute_audit_alert_delete(context: KeeperParams, **kwargs): @@ -62,6 +82,7 @@ def execute_audit_alert_delete(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Delete audit alert using Keeper SDK', @@ -72,10 +93,11 @@ def execute_audit_alert_delete(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/audit_alert/audit_alert_edit.py b/examples/cli_examples/audit_alert/audit_alert_edit.py index 34e6add4..81fed45a 100644 --- a/examples/cli_examples/audit_alert/audit_alert_edit.py +++ b/examples/cli_examples/audit_alert/audit_alert_edit.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.audit_alert import AuditAlertEdit -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_audit_alert_edit(context: KeeperParams, **kwargs): """ Execute audit alert edit command. @@ -72,10 +93,11 @@ def execute_audit_alert_edit(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/audit_alert/audit_alert_history.py b/examples/cli_examples/audit_alert/audit_alert_history.py index 9c018282..d2ca00c5 100644 --- a/examples/cli_examples/audit_alert/audit_alert_history.py +++ b/examples/cli_examples/audit_alert/audit_alert_history.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.audit_alert import AuditAlertHistory -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_audit_alert_history(context: KeeperParams, **kwargs): """ Execute audit alert history command. @@ -62,6 +83,7 @@ def execute_audit_alert_history(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='View audit alert history using Keeper SDK', @@ -72,10 +94,11 @@ def execute_audit_alert_history(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/audit_alert/audit_alert_list.py b/examples/cli_examples/audit_alert/audit_alert_list.py index 5b3a846f..02a1b1dd 100644 --- a/examples/cli_examples/audit_alert/audit_alert_list.py +++ b/examples/cli_examples/audit_alert/audit_alert_list.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.audit_alert import AuditAlertList -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_audit_alert_list(context: KeeperParams, **kwargs): """ Execute audit alert list command. @@ -62,6 +83,7 @@ def execute_audit_alert_list(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='List audit alerts using Keeper SDK', @@ -72,10 +94,11 @@ def execute_audit_alert_list(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/audit_alert/audit_alert_view.py b/examples/cli_examples/audit_alert/audit_alert_view.py index 12271d1e..6218ef2e 100644 --- a/examples/cli_examples/audit_alert/audit_alert_view.py +++ b/examples/cli_examples/audit_alert/audit_alert_view.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.audit_alert import AuditAlertView -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_audit_alert_view(context: KeeperParams, **kwargs): """ Execute audit alert view command. @@ -62,6 +83,7 @@ def execute_audit_alert_view(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='View audit alert details using Keeper SDK', @@ -72,10 +94,11 @@ def execute_audit_alert_view(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/audit_report/audit_log.py b/examples/cli_examples/audit_report/audit_log.py index 727a8cdd..edf8158b 100644 --- a/examples/cli_examples/audit_report/audit_log.py +++ b/examples/cli_examples/audit_report/audit_log.py @@ -21,12 +21,31 @@ from typing import Optional from keepercli.commands.audit_log import AuditLogCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -39,16 +58,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def audit_log( context: KeeperParams, anonymize: Optional[bool] = None, @@ -92,10 +113,11 @@ def audit_log( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -128,4 +150,4 @@ def audit_log( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/audit_report/audit_report.py b/examples/cli_examples/audit_report/audit_report.py index fd9358e1..e36ed148 100644 --- a/examples/cli_examples/audit_report/audit_report.py +++ b/examples/cli_examples/audit_report/audit_report.py @@ -20,12 +20,32 @@ import logging from keepercli.commands.audit_report import EnterpriseAuditReport -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +58,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_audit_report(context: KeeperParams, **kwargs): """ Execute audit report command. @@ -62,6 +84,7 @@ def execute_audit_report(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Generate audit reports using Keeper SDK', @@ -72,10 +95,11 @@ def execute_audit_report(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/breachwatch/breachwatch_ignore.py b/examples/cli_examples/breachwatch/breachwatch_ignore.py index 8ee2d5ba..c59c07f6 100644 --- a/examples/cli_examples/breachwatch/breachwatch_ignore.py +++ b/examples/cli_examples/breachwatch/breachwatch_ignore.py @@ -20,12 +20,32 @@ import logging from keepercli.commands.breachwatch import BreachWatchIgnoreCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +58,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_breachwatch_ignore(context: KeeperParams, **kwargs): """ Execute breachwatch ignore command. @@ -62,6 +84,7 @@ def execute_breachwatch_ignore(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Ignore breached passwords for records using Keeper SDK', @@ -72,10 +95,11 @@ def execute_breachwatch_ignore(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/breachwatch/breachwatch_list.py b/examples/cli_examples/breachwatch/breachwatch_list.py index f9958336..952ea807 100644 --- a/examples/cli_examples/breachwatch/breachwatch_list.py +++ b/examples/cli_examples/breachwatch/breachwatch_list.py @@ -20,12 +20,32 @@ import logging from keepercli.commands.breachwatch import BreachWatchListCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +58,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_breachwatch_list(context: KeeperParams, **kwargs): """ Execute breachwatch list command. @@ -62,6 +84,7 @@ def execute_breachwatch_list(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='List breached passwords using Keeper SDK', @@ -72,10 +95,11 @@ def execute_breachwatch_list(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/breachwatch/breachwatch_password.py b/examples/cli_examples/breachwatch/breachwatch_password.py index e4334699..69625ba6 100644 --- a/examples/cli_examples/breachwatch/breachwatch_password.py +++ b/examples/cli_examples/breachwatch/breachwatch_password.py @@ -20,9 +20,27 @@ from typing import Optional, List from keepercli.commands.breachwatch import BreachWatchPasswordCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -35,16 +53,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def scan_passwords( context: KeeperParams, passwords: Optional[List[str]] = None, @@ -79,10 +99,11 @@ def scan_passwords( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -107,4 +128,4 @@ def scan_passwords( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/breachwatch/breachwatch_scan.py b/examples/cli_examples/breachwatch/breachwatch_scan.py index e357231b..c73596c3 100644 --- a/examples/cli_examples/breachwatch/breachwatch_scan.py +++ b/examples/cli_examples/breachwatch/breachwatch_scan.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.breachwatch import BreachWatchScanCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_breachwatch_scan(context: KeeperParams, **kwargs): """ Execute breachwatch scan command. @@ -62,6 +83,7 @@ def execute_breachwatch_scan(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Scan for breached passwords using Keeper SDK', @@ -72,10 +94,11 @@ def execute_breachwatch_scan(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/custom_record_type/create_custom_record_type.py b/examples/cli_examples/custom_record_type/create_custom_record_type.py index c8ef9e76..085b128f 100644 --- a/examples/cli_examples/custom_record_type/create_custom_record_type.py +++ b/examples/cli_examples/custom_record_type/create_custom_record_type.py @@ -14,15 +14,36 @@ # import argparse +import logging import json import os import sys from typing import Optional, List, Dict, Any from keepersdk.vault import record_type_management, vault_online -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -35,16 +56,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def create_custom_record_type( vault: vault_online.VaultOnline, record_type_title: str, @@ -96,10 +119,11 @@ def create_custom_record_type( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/custom_record_type/custom_record_type_info.py b/examples/cli_examples/custom_record_type/custom_record_type_info.py index 4db98ac4..7a13ce8f 100644 --- a/examples/cli_examples/custom_record_type/custom_record_type_info.py +++ b/examples/cli_examples/custom_record_type/custom_record_type_info.py @@ -15,15 +15,35 @@ import argparse import json - +import logging import os import sys from keepercli.commands.record_type import RecordTypeInfoCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -36,16 +56,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_record_type_info(context: KeeperParams, **kwargs): """ Execute record type information command. @@ -62,6 +84,7 @@ def execute_record_type_info(context: KeeperParams, **kwargs): print(f'Error: {str(e)}') return False + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Get record type information using Keeper SDK', @@ -72,10 +95,11 @@ def execute_record_type_info(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -97,4 +121,4 @@ def execute_record_type_info(context: KeeperParams, **kwargs): sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/custom_record_type/delete_custom_record_type.py b/examples/cli_examples/custom_record_type/delete_custom_record_type.py index 0321e513..a70cc0cf 100644 --- a/examples/cli_examples/custom_record_type/delete_custom_record_type.py +++ b/examples/cli_examples/custom_record_type/delete_custom_record_type.py @@ -17,11 +17,35 @@ import json import os import sys +import logging from keepersdk.vault import record_type_management, vault_online -from keepercli.params import KeeperParams + +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -34,16 +58,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def delete_custom_record_type( vault: vault_online.VaultOnline, record_type_id: int, @@ -84,10 +110,11 @@ def delete_custom_record_type( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -110,4 +137,4 @@ def delete_custom_record_type( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/custom_record_type/download_record_types.py b/examples/cli_examples/custom_record_type/download_record_types.py index c5fca2dc..0dc7f4cb 100644 --- a/examples/cli_examples/custom_record_type/download_record_types.py +++ b/examples/cli_examples/custom_record_type/download_record_types.py @@ -17,11 +17,32 @@ import json import os import sys +import logging from keepercli.commands.record_type import DownloadRecordTypesCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -34,15 +55,17 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def download_record_types(context: KeeperParams, **kwargs): """ @@ -69,6 +92,7 @@ def download_record_types(context: KeeperParams, **kwargs): print(f'Error downloading record types: {str(e)}') return False + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Download custom record types to a JSON file using Keeper CLI', @@ -79,10 +103,11 @@ def download_record_types(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -107,4 +132,4 @@ def download_record_types(context: KeeperParams, **kwargs): sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/custom_record_type/edit_custom_record_type.py b/examples/cli_examples/custom_record_type/edit_custom_record_type.py index b16489c7..f0549cd4 100644 --- a/examples/cli_examples/custom_record_type/edit_custom_record_type.py +++ b/examples/cli_examples/custom_record_type/edit_custom_record_type.py @@ -15,16 +15,38 @@ import argparse import json - +import logging import os - import sys + from typing import Optional, Dict, Any, List from keepersdk.vault import record_type_management, vault_online -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -37,16 +59,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def edit_custom_record_type( vault: vault_online.VaultOnline, record_type_id: int, @@ -100,10 +124,11 @@ def edit_custom_record_type( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -130,4 +155,4 @@ def edit_custom_record_type( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/custom_record_type/load_record_types.py b/examples/cli_examples/custom_record_type/load_record_types.py index c65b80f4..ea5805e9 100644 --- a/examples/cli_examples/custom_record_type/load_record_types.py +++ b/examples/cli_examples/custom_record_type/load_record_types.py @@ -17,11 +17,34 @@ import json import os import sys +import logging from keepercli.commands.record_type import LoadRecordTypesCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -34,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def load_record_types(context: KeeperParams, **kwargs): """ Load custom record types from a JSON file. @@ -67,6 +92,7 @@ def load_record_types(context: KeeperParams, **kwargs): print(f'Error loading record types: {str(e)}') return False + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Load custom record types from JSON file using Keeper SDK', @@ -77,10 +103,11 @@ def load_record_types(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -108,4 +135,4 @@ def load_record_types(context: KeeperParams, **kwargs): sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/enterprise_info/enterprise_info_node.py b/examples/cli_examples/enterprise_info/enterprise_info_node.py index 1cdac14b..17582417 100644 --- a/examples/cli_examples/enterprise_info/enterprise_info_node.py +++ b/examples/cli_examples/enterprise_info/enterprise_info_node.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_info import EnterpriseInfoNodeCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_info_node(context: KeeperParams, **kwargs): """ Execute enterprise info node command. @@ -62,6 +83,7 @@ def execute_enterprise_info_node(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Display enterprise node information using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_info_node(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_info/enterprise_info_role.py b/examples/cli_examples/enterprise_info/enterprise_info_role.py index bfdc9cbc..805ef65c 100644 --- a/examples/cli_examples/enterprise_info/enterprise_info_role.py +++ b/examples/cli_examples/enterprise_info/enterprise_info_role.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_info import EnterpriseInfoRoleCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_info_role(context: KeeperParams, **kwargs): """ Execute enterprise info role command. @@ -62,6 +83,7 @@ def execute_enterprise_info_role(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Display enterprise role information using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_info_role(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_info/enterprise_info_team.py b/examples/cli_examples/enterprise_info/enterprise_info_team.py index f5c816a8..1776e148 100644 --- a/examples/cli_examples/enterprise_info/enterprise_info_team.py +++ b/examples/cli_examples/enterprise_info/enterprise_info_team.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_info import EnterpriseInfoTeamCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_info_team(context: KeeperParams, **kwargs): """ Execute enterprise info team command. @@ -62,6 +83,7 @@ def execute_enterprise_info_team(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Display enterprise team information using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_info_team(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_info/enterprise_info_tree.py b/examples/cli_examples/enterprise_info/enterprise_info_tree.py index f0e9dd6c..76ef135d 100644 --- a/examples/cli_examples/enterprise_info/enterprise_info_tree.py +++ b/examples/cli_examples/enterprise_info/enterprise_info_tree.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_info import EnterpriseInfoTreeCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_info_tree(context: KeeperParams, **kwargs): """ Execute enterprise info tree command. @@ -64,6 +85,7 @@ def execute_enterprise_info_tree(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Display enterprise tree structure using Keeper SDK', @@ -74,10 +96,11 @@ def execute_enterprise_info_tree(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_info/enterprise_info_user.py b/examples/cli_examples/enterprise_info/enterprise_info_user.py index 79310055..c2879049 100644 --- a/examples/cli_examples/enterprise_info/enterprise_info_user.py +++ b/examples/cli_examples/enterprise_info/enterprise_info_user.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_info import EnterpriseInfoUserCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_info_user(context: KeeperParams, **kwargs): """ Execute enterprise info user command. @@ -62,6 +83,7 @@ def execute_enterprise_info_user(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Display enterprise user information using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_info_user(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_node/enterprise_node_add.py b/examples/cli_examples/enterprise_node/enterprise_node_add.py index 753beb7d..cf5384d8 100644 --- a/examples/cli_examples/enterprise_node/enterprise_node_add.py +++ b/examples/cli_examples/enterprise_node/enterprise_node_add.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_node import EnterpriseNodeAddCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_node_add(context: KeeperParams, **kwargs): """ Execute enterprise node add command. @@ -62,6 +83,7 @@ def execute_enterprise_node_add(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Add new enterprise node using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_node_add(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_node/enterprise_node_delete.py b/examples/cli_examples/enterprise_node/enterprise_node_delete.py index 9ecc8765..1734e60c 100644 --- a/examples/cli_examples/enterprise_node/enterprise_node_delete.py +++ b/examples/cli_examples/enterprise_node/enterprise_node_delete.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_node import EnterpriseNodeDeleteCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_node_delete(context: KeeperParams, **kwargs): """ Execute enterprise node delete command. @@ -62,6 +83,7 @@ def execute_enterprise_node_delete(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Delete enterprise node using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_node_delete(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_node/enterprise_node_edit.py b/examples/cli_examples/enterprise_node/enterprise_node_edit.py index aba89933..f5c127ca 100644 --- a/examples/cli_examples/enterprise_node/enterprise_node_edit.py +++ b/examples/cli_examples/enterprise_node/enterprise_node_edit.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_node import EnterpriseNodeEditCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_node_edit(context: KeeperParams, **kwargs): """ Execute enterprise node edit command. @@ -62,6 +83,7 @@ def execute_enterprise_node_edit(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Edit enterprise node using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_node_edit(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_node/enterprise_node_invite_email.py b/examples/cli_examples/enterprise_node/enterprise_node_invite_email.py index a4e546b3..7edfb625 100644 --- a/examples/cli_examples/enterprise_node/enterprise_node_invite_email.py +++ b/examples/cli_examples/enterprise_node/enterprise_node_invite_email.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_node import EnterpriseNodeInviteCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_node_invite_email(context: KeeperParams, **kwargs): """ Execute enterprise node invite email command. @@ -62,6 +83,7 @@ def execute_enterprise_node_invite_email(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Send invitation email from enterprise node using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_node_invite_email(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_node/enterprise_node_set_logo.py b/examples/cli_examples/enterprise_node/enterprise_node_set_logo.py index c682da7a..95fe4fdf 100644 --- a/examples/cli_examples/enterprise_node/enterprise_node_set_logo.py +++ b/examples/cli_examples/enterprise_node/enterprise_node_set_logo.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_node import EnterpriseNodeSetLogoCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_node_set_logo(context: KeeperParams, **kwargs): """ Execute enterprise node set logo command. @@ -62,6 +83,7 @@ def execute_enterprise_node_set_logo(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Set logo for enterprise node using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_node_set_logo(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_node/enterprise_node_view.py b/examples/cli_examples/enterprise_node/enterprise_node_view.py index a150f099..25288c95 100644 --- a/examples/cli_examples/enterprise_node/enterprise_node_view.py +++ b/examples/cli_examples/enterprise_node/enterprise_node_view.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_node import EnterpriseNodeViewCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_node_view(context: KeeperParams, **kwargs): """ Execute enterprise node view command. @@ -62,6 +83,7 @@ def execute_enterprise_node_view(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='View enterprise node information using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_node_view(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_node/enterprise_node_wipe_out.py b/examples/cli_examples/enterprise_node/enterprise_node_wipe_out.py index 9ea0d209..00df21d7 100644 --- a/examples/cli_examples/enterprise_node/enterprise_node_wipe_out.py +++ b/examples/cli_examples/enterprise_node/enterprise_node_wipe_out.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_node import EnterpriseNodeWipeOutCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_node_wipe_out(context: KeeperParams, **kwargs): """ Execute enterprise node wipe out command. @@ -62,6 +83,7 @@ def execute_enterprise_node_wipe_out(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Wipe out enterprise node using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_node_wipe_out(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_role/enterprise_role_add.py b/examples/cli_examples/enterprise_role/enterprise_role_add.py index d51c79af..5b00792c 100644 --- a/examples/cli_examples/enterprise_role/enterprise_role_add.py +++ b/examples/cli_examples/enterprise_role/enterprise_role_add.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_role import EnterpriseRoleAddCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_role_add(context: KeeperParams, **kwargs): """ Execute enterprise role add command. @@ -62,6 +83,7 @@ def execute_enterprise_role_add(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Add new enterprise role using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_role_add(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_role/enterprise_role_admin.py b/examples/cli_examples/enterprise_role/enterprise_role_admin.py index e67eda12..4d59ebbe 100644 --- a/examples/cli_examples/enterprise_role/enterprise_role_admin.py +++ b/examples/cli_examples/enterprise_role/enterprise_role_admin.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_role import EnterpriseRoleAdminCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_role_admin(context: KeeperParams, **kwargs): """ Execute enterprise role admin command. @@ -62,6 +83,7 @@ def execute_enterprise_role_admin(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Manage enterprise role admin privileges using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_role_admin(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -84,8 +107,8 @@ def execute_enterprise_role_admin(context: KeeperParams, **kwargs): print(f'Config file {args.config} not found') sys.exit(1) - role_name = "Test Role" - node_name = "New Test Node" + role_name = "Test2 Role" + node_name = ["Dev Node"] privileges = ["MANAGE_TEAMS"] cascade = "on" # on or off diff --git a/examples/cli_examples/enterprise_role/enterprise_role_copy.py b/examples/cli_examples/enterprise_role/enterprise_role_copy.py index 5f34a018..483be6b2 100644 --- a/examples/cli_examples/enterprise_role/enterprise_role_copy.py +++ b/examples/cli_examples/enterprise_role/enterprise_role_copy.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_role import EnterpriseRoleCopyCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_role_copy(context: KeeperParams, **kwargs): """ Execute enterprise role copy command. @@ -62,6 +83,7 @@ def execute_enterprise_role_copy(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Copy enterprise role using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_role_copy(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_role/enterprise_role_delete.py b/examples/cli_examples/enterprise_role/enterprise_role_delete.py index dcd629b0..3693a786 100644 --- a/examples/cli_examples/enterprise_role/enterprise_role_delete.py +++ b/examples/cli_examples/enterprise_role/enterprise_role_delete.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_role import EnterpriseRoleDeleteCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_role_delete(context: KeeperParams, **kwargs): """ Execute enterprise role delete command. @@ -62,6 +83,7 @@ def execute_enterprise_role_delete(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Delete enterprise role using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_role_delete(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_role/enterprise_role_edit.py b/examples/cli_examples/enterprise_role/enterprise_role_edit.py index 2112c2a9..c8d5215d 100644 --- a/examples/cli_examples/enterprise_role/enterprise_role_edit.py +++ b/examples/cli_examples/enterprise_role/enterprise_role_edit.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_role import EnterpriseRoleEditCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_role_edit(context: KeeperParams, **kwargs): """ Execute enterprise role edit command. @@ -62,6 +83,7 @@ def execute_enterprise_role_edit(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Edit enterprise role using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_role_edit(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_role/enterprise_role_membership.py b/examples/cli_examples/enterprise_role/enterprise_role_membership.py index 39978244..8dfbe9f6 100644 --- a/examples/cli_examples/enterprise_role/enterprise_role_membership.py +++ b/examples/cli_examples/enterprise_role/enterprise_role_membership.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_role import EnterpriseRoleMembershipCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_role_membership(context: KeeperParams, **kwargs): """ Execute enterprise role membership command. @@ -62,6 +83,7 @@ def execute_enterprise_role_membership(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Manage enterprise role membership using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_role_membership(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_role/enterprise_role_view.py b/examples/cli_examples/enterprise_role/enterprise_role_view.py index 91f6e935..ff6fe492 100644 --- a/examples/cli_examples/enterprise_role/enterprise_role_view.py +++ b/examples/cli_examples/enterprise_role/enterprise_role_view.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_role import EnterpriseRoleViewCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_role_view(context: KeeperParams, **kwargs): """ Execute enterprise role view command. @@ -62,6 +83,7 @@ def execute_enterprise_role_view(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='View enterprise role details using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_role_view(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_team/enterprise_team_add.py b/examples/cli_examples/enterprise_team/enterprise_team_add.py index 7973636b..d0e0fb56 100644 --- a/examples/cli_examples/enterprise_team/enterprise_team_add.py +++ b/examples/cli_examples/enterprise_team/enterprise_team_add.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_team import EnterpriseTeamAddCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_team_add(context: KeeperParams, **kwargs): """ Execute enterprise team add command. @@ -62,6 +83,7 @@ def execute_enterprise_team_add(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Add new enterprise team using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_team_add(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_team/enterprise_team_delete.py b/examples/cli_examples/enterprise_team/enterprise_team_delete.py index d6823a41..73fa8a94 100644 --- a/examples/cli_examples/enterprise_team/enterprise_team_delete.py +++ b/examples/cli_examples/enterprise_team/enterprise_team_delete.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_team import EnterpriseTeamDeleteCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_team_delete(context: KeeperParams, **kwargs): """ Execute enterprise team delete command. @@ -62,6 +83,7 @@ def execute_enterprise_team_delete(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Delete enterprise team using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_team_delete(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_team/enterprise_team_edit.py b/examples/cli_examples/enterprise_team/enterprise_team_edit.py index 7488592a..322d65a8 100644 --- a/examples/cli_examples/enterprise_team/enterprise_team_edit.py +++ b/examples/cli_examples/enterprise_team/enterprise_team_edit.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_team import EnterpriseTeamEditCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_team_edit(context: KeeperParams, **kwargs): """ Execute enterprise team edit command. @@ -62,6 +83,7 @@ def execute_enterprise_team_edit(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Edit enterprise team using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_team_edit(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_team/enterprise_team_membership.py b/examples/cli_examples/enterprise_team/enterprise_team_membership.py index 1ef70045..278c4d0f 100644 --- a/examples/cli_examples/enterprise_team/enterprise_team_membership.py +++ b/examples/cli_examples/enterprise_team/enterprise_team_membership.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_team import EnterpriseTeamMembershipCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_team_membership(context: KeeperParams, **kwargs): """ Execute enterprise team membership command. @@ -62,6 +83,7 @@ def execute_enterprise_team_membership(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Manage enterprise team membership using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_team_membership(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_team/enterprise_team_view.py b/examples/cli_examples/enterprise_team/enterprise_team_view.py index 8fc9945d..b3f8d869 100644 --- a/examples/cli_examples/enterprise_team/enterprise_team_view.py +++ b/examples/cli_examples/enterprise_team/enterprise_team_view.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_team import EnterpriseTeamViewCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_team_view(context: KeeperParams, **kwargs): """ Execute enterprise team view command. @@ -62,6 +83,7 @@ def execute_enterprise_team_view(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='View enterprise team information using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_team_view(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_user/create_user.py b/examples/cli_examples/enterprise_user/create_user.py index f9de4e05..b9043859 100644 --- a/examples/cli_examples/enterprise_user/create_user.py +++ b/examples/cli_examples/enterprise_user/create_user.py @@ -21,12 +21,31 @@ from typing import Optional from keepercli.commands.enterprise_user import EnterpriseUserAddCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -39,16 +58,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def create_enterprise_user( context: KeeperParams, email: str, @@ -121,10 +142,11 @@ def create_enterprise_user( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_user/enterprise_user_action.py b/examples/cli_examples/enterprise_user/enterprise_user_action.py index 7d4f182d..81d889f4 100644 --- a/examples/cli_examples/enterprise_user/enterprise_user_action.py +++ b/examples/cli_examples/enterprise_user/enterprise_user_action.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_user import EnterpriseUserActionCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_user_action(context: KeeperParams, **kwargs): """ Execute enterprise user action command. @@ -62,6 +83,7 @@ def execute_enterprise_user_action(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Perform actions on enterprise users using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_user_action(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_user/enterprise_user_add.py b/examples/cli_examples/enterprise_user/enterprise_user_add.py index cf9259bd..4b7b5c74 100644 --- a/examples/cli_examples/enterprise_user/enterprise_user_add.py +++ b/examples/cli_examples/enterprise_user/enterprise_user_add.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_user import EnterpriseUserAddCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_user_add(context: KeeperParams, **kwargs): """ Execute enterprise user add command. @@ -62,6 +83,7 @@ def execute_enterprise_user_add(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Add new enterprise user using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_user_add(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_user/enterprise_user_alias.py b/examples/cli_examples/enterprise_user/enterprise_user_alias.py index 38559787..eb8e97ee 100644 --- a/examples/cli_examples/enterprise_user/enterprise_user_alias.py +++ b/examples/cli_examples/enterprise_user/enterprise_user_alias.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_user import EnterpriseUserAliasCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_user_alias(context: KeeperParams, **kwargs): """ Execute enterprise user alias command. @@ -62,6 +83,7 @@ def execute_enterprise_user_alias(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Manage enterprise user aliases using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_user_alias(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_user/enterprise_user_delete.py b/examples/cli_examples/enterprise_user/enterprise_user_delete.py index 7a7a1dbe..5dc65a8c 100644 --- a/examples/cli_examples/enterprise_user/enterprise_user_delete.py +++ b/examples/cli_examples/enterprise_user/enterprise_user_delete.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_user import EnterpriseUserDeleteCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_user_delete(context: KeeperParams, **kwargs): """ Execute enterprise user delete command. @@ -62,6 +83,7 @@ def execute_enterprise_user_delete(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Delete enterprise user using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_user_delete(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -108,4 +131,4 @@ def execute_enterprise_user_delete(context: KeeperParams, **kwargs): sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/enterprise_user/enterprise_user_edit.py b/examples/cli_examples/enterprise_user/enterprise_user_edit.py index 7dbe4993..03e469f7 100644 --- a/examples/cli_examples/enterprise_user/enterprise_user_edit.py +++ b/examples/cli_examples/enterprise_user/enterprise_user_edit.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_user import EnterpriseUserEditCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_user_edit(context: KeeperParams, **kwargs): """ Execute enterprise user edit command. @@ -62,6 +83,7 @@ def execute_enterprise_user_edit(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Edit enterprise user using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_user_edit(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_user/enterprise_user_view.py b/examples/cli_examples/enterprise_user/enterprise_user_view.py index e306f0e5..ddb615b5 100644 --- a/examples/cli_examples/enterprise_user/enterprise_user_view.py +++ b/examples/cli_examples/enterprise_user/enterprise_user_view.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.enterprise_user import EnterpriseUserViewCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_enterprise_user_view(context: KeeperParams, **kwargs): """ Execute enterprise user view command. @@ -62,6 +83,7 @@ def execute_enterprise_user_view(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='View enterprise user details using Keeper SDK', @@ -72,10 +94,11 @@ def execute_enterprise_user_view(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/enterprise_user/transfer_user.py b/examples/cli_examples/enterprise_user/transfer_user.py index 2d7cefc9..114cfe98 100644 --- a/examples/cli_examples/enterprise_user/transfer_user.py +++ b/examples/cli_examples/enterprise_user/transfer_user.py @@ -20,28 +20,52 @@ import logging from keepercli.commands.transfer_account import EnterpriseTransferAccountCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ - Login to Keeper with a configuration file and return an authenticated context. + Login to Keeper with a configuration file. + + This function logs in to Keeper using the provided configuration file. + It reads the configuration file, extracts the username, + and returns a Authenticated KeeperParams Context object. """ if not os.path.exists(filename): raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context @@ -55,10 +79,11 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/folder/list_sf.py b/examples/cli_examples/folder/list_sf.py index ecdbad54..bcaf1a04 100644 --- a/examples/cli_examples/folder/list_sf.py +++ b/examples/cli_examples/folder/list_sf.py @@ -21,12 +21,31 @@ from typing import Optional from keepercli.commands.vault_record import SharedFolderListCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -39,16 +58,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def list_shared_folders( context: KeeperParams, criteria: Optional[str] = None, @@ -85,10 +106,11 @@ def list_shared_folders( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -115,4 +137,4 @@ def list_shared_folders( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/folder/share_folder.py b/examples/cli_examples/folder/share_folder.py index 04934c34..663a8a99 100644 --- a/examples/cli_examples/folder/share_folder.py +++ b/examples/cli_examples/folder/share_folder.py @@ -20,9 +20,27 @@ from typing import Optional from keepercli.commands.share_management import ShareFolderCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -35,16 +53,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def share_folder_with_user( context: KeeperParams, folder_uid: str, @@ -98,6 +118,7 @@ def share_folder_with_user( print(f'Error sharing folder: {str(e)}') return False + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Share a folder with another user using Keeper SDK', @@ -108,10 +129,11 @@ def share_folder_with_user( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -148,4 +170,4 @@ def share_folder_with_user( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/folder/transform_folder.py b/examples/cli_examples/folder/transform_folder.py index 5c6a5e60..f334ad0f 100644 --- a/examples/cli_examples/folder/transform_folder.py +++ b/examples/cli_examples/folder/transform_folder.py @@ -21,12 +21,31 @@ from typing import Optional from keepercli.commands.vault_folder import FolderTransformCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -39,16 +58,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def transform_folder( context: KeeperParams, folder: str, @@ -92,10 +113,11 @@ def transform_folder( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -130,4 +152,4 @@ def transform_folder( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/importing_exporting/apply_membership.py b/examples/cli_examples/importing_exporting/apply_membership.py index 8e5516be..4bbb71d3 100644 --- a/examples/cli_examples/importing_exporting/apply_membership.py +++ b/examples/cli_examples/importing_exporting/apply_membership.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.importer_commands import ApplyMembershipCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_apply_membership(context: KeeperParams, **kwargs): """ Execute apply membership command. @@ -62,6 +83,7 @@ def execute_apply_membership(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Apply shared folder membership using Keeper SDK', @@ -72,10 +94,11 @@ def execute_apply_membership(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/importing_exporting/download_membership.py b/examples/cli_examples/importing_exporting/download_membership.py index bb00b8f3..7b9869ae 100644 --- a/examples/cli_examples/importing_exporting/download_membership.py +++ b/examples/cli_examples/importing_exporting/download_membership.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.importer_commands import DownloadMembershipCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_download_membership(context: KeeperParams, **kwargs): """ Execute download membership command. @@ -62,6 +83,7 @@ def execute_download_membership(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Download shared folder membership using Keeper SDK', @@ -72,10 +94,11 @@ def execute_download_membership(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/importing_exporting/export_data.py b/examples/cli_examples/importing_exporting/export_data.py index 5d4af0b7..5678f002 100644 --- a/examples/cli_examples/importing_exporting/export_data.py +++ b/examples/cli_examples/importing_exporting/export_data.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.importer_commands import ExportCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_export_data(context: KeeperParams, **kwargs): """ Execute export data command. @@ -62,6 +83,7 @@ def execute_export_data(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Export data from Keeper vault using Keeper SDK', @@ -72,10 +94,11 @@ def execute_export_data(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/importing_exporting/import_data.py b/examples/cli_examples/importing_exporting/import_data.py index d5d422a7..2643a5c8 100644 --- a/examples/cli_examples/importing_exporting/import_data.py +++ b/examples/cli_examples/importing_exporting/import_data.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.importer_commands import ImportCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_import_data(context: KeeperParams, **kwargs): """ Execute import data command. @@ -62,6 +83,7 @@ def execute_import_data(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Import data into Keeper vault using Keeper SDK', @@ -72,10 +94,11 @@ def execute_import_data(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/miscellaneous/clipboard_copy.py b/examples/cli_examples/miscellaneous/clipboard_copy.py index 4d8d9a89..cc055b6a 100644 --- a/examples/cli_examples/miscellaneous/clipboard_copy.py +++ b/examples/cli_examples/miscellaneous/clipboard_copy.py @@ -21,12 +21,30 @@ from typing import Optional from keepercli.commands.record_handling_commands import ClipboardCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -39,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def clipboard_copy( context: KeeperParams, record_uid: str, @@ -96,10 +116,11 @@ def clipboard_copy( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -138,4 +159,4 @@ def clipboard_copy( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/miscellaneous/find_ownerless.py b/examples/cli_examples/miscellaneous/find_ownerless.py index f51cb16c..f6b12905 100644 --- a/examples/cli_examples/miscellaneous/find_ownerless.py +++ b/examples/cli_examples/miscellaneous/find_ownerless.py @@ -21,12 +21,31 @@ from typing import Optional from keepercli.commands.register import FindOwnerlessCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -39,16 +58,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def find_ownerless( context: KeeperParams, claim: Optional[bool] = None, @@ -87,10 +108,11 @@ def find_ownerless( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -119,4 +141,4 @@ def find_ownerless( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/miscellaneous/list_team.py b/examples/cli_examples/miscellaneous/list_team.py index 3f422211..6a0e8396 100644 --- a/examples/cli_examples/miscellaneous/list_team.py +++ b/examples/cli_examples/miscellaneous/list_team.py @@ -21,12 +21,31 @@ from typing import Optional from keepercli.commands.vault_record import TeamListCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -39,16 +58,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def list_teams( context: KeeperParams, verbose: Optional[bool] = None, @@ -89,10 +110,11 @@ def list_teams( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -123,4 +145,4 @@ def list_teams( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/miscellaneous/password-report.py b/examples/cli_examples/miscellaneous/password-report.py index 94919480..f60b6f75 100644 --- a/examples/cli_examples/miscellaneous/password-report.py +++ b/examples/cli_examples/miscellaneous/password-report.py @@ -14,15 +14,37 @@ # import argparse +import logging import json import os import sys from typing import Optional from keepercli.commands.password_report import PasswordReportCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -35,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def password_report( context: KeeperParams, format: Optional[str] = None, @@ -98,10 +122,11 @@ def password_report( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -144,4 +169,4 @@ def password_report( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/one_time_share/create_one_time_share.py b/examples/cli_examples/one_time_share/create_one_time_share.py index 27dd2f84..cc4fc005 100644 --- a/examples/cli_examples/one_time_share/create_one_time_share.py +++ b/examples/cli_examples/one_time_share/create_one_time_share.py @@ -15,13 +15,36 @@ import argparse import json +import logging import os import sys from keepercli.commands.share_management import OneTimeShareCreateCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -34,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_one_time_share_create(context: KeeperParams, **kwargs): """ Execute one-time share create command. @@ -58,6 +83,7 @@ def execute_one_time_share_create(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Create a one-time share URL for a record using Keeper SDK', @@ -68,10 +94,11 @@ def execute_one_time_share_create(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/one_time_share/list_one_time_shares.py b/examples/cli_examples/one_time_share/list_one_time_shares.py index 83d84865..39f81841 100644 --- a/examples/cli_examples/one_time_share/list_one_time_shares.py +++ b/examples/cli_examples/one_time_share/list_one_time_shares.py @@ -15,13 +15,36 @@ import argparse import json +import logging import os import sys from keepercli.commands.share_management import OneTimeShareListCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -34,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_one_time_share_list(context: KeeperParams, **kwargs): """ Execute one-time share list command. @@ -58,6 +83,7 @@ def execute_one_time_share_list(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='List one-time shares for a record using Keeper SDK', @@ -68,10 +94,11 @@ def execute_one_time_share_list(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/one_time_share/remove_one_time_share.py b/examples/cli_examples/one_time_share/remove_one_time_share.py index 0ef96c68..ec2a8d1e 100644 --- a/examples/cli_examples/one_time_share/remove_one_time_share.py +++ b/examples/cli_examples/one_time_share/remove_one_time_share.py @@ -15,13 +15,36 @@ import argparse import json +import logging import os import sys from keepercli.commands.share_management import OneTimeShareRemoveCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -34,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_one_time_share_remove(context: KeeperParams, **kwargs): """ Execute one-time share remove command. @@ -58,6 +83,7 @@ def execute_one_time_share_remove(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Remove a one-time share URL for a record using Keeper SDK', @@ -68,10 +94,11 @@ def execute_one_time_share_remove(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -106,4 +133,4 @@ def execute_one_time_share_remove(context: KeeperParams, **kwargs): sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/password/advanced_password_generation.py b/examples/cli_examples/password/advanced_password_generation.py index 62a50ce5..a8cf9a4d 100644 --- a/examples/cli_examples/password/advanced_password_generation.py +++ b/examples/cli_examples/password/advanced_password_generation.py @@ -18,15 +18,16 @@ import os import sys import logging -from typing import Optional from keepercli.commands.password_generate import PasswordGenerateCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + def get_default_config_path() -> str: """ Get the default config file path following the same logic as JsonFileLoader. @@ -43,6 +44,7 @@ def get_default_config_path() -> str: os.mkdir(keeper_dir) return os.path.join(keeper_dir, file_name) + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -55,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def generate_advanced_passwords(context: KeeperParams): """ Generate advanced passwords with complexity rules and BreachWatch scanning. diff --git a/examples/cli_examples/password/basic_password_generation.py b/examples/cli_examples/password/basic_password_generation.py index 250d0e80..aa5f3935 100644 --- a/examples/cli_examples/password/basic_password_generation.py +++ b/examples/cli_examples/password/basic_password_generation.py @@ -20,12 +20,14 @@ import logging from keepercli.commands.password_generate import PasswordGenerateCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + def get_default_config_path() -> str: """ Get the default config file path following the same logic as JsonFileLoader. @@ -42,6 +44,7 @@ def get_default_config_path() -> str: os.mkdir(keeper_dir) return os.path.join(keeper_dir, file_name) + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -54,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def generate_basic_passwords(context: KeeperParams): """ Generate basic random passwords. diff --git a/examples/cli_examples/password/comprehensive_password_generation.py b/examples/cli_examples/password/comprehensive_password_generation.py index 7d6e3a87..4bcb6e32 100644 --- a/examples/cli_examples/password/comprehensive_password_generation.py +++ b/examples/cli_examples/password/comprehensive_password_generation.py @@ -20,9 +20,14 @@ import logging from keepercli.commands.password_generate import PasswordGenerateCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + def get_default_config_path() -> str: """ Get the default config file path following the same logic as JsonFileLoader. @@ -39,8 +44,6 @@ def get_default_config_path() -> str: os.mkdir(keeper_dir) return os.path.join(keeper_dir, file_name) -logging.basicConfig(level=logging.INFO, format='%(message)s') -logger = logging.getLogger(__name__) def login_to_keeper_with_config(filename: str) -> KeeperParams: """ @@ -54,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def demonstrate_all_password_types(context: KeeperParams): """ Demonstrate all available password generation types and features. @@ -197,6 +202,7 @@ def demonstrate_all_password_types(context: KeeperParams): print("DEMONSTRATION COMPLETE") print("="*80) + def generate_custom_passwords( context: KeeperParams, password_type: str, diff --git a/examples/cli_examples/password/crypto_password_generation.py b/examples/cli_examples/password/crypto_password_generation.py index ba5c802d..9fe19b19 100644 --- a/examples/cli_examples/password/crypto_password_generation.py +++ b/examples/cli_examples/password/crypto_password_generation.py @@ -20,12 +20,14 @@ import logging from keepercli.commands.password_generate import PasswordGenerateCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + def get_default_config_path() -> str: """ Get the default config file path following the same logic as JsonFileLoader. @@ -42,6 +44,7 @@ def get_default_config_path() -> str: os.mkdir(keeper_dir) return os.path.join(keeper_dir, file_name) + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -54,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def generate_crypto_passwords(context: KeeperParams): """ Generate crypto-style strong passwords. diff --git a/examples/cli_examples/password/diceware_password_generation.py b/examples/cli_examples/password/diceware_password_generation.py index 99e2fdc2..a65c8345 100644 --- a/examples/cli_examples/password/diceware_password_generation.py +++ b/examples/cli_examples/password/diceware_password_generation.py @@ -20,12 +20,14 @@ import logging from keepercli.commands.password_generate import PasswordGenerateCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + def get_default_config_path() -> str: """ Get the default config file path following the same logic as JsonFileLoader. @@ -42,6 +44,7 @@ def get_default_config_path() -> str: os.mkdir(keeper_dir) return os.path.join(keeper_dir, file_name) + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -54,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def generate_diceware_passwords(context: KeeperParams): """ Generate diceware passwords. diff --git a/examples/cli_examples/password/recovery_phrase_generation.py b/examples/cli_examples/password/recovery_phrase_generation.py index 0ddac9fb..feb7a87f 100644 --- a/examples/cli_examples/password/recovery_phrase_generation.py +++ b/examples/cli_examples/password/recovery_phrase_generation.py @@ -20,12 +20,14 @@ import logging from keepercli.commands.password_generate import PasswordGenerateCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + def get_default_config_path() -> str: """ Get the default config file path following the same logic as JsonFileLoader. @@ -42,6 +44,7 @@ def get_default_config_path() -> str: os.mkdir(keeper_dir) return os.path.join(keeper_dir, file_name) + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -54,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def generate_recovery_phrases(context: KeeperParams): """ Generate 24-word recovery phrases. diff --git a/examples/cli_examples/record/add_record.py b/examples/cli_examples/record/add_record.py index 1f85a1f3..57c1c132 100644 --- a/examples/cli_examples/record/add_record.py +++ b/examples/cli_examples/record/add_record.py @@ -17,12 +17,35 @@ import json import os import sys +import logging from typing import Optional from keepersdk.vault import vault_record, record_management, vault_online -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -35,16 +58,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def add_record( vault: vault_online.VaultOnline, title: str, @@ -99,10 +124,11 @@ def add_record( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -128,4 +154,4 @@ def add_record( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/record/delete_attachment.py b/examples/cli_examples/record/delete_attachment.py index 5851a6d8..4fcecd01 100644 --- a/examples/cli_examples/record/delete_attachment.py +++ b/examples/cli_examples/record/delete_attachment.py @@ -20,12 +20,29 @@ import logging from keepercli.commands.record_edit import RecordDeleteAttachmentCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +55,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_delete_attachment(context: KeeperParams, **kwargs): """ Execute delete attachment command. @@ -62,6 +81,7 @@ def execute_delete_attachment(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Delete attachments from a record using Keeper SDK', @@ -72,10 +92,11 @@ def execute_delete_attachment(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/record/delete_record.py b/examples/cli_examples/record/delete_record.py index 48fd1575..1e83e8ed 100644 --- a/examples/cli_examples/record/delete_record.py +++ b/examples/cli_examples/record/delete_record.py @@ -17,12 +17,35 @@ import json import os import sys +import logging from typing import Optional from keepersdk.vault import vault_types, record_management, vault_online -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -35,16 +58,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def delete_record(vault: vault_online.VaultOnline, record_uid: str, force: bool = False): """ Delete a record from the Keeper vault. @@ -106,10 +131,11 @@ def find_record_by_title(vault: vault_online.VaultOnline, title: str) -> Optiona ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -142,4 +168,4 @@ def find_record_by_title(vault: vault_online.VaultOnline, title: str) -> Optiona sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/record/download_attachment.py b/examples/cli_examples/record/download_attachment.py index dc21a465..eef54e73 100644 --- a/examples/cli_examples/record/download_attachment.py +++ b/examples/cli_examples/record/download_attachment.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.record_edit import RecordDownloadAttachmentCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_download_attachment(context: KeeperParams, **kwargs): """ Execute download attachment command. @@ -62,6 +83,7 @@ def execute_download_attachment(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Download record attachments using Keeper SDK', @@ -72,10 +94,11 @@ def execute_download_attachment(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/record/file_report.py b/examples/cli_examples/record/file_report.py index 5bed6cc0..04086f79 100644 --- a/examples/cli_examples/record/file_report.py +++ b/examples/cli_examples/record/file_report.py @@ -21,12 +21,31 @@ from typing import Optional from keepercli.commands.record_file_report import RecordFileReportCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -39,16 +58,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def file_report( context: KeeperParams, try_download: Optional[bool] = None, @@ -83,10 +104,11 @@ def file_report( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -111,4 +133,4 @@ def file_report( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/record/find_duplicate.py b/examples/cli_examples/record/find_duplicate.py index 047120ae..24f3040f 100644 --- a/examples/cli_examples/record/find_duplicate.py +++ b/examples/cli_examples/record/find_duplicate.py @@ -20,13 +20,32 @@ import logging from typing import Optional -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow from keepercli.commands.record_handling_commands import FindDuplicateCommand + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -39,16 +58,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def find_duplicate_records( context: KeeperParams, match_by_title: Optional[bool] = None, @@ -116,10 +137,11 @@ def find_duplicate_records( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/record/get_command.py b/examples/cli_examples/record/get_command.py index 0ae9c653..31f6c6be 100644 --- a/examples/cli_examples/record/get_command.py +++ b/examples/cli_examples/record/get_command.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.record_edit import RecordGetCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def get( context: KeeperParams, uid: str, @@ -72,6 +93,7 @@ def get( print(f'Error getting record/folder/team details: {str(e)}') return False + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Get record/folder/team details using Keeper SDK', @@ -82,10 +104,11 @@ def get( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -114,4 +137,5 @@ def get( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() + \ No newline at end of file diff --git a/examples/cli_examples/record/list_records.py b/examples/cli_examples/record/list_records.py index da31745c..0ee5c9e8 100644 --- a/examples/cli_examples/record/list_records.py +++ b/examples/cli_examples/record/list_records.py @@ -17,12 +17,35 @@ import json import os import sys +import logging from typing import Optional from keepercli.commands.vault_record import RecordListCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -35,16 +58,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def list_records( context: KeeperParams, show_details: bool = False, @@ -86,10 +111,11 @@ def list_records( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -118,4 +144,4 @@ def list_records( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/record/record_history.py b/examples/cli_examples/record/record_history.py index ca1f68fd..edea5a97 100644 --- a/examples/cli_examples/record/record_history.py +++ b/examples/cli_examples/record/record_history.py @@ -21,12 +21,31 @@ from typing import Optional from keepercli.commands.record_handling_commands import RecordHistoryCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -39,16 +58,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def record_history( context: KeeperParams, record_uid: str, @@ -89,10 +110,11 @@ def record_history( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -123,4 +145,4 @@ def record_history( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/record/search_record.py b/examples/cli_examples/record/search_record.py index 412f34ae..1953eb87 100644 --- a/examples/cli_examples/record/search_record.py +++ b/examples/cli_examples/record/search_record.py @@ -20,12 +20,31 @@ from typing import Optional from keepersdk.vault import vault_online -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def search( vault: vault_online.VaultOnline, pattern: str, @@ -70,6 +91,7 @@ def search( print(f'Error searching for records: {str(e)}') return False + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Search for records using Keeper SDK', @@ -80,10 +102,11 @@ def search( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -116,4 +139,4 @@ def search( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/record/share_record.py b/examples/cli_examples/record/share_record.py index 0dbc2de6..b0163413 100644 --- a/examples/cli_examples/record/share_record.py +++ b/examples/cli_examples/record/share_record.py @@ -17,11 +17,34 @@ import json import os import sys +import logging from keepercli.commands.share_management import ShareRecordCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -34,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def share_record_with_user( context: KeeperParams, record_uid: str, @@ -97,6 +122,7 @@ def share_record_with_user( print(f'Error sharing record: {str(e)}') return False + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Share a record with another user using Keeper SDK', @@ -107,10 +133,11 @@ def share_record_with_user( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -148,4 +175,4 @@ def share_record_with_user( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/record/share_record_permissions.py b/examples/cli_examples/record/share_record_permissions.py index b25c89c6..8e3e4ca0 100644 --- a/examples/cli_examples/record/share_record_permissions.py +++ b/examples/cli_examples/record/share_record_permissions.py @@ -18,15 +18,34 @@ import os import sys import logging -from typing import Optional, List +from typing import Optional from keepercli.commands.record_handling_commands import RecordPermissionCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -39,16 +58,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def manage_record_permissions( context: KeeperParams, folder: Optional[str] = None, @@ -116,10 +137,11 @@ def manage_record_permissions( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/record/update_record.py b/examples/cli_examples/record/update_record.py index cac52871..124a99b1 100644 --- a/examples/cli_examples/record/update_record.py +++ b/examples/cli_examples/record/update_record.py @@ -17,12 +17,35 @@ import json import os import sys +import logging from typing import Optional, Dict, Any from keepersdk.vault import vault_record, record_management, vault_online -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -35,16 +58,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def find_record_by_criteria( vault: vault_online.VaultOnline, criteria: str, @@ -221,10 +246,11 @@ def update_record(vault: vault_online.VaultOnline, record_criteria: str, updates ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -259,4 +285,4 @@ def update_record(vault: vault_online.VaultOnline, record_criteria: str, updates sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/record/upload_attachment.py b/examples/cli_examples/record/upload_attachment.py index f9c68517..5e5d0756 100644 --- a/examples/cli_examples/record/upload_attachment.py +++ b/examples/cli_examples/record/upload_attachment.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.record_edit import RecordUploadAttachmentCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def execute_upload_attachment(context: KeeperParams, **kwargs): """ Execute upload attachment command. @@ -62,6 +83,7 @@ def execute_upload_attachment(context: KeeperParams, **kwargs): except Exception as e: raise Exception(f'Error: {str(e)}') + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Upload attachments to a record using Keeper SDK', @@ -72,10 +94,11 @@ def execute_upload_attachment(context: KeeperParams, **kwargs): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() diff --git a/examples/cli_examples/secrets_manager_app/create_secrets_manager_app.py b/examples/cli_examples/secrets_manager_app/create_secrets_manager_app.py index dcd1c5a5..766469f2 100644 --- a/examples/cli_examples/secrets_manager_app/create_secrets_manager_app.py +++ b/examples/cli_examples/secrets_manager_app/create_secrets_manager_app.py @@ -17,11 +17,34 @@ import json import os import sys +import logging from keepersdk.vault import ksm_management, vault_online -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -34,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def create_secrets_manager_app( vault: vault_online.VaultOnline, app_name: str, @@ -74,6 +99,7 @@ def create_secrets_manager_app( print(f'Error creating Secrets Manager application {app_name}: {str(e)}') return None + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Create a Secrets Manager application using Keeper SDK', @@ -84,10 +110,11 @@ def create_secrets_manager_app( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -112,4 +139,4 @@ def create_secrets_manager_app( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/secrets_manager_app/get_secrets_manager_app.py b/examples/cli_examples/secrets_manager_app/get_secrets_manager_app.py index d1ec11ae..699f117b 100644 --- a/examples/cli_examples/secrets_manager_app/get_secrets_manager_app.py +++ b/examples/cli_examples/secrets_manager_app/get_secrets_manager_app.py @@ -20,12 +20,31 @@ import sys from keepersdk.vault import ksm_management, vault_online -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def print_client_device_info(client_devices): """Print client device information in a table-like format.""" for index, client_device in enumerate(client_devices, start=1): @@ -63,6 +84,7 @@ def print_client_device_info(client_devices): f' IP Address: {client_device.ip_address or "--"}' logger.info(client_devices_str) + def print_shared_secrets_info(shared_secrets): """Print shared secrets information in a table-like format.""" if not shared_secrets: @@ -80,6 +102,7 @@ def print_shared_secrets_info(shared_secrets): permissions = str(secrets.permissions) logger.info(f"{share_type:<15} {uid:<25} {name:<30} {permissions}") + def get_secrets_manager_app(vault: vault_online.VaultOnline, app_id: str): """Retrieve and display Secrets Manager application details by UID or title.""" try: @@ -110,6 +133,7 @@ def get_secrets_manager_app(vault: vault_online.VaultOnline, app_id: str): logger.error(f'Error getting Secrets Manager application {app_id}: {str(e)}') return None + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Get details of a Secrets Manager application using Keeper SDK', @@ -120,10 +144,11 @@ def get_secrets_manager_app(vault: vault_online.VaultOnline, app_id: str): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -149,4 +174,4 @@ def get_secrets_manager_app(vault: vault_online.VaultOnline, app_id: str): sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/secrets_manager_app/list_secrets_manager_apps.py b/examples/cli_examples/secrets_manager_app/list_secrets_manager_apps.py index 07ada892..566570d9 100644 --- a/examples/cli_examples/secrets_manager_app/list_secrets_manager_apps.py +++ b/examples/cli_examples/secrets_manager_app/list_secrets_manager_apps.py @@ -20,12 +20,31 @@ import sys from keepersdk.vault import ksm_management, vault_online -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def print_apps_table(apps): """Print applications in a table-like format with key attributes.""" if not apps: @@ -67,6 +88,7 @@ def print_apps_table(apps): logger.info(f"{app_name:<20} {app_uid:<25} {records:<8} {folders:<8} {devices:<8} {last_access}") + def list_secrets_manager_apps(vault: vault_online.VaultOnline): """ List all Secrets Manager applications in the Keeper vault. @@ -89,6 +111,7 @@ def list_secrets_manager_apps(vault: vault_online.VaultOnline): logger.error(f'Error listing Secrets Manager applications: {str(e)}') return None + if __name__ == '__main__': parser = argparse.ArgumentParser( description='List Secrets Manager applications using Keeper SDK', @@ -99,10 +122,11 @@ def list_secrets_manager_apps(vault: vault_online.VaultOnline): ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -121,4 +145,4 @@ def list_secrets_manager_apps(vault: vault_online.VaultOnline): sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/secrets_manager_app/remove_secrets_manager_app.py b/examples/cli_examples/secrets_manager_app/remove_secrets_manager_app.py index 2ec4d557..d17f7595 100644 --- a/examples/cli_examples/secrets_manager_app/remove_secrets_manager_app.py +++ b/examples/cli_examples/secrets_manager_app/remove_secrets_manager_app.py @@ -17,11 +17,34 @@ import json import os import sys +import logging from keepersdk.vault import ksm_management, vault_online -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -34,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def remove_secrets_manager_app( vault: vault_online.VaultOnline, uid_or_name: str, @@ -97,6 +122,7 @@ def remove_secrets_manager_app( print(f'Error removing Secrets Manager application {uid_or_name}: {str(e)}') return None + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Remove a Secrets Manager application using Keeper SDK', @@ -107,10 +133,11 @@ def remove_secrets_manager_app( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -134,4 +161,4 @@ def remove_secrets_manager_app( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/secrets_manager_app/secrets_manager_app_add_record.py b/examples/cli_examples/secrets_manager_app/secrets_manager_app_add_record.py index b49c388d..cd75e6c8 100644 --- a/examples/cli_examples/secrets_manager_app/secrets_manager_app_add_record.py +++ b/examples/cli_examples/secrets_manager_app/secrets_manager_app_add_record.py @@ -17,12 +17,35 @@ import json import os import sys +import logging from typing import List from keepercli.commands.secrets_manager import SecretsManagerShareCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -35,16 +58,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def add_secrets_to_app( context: KeeperParams, app_id: str, @@ -86,6 +111,7 @@ def add_secrets_to_app( print(f'Error adding secrets to Secrets Manager application: {str(e)}') return False + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Add secrets to a Secrets Manager application using Keeper SDK', @@ -96,10 +122,11 @@ def add_secrets_to_app( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -132,4 +159,4 @@ def add_secrets_to_app( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/secrets_manager_app/secrets_manager_app_remove_record.py b/examples/cli_examples/secrets_manager_app/secrets_manager_app_remove_record.py index 8471ee53..e12bb281 100644 --- a/examples/cli_examples/secrets_manager_app/secrets_manager_app_remove_record.py +++ b/examples/cli_examples/secrets_manager_app/secrets_manager_app_remove_record.py @@ -17,13 +17,35 @@ import json import os import sys +import logging from typing import List -from keepersdk.vault import ksm_management from keepercli.commands.secrets_manager import SecretsManagerShareCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -36,16 +58,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def remove_secrets_from_app( context: KeeperParams, app_id: str, @@ -83,6 +107,7 @@ def remove_secrets_from_app( print(f'Error removing secrets from Secrets Manager application: {str(e)}') return False + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Remove secrets from a Secrets Manager application using Keeper SDK', @@ -93,10 +118,11 @@ def remove_secrets_from_app( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -127,4 +153,4 @@ def remove_secrets_from_app( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/secrets_manager_app/secrets_manager_client_add.py b/examples/cli_examples/secrets_manager_app/secrets_manager_client_add.py index 9713efc6..10abcdfb 100644 --- a/examples/cli_examples/secrets_manager_app/secrets_manager_client_add.py +++ b/examples/cli_examples/secrets_manager_app/secrets_manager_client_add.py @@ -17,12 +17,36 @@ import json import os import sys +import logging from typing import Optional + from keepercli.commands.secrets_manager import SecretsManagerClientCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -35,16 +59,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def add_client_to_app( context: KeeperParams, app_id: str, @@ -112,10 +138,11 @@ def add_client_to_app( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -156,4 +183,4 @@ def add_client_to_app( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/secrets_manager_app/secrets_manager_client_remove.py b/examples/cli_examples/secrets_manager_app/secrets_manager_client_remove.py index 93ef1314..adefa83f 100644 --- a/examples/cli_examples/secrets_manager_app/secrets_manager_client_remove.py +++ b/examples/cli_examples/secrets_manager_app/secrets_manager_client_remove.py @@ -17,13 +17,35 @@ import json import os import sys +import logging from typing import List -from keepersdk.vault import ksm_management from keepercli.commands.secrets_manager import SecretsManagerClientCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -36,14 +58,15 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context @@ -87,6 +110,8 @@ def remove_client_from_app( except Exception as e: print(f'Error removing client from Secrets Manager application: {str(e)}') return False + + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Remove a client from a Secrets Manager application using Keeper SDK', @@ -97,10 +122,11 @@ def remove_client_from_app( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -133,4 +159,4 @@ def remove_client_from_app( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/secrets_manager_app/share_secrets_manager_app.py b/examples/cli_examples/secrets_manager_app/share_secrets_manager_app.py index 0a866bc3..12abff5d 100644 --- a/examples/cli_examples/secrets_manager_app/share_secrets_manager_app.py +++ b/examples/cli_examples/secrets_manager_app/share_secrets_manager_app.py @@ -17,11 +17,34 @@ import json import os import sys +import logging from keepercli.commands.secrets_manager import SecretsManagerAppCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -34,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def share_secrets_manager_app( context: KeeperParams, app_id: str, @@ -79,6 +104,7 @@ def share_secrets_manager_app( print(f'Error sharing Secrets Manager application: {str(e)}') return False + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Share a Secrets Manager application with another user using Keeper SDK', @@ -89,10 +115,11 @@ def share_secrets_manager_app( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -125,4 +152,4 @@ def share_secrets_manager_app( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/secrets_manager_app/unshare_secrets_manager_app.py b/examples/cli_examples/secrets_manager_app/unshare_secrets_manager_app.py index 2a463b6c..b57cb6fb 100644 --- a/examples/cli_examples/secrets_manager_app/unshare_secrets_manager_app.py +++ b/examples/cli_examples/secrets_manager_app/unshare_secrets_manager_app.py @@ -15,15 +15,36 @@ import argparse import json - +import logging import os - import sys from keepercli.commands.secrets_manager import SecretsManagerAppCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -36,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def unshare_secrets_manager_app( context: KeeperParams, app_id: str, @@ -74,6 +97,7 @@ def unshare_secrets_manager_app( print(f'Error unsharing Secrets Manager application: {str(e)}') return False + if __name__ == '__main__': parser = argparse.ArgumentParser( description='Unshare a Secrets Manager application from a user using Keeper SDK', @@ -84,10 +108,11 @@ def unshare_secrets_manager_app( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -118,4 +143,4 @@ def unshare_secrets_manager_app( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/trash/trash_get.py b/examples/cli_examples/trash/trash_get.py index bd3dcd5e..bbef8c8f 100644 --- a/examples/cli_examples/trash/trash_get.py +++ b/examples/cli_examples/trash/trash_get.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.trash import TrashGetCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def get_trash_record( context: KeeperParams, record_uid: str, @@ -83,10 +104,11 @@ def get_trash_record( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -112,4 +134,4 @@ def get_trash_record( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/trash/trash_list.py b/examples/cli_examples/trash/trash_list.py index 55e086d8..9c365b0e 100644 --- a/examples/cli_examples/trash/trash_list.py +++ b/examples/cli_examples/trash/trash_list.py @@ -17,12 +17,35 @@ import json import os import sys +import logging from typing import Optional from keepercli.commands.trash import TrashListCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -35,16 +58,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def list_trash_records( context: KeeperParams, show_details: Optional[bool] = None, @@ -86,10 +111,11 @@ def list_trash_records( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -120,4 +146,4 @@ def list_trash_records( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/trash/trash_purge.py b/examples/cli_examples/trash/trash_purge.py index e92997ca..0a94c12d 100644 --- a/examples/cli_examples/trash/trash_purge.py +++ b/examples/cli_examples/trash/trash_purge.py @@ -20,12 +20,31 @@ import logging from keepercli.commands.trash import TrashPurgeCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -38,16 +57,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def purge_trash( context: KeeperParams, force: bool, @@ -83,10 +104,11 @@ def purge_trash( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -111,4 +133,4 @@ def purge_trash( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/trash/trash_restore.py b/examples/cli_examples/trash/trash_restore.py index fc862ece..66e49561 100644 --- a/examples/cli_examples/trash/trash_restore.py +++ b/examples/cli_examples/trash/trash_restore.py @@ -21,12 +21,31 @@ from typing import Optional from keepercli.commands.trash import TrashRestoreCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -39,16 +58,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def restore_trash_record( context: KeeperParams, record_uid: str, @@ -86,10 +107,11 @@ def restore_trash_record( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -119,4 +141,4 @@ def restore_trash_record( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/examples/cli_examples/trash/trash_unshare.py b/examples/cli_examples/trash/trash_unshare.py index efa01c05..433a149a 100644 --- a/examples/cli_examples/trash/trash_unshare.py +++ b/examples/cli_examples/trash/trash_unshare.py @@ -21,12 +21,31 @@ from typing import Optional from keepercli.commands.trash import TrashUnshareCommand -from keepercli.params import KeeperParams +from keepercli.params import KeeperParams, KeeperConfig from keepercli.login import LoginFlow + logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger(__name__) + +def get_default_config_path() -> str: + """ + Get the default config file path following the same logic as JsonFileLoader. + + First checks if 'config.json' exists in the current directory. + If not, uses ~/.keeper/config.json. + """ + file_name = 'config.json' + if os.path.isfile(file_name): + return os.path.abspath(file_name) + else: + keeper_dir = os.path.join(os.path.expanduser('~'), '.keeper') + if not os.path.exists(keeper_dir): + os.mkdir(keeper_dir) + return os.path.join(keeper_dir, file_name) + + def login_to_keeper_with_config(filename: str) -> KeeperParams: """ Login to Keeper with a configuration file. @@ -39,16 +58,18 @@ def login_to_keeper_with_config(filename: str) -> KeeperParams: raise FileNotFoundError(f'Config file {filename} not found') with open(filename, 'r') as f: config_data = json.load(f) - username = config_data.get('user', config_data.get('username')) - password = config_data.get('password', '') - if not username: - raise ValueError('Username not found in config file') - context = KeeperParams(config_filename=filename, config=config_data) - logged_in = LoginFlow.login(context, username=username, password=password or None, resume_session=bool(username)) - if not logged_in: + + keeper_config = KeeperConfig(config_filename=filename, config=config_data) + auth = LoginFlow.login(keeper_config) + if not auth: raise Exception('Failed to authenticate with Keeper') + + context = KeeperParams(keeper_config=keeper_config) + context.set_auth(auth) + return context + def unshare_trash_record( context: KeeperParams, record_uid: str, @@ -86,10 +107,11 @@ def unshare_trash_record( ''' ) + default_config_path = get_default_config_path() parser.add_argument( '-c', '--config', - default='myconfig.json', - help='Configuration file (default: myconfig.json)' + default=default_config_path, + help=f'Configuration file (default: {default_config_path})' ) args = parser.parse_args() @@ -119,4 +141,4 @@ def unshare_trash_record( sys.exit(1) finally: if context: - context.clear_session() \ No newline at end of file + context.clear_session() diff --git a/keepercli-package/src/keepercli/commands/secrets_manager.py b/keepercli-package/src/keepercli/commands/secrets_manager.py index d15e7c1b..141c4586 100644 --- a/keepercli-package/src/keepercli/commands/secrets_manager.py +++ b/keepercli-package/src/keepercli/commands/secrets_manager.py @@ -1,29 +1,15 @@ import argparse -import datetime from enum import Enum -import hmac -import os import time from typing import Optional -from urllib import parse - -from keepersdk import crypto, utils -from keepersdk.proto.APIRequest_pb2 import ( - AddAppClientRequest, - AddAppSharesRequest, - AppShareAdd, - ApplicationShareType, - Device, - RemoveAppClientsRequest, - RemoveAppSharesRequest -) + +from keepersdk import utils from keepersdk.proto.enterprise_pb2 import GENERAL -from keepersdk.vault import ksm_management, vault_online, share_management_utils, shares_management -from keepersdk.vault.vault_record import TypedRecord +from keepersdk.vault import ksm_management, vault_online from . import base -from .shares import ShareAction, ShareRecordCommand -from .. import api, constants, prompt_utils +from .shares import ShareAction +from .. import api, prompt_utils from ..helpers import ksm_utils, report_utils from ..params import KeeperParams @@ -31,23 +17,9 @@ logger = api.get_logger() -CLIENT_ADD_URL = 'vault/app_client_add' -CLIENT_REMOVE_URL = 'vault/app_client_remove' -SHARE_ADD_URL = 'vault/app_share_add' -SHARE_REMOVE_URL = 'vault/app_share_remove' - - -RECORD = 'Record' -SHARED_FOLDER = 'Shared Folder' - - -CLIENT_ID_COUNTER_BYTES = b'KEEPER_SECRETS_MANAGER_CLIENT_ID' -CLIENT_ID_DIGEST = 'sha512' - MILLISECONDS_PER_MINUTE = 60 * 1000 MILLISECONDS_PER_SECOND = 1000 DEFAULT_FIRST_ACCESS_EXPIRES_IN_MINUTES = 60 -MAX_FIRST_ACCESS_EXPIRES_IN_MINUTES = 1440 DEFAULT_TOKEN_COUNT = 1 @@ -56,9 +28,6 @@ SHARE_ACTION_REMOVE = ShareAction.REMOVE.value -DATE_FORMAT = '%Y-%m-%d %H:%M:%S' - - USER_CHOICE_DEFAULT_NO = 'n' USER_CHOICE_YES = 'y' @@ -208,319 +177,38 @@ def share_app(self, context: KeeperParams, uid_or_name: str, unshare: bool = Fal self._validate_email_parameter(email) app_uid = self._find_app_uid(context.vault, uid_or_name) - share_args = self._build_share_args(app_uid, email, is_admin, unshare) - - self._execute_share_record(context, share_args) - context.vault.sync_down() - - SecretsManagerAppCommand.update_shares_user_permissions(context=context, uid=app_uid, removed=unshare) - - def _validate_email_parameter(self, email: Optional[str]) -> None: - """Validate that email parameter is provided.""" - if not email: - raise ValueError("Email parameter is required for sharing. Use --email='user@example.com' to set it.") - def _find_app_uid(self, vault, uid_or_name: str) -> str: - """Find application UID by name or UID.""" - app_record = next( - (r for r in vault.vault_data.records() - if r.record_uid == uid_or_name or r.title == uid_or_name), - None - ) - - if not app_record: - raise ValueError(f'No application found with UID/Name: {uid_or_name}') - - return app_record.record_uid - - def _build_share_args(self, app_uid: str, email: str, is_admin: bool, unshare: bool) -> dict: - """Build arguments for share record command.""" action = SHARE_ACTION_REVOKE if unshare else SHARE_ACTION_GRANT can_edit = is_admin and not unshare can_share = is_admin and not unshare - - return { - "action": action, - "email": [email], - "record": app_uid, - "can_edit": can_edit, - "can_share": can_share - } - def _execute_share_record(self, context: KeeperParams, share_args: dict) -> None: - """Execute share record command.""" - share_record_command = ShareRecordCommand() - share_record_command.execute(context=context, **share_args) - - @staticmethod - def update_shares_user_permissions(context: KeeperParams, uid: str, removed: bool): - - vault = context.vault - - # Get user permissions for the app - user_perms = SecretsManagerAppCommand._get_app_user_permissions(vault, uid) - - # Get app info and shared secrets - app_infos = ksm_management.get_app_info(vault=vault, app_uid=uid) - app_info = app_infos[0] - if not app_info: - return - - # Separate shared records and folders - shared_recs, shared_folders = SecretsManagerAppCommand._separate_shared_items( - vault, app_info.shares - ) - - # Create share requests for users that need updates - SecretsManagerAppCommand._process_share_updates( - context, vault, user_perms, shared_recs, shared_folders, removed + success_responses, failed_responses = ksm_management.share_secrets_manager_app( + vault=context.vault, enterprise=context.enterprise_data, app_uid=app_uid, emails=[email], action=action, can_edit=can_edit, can_share=can_share ) - - @staticmethod - def _get_app_user_permissions(vault: vault_online.VaultOnline, uid: str) -> list: - """Get user permissions for the application.""" - share_info = share_management_utils.get_record_shares(vault=vault, record_uids=[uid], is_share_admin=False) - user_perms = [] - if share_info: - for record_info in share_info: - if record_info.get('record_uid') == uid: - user_perms = record_info.get('shares', {}).get('user_permissions', []) - break - return user_perms - - @staticmethod - def _separate_shared_items(vault: vault_online.VaultOnline, shared_secrets): - """Separate shared secrets into records and folders.""" - shared_recs = [] - shared_folders = [] - - for share in shared_secrets: - uid_str = utils.base64_url_encode(share.secretUid) - share_type = ApplicationShareType.Name(share.shareType) - - if share_type == ApplicationShareType.SHARE_TYPE_RECORD: - shared_recs.append(uid_str) - elif share_type == ApplicationShareType.SHARE_TYPE_FOLDER: - shared_folders.append(uid_str) - - if shared_recs: - share_management_utils.get_record_shares( - vault=vault, - record_uids=shared_recs, - is_share_admin=False - ) - - return shared_recs, shared_folders - - @staticmethod - def _process_share_updates(context: KeeperParams, vault: vault_online.VaultOnline, - user_perms: list, shared_recs: list, shared_folders: list, removed: bool): - """Process share updates for users.""" - app_users_map = SecretsManagerAppCommand._categorize_app_users(vault, user_perms) - - sf_requests, rec_requests = SecretsManagerAppCommand._build_share_requests( - context, vault, app_users_map, shared_recs, shared_folders, removed - ) - - SecretsManagerAppCommand._send_share_requests(vault, sf_requests, rec_requests) - logger.info("Share updates processed successfully") - - @staticmethod - def _categorize_app_users(vault: vault_online.VaultOnline, user_perms: list) -> dict: - """Categorize users into admins and viewers.""" - current_username = vault.keeper_auth.auth_context.username - admins = [ - up.get('username') for up in user_perms - if up.get('editable') and up.get('username') != current_username - ] - viewers = [ - up.get('username') for up in user_perms - if not up.get('editable') - ] - return dict(admins=admins, viewers=viewers) - - @staticmethod - def _build_share_requests(context: KeeperParams, vault: vault_online.VaultOnline, - app_users_map: dict, shared_recs: list, shared_folders: list, - removed: bool) -> tuple: - """Build share requests for folders and records.""" - sf_requests = [] - rec_requests = [] - all_share_uids = shared_recs + shared_folders - - for users in app_users_map.values(): - users_needing_update = [ - u for u in users - if SecretsManagerAppCommand._user_needs_update(vault, u, all_share_uids, removed) - ] - - if not users_needing_update: - continue - - folder_requests = SecretsManagerAppCommand._create_folder_share_requests( - vault, shared_folders, users_needing_update, removed - ) - if folder_requests: - sf_requests.append(folder_requests) - - record_requests = SecretsManagerAppCommand._create_record_share_requests( - context, shared_recs, users_needing_update, removed - ) - rec_requests.extend(record_requests) - - return sf_requests, rec_requests - - @staticmethod - def _send_share_requests(vault: vault_online.VaultOnline, sf_requests: list, rec_requests: list) -> None: - """Send share requests to the server.""" - success_responses = [] - failed_responses = [] - if sf_requests: - success_responses, failed_responses = shares_management.FolderShares.send_requests(vault, sf_requests) - if rec_requests: - success_responses_rec, failed_responses_rec = shares_management.RecordShares.send_requests(vault, rec_requests) - success_responses.extend(success_responses_rec) - failed_responses.extend(failed_responses_rec) if success_responses: logger.info(f'{len(success_responses)} share requests were successfully processed') if failed_responses: logger.error(f'{len(failed_responses)} share requests failed to process') for failed_response in failed_responses: logger.error(f'Failed to process share request: {failed_response}') - vault.sync_down() - - @staticmethod - def _user_needs_update(vault: vault_online.VaultOnline, user: str, share_uids: list, removed: bool) -> bool: - """Check if a user needs share permission updates.""" - record_permissions = SecretsManagerAppCommand._get_record_permissions(vault, share_uids) - record_cache = {x.record_uid: x for x in vault.vault_data.records()} - - for share_uid in share_uids: - share_user_permissions = SecretsManagerAppCommand._get_share_user_permissions( - vault, share_uid, record_cache, record_permissions - ) - - user_permissions_set = { - up.get('username') for up in share_user_permissions - if isinstance(up, dict) - } - - if user not in user_permissions_set: - return True - return False - - @staticmethod - def _get_record_permissions(vault: vault_online.VaultOnline, share_uids: list) -> dict: - """Get record permissions for given share UIDs.""" - record_share_info = share_management_utils.get_record_shares( - vault=vault, - record_uids=share_uids, - is_share_admin=False - ) - - record_permissions = {} - if record_share_info: - for record_info in record_share_info: - record_uid = record_info.get('record_uid') - if record_uid: - record_permissions[record_uid] = ( - record_info.get('shares', {}).get('user_permissions', []) - ) - return record_permissions - @staticmethod - def _get_share_user_permissions(vault: vault_online.VaultOnline, share_uid: str, - record_cache: dict, record_permissions: dict) -> list: - """Get user permissions for a share (record or folder).""" - is_record_share = share_uid in record_cache - - if is_record_share: - return record_permissions.get(share_uid, []) - - shared_folder_obj = vault.vault_data.load_shared_folder(shared_folder_uid=share_uid) - if shared_folder_obj and shared_folder_obj.user_permissions: - return shared_folder_obj.user_permissions - - return [] - - @staticmethod - def _create_folder_share_requests(vault: vault_online.VaultOnline, shared_folders: list, - users: list, removed: bool) -> list: - """Create folder share requests.""" - if not shared_folders: - return [] - - sf_action = SHARE_ACTION_REMOVE if removed else SHARE_ACTION_GRANT - requests = [] - - for folder_uid in shared_folders: - for user in users: - if SecretsManagerAppCommand._user_needs_update(vault, user, [folder_uid], removed): - request = SecretsManagerAppCommand._build_folder_share_request( - vault, folder_uid, user, sf_action - ) - requests.append(request) - - return requests + def _validate_email_parameter(self, email: Optional[str]) -> None: + """Validate that email parameter is provided.""" + if not email: + raise ValueError("Email parameter is required for sharing. Use --email='user@example.com' to set it.") - @staticmethod - def _build_folder_share_request(vault: vault_online.VaultOnline, folder_uid: str, - user: str, action: str) -> dict: - """Build a single folder share request.""" - shared_folder = vault.vault_data.load_shared_folder(folder_uid) - shared_folder_revision = vault.vault_data.storage.shared_folders.get_entity(folder_uid).revision - sf_unencrypted_key = vault.vault_data.get_shared_folder_key(shared_folder_uid=folder_uid) - - sf_info = { - 'shared_folder_uid': folder_uid, - 'users': shared_folder.user_permissions, - 'teams': [], - 'records': shared_folder.record_permissions, - 'shared_folder_key_unencrypted': sf_unencrypted_key, - 'default_manage_users': shared_folder.default_can_share, - 'default_manage_records': shared_folder.default_can_edit, - 'revision': shared_folder_revision - } - - return shares_management.FolderShares.prepare_request( - vault=vault, - kwargs={'action': action}, - curr_sf=sf_info, - users=[user], - teams=[], - rec_uids=[], - default_record=False, - default_account=False, - share_expiration=-1 + def _find_app_uid(self, vault: vault_online.VaultOnline, uid_or_name: str) -> str: + """Find application UID by name or UID.""" + app_record = next( + (r for r in vault.vault_data.records() + if r.record_uid == uid_or_name or r.title == uid_or_name), + None ) - - @staticmethod - def _create_record_share_requests(context: KeeperParams, shared_recs: list, - users: list, removed: bool) -> list: - """Create record share requests.""" - if not shared_recs or not context.vault: - return [] - - rec_action = SHARE_ACTION_REVOKE if removed else SHARE_ACTION_GRANT - requests = [] - for record_uid in shared_recs: - for user in users: - if SecretsManagerAppCommand._user_needs_update(context.vault, user, [record_uid], removed): - request = shares_management.RecordShares.prep_request( - vault=context.vault, - emails=[user], - action=rec_action, - uid_or_name=record_uid, - share_expiration=-1, - dry_run=False, - enterprise=context.enterprise_data, - can_edit=False, - can_share=False - ) - requests.append(request) + if not app_record: + raise ValueError(f'No application found with UID/Name: {uid_or_name}') - return requests + return app_record.record_uid class SecretsManagerClientCommand(base.ArgparseCommand): @@ -661,7 +349,6 @@ def _is_remove_all_clients(self, client_names_or_ids: list) -> bool: return (len(client_names_or_ids) == 1 and client_names_or_ids[0] in [WILDCARD_ALL, WILDCARD_ALL_ALIAS]) - @staticmethod def add_client( vault: vault_online.VaultOnline, @@ -688,7 +375,7 @@ def add_client( output_lines = [] for i in range(count): - token_data = SecretsManagerClientCommand._generate_single_client( + token_data = ksm_management.KSMClientManagement.add_client_to_ksm_app( vault=vault, uid=uid, client_name=client_name, @@ -712,168 +399,6 @@ def add_client( return tokens - @staticmethod - def _generate_single_client( - vault: vault_online.VaultOnline, - uid: str, - client_name: str, - count: int, - index: int, - unlock_ip: bool, - first_access_expire_duration_ms: int, - access_expire_in_ms: Optional[int], - master_key: bytes, - server: str) -> dict: - """Generate a single client device and return token info and output string.""" - - # Generate secret and client ID - secret_bytes = os.urandom(32) - client_id = SecretsManagerClientCommand._generate_client_id(secret_bytes) - - encrypted_master_key = crypto.encrypt_aes_v2(master_key, secret_bytes) - - # Create and send request - device = SecretsManagerClientCommand._create_client_request( - vault=vault, - uid=uid, - encrypted_master_key=encrypted_master_key, - unlock_ip=unlock_ip, - first_access_expire_duration_ms=first_access_expire_duration_ms, - access_expire_in_ms=access_expire_in_ms, - client_id=client_id, - client_name=client_name, - count=count, - index=index - ) - - # Generate token with server prefix - token_with_prefix = SecretsManagerClientCommand._generate_token_with_prefix( - secret_bytes=secret_bytes, - server=server - ) - - output_string = SecretsManagerClientCommand._create_output_string( - token_with_prefix=token_with_prefix, - client_name=client_name, - unlock_ip=unlock_ip, - first_access_expire_duration_ms=first_access_expire_duration_ms, - access_expire_in_ms=access_expire_in_ms - ) - - return { - 'token_info': { - 'oneTimeToken': token_with_prefix, - 'deviceToken': utils.base64_url_encode(device.encryptedDeviceToken) - }, - 'output_string': output_string - } - - @staticmethod - def _generate_client_id(secret_bytes: bytes) -> bytes: - """Generate client ID using HMAC.""" - try: - return hmac.new( - secret_bytes, - CLIENT_ID_COUNTER_BYTES, - CLIENT_ID_DIGEST - ).digest() - except Exception as e: - logger.error(e) - raise - - @staticmethod - def _create_client_request( - vault: vault_online.VaultOnline, - uid: str, - encrypted_master_key: bytes, - unlock_ip: bool, - first_access_expire_duration_ms: int, - access_expire_in_ms: Optional[int], - client_id: bytes, - client_name: str, - count: int, - index: int) -> Device: - """Create and send client request to server.""" - - request = AddAppClientRequest() - request.appRecordUid = utils.base64_url_decode(uid) - request.encryptedAppKey = encrypted_master_key - request.lockIp = not unlock_ip - request.firstAccessExpireOn = first_access_expire_duration_ms - request.appClientType = GENERAL - request.clientId = client_id - - if access_expire_in_ms: - request.accessExpireOn = access_expire_in_ms - - if client_name: - request.id = client_name if count == 1 else f"{client_name} {index + 1}" - - device = vault.keeper_auth.execute_auth_rest( - rest_endpoint=CLIENT_ADD_URL, - request=request, - response_type=Device - ) - - if not device or not device.encryptedDeviceToken: - raise ValueError("Failed to create client device - no device token received") - - return device - - @staticmethod - def _generate_token_with_prefix(secret_bytes: bytes, server: str) -> str: - """Generate token with server prefix.""" - token = utils.base64_url_encode(secret_bytes) - - # Get server abbreviation - abbrev = constants.get_abbrev_by_host(server) - - if abbrev: - return f'{abbrev}:{token}' - else: - tmp_server = server if server.startswith(('http://', 'https://')) else f"https://{server}" - - return f'{parse.urlparse(tmp_server).netloc.lower()}:{token}' - - @staticmethod - def _create_output_string( - token_with_prefix: str, - client_name: str, - unlock_ip: bool, - first_access_expire_duration_ms: int, - access_expire_in_ms: Optional[int]) -> str: - """Create formatted output string for logging.""" - output_lines = [f'\nOne-Time Access Token: {token_with_prefix}'] - - if client_name: - output_lines.append(f'Name: {client_name}') - - ip_lock = 'Disabled' if unlock_ip else 'Enabled' - output_lines.append(f'IP Lock: {ip_lock}') - - exp_date_str = SecretsManagerClientCommand._format_timestamp( - first_access_expire_duration_ms - ) - output_lines.append(f'Token Expires On: {exp_date_str}') - - app_expire_on_str = ( - SecretsManagerClientCommand._format_timestamp(access_expire_in_ms) - if access_expire_in_ms else "Never" - ) - output_lines.append(f'App Access Expires on: {app_expire_on_str}') - - return '\n'.join(output_lines) - - @staticmethod - def _format_timestamp(timestamp_ms: int) -> str: - """Format timestamp in milliseconds to date string.""" - try: - return datetime.datetime.fromtimestamp( - timestamp_ms / MILLISECONDS_PER_SECOND - ).strftime(DATE_FORMAT) - except (OSError, ValueError): - return 'Invalid timestamp' - @staticmethod def _log_success_message(output_string: str) -> None: """Log success message with generated client information.""" @@ -929,64 +454,16 @@ def _confirm_remove_all_clients(clients_count: int) -> bool: return user_choice.lower() == USER_CHOICE_YES @staticmethod - def remove_client(vault: vault_online.VaultOnline, uid: str, client_names_and_ids: list[str], force=False): + def remove_client(vault: vault_online.VaultOnline, uid: str, client_names_and_ids: list[str], force: bool = False): """Remove client devices from a KSM application.""" - client_hashes = SecretsManagerClientCommand._convert_to_client_hashes( - vault, uid, client_names_and_ids + ksm_management.KSMClientManagement.remove_clients_from_ksm_app( + vault=vault, + uid=uid, + client_names_and_ids=client_names_and_ids, + callable=SecretsManagerClientCommand._confirm_remove_clients if not force else None ) - - found_clients_count = len(client_hashes) - if found_clients_count == 0: - logger.warning('No Client Devices found with given name or ID\n') - return - - if not force: - if not SecretsManagerClientCommand._confirm_remove_clients(found_clients_count): - return - - SecretsManagerClientCommand._send_remove_client_request(vault, uid, client_hashes) logger.info('\nClient removal was successful\n') - @staticmethod - def _convert_to_client_hashes(vault: vault_online.VaultOnline, uid: str, - client_names_and_ids: list[str]) -> list[bytes]: - """Convert client names/IDs to client ID hashes.""" - exact_matches, partial_matches = SecretsManagerClientCommand._categorize_client_matches( - client_names_and_ids - ) - - app_infos = ksm_management.get_app_info(vault=vault, app_uid=uid) - app_info = app_infos[0] - client_id_hashes_bytes = [] - - for client in app_info.clients: - if client.id in exact_matches: - client_id_hashes_bytes.append(client.clientId) - continue - - if partial_matches: - client_id = utils.base64_url_encode(client.clientId) - for partial_name in partial_matches: - if client_id.startswith(partial_name): - client_id_hashes_bytes.append(client.clientId) - break - - return client_id_hashes_bytes - - @staticmethod - def _categorize_client_matches(client_names_and_ids: list[str]) -> tuple[set, set]: - """Categorize client names/IDs into exact and partial matches.""" - exact_matches = set() - partial_matches = set() - - for name in client_names_and_ids: - if len(name) >= ksm_management.CLIENT_SHORT_ID_LENGTH: - partial_matches.add(name) - else: - exact_matches.add(name) - - return exact_matches, partial_matches - @staticmethod def _confirm_remove_clients(clients_count: int) -> bool: """Confirm removal of clients.""" @@ -997,15 +474,6 @@ def _confirm_remove_clients(clients_count: int) -> bool: ) return user_choice.lower() == USER_CHOICE_YES - @staticmethod - def _send_remove_client_request(vault: vault_online.VaultOnline, uid: str, - client_hashes: list[bytes]) -> None: - """Send remove client request to server.""" - request = RemoveAppClientsRequest() - request.appRecordUid = utils.base64_url_decode(uid) - request.clients.extend(client_hashes) - vault.keeper_auth.execute_auth_rest(rest_endpoint=CLIENT_REMOVE_URL, request=request) - class SecretsManagerShareCommand(base.ArgparseCommand): @@ -1090,21 +558,20 @@ def _handle_add_share(self, context: KeeperParams, app_uid: str, secret_uids: li raise ValueError("Vault is not initialized.") master_key = self._get_master_key(context.vault, app_uid) - success = SecretsManagerShareCommand.share_secret( - vault=context.vault, - app_uid=app_uid, - secret_uids=secret_uids, - master_key=master_key, - is_editable=is_editable - ) - - if success: - context.vault.sync_down() - SecretsManagerAppCommand.update_shares_user_permissions( - context=context, - uid=app_uid, - removed=False + + try: + added_secret_info = ksm_management.KSMShareManagement.add_secrets_to_ksm_app( + vault=context.vault, + enterprise=context.enterprise_data, + app_uid=app_uid, + secret_uids=secret_uids, + master_key=master_key, + is_editable=is_editable ) + if added_secret_info: + SecretsManagerShareCommand._log_share_success(app_uid, is_editable, added_secret_info) + except base.errors.KeeperApiError as kae: + SecretsManagerShareCommand._handle_share_error(kae) def _get_master_key(self, vault, app_uid: str) -> bytes: """Get master key for application.""" @@ -1113,146 +580,6 @@ def _get_master_key(self, vault, app_uid: str) -> bytes: raise ValueError(f"Could not retrieve master key for application {app_uid}") return master_key - @staticmethod - def share_secret(vault: vault_online.VaultOnline, app_uid: str, master_key: bytes, - secret_uids: list[str], is_editable: bool = False) -> bool: - """Share secrets with a KSM application.""" - if not secret_uids: - logger.warning("No secret UIDs provided for sharing.") - return False - - app_shares, added_secret_info = SecretsManagerShareCommand._process_all_secrets( - vault, secret_uids, master_key, is_editable - ) - - if not added_secret_info: - logger.warning("No valid secrets found to share.") - return False - - return SecretsManagerShareCommand._send_share_request( - vault, app_uid, app_shares, added_secret_info, is_editable - ) - - @staticmethod - def _process_all_secrets(vault: vault_online.VaultOnline, secret_uids: list[str], - master_key: bytes, is_editable: bool) -> tuple[list, list]: - """Process all secrets and build share requests.""" - app_shares = [] - added_secret_info = [] - - for secret_uid in secret_uids: - share_info = SecretsManagerShareCommand._process_secret( - vault, secret_uid, master_key, is_editable - ) - - if share_info: - app_shares.append(share_info['app_share']) - added_secret_info.append(share_info['secret_info']) - - return app_shares, added_secret_info - - @staticmethod - def _process_secret(vault: vault_online.VaultOnline, secret_uid: str, - master_key: bytes, is_editable: bool) -> Optional[dict]: - """Process a single secret and create share request.""" - secret_info = SecretsManagerShareCommand._get_secret_info(vault, secret_uid) - - if not secret_info: - return None - - share_key_decrypted, share_type, secret_type_name = secret_info - - if not share_key_decrypted: - logger.warning(f"Could not retrieve key for secret {secret_uid}") - return None - - app_share = SecretsManagerShareCommand._build_app_share( - secret_uid, share_key_decrypted, master_key, share_type, is_editable - ) - - return { - 'app_share': app_share, - 'secret_info': (secret_uid, secret_type_name) - } - - @staticmethod - def _get_secret_info(vault: vault_online.VaultOnline, secret_uid: str) -> Optional[tuple]: - """Get secret information (key, type, name) for a given UID.""" - is_record = secret_uid in vault.vault_data._records - is_shared_folder = secret_uid in vault.vault_data._shared_folders - - if is_record: - return SecretsManagerShareCommand._get_record_secret_info(vault, secret_uid) - elif is_shared_folder: - return SecretsManagerShareCommand._get_folder_secret_info(vault, secret_uid) - else: - SecretsManagerShareCommand._log_invalid_secret_warning(secret_uid) - return None - - @staticmethod - def _get_record_secret_info(vault: vault_online.VaultOnline, secret_uid: str) -> Optional[tuple]: - """Get secret info for a record.""" - record = vault.vault_data.load_record(record_uid=secret_uid) - if not isinstance(record, TypedRecord): - raise ValueError("Unable to share application secret, only typed records can be shared") - - share_key_decrypted = vault.vault_data.get_record_key(record_uid=secret_uid) - share_type = ApplicationShareType.SHARE_TYPE_RECORD - secret_type_name = RECORD - - return share_key_decrypted, share_type, secret_type_name - - @staticmethod - def _get_folder_secret_info(vault: vault_online.VaultOnline, secret_uid: str) -> tuple: - """Get secret info for a shared folder.""" - share_key_decrypted = vault.vault_data.get_shared_folder_key(shared_folder_uid=secret_uid) - share_type = ApplicationShareType.SHARE_TYPE_FOLDER - secret_type_name = SHARED_FOLDER - - return share_key_decrypted, share_type, secret_type_name - - @staticmethod - def _log_invalid_secret_warning(secret_uid: str) -> None: - """Log warning for invalid secret UID.""" - logger.warning( - f"UID='{secret_uid}' is not a Record nor Shared Folder. " - "Only individual records or Shared Folders can be added to the application. " - "Make sure your local cache is up to date by running 'sync-down' command and trying again." - ) - - @staticmethod - def _build_app_share(secret_uid: str, share_key_decrypted: bytes, master_key: bytes, - share_type: int, is_editable: bool) -> AppShareAdd: - """Build AppShareAdd object.""" - app_share = AppShareAdd() - app_share.secretUid = utils.base64_url_decode(secret_uid) - app_share.shareType = share_type - app_share.encryptedSecretKey = crypto.encrypt_aes_v2(share_key_decrypted, master_key) - app_share.editable = is_editable - return app_share - - @staticmethod - def _send_share_request(vault: vault_online.VaultOnline, app_uid: str, - app_shares: list, added_secret_info: list, is_editable: bool) -> bool: - """Send the share request to the server.""" - request = SecretsManagerShareCommand._build_share_request(app_uid, app_shares) - - try: - vault.keeper_auth.execute_auth_rest(rest_endpoint=SHARE_ADD_URL, request=request) - SecretsManagerShareCommand._log_share_success(app_uid, is_editable, added_secret_info) - return True - - except base.errors.KeeperApiError as kae: - return SecretsManagerShareCommand._handle_share_error(kae) - - @staticmethod - def _build_share_request(app_uid: str, app_shares: list) -> AddAppSharesRequest: - """Build share request object.""" - request = AddAppSharesRequest() - request.appRecordUid = utils.base64_url_decode(app_uid) - request.shares.extend(app_shares) - return request - @staticmethod def _log_share_success(app_uid: str, is_editable: bool, added_secret_info: list) -> None: """Log successful share operation.""" @@ -1292,7 +619,7 @@ def remove_share(vault: vault_online.VaultOnline, app_uid: str, secret_uids: lis ) return - SecretsManagerShareCommand._send_remove_share_request(vault, app_uid, valid_uids) + ksm_management.KSMShareManagement.remove_secrets_from_ksm_app(vault, app_uid, valid_uids) logger.info("Shared secrets were successfully removed from the application\n") @staticmethod @@ -1320,12 +647,3 @@ def _log_invalid_uids(invalid_uids: list[str]) -> None: """Log warnings for invalid UIDs.""" for uid in invalid_uids: logger.warning(f"Secret UID '{uid}' is not shared with this application. Skipping.") - - @staticmethod - def _send_remove_share_request(vault: vault_online.VaultOnline, app_uid: str, - valid_uids: list[str]) -> None: - """Send remove share request to server.""" - request = RemoveAppSharesRequest() - request.appRecordUid = utils.base64_url_decode(app_uid) - request.shares.extend(utils.base64_url_decode(uid) for uid in valid_uids) - vault.keeper_auth.execute_auth_rest(rest_endpoint=SHARE_REMOVE_URL, request=request) \ No newline at end of file diff --git a/keepersdk-package/src/keepersdk/vault/ksm_management.py b/keepersdk-package/src/keepersdk/vault/ksm_management.py index 9cf40d63..31a6f883 100644 --- a/keepersdk-package/src/keepersdk/vault/ksm_management.py +++ b/keepersdk-package/src/keepersdk/vault/ksm_management.py @@ -1,18 +1,41 @@ import datetime +import hmac import json -from typing import Optional, List, Union - -from . import vault_online, ksm, record_management, vault_types -from ..proto.APIRequest_pb2 import GetApplicationsSummaryResponse, ApplicationShareType, GetAppInfoRequest, GetAppInfoResponse +import logging +import os + +from typing import Callable, Optional, List, Union +from urllib import parse + +from . import ksm, record_management, shares_management, share_management_utils, vault_online, vault_record, vault_types +from .. import utils, crypto, constants +from ..enterprise import enterprise_data +from ..proto.APIRequest_pb2 import ( + GetApplicationsSummaryResponse, ApplicationShareType, GetAppInfoRequest, + GetAppInfoResponse, RemoveAppClientsRequest, Device, AddAppClientRequest, + AppShareAdd, AddAppSharesRequest, RemoveAppSharesRequest +) from ..proto.enterprise_pb2 import GENERAL from ..proto.record_pb2 import ApplicationAddRequest -from .. import utils, crypto URL_GET_SUMMARY_API = 'vault/get_applications_summary' URL_GET_APP_INFO_API = 'vault/get_app_info' URL_CREATE_APP_API = 'vault/application_add' + +CLIENT_ADD_URL = 'vault/app_client_add' +CLIENT_REMOVE_URL = 'vault/app_client_remove' + +SHARE_ADD_URL = 'vault/app_share_add' +SHARE_REMOVE_URL = 'vault/app_share_remove' + CLIENT_SHORT_ID_LENGTH = 8 +MILLISECONDS_PER_SECOND = 1000 + +CLIENT_ID_COUNTER_BYTES = b'KEEPER_SECRETS_MANAGER_CLIENT_ID' +CLIENT_ID_DIGEST = 'sha512' + +DATE_FORMAT = '%Y-%m-%d %H:%M:%S' def list_secrets_manager_apps(vault: vault_online.VaultOnline) -> List[ksm.SecretsManagerApp]: response = vault.keeper_auth.execute_auth_rest( @@ -139,6 +162,288 @@ def remove_secrets_manager_app(vault: vault_online.VaultOnline, uid_or_name: str return app.uid +def share_secrets_manager_app(vault: vault_online.VaultOnline, enterprise: enterprise_data.EnterpriseData, + app_uid: str, emails: list[str], action: str, can_edit: bool, can_share: bool) -> tuple[list, list]: + + request = shares_management.RecordShares.prep_request( + vault=vault, emails=emails, action=action, uid_or_name=app_uid, + share_expiration=None, dry_run=False, enterprise=enterprise, enterprise_access=True, + recursive=False, can_edit=can_edit, can_share=can_share + ) + + success_responses, failed_responses = shares_management.RecordShares.send_requests(vault=vault, requests=[request]) + + vault.sync_down() + + removed = action == 'remove' + + success_responses_content, failed_responses_content = _update_shares_user_permissions(vault=vault, enterprise=enterprise, uid=app_uid, removed=removed) + return success_responses.extend(success_responses_content), failed_responses.extend(failed_responses_content) + + +def _update_shares_user_permissions(vault: vault_online.VaultOnline, enterprise: enterprise_data.EnterpriseData, uid: str, removed: bool) -> tuple[list, list]: + + # Get user permissions for the app + user_perms = _get_app_user_permissions(vault=vault, uid=uid) + + # Get app info and shared secrets + app_infos = get_app_info(vault=vault, app_uid=uid) + app_info = app_infos[0] + if not app_info: + return [], [] + + # Separate shared records and folders + shared_recs, shared_folders = _separate_shared_items( + vault, app_info.shares + ) + + # Create share requests for users that need updates + return _process_share_updates( + vault, enterprise, user_perms, shared_recs, shared_folders, removed + ) + + +def _get_app_user_permissions(vault: vault_online.VaultOnline, uid: str) -> list: + """Get user permissions for the application.""" + share_info = share_management_utils.get_record_shares(vault=vault, record_uids=[uid], is_share_admin=False) + user_perms = [] + if share_info: + for record_info in share_info: + if record_info.get('record_uid') == uid: + user_perms = record_info.get('shares', {}).get('user_permissions', []) + break + return user_perms + + +def _separate_shared_items(vault: vault_online.VaultOnline, shared_secrets): + """Separate shared secrets into records and folders.""" + shared_recs = [] + shared_folders = [] + + for share in shared_secrets: + uid_str = utils.base64_url_encode(share.secretUid) + share_type = ApplicationShareType.Name(share.shareType) + + if share_type == ApplicationShareType.SHARE_TYPE_RECORD: + shared_recs.append(uid_str) + elif share_type == ApplicationShareType.SHARE_TYPE_FOLDER: + shared_folders.append(uid_str) + + if shared_recs: + share_management_utils.get_record_shares( + vault=vault, + record_uids=shared_recs, + is_share_admin=False + ) + + return shared_recs, shared_folders + + +def _process_share_updates(vault: vault_online.VaultOnline, enterprise: enterprise_data.EnterpriseData, + user_perms: list, shared_recs: list, shared_folders: list, removed: bool) -> tuple[list, list]: + """Process share updates for users.""" + app_users_map = _categorize_app_users(vault, user_perms) + + sf_requests, rec_requests = _build_share_requests( + vault, enterprise, app_users_map, shared_recs, shared_folders, removed + ) + + return _send_share_requests(vault, sf_requests, rec_requests) + + +def _categorize_app_users(vault: vault_online.VaultOnline, user_perms: list) -> dict: + """Categorize users into admins and viewers.""" + current_username = vault.keeper_auth.auth_context.username + admins = [ + up.get('username') for up in user_perms + if up.get('editable') and up.get('username') != current_username + ] + viewers = [ + up.get('username') for up in user_perms + if not up.get('editable') + ] + return dict(admins=admins, viewers=viewers) + + +def _build_share_requests(vault: vault_online.VaultOnline, enterprise: enterprise_data.EnterpriseData, + app_users_map: dict, shared_recs: list, shared_folders: list, + removed: bool) -> tuple: + """Build share requests for folders and records.""" + sf_requests = [] + rec_requests = [] + all_share_uids = shared_recs + shared_folders + + for users in app_users_map.values(): + users_needing_update = [ + u for u in users + if _user_needs_update(vault, u, all_share_uids, removed) + ] + + if not users_needing_update: + continue + + folder_requests = _create_folder_share_requests( + vault, shared_folders, users_needing_update, removed + ) + if folder_requests: + sf_requests.append(folder_requests) + + record_requests = _create_record_share_requests( + vault, enterprise, shared_recs, users_needing_update, removed + ) + rec_requests.extend(record_requests) + + return sf_requests, rec_requests + + +def _send_share_requests(vault: vault_online.VaultOnline, sf_requests: list, rec_requests: list) -> tuple[list, list]: + """Send share requests to the server.""" + success_responses = [] + failed_responses = [] + if sf_requests: + success_responses, failed_responses = shares_management.FolderShares.send_requests(vault, sf_requests) + if rec_requests: + success_responses_rec, failed_responses_rec = shares_management.RecordShares.send_requests(vault, rec_requests) + success_responses.extend(success_responses_rec) + failed_responses.extend(failed_responses_rec) + + vault.sync_down() + return success_responses, failed_responses + + +def _user_needs_update(vault: vault_online.VaultOnline, user: str, share_uids: list, removed: bool) -> bool: + """Check if a user needs share permission updates.""" + record_permissions = _get_record_permissions(vault, share_uids) + record_cache = {x.record_uid: x for x in vault.vault_data.records()} + + for share_uid in share_uids: + share_user_permissions = _get_share_user_permissions( + vault, share_uid, record_cache, record_permissions + ) + + user_permissions_set = { + up.get('username') for up in share_user_permissions + if isinstance(up, dict) + } + + if user not in user_permissions_set: + return True + return False + + +def _get_record_permissions(vault: vault_online.VaultOnline, share_uids: list) -> dict: + """Get record permissions for given share UIDs.""" + record_share_info = share_management_utils.get_record_shares( + vault=vault, + record_uids=share_uids, + is_share_admin=False + ) + + record_permissions = {} + if record_share_info: + for record_info in record_share_info: + record_uid = record_info.get('record_uid') + if record_uid: + record_permissions[record_uid] = ( + record_info.get('shares', {}).get('user_permissions', []) + ) + return record_permissions + + +def _get_share_user_permissions(vault: vault_online.VaultOnline, share_uid: str, + record_cache: dict, record_permissions: dict) -> list: + """Get user permissions for a share (record or folder).""" + is_record_share = share_uid in record_cache + + if is_record_share: + return record_permissions.get(share_uid, []) + + shared_folder_obj = vault.vault_data.load_shared_folder(shared_folder_uid=share_uid) + if shared_folder_obj and shared_folder_obj.user_permissions: + return shared_folder_obj.user_permissions + + return [] + + +def _create_folder_share_requests(vault: vault_online.VaultOnline, shared_folders: list, + users: list, removed: bool) -> list: + """Create folder share requests.""" + if not shared_folders: + return [] + + sf_action = 'remove' if removed else 'grant' + requests = [] + + for folder_uid in shared_folders: + for user in users: + if _user_needs_update(vault, user, [folder_uid], removed): + request = _build_folder_share_request( + vault, folder_uid, user, sf_action + ) + requests.append(request) + + return requests + + +def _build_folder_share_request(vault: vault_online.VaultOnline, folder_uid: str, + user: str, action: str) -> dict: + """Build a single folder share request.""" + shared_folder = vault.vault_data.load_shared_folder(folder_uid) + shared_folder_revision = vault.vault_data.storage.shared_folders.get_entity(folder_uid).revision + sf_unencrypted_key = vault.vault_data.get_shared_folder_key(shared_folder_uid=folder_uid) + + sf_info = { + 'shared_folder_uid': folder_uid, + 'users': shared_folder.user_permissions, + 'teams': [], + 'records': shared_folder.record_permissions, + 'shared_folder_key_unencrypted': sf_unencrypted_key, + 'default_manage_users': shared_folder.default_can_share, + 'default_manage_records': shared_folder.default_can_edit, + 'revision': shared_folder_revision + } + + return shares_management.FolderShares.prepare_request( + vault=vault, + kwargs={'action': action}, + curr_sf=sf_info, + users=[user], + teams=[], + rec_uids=[], + default_record=False, + default_account=False, + share_expiration=-1 + ) + + +def _create_record_share_requests(vault: vault_online.VaultOnline, enterprise: enterprise_data.EnterpriseData, shared_recs: list, + users: list, removed: bool) -> list: + """Create record share requests.""" + if not shared_recs or not vault: + return [] + + rec_action = 'remove' if removed else 'grant' + requests = [] + + for record_uid in shared_recs: + for user in users: + if _user_needs_update(vault, user, [record_uid], removed): + request = shares_management.RecordShares.prep_request( + vault=vault, + emails=[user], + action=rec_action, + uid_or_name=record_uid, + share_expiration=-1, + dry_run=False, + enterprise=enterprise, + can_edit=False, + can_share=False + ) + requests.append(request) + + return requests + + def get_app_info(vault: vault_online.VaultOnline, app_uid: Union[str, List[str]]) -> List: rq = GetAppInfoRequest() @@ -167,6 +472,7 @@ def shorten_client_id(all_clients, original_id, number_of_characters): def int_to_datetime(timestamp: int) -> datetime.datetime: return datetime.datetime.fromtimestamp(timestamp / 1000) if timestamp and timestamp != 0 else None + def handle_share_type(share, ksm_app, vault: vault_online.VaultOnline): uid_str = utils.base64_url_encode(share.secretUid) share_type = ApplicationShareType.Name(share.shareType) @@ -181,4 +487,397 @@ def handle_share_type(share, ksm_app, vault: vault_online.VaultOnline): return ksm.SharedSecretsInfo(type='FOLDER', uid=uid_str, name=cached_sf.name, permissions=editable_status) else: - return None \ No newline at end of file + return None + + +class KSMClientManagement: + + @staticmethod + def add_client_to_ksm_app( + vault: vault_online.VaultOnline, + uid: str, + client_name: str, + count: int, + index: int, + unlock_ip: bool, + first_access_expire_duration_ms: int, + access_expire_in_ms: Optional[int], + master_key: bytes, + server: str) -> dict: + """Generate a single client device and return token info and output string.""" + + # Generate secret and client ID + secret_bytes = os.urandom(32) + client_id = KSMClientManagement._generate_client_id(secret_bytes) + + encrypted_master_key = crypto.encrypt_aes_v2(master_key, secret_bytes) + + # Create and send request + device = KSMClientManagement._create_client_request( + vault=vault, + uid=uid, + encrypted_master_key=encrypted_master_key, + unlock_ip=unlock_ip, + first_access_expire_duration_ms=first_access_expire_duration_ms, + access_expire_in_ms=access_expire_in_ms, + client_id=client_id, + client_name=client_name, + count=count, + index=index + ) + + # Generate token with server prefix + token_with_prefix = KSMClientManagement._generate_token_with_prefix( + secret_bytes=secret_bytes, + server=server + ) + + output_string = KSMClientManagement._create_output_string( + token_with_prefix=token_with_prefix, + client_name=client_name, + unlock_ip=unlock_ip, + first_access_expire_duration_ms=first_access_expire_duration_ms, + access_expire_in_ms=access_expire_in_ms + ) + + return { + 'token_info': { + 'oneTimeToken': token_with_prefix, + 'deviceToken': utils.base64_url_encode(device.encryptedDeviceToken) + }, + 'output_string': output_string + } + + @staticmethod + def _generate_client_id(secret_bytes: bytes) -> bytes: + """Generate client ID using HMAC.""" + return hmac.new( + secret_bytes, + CLIENT_ID_COUNTER_BYTES, + CLIENT_ID_DIGEST + ).digest() + + @staticmethod + def _create_client_request( + vault: vault_online.VaultOnline, + uid: str, + encrypted_master_key: bytes, + unlock_ip: bool, + first_access_expire_duration_ms: int, + access_expire_in_ms: Optional[int], + client_id: bytes, + client_name: str, + count: int, + index: int) -> Device: + """Create and send client request to server.""" + + request = AddAppClientRequest() + request.appRecordUid = utils.base64_url_decode(uid) + request.encryptedAppKey = encrypted_master_key + request.lockIp = not unlock_ip + request.firstAccessExpireOn = first_access_expire_duration_ms + request.appClientType = GENERAL + request.clientId = client_id + + if access_expire_in_ms: + request.accessExpireOn = access_expire_in_ms + + if client_name: + request.id = client_name if count == 1 else f"{client_name} {index + 1}" + + device = vault.keeper_auth.execute_auth_rest( + rest_endpoint=CLIENT_ADD_URL, + request=request, + response_type=Device + ) + + if not device or not device.encryptedDeviceToken: + raise ValueError("Failed to create client device - no device token received") + + return device + + @staticmethod + def _generate_token_with_prefix(secret_bytes: bytes, server: str) -> str: + """Generate token with server prefix.""" + token = utils.base64_url_encode(secret_bytes) + + # Get server abbreviation + abbrev = KSMClientManagement._get_abbrev_by_host(server) + + if abbrev: + return f'{abbrev}:{token}' + else: + tmp_server = server if server.startswith(('http://', 'https://')) else f"https://{server}" + + return f'{parse.urlparse(tmp_server).netloc.lower()}:{token}' + + + @staticmethod + def _get_abbrev_by_host(host: str) -> Optional[str]: + # Return abbreviation of the Keeper's public host + + if host.startswith('https:'): + host = parse.urlparse(host).netloc # https://keepersecurity.com/api/v2/ --> keepersecurity.com + + keys = [k for k, v in constants.KEEPER_PUBLIC_HOSTS.items() if v == host] + if keys: + return keys[0] + return None + + @staticmethod + def _create_output_string( + token_with_prefix: str, + client_name: str, + unlock_ip: bool, + first_access_expire_duration_ms: int, + access_expire_in_ms: Optional[int]) -> str: + """Create formatted output string for logging.""" + output_lines = [f'\nOne-Time Access Token: {token_with_prefix}'] + + if client_name: + output_lines.append(f'Name: {client_name}') + + ip_lock = 'Disabled' if unlock_ip else 'Enabled' + output_lines.append(f'IP Lock: {ip_lock}') + + exp_date_str = KSMClientManagement._format_timestamp( + first_access_expire_duration_ms + ) + output_lines.append(f'Token Expires On: {exp_date_str}') + + app_expire_on_str = ( + KSMClientManagement._format_timestamp(access_expire_in_ms) + if access_expire_in_ms else "Never" + ) + output_lines.append(f'App Access Expires on: {app_expire_on_str}') + + return '\n'.join(output_lines) + + @staticmethod + def _format_timestamp(timestamp_ms: int) -> str: + """Format timestamp in milliseconds to date string.""" + try: + return datetime.datetime.fromtimestamp( + timestamp_ms / MILLISECONDS_PER_SECOND + ).strftime(DATE_FORMAT) + except (OSError, ValueError): + return 'Invalid timestamp' + + @staticmethod + def remove_clients_from_ksm_app(vault: vault_online.VaultOnline, uid: str, client_names_and_ids: list[str], callable: Callable = None): + """Remove client devices from a KSM application.""" + client_hashes = KSMClientManagement._convert_to_client_hashes( + vault, uid, client_names_and_ids + ) + + found_clients_count = len(client_hashes) + if found_clients_count == 0: + raise ValueError('No Client Devices found with given name or ID\n') + + if callable: + if not KSMClientManagement._confirm_remove_clients(found_clients_count, callable): + raise ValueError('User did not confirm removal of clients') + + KSMClientManagement._send_remove_client_request(vault, uid, client_hashes) + + @staticmethod + def _convert_to_client_hashes(vault: vault_online.VaultOnline, uid: str, + client_names_and_ids: list[str]) -> list[bytes]: + """Convert client names/IDs to client ID hashes.""" + exact_matches, partial_matches = KSMClientManagement._categorize_client_matches( + client_names_and_ids + ) + + app_infos = get_app_info(vault=vault, app_uid=uid) + app_info = app_infos[0] + client_id_hashes_bytes = [] + + for client in app_info.clients: + if client.id in exact_matches: + client_id_hashes_bytes.append(client.clientId) + continue + + if partial_matches: + client_id = utils.base64_url_encode(client.clientId) + for partial_name in partial_matches: + if client_id.startswith(partial_name): + client_id_hashes_bytes.append(client.clientId) + break + + return client_id_hashes_bytes + + @staticmethod + def _categorize_client_matches(client_names_and_ids: list[str]) -> tuple[set, set]: + """Categorize client names/IDs into exact and partial matches.""" + exact_matches = set() + partial_matches = set() + + for name in client_names_and_ids: + if len(name) >= CLIENT_SHORT_ID_LENGTH: + partial_matches.add(name) + else: + exact_matches.add(name) + + return exact_matches, partial_matches + + @staticmethod + def _confirm_remove_clients(clients_count: int, callable: Callable) -> bool: + """Confirm removal of clients.""" + return callable(clients_count) + + @staticmethod + def _send_remove_client_request(vault: vault_online.VaultOnline, uid: str, + client_hashes: list[bytes]) -> None: + """Send remove client request to server.""" + request = RemoveAppClientsRequest() + request.appRecordUid = utils.base64_url_decode(uid) + request.clients.extend(client_hashes) + vault.keeper_auth.execute_auth_rest(rest_endpoint=CLIENT_REMOVE_URL, request=request) + + +class KSMShareManagement: + + @staticmethod + def add_secrets_to_ksm_app(vault: vault_online.VaultOnline, enterprise:enterprise_data.EnterpriseData, app_uid: str, master_key: bytes, + secret_uids: list[str], is_editable: bool = False) -> list: + """Share secrets with a KSM application.""" + + app_shares, added_secret_info = KSMShareManagement._process_all_secrets( + vault, secret_uids, master_key, is_editable + ) + + if not added_secret_info: + raise ValueError("No valid secrets found to share.") + + KSMShareManagement._send_share_request( + vault, app_uid, app_shares + ) + + vault.sync_down() + + _update_shares_user_permissions(vault, enterprise, app_uid, removed=False) + + return added_secret_info + + @staticmethod + def _process_all_secrets(vault: vault_online.VaultOnline, secret_uids: list[str], + master_key: bytes, is_editable: bool) -> tuple[list, list]: + """Process all secrets and build share requests.""" + app_shares = [] + added_secret_info = [] + + for secret_uid in secret_uids: + share_info = KSMShareManagement._process_secret( + vault, secret_uid, master_key, is_editable + ) + + if share_info: + app_shares.append(share_info['app_share']) + added_secret_info.append(share_info['secret_info']) + + return app_shares, added_secret_info + + @staticmethod + def _process_secret(vault: vault_online.VaultOnline, secret_uid: str, + master_key: bytes, is_editable: bool) -> Optional[dict]: + """Process a single secret and create share request.""" + secret_info = KSMShareManagement._get_secret_info(vault, secret_uid) + + if not secret_info: + return None + + share_key_decrypted, share_type, secret_type_name = secret_info + + if not share_key_decrypted: + logging.warning(f"Could not retrieve key for secret {secret_uid}") + return None + + app_share = KSMShareManagement._build_app_share( + secret_uid, share_key_decrypted, master_key, share_type, is_editable + ) + + return { + 'app_share': app_share, + 'secret_info': (secret_uid, secret_type_name) + } + + @staticmethod + def _get_secret_info(vault: vault_online.VaultOnline, secret_uid: str) -> Optional[tuple]: + """Get secret information (key, type, name) for a given UID.""" + is_record = secret_uid in vault.vault_data._records + is_shared_folder = secret_uid in vault.vault_data._shared_folders + + if is_record: + return KSMShareManagement._get_record_secret_info(vault, secret_uid) + elif is_shared_folder: + return KSMShareManagement._get_folder_secret_info(vault, secret_uid) + else: + KSMShareManagement._log_invalid_secret_warning(secret_uid) + return None + + @staticmethod + def _get_record_secret_info(vault: vault_online.VaultOnline, secret_uid: str) -> Optional[tuple]: + """Get secret info for a record.""" + record = vault.vault_data.load_record(record_uid=secret_uid) + if not isinstance(record, vault_record.TypedRecord): + raise ValueError("Unable to share application secret, only typed records can be shared") + + share_key_decrypted = vault.vault_data.get_record_key(record_uid=secret_uid) + share_type = ApplicationShareType.SHARE_TYPE_RECORD + secret_type_name = 'Record' + + return share_key_decrypted, share_type, secret_type_name + + @staticmethod + def _get_folder_secret_info(vault: vault_online.VaultOnline, secret_uid: str) -> tuple: + """Get secret info for a shared folder.""" + share_key_decrypted = vault.vault_data.get_shared_folder_key(shared_folder_uid=secret_uid) + share_type = ApplicationShareType.SHARE_TYPE_FOLDER + secret_type_name = 'Shared Folder' + + return share_key_decrypted, share_type, secret_type_name + + @staticmethod + def _log_invalid_secret_warning(secret_uid: str) -> None: + """Log warning for invalid secret UID.""" + logging.warning( + f"UID='{secret_uid}' is not a Record nor Shared Folder. " + "Only individual records or Shared Folders can be added to the application. " + ) + + @staticmethod + def _build_app_share(secret_uid: str, share_key_decrypted: bytes, master_key: bytes, + share_type: int, is_editable: bool) -> AppShareAdd: + """Build AppShareAdd object.""" + app_share = AppShareAdd() + app_share.secretUid = utils.base64_url_decode(secret_uid) + app_share.shareType = share_type + app_share.encryptedSecretKey = crypto.encrypt_aes_v2(share_key_decrypted, master_key) + app_share.editable = is_editable + return app_share + + @staticmethod + def _send_share_request(vault: vault_online.VaultOnline, app_uid: str, + app_shares: list) -> bool: + """Send the share request to the server.""" + request = KSMShareManagement._build_share_request(app_uid, app_shares) + + vault.keeper_auth.execute_auth_rest(rest_endpoint=SHARE_ADD_URL, request=request) + return True + + @staticmethod + def _build_share_request(app_uid: str, app_shares: list) -> AddAppSharesRequest: + """Build share request object.""" + request = AddAppSharesRequest() + request.appRecordUid = utils.base64_url_decode(app_uid) + request.shares.extend(app_shares) + return request + + @staticmethod + def remove_secrets_from_ksm_app(vault: vault_online.VaultOnline, app_uid: str, + secret_uids: list[str]) -> None: + """Send remove share request to server.""" + request = RemoveAppSharesRequest() + request.appRecordUid = utils.base64_url_decode(app_uid) + request.shares.extend(utils.base64_url_decode(uid) for uid in secret_uids) + vault.keeper_auth.execute_auth_rest(rest_endpoint=SHARE_REMOVE_URL, request=request) \ No newline at end of file diff --git a/keepersdk-package/src/keepersdk/vault/record_management.py b/keepersdk-package/src/keepersdk/vault/record_management.py index 77ee1d16..6426db59 100644 --- a/keepersdk-package/src/keepersdk/vault/record_management.py +++ b/keepersdk-package/src/keepersdk/vault/record_management.py @@ -287,9 +287,9 @@ def delete_vault_objects(vault: vault_online.VaultOnline, folder = vault.vault_data.get_folder(to_delete) if folder: obj = { + 'delete_resolution': 'unlink', 'object_uid': folder.folder_uid, 'object_type': folder.folder_type, - 'delete_resolution': 'unlink', 'from_type': folder.folder_type, } if folder.parent_uid: @@ -306,14 +306,36 @@ def delete_vault_objects(vault: vault_online.VaultOnline, folder = folders[0] if record: obj = { + 'delete_resolution': 'unlink', 'object_uid': record.record_uid, 'object_type': 'record', - 'delete_resolution': 'unlink', 'from_type': 'user_folder' } if folder: obj['from_uid'] = folder.folder_uid + obj['from_type'] = 'user_folder' if folder.folder_type == 'user_folder' else 'shared_folder_folder' objects.append(obj) + + if not folder and not record: + record_cache = vault.vault_data._records + for record_uid, record_info in record_cache.items(): + if to_delete == record_info.info.title: + folders = vault_utils.get_folders_for_record(vault.vault_data, record_info.info.record_uid) + if folders: + parent_folder = folders[0] + if record_info: + obj = { + 'delete_resolution': 'unlink', + 'object_uid': record_info.info.record_uid, + 'object_type': 'record', + 'from_type': 'user_folder' + } + if parent_folder: + obj['from_uid'] = parent_folder.folder_uid + obj['from_type'] = 'user_folder' if parent_folder.folder_type == 'user_folder' else 'shared_folder_folder' + objects.append(obj) + break + elif isinstance(to_delete, vault_types.RecordPath): if not to_delete.record_uid: raise ValueError('record UID cannot be empy') @@ -327,9 +349,9 @@ def delete_vault_objects(vault: vault_online.VaultOnline, if not record: raise ValueError(f'Record \"{to_delete.record_uid}\" not found') obj = { + 'delete_resolution': 'unlink', 'object_uid': record.record_uid, 'object_type': 'record', - 'delete_resolution': 'unlink', } if folder: obj['from_uid'] = folder.folder_uid @@ -359,6 +381,8 @@ def delete_vault_objects(vault: vault_online.VaultOnline, 'pre_delete_token': delete_token } vault.keeper_auth.execute_auth_command(rq) + else: + raise ValueError('No objects found to delete.') vault.sync_requested = True diff --git a/keepersdk-package/src/keepersdk/vault/shares_management.py b/keepersdk-package/src/keepersdk/vault/shares_management.py index aec4e3a9..610604e6 100644 --- a/keepersdk-package/src/keepersdk/vault/shares_management.py +++ b/keepersdk-package/src/keepersdk/vault/shares_management.py @@ -74,7 +74,7 @@ def cancel_share(vault: vault_online.VaultOnline, emails: list[str]): vault.sync_down() @staticmethod - def _resolve_uid_or_name(vault, uid_or_name, record_cache, shared_folder_cache, folder_cache): + def _resolve_uid_or_name(vault: vault_online.VaultOnline, uid_or_name: str, record_cache: dict, shared_folder_cache: dict, folder_cache: dict): """Resolve uid_or_name to record_uid, folder_uid, or shared_folder_uid.""" record_uid = None folder_uid = None From e1a9b0b4fc33880d941d13d72b3ab6b3b09b86f4 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Thu, 18 Dec 2025 16:38:21 +0530 Subject: [PATCH 17/37] User report command added --- .../sdk_examples/user_report/user_report.py | 154 ++++++++++ .../src/keepercli/commands/user_report.py | 77 +++++ .../src/keepercli/register_commands.py | 3 +- .../src/keepersdk/enterprise/user_report.py | 281 ++++++++++++++++++ 4 files changed, 514 insertions(+), 1 deletion(-) create mode 100644 examples/sdk_examples/user_report/user_report.py create mode 100644 keepercli-package/src/keepercli/commands/user_report.py create mode 100644 keepersdk-package/src/keepersdk/enterprise/user_report.py diff --git a/examples/sdk_examples/user_report/user_report.py b/examples/sdk_examples/user_report/user_report.py new file mode 100644 index 00000000..9e95d7b2 --- /dev/null +++ b/examples/sdk_examples/user_report/user_report.py @@ -0,0 +1,154 @@ +import getpass +import sqlite3 +import traceback + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage, user_report +from keepersdk.errors import KeeperApiError +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +TABLE_WIDTH = 140 +COL_WIDTHS = (35, 20, 10, 15, 25, 25, 30, 30) + + +def login(): + """Handle login with server selection, authentication, and MFA.""" + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = config.get().last_login or input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Approve this device and press Enter to continue.") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def format_row(values): + """Format a row of values according to column widths.""" + formatted = [] + for i, val in enumerate(values): + width = COL_WIDTHS[i] + if isinstance(val, list): + val = ', '.join(val) + text = str(val or '')[:width - 1] + formatted.append(f"{text:<{width}}") + return ' '.join(formatted) + + +def print_report(rows): + """Print the user report in table format.""" + print("\n" + "=" * TABLE_WIDTH) + print("ENTERPRISE USER REPORT") + print("=" * TABLE_WIDTH) + + headers = ['Email', 'Name', 'Status', 'Transfer', 'Last Login', 'Node', 'Roles', 'Teams'] + print(format_row(headers)) + print("-" * TABLE_WIDTH) + + for row in rows: + print(format_row(row)) + + print("=" * TABLE_WIDTH) + print(f"\nTotal Users: {len(rows)}") + + +def generate_user_report(keeper_auth_context: keeper_auth.KeeperAuth): + """Generate enterprise user report with details, roles, teams, and last login.""" + if not keeper_auth_context.auth_context.is_enterprise_admin: + print("ERROR: This operation requires enterprise admin privileges.") + keeper_auth_context.close() + return + + enterprise = None + try: + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + enterprise_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + enterprise = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + + print("\nLoading enterprise data...") + + lookback_input = input('Days to look back for last login (default 365, 0 for all): ').strip() + lookback_days = int(lookback_input) if lookback_input else 365 + + config = user_report.UserReportConfig( + lookback_days=lookback_days, + include_last_login=True, + include_roles=True, + include_teams=True, + simplified_report=False + ) + + print("Generating user report...") + generator = user_report.UserReportGenerator( + enterprise.enterprise_data, + keeper_auth_context, + config + ) + + rows = list(generator.generate_report_rows()) + print_report(rows) + + except KeeperApiError as e: + print(f"\nAPI Error: {e}") + except Exception as e: + print(f"\nError generating user report: {e}") + traceback.print_exc() + finally: + if enterprise: + enterprise.close() + keeper_auth_context.close() + + +def main(): + """Main entry point for the user report script.""" + print("=" * 60) + print("Keeper Enterprise User Report Generator") + print("=" * 60) + + keeper_auth_context = login() + + if keeper_auth_context: + generate_user_report(keeper_auth_context) + else: + print("Login failed. Unable to generate user report.") + + +if __name__ == "__main__": + main() diff --git a/keepercli-package/src/keepercli/commands/user_report.py b/keepercli-package/src/keepercli/commands/user_report.py new file mode 100644 index 00000000..5c22327a --- /dev/null +++ b/keepercli-package/src/keepercli/commands/user_report.py @@ -0,0 +1,77 @@ +"""User report command for Keeper CLI.""" + +import argparse +import os +from typing import Any, List + +from keepersdk.enterprise import user_report + +from . import base +from ..helpers import report_utils +from ..params import KeeperParams +from .. import api + + +class UserReportCommand(base.ArgparseCommand): + """Command to generate a user report with login activity.""" + + def __init__(self): + parser = argparse.ArgumentParser( + prog='user-report', + description='Run a user report with login activity.', + parents=[base.report_output_parser] + ) + parser.add_argument( + '--days', + dest='days', + type=int, + default=365, + help='Days to look back for last login (0 = no limit). Default: 365' + ) + parser.add_argument( + '-l', '--last-login', + dest='last_login', + action='store_true', + help='Simplify report to show only last-login info' + ) + super().__init__(parser) + + def execute(self, context: KeeperParams, **kwargs) -> Any: + base.require_login(context) + base.require_enterprise_admin(context) + + logger = api.get_logger() + lookback_days = kwargs.get('days', 365) + simplified = kwargs.get('last_login', False) + output_format = kwargs.get('format', 'table') + output_file = kwargs.get('output') + + if lookback_days > 0: + logger.info(f'Querying latest login for the last {lookback_days} days') + else: + logger.info('Querying latest login without date limit') + + config = user_report.UserReportConfig( + lookback_days=lookback_days, + include_last_login=True, + include_roles=not simplified, + include_teams=not simplified, + simplified_report=simplified + ) + + generator = user_report.UserReportGenerator(context.enterprise_data, context.auth, config) + rows: List[List[Any]] = list(generator.generate_report_rows()) + headers = user_report.UserReportGenerator.get_headers(simplified=simplified) + + if output_format != 'json': + headers = [report_utils.field_to_title(h) for h in headers] + + result = report_utils.dump_report_data(rows, headers, fmt=output_format, filename=output_file) + + if output_file: + _, ext = os.path.splitext(output_file) + if not ext: + output_file += '.json' if output_format == 'json' else '.csv' + logger.info(f'Report saved to: {os.path.abspath(output_file)}') + + return result diff --git a/keepercli-package/src/keepercli/register_commands.py b/keepercli-package/src/keepercli/register_commands.py index 0d5e01b9..00684bb7 100644 --- a/keepercli-package/src/keepercli/register_commands.py +++ b/keepercli-package/src/keepercli/register_commands.py @@ -85,7 +85,7 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS if not scopes or bool(scopes & base.CommandScope.Enterprise): from .commands import (enterprise_info, enterprise_node, enterprise_role, enterprise_team, enterprise_user, enterprise_create_user, - importer_commands, audit_report, audit_alert, audit_log, transfer_account, pedm_admin, msp) + importer_commands, audit_report, audit_alert, audit_log, transfer_account, pedm_admin, msp, user_report) commands.register_command('create-user', enterprise_create_user.CreateEnterpriseUserCommand(), base.CommandScope.Enterprise, 'ecu') commands.register_command('enterprise-down', enterprise_info.EnterpriseDownCommand(), base.CommandScope.Enterprise, 'ed') @@ -104,3 +104,4 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS commands.register_command('pedm', pedm_admin.PedmCommand(), base.CommandScope.Enterprise) commands.register_command('switch-to-mc', msp.SwitchToManagedCompanyCommand(), base.CommandScope.Enterprise) commands.register_command('team-approve', enterprise_team.TeamApproveCommand(), base.CommandScope.Enterprise) + commands.register_command('user-report', user_report.UserReportCommand(), base.CommandScope.Enterprise, 'ur') diff --git a/keepersdk-package/src/keepersdk/enterprise/user_report.py b/keepersdk-package/src/keepersdk/enterprise/user_report.py new file mode 100644 index 00000000..0db4da1f --- /dev/null +++ b/keepersdk-package/src/keepersdk/enterprise/user_report.py @@ -0,0 +1,281 @@ +"""Enterprise user report functionality for Keeper SDK.""" + +import dataclasses +import datetime +from collections import defaultdict +from typing import Optional, List, Dict, Set, Any, Iterable + +from ..authentication import keeper_auth +from . import enterprise_types + + +API_EVENT_SUMMARY_ROW_LIMIT = 1000 +DEFAULT_LOOKBACK_DAYS = 365 +LOGIN_EVENT_TYPES = ['login', 'login_console', 'chat_login', 'accept_invitation'] + + +@dataclasses.dataclass +class UserReportEntry: + """Represents a single user entry in the report.""" + enterprise_user_id: int + email: str + full_name: str = '' + status: str = '' + transfer_status: str = '' + node_path: str = '' + roles: Optional[List[str]] = None + teams: Optional[List[str]] = None + tfa_enabled: bool = False + last_login: Optional[datetime.datetime] = None + last_login_text: str = '' + + +@dataclasses.dataclass +class UserReportConfig: + """Configuration for user report generation.""" + lookback_days: int = DEFAULT_LOOKBACK_DAYS + include_last_login: bool = True + include_roles: bool = True + include_teams: bool = True + simplified_report: bool = False + + +class UserReportGenerator: + """Generates comprehensive user reports for enterprise users.""" + + def __init__( + self, + enterprise_data: enterprise_types.IEnterpriseData, + auth: keeper_auth.KeeperAuth, + config: Optional[UserReportConfig] = None + ) -> None: + self._enterprise_data = enterprise_data + self._auth = auth + self._config = config or UserReportConfig() + self._user_teams: Optional[Dict[int, Set[str]]] = None + self._user_roles: Optional[Dict[int, Set[int]]] = None + self._team_roles: Optional[Dict[str, Set[int]]] = None + self._last_login_cache: Optional[Dict[str, int]] = None + + @property + def enterprise_data(self) -> enterprise_types.IEnterpriseData: + return self._enterprise_data + + @property + def config(self) -> UserReportConfig: + return self._config + + def _build_user_teams_lookup(self) -> Dict[int, Set[str]]: + if self._user_teams is not None: + return self._user_teams + + self._user_teams = defaultdict(set) + for team_user in self._enterprise_data.team_users.get_all_links(): + self._user_teams[team_user.enterprise_user_id].add(team_user.team_uid) + return self._user_teams + + def _build_user_roles_lookup(self) -> Dict[int, Set[int]]: + if self._user_roles is not None: + return self._user_roles + + self._user_roles = defaultdict(set) + for role_user in self._enterprise_data.role_users.get_all_links(): + self._user_roles[role_user.enterprise_user_id].add(role_user.role_id) + return self._user_roles + + def _build_team_roles_lookup(self) -> Dict[str, Set[int]]: + if self._team_roles is not None: + return self._team_roles + + self._team_roles = defaultdict(set) + for role_team in self._enterprise_data.role_teams.get_all_links(): + self._team_roles[role_team.team_uid].add(role_team.role_id) + return self._team_roles + + def _get_user_role_ids(self, user_id: int) -> Set[int]: + """Get all role IDs for a user, including roles inherited from teams.""" + user_roles = self._build_user_roles_lookup() + user_teams = self._build_user_teams_lookup() + team_roles = self._build_team_roles_lookup() + + role_ids = set(user_roles.get(user_id, set())) + for team_uid in user_teams.get(user_id, set()): + role_ids.update(team_roles.get(team_uid, set())) + + return role_ids + + def _get_user_team_names(self, user_id: int) -> List[str]: + user_teams = self._build_user_teams_lookup() + team_names = [] + for team_uid in user_teams.get(user_id, set()): + team = self._enterprise_data.teams.get_entity(team_uid) + if team: + team_names.append(team.name) + return sorted(team_names, key=str.lower) + + def _get_user_role_names(self, user_id: int) -> List[str]: + role_names = [] + for role_id in self._get_user_role_ids(user_id): + role = self._enterprise_data.roles.get_entity(role_id) + if role: + role_names.append(role.name) + return sorted(role_names, key=str.lower) + + @staticmethod + def get_node_path( + enterprise_data: enterprise_types.IEnterpriseData, + node_id: int, + omit_root: bool = False + ) -> str: + """Get the full path for a node as a backslash-separated string.""" + nodes: List[str] = [] + n_id = node_id + while isinstance(n_id, int) and n_id > 0: + node = enterprise_data.nodes.get_entity(n_id) + if not node: + break + n_id = node.parent_id or 0 + if not omit_root or n_id > 0: + node_name = node.name + if not node_name and node.node_id == enterprise_data.root_node.node_id: + node_name = enterprise_data.enterprise_info.enterprise_name + nodes.append(node_name) + nodes.reverse() + return '\\'.join(nodes) + + @staticmethod + def get_user_status_text(user: enterprise_types.User) -> str: + if user.status == 'invited': + return 'Invited' + if user.lock > 0: + return 'Locked' if user.lock == 1 else 'Disabled' + return 'Active' + + @staticmethod + def get_user_transfer_status_text(user: enterprise_types.User) -> str: + if isinstance(user.account_share_expiration, int) and user.account_share_expiration > 0: + expire_at = datetime.datetime.fromtimestamp(user.account_share_expiration / 1000.0) + if expire_at < datetime.datetime.now(): + return 'Blocked' + return 'Pending Transfer' + return '' + + def _query_last_login(self, usernames: List[str]) -> Dict[str, int]: + """Query last login timestamps for usernames via audit API.""" + if self._last_login_cache is not None: + return self._last_login_cache + + self._last_login_cache = {} + if not usernames: + return self._last_login_cache + + report_filter: Dict[str, Any] = {'audit_event_type': LOGIN_EVENT_TYPES} + + if self._config.lookback_days > 0: + from_date = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=self._config.lookback_days) + report_filter['created'] = {'min': int(from_date.timestamp())} + + limit = API_EVENT_SUMMARY_ROW_LIMIT + remaining = list(usernames) + + while remaining: + batch = remaining[:limit] + remaining = remaining[limit:] + report_filter['username'] = batch + + rq = { + 'command': 'get_audit_event_reports', + 'report_type': 'span', + 'scope': 'enterprise', + 'aggregate': ['last_created'], + 'columns': ['username'], + 'filter': report_filter, + 'limit': limit + } + + try: + rs = self._auth.execute_auth_command(rq) + for row in rs.get('audit_event_overview_report_rows', []): + username = row.get('username', '').lower() + last_created = row.get('last_created') + if username and last_created: + self._last_login_cache[username] = int(last_created) + except Exception: + pass + + return self._last_login_cache + + def generate_report(self) -> List[UserReportEntry]: + """Generate the user report.""" + users = list(self._enterprise_data.users.get_all_entities()) + active_usernames = [u.username.lower() for u in users if u.status == 'active'] + + last_login_data: Dict[str, int] = {} + if self._config.include_last_login: + last_login_data = self._query_last_login(active_usernames) + + report_entries: List[UserReportEntry] = [] + + for user in users: + entry = UserReportEntry( + enterprise_user_id=user.enterprise_user_id, + email=user.username, + full_name=user.full_name or '', + status=self.get_user_status_text(user), + transfer_status=self.get_user_transfer_status_text(user), + node_path=self.get_node_path(self._enterprise_data, user.node_id, omit_root=True), + tfa_enabled=user.tfa_enabled + ) + + if self._config.include_roles: + entry.roles = self._get_user_role_names(user.enterprise_user_id) + + if self._config.include_teams: + entry.teams = self._get_user_team_names(user.enterprise_user_id) + + if self._config.include_last_login: + last_login_ts = last_login_data.get(user.username.lower(), 0) + if last_login_ts: + entry.last_login = datetime.datetime.fromtimestamp(last_login_ts, datetime.timezone.utc) + entry.last_login_text = str(entry.last_login) + elif user.status == 'invited': + entry.last_login_text = 'N/A' + elif self._config.lookback_days > 0: + entry.last_login_text = f'> {self._config.lookback_days} DAYS AGO' + else: + entry.last_login_text = 'N/A' + + report_entries.append(entry) + + report_entries.sort(key=lambda x: x.email.lower()) + return report_entries + + def generate_report_rows(self) -> Iterable[List[Any]]: + """Generate report rows suitable for tabular output.""" + simplified = self._config.simplified_report + + for entry in self.generate_report(): + if simplified: + yield [entry.email, entry.full_name, entry.status, entry.transfer_status, entry.last_login_text] + else: + yield [ + entry.email, entry.full_name, entry.status, entry.transfer_status, + entry.last_login_text, entry.node_path, entry.roles or [], entry.teams or [] + ] + + @staticmethod + def get_headers(simplified: bool = False) -> List[str]: + if simplified: + return ['email', 'name', 'status', 'transfer_status', 'last_login'] + return ['email', 'name', 'status', 'transfer_status', 'last_login', 'node', 'roles', 'teams'] + + +def generate_user_report( + enterprise_data: enterprise_types.IEnterpriseData, + auth: keeper_auth.KeeperAuth, + lookback_days: int = DEFAULT_LOOKBACK_DAYS, + simplified: bool = False +) -> List[UserReportEntry]: + """Convenience function to generate a user report.""" + config = UserReportConfig(lookback_days=lookback_days, simplified_report=simplified) + return UserReportGenerator(enterprise_data, auth, config).generate_report() From 896b7152b73aad00227dcbd11df2a7243ea68db6 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Mon, 22 Dec 2025 18:07:52 +0530 Subject: [PATCH 18/37] Bugfix SDK - 232 and 233 --- .../src/keepercli/commands/enterprise_user.py | 22 +++++++++---------- .../keepersdk/enterprise/batch_management.py | 21 ++++++++++++++---- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/keepercli-package/src/keepercli/commands/enterprise_user.py b/keepercli-package/src/keepercli/commands/enterprise_user.py index e8c02faa..b1408ffd 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_user.py +++ b/keepercli-package/src/keepercli/commands/enterprise_user.py @@ -670,18 +670,16 @@ def execute(self, context: KeeperParams, **kwargs) -> None: if not users: raise base.CommandError('No users to delete') - active_users = [x for x in users if x.status == 'active'] - if len(active_users) > 0: - if kwargs.get('force') is not True: - alert = prompt_utils.get_formatted_text('\nALERT!\n', prompt_utils.COLORS.FAIL) - prompt_utils.output_text( - alert,'Deleting a user will also delete any records owned and shared by this user.\n' + - 'Before you delete this user(s), we strongly recommend you lock their account\n' + - 'and transfer any important records to other user(s).\n' + - 'This action cannot be undone.\n') - answer = prompt_utils.user_choice('Do you want to proceed with deletion?', 'yn', 'n') - if answer.lower() not in ('y', 'yes'): - return + if kwargs.get('force') is not True: + alert = prompt_utils.get_formatted_text('\nALERT!\n', prompt_utils.COLORS.FAIL) + prompt_utils.output_text( + alert, 'Deleting a user will also delete any records owned and shared by this user.\n' + + 'Before you delete this user(s), we strongly recommend you lock their account\n' + + 'and transfer any important records to other user(s).\n' + + 'This action cannot be undone.\n') + answer = prompt_utils.user_choice('Do you want to proceed with deletion?', 'yn', 'n') + if answer.lower() not in ('y', 'yes'): + return batch = batch_management.BatchManagement(loader=context.enterprise_loader, logger=self) users_to_delete = [enterprise_management.UserEdit(enterprise_user_id=x.enterprise_user_id) for x in users] diff --git a/keepersdk-package/src/keepersdk/enterprise/batch_management.py b/keepersdk-package/src/keepersdk/enterprise/batch_management.py index f85c0b03..cd5255f5 100644 --- a/keepersdk-package/src/keepersdk/enterprise/batch_management.py +++ b/keepersdk-package/src/keepersdk/enterprise/batch_management.py @@ -663,14 +663,23 @@ def _to_user_requests(self) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]] return add_requests, remove_requests - def _to_disable_tfa_requests(self) -> Optional[enterprise_pb2.EnterpriseUserIds]: + def _to_disable_tfa_requests(self) -> Tuple[Optional[enterprise_pb2.EnterpriseUserIds], List[str]]: + """Returns tuple of (request, list of usernames to disable 2FA)""" add_requests: Optional[enterprise_pb2.EnterpriseUserIds] = None + usernames: List[str] = [] if isinstance(self._user_actions, dict): - user_ids = [user_id for user_id, action in self._user_actions.items() if action == UserAction.DisableTfa] + enterprise_data = self.loader.enterprise_data + user_ids: List[int] = [] + for user_id, action in self._user_actions.items(): + if action == UserAction.DisableTfa: + user = enterprise_data.users.get_entity(user_id) + if user: + user_ids.append(user_id) + usernames.append(user.username) if len(user_ids) > 0: add_requests = enterprise_pb2.EnterpriseUserIds() add_requests.enterpriseUserId.extend(user_ids) - return add_requests + return add_requests, usernames def _to_user_actions(self) -> List[Dict[str, Any]]: requests: List[Dict[str, Any]] = [] @@ -679,6 +688,8 @@ def _to_user_actions(self) -> List[Dict[str, Any]]: for enterprise_user_id, user_action in self._user_actions.items(): try: + if user_action == UserAction.DisableTfa: + continue u = enterprise_data.users.get_entity(enterprise_user_id) if not u: raise Exception('user does not exist') @@ -1171,9 +1182,11 @@ def apply(self) -> None: self._execute_role_team('enterprise/role_team_add', add_rt_rqs) try: - tfs_rqs = self._to_disable_tfa_requests() + tfs_rqs, usernames_with_tfa = self._to_disable_tfa_requests() if tfs_rqs is not None: self.loader.keeper_auth.execute_auth_rest('enterprise/disable_two_fa', tfs_rqs) + for username in usernames_with_tfa: + self.logger.warning(f'2FA successfully removed for {username}') except Exception as e: self.logger.warning(f'Disable TFA error: {e}') From 5153b97ce80e35148915a477fb2f48ac8fc26a47 Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Tue, 30 Dec 2025 11:33:45 +0530 Subject: [PATCH 19/37] Pam gateway commands added --- .../src/keepercli/commands/pam/keeper_pam.py | 653 ++++++++++++++++++ .../src/keepercli/helpers/gateway_utils.py | 111 +++ .../src/keepercli/helpers/router_utils.py | 29 + .../src/keepercli/register_commands.py | 2 + 4 files changed, 795 insertions(+) create mode 100644 keepercli-package/src/keepercli/commands/pam/keeper_pam.py create mode 100644 keepercli-package/src/keepercli/helpers/gateway_utils.py create mode 100644 keepercli-package/src/keepercli/helpers/router_utils.py diff --git a/keepercli-package/src/keepercli/commands/pam/keeper_pam.py b/keepercli-package/src/keepercli/commands/pam/keeper_pam.py new file mode 100644 index 00000000..92f4b52c --- /dev/null +++ b/keepercli-package/src/keepercli/commands/pam/keeper_pam.py @@ -0,0 +1,653 @@ +import argparse +import json +import requests +from datetime import datetime + +from .. import base +from ... import api +from ...helpers import report_utils, router_utils, gateway_utils +from ...params import KeeperParams + +from keepersdk import utils + + +logger = api.get_logger() + +# Constants +DATETIME_FORMAT = '%Y-%m-%d %H:%M:%S' +MILLISECONDS_TO_SECONDS = 1000 +VERSION_SEPARATOR = ';' + +DEFAULT_TOKEN_EXPIRATION_MIN = 60 +MAX_TOKEN_EXPIRATION_MIN = 1440 +MIN_INSTANCES = 1 + +ERROR_VAULT_NOT_INITIALIZED = "Vault is not initialized, login to initialize the vault." +ERROR_ROUTER_DOWN_MESSAGE_TEMPLATE = "Looks like router is down. Use '-f' flag to retrieve list of all available routers associated with your enterprise.\n\nRouter URL [{}]" +ERROR_ROUTER_DOWN_INFO_TEMPLATE = "Looks like router is down. Router URL [{}]" +ERROR_UNHANDLED_GATEWAY_RETRIEVAL = "Unhandled error during retrieval of the connected gateways." +MESSAGE_NO_GATEWAYS = "This Enterprise does not have Gateways yet. To create new Gateway, use command `pam gateway new`\n\nNOTE: If you have added new Gateway, you might still need to initialize it before it is listed." +MESSAGE_NO_GATEWAYS_JSON = "This Enterprise does not have Gateways yet." + +# Header field names +HEADER_KSM_APP_NAME_UID = 'ksm_app_name_uid' +HEADER_GATEWAY_NAME = 'gateway_name' +HEADER_GATEWAY_UID = 'gateway_uid' +HEADER_GATEWAY_VERSION = 'gateway_version' +HEADER_DEVICE_NAME = 'device_name' +HEADER_DEVICE_TOKEN = 'device_token' +HEADER_CREATED_ON = 'created_on' +HEADER_LAST_MODIFIED = 'last_modified' +HEADER_NODE_ID = 'node_id' +HEADER_OS_RELEASE = 'os_release' +HEADER_MACHINE_TYPE = 'machine_type' +HEADER_OS_VERSION = 'os_version' + +# Display headers +DISPLAY_HEADER_KSM_APP_NAME_UID = 'KSM Application Name (UID)' +DISPLAY_HEADER_GATEWAY_NAME = 'Gateway Name' +DISPLAY_HEADER_GATEWAY_UID = 'Gateway UID' +DISPLAY_HEADER_GATEWAY_VERSION = 'Gateway Version' +DISPLAY_HEADER_DEVICE_NAME = 'Device Name' +DISPLAY_HEADER_DEVICE_TOKEN = 'Device Token' +DISPLAY_HEADER_CREATED_ON = 'Created On' +DISPLAY_HEADER_LAST_MODIFIED = 'Last Modified' +DISPLAY_HEADER_NODE_ID = 'Node ID' +DISPLAY_HEADER_OS_RELEASE = 'OS Release' +DISPLAY_HEADER_MACHINE_TYPE = 'Machine Type' +DISPLAY_HEADER_OS_VERSION = 'OS Version' + +APP_NOT_ACCESSIBLE_FORMAT = '[APP NOT ACCESSIBLE OR DELETED] ({})' +INSTANCE_PREFIX = ' |- Instance {} (connected: {})' +TOKEN_SEPARATOR = '-----------------------------------------------' + + +class PAMControllerCommand(base.GroupCommand): + + def __init__(self): + super().__init__('PAM Controller') + self.register_command(PAMGatewayCommand(), 'gateway', 'g') + + +class PAMGatewayCommand(base.GroupCommand): + + def __init__(self): + super().__init__('PAM Gateway') + self.register_command(PAMGatewayListCommand(), 'list', 'l') + self.register_command(PAMGatewayNewCommand(), 'new', 'n') + self.register_command(PAMGatewayRemoveCommand(), 'remove', 'rm') + self.register_command(PAMGatewaySetMaxInstancesCommand(), 'set-max-instances', 'smi') + self.default_verb = 'list' + +class PAMGatewayListCommand(base.ArgparseCommand): + + def __init__(self): + parser = argparse.ArgumentParser(prog='dr-gateway') + PAMGatewayListCommand.add_arguments_to_parser(parser) + super().__init__(parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser): + parser.add_argument('--force', '-f', required=False, default=False, dest='is_force', action='store_true', + help='Force retrieval of gateways') + parser.add_argument('--verbose', '-v', required=False, default=False, dest='is_verbose', action='store_true', + help='Verbose output') + parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], default='table', + help='Output format (table, json)') + + def execute(self, context: KeeperParams, **kwargs): + self._validate_vault_and_permissions(context) + vault = context.vault + + is_force = kwargs.get('is_force') + is_verbose = kwargs.get('is_verbose') + format_type = kwargs.get('format', 'table') + + enterprise_controllers_connected, is_router_down = self._fetch_connected_gateways(vault, is_force) + enterprise_controllers_all = gateway_utils.get_all_gateways(vault) + + if not enterprise_controllers_all: + return self._handle_no_gateways(format_type) + + headers = self._build_headers(format_type, is_verbose) + connected_controllers_dict = self._build_connected_controllers_dict(enterprise_controllers_connected) + + table, gateways_data = self._process_gateways( + vault, enterprise_controllers_all, connected_controllers_dict, + is_router_down, format_type, is_verbose + ) + + return self._format_output(vault, table, gateways_data, headers, format_type, is_verbose) + + def _validate_vault_and_permissions(self, context: KeeperParams): + """Validates that vault is initialized and user has enterprise admin permissions.""" + if not context.vault: + raise ValueError(ERROR_VAULT_NOT_INITIALIZED) + base.require_enterprise_admin(context) + + def _fetch_connected_gateways(self, vault, is_force): + """Fetches connected gateways and handles router connection errors.""" + is_router_down = False + krouter_url = f"https://{vault.keeper_auth.keeper_endpoint.get_router_server()}" + enterprise_controllers_connected = None + + try: + enterprise_controllers_connected = router_utils.router_get_connected_gateways(vault) + except requests.exceptions.ConnectionError: + is_router_down = True + if not is_force: + logger.warning(ERROR_ROUTER_DOWN_MESSAGE_TEMPLATE.format(krouter_url)) + return None, is_router_down + else: + logger.info(ERROR_ROUTER_DOWN_INFO_TEMPLATE.format(krouter_url)) + except Exception as e: + logger.warning(ERROR_UNHANDLED_GATEWAY_RETRIEVAL) + raise e + + return enterprise_controllers_connected, is_router_down + + def _handle_no_gateways(self, format_type): + """Handles the case when no gateways are found.""" + if format_type == 'json': + return json.dumps({"gateways": [], "message": MESSAGE_NO_GATEWAYS_JSON}) + else: + logger.info(MESSAGE_NO_GATEWAYS) + return None + + def _build_headers(self, format_type, is_verbose): + """Builds headers for output based on format type and verbosity.""" + if format_type == 'json': + headers = [HEADER_KSM_APP_NAME_UID, HEADER_GATEWAY_NAME, HEADER_GATEWAY_UID, + 'status', HEADER_GATEWAY_VERSION] + if is_verbose: + headers.extend([HEADER_DEVICE_NAME, HEADER_DEVICE_TOKEN, HEADER_CREATED_ON, + HEADER_LAST_MODIFIED, HEADER_NODE_ID, 'os', HEADER_OS_RELEASE, + HEADER_MACHINE_TYPE, HEADER_OS_VERSION]) + else: + headers = [DISPLAY_HEADER_KSM_APP_NAME_UID, DISPLAY_HEADER_GATEWAY_NAME, + DISPLAY_HEADER_GATEWAY_UID, 'Status', DISPLAY_HEADER_GATEWAY_VERSION] + if is_verbose: + headers.extend([DISPLAY_HEADER_DEVICE_NAME, DISPLAY_HEADER_DEVICE_TOKEN, + DISPLAY_HEADER_CREATED_ON, DISPLAY_HEADER_LAST_MODIFIED, DISPLAY_HEADER_NODE_ID, + 'OS', DISPLAY_HEADER_OS_RELEASE, DISPLAY_HEADER_MACHINE_TYPE, + DISPLAY_HEADER_OS_VERSION]) + return headers + + def _build_connected_controllers_dict(self, enterprise_controllers_connected): + """Builds a dictionary mapping controller UIDs to their connected instances.""" + connected_controllers_dict = {} + if enterprise_controllers_connected: + for controller in list(enterprise_controllers_connected.controllers): + if controller.controllerUid not in connected_controllers_dict: + connected_controllers_dict[controller.controllerUid] = [] + connected_controllers_dict[controller.controllerUid].append(controller) + return connected_controllers_dict + + def _process_gateways(self, vault, enterprise_controllers_all, connected_controllers_dict, + is_router_down, format_type, is_verbose): + """Processes all gateways and builds table and JSON data structures.""" + table = [] + gateways_data = [] + + for controller in enterprise_controllers_all: + gateway_uid_bytes = controller.controllerUid + gateway_uid_str = utils.base64_url_encode(controller.controllerUid) + connected_instances = connected_controllers_dict.get(gateway_uid_bytes, []) + + ksm_app_info = self._get_ksm_app_info(vault, controller) + overall_status = self._determine_gateway_status(connected_instances, is_router_down) + is_pool = len(connected_instances) > 1 + + if not is_pool: + self._process_single_gateway( + controller, gateway_uid_str, connected_instances, ksm_app_info, + overall_status, format_type, is_verbose, table, gateways_data + ) + else: + self._process_pool_gateway( + controller, gateway_uid_str, connected_instances, ksm_app_info, + overall_status, format_type, is_verbose, table, gateways_data + ) + + return table, gateways_data + + def _get_ksm_app_info(self, vault, controller): + """Retrieves KSM application information for a controller.""" + ksm_app_uid_str = utils.base64_url_encode(controller.applicationUid) + ksm_app = vault.vault_data.load_record(ksm_app_uid_str) + + if ksm_app: + ksm_app_title = ksm_app.title + ksm_app_info_plain = f'{ksm_app_title} ({ksm_app_uid_str})' + ksm_app_name = ksm_app_title + ksm_app_accessible = True + else: + ksm_app_info_plain = APP_NOT_ACCESSIBLE_FORMAT.format(ksm_app_uid_str) + ksm_app_name = None + ksm_app_accessible = False + + return { + 'ksm_app_uid_str': ksm_app_uid_str, + 'ksm_app_info_plain': ksm_app_info_plain, + 'ksm_app_name': ksm_app_name, + 'ksm_app_accessible': ksm_app_accessible + } + + def _determine_gateway_status(self, connected_instances, is_router_down): + """Determines the overall status of a gateway.""" + if is_router_down: + return 'UNKNOWN' + elif len(connected_instances) > 0: + is_pool = len(connected_instances) > 1 + return f"ONLINE ({len(connected_instances)} instances)" if is_pool else 'ONLINE' + else: + return 'OFFLINE' + + def _parse_version(self, version_string): + """Parses version string and returns version and parts.""" + if not version_string: + return "", [] + version_parts = version_string.split(VERSION_SEPARATOR) + version = version_parts[0] if version_parts else version_string + return version, version_parts + + def _format_timestamp(self, timestamp_ms): + """Formats timestamp from milliseconds to datetime string.""" + return datetime.fromtimestamp(timestamp_ms / MILLISECONDS_TO_SECONDS).strftime(DATETIME_FORMAT) + + def _extract_version_info(self, version_parts): + """Extracts OS information from version parts.""" + os_name = version_parts[1] if len(version_parts) > 1 else "" + os_release = version_parts[2] if len(version_parts) > 2 else "" + machine_type = version_parts[3] if len(version_parts) > 3 else "" + os_version = version_parts[4] if len(version_parts) > 4 else "" + return os_name, os_release, machine_type, os_version + + def _process_single_gateway(self, controller, gateway_uid_str, connected_instances, + ksm_app_info, overall_status, format_type, is_verbose, + table, gateways_data): + """Processes a single gateway (non-pool) instance.""" + connected_controller = connected_instances[0] if connected_instances else None + version, version_parts = self._parse_version( + connected_controller.version if connected_controller and hasattr(connected_controller, 'version') else None + ) + + gateway_data = { + "ksm_app_name": ksm_app_info['ksm_app_name'], + "ksm_app_uid": ksm_app_info['ksm_app_uid_str'], + "ksm_app_accessible": ksm_app_info['ksm_app_accessible'], + "gateway_name": controller.controllerName, + "gateway_uid": gateway_uid_str, + "status": overall_status, + "gateway_version": version + } + + if is_verbose: + os_name, os_release, machine_type, os_version = self._extract_version_info(version_parts) + gateway_data.update({ + "device_name": controller.deviceName, + "device_token": controller.deviceToken, + "created_on": self._format_timestamp(controller.created), + "last_modified": self._format_timestamp(controller.lastModified), + "node_id": controller.nodeId, + "os": os_name, + "os_release": os_release, + "machine_type": machine_type, + "os_version": os_version + }) + + gateways_data.append(gateway_data) + + if format_type == 'table': + row = self._build_single_gateway_table_row( + controller, gateway_uid_str, ksm_app_info, overall_status, version, is_verbose, version_parts + ) + table.append(row) + + def _build_single_gateway_table_row(self, controller, gateway_uid_str, ksm_app_info, + overall_status, version, is_verbose, version_parts): + """Builds a table row for a single gateway.""" + row = [ + ksm_app_info['ksm_app_info_plain'], + controller.controllerName, + gateway_uid_str, + overall_status, + version + ] + + if is_verbose: + os_name, os_release, machine_type, os_version = self._extract_version_info(version_parts) + row.extend([ + controller.deviceName, + controller.deviceToken, + datetime.fromtimestamp(controller.created / MILLISECONDS_TO_SECONDS), + datetime.fromtimestamp(controller.lastModified / MILLISECONDS_TO_SECONDS), + controller.nodeId, + os_name, + os_release, + machine_type, + os_version + ]) + + return row + + def _process_pool_gateway(self, controller, gateway_uid_str, connected_instances, + ksm_app_info, overall_status, format_type, is_verbose, + table, gateways_data): + """Processes a pool gateway with multiple instances.""" + if format_type == 'json': + instances_data = self._build_pool_instances_json(connected_instances, is_verbose) + gateway_data = { + "ksm_app_name": ksm_app_info['ksm_app_name'], + "ksm_app_uid": ksm_app_info['ksm_app_uid_str'], + "ksm_app_accessible": ksm_app_info['ksm_app_accessible'], + "gateway_name": controller.controllerName, + "gateway_uid": gateway_uid_str, + "status": overall_status, + "instances": instances_data + } + + if is_verbose: + gateway_data.update({ + "device_name": controller.deviceName, + "device_token": controller.deviceToken, + "created_on": self._format_timestamp(controller.created), + "last_modified": self._format_timestamp(controller.lastModified), + "node_id": controller.nodeId + }) + + gateways_data.append(gateway_data) + else: + row = self._build_pool_gateway_table_row( + controller, gateway_uid_str, ksm_app_info, overall_status, is_verbose + ) + table.append(row) + + for idx, instance in enumerate(connected_instances, 1): + instance_row = self._build_pool_instance_table_row(instance, idx, is_verbose) + table.append(instance_row) + + def _build_pool_instances_json(self, connected_instances, is_verbose): + """Builds JSON data for pool gateway instances.""" + instances_data = [] + for idx, instance in enumerate(connected_instances, 1): + version, version_parts = self._parse_version( + instance.version if hasattr(instance, 'version') else None + ) + + instance_data = { + "instance_number": idx, + "status": 'ONLINE', + "gateway_version": version, + "ip_address": instance.ipAddress if hasattr(instance, 'ipAddress') else "", + "connected_on": instance.connectedOn + } + + if is_verbose: + os_name, os_release, machine_type, os_version = self._extract_version_info(version_parts) + instance_data.update({ + "os": os_name, + "os_release": os_release, + "machine_type": machine_type, + "os_version": os_version + }) + + instances_data.append(instance_data) + + return instances_data + + def _build_pool_gateway_table_row(self, controller, gateway_uid_str, ksm_app_info, + overall_status, is_verbose): + """Builds a table row for a pool gateway header.""" + row = [ + ksm_app_info['ksm_app_info_plain'], + controller.controllerName, + gateway_uid_str, + overall_status, + '' + ] + + if is_verbose: + row.extend([ + controller.deviceName, + controller.deviceToken, + datetime.fromtimestamp(controller.created / MILLISECONDS_TO_SECONDS), + datetime.fromtimestamp(controller.lastModified / MILLISECONDS_TO_SECONDS), + controller.nodeId, + '', '', '', '' + ]) + + return row + + def _build_pool_instance_table_row(self, instance, idx, is_verbose): + """Builds a table row for a pool gateway instance.""" + version, version_parts = self._parse_version( + instance.version if hasattr(instance, 'version') else None + ) + + ip_address = instance.ipAddress if hasattr(instance, 'ipAddress') else "" + connected_on = self._format_timestamp(instance.connectedOn) if hasattr(instance, 'connectedOn') else "" + + instance_row = [ + '', + INSTANCE_PREFIX.format(idx, connected_on), + ip_address, + 'ONLINE', + version + ] + + if is_verbose: + os_name, os_release, machine_type, os_version = self._extract_version_info(version_parts) + instance_row.extend([ + '', '', + datetime.fromtimestamp(instance.connectedOn / MILLISECONDS_TO_SECONDS) if hasattr(instance, 'connectedOn') else "", + '', '', + os_name, os_release, machine_type, os_version + ]) + + return instance_row + + def _format_output(self, vault, table, gateways_data, headers, format_type, is_verbose): + """Formats and returns the final output.""" + if format_type == 'json': + return self._format_json_output(vault, gateways_data, is_verbose) + else: + return self._format_table_output(vault, table, headers, is_verbose) + + def _format_json_output(self, vault, gateways_data, is_verbose): + """Formats output as JSON.""" + gateways_data.sort(key=lambda x: (x['status'], (x['ksm_app_name'] or '').lower())) + + result = {"gateways": gateways_data} + if is_verbose: + krouter_host = f"https://{vault.keeper_auth.keeper_endpoint.get_router_server()}" + result["router_host"] = krouter_host + + return json.dumps(result, indent=2) + + def _format_table_output(self, vault, table, headers, is_verbose): + """Formats output as table.""" + sorted_groups = [] + current_group = [] + + for row in table: + if row[0]: + if current_group: + sorted_groups.append(current_group) + current_group = [row] + else: + current_group.append(row) + + if current_group: + sorted_groups.append(current_group) + + sorted_groups.sort(key=lambda group: (group[0][3] or '', group[0][0].lower())) + + table = [] + for group in sorted_groups: + table.extend(group) + + if is_verbose: + krouter_host = f"https://{vault.keeper_auth.keeper_endpoint.get_router_server()}" + logger.info(f"\nRouter Host: {krouter_host}\n") + + report_utils.dump_report_data(table, headers, fmt='table', filename="", + row_number=False, column_width=None) + + +class PAMGatewayNewCommand(base.ArgparseCommand): + + def __init__(self): + parser = argparse.ArgumentParser(prog='dr-create-gateway') + PAMGatewayNewCommand.add_arguments_to_parser(parser) + super().__init__(parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser): + parser.add_argument('--name', '-n', required=True, dest='gateway_name', + help='Name of the Gateway', + action='store') + parser.add_argument('--application', '-a', required=True, dest='ksm_app', + help='KSM Application name or UID. Use command `sm app list` to view ' + 'available KSM Applications.', action='store') + parser.add_argument('--token-expires-in-min', '-e', type=int, dest='token_expire_in_min', + action='store', + help=f'Time for the one time token to expire. Maximum {MAX_TOKEN_EXPIRATION_MIN} minutes (24 hrs). Default: {DEFAULT_TOKEN_EXPIRATION_MIN}', + default=DEFAULT_TOKEN_EXPIRATION_MIN) + parser.add_argument('--return_value', '-r', dest='return_value', action='store_true', + help='Return value from the command for automation purposes') + + def execute(self, context: KeeperParams, **kwargs): + self._validate_vault_and_permissions(context) + vault = context.vault + + gateway_name = kwargs.get('gateway_name') + ksm_app = kwargs.get('ksm_app') + is_return_value = kwargs.get('return_value') + token_expire_in_min = kwargs.get('token_expire_in_min') + + self._log_gateway_creation_params(gateway_name, ksm_app, token_expire_in_min) + one_time_token = gateway_utils.create_gateway(vault, gateway_name, ksm_app, token_expire_in_min) + + if is_return_value: + return one_time_token + else: + self._display_token_info(ksm_app, gateway_name, token_expire_in_min, one_time_token) + + def _validate_vault_and_permissions(self, context: KeeperParams): + """Validates that vault is initialized and user has enterprise admin permissions.""" + if not context.vault: + raise ValueError(ERROR_VAULT_NOT_INITIALIZED) + base.require_enterprise_admin(context) + + def _log_gateway_creation_params(self, gateway_name, ksm_app, token_expire_in_min): + """Logs gateway creation parameters for debugging.""" + logger.debug(f'gateway_name =[{gateway_name}]') + logger.debug(f'ksm_app =[{ksm_app}]') + logger.debug(f'ott_expire_in_min =[{token_expire_in_min}]') + + def _display_token_info(self, ksm_app, gateway_name, token_expire_in_min, one_time_token): + """Displays one-time token information to the user.""" + logger.info(f'The one time token has been created in application [{ksm_app}].\n\n' + f'The new Gateway named {gateway_name} will show up in a list ' + f'of gateways once it is initialized.\n\n') + logger.info(f'Following one time token will expire in {token_expire_in_min} minutes.') + logger.info(TOKEN_SEPARATOR) + logger.info(one_time_token) + logger.info(TOKEN_SEPARATOR) + + +class PAMGatewayRemoveCommand(base.ArgparseCommand): + + def __init__(self): + parser = argparse.ArgumentParser(prog='dr-remove-gateway') + PAMGatewayRemoveCommand.add_arguments_to_parser(parser) + super().__init__(parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser): + parser.add_argument('--gateway', '-g', required=True, dest='gateway', + help='UID of the Gateway', action='store') + + def execute(self, context: KeeperParams, **kwargs): + self._validate_vault_and_permissions(context) + vault = context.vault + + gateway_uid = kwargs.get('gateway') + gateway = self._find_gateway(vault, gateway_uid) + + if gateway: + gateway_utils.remove_gateway(vault, gateway.controllerUid) + logger.info('Gateway %s has been removed.', gateway.controllerName) + else: + logger.warning('Gateway %s not found', gateway_uid) + + def _validate_vault_and_permissions(self, context: KeeperParams): + """Validates that vault is initialized and user has enterprise admin permissions.""" + if not context.vault: + raise ValueError(ERROR_VAULT_NOT_INITIALIZED) + base.require_enterprise_admin(context) + + def _find_gateway(self, vault, gateway_uid): + """Finds a gateway by UID or name.""" + gateways = gateway_utils.get_all_gateways(vault) + return next((x for x in gateways + if utils.base64_url_encode(x.controllerUid) == gateway_uid + or x.controllerName.lower() == gateway_uid.lower()), None) + + +class PAMGatewaySetMaxInstancesCommand(base.ArgparseCommand): + + def __init__(self): + parser = argparse.ArgumentParser(prog='pam gateway set-max-instances') + PAMGatewaySetMaxInstancesCommand.add_arguments_to_parser(parser) + super().__init__(parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser): + parser.add_argument('--gateway', '-g', required=True, dest='gateway', + help='Gateway UID or Name', action='store') + parser.add_argument('--max-instances', '-m', required=True, dest='max_instances', type=int, + help='Maximum number of gateway instances (must be >= 1)', action='store') + + def execute(self, context: KeeperParams, **kwargs): + self._validate_vault_and_permissions(context) + vault = context.vault + + gateway_uid = kwargs.get('gateway') + max_instances = kwargs.get('max_instances') + + self._validate_max_instances(max_instances) + gateway = self._find_gateway(vault, gateway_uid) + + if not gateway: + raise base.CommandError(f'Gateway "{gateway_uid}" not found') + + self._set_max_instances(vault, gateway, max_instances) + + def _validate_vault_and_permissions(self, context: KeeperParams): + """Validates that vault is initialized and user has enterprise admin permissions.""" + if not context.vault: + raise ValueError(ERROR_VAULT_NOT_INITIALIZED) + base.require_enterprise_admin(context) + + def _validate_max_instances(self, max_instances): + """Validates that max_instances is at least the minimum required.""" + if max_instances < MIN_INSTANCES: + raise base.CommandError(f'pam gateway set-max-instances: --max-instances must be at least {MIN_INSTANCES}') + + def _find_gateway(self, vault, gateway_uid): + """Finds a gateway by UID or name.""" + gateways = gateway_utils.get_all_gateways(vault) + return next((x for x in gateways + if utils.base64_url_encode(x.controllerUid) == gateway_uid + or x.controllerName.lower() == gateway_uid.lower()), None) + + def _set_max_instances(self, vault, gateway, max_instances): + """Sets the maximum number of instances for a gateway.""" + try: + gateway_utils.set_gateway_max_instances(vault, gateway.controllerUid, max_instances) + logger.info('%s: max instance count set to %d', gateway.controllerName, max_instances) + except Exception as e: + raise base.CommandError(f'Error setting max instances: {e}') + diff --git a/keepercli-package/src/keepercli/helpers/gateway_utils.py b/keepercli-package/src/keepercli/helpers/gateway_utils.py new file mode 100644 index 00000000..be6194a9 --- /dev/null +++ b/keepercli-package/src/keepercli/helpers/gateway_utils.py @@ -0,0 +1,111 @@ +from time import time +from typing import List + +from keepersdk.vault import vault_online +from keepersdk.proto import pam_pb2 +from keepersdk.vault import ksm_management + + +# REST endpoint constants +REST_ENDPOINT_GET_CONTROLLERS = 'pam/get_controllers' +REST_ENDPOINT_REMOVE_CONTROLLER = 'pam/remove_controller' +REST_ENDPOINT_SET_MAX_INSTANCE_COUNT = 'pam/set_controller_max_instance_count' + +# Dictionary key constants +KEY_TOKEN_INFO = 'token_info' +KEY_ONE_TIME_TOKEN = 'oneTimeToken' + +# Time constants +MILLISECONDS_PER_SECOND = 1000 +SECONDS_PER_MINUTE = 60 +MILLISECONDS_PER_MINUTE = SECONDS_PER_MINUTE * MILLISECONDS_PER_SECOND +DEFAULT_OTT_EXPIRE_MINUTES = 5 + +# KSM client constants +KSM_CLIENT_COUNT = 1 +KSM_CLIENT_INDEX = 0 + + +def get_all_gateways(vault: vault_online.VaultOnline) -> List[pam_pb2.PAMController]: + """Retrieve all PAM gateways from the vault.""" + rs = vault.keeper_auth.execute_auth_rest( + REST_ENDPOINT_GET_CONTROLLERS, + None, + response_type=pam_pb2.PAMControllersResponse + ) + if rs: + return list(rs.controllers) + return [] + + +def _calculate_first_access_expire_time(ott_expire_in_min: int) -> int: + """Calculate the first access expiration time in milliseconds.""" + current_time_ms = int(time() * MILLISECONDS_PER_SECOND) + return current_time_ms + ott_expire_in_min * MILLISECONDS_PER_MINUTE + + +def _extract_one_time_token(token_dict: dict) -> str: + """Extract the one-time token from the token dictionary.""" + return token_dict[KEY_TOKEN_INFO][KEY_ONE_TIME_TOKEN] + + +def create_gateway( + vault: vault_online.VaultOnline, + gateway_name: str, + app_uid: str, + ott_expire_in_min: int = DEFAULT_OTT_EXPIRE_MINUTES +) -> str: + """Create a new PAM gateway and return its one-time token.""" + master_key = vault.vault_data.get_record_key(record_uid=app_uid) + first_access_expire_duration_ms = _calculate_first_access_expire_time(ott_expire_in_min) + + one_time_token_dict = ksm_management.KSMClientManagement.add_client_to_ksm_app( + vault, + uid=app_uid, + client_name=gateway_name, + count=KSM_CLIENT_COUNT, + index=KSM_CLIENT_INDEX, + unlock_ip=True, + first_access_expire_duration_ms=first_access_expire_duration_ms, + access_expire_in_ms=None, + master_key=master_key, + server=vault.keeper_auth.keeper_endpoint.server + ) + + return _extract_one_time_token(one_time_token_dict) + + +def _find_controller_by_uid(controllers, gateway_uid: bytes): + """Find a controller by its UID.""" + return next((x for x in controllers if x.controllerUid == gateway_uid), None) + + +def _handle_remove_controller_response(rs, gateway_uid: bytes): + """Handle the response from removing a controller and raise exception if needed.""" + if rs and rs.controllers: + controller = _find_controller_by_uid(rs.controllers, gateway_uid) + if controller: + raise Exception(controller.message) + + +def remove_gateway(vault: vault_online.VaultOnline, gateway_uid: bytes): + """Remove a PAM gateway by its UID.""" + rq = pam_pb2.PAMGenericUidRequest() + rq.uid = gateway_uid + rs = vault.keeper_auth.execute_auth_rest( + rest_endpoint=REST_ENDPOINT_REMOVE_CONTROLLER, + request=rq, + response_type=pam_pb2.PAMRemoveControllerResponse + ) + _handle_remove_controller_response(rs, gateway_uid) + + +def set_gateway_max_instances(vault: vault_online.VaultOnline, gateway_uid: bytes, max_instance_count: int): + """Set the maximum instance count for a PAM gateway.""" + rq = pam_pb2.PAMSetMaxInstanceCountRequest() + rq.controllerUid = gateway_uid + rq.maxInstanceCount = max_instance_count + vault.keeper_auth.execute_auth_rest( + rest_endpoint=REST_ENDPOINT_SET_MAX_INSTANCE_COUNT, + request=rq + ) diff --git a/keepercli-package/src/keepercli/helpers/router_utils.py b/keepercli-package/src/keepercli/helpers/router_utils.py new file mode 100644 index 00000000..873e78ce --- /dev/null +++ b/keepercli-package/src/keepercli/helpers/router_utils.py @@ -0,0 +1,29 @@ +import logging +import google +from typing import Optional + +from keepersdk.proto import pam_pb2 +from keepersdk.vault import vault_online + + +API_PATH_GET_CONTROLLERS = "get_controllers" + + +def router_get_connected_gateways(vault: vault_online.VaultOnline) -> Optional[pam_pb2.PAMOnlineControllers]: + """Get connected gateways from the router.""" + rs = vault.keeper_auth.keeper_endpoint.execute_router_rest( + endpoint=API_PATH_GET_CONTROLLERS, + session_token=vault.keeper_auth.auth_context.session_token, + payload=b'' + ) + + if isinstance(rs, bytes): + pam_online_controllers = pam_pb2.PAMOnlineControllers() + pam_online_controllers.ParseFromString(rs) + if logging.getLogger().level <= logging.DEBUG: + js = google.protobuf.json_format.MessageToJson(pam_online_controllers) + logging.debug('>>> [GW RS] %s: %s', API_PATH_GET_CONTROLLERS, js) + + return pam_online_controllers + + return None diff --git a/keepercli-package/src/keepercli/register_commands.py b/keepercli-package/src/keepercli/register_commands.py index 00684bb7..5a6d0e15 100644 --- a/keepercli-package/src/keepercli/register_commands.py +++ b/keepercli-package/src/keepercli/register_commands.py @@ -86,6 +86,7 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS if not scopes or bool(scopes & base.CommandScope.Enterprise): from .commands import (enterprise_info, enterprise_node, enterprise_role, enterprise_team, enterprise_user, enterprise_create_user, importer_commands, audit_report, audit_alert, audit_log, transfer_account, pedm_admin, msp, user_report) + from .commands.pam import keeper_pam commands.register_command('create-user', enterprise_create_user.CreateEnterpriseUserCommand(), base.CommandScope.Enterprise, 'ecu') commands.register_command('enterprise-down', enterprise_info.EnterpriseDownCommand(), base.CommandScope.Enterprise, 'ed') @@ -105,3 +106,4 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS commands.register_command('switch-to-mc', msp.SwitchToManagedCompanyCommand(), base.CommandScope.Enterprise) commands.register_command('team-approve', enterprise_team.TeamApproveCommand(), base.CommandScope.Enterprise) commands.register_command('user-report', user_report.UserReportCommand(), base.CommandScope.Enterprise, 'ur') + commands.register_command('pam', keeper_pam.PAMControllerCommand(), base.CommandScope.Enterprise) \ No newline at end of file From 5a405cc2f9c6b941ec662305e7d239124b31a8ef Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Mon, 5 Jan 2026 10:44:24 +0530 Subject: [PATCH 20/37] Security audit report command added --- .../security_audit/security_audit_report.py | 260 +++++++++ .../commands/security_audit_report.py | 242 ++++++++ .../src/keepercli/register_commands.py | 7 +- .../keepersdk/enterprise/enterprise_types.py | 26 + .../enterprise/security_audit_report.py | 540 ++++++++++++++++++ 5 files changed, 1072 insertions(+), 3 deletions(-) create mode 100644 examples/sdk_examples/security_audit/security_audit_report.py create mode 100644 keepercli-package/src/keepercli/commands/security_audit_report.py create mode 100644 keepersdk-package/src/keepersdk/enterprise/security_audit_report.py diff --git a/examples/sdk_examples/security_audit/security_audit_report.py b/examples/sdk_examples/security_audit/security_audit_report.py new file mode 100644 index 00000000..8aca4d23 --- /dev/null +++ b/examples/sdk_examples/security_audit/security_audit_report.py @@ -0,0 +1,260 @@ +""" +Security Audit Report SDK Example + +This example demonstrates how to use the Keeper SDK to generate a security audit +report for enterprise users. The report includes password strength analysis, +reused passwords, and security scores for all users in the enterprise. + +Usage: + python security_audit_report.py + +Requirements: + - Enterprise admin account + - Keeper SDK installed +""" + +import getpass +import sqlite3 +import traceback + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage, security_audit_report +from keepersdk.errors import KeeperApiError +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +# Table formatting constants +TABLE_WIDTH = 140 +COL_WIDTHS = (35, 20, 8, 8, 8, 8, 8, 8, 8, 10, 6, 20) + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps. + + Returns: + keeper_auth.KeeperAuth: The authenticated Keeper context, or None if login fails. + """ + config = configuration.JsonConfigurationStorage() + + # Server selection + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = config.get().last_login or input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Approve this device and press Enter to continue.") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + +def format_row(values, widths=COL_WIDTHS): + """ + Format a row of values according to column widths. + + Args: + values: List of values to format + widths: Tuple of column widths + + Returns: + str: Formatted row string + """ + formatted = [] + for i, val in enumerate(values): + if i >= len(widths): + break + width = widths[i] + text = str(val if val is not None else '')[:width - 1] + formatted.append(f"{text:<{width}}") + return ' '.join(formatted) + + +def print_report(entries): + """ + Print the security audit report in table format. + + Args: + entries: List of SecurityAuditEntry objects + """ + print("\n" + "=" * TABLE_WIDTH) + print("ENTERPRISE SECURITY AUDIT REPORT") + print("=" * TABLE_WIDTH) + + headers = ['Email', 'Name', 'Weak', 'Fair', 'Medium', 'Strong', 'Reused', 'Unique', 'Score', 'Pending', '2FA', 'Node'] + print(format_row(headers)) + print("-" * TABLE_WIDTH) + + for entry in entries: + row = [ + entry.email, + entry.username, + entry.weak, + entry.fair, + entry.medium, + entry.strong, + entry.reused, + entry.unique, + f"{entry.security_score}%", + 'Yes' if entry.sync_pending else '', + 'On' if entry.two_factor_enabled else 'Off', + entry.node_path + ] + print(format_row(row)) + + print("=" * TABLE_WIDTH) + print(f"\nTotal Users: {len(entries)}") + + if entries: + avg_score = sum(e.security_score for e in entries) / len(entries) + total_weak = sum(e.weak for e in entries) + total_strong = sum(e.strong for e in entries) + total_reused = sum(e.reused for e in entries) + twofa_enabled = sum(1 for e in entries if e.two_factor_enabled) + + print(f"\nSummary Statistics:") + print(f" Average Security Score: {avg_score:.1f}%") + print(f" Total Weak Passwords: {total_weak}") + print(f" Total Strong Passwords: {total_strong}") + print(f" Total Reused Passwords: {total_reused}") + print(f" Users with 2FA Enabled: {twofa_enabled} ({100*twofa_enabled/len(entries):.1f}%)") + + +def print_errors(errors): + """ + Print any errors encountered during report generation. + + Args: + errors: List of SecurityAuditError objects + """ + if not errors: + return + + print("\n" + "!" * 60) + print("ERRORS ENCOUNTERED") + print("!" * 60) + + for error in errors: + print(f" {error.email}: {error.error_message}") + + print("!" * 60) + + +def generate_security_audit_report(keeper_auth_context: keeper_auth.KeeperAuth): + """ + Generate enterprise security audit report. + + This function loads enterprise data, fetches security report data for all users, + and displays the results in a formatted table. + + Args: + keeper_auth_context: The authenticated Keeper context with enterprise admin privileges. + """ + if not keeper_auth_context.auth_context.is_enterprise_admin: + print("ERROR: This operation requires enterprise admin privileges.") + print("The current user is not an enterprise administrator.") + print("\nTo use security audit report features, you need:") + print(" 1. An enterprise account") + print(" 2. Enterprise administrator role") + keeper_auth_context.close() + return + + enterprise = None + try: + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + enterprise_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + enterprise = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + + print("\nLoading enterprise data...") + + config = security_audit_report.SecurityAuditConfig( + node_ids=None, + show_breachwatch=False, + show_updated=False, + save_report=False, + score_type='default' + ) + + print("Generating security audit report...") + + generator = security_audit_report.SecurityAuditReportGenerator( + enterprise.enterprise_data, + keeper_auth_context, + config + ) + + entries = generator.generate_report() + + if generator.has_errors: + print_errors(generator.errors) + + print_report(entries) + + except KeeperApiError as e: + print(f"\nAPI Error: {e}") + except Exception as e: + print(f"\nError generating security audit report: {e}") + traceback.print_exc() + finally: + if enterprise: + enterprise.close() + keeper_auth_context.close() + + +def main(): + """ + Main entry point for the security audit report script. + Performs login and generates the security audit report. + """ + print("=" * 60) + print("Keeper Enterprise Security Audit Report Generator") + print("=" * 60) + print("\nThis tool generates a security audit report for all enterprise users,") + print("including password strength analysis and security scores.\n") + + keeper_auth_context = login() + + if keeper_auth_context: + generate_security_audit_report(keeper_auth_context) + else: + print("Login failed. Unable to generate security audit report.") + + +if __name__ == "__main__": + main() + diff --git a/keepercli-package/src/keepercli/commands/security_audit_report.py b/keepercli-package/src/keepercli/commands/security_audit_report.py new file mode 100644 index 00000000..4e5efeff --- /dev/null +++ b/keepercli-package/src/keepercli/commands/security_audit_report.py @@ -0,0 +1,242 @@ +"""Security Audit Report command for Keeper CLI.""" + +import argparse +from typing import Any, List, Optional + +from keepersdk.enterprise import security_audit_report + +from . import base, enterprise_utils +from ..helpers import report_utils +from ..params import KeeperParams +from .. import api, prompt_utils + + +SECURITY_AUDIT_REPORT_DESCRIPTION = ''' +Security Audit Report Command Syntax Description: + +Column Name Description + username user name + email e-mail address + weak number of records whose password strength is in the weak category + fair number of records whose password strength is in the fair category + medium number of records whose password strength is in the medium category + strong number of records whose password strength is in the strong category + reused number of reused passwords + unique number of unique passwords + securityScore security score + twoFactorChannel 2FA - ON/OFF + +--format: + csv CSV format + json JSON format + table Table format (default) +''' + + +class SecurityAuditReportCommand(base.ArgparseCommand, enterprise_utils.EnterpriseMixin): + """Command to generate a security audit report for enterprise users.""" + + def __init__(self): + parser = argparse.ArgumentParser( + prog='security-audit-report', + description='Run a security audit report.', + parents=[base.report_output_parser] + ) + SecurityAuditReportCommand.add_arguments_to_parser(parser) + super().__init__(parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser): + parser.add_argument( + '--syntax-help', + dest='syntax_help', + action='store_true', + help='display help' + ) + parser.add_argument( + '-n', '--node', + action='append', + help='name(s) or UID(s) of node(s) to filter results of the report by' + ) + parser.add_argument( + '-b', '--breachwatch', + dest='breachwatch', + action='store_true', + help='display BreachWatch report. Ignored if BreachWatch is not active.' + ) + parser.add_argument( + '-s', '--save', + action='store_true', + help='save updated security audit reports' + ) + parser.add_argument( + '-su', '--show-updated', + action='store_true', + help='show updated data' + ) + parser.add_argument( + '-st', '--score-type', + action='store', + choices=['strong_passwords', 'default'], + default='default', + help='define how score is calculated' + ) + parser.add_argument( + '--attempt-fix', + action='store_true', + help='do a "hard" sync for vaults with invalid security-data. Associated security scores ' + 'are reset and will be inaccurate until affected vaults can re-calculate and update ' + 'their security-data' + ) + parser.add_argument( + '-f', '--force', + action='store_true', + help='skip confirmation prompts (non-interactive mode)' + ) + + def execute(self, context: KeeperParams, **kwargs) -> Any: + base.require_login(context) + base.require_enterprise_admin(context) + + logger = api.get_logger() + + if kwargs.get('syntax_help'): + logger.info(SECURITY_AUDIT_REPORT_DESCRIPTION) + return + + enterprise_data = context.enterprise_data + show_breachwatch = kwargs.get('breachwatch') + + if show_breachwatch: + if not context.auth.auth_context.license.get('breachWatchEnabled'): + raise base.CommandError( + 'BreachWatch is not enabled for this account. ' + 'Please contact your administrator to enable this feature.' + ) + logger.info('Generating BreachWatch security audit report...') + + node_ids = self._resolve_node_ids(enterprise_data, kwargs.get('node')) + attempt_fix = kwargs.get('attempt_fix', False) + force = kwargs.get('force', False) + save_report = kwargs.get('save') or attempt_fix + + config = security_audit_report.SecurityAuditConfig( + node_ids=node_ids if node_ids else None, + show_breachwatch=show_breachwatch, + show_updated=save_report or kwargs.get('show_updated'), + save_report=save_report, + score_type=kwargs.get('score_type', 'default'), + attempt_fix=attempt_fix + ) + + generator = security_audit_report.SecurityAuditReportGenerator( + enterprise_data, context.auth, config + ) + rows: List[List[Any]] = list(generator.generate_report_rows(breachwatch=show_breachwatch)) + + fmt = kwargs.get('format', 'table') + out = kwargs.get('output') + + if generator.has_errors: + if attempt_fix: + return self._handle_attempt_fix(generator, context, out, fmt) + result = self._display_error_report(generator, out, fmt) + fix_instructions = ('\nNote: To resolve the issues found above, re-run this command with the' + ' --attempt-fix switch, i.e., run\n\tsecurity-audit-report --attempt-fix') + if result is None: + logger.error(fix_instructions) + else: + result += fix_instructions + return result + + if config.save_report and generator.updated_reports: + if force or attempt_fix or self._confirm_save(len(generator.updated_reports)): + generator.save_updated_reports() + logger.info(f'Saved {len(generator.updated_reports)} updated security report(s).') + else: + logger.info('Save operation cancelled.') + + return self._format_report(rows, show_breachwatch, fmt, out) + + def _resolve_node_ids(self, enterprise_data, nodes: Optional[List[str]]) -> List[int]: + """Resolve node names/IDs to node IDs.""" + if not nodes: + return [] + + node_ids = [] + for name_or_id in nodes: + for n in enterprise_data.nodes.get_all_entities(): + if name_or_id == str(n.node_id) or name_or_id == n.name: + node_ids.append(n.node_id) + break + return node_ids + + def _format_report( + self, + rows: List[List[Any]], + show_breachwatch: bool, + fmt: str, + out: Optional[str] + ) -> Optional[str]: + """Format and output the security audit report.""" + headers = security_audit_report.SecurityAuditReportGenerator.get_headers(breachwatch=show_breachwatch) + if fmt == 'table': + headers = [report_utils.field_to_title(x) for x in headers] + + report_title = f'Security Audit Report{" (BreachWatch)" if show_breachwatch else ""}' + return report_utils.dump_report_data(rows, headers, fmt=fmt, filename=out, title=report_title) + + @staticmethod + def _confirm_save(count: int) -> bool: + """Prompt user for confirmation before saving security reports (CLI interaction).""" + question = f'Do you want to save {count} updated security report(s)?' + answer = prompt_utils.user_choice(question, 'yn', default='n') + return answer.lower() == 'y' + + @staticmethod + def _display_error_report( + generator: security_audit_report.SecurityAuditReportGenerator, + out: Optional[str], + fmt: str + ) -> Optional[str]: + """Format and output the error report with enterprise-level errors first.""" + title = 'Security Audit Report - Problems Found\nSecurity data could not be parsed for the following vaults:' + headers = security_audit_report.SecurityAuditReportGenerator.get_error_headers() + if fmt == 'table': + headers = [report_utils.field_to_title(x) for x in headers] + + error_rows = list(generator.generate_error_rows()) + error_rows.sort(key=lambda row: row[0] != 'Enterprise') + return report_utils.dump_report_data(error_rows, headers, fmt=fmt, filename=out, title=title) + + def _handle_attempt_fix( + self, + generator: security_audit_report.SecurityAuditReportGenerator, + context: KeeperParams, + out: Optional[str], + fmt: str + ) -> Optional[str]: + """Sync problem vaults and regenerate the report.""" + problem_emails = [error.email for error in generator.errors if '@' in error.email] + + if problem_emails: + generator.sync_problem_vaults(problem_emails) + + new_config = security_audit_report.SecurityAuditConfig( + node_ids=generator.config.node_ids, + show_breachwatch=generator.config.show_breachwatch, + show_updated=True, + save_report=True, + score_type=generator.config.score_type, + attempt_fix=False + ) + new_generator = security_audit_report.SecurityAuditReportGenerator( + context.enterprise_data, context.auth, new_config + ) + rows = list(new_generator.generate_report_rows(breachwatch=generator.config.show_breachwatch)) + + if new_generator.updated_reports: + new_generator.save_updated_reports() + + return self._format_report(rows, generator.config.show_breachwatch, fmt, out) + diff --git a/keepercli-package/src/keepercli/register_commands.py b/keepercli-package/src/keepercli/register_commands.py index 5a6d0e15..07e28906 100644 --- a/keepercli-package/src/keepercli/register_commands.py +++ b/keepercli-package/src/keepercli/register_commands.py @@ -80,12 +80,13 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS commands.register_command('record-permission', record_handling_commands.RecordPermissionCommand(), base.CommandScope.Vault) commands.register_command('trash', trash.TrashCommand(), base.CommandScope.Vault) commands.register_command('verify-shared-folders', verify_records.VerifySharedFoldersCommand(), base.CommandScope.Vault) - commands.register_command('verify-records', verify_records.VerifyRecordsCommand(), base.CommandScope.Vault) + commands.register_command('verify-records', verify_records.VerifyRecordsCommand(), base.CommandScope.Vault) if not scopes or bool(scopes & base.CommandScope.Enterprise): from .commands import (enterprise_info, enterprise_node, enterprise_role, enterprise_team, enterprise_user, enterprise_create_user, - importer_commands, audit_report, audit_alert, audit_log, transfer_account, pedm_admin, msp, user_report) + importer_commands, audit_report, audit_alert, audit_log, transfer_account, pedm_admin, msp, user_report, + security_audit_report) from .commands.pam import keeper_pam commands.register_command('create-user', enterprise_create_user.CreateEnterpriseUserCommand(), base.CommandScope.Enterprise, 'ecu') @@ -106,4 +107,4 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS commands.register_command('switch-to-mc', msp.SwitchToManagedCompanyCommand(), base.CommandScope.Enterprise) commands.register_command('team-approve', enterprise_team.TeamApproveCommand(), base.CommandScope.Enterprise) commands.register_command('user-report', user_report.UserReportCommand(), base.CommandScope.Enterprise, 'ur') - commands.register_command('pam', keeper_pam.PAMControllerCommand(), base.CommandScope.Enterprise) \ No newline at end of file + commands.register_command('security-audit-report', security_audit_report.SecurityAuditReportCommand(), base.CommandScope.Enterprise, 'sar') diff --git a/keepersdk-package/src/keepersdk/enterprise/enterprise_types.py b/keepersdk-package/src/keepersdk/enterprise/enterprise_types.py index 398ec7fe..21c827b0 100644 --- a/keepersdk-package/src/keepersdk/enterprise/enterprise_types.py +++ b/keepersdk-package/src/keepersdk/enterprise/enterprise_types.py @@ -26,6 +26,32 @@ class Node: encrypted_data: Optional[str] = None name: str = '' + def get_path(self, enterprise_data: 'IEnterpriseData', omit_root: bool = False) -> str: + """Get the full path for this node as a backslash-separated string. + + Args: + enterprise_data: The enterprise data containing nodes and enterprise info. + omit_root: If True, exclude the root node from the path. + + Returns: + The node path as a backslash-separated string. + """ + nodes: List[str] = [] + n_id = self.node_id + while isinstance(n_id, int) and n_id > 0: + node = enterprise_data.nodes.get_entity(n_id) + if not node: + break + n_id = node.parent_id or 0 + if not omit_root or n_id > 0: + node_name = node.name + if not node_name and node.node_id == enterprise_data.root_node.node_id: + node_name = enterprise_data.enterprise_info.enterprise_name + nodes.append(node_name) + nodes.reverse() + return '\\'.join(nodes) + + @attrs.define(frozen=True) class INode(Node): pass diff --git a/keepersdk-package/src/keepersdk/enterprise/security_audit_report.py b/keepersdk-package/src/keepersdk/enterprise/security_audit_report.py new file mode 100644 index 00000000..e51f7bac --- /dev/null +++ b/keepersdk-package/src/keepersdk/enterprise/security_audit_report.py @@ -0,0 +1,540 @@ +"""Enterprise security audit report functionality for Keeper SDK.""" + +import base64 +import dataclasses +import json +from json import JSONDecodeError +from typing import Optional, List, Dict, Any, Iterable + +from cryptography.hazmat.primitives.asymmetric import rsa, ec + +from ..authentication import keeper_auth +from .. import crypto +from ..proto import APIRequest_pb2, enterprise_pb2 +from . import enterprise_types + + +SECURITY_SCORE_KEYS = ( + 'weak_record_passwords', + 'fair_record_passwords', + 'medium_record_passwords', + 'strong_record_passwords', + 'total_record_passwords', + 'unique_record_passwords', +) + +BREACHWATCH_SCORE_KEYS = ( + 'passed_records', + 'at_risk_records', + 'ignored_records' +) + +SCORE_DATA_KEYS = SECURITY_SCORE_KEYS + BREACHWATCH_SCORE_KEYS + + +def is_pw_strong(strength: Optional[int]) -> bool: + """Check if password strength is strong (>= 80).""" + return isinstance(strength, int) and strength >= 80 + + +def is_pw_fair(strength: Optional[int]) -> bool: + """Check if password strength is fair (40-79).""" + return isinstance(strength, int) and 40 <= strength < 80 + + +def is_pw_weak(strength: Optional[int]) -> bool: + """Check if password strength is weak (< 40).""" + return isinstance(strength, int) and strength < 40 + + +def is_rec_at_risk(bw_result: Optional[int]) -> bool: + """Check if record is at risk based on BreachWatch result.""" + return isinstance(bw_result, int) and bw_result in (1, 2) + + +def passed_bw_check(bw_result: Optional[int]) -> bool: + """Check if record passed BreachWatch check.""" + return isinstance(bw_result, int) and bw_result == 0 + + +@dataclasses.dataclass +class SecurityAuditEntry: + """Represents a single user entry in the security audit report.""" + enterprise_user_id: int + email: str + username: str = '' + node_path: str = '' + total: int = 0 + weak: int = 0 + fair: int = 0 + medium: int = 0 + strong: int = 0 + reused: int = 0 + unique: int = 0 + passed: int = 0 + at_risk: int = 0 + ignored: int = 0 + security_score: int = 25 + two_factor_enabled: bool = False + sync_pending: Optional[bool] = None + + +@dataclasses.dataclass +class SecurityAuditConfig: + """Configuration for security audit report generation.""" + node_ids: Optional[List[int]] = None + show_breachwatch: bool = False + show_updated: bool = False + save_report: bool = False + score_type: str = 'default' + attempt_fix: bool = False + + +@dataclasses.dataclass +class SecurityAuditError: + """Represents an error encountered during security audit processing.""" + email: str + error_message: str + + +class SecurityAuditReportGenerator: + """Generates security audit reports for enterprise users.""" + + def __init__( + self, + enterprise_data: enterprise_types.IEnterpriseData, + auth: keeper_auth.KeeperAuth, + config: Optional[SecurityAuditConfig] = None + ) -> None: + self._enterprise_data = enterprise_data + self._auth = auth + self._config = config or SecurityAuditConfig() + self._tree_key: Optional[bytes] = None + self._user_lookup: Optional[Dict[int, Dict[str, Any]]] = None + self._errors: List[SecurityAuditError] = [] + self._updated_reports: List[APIRequest_pb2.SecurityReport] = [] + self._rsa_key: Optional[rsa.RSAPrivateKey] = None + self._ec_key: Optional[ec.EllipticCurvePrivateKey] = None + + @property + def enterprise_data(self) -> enterprise_types.IEnterpriseData: + return self._enterprise_data + + @property + def config(self) -> SecurityAuditConfig: + return self._config + + @property + def errors(self) -> List[SecurityAuditError]: + """Get list of errors encountered during report generation.""" + return self._errors + + @property + def has_errors(self) -> bool: + """Check if any errors were encountered.""" + return len(self._errors) > 0 + + @property + def updated_reports(self) -> List[APIRequest_pb2.SecurityReport]: + """Get list of updated security reports ready to save.""" + return self._updated_reports + + def _add_error(self, email: str, message: str) -> None: + """Add an error to the error list.""" + self._errors.append(SecurityAuditError(email=email, error_message=message)) + + def _build_user_lookup(self) -> Dict[int, Dict[str, Any]]: + """Build a lookup dictionary of user info by enterprise user ID.""" + if self._user_lookup is not None: + return self._user_lookup + + self._user_lookup = {} + for user in self._enterprise_data.users.get_all_entities(): + email = user.username + username = user.full_name if user.full_name else None + if username is None or not username.strip(): + username = email + node_id = user.node_id or 0 + self._user_lookup[user.enterprise_user_id] = { + 'username': username, + 'email': email, + 'node_id': node_id + } + return self._user_lookup + + def resolve_user_info(self, enterprise_user_id: int) -> Dict[str, Any]: + """Resolve user information by enterprise user ID.""" + user_lookup = self._build_user_lookup() + info = { + 'username': str(enterprise_user_id), + 'email': str(enterprise_user_id), + 'node_id': 0 + } + info = user_lookup.get(enterprise_user_id, info) + return info + + @staticmethod + def get_node_path( + enterprise_data: enterprise_types.IEnterpriseData, + node_id: int, + omit_root: bool = False + ) -> str: + """Get the full path for a node as a backslash-separated string. + + This is a convenience wrapper around Node.get_path(). + """ + node = enterprise_data.nodes.get_entity(node_id) + if node: + return node.get_path(enterprise_data, omit_root) + return '' + + @staticmethod + def get_strong_by_total(total: int, strong: int) -> float: + """Calculate the ratio of strong passwords to total passwords.""" + return 0 if (total == 0) else (strong / total) + + @staticmethod + def get_security_score(total: int, strong: int, unique: int, + two_factor_on: bool, master_password: int = 1) -> float: + """Calculate the overall security score.""" + strong_by_total = SecurityAuditReportGenerator.get_strong_by_total(total, strong) + unique_by_total = 0 if (total == 0) else (unique / total) + two_factor_val = 1 if two_factor_on else 0 + score = (strong_by_total + unique_by_total + master_password + two_factor_val) / 4 + return score + + @staticmethod + def flatten_report_data(data: Dict[str, Any], num_reused_pws: int) -> Dict[str, int]: + """Flatten security report data into a simple dictionary.""" + sec_stats = data.get('securityAuditStats', {}) + bw_stats = data.get('bwStats', {}) + total = data.get('total_record_passwords') or sec_stats.get('total_record_passwords', 0) + result = {k: data.get(k) or sec_stats.get(k) or bw_stats.get(k, 0) for k in SCORE_DATA_KEYS} + result['unique_record_passwords'] = total - num_reused_pws + + if not sec_stats: + weak = result.get('weak_record_passwords', 0) + strong = result.get('strong_record_passwords', 0) + result['medium_record_passwords'] = total - weak - strong + return result + + @staticmethod + def format_report_data(flattened_data: Dict[str, int]) -> Dict[str, Dict[str, int]]: + """Format flattened data back into structured report format.""" + sec_stats = {k: flattened_data.get(k) for k in SECURITY_SCORE_KEYS} + bw_stats = {k: flattened_data.get(k) for k in BREACHWATCH_SCORE_KEYS} + return {'securityAuditStats': sec_stats, 'bwStats': bw_stats} + + def _decrypt_incremental_security_data( + self, + sec_data: bytes, + key_type: int, + current_email: str + ) -> Optional[Dict[str, int]]: + """Decrypt security data from incremental report.""" + decrypted = None + if sec_data: + try: + if key_type == enterprise_pb2.KT_ENCRYPTED_BY_PUBLIC_KEY_ECC: + decrypted_bytes = crypto.decrypt_ec(sec_data, self._ec_key) + else: + decrypted_bytes = crypto.decrypt_rsa(sec_data, self._rsa_key) + except Exception as e: + self._add_error(current_email, f'Decrypt fail (incremental data): {e}') + return None + + try: + decoded = decrypted_bytes.decode() + except UnicodeDecodeError: + self._add_error(current_email, 'Decode fail, incremental data (base 64)') + decoded_b64 = base64.b64encode(decrypted_bytes).decode('ascii') + self._add_error(current_email, decoded_b64) + return None + except Exception as e: + self._add_error(current_email, f'Decode fail: {e}') + return None + + try: + decrypted = json.loads(decoded) + except JSONDecodeError: + self._add_error(current_email, f'Invalid JSON: {decoded}') + except Exception as e: + self._add_error(current_email, f'Load fail (incremental data). {e}') + + return decrypted + + def _get_updated_security_report_row( + self, + sr: APIRequest_pb2.SecurityReport, + last_saved_data: Dict[str, int], + current_email: str + ) -> Dict[str, int]: + """Get updated security report row by applying incremental data.""" + + def decrypt_incremental_data(inc_data: APIRequest_pb2.SecurityReportIncrementalData + ) -> Dict[str, Optional[Dict[str, int]]]: + decrypted = { + 'old': self._decrypt_incremental_security_data( + inc_data.oldSecurityData, inc_data.oldDataEncryptionType, current_email), + 'curr': self._decrypt_incremental_security_data( + inc_data.currentSecurityData, inc_data.currentDataEncryptionType, current_email) + } + return decrypted + + def get_security_score_deltas(rec_sec_data: Dict[str, Any], delta: int) -> Dict[str, int]: + bw_result = rec_sec_data.get('bw_result') + pw_strength = rec_sec_data.get('strength') + sec_deltas = {k: 0 for k in SECURITY_SCORE_KEYS} + bw_deltas = {k: 0 for k in BREACHWATCH_SCORE_KEYS} + + sec_key = 'strong_record_passwords' if is_pw_strong(pw_strength) \ + else 'fair_record_passwords' if is_pw_fair(pw_strength) \ + else 'weak_record_passwords' if is_pw_weak(pw_strength) \ + else 'medium_record_passwords' + sec_deltas[sec_key] = delta + sec_deltas['total_record_passwords'] = delta + + bw_key = 'at_risk_records' if is_rec_at_risk(bw_result) \ + else 'passed_records' if passed_bw_check(bw_result) \ + else 'ignored_records' + bw_deltas[bw_key] = delta + + return {**sec_deltas, **bw_deltas} + + def apply_score_deltas(sec_data: Dict[str, int], deltas: Dict[str, int]) -> Dict[str, int]: + new_scores = {k: v + sec_data.get(k, 0) for k, v in deltas.items()} + return {**sec_data, **new_scores} + + def update_scores(user_sec_data: Dict[str, int], + inc_dataset: List[Dict[str, Optional[Dict[str, int]]]]) -> Dict[str, int]: + def update(u_sec_data: Dict[str, int], old_sec_d: Optional[Dict[str, Any]], + diff: int) -> Dict[str, int]: + if not old_sec_d: + return u_sec_data + deltas = get_security_score_deltas(old_sec_d, diff) + return apply_score_deltas(u_sec_data, deltas) + + for inc_data in inc_dataset: + if any(d for d in inc_data.values() if d is not None and d.get('strength') is None): + self._add_error(current_email, 'Invalid data: "strength" is undefined') + break + existing_data_keys = [k for k, d in inc_data.items() if d] + for k in existing_data_keys: + user_sec_data = update(user_sec_data, inc_data.get(k), -1 if k == 'old' else 1) + + return user_sec_data + + report_data = {**last_saved_data} + incremental_dataset = sr.securityReportIncrementalData + if incremental_dataset: + decrypted_dataset = [decrypt_incremental_data(x) for x in incremental_dataset] + report_data = update_scores(report_data, decrypted_dataset) + + total = report_data.get('total_record_passwords', 0) + report_data['unique_record_passwords'] = total - sr.numberOfReusedPassword + return report_data + + def generate_report(self) -> List[SecurityAuditEntry]: + """Generate the security audit report.""" + self._errors.clear() + self._updated_reports.clear() + + enterprise_info = self._enterprise_data.enterprise_info + tree_key = enterprise_info.tree_key + self._rsa_key = enterprise_info._rsa_private_key + self._ec_key = enterprise_info._ec_private_key + + from_page = 0 + complete = False + entries: List[SecurityAuditEntry] = [] + + while not complete: + rq = APIRequest_pb2.SecurityReportRequest() + rq.fromPage = from_page + security_report_rs = self._auth.execute_auth_rest( + 'enterprise/get_security_report_data', + rq, + response_type=APIRequest_pb2.SecurityReportResponse + ) + if security_report_rs is None: + self._add_error('Enterprise', 'Failed to get security report data') + break + + to_page = security_report_rs.toPage + complete = security_report_rs.complete + from_page = to_page + 1 + + try: + if not self._rsa_key and len(security_report_rs.enterprisePrivateKey) > 0: + key_data = crypto.decrypt_aes_v2(security_report_rs.enterprisePrivateKey, tree_key) + self._rsa_key = crypto.load_rsa_private_key(key_data) + if not self._ec_key and len(security_report_rs.enterpriseEccPrivateKey) > 0: + key_data = crypto.decrypt_aes_v2(security_report_rs.enterpriseEccPrivateKey, tree_key) + self._ec_key = crypto.load_ec_private_key(key_data) + except Exception as e: + self._add_error('Enterprise', f'Invalid enterprise private key: {e}') + continue + + for sr in security_report_rs.securityReport: + user_info = self.resolve_user_info(sr.enterpriseUserId) + node_id = user_info.get('node_id', 0) + + if self._config.node_ids and node_id not in self._config.node_ids: + continue + + email = user_info.get('email', str(sr.enterpriseUserId)) + username = user_info.get('username', str(sr.enterpriseUserId)) + node_path = self.get_node_path(self._enterprise_data, node_id) if node_id > 0 else '' + twofa_on = sr.twoFactor != 'two_factor_disabled' + + if sr.encryptedReportData: + try: + sri = crypto.decrypt_aes_v2(sr.encryptedReportData, tree_key) + except Exception: + continue + + try: + data = self.flatten_report_data(json.loads(sri), sr.numberOfReusedPassword) + except Exception: + continue + else: + data = {dk: 0 for dk in SCORE_DATA_KEYS} + + if self._config.show_updated: + data = self._get_updated_security_report_row(sr, data, email) + + if self.has_errors: + continue + + if self._config.save_report: + updated_sr = APIRequest_pb2.SecurityReport() + updated_sr.revision = security_report_rs.asOfRevision + updated_sr.enterpriseUserId = sr.enterpriseUserId + report = json.dumps(self.format_report_data(data)).encode('utf-8') + updated_sr.encryptedReportData = crypto.encrypt_aes_v2(report, tree_key) + self._updated_reports.append(updated_sr) + + strong = data.get('strong_record_passwords', 0) + total = data.get('total_record_passwords', 0) + unique = data.get('unique_record_passwords', 0) + master_pw_strength = 1 + + if self._config.score_type == 'strong_passwords': + score = int(100 * self.get_strong_by_total(total, strong)) + else: + score = int(100 * round(self.get_security_score(total, strong, unique, twofa_on, master_pw_strength), 2)) + + sync_pending = True if total == 0 and sr.numberOfReusedPassword != 0 else None + + entry = SecurityAuditEntry( + enterprise_user_id=sr.enterpriseUserId, + email=email, + username=username, + node_path=node_path, + total=total, + weak=data.get('weak_record_passwords', 0), + fair=data.get('fair_record_passwords', 0), + medium=data.get('medium_record_passwords', 0), + strong=strong, + reused=sr.numberOfReusedPassword, + unique=unique, + passed=data.get('passed_records', 0), + at_risk=data.get('at_risk_records', 0), + ignored=data.get('ignored_records', 0), + security_score=score, + two_factor_enabled=twofa_on, + sync_pending=sync_pending + ) + entries.append(entry) + + return entries + + def generate_report_rows(self, breachwatch: bool = False) -> Iterable[List[Any]]: + """Generate report rows suitable for tabular output.""" + for entry in self.generate_report(): + if breachwatch: + yield [ + entry.email, entry.username, entry.sync_pending, + entry.at_risk, entry.passed, entry.ignored + ] + else: + yield [ + entry.email, entry.username, entry.sync_pending, + entry.weak, entry.fair, entry.medium, entry.strong, + entry.reused, entry.unique, entry.security_score, + 'On' if entry.two_factor_enabled else 'Off', entry.node_path + ] + + def generate_error_rows(self) -> Iterable[List[Any]]: + """Generate error report rows.""" + for error in self._errors: + yield [error.email, error.error_message] + + def save_updated_reports(self) -> None: + """Save updated security reports to the server.""" + if not self._updated_reports: + return + save_rq = APIRequest_pb2.SecurityReportSaveRequest() + for r in self._updated_reports: + save_rq.securityReport.append(r) + self._auth.execute_auth_rest('enterprise/save_summary_security_report', save_rq) + + def sync_problem_vaults(self, emails: List[str]) -> None: + """ + Perform a hard sync for vaults with invalid security-data. + + This initiates a FORCE_CLIENT_RESEND_SECURITY_DATA sync for the specified + user vaults. Associated security scores will be reset and will be inaccurate + until affected vaults can re-calculate and update their security-data. + + Args: + emails: List of email addresses of users whose vaults need syncing. + """ + if not emails: + return + + userid_lookup = { + u.username: u.enterprise_user_id + for u in self._enterprise_data.users.get_all_entities() + } + + userids = [uid for email in emails if (uid := userid_lookup.get(email))] + + if not userids: + return + + CHUNK_SIZE = 999 + while userids: + chunk = userids[:CHUNK_SIZE] + userids = userids[CHUNK_SIZE:] + + rq = enterprise_pb2.ClearSecurityDataRequest() + rq.type = enterprise_pb2.FORCE_CLIENT_RESEND_SECURITY_DATA + rq.allUsers = False + rq.enterpriseUserId.extend(chunk) + self._auth.execute_auth_rest('enterprise/clear_security_data', rq) + + @staticmethod + def get_headers(breachwatch: bool = False) -> List[str]: + """Get report headers.""" + if breachwatch: + return ['email', 'name', 'sync_pending', 'at_risk', 'passed', 'ignored'] + return ['email', 'name', 'sync_pending', 'weak', 'fair', 'medium', 'strong', + 'reused', 'unique', 'securityScore', 'twoFactorChannel', 'node'] + + @staticmethod + def get_error_headers() -> List[str]: + """Get error report headers.""" + return ['vault_owner', 'error_message'] + + +def generate_security_audit_report( + enterprise_data: enterprise_types.IEnterpriseData, + auth: keeper_auth.KeeperAuth, + node_ids: Optional[List[int]] = None, + score_type: str = 'default' +) -> List[SecurityAuditEntry]: + """Convenience function to generate a security audit report.""" + config = SecurityAuditConfig(node_ids=node_ids, score_type=score_type) + return SecurityAuditReportGenerator(enterprise_data, auth, config).generate_report() + From 6f314696aa7e5e9f9cc69001062d3aad492594d5 Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Fri, 9 Jan 2026 11:25:58 +0530 Subject: [PATCH 21/37] share-report and share-records-report command added --- .../sdk_examples/share_report/share_report.py | 231 ++++++ .../shared_records_report.py | 151 ++++ .../src/keepercli/commands/share_report.py | 281 +++++++ .../commands/shared_records_report.py | 114 +++ .../src/keepercli/register_commands.py | 8 +- .../src/keepersdk/vault/share_report.py | 685 ++++++++++++++++++ .../keepersdk/vault/shared_records_report.py | 455 ++++++++++++ 7 files changed, 1923 insertions(+), 2 deletions(-) create mode 100644 examples/sdk_examples/share_report/share_report.py create mode 100644 examples/sdk_examples/shared_records_report/shared_records_report.py create mode 100644 keepercli-package/src/keepercli/commands/share_report.py create mode 100644 keepercli-package/src/keepercli/commands/shared_records_report.py create mode 100644 keepersdk-package/src/keepersdk/vault/share_report.py create mode 100644 keepersdk-package/src/keepersdk/vault/shared_records_report.py diff --git a/examples/sdk_examples/share_report/share_report.py b/examples/sdk_examples/share_report/share_report.py new file mode 100644 index 00000000..f664d866 --- /dev/null +++ b/examples/sdk_examples/share_report/share_report.py @@ -0,0 +1,231 @@ +"""Share Report SDK Example - Demonstrates generating share reports for vault records and folders.""" + +import getpass +import sqlite3 +import traceback +from typing import Optional, List, Tuple + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import share_report, vault_online +from keepersdk.vault.sqlite_storage import SqliteVaultStorage +from keepersdk.errors import KeeperApiError +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +TABLE_WIDTH = 120 +RECORD_COL_WIDTHS = (36, 30, 25, 30) +FOLDER_COL_WIDTHS = (25, 30, 25, 25, 30) +SUMMARY_COL_WIDTHS = (40, 15, 20) + + +def login() -> Optional[keeper_auth.KeeperAuth]: + """Handle the login process including server selection and authentication.""" + config = configuration.JsonConfigurationStorage() + server = _get_server(config) + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + username = config.get().last_login or input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = _complete_login_steps(login_auth_context) + + if logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + return None + + +def _get_server(config: configuration.JsonConfigurationStorage) -> str: + """Get server from config or prompt user.""" + if config.get().last_server: + return config.get().last_server + + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + return server + + +def _complete_login_steps(login_auth_context: login_auth.LoginAuth) -> bool: + """Complete all login steps, returns True if used persistent login.""" + logged_in_with_persistent = True + + while not login_auth_context.login_step.is_final(): + step = login_auth_context.login_step + + if isinstance(step, login_auth.LoginStepDeviceApproval): + step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Approve this device and press Enter to continue.") + input() + elif isinstance(step, login_auth.LoginStepPassword): + step.verify_password(getpass.getpass('Enter password: ')) + elif isinstance(step, login_auth.LoginStepTwoFactor): + channel = step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step: {type(step).__name__}") + + logged_in_with_persistent = False + + return logged_in_with_persistent + + +def format_row(values: List, widths: Tuple) -> str: + """Format a row of values according to column widths.""" + return ' '.join( + f"{str(val if val is not None else '')[:w-1]:<{w}}" + for val, w in zip(values, widths) + ) + + +def print_table(title: str, headers: List[str], rows: List[List], widths: Tuple, empty_msg: str) -> None: + """Print a formatted table with headers and rows.""" + print(f"\n{'=' * TABLE_WIDTH}") + print(title) + print('=' * TABLE_WIDTH) + + if not rows: + print(empty_msg) + return + + print(format_row(headers, widths)) + print('-' * TABLE_WIDTH) + for row in rows: + print(format_row(row, widths)) + print('=' * TABLE_WIDTH) + print(f"\nTotal: {len(rows)}") + + +def print_records_report(entries) -> None: + """Print the share report for records.""" + rows = [ + [e.record_uid, e.record_title or '', e.record_owner or '', e.shared_with_count] + for e in entries + ] + print_table( + "SHARED RECORDS REPORT", + ['Record UID', 'Title', 'Owner', 'Shared With Count'], + rows, RECORD_COL_WIDTHS, + "No shared records found." + ) + + +def print_folders_report(entries) -> None: + """Print the share report for shared folders.""" + rows = [ + [e.folder_uid or '', e.folder_name or '', e.shared_to or '', e.permissions or '', e.folder_path or ''] + for e in entries + ] + print_table( + "SHARED FOLDERS REPORT", + ['Folder UID', 'Folder Name', 'Shared To', 'Permissions', 'Path'], + rows, FOLDER_COL_WIDTHS, + "No shared folders found." + ) + + +def print_summary_report(entries) -> None: + """Print the summary report showing shares by target.""" + rows = [ + [e.shared_to or '', e.record_count or '-', e.shared_folder_count or '-'] + for e in entries + ] + print_table( + "SHARE SUMMARY REPORT", + ['Shared To', 'Records', 'Shared Folders'], + rows, SUMMARY_COL_WIDTHS, + "No shares found." + ) + + +def generate_share_reports(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + """Generate and display share reports for the vault.""" + vault = None + try: + conn = sqlite3.Connection('file::memory:', uri=True) + vault_owner = bytes(keeper_auth_context.auth_context.username, 'utf-8') + vault = vault_online.VaultOnline(keeper_auth_context, SqliteVaultStorage(lambda: conn, vault_owner)) + + print("\nSyncing vault data...") + vault.sync_down() + print(f"Vault synced: {vault.vault_data.record_count} records, " + f"{vault.vault_data.shared_folder_count} shared folders") + + _generate_all_reports(vault) + + except KeeperApiError as e: + print(f"\nAPI Error: {e}") + except Exception as e: + print(f"\nError generating share reports: {e}") + traceback.print_exc() + finally: + if vault: + vault.close() + keeper_auth_context.close() + + +def _create_report_generator( + vault: vault_online.VaultOnline, + *, + show_ownership: bool = False, + folders_only: bool = False +) -> share_report.ShareReportGenerator: + """Create a ShareReportGenerator with the specified configuration. + + Args: + vault: The VaultOnline instance + show_ownership: Include ownership information in the report + folders_only: Generate report for shared folders only + + Returns: + Configured ShareReportGenerator instance + """ + return share_report.ShareReportGenerator( + vault=vault, + config=share_report.ShareReportConfig( + show_ownership=show_ownership, + folders_only=folders_only, + ), + ) + + +def _generate_all_reports(vault: vault_online.VaultOnline) -> None: + """Generate all three report types.""" + print("\nGenerating shared records report...") + records_generator = _create_report_generator(vault, show_ownership=True) + print_records_report(records_generator.generate_records_report()) + + print("\nGenerating shared folders report...") + folders_generator = _create_report_generator(vault, folders_only=True) + print_folders_report(folders_generator.generate_shared_folders_report()) + + print("\nGenerating share summary report...") + summary_generator = _create_report_generator(vault) + print_summary_report(summary_generator.generate_summary_report()) + + +def main() -> None: + """Main entry point for the share report script.""" + print("=" * 60) + print("Keeper Vault Share Report Generator") + print("=" * 60) + print("\nThis tool generates share reports for records and folders in your Keeper vault.\n") + + keeper_auth_context = login() + if keeper_auth_context: + generate_share_reports(keeper_auth_context) + else: + print("Login failed. Unable to generate share reports.") + + +if __name__ == "__main__": + main() + diff --git a/examples/sdk_examples/shared_records_report/shared_records_report.py b/examples/sdk_examples/shared_records_report/shared_records_report.py new file mode 100644 index 00000000..b78951e4 --- /dev/null +++ b/examples/sdk_examples/shared_records_report/shared_records_report.py @@ -0,0 +1,151 @@ +"""Shared Records Report SDK Example - Generates a report of shared records. + +Usage: + python shared_records_report.py +""" + +import getpass +import sqlite3 +import traceback +from typing import Optional, List + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.vault import shared_records_report, vault_online +from keepersdk.vault.sqlite_storage import SqliteVaultStorage +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage +from keepersdk.errors import KeeperApiError +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + + +def login() -> Optional[keeper_auth.KeeperAuth]: + """Handle login with persistent session support.""" + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + + keeper_endpoint = endpoint.KeeperEndpoint(config, config.get().last_server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + username = config.get().last_login or input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + while not login_auth_context.login_step.is_final(): + step = login_auth_context.login_step + if isinstance(step, login_auth.LoginStepDeviceApproval): + step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Approve and press Enter.") + input() + elif isinstance(step, login_auth.LoginStepPassword): + step.verify_password(getpass.getpass('Enter password: ')) + elif isinstance(step, login_auth.LoginStepTwoFactor): + channel = step.get_channels()[0] + step.send_code(channel.channel_uid, getpass.getpass(f'Enter 2FA code: ')) + else: + raise NotImplementedError(f"Unsupported: {type(step).__name__}") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + return None + + +def print_report(entries: List[shared_records_report.SharedRecordReportEntry]) -> None: + """Print the shared records report in table format.""" + if not entries: + print("No shared records found.") + return + + # Headers and column widths + headers = ['#', 'Record UID', 'Title', 'Share Type', 'Shared To', 'Permissions', 'Folder Path'] + widths = [4, 24, 28, 20, 28, 18, 30] + + # Print header + header_row = ' '.join(f"{h:<{w}}" for h, w in zip(headers, widths)) + print(header_row) + print('-' * len(header_row)) + + # Print rows + for i, e in enumerate(entries, 1): + row = [ + str(i), + e.record_uid[:22] + '..' if len(e.record_uid) > 24 else e.record_uid, + e.title[:26] + '..' if len(e.title) > 28 else e.title, + e.share_type, + e.shared_to[:26] + '..' if len(e.shared_to) > 28 else e.shared_to, + e.permissions, + e.folder_path.replace('\n', ' | ')[:28] + '..' if len(e.folder_path) > 30 else e.folder_path.replace('\n', ' | ') + ] + print(' '.join(f"{v:<{w}}" for v, w in zip(row, widths))) + + +def main() -> None: + """Main entry point.""" + vault = None + enterprise = None + keeper_auth_context = None + + try: + keeper_auth_context = login() + if not keeper_auth_context: + print("Login failed.") + return + + # Initialize vault + vault_conn = sqlite3.Connection('file::memory:', uri=True) + vault_owner = bytes(keeper_auth_context.auth_context.username, 'utf-8') + vault = vault_online.VaultOnline( + keeper_auth_context, + SqliteVaultStorage(lambda: vault_conn, vault_owner) + ) + vault.sync_down() + + # Initialize enterprise data if admin (for team expansion) + enterprise_data = None + if keeper_auth_context.auth_context.is_enterprise_admin: + enterprise_conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + enterprise_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage( + lambda: enterprise_conn, enterprise_id + ) + enterprise = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + enterprise_data = enterprise.enterprise_data + else: + print("Admin access is not present, use an admin account for full team expansion features.") + + # Generate report (default: owned records only) + config = shared_records_report.SharedRecordsReportConfig( + show_team_users=False, + all_records=False + ) + + generator = shared_records_report.SharedRecordsReportGenerator( + vault=vault, + enterprise=enterprise_data, + auth=keeper_auth_context, + config=config + ) + + entries = generator.generate_report() + print_report(entries) + + except KeeperApiError as e: + print(f"API Error: {e}") + except Exception as e: + print(f"Error: {e}") + traceback.print_exc() + finally: + if enterprise: + enterprise.close() + if vault: + vault.close() + if keeper_auth_context: + keeper_auth_context.close() + + +if __name__ == "__main__": + main() diff --git a/keepercli-package/src/keepercli/commands/share_report.py b/keepercli-package/src/keepercli/commands/share_report.py new file mode 100644 index 00000000..9aafc6e1 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/share_report.py @@ -0,0 +1,281 @@ +"""Share Report command for Keeper CLI.""" + +import argparse +from typing import Any, Optional + +from keepersdk.vault import share_report + +from . import base +from ..helpers import report_utils +from ..params import KeeperParams +from .. import api + + +class ShareReportCommand(base.ArgparseCommand): + """Command to generate share reports for records and shared folders.""" + + def __init__(self): + parser = argparse.ArgumentParser( + prog='share-report', + description='Generates a report of shared records', + parents=[base.report_output_parser] + ) + self.add_arguments_to_parser(parser) + super().__init__(parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser) -> None: + """Add command arguments to the parser. + + Args: + parser: The argument parser to add arguments to + """ + parser.add_argument( + '-r', '--record', + dest='record', + action='append', + help='record name or UID (can be specified multiple times)' + ) + parser.add_argument( + '-e', '--email', + dest='user', + action='append', + help='user email or team name to filter by (can be specified multiple times)' + ) + parser.add_argument( + '-o', '--owner', + dest='owner', + action='store_true', + help='display record ownership information' + ) + parser.add_argument( + '--share-date', + dest='share_date', + action='store_true', + help='include date when the record was shared. This data is available only to ' + 'users with permissions to execute reports for their company. ' + 'Example: share-report -v -o --share-date --format table' + ) + parser.add_argument( + '-sf', '--shared-folders', + dest='shared_folders', + action='store_true', + help='display shared folder detail information' + ) + parser.add_argument( + '-v', '--verbose', + dest='verbose', + action='store_true', + help='display verbose information with detailed permissions' + ) + parser.add_argument( + '-f', '--folders', + dest='folders', + action='store_true', + default=False, + help='limit report to shared folders (excludes shared records)' + ) + parser.add_argument( + '-tu', '--show-team-users', + action='store_true', + help='show shared-folder team members (to be used with -f flag, ' + 'ignored for non-admin accounts)' + ) + parser.add_argument( + 'container', + nargs='*', + type=str, + action='store', + help='path(s) or UID(s) of container(s) by which to filter records' + ) + + def execute(self, context: KeeperParams, **kwargs) -> Any: + """Execute the share-report command.""" + base.require_login(context) + + if kwargs.get('share_date'): + base.require_enterprise_admin(context) + + if context.vault is None: + raise base.CommandError('Vault is not initialized. Login to initialize vault.') + + output_format = kwargs.get('format', 'table') + output_file = kwargs.get('output') + show_team_users = kwargs.get('show_team_users', False) + verbose = kwargs.get('verbose', False) or show_team_users + + config = share_report.ShareReportConfig( + record_filter=kwargs.get('record'), + user_filter=kwargs.get('user'), + container_filter=kwargs.get('container') or None, + show_ownership=kwargs.get('owner', False), + show_share_date=kwargs.get('share_date', False), + folders_only=kwargs.get('folders', False), + verbose=verbose, + show_team_users=show_team_users + ) + + enterprise = context.enterprise_data + generator = share_report.ShareReportGenerator( + vault=context.vault, + enterprise=enterprise, + auth=context.auth, + config=config + ) + + if config.folders_only: + return self._generate_folders_report(generator, output_format, output_file) + if config.show_ownership: + return self._generate_ownership_report(generator, output_format, output_file, verbose) + if config.record_filter: + return self._generate_record_detail_report(generator, config) + if config.user_filter: + return self._generate_user_shares_report(generator, config, output_format, output_file) + return self._generate_summary_report(generator, output_format, output_file) + + def _generate_folders_report( + self, + generator: share_report.ShareReportGenerator, + output_format: str, + output_file: Optional[str] + ) -> Optional[str]: + """Generate shared folders report.""" + entries = generator.generate_shared_folders_report() + headers = share_report.ShareReportGenerator.get_headers(folders_only=True) + table = [[e.folder_uid, e.folder_name, e.shared_to, e.permissions, e.folder_path] + for e in entries] + + return report_utils.dump_report_data( + table, + self._format_headers(headers, output_format), + fmt=output_format, + filename=output_file, + title='Shared folders' + ) + + def _generate_ownership_report( + self, + generator: share_report.ShareReportGenerator, + output_format: str, + output_file: Optional[str], + verbose: bool + ) -> Optional[str]: + """Generate record ownership report.""" + entries = generator.generate_records_report() + headers = share_report.ShareReportGenerator.get_headers(ownership=True) + table = [ + [e.record_owner, e.record_uid, e.record_title, + e.shared_with if verbose else e.shared_with_count, + '\n'.join(e.folder_paths)] + for e in entries + ] + + return report_utils.dump_report_data( + table, + self._format_headers(headers, output_format, exclude_json=True), + fmt=output_format, + filename=output_file, + sort_by=0, + row_number=True + ) + + def _generate_record_detail_report( + self, + generator: share_report.ShareReportGenerator, + config: share_report.ShareReportConfig + ) -> None: + """Generate detailed report for specific records (always verbose).""" + logger = api.get_logger() + + verbose_config = share_report.ShareReportConfig( + record_filter=config.record_filter, + user_filter=config.user_filter, + container_filter=config.container_filter, + show_ownership=config.show_ownership, + show_share_date=config.show_share_date, + folders_only=config.folders_only, + verbose=True, + show_team_users=config.show_team_users + ) + + verbose_generator = share_report.ShareReportGenerator( + vault=generator.vault, + enterprise=generator._enterprise, + auth=generator._auth, + config=verbose_config + ) + + entries = verbose_generator.generate_records_report() + if not entries: + logger.info('No records found matching the criteria.') + return + + for entry in entries: + logger.info('') + logger.info(f'{"Record UID:":>20} {entry.record_uid}') + logger.info(f'{"Title:":>20} {entry.record_title}') + self._log_shared_with(logger, entry.shared_with) + logger.info('') + + def _log_shared_with(self, logger, shared_with: str) -> None: + """Log shared with information.""" + if not shared_with: + logger.info(f'{"Shared with:":>20} Not shared') + return + + for i, line in enumerate(shared_with.split('\n')): + label = 'Shared with:' if i == 0 else '' + logger.info(f'{label:>20} {line}') + + def _generate_user_shares_report( + self, + generator: share_report.ShareReportGenerator, + config: share_report.ShareReportConfig, + output_format: str, + output_file: Optional[str] + ) -> Optional[str]: + """Generate report of shares filtered by user.""" + entries = generator.generate_records_report() + headers = ['username', 'record_owner', 'record_uid', 'record_title'] + table = [ + [user, e.record_owner, e.record_uid, e.record_title] + for e in entries + for user in (config.user_filter or []) + ] + + return report_utils.dump_report_data( + table, + self._format_headers(headers, output_format), + fmt=output_format, + filename=output_file, + group_by=0, + row_number=True + ) + + def _generate_summary_report( + self, + generator: share_report.ShareReportGenerator, + output_format: str, + output_file: Optional[str] + ) -> Optional[str]: + """Generate summary report of shares by target.""" + entries = generator.generate_summary_report() + headers = share_report.ShareReportGenerator.get_headers() + table = [[e.shared_to, e.record_count, e.shared_folder_count] for e in entries] + + return report_utils.dump_report_data( + table, + self._format_headers(headers, output_format), + fmt=output_format, + filename=output_file, + group_by=0, + row_number=True + ) + + @staticmethod + def _format_headers(headers: list, output_format: str, exclude_json: bool = False) -> list: + """Format headers based on output format.""" + if output_format == 'table' or (exclude_json and output_format != 'json'): + return [report_utils.field_to_title(h) for h in headers] + return headers + diff --git a/keepercli-package/src/keepercli/commands/shared_records_report.py b/keepercli-package/src/keepercli/commands/shared_records_report.py new file mode 100644 index 00000000..144543fd --- /dev/null +++ b/keepercli-package/src/keepercli/commands/shared_records_report.py @@ -0,0 +1,114 @@ +"""Shared Records Report command for Keeper CLI.""" + +import argparse +import os +from typing import Any, List, Optional + +from keepersdk.vault import shared_records_report + +from . import base +from ..helpers import report_utils +from ..params import KeeperParams +from .. import api + + +class SharedRecordsReportCommand(base.ArgparseCommand): + """Command to generate shared records reports for a logged-in user.""" + + def __init__(self): + parser = argparse.ArgumentParser( + prog='shared-records-report', + description='Report shared records for a logged-in user', + parents=[base.report_output_parser] + ) + SharedRecordsReportCommand.add_arguments_to_parser(parser) + super().__init__(parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser) -> None: + """Add command-specific arguments to the parser.""" + parser.add_argument( + '-tu', '--show-team-users', + dest='show_team_users', + action='store_true', + help='show members of team for records shared via share team folders.' + ) + parser.add_argument( + '--all-records', + dest='all_records', + action='store_true', + help='report on all records in the vault. only owned records are included if this argument is omitted.' + ) + parser.add_argument( + 'folder', + type=str, + nargs='*', + help='Optional (w/ multiple values allowed). Path or UID of folder containing the records to be shown' + ) + + def execute(self, context: KeeperParams, **kwargs) -> Any: + """Execute the shared-records-report command.""" + base.require_login(context) + base.require_enterprise_admin(context) + + if context.vault is None: + raise base.CommandError('Vault not initialized, login to initialize vault.') + + logger = api.get_logger() + output_format = kwargs.get('format', 'table') + output_file = kwargs.get('output') + show_team_users = kwargs.get('show_team_users', False) + all_records = kwargs.get('all_records', False) + folder_filter = kwargs.get('folder') or None + + # Log folder filter if provided + if folder_filter: + logger.info(f'Filtering by folder(s): {", ".join(folder_filter)}') + + config = shared_records_report.SharedRecordsReportConfig( + folder_filter=folder_filter, + show_team_users=show_team_users, + all_records=all_records + ) + + enterprise = getattr(context, 'enterprise_data', None) + generator = shared_records_report.SharedRecordsReportGenerator( + vault=context.vault, + enterprise=enterprise, + auth=context.auth, + config=config + ) + + rows: List[List[Any]] = list(generator.generate_report_rows()) + headers = shared_records_report.SharedRecordsReportGenerator.get_headers(all_records=all_records) + + if not rows: + logger.info('No shared records found matching the criteria.') + if output_format == 'json': + return report_utils.dump_report_data([], headers, fmt=output_format, filename=output_file) + return None + + if output_format != 'json': + headers = [report_utils.field_to_title(h) for h in headers] + + # Sort rows by title (index 2 if all_records, else index 1) + sort_index = 2 if all_records else 1 + rows.sort(key=lambda x: (x[sort_index] or '').lower() if len(x) > sort_index else '') + + result = report_utils.dump_report_data( + rows, + headers, + fmt=output_format, + filename=output_file, + row_number=True, + sort_by=(1, 3) if all_records else (0, 2) + ) + + if output_file: + _, ext = os.path.splitext(output_file) + if not ext: + output_file += '.json' if output_format == 'json' else '.csv' + logger.info(f'Report saved to: {os.path.abspath(output_file)}') + + return result + diff --git a/keepercli-package/src/keepercli/register_commands.py b/keepercli-package/src/keepercli/register_commands.py index 07e28906..1463a9e1 100644 --- a/keepercli-package/src/keepercli/register_commands.py +++ b/keepercli-package/src/keepercli/register_commands.py @@ -29,7 +29,8 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS if not scopes or bool(scopes & base.CommandScope.Vault): from .commands import (vault_folder, vault, vault_record, record_edit, importer_commands, breachwatch, record_type, secrets_manager, shares, password_report, trash, record_file_report, - record_handling_commands, register, password_generate, verify_records) + record_handling_commands, register, password_generate, verify_records, + shared_records_report, share_report) commands.register_command('sync-down', vault.SyncDownCommand(), base.CommandScope.Vault, 'd') commands.register_command('cd', vault_folder.FolderCdCommand(), base.CommandScope.Vault) @@ -80,7 +81,9 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS commands.register_command('record-permission', record_handling_commands.RecordPermissionCommand(), base.CommandScope.Vault) commands.register_command('trash', trash.TrashCommand(), base.CommandScope.Vault) commands.register_command('verify-shared-folders', verify_records.VerifySharedFoldersCommand(), base.CommandScope.Vault) - commands.register_command('verify-records', verify_records.VerifyRecordsCommand(), base.CommandScope.Vault) + commands.register_command('verify-records', verify_records.VerifyRecordsCommand(), base.CommandScope.Vault) + commands.register_command('shared-records-report', shared_records_report.SharedRecordsReportCommand(), base.CommandScope.Vault) + commands.register_command('share-report', share_report.ShareReportCommand(), base.CommandScope.Vault) if not scopes or bool(scopes & base.CommandScope.Enterprise): @@ -108,3 +111,4 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS commands.register_command('team-approve', enterprise_team.TeamApproveCommand(), base.CommandScope.Enterprise) commands.register_command('user-report', user_report.UserReportCommand(), base.CommandScope.Enterprise, 'ur') commands.register_command('security-audit-report', security_audit_report.SecurityAuditReportCommand(), base.CommandScope.Enterprise, 'sar') + commands.register_command('pam', keeper_pam.PAMControllerCommand(), base.CommandScope.Enterprise) diff --git a/keepersdk-package/src/keepersdk/vault/share_report.py b/keepersdk-package/src/keepersdk/vault/share_report.py new file mode 100644 index 00000000..66067013 --- /dev/null +++ b/keepersdk-package/src/keepersdk/vault/share_report.py @@ -0,0 +1,685 @@ +"""Share report functionality for Keeper SDK. + +This module provides functionality to generate comprehensive share reports +for records and shared folders in a Keeper vault. + +Usage: + from keepersdk.vault import share_report + + config = share_report.ShareReportConfig( + show_ownership=True, + verbose=True + ) + generator = share_report.ShareReportGenerator(vault, enterprise, auth, config) + entries = generator.generate_records_report() +""" + +import dataclasses +import datetime +from typing import Optional, List, Dict, Any, Iterable, Set, NamedTuple + +from . import vault_online, vault_types, vault_utils +from . import share_management_utils +from ..authentication import keeper_auth +from ..enterprise import enterprise_data as enterprise_data_types + + +@dataclasses.dataclass +class ShareReportEntry: + """Represents a single entry in the share report.""" + record_uid: str + record_title: str + record_owner: str = '' + shared_with: str = '' + shared_with_count: int = 0 + folder_paths: List[str] = dataclasses.field(default_factory=list) + share_date: Optional[str] = None + expiration: Optional[datetime.datetime] = None + + +@dataclasses.dataclass +class SharedFolderReportEntry: + """Represents a shared folder entry in the report.""" + folder_uid: str + folder_name: str + shared_to: str = '' + permissions: str = '' + folder_path: str = '' + + +@dataclasses.dataclass +class ShareSummaryEntry: + """Represents a summary entry showing shares by target.""" + shared_to: str + record_count: Optional[int] = None + shared_folder_count: Optional[int] = None + + +@dataclasses.dataclass +class ShareReportConfig: + """Configuration for share report generation. + + Attributes: + record_filter: List of record UIDs or names to filter by + user_filter: List of user emails or team names to filter by + container_filter: List of container (folder) UIDs to filter by + show_ownership: Include record ownership information + show_share_date: Include share date information (requires enterprise admin) + folders_only: Generate report for shared folders only (excludes records) + verbose: Include detailed permission information + show_team_users: Expand team memberships in the report + """ + record_filter: Optional[List[str]] = None + user_filter: Optional[List[str]] = None + container_filter: Optional[List[str]] = None + show_ownership: bool = False + show_share_date: bool = False + folders_only: bool = False + verbose: bool = False + show_team_users: bool = False + + +@dataclasses.dataclass +class UserPermissionInfo: + """Information about a user's permission on a record.""" + username: str + is_owner: bool = False + is_share_admin: bool = False + can_share: bool = False + can_edit: bool = False + expiration: int = 0 + + +@dataclasses.dataclass +class RecordShareInfo: + """Share information for a record.""" + record_uid: str + record_title: str + folder_paths: List[str] + user_permissions: List[UserPermissionInfo] + shared_folder_uids: List[str] + + +class SharedFolderMaps(NamedTuple): + """Maps for shared folder users and records.""" + user_map: Dict[str, Set[str]] + records_map: Dict[str, Set[str]] + + +class ShareReportGenerator: + """Generates share reports for records and shared folders. + + This class provides methods to generate detailed reports about record + and folder sharing within a Keeper vault. + + Example: + >>> config = ShareReportConfig(show_ownership=True, verbose=True) + >>> generator = ShareReportGenerator(vault, enterprise, auth, config) + >>> for entry in generator.generate_records_report(): + ... print(f"{entry.record_title}: shared with {entry.shared_with}") + """ + + def __init__( + self, + vault: vault_online.VaultOnline, + enterprise: Optional[enterprise_data_types.EnterpriseData] = None, + auth: Optional[keeper_auth.KeeperAuth] = None, + config: Optional[ShareReportConfig] = None + ) -> None: + """Initialize the ShareReportGenerator. + + Args: + vault: The VaultOnline instance providing access to vault data + enterprise: Optional EnterpriseData for team expansion and share date queries + auth: Optional KeeperAuth for API calls (defaults to vault.keeper_auth) + config: Configuration options for report generation + + Raises: + ValueError: If vault is None + """ + if vault is None: + raise ValueError("vault parameter is required") + self._vault = vault + self._enterprise = enterprise + self._auth = auth or vault.keeper_auth + self._config = config or ShareReportConfig() + self._share_info_cache: Optional[Dict[str, RecordShareInfo]] = None + + @property + def config(self) -> ShareReportConfig: + """Get the current report configuration.""" + return self._config + + @property + def vault(self) -> vault_online.VaultOnline: + """Get the vault instance.""" + return self._vault + + @property + def current_username(self) -> str: + """Get the current user's username.""" + return self._auth.auth_context.username + + def generate_shared_folders_report(self) -> List[SharedFolderReportEntry]: + """Generate a report of shared folders and their permissions. + + Returns: + List of SharedFolderReportEntry objects containing folder share information + """ + entries: List[SharedFolderReportEntry] = [] + + for sf_info in self._vault.vault_data.shared_folders(): + sf = self._vault.vault_data.load_shared_folder(sf_info.shared_folder_uid) + if not sf: + continue + + folder_path = vault_utils.get_folder_path(self._vault.vault_data, sf.shared_folder_uid) + + for perm in sf.user_permissions: + permissions = self._format_folder_permissions(perm) + shared_to = perm.name or perm.user_uid + + if perm.user_type == vault_types.SharedFolderUserType.Team: + shared_to = f'(Team) {shared_to}' + + # Expand team members if requested + if self._config.show_team_users and self._enterprise: + team_users = self._get_team_members(perm.user_uid) + for member in team_users: + entries.append(SharedFolderReportEntry( + folder_uid=sf.shared_folder_uid, + folder_name=sf.name, + shared_to=f'(Team User) {member}', + permissions=permissions, + folder_path=folder_path + )) + + entries.append(SharedFolderReportEntry( + folder_uid=sf.shared_folder_uid, + folder_name=sf.name, + shared_to=shared_to, + permissions=permissions, + folder_path=folder_path + )) + + return entries + + def generate_records_report(self) -> List[ShareReportEntry]: + """Generate a report of shared records.""" + if self._config.record_filter: + record_uids = self._resolve_record_uids(self._config.record_filter) + else: + record_uids = {r.record_uid for r in self._vault.vault_data.records()} + + if not record_uids: + return [] + + share_info_map = self._fetch_share_info(list(record_uids)) or {} + entries: List[ShareReportEntry] = [] + processed_uids: Set[str] = set() + + user_filter_lower = {u.lower() for u in self._config.user_filter} if self._config.user_filter else None + + for uid, share_info in share_info_map.items(): + if not self._should_include_record(share_info): + continue + + if user_filter_lower and not self._record_matches_user_filter(share_info, user_filter_lower): + continue + + entries.append(self._build_share_entry(share_info)) + processed_uids.add(uid) + + self._add_shared_folder_records(entries, processed_uids, share_info_map, user_filter_lower) + + return entries + + def _should_include_record(self, share_info: RecordShareInfo) -> bool: + """Check if a record should be included in the report.""" + non_owner_perms = [p for p in share_info.user_permissions if not p.is_owner] + has_owner = any(p.is_owner for p in share_info.user_permissions) + + if self._config.record_filter: + return True + + if not non_owner_perms: + return False + + return has_owner + + def _add_shared_folder_records( + self, + entries: List[ShareReportEntry], + processed_uids: Set[str], + share_info_map: Dict[str, RecordShareInfo], + user_filter_lower: Optional[Set[str]] + ) -> None: + """Add records from shared folders that weren't returned by the share API.""" + should_include = ( + self._config.user_filter or + self._config.show_ownership or + not self._config.record_filter + ) + + if not should_include: + return + + sf_records = ( + self._get_shared_folder_records_for_user(user_filter_lower) + if user_filter_lower + else self._get_all_shared_folder_records() + ) + + for record_uid in sf_records: + if record_uid in processed_uids: + continue + + record_info = self._vault.vault_data.get_record(record_uid) + if not record_info: + continue + + folder_paths = self._get_folder_paths(record_uid) + owner = self._get_owner_from_share_info(share_info_map, record_uid) + + entries.append(ShareReportEntry( + record_uid=record_uid, + record_title=record_info.title, + record_owner=owner, + shared_with='', + shared_with_count=0, + folder_paths=folder_paths + )) + processed_uids.add(record_uid) + + def _get_folder_paths(self, record_uid: str) -> List[str]: + """Get folder paths for a record.""" + paths = [] + for folder in vault_utils.get_folders_for_record(self._vault.vault_data, record_uid): + path = vault_utils.get_folder_path(self._vault.vault_data, folder.folder_uid) + if path: + paths.append(path) + return paths + + def _get_owner_from_share_info(self, share_info_map: Dict[str, RecordShareInfo], record_uid: str) -> str: + """Extract owner username from share info if available.""" + if record_uid not in share_info_map: + return '' + for perm in share_info_map[record_uid].user_permissions: + if perm.is_owner: + return perm.username + return '' + + def _get_all_shared_folder_records(self) -> Set[str]: + """Get all records in all shared folders.""" + return self._get_shared_folder_records_for_user(None) + + def _get_shared_folder_records_for_user(self, user_filter: Optional[Set[str]]) -> Set[str]: + """Get records in shared folders, optionally filtered by user access.""" + result: Set[str] = set() + + for sf_info in self._vault.vault_data.shared_folders(): + if user_filter: + sf = self._vault.vault_data.load_shared_folder(sf_info.shared_folder_uid) + if not sf or not self._user_has_sf_access(sf, user_filter): + continue + + self._collect_folder_records(sf_info.shared_folder_uid, result) + + return result + + def _user_has_sf_access(self, sf: vault_types.SharedFolder, user_filter: Set[str]) -> bool: + """Check if any user in the filter has access to the shared folder.""" + for perm in sf.user_permissions: + target = (perm.name or perm.user_uid or '').lower() + if target in user_filter: + return True + return False + + def _collect_folder_records(self, folder_uid: str, result: Set[str]) -> None: + """Collect all records from a folder and its subfolders.""" + folder = self._vault.vault_data.get_folder(folder_uid) + if not folder: + return + + result.update(folder.records) + + def collect(f: vault_types.Folder) -> None: + result.update(f.records) + + vault_utils.traverse_folder_tree(self._vault.vault_data, folder, collect) + + def generate_summary_report(self) -> List[ShareSummaryEntry]: + """Generate a summary report showing share counts by target user.""" + record_shares: Dict[str, Set[str]] = {} + sf_shares: Dict[str, Set[str]] = {} + + sf_maps = self._build_shared_folder_maps() + self._aggregate_shared_folder_access(sf_maps.user_map, sf_maps.records_map, record_shares, sf_shares) + self._aggregate_direct_shares(record_shares) + self._remove_current_user(record_shares, sf_shares) + + return self._build_summary_entries(record_shares, sf_shares) + + def _build_shared_folder_maps(self) -> SharedFolderMaps: + """Build maps of shared folder users and records. + + Returns: + SharedFolderMaps containing user_map and records_map + """ + sf_user_map: Dict[str, Set[str]] = {} + sf_records_map: Dict[str, Set[str]] = {} + + for sf_info in self._vault.vault_data.shared_folders(): + sf = self._vault.vault_data.load_shared_folder(sf_info.shared_folder_uid) + if not sf: + continue + + users_in_sf: Set[str] = set() + for perm in sf.user_permissions: + target = perm.name or perm.user_uid + if target: + users_in_sf.add(target) + sf_user_map[sf_info.shared_folder_uid] = users_in_sf + + folder = self._vault.vault_data.get_folder(sf_info.shared_folder_uid) + if folder and folder.records: + sf_records_map[sf_info.shared_folder_uid] = set(folder.records) + + return SharedFolderMaps(user_map=sf_user_map, records_map=sf_records_map) + + def _aggregate_shared_folder_access( + self, + sf_user_map: Dict[str, Set[str]], + sf_records_map: Dict[str, Set[str]], + record_shares: Dict[str, Set[str]], + sf_shares: Dict[str, Set[str]] + ) -> None: + """Aggregate record and folder counts from shared folder access.""" + for sf_uid, users in sf_user_map.items(): + records_in_sf = sf_records_map.get(sf_uid, set()) + for target in users: + if target == self.current_username: + continue + sf_shares.setdefault(target, set()).add(sf_uid) + for record_uid in records_in_sf: + record_shares.setdefault(target, set()).add(record_uid) + + def _aggregate_direct_shares(self, record_shares: Dict[str, Set[str]]) -> None: + """Aggregate record counts from direct share permissions.""" + all_record_uids = [r.record_uid for r in self._vault.vault_data.records()] + if not all_record_uids: + return + + share_info_map = self._fetch_share_info(all_record_uids) + for uid, share_info in share_info_map.items(): + for perm in share_info.user_permissions: + if perm.username != self.current_username: + record_shares.setdefault(perm.username, set()).add(uid) + + def _remove_current_user( + self, + record_shares: Dict[str, Set[str]], + sf_shares: Dict[str, Set[str]] + ) -> None: + """Remove current user from share counts.""" + record_shares.pop(self.current_username, None) + sf_shares.pop(self.current_username, None) + + def _build_summary_entries( + self, + record_shares: Dict[str, Set[str]], + sf_shares: Dict[str, Set[str]] + ) -> List[ShareSummaryEntry]: + """Build sorted list of summary entries.""" + all_targets = set(record_shares.keys()) | set(sf_shares.keys()) + return [ + ShareSummaryEntry( + shared_to=target, + record_count=len(record_shares.get(target, set())) or None, + shared_folder_count=len(sf_shares.get(target, set())) or None + ) + for target in sorted(all_targets) + ] + + def generate_report_rows(self) -> Iterable[List[Any]]: + """Generate report rows suitable for tabular output. + + Yields: + Lists of values representing report rows + """ + if self._config.folders_only: + for entry in self.generate_shared_folders_report(): + yield [entry.folder_uid, entry.folder_name, entry.shared_to, + entry.permissions, entry.folder_path] + elif self._config.show_ownership: + for entry in self.generate_records_report(): + shared_info = entry.shared_with if self._config.verbose else entry.shared_with_count + yield [entry.record_owner, entry.record_uid, entry.record_title, + shared_info, '\n'.join(entry.folder_paths)] + else: + for entry in self.generate_summary_report(): + yield [entry.shared_to, entry.record_count, entry.shared_folder_count] + + @staticmethod + def get_headers(folders_only: bool = False, ownership: bool = False) -> List[str]: + """Get report headers based on configuration. + + Args: + folders_only: True if generating shared folders report + ownership: True if generating ownership report + + Returns: + List of header column names + """ + if folders_only: + return ['folder_uid', 'folder_name', 'shared_to', 'permissions', 'folder_path'] + if ownership: + return ['record_owner', 'record_uid', 'record_title', 'shared_with', 'folder_path'] + return ['shared_to', 'records', 'shared_folders'] + + def _resolve_record_uids(self, record_refs: List[str]) -> Set[str]: + """Resolve record names or UIDs to actual UIDs.""" + result: Set[str] = set() + vault_data_instance = self._vault.vault_data + + for ref in record_refs: + record = vault_data_instance.get_record(ref) + if record: + result.add(ref) + continue + + for record_info in vault_data_instance.records(): + if record_info.title.lower() == ref.lower(): + result.add(record_info.record_uid) + break + + return result + + def _fetch_share_info(self, record_uids: List[str]) -> Dict[str, RecordShareInfo]: + """Fetch share information for records using the API.""" + if not record_uids: + return {} + + result: Dict[str, RecordShareInfo] = {} + + try: + shares_data = share_management_utils.get_record_shares( + self._vault, record_uids, is_share_admin=False + ) + + if not shares_data: + return result + + for share_record in shares_data: + record_uid = share_record.get('record_uid') + if not record_uid: + continue + + record_info = self._vault.vault_data.get_record(record_uid) + shares = share_record.get('shares', {}) + + result[record_uid] = RecordShareInfo( + record_uid=record_uid, + record_title=record_info.title if record_info else record_uid, + folder_paths=self._get_folder_paths(record_uid), + user_permissions=self._parse_user_permissions(shares), + shared_folder_uids=[ + sp.get('shared_folder_uid') + for sp in shares.get('shared_folder_permissions', []) + if sp.get('shared_folder_uid') + ] + ) + except Exception: + pass + + return result + + def _parse_user_permissions(self, shares: Dict) -> List[UserPermissionInfo]: + """Parse user permissions from share data.""" + permissions = [] + for up in shares.get('user_permissions', []): + exp = up.get('expiration', 0) + if isinstance(exp, str): + try: + exp = int(exp) + except ValueError: + exp = 0 + permissions.append(UserPermissionInfo( + username=up.get('username', ''), + is_owner=up.get('owner', False), + is_share_admin=up.get('share_admin', False), + can_share=up.get('shareable', False), + can_edit=up.get('editable', False), + expiration=exp + )) + return permissions + + def _build_share_entry(self, share_info: RecordShareInfo) -> ShareReportEntry: + """Build a ShareReportEntry from RecordShareInfo.""" + owner = self._get_owner_from_share_info({share_info.record_uid: share_info}, share_info.record_uid) + non_owner_shares = [p for p in share_info.user_permissions if not p.is_owner] + + shared_with = '' + if self._config.verbose: + shared_with = self._format_verbose_permissions(share_info) + + return ShareReportEntry( + record_uid=share_info.record_uid, + record_title=share_info.record_title, + record_owner=owner, + shared_with=shared_with, + shared_with_count=len(non_owner_shares), + folder_paths=share_info.folder_paths + ) + + def _format_verbose_permissions(self, share_info: RecordShareInfo) -> str: + """Format user permissions as newline-separated usernames.""" + lines: List[str] = [] + for perm in share_info.user_permissions: + lines.append(perm.username) + if perm.expiration > 0: + dt = datetime.datetime.fromtimestamp(perm.expiration // 1000) + lines.append(f'\t(expires on {dt})') + return '\n'.join(lines) + + def _format_folder_permissions(self, perm: vault_types.SharedFolderPermission) -> str: + """Format shared folder permissions as human-readable text.""" + if perm.manage_users and perm.manage_records: + return "Can Manage Users & Records" + if perm.manage_records: + return "Can Manage Records" + if perm.manage_users: + return "Can Manage Users" + return "No User Permissions" + + def _record_matches_user_filter(self, share_info: RecordShareInfo, user_filter: Set[str]) -> bool: + """Check if user has access via direct shares or shared folder membership.""" + for perm in share_info.user_permissions: + if perm.username.lower() in user_filter: + return True + + for folder in vault_utils.get_folders_for_record(self._vault.vault_data, share_info.record_uid): + sf_uid = self._get_shared_folder_uid(folder) + if sf_uid: + sf = self._vault.vault_data.load_shared_folder(sf_uid) + if sf and self._user_has_sf_access(sf, user_filter): + return True + + return False + + def _get_shared_folder_uid(self, folder: vault_types.Folder) -> Optional[str]: + """Get the shared folder UID for a folder.""" + if folder.folder_type == 'shared_folder': + return folder.folder_uid + if folder.folder_type == 'shared_folder_folder' and folder.folder_scope_uid: + return folder.folder_scope_uid + return None + + def _get_team_members(self, team_uid: str) -> List[str]: + """Get team member usernames.""" + if not self._enterprise: + return [] + + members: List[str] = [] + try: + for team_user in self._enterprise.team_users.get_all_links(): + if team_user.team_uid == team_uid: + user = self._enterprise.users.get_entity(team_user.enterprise_user_id) + if user: + members.append(user.username) + except Exception: + pass + + return members + + +def generate_share_report( + vault: vault_online.VaultOnline, + enterprise: Optional[enterprise_data_types.EnterpriseData] = None, + record_filter: Optional[List[str]] = None, + user_filter: Optional[List[str]] = None, + verbose: bool = False +) -> List[ShareReportEntry]: + """Generate a share report for records. + + Args: + vault: The VaultOnline instance (required) + enterprise: Optional EnterpriseData for team expansion + record_filter: Optional list of record UIDs or names to filter by + user_filter: Optional list of user emails to filter by + verbose: Include detailed permission information + + Returns: + List of ShareReportEntry objects + + Raises: + ValueError: If vault is None + """ + config = ShareReportConfig( + record_filter=record_filter, + user_filter=user_filter, + verbose=verbose + ) + return ShareReportGenerator(vault, enterprise, config=config).generate_records_report() + + +def generate_shared_folders_report( + vault: vault_online.VaultOnline, + enterprise: Optional[enterprise_data_types.EnterpriseData] = None, + show_team_users: bool = False +) -> List[SharedFolderReportEntry]: + """Generate a report of shared folders and their permissions. + + Args: + vault: The VaultOnline instance (required) + enterprise: Optional EnterpriseData for team expansion + show_team_users: Expand team memberships in the report + + Returns: + List of SharedFolderReportEntry objects + + Raises: + ValueError: If vault is None + """ + config = ShareReportConfig(folders_only=True, show_team_users=show_team_users) + return ShareReportGenerator(vault, enterprise, config=config).generate_shared_folders_report() diff --git a/keepersdk-package/src/keepersdk/vault/shared_records_report.py b/keepersdk-package/src/keepersdk/vault/shared_records_report.py new file mode 100644 index 00000000..965f722a --- /dev/null +++ b/keepersdk-package/src/keepersdk/vault/shared_records_report.py @@ -0,0 +1,455 @@ +"""Shared records report functionality for Keeper SDK. + +This module provides functionality to generate comprehensive reports of shared records +for a logged-in user in a Keeper vault. + +Usage: + from keepersdk.vault import shared_records_report + + config = shared_records_report.SharedRecordsReportConfig( + show_team_users=True, + all_records=True + ) + generator = shared_records_report.SharedRecordsReportGenerator(vault, enterprise, auth, config) + entries = generator.generate_report() +""" + +import dataclasses +from typing import Optional, List, Dict, Any, Iterable, Set + +from . import vault_online, vault_types, vault_utils, vault_record +from . import share_management_utils +from ..authentication import keeper_auth +from ..enterprise import enterprise_data as enterprise_data_types + + +# Share types +SHARE_TYPE_DIRECT = 'Direct Share' +SHARE_TYPE_SHARED_FOLDER = 'Share Folder' +SHARE_TYPE_TEAM_FOLDER = 'Share Team Folder' + +# Record versions to include +OWNED_RECORD_VERSIONS = (2, 3) +ALL_RECORD_VERSIONS = (0, 1, 2, 3, 5, 6) + + +@dataclasses.dataclass +class SharedRecordReportEntry: + """Represents a single entry in the shared records report.""" + record_uid: str + title: str + owner: str = '' + share_type: str = '' + shared_to: str = '' + permissions: str = '' + folder_path: str = '' + + +@dataclasses.dataclass +class SharedRecordsReportConfig: + """Configuration for shared records report generation. + + Attributes: + folder_filter: List of folder UIDs or paths to filter by + show_team_users: Expand team memberships to show individual team members + all_records: Include all records in vault (not just owned records) + """ + folder_filter: Optional[List[str]] = None + show_team_users: bool = False + all_records: bool = False + + +class SharedRecordsReportGenerator: + """Generates shared records reports for a logged-in user. + + This class provides methods to generate detailed reports about shared records + within a Keeper vault, showing who has access to records and through what mechanism. + + Example: + >>> config = SharedRecordsReportConfig(show_team_users=True) + >>> generator = SharedRecordsReportGenerator(vault, enterprise, auth, config) + >>> for entry in generator.generate_report(): + ... print(f"{entry.title}: shared with {entry.shared_to} via {entry.share_type}") + """ + + def __init__( + self, + vault: vault_online.VaultOnline, + enterprise: Optional[enterprise_data_types.EnterpriseData] = None, + auth: Optional[keeper_auth.KeeperAuth] = None, + config: Optional[SharedRecordsReportConfig] = None + ) -> None: + """Initialize the SharedRecordsReportGenerator. + + Args: + vault: The VaultOnline instance providing access to vault data + enterprise: Optional EnterpriseData for team expansion + auth: Optional KeeperAuth for API calls (defaults to vault.keeper_auth) + config: Configuration options for report generation + """ + self._vault = vault + self._enterprise = enterprise + self._auth = auth or vault.keeper_auth + self._config = config or SharedRecordsReportConfig() + self._team_membership: Optional[Dict[str, List[str]]] = None + self._shares_cache: Dict[str, Dict[str, Any]] = {} + + @property + def config(self) -> SharedRecordsReportConfig: + """Get the current report configuration.""" + return self._config + + @property + def vault(self) -> vault_online.VaultOnline: + """Get the vault instance.""" + return self._vault + + @property + def current_username(self) -> str: + """Get the current user's username.""" + return self._auth.auth_context.username + + @staticmethod + def permissions_text( + *, + can_share: Optional[bool] = None, + can_edit: Optional[bool] = None, + can_view: bool = True + ) -> str: + """Generate human-readable permissions text. + + Args: + can_share: Whether the user can share the record + can_edit: Whether the user can edit the record + can_view: Whether the user can view the record (default True) + + Returns: + Human-readable permission string + """ + if not can_edit and not can_share: + return 'Read Only' if can_view else 'Launch Only' + else: + privs = [can_share and 'Share', can_edit and 'Edit'] + return f'Can {" & ".join([p for p in privs if p])}' + + def generate_report(self) -> List[SharedRecordReportEntry]: + """Generate the shared records report. + + Returns: + List of SharedRecordReportEntry objects containing share information + """ + records = self._get_records_to_report() + if not records: + return [] + + # Fetch share information for all records + record_uids = list(records.keys()) + self._fetch_and_cache_shares(record_uids) + + # Build team membership cache if needed + if self._config.show_team_users: + self._build_team_membership_cache() + + entries: List[SharedRecordReportEntry] = [] + + for record_uid, record_info in records.items(): + record_entries = self._process_record_shares(record_uid, record_info) + entries.extend(record_entries) + + return entries + + def _fetch_and_cache_shares(self, record_uids: List[str]) -> None: + """Fetch and cache share information for records.""" + shares_data = share_management_utils.get_record_shares(self._vault, record_uids) + if shares_data: + for share_record in shares_data: + record_uid = share_record.get('record_uid') + if record_uid: + self._shares_cache[record_uid] = share_record + + def _get_records_to_report(self) -> Dict[str, Any]: + """Get records to include in the report based on configuration.""" + records: Dict[str, Any] = {} + versions = ALL_RECORD_VERSIONS if self._config.all_records else OWNED_RECORD_VERSIONS + filter_folders: Optional[Set[str]] = None + + if self._config.folder_filter: + filter_folders = set() + for folder_name in self._config.folder_filter: + folder_uids = share_management_utils.get_folder_uids(self._vault, folder_name) + if folder_uids: + for uid in folder_uids: + self._traverse_folder_for_records(uid, filter_folders, records, versions) + else: + # Get all shared records matching criteria + for record_info in self._vault.vault_data.records(): + if not (record_info.flags & vault_record.RecordFlags.IsShared): + continue + if record_info.version not in versions: + continue + if not self._config.all_records: + # Check if user owns the record + if not (record_info.flags & vault_record.RecordFlags.IsOwner): + continue + records[record_info.record_uid] = record_info + + return records + + def _traverse_folder_for_records( + self, + folder_uid: str, + filter_folders: Set[str], + records: Dict[str, Any], + versions: tuple + ) -> None: + """Traverse a folder tree and collect records.""" + folder = self._vault.vault_data.get_folder(folder_uid) + if not folder: + return + + def on_folder(f: vault_types.Folder) -> None: + filter_folders.add(f.folder_uid) + for record_uid in f.records: + if record_uid in records: + continue + record_info = self._vault.vault_data.get_record(record_uid) + if not record_info: + continue + if not (record_info.flags & vault_record.RecordFlags.IsShared): + continue + if record_info.version not in versions: + continue + if not self._config.all_records: + if not (record_info.flags & vault_record.RecordFlags.IsOwner): + continue + records[record_uid] = record_info + + vault_utils.traverse_folder_tree(self._vault.vault_data, folder, on_folder) + + def _build_team_membership_cache(self) -> None: + """Build cache of team memberships for team expansion.""" + self._team_membership = {} + + if self._enterprise is None: + return + + # Build user lookup + user_lookup: Dict[int, str] = {} + for user in self._enterprise.users.get_all_entities(): + if user.status == 'active': + user_lookup[user.enterprise_user_id] = user.username + + # Build team membership + for team_user in self._enterprise.team_users.get_all_links(): + team_uid = team_user.team_uid + enterprise_user_id = team_user.enterprise_user_id + if enterprise_user_id in user_lookup: + if team_uid not in self._team_membership: + self._team_membership[team_uid] = [] + self._team_membership[team_uid].append(user_lookup[enterprise_user_id]) + + def _process_record_shares(self, record_uid: str, record_info: Any) -> List[SharedRecordReportEntry]: + """Process shares for a single record.""" + entries: List[SharedRecordReportEntry] = [] + + # Get share info from cache + shares_data = self._get_record_shares_data(record_uid) + if not shares_data: + return entries + + shares = shares_data.get('shares', {}) + owner = self._get_owner_from_shares(shares) + folder_path = self._get_folder_path(record_uid) + + # Process user permissions (direct shares) + for up in shares.get('user_permissions', []): + username = up.get('username') + if not username: + continue + if not self._config.all_records and username == self.current_username: + continue + + permission = self.permissions_text( + can_share=up.get('shareable'), + can_edit=up.get('editable') + ) + + entries.append(SharedRecordReportEntry( + record_uid=record_uid, + title=record_info.title, + owner=owner, + share_type=SHARE_TYPE_DIRECT, + shared_to=username, + permissions=permission, + folder_path=folder_path + )) + + # Process shared folder permissions + for sfp in shares.get('shared_folder_permissions', []): + shared_folder_uid = sfp.get('shared_folder_uid') + can_share = sfp.get('reshareable') + can_edit = sfp.get('editable') + base_permission = self.permissions_text(can_share=can_share, can_edit=can_edit) + + sf = self._vault.vault_data.load_shared_folder(shared_folder_uid) + if sf: + sf_folder_path = vault_utils.get_folder_path(self._vault.vault_data, shared_folder_uid) + + # Process users in shared folder + for perm in sf.user_permissions: + if perm.user_type == vault_types.SharedFolderUserType.User: + username = perm.name or perm.user_uid + if not self._config.all_records and username == self.current_username: + continue + + entries.append(SharedRecordReportEntry( + record_uid=record_uid, + title=record_info.title, + owner=owner, + share_type=SHARE_TYPE_SHARED_FOLDER, + shared_to=username, + permissions=base_permission, + folder_path=sf_folder_path + )) + + elif perm.user_type == vault_types.SharedFolderUserType.Team: + team_uid = perm.user_uid + team_name = perm.name or team_uid + + # Calculate team-specific permissions + team_permission = self._get_team_permission(team_uid, can_share, can_edit) + + # Expand team members if requested + if self._team_membership and team_uid in self._team_membership: + for member in self._team_membership[team_uid]: + entries.append(SharedRecordReportEntry( + record_uid=record_uid, + title=record_info.title, + owner=owner, + share_type=SHARE_TYPE_TEAM_FOLDER, + shared_to=f'({team_name}) {member}', + permissions=team_permission, + folder_path=sf_folder_path + )) + else: + entries.append(SharedRecordReportEntry( + record_uid=record_uid, + title=record_info.title, + owner=owner, + share_type=SHARE_TYPE_TEAM_FOLDER, + shared_to=team_name, + permissions=team_permission, + folder_path=sf_folder_path + )) + else: + # Shared folder not accessible + entries.append(SharedRecordReportEntry( + record_uid=record_uid, + title=record_info.title, + owner=owner, + share_type=SHARE_TYPE_SHARED_FOLDER, + shared_to='***', + permissions=base_permission, + folder_path=shared_folder_uid + )) + + return entries + + def _get_record_shares_data(self, record_uid: str) -> Optional[Dict[str, Any]]: + """Get cached share data for a record.""" + return self._shares_cache.get(record_uid) + + def _get_owner_from_shares(self, shares: Dict) -> str: + """Extract owner username from share data.""" + for up in shares.get('user_permissions', []): + if up.get('owner') is True: + return up.get('username', '') + return '' + + def _get_folder_path(self, record_uid: str) -> str: + """Get folder path(s) for a record.""" + paths: List[str] = [] + for folder in vault_utils.get_folders_for_record(self._vault.vault_data, record_uid): + path = vault_utils.get_folder_path(self._vault.vault_data, folder.folder_uid) + if path: + paths.append(path) + return '\n'.join(paths) + + def _get_team_permission( + self, + team_uid: str, + can_share: Optional[bool], + can_edit: Optional[bool] + ) -> str: + """Calculate team-specific permissions considering team restrictions.""" + # Try to get team restrictions from vault cache + team = None + for t_info in self._vault.vault_data.teams(): + if t_info.team_uid == team_uid: + team = self._vault.vault_data.load_team(team_uid) + break + + if team: + return self.permissions_text( + can_share=can_share and not team.restrict_share, + can_edit=can_edit and not team.restrict_edit, + can_view=not team.restrict_view + ) + + return self.permissions_text(can_share=can_share, can_edit=can_edit) + + def generate_report_rows(self) -> Iterable[List[Any]]: + """Generate report rows suitable for tabular output. + + Yields: + Lists of values representing report rows + """ + for entry in self.generate_report(): + if self._config.all_records: + yield [entry.owner, entry.record_uid, entry.title, entry.share_type, + entry.shared_to, entry.permissions, entry.folder_path] + else: + yield [entry.record_uid, entry.title, entry.share_type, + entry.shared_to, entry.permissions, entry.folder_path] + + @staticmethod + def get_headers(all_records: bool = False) -> List[str]: + """Get report headers based on configuration. + + Args: + all_records: True if reporting on all records (includes owner column) + + Returns: + List of header column names + """ + if all_records: + return ['owner', 'record_uid', 'title', 'share_type', 'shared_to', 'permissions', 'folder_path'] + return ['record_uid', 'title', 'share_type', 'shared_to', 'permissions', 'folder_path'] + + +def generate_shared_records_report( + vault: vault_online.VaultOnline, + enterprise: Optional[enterprise_data_types.EnterpriseData] = None, + folder_filter: Optional[List[str]] = None, + show_team_users: bool = False, + all_records: bool = False +) -> List[SharedRecordReportEntry]: + """Convenience function to generate a shared records report. + + Args: + vault: The VaultOnline instance + enterprise: Optional EnterpriseData for team expansion + folder_filter: Optional list of folder UIDs/paths to filter by + show_team_users: Expand team memberships + all_records: Include all records (not just owned) + + Returns: + List of SharedRecordReportEntry objects + """ + config = SharedRecordsReportConfig( + folder_filter=folder_filter, + show_team_users=show_team_users, + all_records=all_records + ) + return SharedRecordsReportGenerator(vault, enterprise, config=config).generate_report() + From c9a960c021b45fc084508477bb7b725f28a3d410 Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Thu, 25 Dec 2025 22:00:55 -0800 Subject: [PATCH 22/37] PEDM: approval extend --- .../src/keepercli/commands/pedm_admin.py | 50 ++++++- .../keepersdk/plugins/pedm/admin_plugin.py | 16 +++ .../src/keepersdk/plugins/pedm/admin_types.py | 6 + .../src/keepersdk/proto/pedm_pb2.py | 136 ++++++++++-------- .../src/keepersdk/proto/pedm_pb2.pyi | 36 +++++ 5 files changed, 180 insertions(+), 64 deletions(-) diff --git a/keepercli-package/src/keepercli/commands/pedm_admin.py b/keepercli-package/src/keepercli/commands/pedm_admin.py index 576609a0..ae193a91 100644 --- a/keepercli-package/src/keepercli/commands/pedm_admin.py +++ b/keepercli-package/src/keepercli/commands/pedm_admin.py @@ -20,7 +20,7 @@ from keepersdk.proto import NotificationCenter_pb2, pedm_pb2 from . import base, pedm_aram from .. import prompt_utils, api -from ..helpers import report_utils +from ..helpers import report_utils, share_utils from ..params import KeeperParams @@ -155,6 +155,20 @@ def resolve_existing_collections( found_collections[c.collection_uid] = c return list(found_collections.values()) + @staticmethod + def resolve_existing_approvals(pedm: admin_plugin.PedmPlugin, approval_names: Any) -> List[admin_types.PedmApproval]: + found_approvals: Dict[str, admin_types.PedmApproval] = {} + p: Optional[admin_types.PedmApproval] + if isinstance(approval_names, list): + for approval_name in approval_names: + a = pedm.approvals.get_entity(approval_name) + if a is None: + raise base.CommandError(f'Approval "{approval_name}" is not found') + found_approvals[a.approval_uid] = a + if len(found_approvals) == 0: + raise base.CommandError('No approvals were found') + return list(found_approvals.values()) + class PedmCommand(base.GroupCommand): def __init__(self): @@ -1688,6 +1702,7 @@ def __init__(self): super().__init__('Manage PEDM approval requests') self.register_command(PedmApprovalListCommand(), 'list', 'l') self.register_command(PedmApprovalStatusCommand(), 'action', 'a') + self.register_command(PedmApprovalExtendCommand(), 'extend') self.default_verb = 'list' @@ -1732,6 +1747,39 @@ def execute(self, context: KeeperParams, **kwargs) -> Any: return report_utils.dump_report_data(table, headers, fmt=fmt, filename=kwargs.get('output')) +class PedmApprovalExtendCommand(base.ArgparseCommand, PedmUtils): + def __init__(self): + parser = argparse.ArgumentParser(prog='extend', description='Extend PEDM approval request expiration time') + expiration = parser.add_mutually_exclusive_group() + expiration.add_argument('--expire-at', dest='expire_at', action='store', help='UTC datetime') + expiration.add_argument('--expire-in', dest='expire_in', action='store', + help='expiration period ([(mi)nutes|(h)ours|(d)ays|(mo)nths|(y)ears])') + parser.add_argument('approval', nargs='+', help='Approval UID or Name') + super().__init__(parser) + + def execute(self, context: KeeperParams, **kwargs) -> None: + plugin = context.pedm_plugin + logger = api.get_logger() + + share_expiration = share_utils.get_share_expiration(kwargs.get('expire_at'), kwargs.get('expire_in')) + if share_expiration > 0: + share_expiration = share_expiration - int(datetime.datetime.now().timestamp()) + if share_expiration < 100: + raise base.CommandError('Expiration time must be at least 1 minute') + expire_in = share_expiration // 60 + approvals = PedmUtils.resolve_existing_approvals(plugin, kwargs.get('approval')) + + to_extend: List[admin_types.PedmUpdateApproval] = [] + for approval in approvals: + to_extend.append(admin_types.PedmUpdateApproval(approval_uid=approval.approval_uid, expire_in=expire_in)) + status_rs = plugin.extend_approvals(to_extend=to_extend) + if status_rs.update: + for status in status_rs.update: + if not status.success: + if isinstance(status, admin_types.EntityStatus): + logger.warning(f'Failed to extend approval "{status.entity_uid}": {status.message}') + + class PedmApprovalStatusCommand(base.ArgparseCommand): def __init__(self): parser = argparse.ArgumentParser(prog='action', description='Modify PEDM approval requests') diff --git a/keepersdk-package/src/keepersdk/plugins/pedm/admin_plugin.py b/keepersdk-package/src/keepersdk/plugins/pedm/admin_plugin.py index 113f6606..60332bc3 100644 --- a/keepersdk-package/src/keepersdk/plugins/pedm/admin_plugin.py +++ b/keepersdk-package/src/keepersdk/plugins/pedm/admin_plugin.py @@ -813,3 +813,19 @@ def modify_approvals(self, *, self._need_sync = True assert status_rs is not None return admin_types.ModifyStatus.from_proto(status_rs) + + def extend_approvals(self, *, to_extend: Optional[List[admin_types.PedmUpdateApproval]] = None) -> admin_types.ModifyStatus: + auth = self.loader.keeper_auth + + rq = pedm_pb2.ModifyApprovalRequest() + if isinstance(to_extend, list): + for update in to_extend: + au = pedm_pb2.ApprovalExtendData() + au.approvalUid = utils.base64_url_decode(update.approval_uid) + au.expireIn = update.expire_in + rq.extendApproval.append(au) + + status_rs = auth.execute_router('pedm/modify_approval', rq, response_type=pedm_pb2.PedmStatusResponse) + self._need_sync = True + assert status_rs is not None + return admin_types.ModifyStatus.from_proto(status_rs) diff --git a/keepersdk-package/src/keepersdk/plugins/pedm/admin_types.py b/keepersdk-package/src/keepersdk/plugins/pedm/admin_types.py index 98e725e0..22b2b499 100644 --- a/keepersdk-package/src/keepersdk/plugins/pedm/admin_types.py +++ b/keepersdk-package/src/keepersdk/plugins/pedm/admin_types.py @@ -90,6 +90,12 @@ def uid(self) -> str: return self.approval_uid +@attrs.define(kw_only=True, frozen=True) +class PedmUpdateApproval: + approval_uid: str + expire_in: int + + @attrs.define(kw_only=True) class AddDeployment: name: str diff --git a/keepersdk-package/src/keepersdk/proto/pedm_pb2.py b/keepersdk-package/src/keepersdk/proto/pedm_pb2.py index 840a83a1..324a4b0c 100644 --- a/keepersdk-package/src/keepersdk/proto/pedm_pb2.py +++ b/keepersdk-package/src/keepersdk/proto/pedm_pb2.py @@ -26,7 +26,7 @@ from . import NotificationCenter_pb2 as NotificationCenter__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\npedm.proto\x12\x04PEDM\x1a\x0c\x66older.proto\x1a\x18NotificationCenter.proto\"O\n\x17PEDMTOTPValidateRequest\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x14\n\x0c\x65nterpriseId\x18\x02 \x01(\x05\x12\x0c\n\x04\x63ode\x18\x03 \x01(\x05\";\n\nPedmStatus\x12\x0b\n\x03key\x18\x01 \x03(\x0c\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x0f\n\x07message\x18\x03 \x01(\t\"\x89\x01\n\x12PedmStatusResponse\x12#\n\taddStatus\x18\x01 \x03(\x0b\x32\x10.PEDM.PedmStatus\x12&\n\x0cupdateStatus\x18\x02 \x03(\x0b\x32\x10.PEDM.PedmStatus\x12&\n\x0cremoveStatus\x18\x03 \x03(\x0b\x32\x10.PEDM.PedmStatus\"4\n\x0e\x44\x65ploymentData\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x14\n\x0c\x65\x63PrivateKey\x18\x02 \x01(\x0c\"\x9a\x01\n\x17\x44\x65ploymentCreateRequest\x12\x15\n\rdeploymentUid\x18\x01 \x01(\x0c\x12\x0e\n\x06\x61\x65sKey\x18\x02 \x01(\x0c\x12\x13\n\x0b\x65\x63PublicKey\x18\x03 \x01(\x0c\x12\x19\n\x11spiffeCertificate\x18\x04 \x01(\x0c\x12\x15\n\rencryptedData\x18\x05 \x01(\x0c\x12\x11\n\tagentData\x18\x06 \x01(\x0c\"\x8d\x01\n\x17\x44\x65ploymentUpdateRequest\x12\x15\n\rdeploymentUid\x18\x01 \x01(\x0c\x12\x15\n\rencryptedData\x18\x02 \x01(\x0c\x12)\n\x08\x64isabled\x18\x03 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x19\n\x11spiffeCertificate\x18\x04 \x01(\x0c\"\xa2\x01\n\x17ModifyDeploymentRequest\x12\x34\n\raddDeployment\x18\x01 \x03(\x0b\x32\x1d.PEDM.DeploymentCreateRequest\x12\x37\n\x10updateDeployment\x18\x02 \x03(\x0b\x32\x1d.PEDM.DeploymentUpdateRequest\x12\x18\n\x10removeDeployment\x18\x03 \x03(\x0c\"a\n\x0b\x41gentUpdate\x12\x10\n\x08\x61gentUid\x18\x01 \x01(\x0c\x12)\n\x08\x64isabled\x18\x02 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x15\n\rdeploymentUid\x18\x03 \x01(\x0c\"Q\n\x12ModifyAgentRequest\x12&\n\x0bupdateAgent\x18\x02 \x03(\x0b\x32\x11.PEDM.AgentUpdate\x12\x13\n\x0bremoveAgent\x18\x03 \x03(\x0c\"p\n\tPolicyAdd\x12\x11\n\tpolicyUid\x18\x01 \x01(\x0c\x12\x11\n\tplainData\x18\x02 \x01(\x0c\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\x12\x14\n\x0c\x65ncryptedKey\x18\x04 \x01(\x0c\x12\x10\n\x08\x64isabled\x18\x05 \x01(\x08\"v\n\x0cPolicyUpdate\x12\x11\n\tpolicyUid\x18\x01 \x01(\x0c\x12\x11\n\tplainData\x18\x02 \x01(\x0c\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\x12)\n\x08\x64isabled\x18\x04 \x01(\x0e\x32\x17.Folder.SetBooleanValue\"s\n\rPolicyRequest\x12\"\n\taddPolicy\x18\x01 \x03(\x0b\x32\x0f.PEDM.PolicyAdd\x12(\n\x0cupdatePolicy\x18\x02 \x03(\x0b\x32\x12.PEDM.PolicyUpdate\x12\x14\n\x0cremovePolicy\x18\x03 \x03(\x0c\"6\n\nPolicyLink\x12\x11\n\tpolicyUid\x18\x01 \x01(\x0c\x12\x15\n\rcollectionUid\x18\x02 \x03(\x0c\"E\n\x1aSetPolicyCollectionRequest\x12\'\n\rsetCollection\x18\x01 \x03(\x0b\x32\x10.PEDM.PolicyLink\"W\n\x0f\x43ollectionValue\x12\x15\n\rcollectionUid\x18\x01 \x01(\x0c\x12\x16\n\x0e\x63ollectionType\x18\x02 \x01(\x05\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\"z\n\x12\x43ollectionLinkData\x12\x15\n\rcollectionUid\x18\x01 \x01(\x0c\x12\x0f\n\x07linkUid\x18\x02 \x01(\x0c\x12*\n\x08linkType\x18\x03 \x01(\x0e\x32\x18.PEDM.CollectionLinkType\x12\x10\n\x08linkData\x18\x04 \x01(\x0c\"\x8c\x01\n\x11\x43ollectionRequest\x12,\n\raddCollection\x18\x01 \x03(\x0b\x32\x15.PEDM.CollectionValue\x12/\n\x10updateCollection\x18\x02 \x03(\x0b\x32\x15.PEDM.CollectionValue\x12\x18\n\x10removeCollection\x18\x03 \x03(\x0c\"{\n\x18SetCollectionLinkRequest\x12/\n\raddCollection\x18\x01 \x03(\x0b\x32\x18.PEDM.CollectionLinkData\x12.\n\x10removeCollection\x18\x02 \x03(\x0b\x32\x14.PEDM.CollectionLink\"F\n\x15\x41pprovalActionRequest\x12\x0f\n\x07\x61pprove\x18\x01 \x03(\x0c\x12\x0c\n\x04\x64\x65ny\x18\x02 \x03(\x0c\x12\x0e\n\x06remove\x18\x03 \x03(\x0c\"\xab\x01\n\x0e\x44\x65ploymentNode\x12\x15\n\rdeploymentUid\x18\x01 \x01(\x0c\x12\x10\n\x08\x64isabled\x18\x02 \x01(\x08\x12\x0e\n\x06\x61\x65sKey\x18\x03 \x01(\x0c\x12\x13\n\x0b\x65\x63PublicKey\x18\x04 \x01(\x0c\x12\x15\n\rencryptedData\x18\x05 \x01(\x0c\x12\x11\n\tagentData\x18\x06 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x07 \x01(\x03\x12\x10\n\x08modified\x18\x08 \x01(\x03\"\xa8\x01\n\tAgentNode\x12\x10\n\x08\x61gentUid\x18\x01 \x01(\x0c\x12\x11\n\tmachineId\x18\x02 \x01(\t\x12\x15\n\rdeploymentUid\x18\x03 \x01(\x0c\x12\x13\n\x0b\x65\x63PublicKey\x18\x04 \x01(\x0c\x12\x10\n\x08\x64isabled\x18\x05 \x01(\x08\x12\x15\n\rencryptedData\x18\x06 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x07 \x01(\x03\x12\x10\n\x08modified\x18\x08 \x01(\x03\"\x94\x01\n\nPolicyNode\x12\x11\n\tpolicyUid\x18\x01 \x01(\x0c\x12\x11\n\tplainData\x18\x02 \x01(\x0c\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\x12\x14\n\x0c\x65ncryptedKey\x18\x04 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x05 \x01(\x03\x12\x10\n\x08modified\x18\x06 \x01(\x03\x12\x10\n\x08\x64isabled\x18\x07 \x01(\x08\"g\n\x0e\x43ollectionNode\x12\x15\n\rcollectionUid\x18\x01 \x01(\x0c\x12\x16\n\x0e\x63ollectionType\x18\x02 \x01(\x05\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x04 \x01(\x03\"d\n\x0e\x43ollectionLink\x12\x15\n\rcollectionUid\x18\x01 \x01(\x0c\x12\x0f\n\x07linkUid\x18\x02 \x01(\x0c\x12*\n\x08linkType\x18\x03 \x01(\x0e\x32\x18.PEDM.CollectionLinkType\"\x9d\x01\n\x12\x41pprovalStatusNode\x12\x13\n\x0b\x61pprovalUid\x18\x01 \x01(\x0c\x12\x46\n\x0e\x61pprovalStatus\x18\x02 \x01(\x0e\x32..NotificationCenter.NotificationApprovalStatus\x12\x18\n\x10\x65nterpriseUserId\x18\x03 \x01(\x03\x12\x10\n\x08modified\x18\n \x01(\x03\"\xb3\x01\n\x0c\x41pprovalNode\x12\x13\n\x0b\x61pprovalUid\x18\x01 \x01(\x0c\x12\x14\n\x0c\x61pprovalType\x18\x02 \x01(\x05\x12\x10\n\x08\x61gentUid\x18\x03 \x01(\x0c\x12\x13\n\x0b\x61\x63\x63ountInfo\x18\x04 \x01(\x0c\x12\x17\n\x0f\x61pplicationInfo\x18\x05 \x01(\x0c\x12\x15\n\rjustification\x18\x06 \x01(\x0c\x12\x10\n\x08\x65xpireIn\x18\x07 \x01(\x05\x12\x0f\n\x07\x63reated\x18\n \x01(\x03\"C\n\rFullSyncToken\x12\x15\n\rstartRevision\x18\x01 \x01(\x03\x12\x0e\n\x06\x65ntity\x18\x02 \x01(\x05\x12\x0b\n\x03key\x18\x03 \x03(\x0c\"$\n\x0cIncSyncToken\x12\x14\n\x0clastRevision\x18\x02 \x01(\x03\"h\n\rPedmSyncToken\x12\'\n\x08\x66ullSync\x18\x02 \x01(\x0b\x32\x13.PEDM.FullSyncTokenH\x00\x12%\n\x07incSync\x18\x03 \x01(\x0b\x32\x12.PEDM.IncSyncTokenH\x00\x42\x07\n\x05token\"/\n\x12GetPedmDataRequest\x12\x19\n\x11\x63ontinuationToken\x18\x01 \x01(\x0c\"\xad\x04\n\x13GetPedmDataResponse\x12\x19\n\x11\x63ontinuationToken\x18\x01 \x01(\x0c\x12\x12\n\nresetCache\x18\x02 \x01(\x08\x12\x0f\n\x07hasMore\x18\x03 \x01(\x08\x12\x1a\n\x12removedDeployments\x18\n \x03(\x0c\x12\x15\n\rremovedAgents\x18\x0b \x03(\x0c\x12\x17\n\x0fremovedPolicies\x18\x0c \x03(\x0c\x12\x19\n\x11removedCollection\x18\r \x03(\x0c\x12\x33\n\x15removedCollectionLink\x18\x0e \x03(\x0b\x32\x14.PEDM.CollectionLink\x12\x18\n\x10removedApprovals\x18\x0f \x03(\x0c\x12)\n\x0b\x64\x65ployments\x18\x14 \x03(\x0b\x32\x14.PEDM.DeploymentNode\x12\x1f\n\x06\x61gents\x18\x15 \x03(\x0b\x32\x0f.PEDM.AgentNode\x12\"\n\x08policies\x18\x16 \x03(\x0b\x32\x10.PEDM.PolicyNode\x12)\n\x0b\x63ollections\x18\x17 \x03(\x0b\x32\x14.PEDM.CollectionNode\x12,\n\x0e\x63ollectionLink\x18\x18 \x03(\x0b\x32\x14.PEDM.CollectionLink\x12%\n\tapprovals\x18\x19 \x03(\x0b\x32\x12.PEDM.ApprovalNode\x12\x30\n\x0e\x61pprovalStatus\x18\x1a \x03(\x0b\x32\x18.PEDM.ApprovalStatusNode\"<\n\x12PolicyAgentRequest\x12\x11\n\tpolicyUid\x18\x01 \x03(\x0c\x12\x13\n\x0bsummaryOnly\x18\x02 \x01(\x08\";\n\x13PolicyAgentResponse\x12\x12\n\nagentCount\x18\x01 \x01(\x05\x12\x10\n\x08\x61gentUid\x18\x02 \x03(\x0c\"]\n\x16\x41uditCollectionRequest\x12\x19\n\x11\x63ontinuationToken\x18\x01 \x01(\x0c\x12\x10\n\x08valueUid\x18\x02 \x03(\x0c\x12\x16\n\x0e\x63ollectionName\x18\x03 \x03(\t\"h\n\x14\x41uditCollectionValue\x12\x16\n\x0e\x63ollectionName\x18\x01 \x01(\t\x12\x10\n\x08valueUid\x18\x02 \x01(\x0c\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x04 \x01(\x03\"q\n\x17\x41uditCollectionResponse\x12*\n\x06values\x18\x01 \x03(\x0b\x32\x1a.PEDM.AuditCollectionValue\x12\x0f\n\x07hasMore\x18\x02 \x01(\x08\x12\x19\n\x11\x63ontinuationToken\x18\x03 \x01(\x0c\"H\n\x18GetCollectionLinkRequest\x12,\n\x0e\x63ollectionLink\x18\x01 \x03(\x0b\x32\x14.PEDM.CollectionLink\"Q\n\x19GetCollectionLinkResponse\x12\x34\n\x12\x63ollectionLinkData\x18\x01 \x03(\x0b\x32\x18.PEDM.CollectionLinkData\"2\n\x1aGetActiveAgentCountRequest\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x03(\x05\">\n\x10\x41\x63tiveAgentCount\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x05\x12\x14\n\x0c\x61\x63tiveAgents\x18\x02 \x01(\x05\";\n\x12\x41\x63tiveAgentFailure\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x05\x12\x0f\n\x07message\x18\x02 \x01(\t\"x\n\x1bGetActiveAgentCountResponse\x12*\n\nagentCount\x18\x01 \x03(\x0b\x32\x16.PEDM.ActiveAgentCount\x12-\n\x0b\x66\x61iledCount\x18\x02 \x03(\x0b\x32\x18.PEDM.ActiveAgentFailure\"\x87\x01\n\x19GetAgentDailyCountRequest\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x03(\x05\x12$\n\tmonthYear\x18\x02 \x01(\x0b\x32\x0f.PEDM.MonthYearH\x00\x12$\n\tdateRange\x18\x03 \x01(\x0b\x32\x0f.PEDM.DateRangeH\x00\x42\x08\n\x06period\"(\n\tMonthYear\x12\r\n\x05month\x18\x01 \x01(\x05\x12\x0c\n\x04year\x18\x02 \x01(\x05\"\'\n\tDateRange\x12\r\n\x05start\x18\x01 \x01(\x03\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x03\"3\n\x0f\x41gentDailyCount\x12\x0c\n\x04\x64\x61te\x18\x01 \x01(\x03\x12\x12\n\nagentCount\x18\x02 \x01(\x05\"V\n\x17\x41gentCountForEnterprise\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x05\x12%\n\x06\x63ounts\x18\x02 \x03(\x0b\x32\x15.PEDM.AgentDailyCount\"U\n\x1aGetAgentDailyCountResponse\x12\x37\n\x10\x65nterpriseCounts\x18\x01 \x03(\x0b\x32\x1d.PEDM.AgentCountForEnterprise*j\n\x12\x43ollectionLinkType\x12\r\n\tCLT_OTHER\x10\x00\x12\r\n\tCLT_AGENT\x10\x01\x12\x0e\n\nCLT_POLICY\x10\x02\x12\x12\n\x0e\x43LT_COLLECTION\x10\x03\x12\x12\n\x0e\x43LT_DEPLOYMENT\x10\x04\x42 \n\x18\x63om.keepersecurity.protoB\x04PEDMb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\npedm.proto\x12\x04PEDM\x1a\x0c\x66older.proto\x1a\x18NotificationCenter.proto\"O\n\x17PEDMTOTPValidateRequest\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x14\n\x0c\x65nterpriseId\x18\x02 \x01(\x05\x12\x0c\n\x04\x63ode\x18\x03 \x01(\x05\";\n\nPedmStatus\x12\x0b\n\x03key\x18\x01 \x03(\x0c\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x0f\n\x07message\x18\x03 \x01(\t\"\x89\x01\n\x12PedmStatusResponse\x12#\n\taddStatus\x18\x01 \x03(\x0b\x32\x10.PEDM.PedmStatus\x12&\n\x0cupdateStatus\x18\x02 \x03(\x0b\x32\x10.PEDM.PedmStatus\x12&\n\x0cremoveStatus\x18\x03 \x03(\x0b\x32\x10.PEDM.PedmStatus\"4\n\x0e\x44\x65ploymentData\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x14\n\x0c\x65\x63PrivateKey\x18\x02 \x01(\x0c\"\x9a\x01\n\x17\x44\x65ploymentCreateRequest\x12\x15\n\rdeploymentUid\x18\x01 \x01(\x0c\x12\x0e\n\x06\x61\x65sKey\x18\x02 \x01(\x0c\x12\x13\n\x0b\x65\x63PublicKey\x18\x03 \x01(\x0c\x12\x19\n\x11spiffeCertificate\x18\x04 \x01(\x0c\x12\x15\n\rencryptedData\x18\x05 \x01(\x0c\x12\x11\n\tagentData\x18\x06 \x01(\x0c\"\x8d\x01\n\x17\x44\x65ploymentUpdateRequest\x12\x15\n\rdeploymentUid\x18\x01 \x01(\x0c\x12\x15\n\rencryptedData\x18\x02 \x01(\x0c\x12)\n\x08\x64isabled\x18\x03 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x19\n\x11spiffeCertificate\x18\x04 \x01(\x0c\"\xa2\x01\n\x17ModifyDeploymentRequest\x12\x34\n\raddDeployment\x18\x01 \x03(\x0b\x32\x1d.PEDM.DeploymentCreateRequest\x12\x37\n\x10updateDeployment\x18\x02 \x03(\x0b\x32\x1d.PEDM.DeploymentUpdateRequest\x12\x18\n\x10removeDeployment\x18\x03 \x03(\x0c\"a\n\x0b\x41gentUpdate\x12\x10\n\x08\x61gentUid\x18\x01 \x01(\x0c\x12)\n\x08\x64isabled\x18\x02 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x15\n\rdeploymentUid\x18\x03 \x01(\x0c\"Q\n\x12ModifyAgentRequest\x12&\n\x0bupdateAgent\x18\x02 \x03(\x0b\x32\x11.PEDM.AgentUpdate\x12\x13\n\x0bremoveAgent\x18\x03 \x03(\x0c\"p\n\tPolicyAdd\x12\x11\n\tpolicyUid\x18\x01 \x01(\x0c\x12\x11\n\tplainData\x18\x02 \x01(\x0c\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\x12\x14\n\x0c\x65ncryptedKey\x18\x04 \x01(\x0c\x12\x10\n\x08\x64isabled\x18\x05 \x01(\x08\"v\n\x0cPolicyUpdate\x12\x11\n\tpolicyUid\x18\x01 \x01(\x0c\x12\x11\n\tplainData\x18\x02 \x01(\x0c\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\x12)\n\x08\x64isabled\x18\x04 \x01(\x0e\x32\x17.Folder.SetBooleanValue\"s\n\rPolicyRequest\x12\"\n\taddPolicy\x18\x01 \x03(\x0b\x32\x0f.PEDM.PolicyAdd\x12(\n\x0cupdatePolicy\x18\x02 \x03(\x0b\x32\x12.PEDM.PolicyUpdate\x12\x14\n\x0cremovePolicy\x18\x03 \x03(\x0c\"6\n\nPolicyLink\x12\x11\n\tpolicyUid\x18\x01 \x01(\x0c\x12\x15\n\rcollectionUid\x18\x02 \x03(\x0c\"E\n\x1aSetPolicyCollectionRequest\x12\'\n\rsetCollection\x18\x01 \x03(\x0b\x32\x10.PEDM.PolicyLink\"W\n\x0f\x43ollectionValue\x12\x15\n\rcollectionUid\x18\x01 \x01(\x0c\x12\x16\n\x0e\x63ollectionType\x18\x02 \x01(\x05\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\"z\n\x12\x43ollectionLinkData\x12\x15\n\rcollectionUid\x18\x01 \x01(\x0c\x12\x0f\n\x07linkUid\x18\x02 \x01(\x0c\x12*\n\x08linkType\x18\x03 \x01(\x0e\x32\x18.PEDM.CollectionLinkType\x12\x10\n\x08linkData\x18\x04 \x01(\x0c\"\x8c\x01\n\x11\x43ollectionRequest\x12,\n\raddCollection\x18\x01 \x03(\x0b\x32\x15.PEDM.CollectionValue\x12/\n\x10updateCollection\x18\x02 \x03(\x0b\x32\x15.PEDM.CollectionValue\x12\x18\n\x10removeCollection\x18\x03 \x03(\x0c\"{\n\x18SetCollectionLinkRequest\x12/\n\raddCollection\x18\x01 \x03(\x0b\x32\x18.PEDM.CollectionLinkData\x12.\n\x10removeCollection\x18\x02 \x03(\x0b\x32\x14.PEDM.CollectionLink\";\n\x12\x41pprovalExtendData\x12\x13\n\x0b\x61pprovalUid\x18\x01 \x01(\x0c\x12\x10\n\x08\x65xpireIn\x18\x02 \x01(\x05\"I\n\x15ModifyApprovalRequest\x12\x30\n\x0e\x65xtendApproval\x18\x01 \x03(\x0b\x32\x18.PEDM.ApprovalExtendData\"F\n\x15\x41pprovalActionRequest\x12\x0f\n\x07\x61pprove\x18\x01 \x03(\x0c\x12\x0c\n\x04\x64\x65ny\x18\x02 \x03(\x0c\x12\x0e\n\x06remove\x18\x03 \x03(\x0c\"\xab\x01\n\x0e\x44\x65ploymentNode\x12\x15\n\rdeploymentUid\x18\x01 \x01(\x0c\x12\x10\n\x08\x64isabled\x18\x02 \x01(\x08\x12\x0e\n\x06\x61\x65sKey\x18\x03 \x01(\x0c\x12\x13\n\x0b\x65\x63PublicKey\x18\x04 \x01(\x0c\x12\x15\n\rencryptedData\x18\x05 \x01(\x0c\x12\x11\n\tagentData\x18\x06 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x07 \x01(\x03\x12\x10\n\x08modified\x18\x08 \x01(\x03\"\xa8\x01\n\tAgentNode\x12\x10\n\x08\x61gentUid\x18\x01 \x01(\x0c\x12\x11\n\tmachineId\x18\x02 \x01(\t\x12\x15\n\rdeploymentUid\x18\x03 \x01(\x0c\x12\x13\n\x0b\x65\x63PublicKey\x18\x04 \x01(\x0c\x12\x10\n\x08\x64isabled\x18\x05 \x01(\x08\x12\x15\n\rencryptedData\x18\x06 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x07 \x01(\x03\x12\x10\n\x08modified\x18\x08 \x01(\x03\"\x94\x01\n\nPolicyNode\x12\x11\n\tpolicyUid\x18\x01 \x01(\x0c\x12\x11\n\tplainData\x18\x02 \x01(\x0c\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\x12\x14\n\x0c\x65ncryptedKey\x18\x04 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x05 \x01(\x03\x12\x10\n\x08modified\x18\x06 \x01(\x03\x12\x10\n\x08\x64isabled\x18\x07 \x01(\x08\"g\n\x0e\x43ollectionNode\x12\x15\n\rcollectionUid\x18\x01 \x01(\x0c\x12\x16\n\x0e\x63ollectionType\x18\x02 \x01(\x05\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x04 \x01(\x03\"d\n\x0e\x43ollectionLink\x12\x15\n\rcollectionUid\x18\x01 \x01(\x0c\x12\x0f\n\x07linkUid\x18\x02 \x01(\x0c\x12*\n\x08linkType\x18\x03 \x01(\x0e\x32\x18.PEDM.CollectionLinkType\"\x9d\x01\n\x12\x41pprovalStatusNode\x12\x13\n\x0b\x61pprovalUid\x18\x01 \x01(\x0c\x12\x46\n\x0e\x61pprovalStatus\x18\x02 \x01(\x0e\x32..NotificationCenter.NotificationApprovalStatus\x12\x18\n\x10\x65nterpriseUserId\x18\x03 \x01(\x03\x12\x10\n\x08modified\x18\n \x01(\x03\"\xb3\x01\n\x0c\x41pprovalNode\x12\x13\n\x0b\x61pprovalUid\x18\x01 \x01(\x0c\x12\x14\n\x0c\x61pprovalType\x18\x02 \x01(\x05\x12\x10\n\x08\x61gentUid\x18\x03 \x01(\x0c\x12\x13\n\x0b\x61\x63\x63ountInfo\x18\x04 \x01(\x0c\x12\x17\n\x0f\x61pplicationInfo\x18\x05 \x01(\x0c\x12\x15\n\rjustification\x18\x06 \x01(\x0c\x12\x10\n\x08\x65xpireIn\x18\x07 \x01(\x05\x12\x0f\n\x07\x63reated\x18\n \x01(\x03\"C\n\rFullSyncToken\x12\x15\n\rstartRevision\x18\x01 \x01(\x03\x12\x0e\n\x06\x65ntity\x18\x02 \x01(\x05\x12\x0b\n\x03key\x18\x03 \x03(\x0c\"$\n\x0cIncSyncToken\x12\x14\n\x0clastRevision\x18\x02 \x01(\x03\"h\n\rPedmSyncToken\x12\'\n\x08\x66ullSync\x18\x02 \x01(\x0b\x32\x13.PEDM.FullSyncTokenH\x00\x12%\n\x07incSync\x18\x03 \x01(\x0b\x32\x12.PEDM.IncSyncTokenH\x00\x42\x07\n\x05token\"/\n\x12GetPedmDataRequest\x12\x19\n\x11\x63ontinuationToken\x18\x01 \x01(\x0c\"\xad\x04\n\x13GetPedmDataResponse\x12\x19\n\x11\x63ontinuationToken\x18\x01 \x01(\x0c\x12\x12\n\nresetCache\x18\x02 \x01(\x08\x12\x0f\n\x07hasMore\x18\x03 \x01(\x08\x12\x1a\n\x12removedDeployments\x18\n \x03(\x0c\x12\x15\n\rremovedAgents\x18\x0b \x03(\x0c\x12\x17\n\x0fremovedPolicies\x18\x0c \x03(\x0c\x12\x19\n\x11removedCollection\x18\r \x03(\x0c\x12\x33\n\x15removedCollectionLink\x18\x0e \x03(\x0b\x32\x14.PEDM.CollectionLink\x12\x18\n\x10removedApprovals\x18\x0f \x03(\x0c\x12)\n\x0b\x64\x65ployments\x18\x14 \x03(\x0b\x32\x14.PEDM.DeploymentNode\x12\x1f\n\x06\x61gents\x18\x15 \x03(\x0b\x32\x0f.PEDM.AgentNode\x12\"\n\x08policies\x18\x16 \x03(\x0b\x32\x10.PEDM.PolicyNode\x12)\n\x0b\x63ollections\x18\x17 \x03(\x0b\x32\x14.PEDM.CollectionNode\x12,\n\x0e\x63ollectionLink\x18\x18 \x03(\x0b\x32\x14.PEDM.CollectionLink\x12%\n\tapprovals\x18\x19 \x03(\x0b\x32\x12.PEDM.ApprovalNode\x12\x30\n\x0e\x61pprovalStatus\x18\x1a \x03(\x0b\x32\x18.PEDM.ApprovalStatusNode\"<\n\x12PolicyAgentRequest\x12\x11\n\tpolicyUid\x18\x01 \x03(\x0c\x12\x13\n\x0bsummaryOnly\x18\x02 \x01(\x08\";\n\x13PolicyAgentResponse\x12\x12\n\nagentCount\x18\x01 \x01(\x05\x12\x10\n\x08\x61gentUid\x18\x02 \x03(\x0c\"]\n\x16\x41uditCollectionRequest\x12\x19\n\x11\x63ontinuationToken\x18\x01 \x01(\x0c\x12\x10\n\x08valueUid\x18\x02 \x03(\x0c\x12\x16\n\x0e\x63ollectionName\x18\x03 \x03(\t\"h\n\x14\x41uditCollectionValue\x12\x16\n\x0e\x63ollectionName\x18\x01 \x01(\t\x12\x10\n\x08valueUid\x18\x02 \x01(\x0c\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x04 \x01(\x03\"q\n\x17\x41uditCollectionResponse\x12*\n\x06values\x18\x01 \x03(\x0b\x32\x1a.PEDM.AuditCollectionValue\x12\x0f\n\x07hasMore\x18\x02 \x01(\x08\x12\x19\n\x11\x63ontinuationToken\x18\x03 \x01(\x0c\"H\n\x18GetCollectionLinkRequest\x12,\n\x0e\x63ollectionLink\x18\x01 \x03(\x0b\x32\x14.PEDM.CollectionLink\"Q\n\x19GetCollectionLinkResponse\x12\x34\n\x12\x63ollectionLinkData\x18\x01 \x03(\x0b\x32\x18.PEDM.CollectionLinkData\"?\n\x17GetAgentLastSeenRequest\x12\x12\n\nactiveOnly\x18\x01 \x01(\x08\x12\x10\n\x08\x61gentUid\x18\x02 \x03(\x0c\"3\n\rAgentLastSeen\x12\x10\n\x08\x61gentUid\x18\x01 \x01(\x0c\x12\x10\n\x08lastSeen\x18\x02 \x01(\x03\"A\n\x18GetAgentLastSeenResponse\x12%\n\x08lastSeen\x18\x01 \x03(\x0b\x32\x13.PEDM.AgentLastSeen\"2\n\x1aGetActiveAgentCountRequest\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x03(\x05\">\n\x10\x41\x63tiveAgentCount\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x05\x12\x14\n\x0c\x61\x63tiveAgents\x18\x02 \x01(\x05\";\n\x12\x41\x63tiveAgentFailure\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x05\x12\x0f\n\x07message\x18\x02 \x01(\t\"x\n\x1bGetActiveAgentCountResponse\x12*\n\nagentCount\x18\x01 \x03(\x0b\x32\x16.PEDM.ActiveAgentCount\x12-\n\x0b\x66\x61iledCount\x18\x02 \x03(\x0b\x32\x18.PEDM.ActiveAgentFailure\"\x87\x01\n\x19GetAgentDailyCountRequest\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x03(\x05\x12$\n\tmonthYear\x18\x02 \x01(\x0b\x32\x0f.PEDM.MonthYearH\x00\x12$\n\tdateRange\x18\x03 \x01(\x0b\x32\x0f.PEDM.DateRangeH\x00\x42\x08\n\x06period\"(\n\tMonthYear\x12\r\n\x05month\x18\x01 \x01(\x05\x12\x0c\n\x04year\x18\x02 \x01(\x05\"\'\n\tDateRange\x12\r\n\x05start\x18\x01 \x01(\x03\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x03\"3\n\x0f\x41gentDailyCount\x12\x0c\n\x04\x64\x61te\x18\x01 \x01(\x03\x12\x12\n\nagentCount\x18\x02 \x01(\x05\"V\n\x17\x41gentCountForEnterprise\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x05\x12%\n\x06\x63ounts\x18\x02 \x03(\x0b\x32\x15.PEDM.AgentDailyCount\"U\n\x1aGetAgentDailyCountResponse\x12\x37\n\x10\x65nterpriseCounts\x18\x01 \x03(\x0b\x32\x1d.PEDM.AgentCountForEnterprise*j\n\x12\x43ollectionLinkType\x12\r\n\tCLT_OTHER\x10\x00\x12\r\n\tCLT_AGENT\x10\x01\x12\x0e\n\nCLT_POLICY\x10\x02\x12\x12\n\x0e\x43LT_COLLECTION\x10\x03\x12\x12\n\x0e\x43LT_DEPLOYMENT\x10\x04\x42 \n\x18\x63om.keepersecurity.protoB\x04PEDMb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -34,8 +34,8 @@ if not _descriptor._USE_C_DESCRIPTORS: _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'\n\030com.keepersecurity.protoB\004PEDM' - _globals['_COLLECTIONLINKTYPE']._serialized_start=5286 - _globals['_COLLECTIONLINKTYPE']._serialized_end=5392 + _globals['_COLLECTIONLINKTYPE']._serialized_start=5607 + _globals['_COLLECTIONLINKTYPE']._serialized_end=5713 _globals['_PEDMTOTPVALIDATEREQUEST']._serialized_start=60 _globals['_PEDMTOTPVALIDATEREQUEST']._serialized_end=139 _globals['_PEDMSTATUS']._serialized_start=141 @@ -72,64 +72,74 @@ _globals['_COLLECTIONREQUEST']._serialized_end=1876 _globals['_SETCOLLECTIONLINKREQUEST']._serialized_start=1878 _globals['_SETCOLLECTIONLINKREQUEST']._serialized_end=2001 - _globals['_APPROVALACTIONREQUEST']._serialized_start=2003 - _globals['_APPROVALACTIONREQUEST']._serialized_end=2073 - _globals['_DEPLOYMENTNODE']._serialized_start=2076 - _globals['_DEPLOYMENTNODE']._serialized_end=2247 - _globals['_AGENTNODE']._serialized_start=2250 - _globals['_AGENTNODE']._serialized_end=2418 - _globals['_POLICYNODE']._serialized_start=2421 - _globals['_POLICYNODE']._serialized_end=2569 - _globals['_COLLECTIONNODE']._serialized_start=2571 - _globals['_COLLECTIONNODE']._serialized_end=2674 - _globals['_COLLECTIONLINK']._serialized_start=2676 - _globals['_COLLECTIONLINK']._serialized_end=2776 - _globals['_APPROVALSTATUSNODE']._serialized_start=2779 - _globals['_APPROVALSTATUSNODE']._serialized_end=2936 - _globals['_APPROVALNODE']._serialized_start=2939 - _globals['_APPROVALNODE']._serialized_end=3118 - _globals['_FULLSYNCTOKEN']._serialized_start=3120 - _globals['_FULLSYNCTOKEN']._serialized_end=3187 - _globals['_INCSYNCTOKEN']._serialized_start=3189 - _globals['_INCSYNCTOKEN']._serialized_end=3225 - _globals['_PEDMSYNCTOKEN']._serialized_start=3227 - _globals['_PEDMSYNCTOKEN']._serialized_end=3331 - _globals['_GETPEDMDATAREQUEST']._serialized_start=3333 - _globals['_GETPEDMDATAREQUEST']._serialized_end=3380 - _globals['_GETPEDMDATARESPONSE']._serialized_start=3383 - _globals['_GETPEDMDATARESPONSE']._serialized_end=3940 - _globals['_POLICYAGENTREQUEST']._serialized_start=3942 - _globals['_POLICYAGENTREQUEST']._serialized_end=4002 - _globals['_POLICYAGENTRESPONSE']._serialized_start=4004 - _globals['_POLICYAGENTRESPONSE']._serialized_end=4063 - _globals['_AUDITCOLLECTIONREQUEST']._serialized_start=4065 - _globals['_AUDITCOLLECTIONREQUEST']._serialized_end=4158 - _globals['_AUDITCOLLECTIONVALUE']._serialized_start=4160 - _globals['_AUDITCOLLECTIONVALUE']._serialized_end=4264 - _globals['_AUDITCOLLECTIONRESPONSE']._serialized_start=4266 - _globals['_AUDITCOLLECTIONRESPONSE']._serialized_end=4379 - _globals['_GETCOLLECTIONLINKREQUEST']._serialized_start=4381 - _globals['_GETCOLLECTIONLINKREQUEST']._serialized_end=4453 - _globals['_GETCOLLECTIONLINKRESPONSE']._serialized_start=4455 - _globals['_GETCOLLECTIONLINKRESPONSE']._serialized_end=4536 - _globals['_GETACTIVEAGENTCOUNTREQUEST']._serialized_start=4538 - _globals['_GETACTIVEAGENTCOUNTREQUEST']._serialized_end=4588 - _globals['_ACTIVEAGENTCOUNT']._serialized_start=4590 - _globals['_ACTIVEAGENTCOUNT']._serialized_end=4652 - _globals['_ACTIVEAGENTFAILURE']._serialized_start=4654 - _globals['_ACTIVEAGENTFAILURE']._serialized_end=4713 - _globals['_GETACTIVEAGENTCOUNTRESPONSE']._serialized_start=4715 - _globals['_GETACTIVEAGENTCOUNTRESPONSE']._serialized_end=4835 - _globals['_GETAGENTDAILYCOUNTREQUEST']._serialized_start=4838 - _globals['_GETAGENTDAILYCOUNTREQUEST']._serialized_end=4973 - _globals['_MONTHYEAR']._serialized_start=4975 - _globals['_MONTHYEAR']._serialized_end=5015 - _globals['_DATERANGE']._serialized_start=5017 - _globals['_DATERANGE']._serialized_end=5056 - _globals['_AGENTDAILYCOUNT']._serialized_start=5058 - _globals['_AGENTDAILYCOUNT']._serialized_end=5109 - _globals['_AGENTCOUNTFORENTERPRISE']._serialized_start=5111 - _globals['_AGENTCOUNTFORENTERPRISE']._serialized_end=5197 - _globals['_GETAGENTDAILYCOUNTRESPONSE']._serialized_start=5199 - _globals['_GETAGENTDAILYCOUNTRESPONSE']._serialized_end=5284 + _globals['_APPROVALEXTENDDATA']._serialized_start=2003 + _globals['_APPROVALEXTENDDATA']._serialized_end=2062 + _globals['_MODIFYAPPROVALREQUEST']._serialized_start=2064 + _globals['_MODIFYAPPROVALREQUEST']._serialized_end=2137 + _globals['_APPROVALACTIONREQUEST']._serialized_start=2139 + _globals['_APPROVALACTIONREQUEST']._serialized_end=2209 + _globals['_DEPLOYMENTNODE']._serialized_start=2212 + _globals['_DEPLOYMENTNODE']._serialized_end=2383 + _globals['_AGENTNODE']._serialized_start=2386 + _globals['_AGENTNODE']._serialized_end=2554 + _globals['_POLICYNODE']._serialized_start=2557 + _globals['_POLICYNODE']._serialized_end=2705 + _globals['_COLLECTIONNODE']._serialized_start=2707 + _globals['_COLLECTIONNODE']._serialized_end=2810 + _globals['_COLLECTIONLINK']._serialized_start=2812 + _globals['_COLLECTIONLINK']._serialized_end=2912 + _globals['_APPROVALSTATUSNODE']._serialized_start=2915 + _globals['_APPROVALSTATUSNODE']._serialized_end=3072 + _globals['_APPROVALNODE']._serialized_start=3075 + _globals['_APPROVALNODE']._serialized_end=3254 + _globals['_FULLSYNCTOKEN']._serialized_start=3256 + _globals['_FULLSYNCTOKEN']._serialized_end=3323 + _globals['_INCSYNCTOKEN']._serialized_start=3325 + _globals['_INCSYNCTOKEN']._serialized_end=3361 + _globals['_PEDMSYNCTOKEN']._serialized_start=3363 + _globals['_PEDMSYNCTOKEN']._serialized_end=3467 + _globals['_GETPEDMDATAREQUEST']._serialized_start=3469 + _globals['_GETPEDMDATAREQUEST']._serialized_end=3516 + _globals['_GETPEDMDATARESPONSE']._serialized_start=3519 + _globals['_GETPEDMDATARESPONSE']._serialized_end=4076 + _globals['_POLICYAGENTREQUEST']._serialized_start=4078 + _globals['_POLICYAGENTREQUEST']._serialized_end=4138 + _globals['_POLICYAGENTRESPONSE']._serialized_start=4140 + _globals['_POLICYAGENTRESPONSE']._serialized_end=4199 + _globals['_AUDITCOLLECTIONREQUEST']._serialized_start=4201 + _globals['_AUDITCOLLECTIONREQUEST']._serialized_end=4294 + _globals['_AUDITCOLLECTIONVALUE']._serialized_start=4296 + _globals['_AUDITCOLLECTIONVALUE']._serialized_end=4400 + _globals['_AUDITCOLLECTIONRESPONSE']._serialized_start=4402 + _globals['_AUDITCOLLECTIONRESPONSE']._serialized_end=4515 + _globals['_GETCOLLECTIONLINKREQUEST']._serialized_start=4517 + _globals['_GETCOLLECTIONLINKREQUEST']._serialized_end=4589 + _globals['_GETCOLLECTIONLINKRESPONSE']._serialized_start=4591 + _globals['_GETCOLLECTIONLINKRESPONSE']._serialized_end=4672 + _globals['_GETAGENTLASTSEENREQUEST']._serialized_start=4674 + _globals['_GETAGENTLASTSEENREQUEST']._serialized_end=4737 + _globals['_AGENTLASTSEEN']._serialized_start=4739 + _globals['_AGENTLASTSEEN']._serialized_end=4790 + _globals['_GETAGENTLASTSEENRESPONSE']._serialized_start=4792 + _globals['_GETAGENTLASTSEENRESPONSE']._serialized_end=4857 + _globals['_GETACTIVEAGENTCOUNTREQUEST']._serialized_start=4859 + _globals['_GETACTIVEAGENTCOUNTREQUEST']._serialized_end=4909 + _globals['_ACTIVEAGENTCOUNT']._serialized_start=4911 + _globals['_ACTIVEAGENTCOUNT']._serialized_end=4973 + _globals['_ACTIVEAGENTFAILURE']._serialized_start=4975 + _globals['_ACTIVEAGENTFAILURE']._serialized_end=5034 + _globals['_GETACTIVEAGENTCOUNTRESPONSE']._serialized_start=5036 + _globals['_GETACTIVEAGENTCOUNTRESPONSE']._serialized_end=5156 + _globals['_GETAGENTDAILYCOUNTREQUEST']._serialized_start=5159 + _globals['_GETAGENTDAILYCOUNTREQUEST']._serialized_end=5294 + _globals['_MONTHYEAR']._serialized_start=5296 + _globals['_MONTHYEAR']._serialized_end=5336 + _globals['_DATERANGE']._serialized_start=5338 + _globals['_DATERANGE']._serialized_end=5377 + _globals['_AGENTDAILYCOUNT']._serialized_start=5379 + _globals['_AGENTDAILYCOUNT']._serialized_end=5430 + _globals['_AGENTCOUNTFORENTERPRISE']._serialized_start=5432 + _globals['_AGENTCOUNTFORENTERPRISE']._serialized_end=5518 + _globals['_GETAGENTDAILYCOUNTRESPONSE']._serialized_start=5520 + _globals['_GETAGENTDAILYCOUNTRESPONSE']._serialized_end=5605 # @@protoc_insertion_point(module_scope) diff --git a/keepersdk-package/src/keepersdk/proto/pedm_pb2.pyi b/keepersdk-package/src/keepersdk/proto/pedm_pb2.pyi index f38615bf..bdbb0427 100644 --- a/keepersdk-package/src/keepersdk/proto/pedm_pb2.pyi +++ b/keepersdk-package/src/keepersdk/proto/pedm_pb2.pyi @@ -205,6 +205,20 @@ class SetCollectionLinkRequest(_message.Message): removeCollection: _containers.RepeatedCompositeFieldContainer[CollectionLink] def __init__(self, addCollection: _Optional[_Iterable[_Union[CollectionLinkData, _Mapping]]] = ..., removeCollection: _Optional[_Iterable[_Union[CollectionLink, _Mapping]]] = ...) -> None: ... +class ApprovalExtendData(_message.Message): + __slots__ = ("approvalUid", "expireIn") + APPROVALUID_FIELD_NUMBER: _ClassVar[int] + EXPIREIN_FIELD_NUMBER: _ClassVar[int] + approvalUid: bytes + expireIn: int + def __init__(self, approvalUid: _Optional[bytes] = ..., expireIn: _Optional[int] = ...) -> None: ... + +class ModifyApprovalRequest(_message.Message): + __slots__ = ("extendApproval",) + EXTENDAPPROVAL_FIELD_NUMBER: _ClassVar[int] + extendApproval: _containers.RepeatedCompositeFieldContainer[ApprovalExtendData] + def __init__(self, extendApproval: _Optional[_Iterable[_Union[ApprovalExtendData, _Mapping]]] = ...) -> None: ... + class ApprovalActionRequest(_message.Message): __slots__ = ("approve", "deny", "remove") APPROVE_FIELD_NUMBER: _ClassVar[int] @@ -453,6 +467,28 @@ class GetCollectionLinkResponse(_message.Message): collectionLinkData: _containers.RepeatedCompositeFieldContainer[CollectionLinkData] def __init__(self, collectionLinkData: _Optional[_Iterable[_Union[CollectionLinkData, _Mapping]]] = ...) -> None: ... +class GetAgentLastSeenRequest(_message.Message): + __slots__ = ("activeOnly", "agentUid") + ACTIVEONLY_FIELD_NUMBER: _ClassVar[int] + AGENTUID_FIELD_NUMBER: _ClassVar[int] + activeOnly: bool + agentUid: _containers.RepeatedScalarFieldContainer[bytes] + def __init__(self, activeOnly: bool = ..., agentUid: _Optional[_Iterable[bytes]] = ...) -> None: ... + +class AgentLastSeen(_message.Message): + __slots__ = ("agentUid", "lastSeen") + AGENTUID_FIELD_NUMBER: _ClassVar[int] + LASTSEEN_FIELD_NUMBER: _ClassVar[int] + agentUid: bytes + lastSeen: int + def __init__(self, agentUid: _Optional[bytes] = ..., lastSeen: _Optional[int] = ...) -> None: ... + +class GetAgentLastSeenResponse(_message.Message): + __slots__ = ("lastSeen",) + LASTSEEN_FIELD_NUMBER: _ClassVar[int] + lastSeen: _containers.RepeatedCompositeFieldContainer[AgentLastSeen] + def __init__(self, lastSeen: _Optional[_Iterable[_Union[AgentLastSeen, _Mapping]]] = ...) -> None: ... + class GetActiveAgentCountRequest(_message.Message): __slots__ = ("enterpriseId",) ENTERPRISEID_FIELD_NUMBER: _ClassVar[int] From 8ba5f65110632c53908599450d58534de7c36e3a Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Sat, 10 Jan 2026 16:48:00 -0800 Subject: [PATCH 23/37] Recover from invalid device error --- .../src/keepersdk/authentication/login_auth.py | 16 +++++++++++++--- .../src/keepersdk/vault/attachment.py | 7 ++++--- .../src/keepersdk/vault/batch_operations.py | 1 + 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/keepersdk-package/src/keepersdk/authentication/login_auth.py b/keepersdk-package/src/keepersdk/authentication/login_auth.py index c31c3ba4..2ef6cea6 100644 --- a/keepersdk-package/src/keepersdk/authentication/login_auth.py +++ b/keepersdk-package/src/keepersdk/authentication/login_auth.py @@ -189,8 +189,8 @@ class LoginContext: def __init__(self) -> None: self.username = '' self.passwords: List[str] = [] - self.clone_code = b'' - self.device_token = b'' + self.clone_code: Optional[bytes] = None + self.device_token: Optional[bytes] = None self.device_private_key: Optional[ec.EllipticCurvePrivateKey] = None self.message_session_uid: bytes = crypto.get_random_bytes(16) self.account_type: AccountAuthType = AccountAuthType.Regular @@ -248,6 +248,9 @@ def login(self, username: str, *passwords: str) -> None: try: _ensure_device_token_loaded(self) _start_login(self) + except errors.InvalidDeviceTokenError: + _ensure_device_token_loaded(self, new_device=True) + _start_login(self) except errors.RegionRedirectError as rr: _redirect_to_region(self, rr.region_host) _ensure_device_token_loaded(self) @@ -377,7 +380,7 @@ def _tfa_channel_info_keeper_to_sdk(channel_info: APIRequest_pb2.TwoFactorChanne return info -def _ensure_device_token_loaded(login: LoginAuth) -> None: +def _ensure_device_token_loaded(login: LoginAuth, *, new_device=False) -> None: logger = utils.get_logger() attempt = 0 @@ -385,6 +388,13 @@ def _ensure_device_token_loaded(login: LoginAuth) -> None: context.clone_code = b'' config = login.keeper_endpoint.get_configuration_storage().get() server = login.keeper_endpoint.server + if new_device: + context.device_token = None + context.device_private_key = None + all_devices = [x.device_token for x in config.devices().list()] + for device_token in all_devices: + config.devices().delete(device_token) + logger.info('Registering a new device') while attempt < 6: attempt += 1 diff --git a/keepersdk-package/src/keepersdk/vault/attachment.py b/keepersdk-package/src/keepersdk/vault/attachment.py index 68f82045..8dfb5ab7 100644 --- a/keepersdk-package/src/keepersdk/vault/attachment.py +++ b/keepersdk-package/src/keepersdk/vault/attachment.py @@ -14,6 +14,7 @@ from .vault_record import FileRecord, PasswordRecord, TypedRecord, AttachmentFile, AttachmentFileThumb from .. import utils, crypto from ..proto import record_pb2 +from ..authentication import endpoint class AttachmentDownloadRequest: @@ -212,7 +213,7 @@ def upload_attachments(vault: vault_online.VaultOnline, files = { uo['file_parameter']: (attachment_id, crypto_stream, 'application/octet-stream') } - response = requests.post(uo['url'], files=files, data=uo['parameters']) + response = requests.post(uo['url'], files=files, data=uo['parameters'], verify=endpoint.get_certificate_check()) if response.status_code == uo['success_status_code']: atta.id = attachment_id atta.name = task.name or '' @@ -237,7 +238,7 @@ def upload_attachments(vault: vault_online.VaultOnline, files = { tuo['file_parameter']: (tuo['file_id'], crypto_stream, 'application/octet-stream') } - response = requests.post(tuo['url'], files=files, data=tuo['parameters']) + response = requests.post(tuo['url'], files=files, data=tuo['parameters'], verify=endpoint.get_certificate_check()) if response.status_code == uo['success_status_code']: thumb = AttachmentFileThumb() thumb.id = tuo['file_id'] @@ -301,7 +302,7 @@ def upload_attachments(vault: vault_online.VaultOnline, files = { 'file': (file_ref, crypto_stream, 'application/octet-stream') } - response = requests.post(uo.url, files=files, data=json.loads(uo.parameters)) + response = requests.post(uo.url, files=files, data=json.loads(uo.parameters), verify=endpoint.get_certificate_check()) if response.status_code == uo.success_status_code: facade.file_ref.append(file_ref) if record.linked_keys is None: diff --git a/keepersdk-package/src/keepersdk/vault/batch_operations.py b/keepersdk-package/src/keepersdk/vault/batch_operations.py index e7356b81..2b8ce423 100644 --- a/keepersdk-package/src/keepersdk/vault/batch_operations.py +++ b/keepersdk-package/src/keepersdk/vault/batch_operations.py @@ -588,6 +588,7 @@ def get_shared_folder_key(f_uid: str) -> bytes: if folder_node: if folder_node.folder_type == 'user_folder': tra_rq.folder_type = record_pb2.RecordFolderType.user_folder + shared_folder_uid = None elif folder_node.folder_type == 'shared_folder': tra_rq.folder_type = record_pb2.RecordFolderType.shared_folder shared_folder_uid = folder_node.folder_uid From b94cac27395b9f5304c4f9aee7bd1ad35a8f6eff Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Sat, 10 Jan 2026 18:45:32 -0800 Subject: [PATCH 24/37] KEPM: expiration parser --- keepercli-package/src/keepercli/commands/pedm_admin.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/keepercli-package/src/keepercli/commands/pedm_admin.py b/keepercli-package/src/keepercli/commands/pedm_admin.py index ae193a91..ce220175 100644 --- a/keepercli-package/src/keepercli/commands/pedm_admin.py +++ b/keepercli-package/src/keepercli/commands/pedm_admin.py @@ -18,9 +18,10 @@ from keepersdk.plugins.pedm import admin_plugin, pedm_shared, admin_types, admin_storage from keepersdk.plugins.pedm.pedm_shared import CollectionType from keepersdk.proto import NotificationCenter_pb2, pedm_pb2 +from keepersdk.vault import share_management_utils from . import base, pedm_aram from .. import prompt_utils, api -from ..helpers import report_utils, share_utils +from ..helpers import report_utils from ..params import KeeperParams @@ -1761,7 +1762,7 @@ def execute(self, context: KeeperParams, **kwargs) -> None: plugin = context.pedm_plugin logger = api.get_logger() - share_expiration = share_utils.get_share_expiration(kwargs.get('expire_at'), kwargs.get('expire_in')) + share_expiration = share_management_utils.get_share_expiration(kwargs.get('expire_at'), kwargs.get('expire_in')) if share_expiration > 0: share_expiration = share_expiration - int(datetime.datetime.now().timestamp()) if share_expiration < 100: From 590651687748b99a1e918b3d8c724f9b14dd78ca Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Sat, 10 Jan 2026 16:29:19 -0800 Subject: [PATCH 25/37] Add "certificate_check" property --- .../src/keepersdk/authentication/configuration.py | 4 +++- keepersdk-package/src/keepersdk/authentication/endpoint.py | 2 +- .../src/keepersdk/authentication/notifications.py | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/keepersdk-package/src/keepersdk/authentication/configuration.py b/keepersdk-package/src/keepersdk/authentication/configuration.py index 1957555b..f4154e49 100644 --- a/keepersdk-package/src/keepersdk/authentication/configuration.py +++ b/keepersdk-package/src/keepersdk/authentication/configuration.py @@ -3,7 +3,7 @@ import json import os from typing import Type, Union, TypeVar, Optional, Generic, Iterator, List, Dict -from urllib.parse import urlparse +from urllib.parse import urlparse, urlsplit from .. import utils from ..constants import DEFAULT_KEEPER_SERVER @@ -15,6 +15,8 @@ def adjust_username(username: str) -> str: def adjust_servername(server: str) -> str: if server: + if '://' not in server: + server = '//' + server url = urlparse(server) if url.netloc: return url.netloc.lower() diff --git a/keepersdk-package/src/keepersdk/authentication/endpoint.py b/keepersdk-package/src/keepersdk/authentication/endpoint.py index 6f623a2f..f351b3e7 100644 --- a/keepersdk-package/src/keepersdk/authentication/endpoint.py +++ b/keepersdk-package/src/keepersdk/authentication/endpoint.py @@ -150,7 +150,7 @@ def execute_router_rest(self, endpoint: str, *, session_token: bytes, payload: O logger.debug('>>> [ROUTER] POST Request: [%s]', url) if payload is not None: payload = crypto.encrypt_aes_v2(payload, transmission_key) - response = requests.post(url, headers=headers, data=payload) + response = requests.post(url, headers=headers, data=payload, verify=get_certificate_check()) logger.debug('<<< [ROUTER] Response Code: [%d]', response.status_code) if response.status_code == 200: diff --git a/keepersdk-package/src/keepersdk/authentication/notifications.py b/keepersdk-package/src/keepersdk/authentication/notifications.py index 7e3f03ea..8f997b61 100644 --- a/keepersdk-package/src/keepersdk/authentication/notifications.py +++ b/keepersdk-package/src/keepersdk/authentication/notifications.py @@ -117,6 +117,7 @@ async def main_loop(self) -> None: if url.startswith('wss://'): ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) if not endpoint.get_certificate_check(): + ssl_context.check_hostname = False ssl_context.verify_mode = ssl.CERT_NONE try: async with websockets.connect( From 1f0538a4404dcb5675d8eec7f5b3b11b16169baa Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Sat, 10 Jan 2026 18:51:52 -0800 Subject: [PATCH 26/37] KEPM: last seen --- .../src/keepercli/commands/pedm_admin.py | 5 ++-- .../keepersdk/plugins/pedm/admin_plugin.py | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/keepercli-package/src/keepercli/commands/pedm_admin.py b/keepercli-package/src/keepercli/commands/pedm_admin.py index ce220175..d354b9b4 100644 --- a/keepercli-package/src/keepercli/commands/pedm_admin.py +++ b/keepercli-package/src/keepercli/commands/pedm_admin.py @@ -577,7 +577,7 @@ def execute(self, context: KeeperParams, **kwargs) -> Any: verbose = kwargs.get('verbose') is True table = [] - headers = ['agent_uid', 'machine_name', 'deployment', 'disabled', 'created'] + headers = ['agent_uid', 'machine_name', 'deployment', 'disabled', 'created', 'last_seen'] active_agents: Set[str] = set() if verbose: headers.extend(('active', 'properties')) @@ -595,7 +595,8 @@ def execute(self, context: KeeperParams, **kwargs) -> Any: machine_name = '' if isinstance(agent.properties, dict): machine_name = agent.properties.get('MachineName') or '' - row: List[Any] = [agent.agent_uid, machine_name, deployment_name, agent.disabled, time_created] + row: List[Any] = [agent.agent_uid, machine_name, deployment_name, agent.disabled, time_created, + plugin.agent_last_seen(agent.agent_uid)] if verbose: row.append(agent.agent_uid in active_agents) props: Optional[List[str]] = None diff --git a/keepersdk-package/src/keepersdk/plugins/pedm/admin_plugin.py b/keepersdk-package/src/keepersdk/plugins/pedm/admin_plugin.py index 60332bc3..0ccbe8f6 100644 --- a/keepersdk-package/src/keepersdk/plugins/pedm/admin_plugin.py +++ b/keepersdk-package/src/keepersdk/plugins/pedm/admin_plugin.py @@ -158,6 +158,7 @@ def __init__(self, loader: enterprise_loader.EnterpriseLoader): self._push_notifications.register_callback(self.on_push_message) self._push_notifications.connect_to_push_channel() self._need_sync = True + self._last_seen: Optional[Dict[str, int]] = None self.logger = utils.get_logger() def on_push_message(self, message: Dict[str, Any]): @@ -814,6 +815,29 @@ def modify_approvals(self, *, assert status_rs is not None return admin_types.ModifyStatus.from_proto(status_rs) + def load_last_seen(self) -> None: + if self._last_seen is None: + self._last_seen = {} + else: + self._last_seen.clear() + auth = self.loader.keeper_auth + rq = pedm_pb2.GetAgentLastSeenRequest() + rq.activeOnly = True + last_seen_rs = auth.execute_router( + 'pedm/get_agent_last_seen', rq, response_type=pedm_pb2.GetAgentLastSeenResponse) + assert last_seen_rs is not None + for ls in last_seen_rs.lastSeen: + self._last_seen[utils.base64_url_encode(ls.agentUid)] = ls.lastSeen + + def agent_last_seen(self, agent_uid: str) -> Optional[datetime.datetime]: + if self._last_seen is None: + self.load_last_seen() + + if agent_uid in self._last_seen: + millis = self._last_seen[agent_uid] + return datetime.datetime.fromtimestamp(millis / 1000) + + return None def extend_approvals(self, *, to_extend: Optional[List[admin_types.PedmUpdateApproval]] = None) -> admin_types.ModifyStatus: auth = self.loader.keeper_auth From e705305df0e18e65962107e094f090590ea5390e Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Thu, 15 Jan 2026 11:38:39 +0530 Subject: [PATCH 27/37] Aging report and action-report commands added --- .../action_report/action_report.py | 125 ++++ .../sdk_examples/aging_report/aging_report.py | 123 ++++ .../src/keepercli/commands/action_report.py | 371 ++++++++++++ .../src/keepercli/commands/aging_report.py | 175 ++++++ .../src/keepercli/commands/audit_report.py | 30 +- .../src/keepercli/register_commands.py | 4 +- .../src/keepersdk/enterprise/action_report.py | 416 +++++++++++++ .../src/keepersdk/enterprise/aging_report.py | 557 ++++++++++++++++++ .../src/keepersdk/enterprise/audit_report.py | 2 +- 9 files changed, 1789 insertions(+), 14 deletions(-) create mode 100644 examples/sdk_examples/action_report/action_report.py create mode 100644 examples/sdk_examples/aging_report/aging_report.py create mode 100644 keepercli-package/src/keepercli/commands/action_report.py create mode 100644 keepercli-package/src/keepercli/commands/aging_report.py create mode 100644 keepersdk-package/src/keepersdk/enterprise/action_report.py create mode 100644 keepersdk-package/src/keepersdk/enterprise/aging_report.py diff --git a/examples/sdk_examples/action_report/action_report.py b/examples/sdk_examples/action_report/action_report.py new file mode 100644 index 00000000..b52f5ab3 --- /dev/null +++ b/examples/sdk_examples/action_report/action_report.py @@ -0,0 +1,125 @@ +"""Enterprise Action Report SDK Example.""" + +import getpass +import sqlite3 + +from keepersdk.authentication import configuration, endpoint, keeper_auth, login_auth +from keepersdk.constants import KEEPER_PUBLIC_HOSTS +from keepersdk.enterprise import action_report, enterprise_loader, sqlite_enterprise_storage +from keepersdk.errors import KeeperApiError + +TARGET_STATUS = 'no-logon' +DAYS_SINCE = 30 + + +def login(): + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + username = config.get().last_login or input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + while not login_auth_context.login_step.is_final(): + step = login_auth_context.login_step + if isinstance(step, login_auth.LoginStepDeviceApproval): + step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval request sent. Approve this device and press Enter.") + input() + elif isinstance(step, login_auth.LoginStepPassword): + step.verify_password(getpass.getpass('Enter password: ')) + elif isinstance(step, login_auth.LoginStepTwoFactor): + channel = step.get_channels()[0] + step.send_code(channel.channel_uid, getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ')) + else: + raise NotImplementedError(f"Unsupported login step: {type(step).__name__}") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + return None + + +def print_report(entries, target_status, days_since): + action_text = ( + '\tCOMMAND: NONE (No action specified)\n' + '\tSTATUS: n/a\n' + '\tSERVER MESSAGE: n/a\n' + '\tAFFECTED: 0' + ) + status_display = target_status[0].upper() + target_status[1:] + + print(f'\nAdmin Action Taken:\n{action_text}\n') + print('Note: the following reflects data prior to any administrative action being applied') + print(f'{len(entries)} User(s) With "{status_display}" Status Older Than {days_since} Day(s):\n') + + if not entries: + return + + headers = ['User ID', 'Email', 'Name', 'Status', 'Transfer Status', 'Node'] + col_widths = [14, 31, 22, 8, 17, 19] + + print(' '.join(f'{h:<{w}}' for h, w in zip(headers, col_widths))) + print(' '.join('-' * w for w in col_widths)) + + for entry in entries: + row = [ + str(entry.enterprise_user_id), entry.email, entry.full_name, + entry.status, entry.transfer_status, entry.node_path + ] + print(' '.join(f'{str(v)[:w]:<{w}}' for v, w in zip(row, col_widths))) + + +def generate_action_report(keeper_auth_context: keeper_auth.KeeperAuth): + if not keeper_auth_context.auth_context.is_enterprise_admin: + print("ERROR: This operation requires enterprise admin privileges.") + keeper_auth_context.close() + return + + enterprise = None + try: + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + enterprise = enterprise_loader.EnterpriseLoader(keeper_auth_context, storage) + + config = action_report.ActionReportConfig( + target_user_status=TARGET_STATUS, + days_since=DAYS_SINCE + ) + generator = action_report.ActionReportGenerator( + enterprise.enterprise_data, keeper_auth_context, loader=enterprise, config=config + ) + entries = generator.generate_report() + print_report(entries, TARGET_STATUS, DAYS_SINCE) + + except KeeperApiError as e: + print(f"\nAPI Error: {e}") + except Exception as e: + print(f"\nError: {e}") + finally: + if enterprise: + enterprise.close() + keeper_auth_context.close() + + +def main(): + auth = login() + if auth: + generate_action_report(auth) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/aging_report/aging_report.py b/examples/sdk_examples/aging_report/aging_report.py new file mode 100644 index 00000000..daadad66 --- /dev/null +++ b/examples/sdk_examples/aging_report/aging_report.py @@ -0,0 +1,123 @@ +"""Example: Password aging report using Keeper SDK.""" + +import datetime +import getpass +import sqlite3 +import sys +import traceback + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage, aging_report +from keepersdk.errors import KeeperApiError + + +TABLE_WIDTH = 140 +COL_WIDTHS = (30, 30, 25, 10, 45) +HEADERS = ['Owner', 'Title', 'Password Changed', 'Shared', 'Record URL'] + + +def login(): + config = configuration.JsonConfigurationStorage() + server = config.get().last_server or 'keepersecurity.com' + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + auth_context = login_auth.LoginAuth(keeper_endpoint) + auth_context.resume_session = True + + username = config.get().last_login + if not username: + print("Error: No saved login found. Please run with interactive login first.") + return None, None + + auth_context.login(username) + + while not auth_context.login_step.is_final(): + step = auth_context.login_step + if isinstance(step, login_auth.LoginStepDeviceApproval): + step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print("Device approval required. Approve and press Enter.") + input() + elif isinstance(step, login_auth.LoginStepPassword): + step.verify_password(getpass.getpass('Enter password: ')) + elif isinstance(step, login_auth.LoginStepTwoFactor): + channel = step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError(f"Unsupported login step: {type(step).__name__}") + + if isinstance(auth_context.login_step, login_auth.LoginStepConnected): + return auth_context.login_step.take_keeper_auth(), server + return None, None + + +def format_row(values): + return ' '.join( + f"{str(val or '')[:w-1]:<{w}}" + for val, w in zip(values, COL_WIDTHS + (20,) * (len(values) - len(COL_WIDTHS))) + ) + + +def print_report(rows, title): + print(f"\n{title}") + print('=' * TABLE_WIDTH) + print(format_row(HEADERS)) + print('-' * TABLE_WIDTH) + + for row in rows: + display_row = list(row) + display_row[3] = 'True' if display_row[3] else 'False' + print(format_row(display_row)) + + print('=' * TABLE_WIDTH) + print(f"\nFound {len(rows)} record(s) with aging passwords") + + +def generate_report(auth: keeper_auth.KeeperAuth, server: str): + if not auth.auth_context.is_enterprise_admin: + print("ERROR: This operation requires enterprise admin privileges.") + return 1 + + enterprise = None + try: + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = auth.auth_context.enterprise_id or 0 + storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + enterprise = enterprise_loader.EnterpriseLoader(auth, storage) + + print('\nThe default password aging period is 3 months\n') + print('Loading record password change information...') + + config = aging_report.AgingReportConfig(server=server) + generator = aging_report.AgingReportGenerator(enterprise.enterprise_data, auth, config) + rows = list(generator.generate_report_rows()) + + cutoff_dt = datetime.datetime.now() - datetime.timedelta(days=aging_report.DEFAULT_PERIOD_DAYS) + title = f'Aging Report: Records With Passwords Last Modified Before {cutoff_dt.strftime("%Y/%m/%d %H:%M:%S")}' + + print_report(rows, title) + return 0 + + except KeeperApiError as e: + print(f"API Error: {e}") + return 1 + except Exception as e: + print(f"Error generating aging report: {e}") + traceback.print_exc() + return 1 + finally: + if enterprise: + enterprise.close() + auth.close() + + +def main(): + auth, server = login() + if not auth: + print("Login failed. Unable to generate aging report.") + return 1 + return generate_report(auth, server) + + +if __name__ == "__main__": + sys.exit(main() or 0) diff --git a/keepercli-package/src/keepercli/commands/action_report.py b/keepercli-package/src/keepercli/commands/action_report.py new file mode 100644 index 00000000..18b0e269 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/action_report.py @@ -0,0 +1,371 @@ +"""Action report command for Keeper CLI.""" + +import argparse +import os +from typing import Any, List, Optional + +from keepersdk.authentication import keeper_auth +from keepersdk.enterprise import action_report, account_transfer, batch_management, enterprise_management, enterprise_types + +from . import base +from .. import api, prompt_utils +from ..helpers import report_utils +from ..params import KeeperParams + + +class ActionReportCommand(base.ArgparseCommand, enterprise_management.IEnterpriseManagementLogger): + def __init__(self): + parser = argparse.ArgumentParser( + prog='action-report', + description='Run an action report based on user activity.', + parents=[base.report_output_parser] + ) + ActionReportCommand.add_arguments_to_parser(parser) + super().__init__(parser) + self.logger = api.get_logger() + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + '--target', '-t', + dest='target_user_status', + action='store', + choices=['no-logon', 'no-update', 'locked', 'invited', 'no-recovery'], + default='no-logon', + help='User status to report on. Default: no-logon' + ) + parser.add_argument( + '--days-since', '-d', + dest='days_since', + type=int, + action='store', + help='Number of days since event of interest (e.g., login, record add/update, lock). ' + 'Default: 30 (or 90 for locked users)' + ) + parser.add_argument( + '--columns', + dest='columns', + action='store', + type=str, + help='Comma-separated list of columns to show on report. ' + 'Supported: name, status, transfer_status, node, team_count, teams, role_count, roles, alias, 2fa_enabled' + ) + parser.add_argument( + '--apply-action', '-a', + dest='apply_action', + action='store', + choices=['lock', 'delete', 'transfer', 'none'], + default='none', + help='Admin action to apply to each user in the report. Default: none' + ) + parser.add_argument( + '--target-user', + dest='target_user', + action='store', + help='Username/email of account to transfer users to when --apply-action=transfer is specified' + ) + parser.add_argument( + '--dry-run', '-n', + dest='dry_run', + action='store_true', + default=False, + help='Enable dry-run mode (preview actions without executing)' + ) + parser.add_argument( + '--force', '-f', + dest='force', + action='store_true', + help='Skip confirmation prompt when applying irreversible admin actions (e.g., delete, transfer)' + ) + parser.add_argument( + '--node', + dest='node', + action='store', + help='Filter users by node (node name or ID)' + ) + + def warning(self, message: str) -> None: + self.logger.warning(message) + + def execute(self, context: KeeperParams, **kwargs) -> Any: + base.require_login(context) + base.require_enterprise_admin(context) + + target_status = kwargs.get('target_user_status', 'no-logon') + days_since = kwargs.get('days_since') + node_name = kwargs.get('node') + apply_action = kwargs.get('apply_action', 'none') + target_user = kwargs.get('target_user') + dry_run = kwargs.get('dry_run', False) + force = kwargs.get('force', False) + output_format = kwargs.get('format', 'table') + output_file = kwargs.get('output') + + if node_name is not None and (not isinstance(node_name, str) or not node_name.strip()): + self.logger.warning('Please provide node name or node ID. The --node parameter cannot be empty.') + return + + allowed_actions = action_report.ActionReportGenerator.get_allowed_actions(target_status) + if apply_action not in allowed_actions: + self.logger.warning( + f'Action \'{apply_action}\' not allowed on \'{target_status}\' users: ' + f'value must be one of {allowed_actions}' + ) + return + + if apply_action == 'transfer' and not target_user: + self.logger.warning('--target-user is required when --apply-action=transfer is specified') + return + + if days_since is None: + days_since = 90 if target_status == 'locked' else 30 + + config = action_report.ActionReportConfig( + target_user_status=target_status, + days_since=days_since, + node_name=node_name, + apply_action=apply_action, + target_user=target_user, + dry_run=dry_run, + force=force + ) + + generator = action_report.ActionReportGenerator( + context.enterprise_data, context.auth, config=config + ) + + report_entries = generator.generate_report() + action_result = self._apply_admin_action(context, report_entries, config) + rows, headers = self._generate_output(report_entries, output_format, kwargs.get('columns')) + + status_display = target_status[0].upper() + target_status[1:] if target_status else target_status + title = f'Admin Action Taken:\n{action_result.to_text()}\n' + title += '\nNote: the following reflects data prior to any administrative action being applied' + title += f'\n{len(report_entries)} User(s) With "{status_display}" Status Older Than {days_since} Day(s)' + if node_name: + title += f' in Node "{node_name}"' + title += ': ' + + result = report_utils.dump_report_data( + rows, headers, fmt=output_format, filename=output_file, title=title + ) + + if output_file: + _, ext = os.path.splitext(output_file) + if not ext: + output_file += '.json' if output_format == 'json' else '.csv' + self.logger.info(f'Report saved to: {os.path.abspath(output_file)}') + + if apply_action != 'none' and not dry_run and action_result.affected_count > 0: + context.enterprise_loader.load() + + return result + + def _generate_output( + self, + entries: List[action_report.ActionReportEntry], + output_format: str, + columns_filter: Optional[str] + ) -> tuple: + all_columns = { + 'user_id': lambda e: e.enterprise_user_id, + 'email': lambda e: e.email, + 'name': lambda e: e.full_name, + 'status': lambda e: e.status, + 'transfer_status': lambda e: e.transfer_status, + 'node': lambda e: e.node_path, + 'roles': lambda e: e.roles or [], + 'role_count': lambda e: len(e.roles) if e.roles else 0, + 'teams': lambda e: e.teams or [], + 'team_count': lambda e: len(e.teams) if e.teams else 0, + '2fa_enabled': lambda e: e.tfa_enabled, + } + + if columns_filter: + requested_cols = [c.strip().lower() for c in columns_filter.split(',')] + columns = ['user_id'] + for col in requested_cols: + if col in all_columns and col not in columns: + columns.append(col) + else: + columns = ['user_id', 'email', 'name', 'status', 'transfer_status', 'node'] + + rows = [[all_columns[col](entry) for col in columns] for entry in entries] + headers = columns if output_format == 'json' else [report_utils.field_to_title(h) for h in columns] + return rows, headers + + def _apply_admin_action( + self, + context: KeeperParams, + entries: List[action_report.ActionReportEntry], + config: action_report.ActionReportConfig + ) -> action_report.ActionResult: + action = config.apply_action + + if action == 'none' or not entries: + return action_report.ActionResult(action='NONE (No action specified)', status='n/a', affected_count=0) + + if config.dry_run: + return action_report.ActionResult(action=action, status='dry run', affected_count=0) + + if action in ('delete', 'transfer') and not config.force: + emails = [e.email for e in entries] + alert = prompt_utils.get_formatted_text('\nALERT!\n', prompt_utils.COLORS.FAIL) + prompt_utils.output_text( + alert, + f'\nYou are about to {action} the following accounts:\n' + + '\n'.join(f'{idx + 1}) {email}' for idx, email in enumerate(emails)) + + '\n\nThis action cannot be undone.\n' + ) + answer = prompt_utils.user_choice('Do you wish to proceed?', 'yn', 'n') + if answer.lower() != 'y': + return action_report.ActionResult( + action=action, + status='Cancelled by user', + affected_count=0 + ) + + try: + if action == 'lock': + return self._lock_users(context.enterprise_loader, entries) + elif action == 'delete': + return self._delete_users(context.enterprise_loader, entries) + elif action == 'transfer': + return self._transfer_users(context, entries, config.target_user) + return action_report.ActionResult(action=action, status='unknown', affected_count=0) + except Exception as e: + self.logger.warning(f'Action failed: {e}') + return action_report.ActionResult( + action=action, status='fail', affected_count=0, server_message=str(e) + ) + + def _lock_users( + self, loader: enterprise_types.IEnterpriseLoader, entries: List[action_report.ActionReportEntry] + ) -> action_report.ActionResult: + batch = batch_management.BatchManagement(loader=loader, logger=self) + user_ids = [e.enterprise_user_id for e in entries] + batch.user_actions(to_lock=user_ids) + + try: + batch.apply() + return action_report.ActionResult( + action='lock', + status='success', + affected_count=len(entries) + ) + except Exception as e: + return action_report.ActionResult( + action='lock', + status='fail', + affected_count=0, + server_message=str(e) + ) + + def _delete_users( + self, loader: enterprise_types.IEnterpriseLoader, entries: List[action_report.ActionReportEntry] + ) -> action_report.ActionResult: + batch = batch_management.BatchManagement(loader=loader, logger=self) + users_to_delete = [ + enterprise_management.UserEdit(enterprise_user_id=e.enterprise_user_id) + for e in entries + ] + batch.modify_users(to_remove=users_to_delete) + + try: + batch.apply() + return action_report.ActionResult( + action='delete', + status='success', + affected_count=len(entries) + ) + except Exception as e: + return action_report.ActionResult( + action='delete', + status='fail', + affected_count=0, + server_message=str(e) + ) + + def _transfer_users( + self, context: KeeperParams, entries: List[action_report.ActionReportEntry], target_user: Optional[str] + ) -> action_report.ActionResult: + if not target_user: + return action_report.ActionResult( + action='transfer', + status='fail', + affected_count=0, + server_message='No transfer target specified' + ) + + target = target_user.lower().strip() + + target_user_obj = next( + (u for u in context.enterprise_data.users.get_all_entities() + if u.username.lower() == target and u.status == 'active' and u.lock == 0), + None + ) + if not target_user_obj: + return action_report.ActionResult( + action='transfer', status='fail', affected_count=0, + server_message=f'Invalid transfer target: {target}' + ) + + if target in {e.email.lower() for e in entries}: + return action_report.ActionResult( + action='transfer', status='fail', affected_count=0, + server_message='Cannot transfer user to themselves' + ) + + target_keys = self._load_user_public_keys(context.auth, target) + if not target_keys: + return action_report.ActionResult( + action='transfer', status='fail', affected_count=0, + server_message=f'Failed to get user {target} public key' + ) + + transfer_manager = account_transfer.AccountTransferManager( + loader=context.enterprise_loader, auth=context.auth + ) + + affected = 0 + errors = [] + + for entry in entries: + try: + result = transfer_manager.transfer_account(entry.email, target, target_keys) + if result.success: + affected += 1 + else: + errors.append(f'{entry.email}: {result.error_message}') + except Exception as e: + errors.append(f'{entry.email}: {e}') + + status = 'success' if affected == len(entries) else 'incomplete' if affected > 0 else 'fail' + server_message = '\n'.join(errors) if errors else 'n/a' + + return action_report.ActionResult( + action='transfer_and_delete_user', + status=status, + affected_count=affected, + server_message=server_message + ) + + def _load_user_public_keys(self, auth: keeper_auth.KeeperAuth, username: str) -> Optional[keeper_auth.UserKeys]: + try: + rq = { + 'command': 'public_keys', + 'key_owners': [username] + } + rs = auth.execute_auth_command(rq) + + if 'public_keys' in rs and rs['public_keys']: + pk = rs['public_keys'][0] + return keeper_auth.UserKeys( + rsa=pk.get('public_key'), + ec=pk.get('public_ecc_key'), + aes=None + ) + except Exception as e: + self.logger.debug(f'Failed to load public keys for {username}: {e}') + + return None diff --git a/keepercli-package/src/keepercli/commands/aging_report.py b/keepercli-package/src/keepercli/commands/aging_report.py new file mode 100644 index 00000000..fe26da2f --- /dev/null +++ b/keepercli-package/src/keepercli/commands/aging_report.py @@ -0,0 +1,175 @@ +"""Password aging report command for Keeper CLI.""" + +import argparse +import datetime +import os +from typing import Any, List + +from keepersdk.enterprise import aging_report, enterprise_types +from keepersdk.authentication import keeper_auth +from . import base +from ..helpers import report_utils +from ..params import KeeperParams +from .. import api + + +class AgingReportCommand(base.ArgparseCommand): + """Command to generate a password aging report.""" + + def __init__(self): + parser = argparse.ArgumentParser( + prog='aging-report', + description='Run a password aging report', + parents=[base.report_output_parser] + ) + AgingReportCommand.add_arguments_to_parser(parser) + super().__init__(parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser): + parser.add_argument('-r', '--rebuild', dest='rebuild', action='store_true', + help='Rebuild record database') + parser.add_argument('--delete', dest='delete', action='store_true', + help='Delete local database cache') + parser.add_argument('--no-cache', '-nc', dest='no_cache', action='store_true', + help='Remove local storage upon command completion') + parser.add_argument('-s', '--sort', dest='sort_by', action='store', default='last_changed', + choices=['owner', 'title', 'last_changed', 'shared'], + help='Sort output by column') + temporal_group = parser.add_mutually_exclusive_group() + temporal_group.add_argument('--period', dest='period', action='store', + help='Period password has not been modified (e.g., 10d, 3m, 1y)') + temporal_group.add_argument('--cutoff-date', dest='cutoff_date', action='store', + help='Date since password has not been modified (e.g., 2024-01-01)') + parser.add_argument('--username', dest='username', action='store', + help='Report expired passwords for user') + parser.add_argument('--exclude-deleted', dest='exclude_deleted', action='store_true', + help='Exclude deleted records from report') + parser.add_argument('--in-shared-folder', dest='in_shared_folder', action='store_true', + help='Limit report to records in shared folders') + + def execute(self, context: KeeperParams, **kwargs) -> Any: + base.require_login(context) + base.require_enterprise_admin(context) + + logger = api.get_logger() + enterprise_data = context.enterprise_data + auth = context.auth + enterprise_id = self._get_enterprise_id(auth) + + if kwargs.get('delete'): + return self._handle_delete(enterprise_data, auth, enterprise_id, logger) + + period_days, cutoff_date = self._parse_temporal_args(kwargs, logger) + + username = kwargs.get('username') + if username and not self._validate_username(enterprise_data, username, logger): + return + + config = aging_report.AgingReportConfig( + period_days=period_days, + cutoff_date=cutoff_date, + username=username, + exclude_deleted=kwargs.get('exclude_deleted', False), + in_shared_folder=kwargs.get('in_shared_folder', False), + rebuild=kwargs.get('rebuild', False), + no_cache=kwargs.get('no_cache', False), + server=context.keeper_config.server or 'keepersecurity.com' + ) + + if config.rebuild: + logger.info('Rebuilding record database...') + logger.info('Loading record password change information...') + + generator = aging_report.AgingReportGenerator( + context.enterprise_data, context.auth, config, vault=context.vault + ) + + try: + return self._generate_and_output_report( + generator, config, cutoff_date, period_days, kwargs, logger + ) + finally: + if config.no_cache: + generator.cleanup(enterprise_id) + logger.info('Local cache has been removed.') + + def _get_enterprise_id(self, auth: keeper_auth.KeeperAuth) -> int: + """Extract enterprise ID from context.""" + return auth.auth_context.enterprise_id + + def _handle_delete(self, enterprise_data: enterprise_types.IEnterpriseData, auth: keeper_auth.KeeperAuth, enterprise_id: int, logger) -> None: + """Handle --delete option.""" + config = aging_report.AgingReportConfig() + generator = aging_report.AgingReportGenerator(enterprise_data, auth, config) + if generator.delete_local_cache(enterprise_id): + logger.info('Local encrypted storage has been deleted.') + else: + logger.info('Local encrypted storage does not exist.') + + def _parse_temporal_args(self, kwargs, logger) -> tuple: + """Parse period/cutoff date arguments.""" + period_days = aging_report.DEFAULT_PERIOD_DAYS + cutoff_date = None + + cutoff_str = kwargs.get('cutoff_date') + period_str = kwargs.get('period') + + if cutoff_str: + cutoff_date = aging_report.parse_date(cutoff_str) + if cutoff_date is None: + raise base.CommandError(f'Invalid date format: {cutoff_str}') + logger.info(f'Reporting passwords not changed since {cutoff_date.strftime("%Y-%m-%d")}') + elif period_str: + parsed_days = aging_report.parse_period(period_str) + if parsed_days is None: + raise base.CommandError(f'Invalid period format: {period_str}. Use format like 10d, 3m, or 1y') + period_days = parsed_days + logger.info(f'Reporting passwords not changed in the last {period_days} days') + else: + logger.info('\n\nThe default password aging period is 3 months\n' + 'To change this value pass --period=[PERIOD] parameter\n' + '[PERIOD] example: 10d for 10 days; 3m for 3 months; 1y for 1 year\n\n') + + return period_days, cutoff_date + + def _validate_username(self, enterprise_data: enterprise_types.IEnterpriseData, username: str, logger) -> bool: + """Validate username exists in enterprise.""" + for user in enterprise_data.users.get_all_entities(): + if user.username.lower() == username.lower(): + return True + logger.info(f'User {username} is not a valid enterprise user') + return False + + def _generate_and_output_report(self, generator, config, cutoff_date, period_days, kwargs, logger): + """Generate report and output in requested format.""" + in_shared_folder = kwargs.get('in_shared_folder', False) + output_format = kwargs.get('format', 'table') + output_file = kwargs.get('output') + sort_by = kwargs.get('sort_by', 'last_changed') + + rows: List[List[Any]] = list(generator.generate_report_rows(include_shared_folder=in_shared_folder)) + headers = aging_report.AgingReportGenerator.get_headers(include_shared_folder=in_shared_folder) + + if output_format != 'json': + headers = [report_utils.field_to_title(h) for h in headers] + + sort_columns = {'owner': 0, 'title': 1, 'last_changed': 2, 'shared': 3} + cutoff_dt = cutoff_date or (datetime.datetime.now() - datetime.timedelta(days=period_days)) + + result = report_utils.dump_report_data( + rows, headers, fmt=output_format, filename=output_file, + title=f'Aging Report: Records With Passwords Last Modified Before {cutoff_dt.strftime("%Y/%m/%d %H:%M:%S")}', + sort_by=sort_columns.get(sort_by, 2), + sort_desc=sort_by in ('last_changed', 'shared') + ) + + logger.info(f'Found {len(rows)} record(s) with aging passwords') + + if output_file: + _, ext = os.path.splitext(output_file) + if not ext: + output_file += '.json' if output_format == 'json' else '.csv' + logger.info(f'Report saved to: {os.path.abspath(output_file)}') + + return result diff --git a/keepercli-package/src/keepercli/commands/audit_report.py b/keepercli-package/src/keepercli/commands/audit_report.py index 9826c613..0cb98337 100644 --- a/keepercli-package/src/keepercli/commands/audit_report.py +++ b/keepercli-package/src/keepercli/commands/audit_report.py @@ -14,6 +14,7 @@ syslog_templates: Optional[Dict[str, str]] = None + def load_syslog_templates(auth: keeper_auth.KeeperAuth) -> None: global syslog_templates if syslog_templates is None: @@ -53,6 +54,11 @@ def get_event_message(event: Dict[str, Any]) -> str: class EnterpriseAuditReport(base.ArgparseCommand): def __init__(self): parser = argparse.ArgumentParser(prog='audit-report', parents=[base.report_output_parser], description='Run an audit trail report.') + EnterpriseAuditReport.add_arguments_to_parser(parser) + super().__init__(parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser): parser.add_argument('--syntax-help', dest='syntax_help', action='store_true', help='display help') parser.add_argument('--report-type', dest='report_type', action='store', choices=['raw', 'dim', 'hour', 'day', 'week', 'month', 'span'], @@ -89,17 +95,19 @@ def __init__(self): help='Filter: Geo location') parser.add_argument('--device-type', dest='device_type', action='store', help='Filter: Device type') - super().__init__(parser) - + def execute(self, context: KeeperParams, **kwargs) -> Any: base.require_login(context) base.require_enterprise_admin(context) + auth = context.auth + enterprise_data = context.enterprise_data + report_type = kwargs.get('report_type') if kwargs.get('syntax_help') is True or not report_type: prompt_utils.output_text(audit_report_description) if not report_type: - dim_report = audit_report.DimAuditReport(context.auth) + dim_report = audit_report.DimAuditReport(auth) events = dim_report.execute_dimension_report('audit_event_type') table = [] for event in events: @@ -110,7 +118,7 @@ def execute(self, context: KeeperParams, **kwargs) -> Any: return has_aram = False - keeper_license = next(iter(context.enterprise_data.licenses.get_all_entities()), None) + keeper_license = next(iter(enterprise_data.licenses.get_all_entities()), None) if keeper_license and keeper_license.add_ons: has_aram = any((True for x in keeper_license.add_ons if x.name.lower() == 'enterprise_audit_and_reporting')) @@ -125,7 +133,7 @@ def execute(self, context: KeeperParams, **kwargs) -> Any: raise base.CommandError('"dim" reports expect one "columns" parameter') column = columns[0] - dimension = EnterpriseAuditReport.load_audit_dimension(context.auth, column) + dimension = EnterpriseAuditReport.load_audit_dimension(auth, column) if dimension: table = [] if column == 'audit_event_type': @@ -150,9 +158,9 @@ def execute(self, context: KeeperParams, **kwargs) -> Any: return report_utils.dump_report_data(table, fields, fmt=fmt, filename=kwargs.get('output')) elif report_type == 'raw': - raw_report = audit_report.RawAuditReport(context.auth) + raw_report = audit_report.RawAuditReport(auth) if has_aram: - raw_report.filter = self.get_report_filter(context.auth, **kwargs) + raw_report.filter = self.get_report_filter(auth, **kwargs) limit = kwargs.get('limit') if isinstance(limit, int): raw_report.limit = limit @@ -174,7 +182,7 @@ def execute(self, context: KeeperParams, **kwargs) -> Any: misc_fields: Set[str] = set() if report_format == 'message': fields.append('message') - load_syslog_templates(context.auth) + load_syslog_templates(auth) else: misc_fields.update(MISC_FIELDS) @@ -198,9 +206,9 @@ def execute(self, context: KeeperParams, **kwargs) -> Any: else: if not has_aram: raise base.CommandError('Audit Reporting addon is not enabled') - summary_report = audit_report.SummaryAuditReport(context.auth) + summary_report = audit_report.SummaryAuditReport(auth) summary_report.summary_type = report_type - summary_report.filter = self.get_report_filter(context.auth, **kwargs) + summary_report.filter = self.get_report_filter(auth, **kwargs) limit = kwargs.get('limit') if isinstance(limit, int): if not (0 <= limit <= 2000): @@ -265,7 +273,6 @@ def get_field_value(field: str, value: Any, *, report_type: str = 'raw') -> Any: return dt return value - @staticmethod def convert_date_filter(value): if isinstance(value, datetime.datetime): @@ -575,7 +582,6 @@ def load_audit_dimension(auth: keeper_auth.KeeperAuth, dimension) -> Optional[Li Example: "Commander", "Web App, 16.3.4" audit-report --report-type=dim --columns=device_type ''' -in_pattern = re.compile(r"\s*in\s*\(\s*(.*)\s*\)", re.IGNORECASE) between_pattern = re.compile(r"\s*between\s+(\S*)\s+and\s+(.*)", re.IGNORECASE) RAW_FIELDS = ('created', 'audit_event_type', 'username', 'ip_address', 'keeper_version', 'geo_location') diff --git a/keepercli-package/src/keepercli/register_commands.py b/keepercli-package/src/keepercli/register_commands.py index 1463a9e1..1e3facdb 100644 --- a/keepercli-package/src/keepercli/register_commands.py +++ b/keepercli-package/src/keepercli/register_commands.py @@ -89,7 +89,7 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS if not scopes or bool(scopes & base.CommandScope.Enterprise): from .commands import (enterprise_info, enterprise_node, enterprise_role, enterprise_team, enterprise_user, enterprise_create_user, importer_commands, audit_report, audit_alert, audit_log, transfer_account, pedm_admin, msp, user_report, - security_audit_report) + aging_report, action_report, security_audit_report) from .commands.pam import keeper_pam commands.register_command('create-user', enterprise_create_user.CreateEnterpriseUserCommand(), base.CommandScope.Enterprise, 'ecu') @@ -111,4 +111,6 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS commands.register_command('team-approve', enterprise_team.TeamApproveCommand(), base.CommandScope.Enterprise) commands.register_command('user-report', user_report.UserReportCommand(), base.CommandScope.Enterprise, 'ur') commands.register_command('security-audit-report', security_audit_report.SecurityAuditReportCommand(), base.CommandScope.Enterprise, 'sar') + commands.register_command('aging-report', aging_report.AgingReportCommand(), base.CommandScope.Enterprise, 'ar') + commands.register_command('action-report', action_report.ActionReportCommand(), base.CommandScope.Enterprise, 'acr') commands.register_command('pam', keeper_pam.PAMControllerCommand(), base.CommandScope.Enterprise) diff --git a/keepersdk-package/src/keepersdk/enterprise/action_report.py b/keepersdk-package/src/keepersdk/enterprise/action_report.py new file mode 100644 index 00000000..cc5bd7db --- /dev/null +++ b/keepersdk-package/src/keepersdk/enterprise/action_report.py @@ -0,0 +1,416 @@ +"""Enterprise action report functionality for Keeper SDK.""" + +import dataclasses +import datetime +import logging +from collections import defaultdict, deque +from typing import Any, Dict, List, Optional, Set, Tuple + +from ..authentication import keeper_auth +from . import enterprise_types + +API_EVENT_SUMMARY_ROW_LIMIT = 2000 +DEFAULT_DAYS_SINCE = 30 +LOCKED_DEFAULT_DAYS = 90 + +logger = logging.getLogger(__name__) + + +class TargetUserStatus: + NO_LOGON = 'no-logon' + NO_UPDATE = 'no-update' + LOCKED = 'locked' + INVITED = 'invited' + NO_RECOVERY = 'no-recovery' + + +class AdminAction: + NONE = 'none' + LOCK = 'lock' + DELETE = 'delete' + TRANSFER = 'transfer' + + +STATUS_EVENT_TYPES: Dict[str, List[str]] = { + TargetUserStatus.NO_LOGON: ['login', 'login_console', 'chat_login', 'accept_invitation'], + TargetUserStatus.NO_UPDATE: ['record_add', 'record_update'], + TargetUserStatus.LOCKED: ['lock_user'], + TargetUserStatus.INVITED: ['send_invitation', 'auto_invite_user'], + TargetUserStatus.NO_RECOVERY: ['change_security_question', 'account_recovery_setup'], +} + + +@dataclasses.dataclass +class ActionReportEntry: + enterprise_user_id: int + email: str + full_name: str = '' + status: str = '' + transfer_status: str = '' + node_path: str = '' + roles: Optional[List[str]] = None + teams: Optional[List[str]] = None + tfa_enabled: bool = False + + +@dataclasses.dataclass +class ActionReportConfig: + target_user_status: str = TargetUserStatus.NO_LOGON + days_since: Optional[int] = None + node_name: Optional[str] = None + apply_action: str = AdminAction.NONE + target_user: Optional[str] = None + dry_run: bool = False + force: bool = False + + +@dataclasses.dataclass +class ActionResult: + action: str + status: str + affected_count: int = 0 + server_message: str = 'n/a' + + def to_text(self) -> str: + return (f'\tCOMMAND: {self.action}\n' + f'\tSTATUS: {self.status}\n' + f'\tSERVER MESSAGE: {self.server_message}\n' + f'\tAFFECTED: {self.affected_count}') + + +class ActionReportGenerator: + def __init__( + self, + enterprise_data: enterprise_types.IEnterpriseData, + auth: keeper_auth.KeeperAuth, + config: Optional[ActionReportConfig] = None + ) -> None: + self._enterprise_data = enterprise_data + self._auth = auth + self._config = config or ActionReportConfig() + self._user_teams: Optional[Dict[int, Set[str]]] = None + self._user_roles: Optional[Dict[int, Set[int]]] = None + self._team_roles: Optional[Dict[str, Set[int]]] = None + self._node_children: Optional[Dict[int, Set[int]]] = None + + @property + def enterprise_data(self) -> enterprise_types.IEnterpriseData: + return self._enterprise_data + + @property + def config(self) -> ActionReportConfig: + return self._config + + def _get_days_since(self) -> int: + if self._config.days_since is not None: + return self._config.days_since + if self._config.target_user_status == TargetUserStatus.LOCKED: + return LOCKED_DEFAULT_DAYS + return DEFAULT_DAYS_SINCE + + def _build_user_teams_lookup(self) -> Dict[int, Set[str]]: + if self._user_teams is not None: + return self._user_teams + + self._user_teams = defaultdict(set) + for team_user in self._enterprise_data.team_users.get_all_links(): + self._user_teams[team_user.enterprise_user_id].add(team_user.team_uid) + return self._user_teams + + def _build_user_roles_lookup(self) -> Dict[int, Set[int]]: + if self._user_roles is not None: + return self._user_roles + + self._user_roles = defaultdict(set) + for role_user in self._enterprise_data.role_users.get_all_links(): + self._user_roles[role_user.enterprise_user_id].add(role_user.role_id) + return self._user_roles + + def _build_team_roles_lookup(self) -> Dict[str, Set[int]]: + if self._team_roles is not None: + return self._team_roles + + self._team_roles = defaultdict(set) + for role_team in self._enterprise_data.role_teams.get_all_links(): + self._team_roles[role_team.team_uid].add(role_team.role_id) + return self._team_roles + + def _build_node_children_lookup(self) -> Dict[int, Set[int]]: + if self._node_children is not None: + return self._node_children + + self._node_children = defaultdict(set) + for node in self._enterprise_data.nodes.get_all_entities(): + if node.parent_id: + self._node_children[node.parent_id].add(node.node_id) + return self._node_children + + def _get_descendant_nodes(self, node_id: int) -> Set[int]: + children_lookup = self._build_node_children_lookup() + descendants = {node_id} + queue = deque([node_id]) + + while queue: + current_id = queue.popleft() + child_ids = children_lookup.get(current_id, set()) + for child_id in child_ids: + if child_id not in descendants: + descendants.add(child_id) + queue.append(child_id) + + return descendants + + def _resolve_node(self, node_name: str) -> Optional[enterprise_types.Node]: + if not node_name: + return None + + if node_name.isnumeric(): + node = self._enterprise_data.nodes.get_entity(int(node_name)) + if node: + return node + + node_name_lower = node_name.lower() + if node_name_lower == self._enterprise_data.enterprise_info.enterprise_name.lower(): + return self._enterprise_data.root_node + + matching_nodes = [ + n for n in self._enterprise_data.nodes.get_all_entities() + if n.name and n.name.lower() == node_name_lower + ] + + if len(matching_nodes) == 1: + return matching_nodes[0] + elif len(matching_nodes) > 1: + logger.warning(f'More than one node "{node_name}" found. Use Node ID.') + return None + + return None + + def _get_user_role_ids(self, user_id: int) -> Set[int]: + user_roles = self._build_user_roles_lookup() + user_teams = self._build_user_teams_lookup() + team_roles = self._build_team_roles_lookup() + + role_ids = set(user_roles.get(user_id, set())) + for team_uid in user_teams.get(user_id, set()): + role_ids.update(team_roles.get(team_uid, set())) + + return role_ids + + def _get_user_team_names(self, user_id: int) -> List[str]: + user_teams = self._build_user_teams_lookup() + team_names = [] + for team_uid in user_teams.get(user_id, set()): + team = self._enterprise_data.teams.get_entity(team_uid) + if team: + team_names.append(team.name) + return sorted(team_names, key=str.lower) + + def _get_user_role_names(self, user_id: int) -> List[str]: + role_names = [] + for role_id in self._get_user_role_ids(user_id): + role = self._enterprise_data.roles.get_entity(role_id) + if role: + role_names.append(role.name) + return sorted(role_names, key=str.lower) + + @staticmethod + def get_node_path( + enterprise_data: enterprise_types.IEnterpriseData, + node_id: int, + omit_root: bool = False + ) -> str: + nodes: List[str] = [] + n_id = node_id + while isinstance(n_id, int) and n_id > 0: + node = enterprise_data.nodes.get_entity(n_id) + if not node: + break + n_id = node.parent_id or 0 + if not omit_root or n_id > 0: + node_name = node.name + if not node_name and node.node_id == enterprise_data.root_node.node_id: + node_name = enterprise_data.enterprise_info.enterprise_name + nodes.append(node_name) + nodes.reverse() + return '\\'.join(nodes) + + @staticmethod + def get_user_status_text(user: enterprise_types.User) -> str: + if user.status == 'invited': + return 'Invited' + if user.lock > 0: + return 'Locked' if user.lock == 1 else 'Disabled' + return 'Active' + + @staticmethod + def get_user_transfer_status_text(user: enterprise_types.User) -> str: + transfer_status = user.transfer_acceptance_status + if transfer_status is not None: + status_map = {1: '', 2: 'Not accepted', 3: 'Partially accepted', 4: 'Transfer accepted'} + if transfer_status in status_map: + return status_map[transfer_status] + + if isinstance(user.account_share_expiration, int) and user.account_share_expiration > 0: + expire_at = datetime.datetime.fromtimestamp(user.account_share_expiration / 1000.0) + if expire_at < datetime.datetime.now(): + return 'Blocked' + return 'Pending Transfer' + return '' + + def _get_users_by_status(self) -> Tuple[List[enterprise_types.User], List[enterprise_types.User], List[enterprise_types.User]]: + active = [] + locked = [] + invited = [] + + for user in self._enterprise_data.users.get_all_entities(): + if user.status == 'invited': + invited.append(user) + elif user.status == 'active': + if user.lock > 0: + locked.append(user) + else: + active.append(user) + + return active, locked, invited + + def _filter_users_by_node(self, users: List[enterprise_types.User]) -> List[enterprise_types.User]: + node_name = self._config.node_name + if not node_name: + return users + + node = self._resolve_node(node_name) + if not node: + logger.warning(f'Node "{node_name}" not found') + return [] + + target_nodes = self._get_descendant_nodes(node.node_id) + return [u for u in users if u.node_id in target_nodes] + + def _query_users_with_events( + self, + usernames: Set[str], + event_types: List[str], + days_since: int, + username_field: str = 'username' + ) -> Set[str]: + if not usernames: + return set() + + now = datetime.datetime.now(tz=datetime.timezone.utc) + min_dt = now - datetime.timedelta(days=days_since) + + users_with_events: Set[str] = set() + limit = API_EVENT_SUMMARY_ROW_LIMIT + username_list = list(usernames) + + while username_list: + batch = username_list[:limit] + username_list = username_list[limit:] + + report_filter: Dict[str, Any] = { + 'audit_event_type': event_types, + 'created': {'min': int(min_dt.timestamp())}, + username_field: batch + } + + rq = { + 'command': 'get_audit_event_reports', + 'report_type': 'span', + 'scope': 'enterprise', + 'aggregate': ['last_created'], + 'columns': [username_field], + 'filter': report_filter, + 'limit': limit + } + + try: + rs = self._auth.execute_auth_command(rq) + for event in rs.get('audit_event_overview_report_rows', []): + username = event.get(username_field, '').lower() + if username: + users_with_events.add(username) + except Exception as e: + logger.debug(f'Error querying audit events: {e}') + + return users_with_events + + def _get_target_users(self) -> List[enterprise_types.User]: + active, locked, invited = self._get_users_by_status() + active = self._filter_users_by_node(active) + locked = self._filter_users_by_node(locked) + invited = self._filter_users_by_node(invited) + + target_status = self._config.target_user_status + days_since = self._get_days_since() + + if target_status == TargetUserStatus.NO_LOGON: + candidates = active + event_types = STATUS_EVENT_TYPES[TargetUserStatus.NO_LOGON] + username_field = 'username' + elif target_status == TargetUserStatus.NO_UPDATE: + candidates = active + event_types = STATUS_EVENT_TYPES[TargetUserStatus.NO_UPDATE] + username_field = 'username' + elif target_status == TargetUserStatus.LOCKED: + candidates = locked + event_types = STATUS_EVENT_TYPES[TargetUserStatus.LOCKED] + username_field = 'to_username' + elif target_status == TargetUserStatus.INVITED: + candidates = invited + event_types = STATUS_EVENT_TYPES[TargetUserStatus.INVITED] + username_field = 'email' + elif target_status == TargetUserStatus.NO_RECOVERY: + candidates = active + event_types = STATUS_EVENT_TYPES[TargetUserStatus.NO_RECOVERY] + username_field = 'username' + else: + logger.warning(f'Invalid target_user_status: {target_status}') + return [] + + if not candidates: + return [] + + candidate_usernames = {u.username.lower() for u in candidates} + users_with_actions = self._query_users_with_events( + candidate_usernames, event_types, days_since, username_field + ) + return [u for u in candidates if u.username.lower() not in users_with_actions] + + def generate_report(self) -> List[ActionReportEntry]: + target_users = self._get_target_users() + + report_entries: List[ActionReportEntry] = [] + + for user in target_users: + entry = ActionReportEntry( + enterprise_user_id=user.enterprise_user_id, + email=user.username, + full_name=user.full_name or '', + status=self.get_user_status_text(user), + transfer_status=self.get_user_transfer_status_text(user), + node_path=self.get_node_path(self._enterprise_data, user.node_id, omit_root=False), + tfa_enabled=user.tfa_enabled + ) + + entry.roles = self._get_user_role_names(user.enterprise_user_id) + entry.teams = self._get_user_team_names(user.enterprise_user_id) + + report_entries.append(entry) + + report_entries.sort(key=lambda x: x.email.lower()) + return report_entries + + @staticmethod + def get_allowed_actions(target_status: str) -> Set[str]: + default_allowed = {AdminAction.NONE} + + status_actions = { + TargetUserStatus.NO_LOGON: {*default_allowed, AdminAction.LOCK}, + TargetUserStatus.NO_UPDATE: default_allowed, + TargetUserStatus.LOCKED: {*default_allowed, AdminAction.DELETE, AdminAction.TRANSFER}, + TargetUserStatus.INVITED: {*default_allowed, AdminAction.DELETE}, + TargetUserStatus.NO_RECOVERY: default_allowed, + } + + return status_actions.get(target_status, default_allowed) diff --git a/keepersdk-package/src/keepersdk/enterprise/aging_report.py b/keepersdk-package/src/keepersdk/enterprise/aging_report.py new file mode 100644 index 00000000..7c42bf9d --- /dev/null +++ b/keepersdk-package/src/keepersdk/enterprise/aging_report.py @@ -0,0 +1,557 @@ +"""Enterprise password aging report functionality for Keeper SDK.""" + +import dataclasses +import datetime +import json +import logging +import os +import traceback +from typing import Optional, List, Dict, Any, Iterable, Tuple + +from ..authentication import keeper_auth +from ..proto import enterprise_pb2 +from .. import crypto, utils +from . import enterprise_types +from ..vault import vault_online + + +API_EVENT_SUMMARY_ROW_LIMIT = 1000 +DEFAULT_PERIOD_DAYS = 90 +SEARCH_HISTORY_YEARS = 5 +MAX_PAGINATION_ITERATIONS = 10000 # Safety limit to prevent infinite loops +logger = logging.getLogger(__name__) + + +@dataclasses.dataclass +class AgingReportEntry: + """Represents a single record entry in the aging report.""" + record_uid: str + owner_email: str + title: str = '' + last_changed: Optional[datetime.datetime] = None + record_created: Optional[datetime.datetime] = None + shared: bool = False + record_url: str = '' + shared_folder_uid: Optional[List[str]] = None + in_trash: bool = False + + +@dataclasses.dataclass +class AgingReportConfig: + """Configuration for aging report generation.""" + period_days: int = DEFAULT_PERIOD_DAYS + cutoff_date: Optional[datetime.datetime] = None + username: Optional[str] = None + exclude_deleted: bool = False + in_shared_folder: bool = False + rebuild: bool = False + delete_cache: bool = False + no_cache: bool = False + server: str = 'keepersecurity.com' + + +class AgingReportGenerator: + """Generates password aging reports for enterprise records. + + This class identifies records whose passwords have not been changed + within a specified period. It fetches data from the compliance API + and falls back to audit events if needed. + + Usage: + generator = AgingReportGenerator(enterprise_data, auth, config) + entries = generator.generate_report() + """ + + def __init__( + self, + enterprise_data: enterprise_types.IEnterpriseData, + auth: keeper_auth.KeeperAuth, + config: Optional[AgingReportConfig] = None, + vault: Optional[vault_online.VaultOnline] = None + ) -> None: + self._enterprise_data = enterprise_data + self._auth = auth + self._config = config or AgingReportConfig() + self._vault = vault + self._email_to_user_id: Optional[Dict[str, int]] = None + self._user_id_to_email: Optional[Dict[int, str]] = None + self._records: Dict[str, Dict[str, Any]] = {} + self._record_shared_folders: Dict[str, List[str]] = {} + + @property + def enterprise_data(self) -> enterprise_types.IEnterpriseData: + return self._enterprise_data + + @property + def config(self) -> AgingReportConfig: + return self._config + + def get_cache_file_path(self, enterprise_id: int) -> str: + """Get the path to the local cache database file.""" + home_dir = os.path.expanduser('~') + cache_dir = os.path.join(home_dir, '.keeper') + if not os.path.exists(cache_dir): + os.makedirs(cache_dir) + return os.path.join(cache_dir, f'aging_cache_{enterprise_id}.db') + + def delete_local_cache(self, enterprise_id: int) -> bool: + """Delete the local database cache file.""" + cache_file = self.get_cache_file_path(enterprise_id) + if os.path.isfile(cache_file): + os.remove(cache_file) + return True + return False + + def _get_cutoff_timestamp(self) -> int: + """Get the cutoff timestamp based on config.""" + if self._config.cutoff_date: + return int(self._config.cutoff_date.timestamp()) + else: + now = datetime.datetime.now() + cutoff = now - datetime.timedelta(days=self._config.period_days) + return int(cutoff.timestamp()) + + def _build_user_lookups(self) -> None: + """Build lookups between email and enterprise_user_id.""" + if self._email_to_user_id is None: + self._email_to_user_id = {} + self._user_id_to_email = {} + for user in self._enterprise_data.users.get_all_entities(): + email = user.username.lower() + user_id = user.enterprise_user_id + self._email_to_user_id[email] = user_id + self._user_id_to_email[user_id] = email + + def _get_target_username(self) -> Optional[str]: + """Get normalized target username for filtering.""" + if not self._config.username: + return None + return self._config.username.lower() + + def _get_search_min_timestamp(self) -> int: + """Get minimum timestamp for historical searches.""" + return int((datetime.datetime.now() - datetime.timedelta(days=365 * SEARCH_HISTORY_YEARS)).timestamp()) + + def _fetch_paginated_audit_events( + self, + event_types: List[str], + report_type: str = 'raw' + ) -> Iterable[Dict[str, Any]]: + """Fetch paginated audit events. Yields individual events.""" + search_min_ts = self._get_search_min_timestamp() + audit_filter: Dict[str, Any] = { + 'audit_event_type': event_types, + 'created': {'min': search_min_ts} + } + + rq: Dict[str, Any] = { + 'command': 'get_audit_event_reports', + 'scope': 'enterprise', + 'report_type': report_type, + 'filter': audit_filter, + 'limit': API_EVENT_SUMMARY_ROW_LIMIT, + 'order': 'ascending' + } + + if report_type == 'span': + rq['columns'] = ['record_uid', 'audit_event_type'] + rq['aggregate'] = ['last_created'] + del rq['order'] + + last_ts = search_min_ts + iteration_count = 0 + + while True: + iteration_count += 1 + if iteration_count > MAX_PAGINATION_ITERATIONS: + logger.warning(f'Reached maximum pagination iterations ({MAX_PAGINATION_ITERATIONS}). Stopping to prevent infinite loop.') + break + + try: + rs = self._auth.execute_auth_command(rq) + events = rs.get('audit_event_overview_report_rows', []) + + if not events: + break + + for event in events: + yield event + ts_field = 'last_created' if report_type == 'span' else 'created' + last_ts = max(last_ts, int(event.get(ts_field, 0))) + + if len(events) < API_EVENT_SUMMARY_ROW_LIMIT: + break + + if report_type == 'span': + audit_filter['created']['max'] = last_ts + 1 + else: + audit_filter['created'] = {'min': last_ts} + except Exception as e: + logger.debug(f'Error fetching audit events: {e}') + break + + def get_record_sfs(self, record_uid: str) -> List[str]: + """Get list of shared folder UIDs where the record exists.""" + return self._record_shared_folders.get(record_uid, []) + + def _get_ec_private_key(self) -> Optional[bytes]: + """Get the enterprise EC private key for decryption.""" + return self._enterprise_data.enterprise_info.ec_private_key + + def _decrypt_record_data(self, encrypted_data: bytes) -> Dict[str, Any]: + """Decrypt record data using EC private key.""" + if not encrypted_data: + return {} + + ec_key = self._get_ec_private_key() + if ec_key is None: + return {} + + try: + data_json = crypto.decrypt_ec(encrypted_data, ec_key) + return json.loads(data_json.decode('utf-8')) + except Exception as e: + logger.debug(f'Failed to decrypt record data: {e}') + return {} + + def _fetch_compliance_data(self, user_ids: Optional[List[int]] = None) -> None: + """Fetch record data from compliance API.""" + if user_ids is None: + user_ids = [u.enterprise_user_id for u in self._enterprise_data.users.get_all_entities()] + + if not user_ids: + logger.warning('No enterprise users found') + return + + rq = enterprise_pb2.PreliminaryComplianceDataRequest() + rq.includeNonShared = True + rq.includeTotalMatchingRecordsInFirstResponse = True + for uid in user_ids: + rq.enterpriseUserIds.append(uid) + + has_more = True + continuation_token = None + + while has_more: + if continuation_token: + rq.continuationToken = continuation_token + + try: + rs = self._auth.execute_auth_rest( + 'enterprise/get_preliminary_compliance_data', + rq, + response_type=enterprise_pb2.PreliminaryComplianceDataResponse + ) + + for user_data in rs.auditUserData: + user_id = user_data.enterpriseUserId + owner_email = self._user_id_to_email.get(user_id, '') + + for record in user_data.auditUserRecords: + record_uid = utils.base64_url_encode(record.recordUid) + record_data = self._decrypt_record_data(record.encryptedData) + + self._records[record_uid] = { + 'record_uid': record_uid, + 'owner_email': owner_email, + 'owner_user_id': user_id, + 'title': record_data.get('title', ''), + 'shared': record.shared, + 'created_ts': 0, + 'pw_changed_ts': 0, + 'in_trash': record_data.get('in_trash', False) + } + + has_more = rs.hasMore and rs.continuationToken + if has_more: + continuation_token = rs.continuationToken + + except Exception as e: + logger.warning(f'Error fetching compliance data: {e}') + logger.debug(traceback.format_exc()) + break + + logger.debug(f'Fetched {len(self._records)} records from compliance API') + + def _update_timestamps_from_audit_events(self) -> None: + """Update records with timestamps from audit events using span reports.""" + created_lookup: Dict[str, int] = {} + folder_add_lookup: Dict[str, int] = {} + pw_change_lookup: Dict[str, int] = {} + + event_types = ['record_add', 'record_password_change', 'folder_add_record'] + for event in self._fetch_paginated_audit_events(event_types, report_type='span'): + record_uid = event.get('record_uid', '') + if not record_uid or record_uid not in self._records: + continue + + event_type = event.get('audit_event_type', '') + event_ts = int(event.get('last_created', 0)) + + if event_type == 'record_add': + created_lookup.setdefault(record_uid, event_ts) + elif event_type == 'folder_add_record': + folder_add_lookup[record_uid] = event_ts + elif event_type == 'record_password_change': + if event_ts > pw_change_lookup.get(record_uid, 0): + pw_change_lookup[record_uid] = event_ts + + for record_uid, ts in folder_add_lookup.items(): + created_lookup.setdefault(record_uid, ts) + + if self._config.in_shared_folder: + self._fetch_shared_folder_mappings() + + if self._config.exclude_deleted: + self._fetch_deleted_records() + + for record_uid, ts in created_lookup.items(): + if record_uid in self._records: + rec = self._records[record_uid] + if rec['created_ts'] == 0 or ts < rec['created_ts']: + rec['created_ts'] = ts + + for record_uid, ts in pw_change_lookup.items(): + if record_uid in self._records: + self._records[record_uid]['pw_changed_ts'] = ts + + def _fetch_deleted_records(self) -> None: + """Fetch deleted records from audit events for --exclude-deleted filtering.""" + for event in self._fetch_paginated_audit_events(['record_delete']): + record_uid = event.get('record_uid', '') + if record_uid and record_uid in self._records: + self._records[record_uid]['in_trash'] = True + + def _fetch_shared_folder_mappings(self) -> None: + """Fetch shared folder mappings for --in-shared-folder filtering.""" + for event in self._fetch_paginated_audit_events(['folder_add_record']): + record_uid = event.get('record_uid', '') + shared_folder_uid = event.get('shared_folder_uid', '') + + if record_uid and shared_folder_uid: + if record_uid not in self._record_shared_folders: + self._record_shared_folders[record_uid] = [] + if shared_folder_uid not in self._record_shared_folders[record_uid]: + self._record_shared_folders[record_uid].append(shared_folder_uid) + + def _fetch_records_from_audit_events(self) -> None: + """Fallback: Fetch records from audit events if compliance API fails.""" + for event in self._fetch_paginated_audit_events(['record_add', 'folder_add_record']): + record_uid = event.get('record_uid', '') + if not record_uid: + continue + + event_ts = int(event.get('created', 0)) + username = event.get('username', '') + event_type = event.get('audit_event_type', '') + shared_folder_uid = event.get('shared_folder_uid', '') + + if record_uid not in self._records: + self._records[record_uid] = { + 'record_uid': record_uid, + 'owner_email': username, + 'owner_user_id': 0, + 'title': '', + 'shared': bool(shared_folder_uid), + 'created_ts': event_ts if event_type == 'record_add' else 0, + 'pw_changed_ts': 0, + 'in_trash': False + } + else: + rec = self._records[record_uid] + if event_type == 'record_add' and event_ts > 0: + if rec['created_ts'] == 0 or event_ts < rec['created_ts']: + rec['created_ts'] = event_ts + if username: + rec['owner_email'] = username + if shared_folder_uid: + rec['shared'] = True + if rec['created_ts'] == 0 and event_ts > 0: + rec['created_ts'] = event_ts + + logger.debug(f'Fetched {len(self._records)} records from audit events') + + def _get_record_title_from_vault(self, record_uid: str) -> str: + """Try to get record title from vault.""" + if self._vault is None: + return '' + try: + vault_data = self._vault.vault_data + if vault_data: + record = vault_data.get_record(record_uid) + if record: + return record.title or '' + except Exception: + pass + return '' + + def _enrich_titles_from_vault(self) -> None: + """Enrich records missing titles from vault data.""" + if self._vault is None: + return + for record_uid, data in self._records.items(): + if not data.get('title'): + title = self._get_record_title_from_vault(record_uid) + if title: + data['title'] = title + + def generate_report(self) -> List[AgingReportEntry]: + """Generate the password aging report.""" + cutoff_ts = self._get_cutoff_timestamp() + target_username = self._get_target_username() + + self._build_user_lookups() + + if target_username and target_username not in self._email_to_user_id: + return [] + + user_ids = None + if target_username: + user_id = self._email_to_user_id.get(target_username) + if user_id: + user_ids = [user_id] + + self._fetch_compliance_data(user_ids) + + if not self._records: + self._fetch_records_from_audit_events() + + self._update_timestamps_from_audit_events() + self._enrich_titles_from_vault() + + report_entries: List[AgingReportEntry] = [] + + for record_uid, data in self._records.items(): + owner_email = data.get('owner_email', '') + + if target_username and owner_email.lower() != target_username: + continue + + if self._config.exclude_deleted and data.get('in_trash'): + continue + + record_sfs = self.get_record_sfs(record_uid) + if self._config.in_shared_folder and not record_sfs: + continue + + created_ts = data.get('created_ts', 0) + pw_changed_ts = data.get('pw_changed_ts', 0) + + if (created_ts and created_ts >= cutoff_ts) or (pw_changed_ts and pw_changed_ts >= cutoff_ts): + continue + + ts = pw_changed_ts or created_ts + change_dt = datetime.datetime.fromtimestamp(ts) if ts else None + created_dt = datetime.datetime.fromtimestamp(created_ts) if created_ts else None + + entry = AgingReportEntry( + record_uid=record_uid, + owner_email=owner_email, + title=data.get('title', ''), + last_changed=change_dt, + record_created=created_dt, + shared=data.get('shared', False), + record_url=f'https://{self._config.server}/vault/#detail/{record_uid}', + shared_folder_uid=record_sfs or None, + in_trash=data.get('in_trash', False) + ) + report_entries.append(entry) + + report_entries.sort(key=self._sort_key) + return report_entries + + @staticmethod + def _sort_key(entry: AgingReportEntry) -> Tuple[int, float]: + """Sort key for report entries by date.""" + if entry.last_changed: + return (0, entry.last_changed.timestamp()) + if entry.record_created: + return (1, entry.record_created.timestamp()) + return (2, 0) + + def cleanup(self, enterprise_id: int) -> None: + """Clean up cache if no_cache option is set.""" + if self._config.no_cache: + self.delete_local_cache(enterprise_id) + + def generate_report_rows(self, include_shared_folder: bool = False) -> Iterable[List[Any]]: + """Generate report rows for tabular output.""" + for entry in self.generate_report(): + row = [entry.owner_email, entry.title, entry.last_changed, entry.shared, entry.record_url] + if include_shared_folder: + row.append(entry.shared_folder_uid or '') + yield row + + @staticmethod + def get_headers(include_shared_folder: bool = False) -> List[str]: + """Get column headers for the report.""" + headers = ['owner', 'title', 'password_changed', 'shared', 'record_url'] + if include_shared_folder: + headers.append('shared_folder_uid') + return headers + + +def parse_period(period_str: str) -> Optional[int]: + """Parse period string (e.g., '3m', '10d', '1y') to days. + + Args: + period_str: Period string with format '' where unit is: + - 'd' for days + - 'm' for months (30 days) + - 'y' for years (365 days) + + Returns: + Number of days, or None if parsing fails. + """ + if not period_str or len(period_str.strip()) < 2: + return None + + period_str = period_str.strip().lower() + unit = period_str[-1] + + try: + value = abs(int(period_str[:-1])) + except ValueError: + return None + + multipliers = {'d': 1, 'm': 30, 'y': 365} + multiplier = multipliers.get(unit) + if multiplier is None: + return None + + return value * multiplier + + +def parse_date(date_str: str) -> Optional[datetime.datetime]: + """Parse date string in various formats.""" + formats = ['%Y-%m-%d', '%Y.%m.%d', '%Y/%m/%d', '%m-%d-%Y', '%m.%d.%Y', '%m/%d/%Y'] + for fmt in formats: + try: + return datetime.datetime.strptime(date_str, fmt) + except ValueError: + continue + return None + + +def generate_aging_report( + enterprise_data: enterprise_types.IEnterpriseData, + auth: keeper_auth.KeeperAuth, + period_days: int = DEFAULT_PERIOD_DAYS, + cutoff_date: Optional[datetime.datetime] = None, + username: Optional[str] = None, + exclude_deleted: bool = False, + in_shared_folder: bool = False, + rebuild: bool = False, + server: str = 'keepersecurity.com' +) -> List[AgingReportEntry]: + """Convenience function to generate an aging report.""" + config = AgingReportConfig( + period_days=period_days, + cutoff_date=cutoff_date, + username=username, + exclude_deleted=exclude_deleted, + in_shared_folder=in_shared_folder, + rebuild=rebuild, + server=server + ) + return AgingReportGenerator(enterprise_data, auth, config).generate_report() diff --git a/keepersdk-package/src/keepersdk/enterprise/audit_report.py b/keepersdk-package/src/keepersdk/enterprise/audit_report.py index d8e7ed5d..4612d578 100644 --- a/keepersdk-package/src/keepersdk/enterprise/audit_report.py +++ b/keepersdk-package/src/keepersdk/enterprise/audit_report.py @@ -272,7 +272,6 @@ def execute_audit_report(self) -> Iterable[dict]: SUMMARY_REPORTS = ('hour', 'day', 'week', 'month', 'span') -AGGREGATES = ('occurrences', 'first_created', 'last_created') class SummaryAuditReport(AuditReportCommon): @@ -286,6 +285,7 @@ def __init__(self, auth: keeper_auth.KeeperAuth): @property def summary_type(self) -> str: return self._summary_type + @summary_type.setter def summary_type(self, value: str): if value in SUMMARY_REPORTS: From 26f42c5176780a3664e0c4d82fab9cc0e10cfcc9 Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Thu, 15 Jan 2026 21:58:25 -0800 Subject: [PATCH 28/37] move command fix --- keepersdk-package/src/keepersdk/vault/record_management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keepersdk-package/src/keepersdk/vault/record_management.py b/keepersdk-package/src/keepersdk/vault/record_management.py index 6426db59..2c87b566 100644 --- a/keepersdk-package/src/keepersdk/vault/record_management.py +++ b/keepersdk-package/src/keepersdk/vault/record_management.py @@ -610,7 +610,7 @@ def prepare_transition_keys(f: vault_types.Folder): t_record_key = crypto.encrypt_aes_v2(record_key, dst_encryption_key) else: t_record_key = crypto.encrypt_aes_v1(record_key, dst_encryption_key) - transition_keys[f.folder_uid] = utils.base64_url_encode(t_record_key) + transition_keys[record_uid] = utils.base64_url_encode(t_record_key) vault_utils.traverse_folder_tree(vault.vault_data, folder, prepare_transition_keys) parent_folder = vault.vault_data.get_folder(folder.parent_uid) if folder.parent_uid else vault.vault_data.root_folder assert parent_folder is not None From 9a1b3028280c69a5c82fad01a1bc6d9deccdc079 Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Mon, 19 Jan 2026 11:34:04 +0530 Subject: [PATCH 29/37] 2fa commands added --- .../src/keepercli/commands/enterprise_user.py | 71 +-- .../src/keepercli/commands/two_fa.py | 593 ++++++++++++++++++ .../src/keepercli/register_commands.py | 3 +- .../keepersdk/authentication/two_fa_utils.py | 76 +++ .../src/keepersdk/authentication/yubikey.py | 96 ++- 5 files changed, 793 insertions(+), 46 deletions(-) create mode 100644 keepercli-package/src/keepercli/commands/two_fa.py create mode 100644 keepersdk-package/src/keepersdk/authentication/two_fa_utils.py diff --git a/keepercli-package/src/keepercli/commands/enterprise_user.py b/keepercli-package/src/keepercli/commands/enterprise_user.py index b1408ffd..67f08cef 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_user.py +++ b/keepercli-package/src/keepercli/commands/enterprise_user.py @@ -875,7 +875,7 @@ def execute(self, context: KeeperParams, **kwargs) -> None: return if kwargs.get('approve') or kwargs.get('deny'): - self._process_approval_denial(context, enterprise_data, matching_devices, kwargs) + self._process_approval_denial(context, matching_devices, kwargs) else: self._display_report(enterprise_data, matching_devices, kwargs) @@ -1001,14 +1001,14 @@ def _get_trusted_ip_map(self, context: KeeperParams, emails: List[str]) -> Dict[ return ip_map - def _process_approval_denial(self, context: KeeperParams, enterprise_data, + def _process_approval_denial(self, context: KeeperParams, matching_devices: Dict[str, DeviceApprovalRequest], kwargs: Dict[str, Any]) -> None: """Process device approval or denial requests.""" approve_rq = enterprise_pb2.ApproveUserDevicesRequest() data_keys = {} if kwargs.get('approve'): - data_keys = self._collect_user_data_keys(context, enterprise_data, matching_devices) + data_keys = self._collect_user_data_keys(context, matching_devices) device_requests = self._build_device_requests(matching_devices, data_keys, kwargs) if not device_requests: @@ -1019,7 +1019,7 @@ def _process_approval_denial(self, context: KeeperParams, enterprise_data, response_type=enterprise_pb2.ApproveUserDevicesResponse) context.enterprise_loader.load() - def _collect_user_data_keys(self, context: KeeperParams, enterprise_data, + def _collect_user_data_keys(self, context: KeeperParams, matching_devices: Dict[str, DeviceApprovalRequest]) -> Dict[int, bytes]: """Collect user data keys using ECC and RSA methods.""" data_keys: Dict[int, bytes] = {} @@ -1029,23 +1029,23 @@ def _collect_user_data_keys(self, context: KeeperParams, enterprise_data, ecc_user_ids = user_ids.copy() ecc_user_ids.difference_update(data_keys.keys()) if ecc_user_ids: - ecc_keys = self._get_ecc_data_keys(context, enterprise_data, ecc_user_ids) + ecc_keys = self._get_ecc_data_keys(context, ecc_user_ids) data_keys.update(ecc_keys) # Try RSA method for remaining users (Account Transfer) rsa_user_ids = user_ids.copy() rsa_user_ids.difference_update(data_keys.keys()) if rsa_user_ids and not context.auth.auth_context.forbid_rsa: - rsa_keys = self._get_rsa_data_keys(context, enterprise_data, rsa_user_ids) + rsa_keys = self._get_rsa_data_keys(context, rsa_user_ids) data_keys.update(rsa_keys) return data_keys - def _get_ecc_data_keys(self, context: KeeperParams, enterprise_data, user_ids: Set[int]) -> Dict[int, bytes]: + def _get_ecc_data_keys(self, context: KeeperParams, user_ids: Set[int]) -> Dict[int, bytes]: """Get user data keys using ECC encryption.""" data_keys: Dict[int, bytes] = {} curve = ec.SECP256R1() - ecc_private_key = self._get_ecc_private_key(enterprise_data, curve) + ecc_private_key = context.enterprise_data.enterprise_info.ec_private_key if not ecc_private_key: return data_keys @@ -1054,43 +1054,26 @@ def _get_ecc_data_keys(self, context: KeeperParams, enterprise_data, user_ids: S data_key_rq.enterpriseUserId.extend(user_ids) data_key_rs = context.auth.execute_auth_rest( GET_ENTERPRISE_USER_DATA_KEY_ENDPOINT, data_key_rq, - response_type=APIRequest_pb2.EnterpriseUserIdDataKeyPair) + response_type=enterprise_pb2.EnterpriseUserDataKeys) - enc_data_key = data_key_rs.encryptedDataKey - if enc_data_key: - try: - ephemeral_public_key = ec.EllipticCurvePublicKey.from_encoded_point( - curve, enc_data_key[:ECC_PUBLIC_KEY_LENGTH]) - shared_key = ecc_private_key.exchange(ec.ECDH(), ephemeral_public_key) - digest = hashes.Hash(hashes.SHA256(), backend=default_backend()) - digest.update(shared_key) - enc_key = digest.finalize() - data_key = utils.crypto.decrypt_aes_v2(enc_data_key[ECC_PUBLIC_KEY_LENGTH:], enc_key) - data_keys[data_key_rs.enterpriseUserId] = data_key - except Exception as e: - logger.debug(e) + for key in data_key_rs.keys: + enc_data_key = key.userEncryptedDataKey + if enc_data_key: + try: + ephemeral_public_key = ec.EllipticCurvePublicKey.from_encoded_point( + curve, enc_data_key[:ECC_PUBLIC_KEY_LENGTH]) + shared_key = ecc_private_key.exchange(ec.ECDH(), ephemeral_public_key) + digest = hashes.Hash(hashes.SHA256(), backend=default_backend()) + digest.update(shared_key) + enc_key = digest.finalize() + data_key = utils.crypto.decrypt_aes_v2(enc_data_key[ECC_PUBLIC_KEY_LENGTH:], enc_key) + data_keys[key.enterpriseUserId] = data_key + except Exception as e: + logger.debug(e) return data_keys - def _get_ecc_private_key(self, enterprise_data, curve) -> Optional[Any]: - """Extract and decrypt the ECC private key from enterprise data.""" - if not enterprise_data.enterprise_info.keys: - return None - if not enterprise_data.enterprise_info.keys.ecc_encrypted_private_key: - return None - - try: - keys = enterprise_data.get_enterprise_data_keys() - ecc_private_key_data = utils.base64_url_decode(keys.ecc_encrypted_private_key) - ecc_private_key_data = utils.crypto.decrypt_aes_v2( - ecc_private_key_data, enterprise_data.enterprise_info.tree_key) - private_value = int.from_bytes(ecc_private_key_data, byteorder='big', signed=False) - return ec.derive_private_key(private_value, curve, default_backend()) - except Exception as e: - logger.debug(e) - return None - - def _get_rsa_data_keys(self, context: KeeperParams, enterprise_data, user_ids: Set[int]) -> Dict[int, bytes]: + def _get_rsa_data_keys(self, context: KeeperParams, user_ids: Set[int]) -> Dict[int, bytes]: """Get user data keys from Account Transfer using RSA encryption.""" data_keys: Dict[int, bytes] = {} data_key_rq = APIRequest_pb2.UserDataKeyRequest() @@ -1101,7 +1084,7 @@ def _get_rsa_data_keys(self, context: KeeperParams, enterprise_data, user_ids: S if data_key_rs.noEncryptedDataKey: user_ids_without_key = set(data_key_rs.noEncryptedDataKey) - usernames = [x.username for x in enterprise_data.users.get_all_entities() + usernames = [x.username for x in context.enterprise_data.users.get_all_entities() if x.enterprise_user_id in user_ids_without_key] if usernames: logger.info('User(s) \"%s\" have no accepted account transfers or did not share encryption key', @@ -1109,7 +1092,7 @@ def _get_rsa_data_keys(self, context: KeeperParams, enterprise_data, user_ids: S if data_key_rs.accessDenied: denied_user_ids = set(data_key_rs.accessDenied) - usernames = [x.username for x in enterprise_data.users.get_all_entities() + usernames = [x.username for x in context.enterprise_data.users.get_all_entities() if x.enterprise_user_id in denied_user_ids] if usernames: logger.info('You cannot manage these user(s): %s', ', '.join(usernames)) @@ -1117,7 +1100,7 @@ def _get_rsa_data_keys(self, context: KeeperParams, enterprise_data, user_ids: S if data_key_rs.userDataKeys: for dk in data_key_rs.userDataKeys: try: - role_key = utils.crypto.decrypt_aes_v2(dk.roleKey, enterprise_data.enterprise_info.tree_key) + role_key = utils.crypto.decrypt_aes_v2(dk.roleKey, context.enterprise_data.enterprise_info.tree_key) encrypted_private_key = utils.base64_url_decode(dk.privateKey) decrypted_private_key = utils.crypto.decrypt_aes_v1(encrypted_private_key, role_key) private_key = utils.crypto.load_rsa_private_key(decrypted_private_key) diff --git a/keepercli-package/src/keepercli/commands/two_fa.py b/keepercli-package/src/keepercli/commands/two_fa.py new file mode 100644 index 00000000..258bf40b --- /dev/null +++ b/keepercli-package/src/keepercli/commands/two_fa.py @@ -0,0 +1,593 @@ +import argparse +import datetime +import json + +from . import base +from ..params import KeeperParams +from ..helpers import report_utils +from .. import api, prompt_utils + +from keepersdk import utils +from keepersdk.proto import APIRequest_pb2 +from keepersdk.authentication import two_fa_utils +from keepersdk.errors import KeeperApiError + +logger = api.get_logger() + +# Constants +NEVER_EXPIRE_TIMESTAMP = 3_000_000_000_000 +MILLISECONDS_TO_SECONDS = 1000 + +METHOD_CHOICES = ['totp', 'key', 'sms', 'duo', 'backup'] + +# TFA Restriction keys +ALL_TFA_RESTRICTIONS = { + 'require_security_key_pin', + 'restrict_two_factor_channel_text', + 'restrict_two_factor_channel_google', + 'restrict_two_factor_channel_duo', + 'restrict_two_factor_channel_security_key', + 'restrict_two_factor_channel_rsa', + 'restrict_two_factor_channel_dna' +} + +# Duo capabilities +DUO_CAPABILITIES = ('mobile_otp', 'sms', 'voice') +DUO_CAPABILITY_SMS = 'sms' +DUO_CAPABILITY_VOICE = 'voice' +DUO_CAPABILITY_MOBILE_OTP = 'mobile_otp' +DUO_CAPABILITY_DISPLAY_NAMES = { + 'sms': 'Send a Text Message', + 'voice': 'Make a Voice Call', + 'mobile_otp': 'OTP Code on Mobile' +} + +# Method to Channel Type mapping +METHOD_TO_CHANNEL_TYPE = { + 'totp': APIRequest_pb2.TWO_FA_CT_TOTP, + 'sms': APIRequest_pb2.TWO_FA_CT_SMS, + 'key': APIRequest_pb2.TWO_FA_CT_WEBAUTHN, + 'duo': APIRequest_pb2.TWO_FA_CT_DUO, + 'backup': APIRequest_pb2.TWO_FA_CT_BACKUP +} + +# Method to restriction key mapping +METHOD_TO_RESTRICTION = { + 'totp': 'restrict_two_factor_channel_google', + 'sms': 'restrict_two_factor_channel_text', + 'key': 'restrict_two_factor_channel_security_key' +} + +# Channel Type to Value Type mapping for validation +CHANNEL_TYPE_TO_VALUE_TYPE = { + APIRequest_pb2.TWO_FA_CT_TOTP: APIRequest_pb2.TWO_FA_CODE_TOTP, + APIRequest_pb2.TWO_FA_CT_SMS: APIRequest_pb2.TWO_FA_CODE_SMS, + APIRequest_pb2.TWO_FA_CT_WEBAUTHN: APIRequest_pb2.TWO_FA_RESP_WEBAUTHN, + APIRequest_pb2.TWO_FA_CT_DUO: APIRequest_pb2.TWO_FA_CODE_DUO +} + +# Error messages +ERROR_VAULT_NOT_INITIALIZED = "Vault is not initialized. Login to initialize the vault." +ERROR_METHOD_DISABLED_TOTP = 'Authenticator App (TOTP) 2FA method is disabled by the Administrator' +ERROR_METHOD_DISABLED_SMS = 'Text Message (SMS) 2FA method is disabled by the Administrator' +ERROR_METHOD_DISABLED_KEY = 'Security Key 2FA method is disabled by the Administrator' +ERROR_METHOD_NOT_SUPPORTED = '2FA method "{}" is not supported' +ERROR_NAME_REQUIRED = '"name" argument is required' +ERROR_CHANNEL_NOT_FOUND = '2FA channel "{}" not found' + +# Messages +MSG_NO_2FA_METHODS = 'No 2FA methods are found' +MSG_2FA_EXPIRES = '2FA authentication expires: %s\n' +MSG_2FA_METHOD_ADDED = '2FA method is added' +MSG_2FA_CHANNEL_DELETED = '2FA channel is deleted' +MSG_ENTER_PHONE = '\nEnter your phone number for text messages: ' +MSG_DUO_ENROLL_URL = "Enroll URL" +MSG_DUO_DEVICE_PHONE = 'Device Phone Number: {}' +MSG_DUO_SELECTION_PROMPT = 'We\'ll send you a text message or call with a passcode to your device:' +MSG_DUO_CANCEL = ' q. Cancel' +MSG_DUO_SELECTION = 'Selection: ' +MSG_DUO_ACTION_NOT_SUPPORTED = 'Action "{}" is not supported.' +MSG_VERIFICATION_CODE = 'Verification Code: ' +MSG_INVALID_2FA_CODE = 'Invalid 2FA code: (%s): %s ' +MSG_QR_CODE_NOT_INSTALLED = 'QR Code library is not installed.\npip install pyqrcode' +MSG_DELETE_CHANNEL_PROMPT = 'Do you want to delete 2FA channel "{}"?' + +REPORT_HEADERS = ['method', 'channel_uid', 'name', + 'created', 'phone_number'] + +BACKUP_CODES_TITLE = 'Backup Codes' +TOTP_URL_TEMPLATE = 'otpauth://totp/Keeper:{}?secret={}' +QR_CODE_COLORS = ('black', 'white') +CANCEL_CHOICES = ('q', 'Q') +YES_CHOICES = ('y', 'Y') +DEFAULT_CONFIRMATION = 'n' + +class TwoFaCommand(base.GroupCommand): + + def __init__(self): + super().__init__('Two-Factor Authentication') + self.register_command(ListTwoFaCommand(), 'list') + self.register_command(AddTwoFaCommand(), 'add') + self.register_command(DeleteTwoFaCommand(), 'delete') + self.default_verb = 'list' + + @staticmethod + def two_factor_channel_to_desc(channel): + """Convert channel type to human-readable description.""" + channel_descriptions = { + APIRequest_pb2.TWO_FA_CT_TOTP: 'TOTP', + APIRequest_pb2.TWO_FA_CT_SMS: 'SMS', + APIRequest_pb2.TWO_FA_CT_DUO: 'DUO', + APIRequest_pb2.TWO_FA_CT_RSA: 'RSA SecurID', + APIRequest_pb2.TWO_FA_CT_U2F: 'U2F', + APIRequest_pb2.TWO_FA_CT_WEBAUTHN: 'Security Key', + APIRequest_pb2.TWO_FA_CT_DNA: 'Keeper DNA (Watch)', + APIRequest_pb2.TWO_FA_CT_BACKUP: 'Backup Codes' + } + return channel_descriptions.get(channel, 'Unknown') + +class ListTwoFaCommand(base.ArgparseCommand): + + def __init__(self): + parser = argparse.ArgumentParser( + prog='2fa list', + description='List all two-factor authentication methods', + parents=[base.report_output_parser] + ) + super().__init__(parser) + + def execute(self, context: KeeperParams, **kwargs): + if not context.vault: + raise ValueError(ERROR_VAULT_NOT_INITIALIZED) + + vault = context.vault + response = two_fa_utils.get_two_fa_list(vault) + + if not response or response.expireOn <= 0: + logger.info(MSG_NO_2FA_METHODS) + return + + expire_at = self._format_expiry_time(response.expireOn) + if expire_at: + logger.info(MSG_2FA_EXPIRES, expire_at) + + table = self._build_channel_table(response.channels) + fmt = kwargs.get('format') + header = self._get_report_headers(fmt) + + return report_utils.dump_report_data( + table, header, fmt=fmt, filename=kwargs.get('output'), row_number=True + ) + + @staticmethod + def _format_expiry_time(expire_on): + """Format expiry timestamp to human-readable string.""" + if expire_on > NEVER_EXPIRE_TIMESTAMP: + return 'Never' + dt = datetime.datetime.fromtimestamp(expire_on // MILLISECONDS_TO_SECONDS) + return dt.isoformat() + + @staticmethod + def _build_channel_table(channels): + """Build table rows from channel data.""" + table = [] + for channel in channels: + created_on = datetime.datetime.fromtimestamp( + channel.createdOn // MILLISECONDS_TO_SECONDS + ) + row = [ + TwoFaCommand.two_factor_channel_to_desc(channel.channelType), + utils.base64_url_encode(channel.channel_uid), + channel.channelName, + created_on, + channel.phoneNumber + ] + table.append(row) + return table + + @staticmethod + def _get_report_headers(format_type): + """Get report headers, formatted if not JSON.""" + if format_type == 'json': + return REPORT_HEADERS + return [report_utils.field_to_title(x) for x in REPORT_HEADERS] + +class AddTwoFaCommand(base.ArgparseCommand): + + def __init__(self): + parser = argparse.ArgumentParser(prog='2fa add', description='Add 2FA method') + AddTwoFaCommand.add_arguments_to_parser(parser) + super().__init__(parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser): + parser.add_argument( + '--method', '-m', + dest='method', + action='store', + required=True, + choices=METHOD_CHOICES, + help='2FA auth method' + ) + parser.add_argument('--name', dest='name', action='store', help='2FA auth name') + parser.add_argument( + '--key-pin', + dest='key_pin', + action='store_true', + help='force using Security Key PIN' + ) + + def execute(self, context: KeeperParams, **kwargs): + """Execute the add 2FA method command.""" + if not context.vault: + raise ValueError(ERROR_VAULT_NOT_INITIALIZED) + + vault = context.vault + method = kwargs.get('method') + tfa_restrictions = self._get_tfa_restrictions(vault) + + channel_type = self._get_channel_type(method, tfa_restrictions) + channel_uid = utils.base64_url_decode(utils.generate_uid()) + channel_name = kwargs.get('name') or '' + + phone_number, duo_push_type = self._handle_channel_setup( + vault, channel_type, context + ) + if self._is_setup_cancelled(channel_type, phone_number, duo_push_type): + return + + response = two_fa_utils.add_two_fa_method( + vault=vault, + channel_type=channel_type, + channel_uid=channel_uid, + channel_name=channel_name, + phone_number=phone_number or '', + duo_push_type=duo_push_type or APIRequest_pb2.TWO_FA_PUSH_NONE + ) + + if self._handle_channel_specific_setup( + vault, channel_type, response, channel_uid, context, kwargs + ): + return + + value_type = self._get_value_type_for_channel(channel_type) + self._validate_2fa_code(vault, channel_uid, value_type) + + @staticmethod + def _get_tfa_restrictions(vault): + """Get TFA restrictions from vault enforcements.""" + tfa_restrictions = set() + enforcements = vault.keeper_auth.auth_context.enforcements + if not enforcements or 'booleans' not in enforcements: + return tfa_restrictions + + booleans = enforcements['booleans'] + for item in booleans: + key = (item.get('key') or '').lower() + if key in ALL_TFA_RESTRICTIONS: + tfa_restrictions.add(key) + + return tfa_restrictions + + @staticmethod + def _get_channel_type(method, tfa_restrictions): + """Get channel type for method and validate restrictions.""" + if method not in METHOD_TO_CHANNEL_TYPE: + raise base.CommandError(ERROR_METHOD_NOT_SUPPORTED.format(method)) + + restriction_key = METHOD_TO_RESTRICTION.get(method) + if restriction_key and restriction_key in tfa_restrictions: + error_messages = { + 'totp': ERROR_METHOD_DISABLED_TOTP, + 'sms': ERROR_METHOD_DISABLED_SMS, + 'key': ERROR_METHOD_DISABLED_KEY + } + raise base.CommandError(error_messages.get(method, '')) + + return METHOD_TO_CHANNEL_TYPE[method] + + def _handle_channel_setup(self, vault, channel_type, context): + """Handle channel-specific setup (SMS phone, Duo selection).""" + phone_number = None + duo_push_type = None + + if channel_type == APIRequest_pb2.TWO_FA_CT_SMS: + phone_number = self._handle_sms_setup() + elif channel_type == APIRequest_pb2.TWO_FA_CT_DUO: + duo_push_type = self._handle_duo_setup(vault) + + return phone_number, duo_push_type + + @staticmethod + def _is_setup_cancelled(channel_type, phone_number, duo_push_type): + """Check if setup was cancelled by user.""" + if channel_type == APIRequest_pb2.TWO_FA_CT_SMS: + return phone_number is None + if channel_type == APIRequest_pb2.TWO_FA_CT_DUO: + return duo_push_type is None + return False + + @staticmethod + def _handle_sms_setup(): + """Handle SMS phone number input.""" + try: + phone_number = input(MSG_ENTER_PHONE) + return phone_number if phone_number else None + except KeyboardInterrupt: + return None + + def _handle_duo_setup(self, vault): + """Handle Duo setup and return push type.""" + duo_response = vault.keeper_auth.execute_auth_rest( + rest_endpoint='authentication/2fa_duo_status', + request=None, + response_type=APIRequest_pb2.TwoFactorDuoStatus + ) + + if duo_response.enroll_url: + logger.warning(duo_response.message) + logger.warning(MSG_DUO_ENROLL_URL) + logger.info(duo_response.enroll_url) + return None + + capabilities = [ + cap for cap in duo_response.capabilities + if cap in DUO_CAPABILITIES + ] + + if not capabilities: + return None + + logger.info(MSG_DUO_DEVICE_PHONE.format(duo_response.phoneNumber)) + logger.info(MSG_DUO_SELECTION_PROMPT) + + for idx, capability in enumerate(capabilities, 1): + display_name = DUO_CAPABILITY_DISPLAY_NAMES.get( + capability, capability + ) + logger.info(f' {idx}. {display_name}') + + logger.info(MSG_DUO_CANCEL) + + return self._get_duo_push_type_selection(capabilities) + + @staticmethod + def _get_duo_push_type_selection(capabilities): + """Get Duo push type from user selection.""" + while True: + try: + answer = input(MSG_DUO_SELECTION) + if answer in CANCEL_CHOICES: + return None + + if answer and answer.isnumeric(): + code = int(answer) + if 0 < code <= len(capabilities): + selected_capability = capabilities[code - 1] + return { + DUO_CAPABILITY_SMS: APIRequest_pb2.TWO_FA_PUSH_DUO_TEXT, + DUO_CAPABILITY_VOICE: APIRequest_pb2.TWO_FA_PUSH_DUO_CALL, + DUO_CAPABILITY_MOBILE_OTP: APIRequest_pb2.TWO_FA_PUSH_NONE + }.get(selected_capability, APIRequest_pb2.TWO_FA_PUSH_NONE) + + logger.info(MSG_DUO_ACTION_NOT_SUPPORTED.format(answer)) + except (KeyboardInterrupt, ValueError): + return None + + def _handle_channel_specific_setup(self, vault, channel_type, response, + channel_uid, context, kwargs): + """Handle channel-specific post-setup (backup codes, webauthn, totp).""" + if channel_type == APIRequest_pb2.TWO_FA_CT_BACKUP: + self._handle_backup_codes(response) + return True + + if channel_type == APIRequest_pb2.TWO_FA_CT_WEBAUTHN: + self._handle_webauthn_setup(vault, response, channel_uid, kwargs) + return True + + if channel_type == APIRequest_pb2.TWO_FA_CT_TOTP: + self._handle_totp_setup(response, context) + + return False + + @staticmethod + def _handle_backup_codes(response): + """Display backup codes.""" + codes = list(response.backupKeys) + table = [] + for idx in range(0, len(codes), 2): + table.append(codes[idx:idx + 2]) + report_utils.dump_report_data( + table, ('', ''), title=BACKUP_CODES_TITLE, no_header=True + ) + + @staticmethod + def _handle_webauthn_setup(vault, response, channel_uid, kwargs): + """Handle WebAuthn security key setup.""" + try: + from ..login import FidoCliInteraction + from keepersdk.authentication.yubikey import yubikey_register + + request = json.loads(response.challenge) + force_pin = kwargs.get('key_pin') is True + fido_response = yubikey_register( + request, force_pin, user_interaction=FidoCliInteraction() + ) + + if not fido_response: + return + + attestation = { + 'id': fido_response.id, + 'rawId': utils.base64_url_encode(fido_response.raw_id), + 'response': { + 'attestationObject': utils.base64_url_encode( + fido_response.response.attestation_object + ), + 'clientDataJSON': fido_response.response.client_data.b64 + }, + 'type': 'public-key', + 'clientExtensionResults': ( + dict(fido_response.client_extension_results) + if fido_response.client_extension_results else {} + ) + } + + two_fa_utils.validate_two_fa_method( + vault=vault, + channel_uid=channel_uid, + value_type=APIRequest_pb2.TWO_FA_RESP_WEBAUTHN, + value=json.dumps(attestation), + expire_in=APIRequest_pb2.TWO_FA_EXP_IMMEDIATELY + ) + logger.info(MSG_2FA_METHOD_ADDED) + except ImportError as e: + logger.warning(e) + display_fido2_warning() + except Exception as e: + logger.warning(e) + + @staticmethod + def _handle_totp_setup(response, context): + """Handle TOTP setup and display QR code.""" + url = TOTP_URL_TEMPLATE.format( + context.auth.auth_context.username, + response.challenge + ) + logger.info(f'TOTP URL:\n{url}') + + try: + import pyqrcode + qr_code = pyqrcode.create(url) + logger.info(qr_code.terminal(*QR_CODE_COLORS)) + except ModuleNotFoundError: + logger.error(MSG_QR_CODE_NOT_INSTALLED) + + @staticmethod + def _get_value_type_for_channel(channel_type): + """Get value type for channel type validation.""" + return CHANNEL_TYPE_TO_VALUE_TYPE.get( + channel_type, + APIRequest_pb2.TWO_FA_CODE_TOTP + ) + + @staticmethod + def _validate_2fa_code(vault, channel_uid, value_type): + """Validate 2FA code with user input.""" + while True: + try: + answer = input(MSG_VERIFICATION_CODE) + if not answer: + continue + + try: + two_fa_utils.validate_two_fa_method( + vault=vault, + channel_uid=channel_uid, + value_type=value_type, + value=answer, + expire_in=APIRequest_pb2.TWO_FA_EXP_IMMEDIATELY + ) + logger.info(MSG_2FA_METHOD_ADDED) + return + except KeeperApiError as kae: + logger.warning( + MSG_INVALID_2FA_CODE, + kae.result_code, + kae.message + ) + except KeyboardInterrupt: + return + + +warned_on_fido_package = False +install_fido_package_warning = """ + You can use Security Key with KeeperSDK: + Upgrade your Python interpreter to 3.10 or newer + and make sure fido2 package is 2.0.0 or newer +""" + + +def display_fido2_warning(): + global warned_on_fido_package + + if not warned_on_fido_package: + logger.warning(install_fido_package_warning) + warned_on_fido_package = True + + +class DeleteTwoFaCommand(base.ArgparseCommand): + + def __init__(self): + parser = argparse.ArgumentParser( + prog='2fa delete', + description='Delete a two-factor authentication method' + ) + DeleteTwoFaCommand.add_arguments_to_parser(parser) + super().__init__(parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser): + parser.add_argument( + '--force', + dest='force', + action='store_true', + help='do not prompt for confirmation' + ) + parser.add_argument('name', help='2FA method UID or name') + + def execute(self, context: KeeperParams, **kwargs): + if not context.vault: + raise ValueError(ERROR_VAULT_NOT_INITIALIZED) + + vault = context.vault + name = kwargs.get('name') + + if not name: + raise base.CommandError(ERROR_NAME_REQUIRED) + + response = two_fa_utils.get_two_fa_list(vault) + if not response: + logger.info(MSG_NO_2FA_METHODS) + return + + channel = self._find_channel_by_name(response.channels, name) + if not channel: + raise base.CommandError(ERROR_CHANNEL_NOT_FOUND.format(name)) + + if not self._confirm_deletion(channel, kwargs.get('force')): + return + + two_fa_utils.delete_two_fa_method(vault, channel.channel_uid) + logger.info(MSG_2FA_CHANNEL_DELETED) + + @staticmethod + def _find_channel_by_name(channels, name): + """Find channel by UID or name (case-insensitive).""" + channel = next( + (ch for ch in channels if utils.base64_url_encode(ch.channel_uid) == name), + None + ) + + if channel: + return channel + + name_lower = name.casefold() + return next( + (ch for ch in channels if ch.channelName.casefold() == name_lower), + None + ) + + @staticmethod + def _confirm_deletion(channel, force): + """Confirm deletion with user if not forced.""" + if force: + return True + + channel_name = channel.channelName or utils.base64_url_encode(channel.channel_uid) + answer = prompt_utils.user_choice( + MSG_DELETE_CHANNEL_PROMPT.format(channel_name), + 'yn', + DEFAULT_CONFIRMATION + ) + return answer in YES_CHOICES diff --git a/keepercli-package/src/keepercli/register_commands.py b/keepercli-package/src/keepercli/register_commands.py index 1e3facdb..3e609329 100644 --- a/keepercli-package/src/keepercli/register_commands.py +++ b/keepercli-package/src/keepercli/register_commands.py @@ -14,7 +14,7 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS if not scopes or bool(scopes & base.CommandScope.Account): from .commands import account_commands from .biometric import BiometricCommand - from .commands import account_commands + from .commands import account_commands, two_fa commands.register_command('server', base.GetterSetterCommand('server', 'Sets or displays current Keeper region'), base.CommandScope.Account) @@ -24,6 +24,7 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS commands.register_command('this-device', account_commands.ThisDeviceCommand(), base.CommandScope.Account) commands.register_command('whoami', account_commands.WhoamiCommand(), base.CommandScope.Account) commands.register_command('reset-password', account_commands.ResetPasswordCommand(), base.CommandScope.Account) + commands.register_command('2fa', two_fa.TwoFaCommand(), base.CommandScope.Account) if not scopes or bool(scopes & base.CommandScope.Vault): diff --git a/keepersdk-package/src/keepersdk/authentication/two_fa_utils.py b/keepersdk-package/src/keepersdk/authentication/two_fa_utils.py new file mode 100644 index 00000000..fb89b94c --- /dev/null +++ b/keepersdk-package/src/keepersdk/authentication/two_fa_utils.py @@ -0,0 +1,76 @@ +from ..proto import APIRequest_pb2 +from ..vault import vault_online + +# REST endpoint constants +REST_ENDPOINT_2FA_LIST = 'authentication/2fa_list' +REST_ENDPOINT_2FA_ADD = 'authentication/2fa_add' +REST_ENDPOINT_2FA_ADD_VALIDATE = 'authentication/2fa_add_validate' +REST_ENDPOINT_2FA_DELETE = 'authentication/2fa_delete' + +DEFAULT_DUO_PUSH_TYPE = APIRequest_pb2.TWO_FA_PUSH_NONE +DEFAULT_EXPIRE_IN = APIRequest_pb2.TWO_FA_EXP_IMMEDIATELY + + +def get_two_fa_list(vault: vault_online.VaultOnline) -> APIRequest_pb2.TwoFactorListResponse: + """Retrieve the list of two-factor authentication methods.""" + return vault.keeper_auth.execute_auth_rest( + rest_endpoint=REST_ENDPOINT_2FA_LIST, + request=None, + response_type=APIRequest_pb2.TwoFactorListResponse + ) + + +def add_two_fa_method( + vault: vault_online.VaultOnline, + channel_type: APIRequest_pb2.TwoFactorChannelType, + channel_uid: bytes, + channel_name: str = '', + phone_number: str = '', + duo_push_type: APIRequest_pb2.TwoFactorPushType = DEFAULT_DUO_PUSH_TYPE +) -> APIRequest_pb2.TwoFactorAddResponse: + """Add a new two-factor authentication method.""" + request = APIRequest_pb2.TwoFactorAddRequest() + request.channelType = channel_type + request.channel_uid = channel_uid + request.channelName = channel_name + request.phoneNumber = phone_number + request.duoPushType = duo_push_type + + return vault.keeper_auth.execute_auth_rest( + rest_endpoint=REST_ENDPOINT_2FA_ADD, + request=request, + response_type=APIRequest_pb2.TwoFactorAddResponse + ) + + +def validate_two_fa_method( + vault: vault_online.VaultOnline, + channel_uid: bytes, + value_type: APIRequest_pb2.TwoFactorValueType, + value: str, + expire_in: APIRequest_pb2.TwoFactorExpiration = DEFAULT_EXPIRE_IN +) -> None: + """Validate a two-factor authentication method.""" + request = APIRequest_pb2.TwoFactorValidateRequest() + request.channel_uid = channel_uid + request.valueType = value_type + request.value = value + request.expireIn = expire_in + + vault.keeper_auth.execute_auth_rest( + rest_endpoint=REST_ENDPOINT_2FA_ADD_VALIDATE, + request=request, + response_type=None + ) + + +def delete_two_fa_method(vault: vault_online.VaultOnline, channel_uid: bytes) -> None: + """Delete a two-factor authentication method.""" + request = APIRequest_pb2.TwoFactorDeleteRequest() + request.channel_uid = channel_uid + + vault.keeper_auth.execute_auth_rest( + rest_endpoint=REST_ENDPOINT_2FA_DELETE, + request=request, + response_type=None + ) \ No newline at end of file diff --git a/keepersdk-package/src/keepersdk/authentication/yubikey.py b/keepersdk-package/src/keepersdk/authentication/yubikey.py index 4ff6e7bd..2b650c89 100644 --- a/keepersdk-package/src/keepersdk/authentication/yubikey.py +++ b/keepersdk-package/src/keepersdk/authentication/yubikey.py @@ -1,5 +1,7 @@ import abc +import getpass import json +import logging import os import threading from typing import Optional, Any, Dict @@ -7,8 +9,16 @@ from fido2.client import ClientError, DefaultClientDataCollector, UserInteraction, WebAuthnClient from fido2.ctap import CtapError from fido2.hid import CtapHidDevice -from fido2.webauthn import PublicKeyCredentialRequestOptions, UserVerificationRequirement, AuthenticationResponse +from fido2.webauthn import PublicKeyCredentialRequestOptions, UserVerificationRequirement, AuthenticationResponse, PublicKeyCredentialCreationOptions +from fido2.ctap2 import Ctap2, ClientPin from .. import utils +from prompt_toolkit import PromptSession + + +prompt_session = None +if os.isatty(0) and os.isatty(1): + prompt_session = PromptSession(multiline=False, complete_while_typing=False) + class IKeeperUserInteraction(abc.ABC): @abc.abstractmethod @@ -111,3 +121,87 @@ def yubikey_authenticate(request: Dict[str, Any], user_interaction: UserInteract } return json.dumps(signature) return None + + +def yubikey_register(request, force_pin=False, user_interaction: Optional[UserInteraction] = None): + logger = utils.get_logger() + + rq = request.copy() + user_id = rq['user']['id'] + if isinstance(user_id, str): + rq['user']['id'] = utils.base64_url_decode(user_id) + challenge = rq['challenge'] + if isinstance(challenge, str): + rq['challenge'] = utils.base64_url_decode(challenge) + + if force_pin: + uv = rq['authenticatorSelection']['userVerification'] + if uv != UserVerificationRequirement.REQUIRED: + rq['authenticatorSelection']['userVerification'] = UserVerificationRequirement.REQUIRED + + options = PublicKeyCredentialCreationOptions.from_dict(rq) + origin = options.extensions.get('appidExclude') or options.rp.id + + client = None + data_collector = DefaultClientDataCollector(origin, verify=verify_rp_id_none) + if os.name == 'nt': + from fido2.client.windows import WindowsClient + client = WindowsClient(client_data_collector=data_collector) + else: + dev = next(CtapHidDevice.list_devices(), None) + if not dev: + logger.warning("No Security Key detected") + return None + + from fido2.client import Fido2Client + fido_client = Fido2Client(dev, client_data_collector=data_collector, user_interaction=user_interaction) + uv_configured = any(fido_client.info.options.get(k) for k in ("uv", "clientPin", "bioEnroll")) + uv = options.authenticator_selection.user_verification + if uv == UserVerificationRequirement.REQUIRED: + if not uv_configured: + print('\nSecret Key PIN is required') + answer = input('Do you want to setup PIN code for your Secret Key? (y/n): ') + if answer not in ('y', 'Y'): + return None + prompt1 = ' PIN Code: ' + prompt2 = ' PIN Code Again: ' + if prompt_session: + pin1 = prompt_session.prompt(prompt1, is_password=True) + else: + pin1 = getpass.getpass(prompt1) + if not pin1: + raise Exception('PIN is required') + if prompt_session: + pin2 = prompt_session.prompt(prompt2, is_password=True) + else: + pin2 = getpass.getpass(prompt2) + if not pin2: + raise Exception('PIN is required') + if pin1 != pin2: + raise Exception('PINs do not match') + client_pin = ClientPin(Ctap2(dev)) + client_pin.set_pin(pin1) + elif uv == UserVerificationRequirement.PREFERRED: + if not uv_configured: + rq['authenticatorSelection']['userVerification'] = UserVerificationRequirement.DISCOURAGED + options = PublicKeyCredentialCreationOptions.from_dict(rq) + client = fido_client + + evt = threading.Event() + try: + try: + return client.make_credential(options, event=evt) + except ClientError as err: + if isinstance(err.cause, CtapError): + if err.cause.code == CtapError.ERR.PIN_INVALID: + raise Exception('PIN is invalid') + elif err.cause.code == CtapError.ERR.PIN_AUTH_BLOCKED: + raise Exception('PIN is blocked') + elif isinstance(err.cause, str): + if err.code == ClientError.ERR.CONFIGURATION_UNSUPPORTED: + raise Exception('Security key user verification (PIN or Biometric) is not configured') + raise err + except KeyboardInterrupt: + pass + finally: + evt.set() From 3466bb1249450e26b7093066b4342c1de7321021 Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Mon, 16 Feb 2026 11:35:23 +0530 Subject: [PATCH 30/37] enterprise-push command added --- .../src/keepercli/commands/enterprise_push.py | 525 ++++++++++++++++++ .../src/keepercli/register_commands.py | 3 +- 2 files changed, 527 insertions(+), 1 deletion(-) create mode 100644 keepercli-package/src/keepercli/commands/enterprise_push.py diff --git a/keepercli-package/src/keepercli/commands/enterprise_push.py b/keepercli-package/src/keepercli/commands/enterprise_push.py new file mode 100644 index 00000000..19e54f78 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/enterprise_push.py @@ -0,0 +1,525 @@ +import argparse +import json +import os +import copy +import re +from typing import Any + +from . import base +from .. import api +from ..params import KeeperParams + +from keepersdk import crypto, utils, generator +from keepersdk.proto import record_pb2 +from keepersdk.enterprise import enterprise_types +from keepersdk.importer import keeper_format, import_utils +from keepersdk.vault import vault_extensions, vault_online +from keepersdk.authentication import keeper_auth +from keepersdk.vault.record_management import TypedRecord + +logger = api.get_logger() + + +ENTERPRISE_PUSH_DESCRIPTION = """ +"enterprise-push" command uses Keeper JSON record import format. +https://docs.keeper.io/secrets-manager/commander-cli/import-and-export-commands/json-import + +To create template records use the Web Vault or any other Keeper client. +1. Create an empty folder for storing templates. e.g. "Templates" +2. Create records in that folder +3. export the folder as JSON +My Vault> export --format=json --folder=Templates templates.json +4. Optional: edit JSON file to delete the following properties: + "uid", "schema", "folders" not used by "enterprise-push" command + + +The template JSON file should be either array of records or +an object that contains property "records" of array of records + +Template record file examples: +1. Array of records +[ + { + "title": "Record For ${user_name}", + "login": "${user_email}", + "password": "${generate_password}", + "login_url": "", + "notes": "", + "custom_fields": { + "key1": "value1", + "key2": "value2" + } + } +] + +2. Object that holds "records" property +{ + "records": [ + { + "title": "Record For ${user_name}", + } + ] +} + + +Supported template parameters: + + ${user_email} User email address + ${generate_password} Generate random password + ${user_name} User name +""" + + +def load_template_records_from_file(file_path: str) -> list: + """Load and validate template records from a JSON file. + + Accepts either a JSON array of records or an object with a "records" array. + Raises CommandError if the file is missing, invalid, or contains no templates. + """ + path = os.path.abspath(os.path.expanduser(file_path)) + if not os.path.isfile(path): + raise base.CommandError(f"File {file_path} does not exist") + + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + + if isinstance(data, dict) and "records" in data: + records = data["records"] + elif isinstance(data, list): + records = data + else: + records = None + + if not isinstance(records, list) or len(records) == 0: + raise base.CommandError(f"File {file_path} does not contain record templates") + + return records + + +PARAMETER_PATTERN = re.compile(r"\${(\w+)}") +TRANSFER_RECORD_SUCCESS = "transfer_record_success" + + +def _substitute_value(value: str, values: dict[str, str]) -> str: + """Replace all ${key} placeholders in a string with values from the given dict.""" + result = value + while True: + match = PARAMETER_PATTERN.search(result) + if not match: + break + param = match.group(1) + replacement = values.get(param) or param + result = result[: match.start()] + replacement + result[match.end() :] + return result + + +def _substitute_in_dict(container: dict, values: dict[str, str]) -> None: + """Recursively substitute placeholders in dict (and nested dicts/lists) in place.""" + for key, val in list(container.items()): + if isinstance(val, str): + new_val = _substitute_value(val, values) + if val != new_val: + container[key] = new_val + elif isinstance(val, dict): + _substitute_in_dict(val, values) + elif isinstance(val, list): + container[key] = _substitute_in_list(val, values) + + +def _substitute_in_list(container: list, values: dict[str, str]) -> list: + """Return a new list with placeholders substituted.""" + result = [] + for item in container: + if isinstance(item, str): + result.append(_substitute_value(item, values)) + elif isinstance(item, dict): + _substitute_in_dict(item, values) + result.append(item) + elif isinstance(item, list): + result.append(_substitute_in_list(item, values)) + else: + result.append(item) + return result + + +def _get_substitution_values(enterprise: enterprise_types.IEnterpriseData, email: str) -> dict[str, str]: + """Build substitution map for a user: user_email, user_name, generate_password.""" + values = { + "user_email": email, + "generate_password": generator.KeeperPasswordGenerator(length=32).generate(), + } + for u in enterprise.users.get_all_entities(): + if u.username.lower() == email.lower(): + values["user_name"] = u.full_name or "" + break + return values + + +def _substitute_record_params( + enterprise: enterprise_types.IEnterpriseData, email: str, record_data: dict +) -> None: + """Fill template parameters in record_data for the given user (in place).""" + values = _get_substitution_values(enterprise, email) + _substitute_in_dict(record_data, values) + + +def _resolve_user_to_email(enterprise: enterprise_types.IEnterpriseData, user_id: str) -> str | None: + """Resolve user identifier (email, name, or enterprise_user_id) to username (email).""" + user_id_lower = user_id.lower() + for u in enterprise.users.get_all_entities(): + if user_id_lower in ( + u.username.lower(), + (u.full_name or "").lower(), + str(u.enterprise_user_id), + ): + return u.username + return None + + +def _resolve_team_to_uid(enterprise: enterprise_types.IEnterpriseData, team_id: str) -> str | None: + """Resolve team identifier (name or team_uid) to team_uid.""" + for t in enterprise.teams.get_all_entities() or []: + if team_id == t.team_uid or team_id.lower() == t.name.lower(): + return t.team_uid + return None + + +def _collect_recipient_emails( + enterprise: enterprise_types.IEnterpriseData, + current_username: str, + user_ids: list[str], + team_ids: list[str], +) -> set[str]: + """Resolve user_ids and team_ids to a set of recipient emails. Excludes current user.""" + emails = set() + + for user_id in user_ids or []: + email = _resolve_user_to_email(enterprise, user_id) + if email: + if email.lower() != current_username.lower(): + emails.add(email) + else: + logger.warning("Cannot find user %s", user_id) + + if team_ids: + users_map = {u.enterprise_user_id: u.username for u in enterprise.users.get_all_entities()} + users_in_team = {} + for tu in enterprise.team_users.get_all_links() or []: + team_uid = tu.team_uid + if team_uid not in users_in_team: + users_in_team[team_uid] = [] + if tu.enterprise_user_id in users_map: + users_in_team[team_uid].append(users_map[tu.enterprise_user_id]) + + if not enterprise.teams.get_all_entities(): + logger.warning( + "There are no teams to manage. Try to refresh your local data by syncing data from the server (use command `enterprise-down`)." + ) + else: + for team_id in team_ids: + team_uid = _resolve_team_to_uid(enterprise, team_id) + if team_uid and team_uid in users_in_team: + for member_email in users_in_team[team_uid]: + if member_email.lower() != current_username.lower(): + emails.add(member_email) + elif team_uid is None: + logger.warning("Cannot find team %s", team_id) + + return emails + + +def _build_typed_records_for_user( + enterprise: enterprise_types.IEnterpriseData, + email: str, + record_data: list[dict[str, Any]], +) -> list[TypedRecord]: + """Substitute template params and convert JSON templates to typed records.""" + user_records = [] + for template in record_data: + record = copy.deepcopy(template) + _substitute_record_params(enterprise, email, record) + import_record = keeper_format.KeeperJsonMixin.json_to_record(record) + if import_record: + user_records.append(import_record) + return [import_utils._as_typed_record(record=r) for r in user_records] + + +def _build_records_add_request( + auth: keeper_auth.KeeperAuth, + vault: vault_online.VaultOnline, + typed_records: list[TypedRecord], + user_ec_key: Any, + user_rsa_key: Any, + record_keys_out: dict[str, bytes], +) -> record_pb2.RecordsAddRequest: + """Build RecordsAddRequest and fill record_keys_out with uid -> encrypted_key for transfer.""" + rq = record_pb2.RecordsAddRequest() + for record in typed_records: + add_record, uid, encrypted_key = _build_single_record_add( + auth, vault, record, user_ec_key, user_rsa_key + ) + record_keys_out[uid] = encrypted_key + rq.records.append(add_record) + return rq + + +def _build_single_record_add( + auth: keeper_auth.KeeperAuth, + vault: vault_online.VaultOnline, + record: TypedRecord, + user_ec_key: Any, + user_rsa_key: Any, +) -> tuple[record_pb2.RecordAdd, str, bytes]: + """Build one RecordAdd and return (add_record, record_uid, encrypted_record_key). Mutates record.uid and record.record_key.""" + record.uid = utils.generate_uid() + record.record_key = utils.generate_aes_key() + if user_ec_key: + encrypted_record_key = crypto.encrypt_ec(record.record_key, user_ec_key) + else: + encrypted_record_key = crypto.encrypt_rsa(record.record_key, user_rsa_key) + + add_record = record_pb2.RecordAdd() + add_record.record_uid = utils.base64_url_decode(record.uid) + add_record.record_key = crypto.encrypt_aes_v2(record.record_key, auth.auth_context.data_key) + add_record.client_modified_time = utils.current_milli_time() + add_record.folder_type = record_pb2.user_folder + + data = vault_extensions.extract_typed_record_data(record, vault.vault_data.get_record_type_by_name(record.record_type)) + json_data = vault_extensions.get_padded_json_bytes(data) + add_record.data = crypto.encrypt_aes_v2(json_data, record.record_key) + + if auth.auth_context.enterprise_ec_public_key: + audit_data = vault_extensions.extract_audit_data(record) + if audit_data: + add_record.audit.version = 0 + add_record.audit.data = crypto.encrypt_ec( + json.dumps(audit_data).encode("utf-8"), + auth.auth_context.enterprise_ec_public_key, + ) + return add_record, record.uid, encrypted_record_key + + +def _add_transfer_and_cleanup( + auth: keeper_auth.KeeperAuth, + email: str, + add_request: record_pb2.RecordsAddRequest, + record_keys_for_user: dict[str, Any], +) -> None: + """Execute records_add, transfer ownership to user, then unlink from admin (pre_delete + delete).""" + rs = auth.execute_auth_rest( + "vault/records_add", add_request, response_type=record_pb2.RecordsModifyResponse + ) + if not rs: + raise ValueError("Failed to add records") + pre_delete_objects = [] + transfer_rq = record_pb2.RecordsOnwershipTransferRequest() + + for rec in rs.records: + if rec.status == record_pb2.RS_SUCCESS: + record_uid = utils.base64_url_encode(rec.record_uid) + pre_delete_objects.append({ + "from_type": "user_folder", + "delete_resolution": "unlink", + "object_uid": record_uid, + "object_type": "record", + }) + record_key = record_keys_for_user[record_uid] + tr = record_pb2.TransferRecord() + tr.username = email + tr.recordUid = rec.record_uid + tr.recordKey = record_key + tr.useEccKey = len(record_key) < 150 + transfer_rq.transferRecords.append(tr) + else: + logger.warning( + "User: %s Create Record Error: (%s) %s", + email, + record_pb2.RecordModifyResult.Name(rec.status), + rec.message, + ) + + if not transfer_rq.transferRecords: + return + + rs1 = auth.execute_auth_rest( + "vault/records_ownership_transfer", + transfer_rq, + response_type=record_pb2.RecordsOnwershipTransferResponse, + ) + if not rs1: + raise ValueError("Failed to transfer records") + success_count = sum( + 1 for trec in rs1.transferRecordStatus if trec.status == TRANSFER_RECORD_SUCCESS + ) + for trec in rs1.transferRecordStatus: + if trec.status != TRANSFER_RECORD_SUCCESS: + logger.warning("User: %s Transfer Record Error: (%s) %s", email, trec.status, trec.message) + logger.info( + 'Pushed %d %s to "%s"', + success_count, + "record" if success_count == 1 else "records", + email, + ) + + if not pre_delete_objects: + return + pre_delete_rq = {"command": "pre_delete", "objects": pre_delete_objects} + pre_delete_rs = auth.execute_auth_command(pre_delete_rq) + if not pre_delete_rs: + raise ValueError("Failed to process delete records request") + if pre_delete_rs.get("result") == "success": + pdr = pre_delete_rs["pre_delete_response"] + delete_rq = {"command": "delete", "pre_delete_token": pdr["pre_delete_token"]} + auth.execute_auth_command(delete_rq) + + +def _process_one_recipient( + enterprise: enterprise_types.IEnterpriseData, + auth: keeper_auth.KeeperAuth, + vault: vault_online.VaultOnline, + email: str, + record_data: list[dict[str, Any]], +) -> None: + """Load user key, build records, add to vault, transfer ownership to user.""" + user_key = auth.get_user_keys(email) + if user_key is None: + return + + user_ec_key = None + user_rsa_key = None + if auth.auth_context.forbid_rsa and user_key.ec: + user_ec_key = crypto.load_ec_public_key(user_key.ec) + elif not auth.auth_context.forbid_rsa and user_key.rsa: + user_rsa_key = crypto.load_rsa_public_key(user_key.rsa) + if user_ec_key is None and user_rsa_key is None: + logger.warning('User "%s" public key cannot be loaded. Skipping', email) + return + + typed_records = _build_typed_records_for_user(enterprise, email, record_data) + if not typed_records: + return + + record_keys_for_user = {} + add_request = _build_records_add_request( + auth=auth, + vault=vault, + typed_records=typed_records, + user_ec_key=user_ec_key, + user_rsa_key=user_rsa_key, + record_keys_out=record_keys_for_user, + ) + + if not add_request.records: + return + + _add_transfer_and_cleanup( + auth=auth, + email=email, + add_request=add_request, + record_keys_for_user=record_keys_for_user, + ) + + +class EnterprisePush: + """Pushes record templates to specified users or team members.""" + + @staticmethod + def push_enterprise_records( + enterprise: enterprise_types.IEnterpriseData, + auth: keeper_auth.KeeperAuth, + vault: vault_online.VaultOnline, + user_ids: list[str], + team_ids: list[str], + record_data: list[dict[str, Any]], + ) -> None: + """Resolve recipients, then for each user substitute template params and add/transfer records.""" + emails = list( + _collect_recipient_emails( + enterprise, + auth.auth_context.username, + user_ids or [], + team_ids or [], + ) + ) + if not emails: + raise ValueError("No users") + + no_key_emails = auth.load_user_public_keys(emails, False) + if isinstance(no_key_emails, list): + for email in no_key_emails: + logger.warning('User "%s" public key cannot be loaded. Skipping', email) + + for email in emails: + _process_one_recipient( + enterprise=enterprise, + auth=auth, + vault=vault, + email=email, + record_data=record_data, + ) + vault.sync_down() + + +class EnterprisePushCommand(base.ArgparseCommand): + """CLI command: populate user vaults with template records (by user or team).""" + + def __init__(self): + parser = argparse.ArgumentParser( + prog="enterprise-push", + description="Populate user's vault with default records", + ) + EnterprisePushCommand.add_arguments_to_parser(parser) + super().__init__(parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--syntax-help", + dest="syntax_help", + action="store_true", + help="Display help on file format and template parameters.", + ) + parser.add_argument( + "--team", + dest="team", + action="append", + help="Team name or team UID. Records will be assigned to all users in the team.", + ) + parser.add_argument( + "--email", + dest="user", + action="append", + help="User email or User ID. Records will be assigned to the user.", + ) + parser.add_argument( + "file", + nargs="?", + type=str, + action="store", + help="File name in JSON format that contains template records.", + ) + + def execute(self, context: KeeperParams, **kwargs) -> None: + if kwargs.get("syntax_help"): + logger.info(ENTERPRISE_PUSH_DESCRIPTION) + return + + base.require_login(context) + base.require_enterprise_admin(context) + + file_arg = kwargs.get("file") or "" + if not file_arg: + raise base.CommandError("The template file name argument is required") + + template_records = load_template_records_from_file(file_arg) + user_ids = kwargs.get("user") or [] + team_ids = kwargs.get("team") or [] + + EnterprisePush.push_enterprise_records( + context.enterprise_data, + context.auth, + context.vault, + user_ids, + team_ids, + template_records, + ) diff --git a/keepercli-package/src/keepercli/register_commands.py b/keepercli-package/src/keepercli/register_commands.py index 3e609329..e6c12b80 100644 --- a/keepercli-package/src/keepercli/register_commands.py +++ b/keepercli-package/src/keepercli/register_commands.py @@ -90,7 +90,7 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS if not scopes or bool(scopes & base.CommandScope.Enterprise): from .commands import (enterprise_info, enterprise_node, enterprise_role, enterprise_team, enterprise_user, enterprise_create_user, importer_commands, audit_report, audit_alert, audit_log, transfer_account, pedm_admin, msp, user_report, - aging_report, action_report, security_audit_report) + aging_report, action_report, security_audit_report, enterprise_push) from .commands.pam import keeper_pam commands.register_command('create-user', enterprise_create_user.CreateEnterpriseUserCommand(), base.CommandScope.Enterprise, 'ecu') @@ -115,3 +115,4 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS commands.register_command('aging-report', aging_report.AgingReportCommand(), base.CommandScope.Enterprise, 'ar') commands.register_command('action-report', action_report.ActionReportCommand(), base.CommandScope.Enterprise, 'acr') commands.register_command('pam', keeper_pam.PAMControllerCommand(), base.CommandScope.Enterprise) + commands.register_command('enterprise-push', enterprise_push.EnterprisePushCommand(), base.CommandScope.Enterprise, 'ep') From 1e046e11c006d89205444f68ed3ad41a91b1a97e Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Fri, 20 Feb 2026 10:47:43 +0530 Subject: [PATCH 31/37] Compliance commands added --- .../compliance_record_access_report.py | 192 +++ .../compliance/compliance_report.py | 184 +++ .../compliance_shared_folder_report.py | 201 +++ .../compliance/compliance_summary_report.py | 176 ++ .../compliance/compliance_team_report.py | 172 ++ .../src/keepercli/commands/compliance.py | 524 ++++++ .../src/keepercli/register_commands.py | 3 +- .../src/keepersdk/enterprise/compliance.py | 1420 +++++++++++++++++ .../plugins/sox/compliance_storage.py | 308 ++++ .../keepersdk/plugins/sox/storage_types.py | 118 ++ 10 files changed, 3297 insertions(+), 1 deletion(-) create mode 100644 examples/sdk_examples/compliance/compliance_record_access_report.py create mode 100644 examples/sdk_examples/compliance/compliance_report.py create mode 100644 examples/sdk_examples/compliance/compliance_shared_folder_report.py create mode 100644 examples/sdk_examples/compliance/compliance_summary_report.py create mode 100644 examples/sdk_examples/compliance/compliance_team_report.py create mode 100644 keepercli-package/src/keepercli/commands/compliance.py create mode 100644 keepersdk-package/src/keepersdk/enterprise/compliance.py create mode 100644 keepersdk-package/src/keepersdk/plugins/sox/compliance_storage.py create mode 100644 keepersdk-package/src/keepersdk/plugins/sox/storage_types.py diff --git a/examples/sdk_examples/compliance/compliance_record_access_report.py b/examples/sdk_examples/compliance/compliance_record_access_report.py new file mode 100644 index 00000000..aa53f123 --- /dev/null +++ b/examples/sdk_examples/compliance/compliance_record_access_report.py @@ -0,0 +1,192 @@ +""" +Compliance Record Access Report SDK Example + +Usage: python compliance_record_access_report.py +""" + +import getpass +import logging +import os +import sqlite3 +import traceback + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage, compliance +from keepersdk.errors import KeeperApiError +from keepersdk.constants import KEEPER_PUBLIC_HOSTS +from keepersdk.plugins.sox import compliance_storage as cs + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + +TABLE_WIDTH = 250 +COL_WIDTHS_DEFAULT = (34, 22, 30, 14, 25, 8, 8, 34, 15, 15, 20) +COL_WIDTHS_AGING = (34, 22, 30, 14, 25, 8, 8, 34, 15, 15, 20, 12, 12, 12, 12) + + +def login(): + """Handle login with server selection and authentication.""" + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + logger.info("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + logger.info(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + username = config.get().last_login or input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + logger.info("Device approval request sent. Approve this device and press Enter to continue.") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + login_auth_context.login_step.verify_password(getpass.getpass('Enter password: ')) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + login_auth_context.login_step.send_code(channel.channel_uid, getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ')) + else: + raise NotImplementedError(f"Unsupported login step: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + logger.info("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + return None + + +def get_compliance_storage(config_path: str, enterprise_id: int): + """Create SQLite compliance storage for caching.""" + db_name = cs.get_compliance_database_name(config_path, enterprise_id) + storage = cs.SqliteComplianceStorage(lambda: cs.get_cached_connection(db_name), enterprise_id) + storage.database_name = db_name + storage.close_connection = lambda: cs.close_cached_connection(db_name) + return storage + + +def format_value(val): + """Format a single cell value for display.""" + if val is None: + return '' + if isinstance(val, bool): + return 'Yes' if val else '' + if isinstance(val, list): + return ', '.join(str(v) for v in val) if val else '' + return str(val) + + +def format_row(values, widths): + """Format a row of values according to column widths.""" + formatted = [] + for i, val in enumerate(values): + if i >= len(widths): + break + text = str(val if val is not None else '')[:widths[i] - 1] + formatted.append(f"{text:<{widths[i]}}") + return ' '.join(formatted) + + +def print_report(rows, headers, col_widths): + """Print the record access report in table format.""" + logger.info("\n" + "=" * TABLE_WIDTH) + logger.info("RECORD ACCESS REPORT") + logger.info("=" * TABLE_WIDTH) + + display_headers = [h.replace('_', ' ').title() for h in headers] + logger.info(format_row(display_headers, col_widths)) + logger.info("-" * TABLE_WIDTH) + + for row in rows: + logger.info(format_row([format_value(v) for v in row], col_widths)) + + logger.info("=" * TABLE_WIDTH) + logger.info(f"\nTotal Entries: {len(rows)}") + + if rows: + unique_users = len(set(r[0] for r in rows if len(r) > 0 and r[0])) + unique_records = len(set(r[1] for r in rows if len(r) > 1 and r[1])) + logger.info(f"\nSummary: {unique_users} vault owners, {unique_records} records") + + +def generate_record_access_report(keeper_auth_context: keeper_auth.KeeperAuth, aging: bool = False): + """Generate record access report with SQLite caching.""" + if not keeper_auth_context.auth_context.is_enterprise_admin: + logger.error("ERROR: Enterprise admin privileges required.") + keeper_auth_context.close() + return + + enterprise = None + compliance_storage = None + + try: + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + enterprise_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + enterprise = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + + config_path = os.path.expanduser('~/.keeper/config.json') + compliance_storage = get_compliance_storage(config_path, enterprise_id) + + logger.info("\nLoading enterprise data...") + + def progress_callback(msg): + if msg: + print(f"\r{msg}", end='', flush=True) + + config = compliance.ComplianceReportConfig(no_rebuild=True, cache_max_age_days=1, aging=aging) + generator = compliance.ComplianceReportGenerator( + enterprise.enterprise_data, keeper_auth_context, config, + compliance_storage=compliance_storage, progress_callback=progress_callback + ) + + rows = list(generator.generate_report_rows('record_access', report_type='history')) + headers = compliance.ComplianceReportGenerator.get_headers('record_access', aging=aging) + col_widths = COL_WIDTHS_AGING if aging else COL_WIDTHS_DEFAULT + print_report(rows, headers, col_widths) + + except KeeperApiError as e: + logger.error(f"\nAPI Error: {e}") + except Exception as e: + logger.error(f"\nError: {e}") + traceback.print_exc() + finally: + if compliance_storage and hasattr(compliance_storage, 'close_connection'): + compliance_storage.close_connection() + if enterprise: + enterprise.close() + keeper_auth_context.close() + + +def main(aging: bool = False): + """Run the compliance record access report. + + Args: + aging: If True, include aging columns in the report + """ + logger.info("=" * 60) + logger.info("Keeper Compliance Record Access Report") + if aging: + logger.info("(Including aging columns)") + logger.info("=" * 60 + "\n") + + keeper_auth_context = login() + if keeper_auth_context: + generate_record_access_report(keeper_auth_context, aging=aging) + else: + logger.error("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/compliance/compliance_report.py b/examples/sdk_examples/compliance/compliance_report.py new file mode 100644 index 00000000..3b78197b --- /dev/null +++ b/examples/sdk_examples/compliance/compliance_report.py @@ -0,0 +1,184 @@ +""" +Compliance Report SDK Example + +Usage: python compliance_report.py +""" + +import getpass +import logging +import os +import sqlite3 +import traceback + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage, compliance +from keepersdk.errors import KeeperApiError +from keepersdk.constants import KEEPER_PUBLIC_HOSTS +from keepersdk.plugins.sox import compliance_storage as cs + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + +TABLE_WIDTH = 200 +COL_WIDTHS = (22, 30, 15, 34, 12, 40, 10, 24) + + +def login(): + """Handle login with server selection and authentication.""" + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + logger.info("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + logger.info(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + username = config.get().last_login or input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + logger.info("Device approval request sent. Approve this device and press Enter to continue.") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + login_auth_context.login_step.verify_password(getpass.getpass('Enter password: ')) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + login_auth_context.login_step.send_code(channel.channel_uid, getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ')) + else: + raise NotImplementedError(f"Unsupported login step: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + logger.info("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + return None + + +def get_compliance_storage(config_path: str, enterprise_id: int): + """Create SQLite compliance storage for caching.""" + db_name = cs.get_compliance_database_name(config_path, enterprise_id) + storage = cs.SqliteComplianceStorage(lambda: cs.get_cached_connection(db_name), enterprise_id) + storage.database_name = db_name + storage.close_connection = lambda: cs.close_cached_connection(db_name) + return storage + + +def format_value(val): + """Format a single cell value for display.""" + if val is None: + return '' + if isinstance(val, bool): + return 'Yes' if val else '' + if isinstance(val, list): + return ', '.join(str(v) for v in val) if val else '' + return str(val) + + +def format_row(values, widths=COL_WIDTHS): + """Format a row of values according to column widths.""" + formatted = [] + for i, val in enumerate(values): + if i >= len(widths): + break + text = str(val if val is not None else '')[:widths[i] - 1] + formatted.append(f"{text:<{widths[i]}}") + return ' '.join(formatted) + + +def print_report(rows, headers): + """Print the compliance report in table format.""" + logger.info("\n" + "=" * TABLE_WIDTH) + logger.info("COMPLIANCE REPORT") + logger.info("=" * TABLE_WIDTH) + + display_headers = [h.replace('_', ' ').title() for h in headers] + logger.info(format_row(display_headers)) + logger.info("-" * TABLE_WIDTH) + + for row in rows: + logger.info(format_row([format_value(v) for v in row])) + + logger.info("=" * TABLE_WIDTH) + logger.info(f"\nTotal Entries: {len(rows)}") + + if rows: + unique_records = len(set(r[0] for r in rows if r[0])) + unique_users = len(set(r[3] for r in rows if len(r) > 3 and r[3])) + trash_count = sum(1 for r in rows if len(r) > 6 and r[6]) + logger.info(f"\nSummary: {unique_records} records, {unique_users} users, {trash_count} in trash") + + +def generate_compliance_report(keeper_auth_context: keeper_auth.KeeperAuth): + """Generate default compliance report with SQLite caching.""" + if not keeper_auth_context.auth_context.is_enterprise_admin: + logger.error("ERROR: Enterprise admin privileges required.") + keeper_auth_context.close() + return + + enterprise = None + compliance_storage = None + + try: + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + enterprise_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + enterprise = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + + config_path = os.path.expanduser('~/.keeper/config.json') + compliance_storage = get_compliance_storage(config_path, enterprise_id) + + logger.info("\nLoading enterprise data...") + + def progress_callback(msg): + if msg: + print(f"\r{msg}", end='', flush=True) + + config = compliance.ComplianceReportConfig(no_rebuild=True, cache_max_age_days=1) + generator = compliance.ComplianceReportGenerator( + enterprise.enterprise_data, keeper_auth_context, config, + compliance_storage=compliance_storage, progress_callback=progress_callback + ) + + rows = list(generator.generate_report_rows('default', blank_duplicate_uids=True)) + headers = compliance.ComplianceReportGenerator.get_headers('default') + print_report(rows, headers) + + except KeeperApiError as e: + logger.error(f"\nAPI Error: {e}") + except Exception as e: + logger.error(f"\nError: {e}") + traceback.print_exc() + finally: + if compliance_storage and hasattr(compliance_storage, 'close_connection'): + compliance_storage.close_connection() + if enterprise: + enterprise.close() + keeper_auth_context.close() + + +def main(): + logger.info("=" * 60) + logger.info("Keeper Compliance Report") + logger.info("=" * 60 + "\n") + + keeper_auth_context = login() + if keeper_auth_context: + generate_compliance_report(keeper_auth_context) + else: + logger.error("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/compliance/compliance_shared_folder_report.py b/examples/sdk_examples/compliance/compliance_shared_folder_report.py new file mode 100644 index 00000000..52b026e7 --- /dev/null +++ b/examples/sdk_examples/compliance/compliance_shared_folder_report.py @@ -0,0 +1,201 @@ +""" +Compliance Shared Folder Report SDK Example + +Usage: python compliance_shared_folder_report.py +""" + +import getpass +import logging +import os +import sqlite3 +import traceback + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage, compliance +from keepersdk.errors import KeeperApiError +from keepersdk.constants import KEEPER_PUBLIC_HOSTS +from keepersdk.plugins.sox import compliance_storage as cs + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + +TABLE_WIDTH = 180 +COL_WIDTHS = (22, 12, 12, 22, 50, 34) + + +def login(): + """Handle login with server selection and authentication.""" + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + logger.info("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + logger.info(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + username = config.get().last_login or input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + logger.info("Device approval request sent. Approve this device and press Enter to continue.") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + login_auth_context.login_step.verify_password(getpass.getpass('Enter password: ')) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + login_auth_context.login_step.send_code(channel.channel_uid, getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ')) + else: + raise NotImplementedError(f"Unsupported login step: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + logger.info("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + return None + + +def get_compliance_storage(config_path: str, enterprise_id: int): + """Create SQLite compliance storage for caching.""" + db_name = cs.get_compliance_database_name(config_path, enterprise_id) + storage = cs.SqliteComplianceStorage(lambda: cs.get_cached_connection(db_name), enterprise_id) + storage.database_name = db_name + storage.close_connection = lambda: cs.close_cached_connection(db_name) + return storage + + +def format_row(values, widths=COL_WIDTHS): + """Format a row of values according to column widths.""" + formatted = [] + for i, val in enumerate(values): + if i >= len(widths): + break + text = str(val if val is not None else '')[:widths[i] - 1] + formatted.append(f"{text:<{widths[i]}}") + return ' '.join(formatted) + + +def to_list(val): + """Convert a value to a list if not already.""" + if isinstance(val, list): + return val + return [val] if val else [] + + +def flatten_rows(rows): + """Flatten rows with list values into individual rows.""" + flattened = [] + for row in rows: + sf_uid = row[0] if len(row) > 0 else '' + team_uid = to_list(row[1] if len(row) > 1 else '') + team_name = to_list(row[2] if len(row) > 2 else '') + record_uids = to_list(row[3] if len(row) > 3 else []) + record_titles = to_list(row[4] if len(row) > 4 else []) + emails = to_list(row[5] if len(row) > 5 else []) + + max_rows = max(len(record_uids), len(record_titles), len(emails), 1) + + for i in range(max_rows): + flattened.append(( + sf_uid if i == 0 else '', + team_uid[0] if team_uid and i == 0 else '', + team_name[0] if team_name and i == 0 else '', + record_uids[i] if i < len(record_uids) else '', + record_titles[i] if i < len(record_titles) else '', + emails[i] if i < len(emails) else '' + )) + return flattened + + +def print_report(rows, headers): + """Print the shared folder report in table format.""" + logger.info("\n" + "=" * TABLE_WIDTH) + logger.info("SHARED FOLDER REPORT") + logger.info("=" * TABLE_WIDTH) + + logger.info(format_row([h.replace('_', ' ').title() for h in headers])) + logger.info("-" * TABLE_WIDTH) + + flattened = flatten_rows(rows) + for row in flattened: + logger.info(format_row([str(v) if v else '' for v in row])) + + logger.info("=" * TABLE_WIDTH) + logger.info(f"\nTotal Rows: {len(flattened)}") + logger.info(f"Summary: {len(set(r[0] for r in flattened if r[0]))} shared folders, " + f"{len(set(r[3] for r in flattened if r[3]))} records") + + +def generate_shared_folder_report(keeper_auth_context: keeper_auth.KeeperAuth): + """Generate shared folder report with SQLite caching.""" + if not keeper_auth_context.auth_context.is_enterprise_admin: + logger.error("ERROR: Enterprise admin privileges required.") + keeper_auth_context.close() + return + + enterprise = None + compliance_storage = None + + try: + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + enterprise_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + enterprise = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + + config_path = os.path.expanduser('~/.keeper/config.json') + compliance_storage = get_compliance_storage(config_path, enterprise_id) + + logger.info("\nLoading enterprise data...") + + def progress_callback(msg): + if msg: + print(f"\r{msg}", end='', flush=True) + + config = compliance.ComplianceReportConfig(shared=True, no_rebuild=True, cache_max_age_days=1) + generator = compliance.ComplianceReportGenerator( + enterprise.enterprise_data, keeper_auth_context, config, + compliance_storage=compliance_storage, progress_callback=progress_callback + ) + + rows = list(generator.generate_report_rows('shared_folder')) + headers = compliance.ComplianceReportGenerator.get_headers('shared_folder') + print_report(rows, headers) + + except KeeperApiError as e: + logger.error(f"\nAPI Error: {e}") + except Exception as e: + logger.error(f"\nError: {e}") + traceback.print_exc() + finally: + if compliance_storage and hasattr(compliance_storage, 'close_connection'): + compliance_storage.close_connection() + if enterprise: + enterprise.close() + keeper_auth_context.close() + + +def main(): + logger.info("=" * 60) + logger.info("Keeper Compliance Shared Folder Report") + logger.info("=" * 60 + "\n") + + keeper_auth_context = login() + if keeper_auth_context: + generate_shared_folder_report(keeper_auth_context) + else: + logger.error("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/compliance/compliance_summary_report.py b/examples/sdk_examples/compliance/compliance_summary_report.py new file mode 100644 index 00000000..48669f21 --- /dev/null +++ b/examples/sdk_examples/compliance/compliance_summary_report.py @@ -0,0 +1,176 @@ +""" +Compliance Summary Report SDK Example + +Usage: python compliance_summary_report.py +""" + +import getpass +import logging +import os +import sqlite3 +import traceback + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage, compliance +from keepersdk.errors import KeeperApiError +from keepersdk.constants import KEEPER_PUBLIC_HOSTS +from keepersdk.plugins.sox import compliance_storage as cs + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + +TABLE_WIDTH = 100 +COL_WIDTHS = (40, 15, 15, 12, 12) + + +def login(): + """Handle login with server selection and authentication.""" + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + logger.info("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + logger.info(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + username = config.get().last_login or input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + logger.info("Device approval request sent. Approve this device and press Enter to continue.") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + login_auth_context.login_step.verify_password(getpass.getpass('Enter password: ')) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + login_auth_context.login_step.send_code(channel.channel_uid, getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ')) + else: + raise NotImplementedError(f"Unsupported login step: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + logger.info("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + return None + + +def get_compliance_storage(config_path: str, enterprise_id: int): + """Create SQLite compliance storage for caching.""" + db_name = cs.get_compliance_database_name(config_path, enterprise_id) + storage = cs.SqliteComplianceStorage(lambda: cs.get_cached_connection(db_name), enterprise_id) + storage.database_name = db_name + storage.close_connection = lambda: cs.close_cached_connection(db_name) + return storage + + +def format_row(values, widths=COL_WIDTHS): + """Format a row of values according to column widths.""" + formatted = [] + for i, val in enumerate(values): + if i >= len(widths): + break + text = str(val if val is not None else '')[:widths[i] - 1] + formatted.append(f"{text:<{widths[i]}}") + return ' '.join(formatted) + + +def print_report(rows, headers): + """Print the summary report in table format.""" + logger.info("\n" + "=" * TABLE_WIDTH) + logger.info("SUMMARY REPORT") + logger.info("=" * TABLE_WIDTH) + + display_headers = [h.replace('_', ' ').title() for h in headers] + logger.info(format_row(display_headers)) + logger.info("-" * TABLE_WIDTH) + + totals = [0, 0, 0, 0] + for row in rows: + for i in range(4): + totals[i] += row[i + 1] if len(row) > i + 1 and row[i + 1] else 0 + logger.info(format_row([str(v) if v is not None else '' for v in row])) + + logger.info("-" * TABLE_WIDTH) + logger.info(format_row(['TOTAL'] + totals)) + logger.info("=" * TABLE_WIDTH) + logger.info(f"\nTotal Users: {len(rows)}") + + if rows: + avg_items = totals[0] / len(rows) if rows else 0 + logger.info(f"Summary: {totals[0]} items, {totals[1]} owned, avg {avg_items:.1f} per user") + + +def generate_summary_report(keeper_auth_context: keeper_auth.KeeperAuth): + """Generate summary report with SQLite caching.""" + if not keeper_auth_context.auth_context.is_enterprise_admin: + logger.error("ERROR: Enterprise admin privileges required.") + keeper_auth_context.close() + return + + enterprise = None + compliance_storage = None + + try: + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + enterprise_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + enterprise = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + + config_path = os.path.expanduser('~/.keeper/config.json') + compliance_storage = get_compliance_storage(config_path, enterprise_id) + + logger.info("\nLoading enterprise data...") + + def progress_callback(msg): + if msg: + print(f"\r{msg}", end='', flush=True) + + config = compliance.ComplianceReportConfig(no_rebuild=True, cache_max_age_days=1) + generator = compliance.ComplianceReportGenerator( + enterprise.enterprise_data, keeper_auth_context, config, + compliance_storage=compliance_storage, progress_callback=progress_callback + ) + + rows = list(generator.generate_report_rows('summary')) + headers = compliance.ComplianceReportGenerator.get_headers('summary') + print_report(rows, headers) + + except KeeperApiError as e: + logger.error(f"\nAPI Error: {e}") + except Exception as e: + logger.error(f"\nError: {e}") + traceback.print_exc() + finally: + if compliance_storage and hasattr(compliance_storage, 'close_connection'): + compliance_storage.close_connection() + if enterprise: + enterprise.close() + keeper_auth_context.close() + + +def main(): + logger.info("=" * 60) + logger.info("Keeper Compliance Summary Report") + logger.info("=" * 60 + "\n") + + keeper_auth_context = login() + if keeper_auth_context: + generate_summary_report(keeper_auth_context) + else: + logger.error("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/compliance/compliance_team_report.py b/examples/sdk_examples/compliance/compliance_team_report.py new file mode 100644 index 00000000..a4472e47 --- /dev/null +++ b/examples/sdk_examples/compliance/compliance_team_report.py @@ -0,0 +1,172 @@ +""" +Compliance Team Report SDK Example + +Usage: python compliance_team_report.py +""" + +import getpass +import logging +import os +import sqlite3 +import traceback + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage, compliance +from keepersdk.errors import KeeperApiError +from keepersdk.constants import KEEPER_PUBLIC_HOSTS +from keepersdk.plugins.sox import compliance_storage as cs + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + +TABLE_WIDTH = 140 +COL_WIDTHS = (30, 24, 35, 20, 10) + + +def login(): + """Handle login with server selection and authentication.""" + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + logger.info("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + logger.info(f" {region}: {host}") + server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + username = config.get().last_login or input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + logger.info("Device approval request sent. Approve this device and press Enter to continue.") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + login_auth_context.login_step.verify_password(getpass.getpass('Enter password: ')) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + login_auth_context.login_step.send_code(channel.channel_uid, getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ')) + else: + raise NotImplementedError(f"Unsupported login step: {type(login_auth_context.login_step).__name__}") + logged_in_with_persistent = False + + if logged_in_with_persistent: + logger.info("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + return None + + +def get_compliance_storage(config_path: str, enterprise_id: int): + """Create SQLite compliance storage for caching.""" + db_name = cs.get_compliance_database_name(config_path, enterprise_id) + storage = cs.SqliteComplianceStorage(lambda: cs.get_cached_connection(db_name), enterprise_id) + storage.database_name = db_name + storage.close_connection = lambda: cs.close_cached_connection(db_name) + return storage + + +def format_row(values, widths=COL_WIDTHS): + """Format a row of values according to column widths.""" + formatted = [] + for i, val in enumerate(values): + if i >= len(widths): + break + text = str(val if val is not None else '')[:widths[i] - 1] + formatted.append(f"{text:<{widths[i]}}") + return ' '.join(formatted) + + +def print_report(rows, headers): + """Print the team report in table format.""" + logger.info("\n" + "=" * TABLE_WIDTH) + logger.info("TEAM ACCESS REPORT") + logger.info("=" * TABLE_WIDTH) + + logger.info(format_row([h.replace('_', ' ').title() for h in headers])) + logger.info("-" * TABLE_WIDTH) + + for row in rows: + logger.info(format_row([str(v) if v is not None else '' for v in row])) + + logger.info("=" * TABLE_WIDTH) + logger.info(f"\nTotal Entries: {len(rows)}") + + if rows: + unique_teams = len(set(r[1] for r in rows if len(r) > 1 and r[1])) + unique_folders = len(set(r[3] for r in rows if len(r) > 3 and r[3])) + total_records = sum(r[5] for r in rows if len(r) > 5 and r[5]) + logger.info(f"\nSummary: {unique_teams} teams, {unique_folders} folders, {total_records} records") + + +def generate_team_report(keeper_auth_context: keeper_auth.KeeperAuth): + """Generate team access report with SQLite caching.""" + if not keeper_auth_context.auth_context.is_enterprise_admin: + logger.error("ERROR: Enterprise admin privileges required.") + keeper_auth_context.close() + return + + enterprise = None + compliance_storage = None + + try: + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + enterprise_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + enterprise = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + + config_path = os.path.expanduser('~/.keeper/config.json') + compliance_storage = get_compliance_storage(config_path, enterprise_id) + + logger.info("\nLoading enterprise data...") + + def progress_callback(msg): + if msg: + print(f"\r{msg}", end='', flush=True) + + config = compliance.ComplianceReportConfig(shared=True, no_rebuild=True, cache_max_age_days=1) + generator = compliance.ComplianceReportGenerator( + enterprise.enterprise_data, keeper_auth_context, config, + compliance_storage=compliance_storage, progress_callback=progress_callback + ) + + rows = list(generator.generate_report_rows('team')) + headers = compliance.ComplianceReportGenerator.get_headers('team') + print_report(rows, headers) + + except KeeperApiError as e: + logger.error(f"\nAPI Error: {e}") + except Exception as e: + logger.error(f"\nError: {e}") + traceback.print_exc() + finally: + if compliance_storage and hasattr(compliance_storage, 'close_connection'): + compliance_storage.close_connection() + if enterprise: + enterprise.close() + keeper_auth_context.close() + + +def main(): + logger.info("=" * 60) + logger.info("Keeper Compliance Team Report") + logger.info("=" * 60 + "\n") + + keeper_auth_context = login() + if keeper_auth_context: + generate_team_report(keeper_auth_context) + else: + logger.error("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/keepercli-package/src/keepercli/commands/compliance.py b/keepercli-package/src/keepercli/commands/compliance.py new file mode 100644 index 00000000..60cd733e --- /dev/null +++ b/keepercli-package/src/keepercli/commands/compliance.py @@ -0,0 +1,524 @@ +"""Compliance command for Keeper CLI.""" + +import argparse +import os +import re +import sqlite3 +import sys +import threading +import time +from typing import Any, List, Optional + +from keepersdk.enterprise import compliance +from keepersdk.plugins.sox import compliance_storage as cs + +from . import base +from ..helpers import report_utils +from ..params import KeeperParams +from .. import api + +logger = api.get_logger() + + +class ProgressSpinner: + """Animated spinner for progress display.""" + + def __init__(self): + self._spinner_chars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + self._current = 0 + self._running = False + self._thread = None + self._message = '' + self._lock = threading.Lock() + + def start(self, message: str = '') -> None: + self._message = message + self._running = True + self._thread = threading.Thread(target=self._spin, daemon=True) + self._thread.start() + + def update(self, message: str) -> None: + with self._lock: + self._message = message + + def stop(self, final_message: str = '') -> None: + self._running = False + if self._thread: + self._thread.join(timeout=0.5) + sys.stdout.write('\r' + ' ' * 80 + '\r') + if final_message: + sys.stdout.write(final_message + '\n') + sys.stdout.flush() + + def _spin(self) -> None: + while self._running: + with self._lock: + char = self._spinner_chars[self._current % len(self._spinner_chars)] + sys.stdout.write(f'\r{char} {self._message}') + sys.stdout.flush() + self._current += 1 + time.sleep(0.1) + + +def get_compliance_storage(context: KeeperParams) -> Optional[cs.SqliteComplianceStorage]: + """Create or get the SQLite compliance storage.""" + if not context.auth or not context.auth.auth_context: + return None + + enterprise_id = context.auth.auth_context.enterprise_id + if not enterprise_id: + return None + + config_path = context.keeper_config.config_filename or os.path.expanduser('~/.keeper/config.json') + db_name = cs.get_compliance_database_name(config_path, enterprise_id) + + def get_connection() -> sqlite3.Connection: + return cs.get_cached_connection(db_name) + + storage = cs.SqliteComplianceStorage(get_connection, enterprise_id) + storage.database_name = db_name + storage.close_connection = lambda: cs.close_cached_connection(db_name) + return storage + + +def get_node_id(enterprise_data, name: str) -> int: + """Resolve node ID from name or numeric ID.""" + if isinstance(name, str) and name.isdecimal(): + name = int(name) + + nodes = list(enterprise_data.nodes.get_all_entities()) + if not nodes: + return 0 + + node_ids = {n.node_id for n in nodes} + node_id_lookup = {n.name: n.node_id for n in nodes if n.name} + + if isinstance(name, str) and name in node_id_lookup: + return node_id_lookup[name] + elif isinstance(name, int) and name in node_ids: + return name + return nodes[0].node_id + + +def filter_rows(rows: List[List[Any]], patterns: List[str], use_regex: bool = False) -> List[List[Any]]: + """Filter rows based on search patterns.""" + if not patterns: + return rows + + filtered = [] + for row in rows: + row_text = ' '.join(str(cell) for cell in row if cell is not None) + for pattern in patterns: + match = re.search(pattern, row_text, re.IGNORECASE) if use_regex else pattern.lower() in row_text.lower() + if match: + filtered.append(row) + break + return filtered + + +def add_common_arguments(parser: argparse.ArgumentParser): + """Add common arguments shared by all compliance subcommands.""" + rebuild_group = parser.add_mutually_exclusive_group() + rebuild_group.add_argument('--rebuild', '-r', action='store_true', + help='rebuild local data from source') + rebuild_group.add_argument('--no-rebuild', '-nr', action='store_true', + help='prevent remote data fetching if local cache present') + parser.add_argument('--no-cache', '-nc', action='store_true', + help='remove any local non-memory storage of data after report is generated') + parser.add_argument('--node', action='store', + help='ID or name of node (defaults to root node)') + parser.add_argument('--regex', action='store_true', + help='Allow use of regular expressions in search criteria') + parser.add_argument('pattern', type=str, nargs='*', + help='Search string / pattern to filter results by. Multiple values allowed.') + + +def create_progress_callback(spinner: ProgressSpinner): + """Create a progress callback function for the spinner.""" + def callback(msg): + if msg: + spinner.update(msg) + else: + spinner.stop() + return callback + + +class ComplianceCommand(base.GroupCommand): + """Group command for all compliance reporting functions.""" + + def __init__(self): + super().__init__('Compliance Reporting for auditing') + self.register_command(ComplianceReportCommand(), 'report', 'r') + self.register_command(ComplianceTeamReportCommand(), 'team-report', 'tr') + self.register_command(ComplianceRecordAccessReportCommand(), 'record-access-report', 'rar') + self.register_command(ComplianceSummaryReportCommand(), 'summary-report', 'sr') + self.register_command(ComplianceSharedFolderReportCommand(), 'shared-folder-report', 'sfr') + self.default_verb = 'report' + + +class ComplianceReportCommand(base.ArgparseCommand): + """Command to generate default compliance report.""" + + def __init__(self): + parser = argparse.ArgumentParser( + prog='compliance report', + description='Run a compliance report.', + parents=[base.report_output_parser] + ) + add_common_arguments(parser) + ComplianceReportCommand.add_arguments_to_parser(parser) + super().__init__(parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser): + """Add compliance report specific arguments.""" + parser.add_argument('--username', '-u', action='append', + help='user(s) whose records are to be included in report') + parser.add_argument('--job-title', '-jt', action='append', + help='job title(s) of users whose records are to be included in report') + parser.add_argument('--team', action='append', + help='name or UID of team(s) whose members\' records are to be included in report') + parser.add_argument('--record', action='append', + help='UID or title of record(s) to include in report') + parser.add_argument('--url', action='append', + help='URL of record(s) to include in report') + parser.add_argument('--shared', action='store_true', + help='show shared records only') + deleted_status_group = parser.add_mutually_exclusive_group() + deleted_status_group.add_argument('--deleted-items', action='store_true', + help='show deleted records only') + deleted_status_group.add_argument('--active-items', action='store_true', + help='show active records only') + + def execute(self, context: KeeperParams, **kwargs) -> Any: + base.require_login(context) + base.require_enterprise_admin(context) + + output_format = kwargs.get('format', 'table') + no_cache = kwargs.get('no_cache', False) + enterprise_data = context.enterprise_data + node_id = get_node_id(enterprise_data, kwargs['node']) if kwargs.get('node') else None + + config = compliance.ComplianceReportConfig( + username=kwargs.get('username'), + job_title=kwargs.get('job_title'), + team=kwargs.get('team'), + record=kwargs.get('record'), + url=kwargs.get('url'), + shared=kwargs.get('shared', False), + deleted_items=kwargs.get('deleted_items', False), + active_items=kwargs.get('active_items', False), + node_id=node_id, + rebuild=kwargs.get('rebuild', False), + no_rebuild=kwargs.get('no_rebuild', False), + no_cache=no_cache + ) + + spinner = ProgressSpinner() + spinner.start('Loading...') + + generator = compliance.ComplianceReportGenerator( + enterprise_data, context.auth, config, + vault_storage=context.vault.vault_data.storage, + compliance_storage=None if no_cache else get_compliance_storage(context), + progress_callback=create_progress_callback(spinner) + ) + + rows = list(generator.generate_report_rows('default', blank_duplicate_uids=(output_format == 'table'))) + spinner.stop() + + headers = compliance.ComplianceReportGenerator.get_headers('default') + if output_format != 'json': + headers = [report_utils.field_to_title(h) for h in headers] + + if kwargs.get('pattern'): + rows = filter_rows(rows, kwargs['pattern'], use_regex=kwargs.get('regex')) + + if node_id: + logger.info(f'Output is limited to "{kwargs["node"]}" node') + + return report_utils.dump_report_data( + rows, headers, fmt=output_format, filename=kwargs.get('output'), + title='Compliance Report' + ) + + +class ComplianceTeamReportCommand(base.ArgparseCommand): + """Command to generate team access report.""" + + def __init__(self): + parser = argparse.ArgumentParser( + prog='compliance team-report', + description='Run a report showing which shared folders enterprise teams have access to', + parents=[base.report_output_parser] + ) + add_common_arguments(parser) + ComplianceTeamReportCommand.add_arguments_to_parser(parser) + super().__init__(parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser): + """Add team report specific arguments.""" + parser.add_argument('-tu', '--show-team-users', action='store_true', + help='show all members of each team') + + def execute(self, context: KeeperParams, **kwargs) -> Any: + base.require_login(context) + base.require_enterprise_admin(context) + + output_format = kwargs.get('format', 'table') + output_file = kwargs.get('output') + no_cache = kwargs.get('no_cache', False) + show_team_users = kwargs.get('show_team_users', False) + enterprise_data = context.enterprise_data + node_id = get_node_id(enterprise_data, kwargs.get('node')) if kwargs.get('node') else None + + config = compliance.ComplianceReportConfig( + shared=True, + show_team_users=show_team_users, + node_id=node_id, + rebuild=kwargs.get('rebuild', False), + no_rebuild=kwargs.get('no_rebuild', False), + no_cache=no_cache + ) + + spinner = ProgressSpinner() + spinner.start('Loading...') + + generator = compliance.ComplianceReportGenerator( + enterprise_data, context.auth, config, + compliance_storage=None if no_cache else get_compliance_storage(context), + progress_callback=create_progress_callback(spinner) + ) + + rows = list(generator.generate_report_rows('team')) + spinner.stop() + + headers = compliance.ComplianceReportGenerator.get_headers('team', show_team_users) + if output_format != 'json': + headers = [report_utils.field_to_title(h) for h in headers] + + if kwargs.get('pattern'): + rows = filter_rows(rows, kwargs['pattern'], use_regex=kwargs.get('regex')) + + result = report_utils.dump_report_data( + rows, headers, fmt=output_format, filename=output_file, title='Team Access Report' + ) + + if output_file: + logger.info(f'Report saved to: {os.path.abspath(output_file)}') + return result + + +class ComplianceRecordAccessReportCommand(base.ArgparseCommand): + """Command to generate record access history report.""" + + def __init__(self): + parser = argparse.ArgumentParser( + prog='compliance record-access-report', + description='Run a report showing all records a user has accessed or can access', + parents=[base.report_output_parser] + ) + add_common_arguments(parser) + ComplianceRecordAccessReportCommand.add_arguments_to_parser(parser) + super().__init__(parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser): + """Add record access report specific arguments.""" + parser.add_argument('--email', '-e', action='append', type=str, + help='username(s) or ID(s), use "@all" for all users') + parser.add_argument('--report-type', action='store', choices=['history', 'vault'], default='history', + help='type of record-access data: "history" or "vault"') + parser.add_argument('--aging', action='store_true', + help='include record-aging data') + + def execute(self, context: KeeperParams, **kwargs) -> Any: + base.require_login(context) + base.require_enterprise_admin(context) + + output_format = kwargs.get('format', 'table') + output_file = kwargs.get('output') + report_type = kwargs.get('report_type', 'history') + no_cache = kwargs.get('no_cache', False) + aging = kwargs.get('aging', False) + emails = kwargs.get('email', []) + enterprise_data = context.enterprise_data + node_id = get_node_id(enterprise_data, kwargs.get('node')) if kwargs.get('node') else None + + config = compliance.ComplianceReportConfig( + username=emails if emails and '@all' not in emails else None, + node_id=node_id, + rebuild=kwargs.get('rebuild', False), + no_rebuild=kwargs.get('no_rebuild', False), + no_cache=no_cache, + aging=aging + ) + + spinner = ProgressSpinner() + spinner.start('Loading...') + + generator = compliance.ComplianceReportGenerator( + enterprise_data, context.auth, config, + vault_storage=context.vault.vault_data.storage, + compliance_storage=None if no_cache else get_compliance_storage(context), + progress_callback=create_progress_callback(spinner) + ) + + rows = list(generator.generate_report_rows('record_access', report_type=report_type)) + spinner.stop() + + headers = compliance.ComplianceReportGenerator.get_headers('record_access', aging=aging) + if output_format != 'json': + headers = [report_utils.field_to_title(h) for h in headers] + + if kwargs.get('pattern'): + rows = filter_rows(rows, kwargs['pattern'], use_regex=kwargs.get('regex')) + + rows.sort(key=lambda r: (r[0] or '', r[1] if len(r) > 1 else '')) + + result = report_utils.dump_report_data( + rows, headers, fmt=output_format, filename=output_file, + title=f'Record Access Report ({report_type})', + group_by=0, column_width=30, sort_by=0 + ) + + if output_file: + logger.info(f'Report saved to: {os.path.abspath(output_file)}') + return result + + +class ComplianceSummaryReportCommand(base.ArgparseCommand): + """Command to generate summary compliance report.""" + + def __init__(self): + parser = argparse.ArgumentParser( + prog='compliance summary-report', + description='Run a summary compliance report', + parents=[base.report_output_parser] + ) + add_common_arguments(parser) + super().__init__(parser) + + def execute(self, context: KeeperParams, **kwargs) -> Any: + base.require_login(context) + base.require_enterprise_admin(context) + + output_format = kwargs.get('format', 'table') + output_file = kwargs.get('output') + no_cache = kwargs.get('no_cache', False) + enterprise_data = context.enterprise_data + node_id = get_node_id(enterprise_data, kwargs.get('node')) if kwargs.get('node') else None + + config = compliance.ComplianceReportConfig( + node_id=node_id, + rebuild=kwargs.get('rebuild', False), + no_rebuild=kwargs.get('no_rebuild', False), + no_cache=no_cache + ) + + spinner = ProgressSpinner() + spinner.start('Loading...') + + generator = compliance.ComplianceReportGenerator( + enterprise_data, context.auth, config, + compliance_storage=None if no_cache else get_compliance_storage(context), + progress_callback=create_progress_callback(spinner) + ) + + rows = list(generator.generate_report_rows('summary')) + spinner.stop() + + if node_id: + logger.info(f'Output is limited to "{node_id}" node') + + total_items = sum(row[1] for row in rows if len(row) > 1) + total_owned = sum(row[2] for row in rows if len(row) > 2) + active_owned = sum(row[3] for row in rows if len(row) > 3) + deleted_owned = sum(row[4] for row in rows if len(row) > 4) + rows.append(['TOTAL', total_items, total_owned, active_owned, deleted_owned]) + + headers = compliance.ComplianceReportGenerator.get_headers('summary') + if output_format != 'json': + headers = [report_utils.field_to_title(h) for h in headers] + + if kwargs.get('pattern'): + rows = filter_rows(rows, kwargs['pattern'], use_regex=kwargs.get('regex')) + + result = report_utils.dump_report_data( + rows, headers, fmt=output_format, filename=output_file, title='Compliance Summary Report' + ) + + if output_file: + logger.info(f'Report saved to: {os.path.abspath(output_file)}') + return result + + +class ComplianceSharedFolderReportCommand(base.ArgparseCommand): + """Command to generate shared folder access report.""" + + def __init__(self): + parser = argparse.ArgumentParser( + prog='compliance shared-folder-report', + description='Run an enterprise-wide shared-folder report', + parents=[base.report_output_parser] + ) + add_common_arguments(parser) + ComplianceSharedFolderReportCommand.add_arguments_to_parser(parser) + super().__init__(parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser): + """Add shared folder report specific arguments.""" + parser.add_argument('-tu', '--show-team-users', action='store_true', + help='show all members of each team') + + def execute(self, context: KeeperParams, **kwargs) -> Any: + base.require_login(context) + base.require_enterprise_admin(context) + + output_format = kwargs.get('format', 'table') + output_file = kwargs.get('output') + no_cache = kwargs.get('no_cache', False) + show_team_users = kwargs.get('show_team_users', False) + enterprise_data = context.enterprise_data + node_id = get_node_id(enterprise_data, kwargs.get('node')) if kwargs.get('node') else None + + config = compliance.ComplianceReportConfig( + shared=True, + show_team_users=show_team_users, + node_id=node_id, + rebuild=kwargs.get('rebuild', False), + no_rebuild=kwargs.get('no_rebuild', False), + no_cache=no_cache + ) + + spinner = ProgressSpinner() + spinner.start('Loading...') + + generator = compliance.ComplianceReportGenerator( + enterprise_data, context.auth, config, + compliance_storage=None if no_cache else get_compliance_storage(context), + progress_callback=create_progress_callback(spinner) + ) + + rows = list(generator.generate_report_rows('shared_folder')) + spinner.stop() + + headers = compliance.ComplianceReportGenerator.get_headers('shared_folder') + if output_format != 'json': + headers = [report_utils.field_to_title(h) for h in headers] + + if kwargs.get('pattern'): + rows = filter_rows(rows, kwargs['pattern'], use_regex=kwargs.get('regex')) + + title = '(TU) denotes a user whose membership in a team grants them access' \ + if show_team_users else 'Shared Folder Report' + + result = report_utils.dump_report_data( + rows, headers, fmt=output_format, filename=output_file, title=title + ) + + if output_file: + logger.info(f'Report saved to: {os.path.abspath(output_file)}') + return result diff --git a/keepercli-package/src/keepercli/register_commands.py b/keepercli-package/src/keepercli/register_commands.py index e6c12b80..29e768e2 100644 --- a/keepercli-package/src/keepercli/register_commands.py +++ b/keepercli-package/src/keepercli/register_commands.py @@ -90,7 +90,7 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS if not scopes or bool(scopes & base.CommandScope.Enterprise): from .commands import (enterprise_info, enterprise_node, enterprise_role, enterprise_team, enterprise_user, enterprise_create_user, importer_commands, audit_report, audit_alert, audit_log, transfer_account, pedm_admin, msp, user_report, - aging_report, action_report, security_audit_report, enterprise_push) + aging_report, action_report, security_audit_report, enterprise_push, compliance) from .commands.pam import keeper_pam commands.register_command('create-user', enterprise_create_user.CreateEnterpriseUserCommand(), base.CommandScope.Enterprise, 'ecu') @@ -114,5 +114,6 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS commands.register_command('security-audit-report', security_audit_report.SecurityAuditReportCommand(), base.CommandScope.Enterprise, 'sar') commands.register_command('aging-report', aging_report.AgingReportCommand(), base.CommandScope.Enterprise, 'ar') commands.register_command('action-report', action_report.ActionReportCommand(), base.CommandScope.Enterprise, 'acr') + commands.register_command('compliance', compliance.ComplianceCommand(), base.CommandScope.Enterprise, 'cr') commands.register_command('pam', keeper_pam.PAMControllerCommand(), base.CommandScope.Enterprise) commands.register_command('enterprise-push', enterprise_push.EnterprisePushCommand(), base.CommandScope.Enterprise, 'ep') diff --git a/keepersdk-package/src/keepersdk/enterprise/compliance.py b/keepersdk-package/src/keepersdk/enterprise/compliance.py new file mode 100644 index 00000000..e0f96409 --- /dev/null +++ b/keepersdk-package/src/keepersdk/enterprise/compliance.py @@ -0,0 +1,1420 @@ +"""Enterprise compliance report functionality for Keeper SDK.""" + +import datetime +import json +import logging +from collections import defaultdict +from dataclasses import dataclass, field +from typing import Optional, List, Dict, Any, Iterable, Set, Tuple, Callable + +from ..authentication import keeper_auth +from ..proto import enterprise_pb2 +from .. import crypto, utils +from . import enterprise_types +from ..plugins.sox import storage_types as st + + +API_EVENT_SUMMARY_ROW_LIMIT = 1000 +MAX_RECORDS_PER_REQUEST = 1000 + +# Report type constants +REPORT_TYPE_DEFAULT = 'default' +REPORT_TYPE_TEAM = 'team' +REPORT_TYPE_RECORD_ACCESS = 'record_access' +REPORT_TYPE_SUMMARY = 'summary' +REPORT_TYPE_SHARED_FOLDER = 'shared_folder' +REPORT_TYPE_HISTORY = 'history' +REPORT_TYPE_VAULT = 'vault' + +logger = logging.getLogger(__name__) + + +PERMISSION_OWNER = 1 +PERMISSION_MASK = 2 +PERMISSION_EDIT = 4 +PERMISSION_SHARE = 8 +PERMISSION_SHARE_ADMIN = 16 + + +def permissions_to_string(permission_bits: int) -> str: + """Convert permission bits to human-readable string.""" + permission_masks = { + PERMISSION_OWNER: 'owner', + PERMISSION_MASK: 'mask', + PERMISSION_EDIT: 'edit', + PERMISSION_SHARE: 'share', + PERMISSION_SHARE_ADMIN: 'share_admin' + } + + permissions = [perm for mask, perm in permission_masks.items() if (permission_bits & mask)] + if not permissions: + permissions.append('read-only') + + return ','.join(permissions) + + +@dataclass +class ComplianceReportEntry: + """Represents a single record entry in the compliance report.""" + record_uid: str + title: str = '' + record_type: str = '' + username: str = '' + permissions: str = '' + url: str = '' + in_trash: bool = False + shared: bool = False + shared_folder_uid: Optional[List[str]] = None + + +@dataclass +class TeamReportEntry: + """Represents a team's access to shared folders.""" + team_name: str + team_uid: str + shared_folder_name: str + shared_folder_uid: str + permissions: str + records: int = 0 + team_users: Optional[List[str]] = None + + +@dataclass +class RecordAccessReportEntry: + """Represents record access history for a user.""" + vault_owner: str + record_uid: str + record_title: str = '' + record_type: str = '' + record_url: str = '' + has_attachments: Optional[bool] = None + in_trash: bool = False + record_owner: str = '' + ip_address: str = '' + device: str = '' + last_access: Optional[datetime.datetime] = None + created: Optional[datetime.datetime] = None + last_pw_change: Optional[datetime.datetime] = None + last_modified: Optional[datetime.datetime] = None + last_rotation: Optional[datetime.datetime] = None + + +@dataclass +class SummaryReportEntry: + """Represents summary statistics for a user.""" + email: str + total_items: int = 0 + total_owned: int = 0 + active_owned: int = 0 + deleted_owned: int = 0 + + +@dataclass +class SharedFolderReportEntry: + """Represents shared folder access details.""" + shared_folder_uid: str + team_uid: Optional[List[str]] = None + team_name: Optional[List[str]] = None + record_uid: Optional[List[str]] = None + record_title: Optional[List[str]] = None + email: Optional[List[str]] = None + + +@dataclass +class ComplianceReportConfig: + """Configuration for compliance report generation.""" + username: Optional[List[str]] = None + job_title: Optional[List[str]] = None + team: Optional[List[str]] = None + record: Optional[List[str]] = None + url: Optional[List[str]] = None + shared: bool = False + deleted_items: bool = False + active_items: bool = False + show_team_users: bool = False + report_type: str = 'history' + aging: bool = False + node_id: Optional[int] = None + rebuild: bool = False + no_rebuild: bool = False + no_cache: bool = False + cache_max_age_days: int = 1 + + +@dataclass +class RecordInfo: + """Internal representation of record data.""" + record_uid: str = '' + record_uid_bytes: bytes = b'' + encrypted_data: bytes = b'' # API's EC-encrypted data for cache storage + owner_email: str = '' + owner_user_id: int = 0 + title: str = '' + record_type: str = '' + url: str = '' + shared: bool = False + in_trash: bool = False + has_attachments: bool = False + shared_folder_uid: Optional[str] = None + + +@dataclass +class SharedFolderInfo: + """Internal representation of shared folder data.""" + folder_uid: str = '' + records: Dict[str, int] = field(default_factory=dict) + users: Set[int] = field(default_factory=set) + teams: Set[str] = field(default_factory=set) + + +class ComplianceReportGenerator: + """Generates compliance reports for enterprise records and users. + + This class provides various compliance reporting capabilities including: + - Default compliance report with record permissions + - Team access to shared folders report + - Record access history by user + - Summary statistics by user + - Shared folder access details + """ + + def __init__( + self, + enterprise_data: enterprise_types.IEnterpriseData, + auth: keeper_auth.KeeperAuth, + config: Optional[ComplianceReportConfig] = None, + vault_storage: Optional[Any] = None, + compliance_storage: Optional[Any] = None, + progress_callback: Optional[Callable[[str], None]] = None + ) -> None: + self._enterprise_data = enterprise_data + self._auth = auth + self._config = config or ComplianceReportConfig() + self._vault_storage = vault_storage + self._progress_callback = progress_callback + self._compliance_storage = compliance_storage + self._user_teams: Optional[Dict[int, Set[str]]] = None + self._records: Dict[str, RecordInfo] = {} + self._record_shared_folders: Dict[str, List[str]] = {} + self._shared_folders: Dict[str, SharedFolderInfo] = {} + self._email_to_user_id: Optional[Dict[str, int]] = None + self._user_id_to_email: Optional[Dict[int, str]] = None + self._record_permissions: Dict[Tuple[str, int], int] = {} + self._team_members: Dict[str, Set[int]] = {} + self._team_uid_to_name: Dict[str, str] = {} + self._team_name_to_uid: Dict[str, str] = {} + self._team_filter_user_ids: Optional[Set[int]] = None + + @property + def enterprise_data(self) -> enterprise_types.IEnterpriseData: + return self._enterprise_data + + @property + def config(self) -> ComplianceReportConfig: + return self._config + + def get_preliminary_records(self) -> Dict[str, Dict[str, Any]]: + """Build user lookups and fetch preliminary compliance data.""" + self._build_user_lookups() + self._fetch_preliminary_compliance_data() + return dict(self._records) + + def _build_user_lookups(self) -> None: + """Build lookups between email and enterprise_user_id.""" + if self._email_to_user_id is None: + self._email_to_user_id = {} + self._user_id_to_email = {} + for user in self._enterprise_data.users.get_all_entities(): + email = user.username.lower() + user_id = user.enterprise_user_id + self._email_to_user_id[email] = user_id + self._user_id_to_email[user_id] = email + + def _build_user_teams_lookup(self) -> Dict[int, Set[str]]: + """Build lookup of users to their teams.""" + if self._user_teams is not None: + return self._user_teams + + self._user_teams = defaultdict(set) + self._team_uid_to_name = {} + self._team_name_to_uid = {} + + for team in self._enterprise_data.teams.get_all_entities(): + self._team_uid_to_name[team.team_uid] = team.name + self._team_name_to_uid[team.name.lower()] = team.team_uid + + for team_user in self._enterprise_data.team_users.get_all_links(): + self._user_teams[team_user.enterprise_user_id].add(team_user.team_uid) + if team_user.team_uid not in self._team_members: + self._team_members[team_user.team_uid] = set() + self._team_members[team_user.team_uid].add(team_user.enterprise_user_id) + + return self._user_teams + + def _get_team_filter_user_ids(self) -> Optional[Set[int]]: + """Get user IDs that match the team filter.""" + if not self._config.team: + return None + + if self._team_filter_user_ids is not None: + return self._team_filter_user_ids + + self._build_user_teams_lookup() + self._team_filter_user_ids = set() + + for team_ref in self._config.team: + if team_ref in self._team_members: + self._team_filter_user_ids.update(self._team_members[team_ref]) + elif team_ref.lower() in self._team_name_to_uid: + team_uid = self._team_name_to_uid[team_ref.lower()] + if team_uid in self._team_members: + self._team_filter_user_ids.update(self._team_members[team_uid]) + else: + logger.warning(f'Team not found: {team_ref}') + + return self._team_filter_user_ids + + def _is_prelim_cache_fresh(self) -> bool: + """Check if preliminary data cache is still valid.""" + if not self._compliance_storage: + return False + if self._config.rebuild: + return False + if self._config.no_rebuild: + return self._compliance_storage.last_prelim_data_update > 0 + + max_age = datetime.timedelta(days=self._config.cache_max_age_days) + min_ts = int((datetime.datetime.now() - max_age).timestamp()) + return self._compliance_storage.last_prelim_data_update >= min_ts + + def _is_compliance_cache_fresh(self) -> bool: + """Check if full compliance data cache is still valid.""" + if not self._compliance_storage: + return False + if self._config.rebuild: + return False + if self._config.no_rebuild: + return self._compliance_storage.last_compliance_data_update > 0 + + max_age = datetime.timedelta(days=self._config.cache_max_age_days) + min_ts = int((datetime.datetime.now() - max_age).timestamp()) + return self._compliance_storage.last_compliance_data_update >= min_ts + + def _load_prelim_from_cache(self) -> bool: + """Load preliminary data from SQLite cache.""" + if not self._compliance_storage: + return False + + try: + records = list(self._compliance_storage.records.get_all_entities()) + if not records: + return False + + for entity in records: + record_data = self._decrypt_record_data(entity.encrypted_data) + self._records[entity.record_uid] = RecordInfo( + record_uid=entity.record_uid, + record_uid_bytes=entity.record_uid_bytes, + encrypted_data=entity.encrypted_data, + title=record_data.get('title', ''), + record_type=record_data.get('record_type', ''), + url=record_data.get('url', ''), + shared=entity.shared, + in_trash=entity.in_trash, + has_attachments=entity.has_attachments + ) + + links = list(self._compliance_storage.user_record_links.get_all_links()) + for link in links: + if link.record_uid in self._records: + self._records[link.record_uid].owner_user_id = link.user_uid + self._records[link.record_uid].owner_email = self._user_id_to_email.get(link.user_uid, '') + self._update_permissions_lookup( + link.record_uid, + link.user_uid, + PERMISSION_OWNER | PERMISSION_EDIT | PERMISSION_SHARE | PERMISSION_SHARE_ADMIN + ) + + if self._progress_callback: + self._progress_callback('Loaded from cache.') + return True + except Exception as e: + logger.debug(f'Error loading from cache: {e}') + return False + + def _load_compliance_from_cache(self) -> bool: + """Load full compliance data from SQLite cache.""" + if not self._compliance_storage: + return False + + try: + perms = self._compliance_storage.record_permissions.get_all_links() + for link in perms: + self._update_permissions_lookup(link.record_uid, link.user_uid, link.permissions) + + sf_records = self._compliance_storage.sf_record_links.get_all_links() + for link in sf_records: + if link.folder_uid not in self._shared_folders: + self._shared_folders[link.folder_uid] = SharedFolderInfo(folder_uid=link.folder_uid) + self._shared_folders[link.folder_uid].records[link.record_uid] = link.permissions + + if link.record_uid not in self._record_shared_folders: + self._record_shared_folders[link.record_uid] = [] + if link.folder_uid not in self._record_shared_folders[link.record_uid]: + self._record_shared_folders[link.record_uid].append(link.folder_uid) + + if link.record_uid in self._records: + self._records[link.record_uid].shared = True + + sf_users = self._compliance_storage.sf_user_links.get_all_links() + for link in sf_users: + if link.folder_uid in self._shared_folders: + self._shared_folders[link.folder_uid].users.add(link.user_uid) + + sf_teams = self._compliance_storage.sf_team_links.get_all_links() + for link in sf_teams: + if link.folder_uid in self._shared_folders: + self._shared_folders[link.folder_uid].teams.add(link.team_uid) + + team_users = self._compliance_storage.team_user_links.get_all_links() + for link in team_users: + if link.team_uid not in self._team_members: + self._team_members[link.team_uid] = set() + self._team_members[link.team_uid].add(link.user_uid) + + return True + except Exception as e: + logger.debug(f'Error loading compliance from cache: {e}') + return False + + def _save_prelim_to_cache(self) -> None: + """Save preliminary data to SQLite cache.""" + if not self._compliance_storage: + return + + try: + from ..plugins.sox import storage_types as st + + self._compliance_storage.clear_non_aging_data() + + records = [] + links = [] + for record_uid, info in self._records.items(): + entity = st.StorageRecord() + entity.record_uid = record_uid + entity.record_uid_bytes = info.record_uid_bytes + entity.encrypted_data = info.encrypted_data + entity.shared = info.shared + entity.in_trash = info.in_trash + entity.has_attachments = info.has_attachments + records.append(entity) + + if info.owner_user_id: + link = st.StorageUserRecordLink() + link.record_uid = record_uid + link.user_uid = info.owner_user_id + links.append(link) + + self._compliance_storage.records.put_entities(records) + self._compliance_storage.user_record_links.put_links(links) + self._compliance_storage.set_prelim_data_updated() + except Exception as e: + logger.debug(f'Error saving to cache: {e}') + + def _save_compliance_to_cache(self) -> None: + """Save full compliance data to SQLite cache.""" + if not self._compliance_storage: + return + + try: + perms = [] + for (record_uid, user_id), bits in self._record_permissions.items(): + link = st.StorageRecordPermissions() + link.record_uid = record_uid + link.user_uid = user_id + link.permissions = bits + perms.append(link) + self._compliance_storage.record_permissions.put_links(perms) + + sf_records = [] + sf_users = [] + sf_teams = [] + for folder_uid, info in self._shared_folders.items(): + for record_uid, perm_bits in info.records.items(): + link = st.StorageSharedFolderRecordLink() + link.folder_uid = folder_uid + link.record_uid = record_uid + link.permissions = perm_bits + sf_records.append(link) + + for user_id in info.users: + link = st.StorageSharedFolderUserLink() + link.folder_uid = folder_uid + link.user_uid = user_id + sf_users.append(link) + + for team_uid in info.teams: + link = st.StorageSharedFolderTeamLink() + link.folder_uid = folder_uid + link.team_uid = team_uid + sf_teams.append(link) + + self._compliance_storage.sf_record_links.put_links(sf_records) + self._compliance_storage.sf_user_links.put_links(sf_users) + self._compliance_storage.sf_team_links.put_links(sf_teams) + + team_users = [] + for team_uid, user_ids in self._team_members.items(): + for user_id in user_ids: + link = st.StorageTeamUserLink() + link.team_uid = team_uid + link.user_uid = user_id + team_users.append(link) + self._compliance_storage.team_user_links.put_links(team_users) + + records = [] + for record_uid, info in self._records.items(): + entity = self._compliance_storage.records.get_entity(record_uid) + if entity: + entity.in_trash = info.in_trash + entity.has_attachments = info.has_attachments + entity.shared = info.shared + records.append(entity) + if records: + self._compliance_storage.records.put_entities(records) + + self._compliance_storage.set_compliance_data_updated() + except Exception as e: + logger.debug(f'Error saving compliance to cache: {e}') + + def _get_ec_private_key(self) -> Optional[bytes]: + """Get the enterprise EC private key for decryption.""" + return self._enterprise_data.enterprise_info.ec_private_key + + def _decrypt_record_data(self, encrypted_data: bytes) -> Dict[str, Any]: + """Decrypt record data using EC private key.""" + if not encrypted_data: + return {} + + ec_key = self._get_ec_private_key() + if ec_key is None: + return {} + + try: + data_json = crypto.decrypt_ec(encrypted_data, ec_key) + return json.loads(data_json.decode('utf-8')) + except Exception as e: + logger.debug(f'Failed to decrypt record data: {e}') + return {} + + def _update_permissions_lookup( + self, + record_uid: str, + user_id: int, + permission_bits: int + ) -> None: + """Update permissions lookup with OR of existing and new bits.""" + lookup_key = (record_uid, user_id) + existing_bits = self._record_permissions.get(lookup_key, 0) + self._record_permissions[lookup_key] = existing_bits | permission_bits + + def _fetch_preliminary_compliance_data(self, user_ids: Optional[List[int]] = None) -> None: + """Fetch basic record information from compliance API or cache.""" + if self._is_prelim_cache_fresh(): + if self._progress_callback: + self._progress_callback('Loading from cache...') + if self._load_prelim_from_cache(): + return + + if user_ids is None: + user_ids = [u.enterprise_user_id for u in self._enterprise_data.users.get_all_entities()] + + if not user_ids: + logger.warning('No enterprise users found') + return + + total_users = len(user_ids) + + rq = enterprise_pb2.PreliminaryComplianceDataRequest() + rq.includeNonShared = not self._config.shared + rq.includeTotalMatchingRecordsInFirstResponse = True + for uid in user_ids: + rq.enterpriseUserIds.append(uid) + + has_more = True + continuation_token = None + total_records = 0 + loaded_records = 0 + current_batch = 0 + users_processed = 0 + processed_user_ids = set() + + if self._progress_callback: + self._progress_callback(f'Loading record information - Users: 0/{total_users}, Records: 0/0') + + try: + while has_more: + if continuation_token: + rq.continuationToken = continuation_token + + current_batch += 1 + + try: + rs = self._auth.execute_auth_rest( + 'enterprise/get_preliminary_compliance_data', + rq, + response_type=enterprise_pb2.PreliminaryComplianceDataResponse + ) + + if rs.totalMatchingRecords > 0 and total_records == 0: + total_records = rs.totalMatchingRecords + + for user_data in rs.auditUserData: + user_id = user_data.enterpriseUserId + owner_email = self._user_id_to_email.get(user_id, '') + + if user_id not in processed_user_ids: + processed_user_ids.add(user_id) + users_processed = len(processed_user_ids) + + for record in user_data.auditUserRecords: + record_uid = utils.base64_url_encode(record.recordUid) + record_data = self._decrypt_record_data(record.encryptedData) + + shared_folder_uid = record_data.get('shared_folder_uid') or record_data.get('folder_uid') + if shared_folder_uid and record.shared: + if record_uid not in self._record_shared_folders: + self._record_shared_folders[record_uid] = [] + if shared_folder_uid not in self._record_shared_folders[record_uid]: + self._record_shared_folders[record_uid].append(shared_folder_uid) + + self._records[record_uid] = RecordInfo( + record_uid=record_uid, + record_uid_bytes=record.recordUid, + encrypted_data=record.encryptedData, + owner_email=owner_email, + owner_user_id=user_id, + title=record_data.get('title', ''), + record_type=record_data.get('record_type', ''), + url=record_data.get('url', ''), + shared=record.shared, + in_trash=record_data.get('in_trash', False), + has_attachments=record_data.get('has_attachments', False), + shared_folder_uid=shared_folder_uid + ) + + self._update_permissions_lookup( + record_uid, + user_id, + PERMISSION_OWNER | PERMISSION_EDIT | PERMISSION_SHARE | PERMISSION_SHARE_ADMIN + ) + loaded_records += 1 + + if self._progress_callback: + total_display = total_records if total_records > 0 else loaded_records + self._progress_callback(f'Loading record information - Users: {users_processed}/{total_users}, Records: {loaded_records}/{total_display}') + + has_more = rs.hasMore and rs.continuationToken + if has_more: + continuation_token = rs.continuationToken + else: + continuation_token = None + + except Exception as e: + logger.warning(f'Error fetching preliminary compliance data: {e}') + break + + self._save_prelim_to_cache() + finally: + if self._progress_callback: + self._progress_callback('Preliminary compliance data loaded.') + + def _fetch_full_compliance_data(self) -> None: + """Fetch full compliance data including permissions and shared folders or load from cache.""" + if self._is_compliance_cache_fresh(): + if self._load_compliance_from_cache(): + return + + all_record_bytes = [info.record_uid_bytes for info in self._records.values() if info.record_uid_bytes] + total_records = len(all_record_bytes) + + if total_records == 0: + return + + user_ids = [u.enterprise_user_id for u in self._enterprise_data.users.get_all_entities()] + total_users = len(user_ids) + node_id = self._config.node_id if self._config.node_id else self._enterprise_data.root_node.node_id + + batches = [all_record_bytes[i:i + MAX_RECORDS_PER_REQUEST] for i in range(0, total_records, MAX_RECORDS_PER_REQUEST)] + total_batches = len(batches) + + if self._progress_callback: + self._progress_callback(f'Loading compliance data - Users: {total_users}/{total_users}, Current Batch: 0/{total_batches}') + + try: + for batch_idx, record_batch in enumerate(batches): + try: + rq = enterprise_pb2.ComplianceReportRequest() + rq.saveReport = False + rq.reportName = f'Compliance Report on {datetime.datetime.now()}' + + report_run = rq.complianceReportRun + report_run.users.extend(user_ids) + report_run.records.extend(record_batch) + + caf = report_run.reportCriteriaAndFilter + caf.nodeId = node_id + caf.criteria.includeNonShared = not self._config.shared + + rs = self._auth.execute_auth_rest( + 'enterprise/run_compliance_report', + rq, + response_type=enterprise_pb2.ComplianceReportResponse + ) + + self._process_audit_records(rs.auditRecords) + self._process_shared_folder_records(rs.sharedFolderRecords) + self._process_user_record_permissions(rs.userRecords) + self._process_shared_folder_users(rs.sharedFolderUsers) + self._process_shared_folder_teams(rs.sharedFolderTeams) + + if self._progress_callback: + pct = ((batch_idx + 1) / total_batches) * 100 + self._progress_callback(f'Loading compliance data - Users: {total_users}/{total_users}, Current Batch: {batch_idx + 1}/{total_batches} ({pct:.2f}%)') + + except Exception as e: + error_msg = str(e) + if 'access_denied' in error_msg or 'no run compliance reports privilege' in error_msg: + self._build_permissions_from_enterprise_data() + break + else: + logger.warning(f'Error fetching full compliance data batch {batch_idx + 1}/{total_batches}: {e}') + continue + + self._save_compliance_to_cache() + finally: + if self._progress_callback: + self._progress_callback('') + + def _build_permissions_from_enterprise_data(self) -> None: + """Build permissions from vault data when full compliance API isn't available.""" + if self._vault_storage: + self._extract_shared_folders_from_vault() + + def _extract_shared_folders_from_vault(self) -> None: + """Extract shared folder relationships from vault storage.""" + try: + storage = self._vault_storage + folder_records = storage.folder_records.get_all_links() + + for link in folder_records: + folder = storage.folders.get_entity(link.folder_uid) + if folder and hasattr(folder, 'shared_folder_uid') and folder.shared_folder_uid: + if link.record_uid not in self._record_shared_folders: + self._record_shared_folders[link.record_uid] = [] + if folder.shared_folder_uid not in self._record_shared_folders[link.record_uid]: + self._record_shared_folders[link.record_uid].append(folder.shared_folder_uid) + except Exception as e: + logger.error(f'Error extracting shared folders from vault: {e}') + + def _process_audit_records(self, audit_records) -> None: + """Process audit records to extract trash and attachment flags.""" + for audit_record in audit_records: + try: + record_uid = utils.base64_url_encode(audit_record.recordUid) + if record_uid not in self._records: + continue + + self._records[record_uid].in_trash = audit_record.inTrash + self._records[record_uid].has_attachments = audit_record.hasAttachments + + if audit_record.auditData: + audit_data = self._decrypt_record_data(audit_record.auditData) + if audit_data: + record = self._records[record_uid] + if not record.title: + record.title = audit_data.get('title', '') + if not record.record_type: + record.record_type = audit_data.get('record_type', '') + if not record.url: + record.url = audit_data.get('url', '') + except Exception: + continue + + def _process_shared_folder_records(self, sf_records) -> None: + """Process shared folder record relationships.""" + for folder in sf_records: + folder_uid = utils.base64_url_encode(folder.sharedFolderUid) + + if folder_uid not in self._shared_folders: + self._shared_folders[folder_uid] = SharedFolderInfo(folder_uid=folder_uid) + + for rp in folder.recordPermissions: + record_uid = utils.base64_url_encode(rp.recordUid) + self._shared_folders[folder_uid].records[record_uid] = rp.permissionBits + + if record_uid not in self._record_shared_folders: + self._record_shared_folders[record_uid] = [] + if folder_uid not in self._record_shared_folders[record_uid]: + self._record_shared_folders[record_uid].append(folder_uid) + + if record_uid in self._records: + self._records[record_uid].shared = True + + for sar in folder.shareAdminRecords: + for idx in sar.recordPermissionIndexes: + if idx < len(folder.recordPermissions): + rp = folder.recordPermissions[idx] + record_uid = utils.base64_url_encode(rp.recordUid) + self._update_permissions_lookup(record_uid, sar.enterpriseUserId, PERMISSION_SHARE_ADMIN) + + logger.debug(f'Processed {len(sf_records)} shared folder records') + + def _process_user_record_permissions(self, user_records) -> None: + """Process direct user permissions on records.""" + permissions_count = 0 + for ur in user_records: + user_id = ur.enterpriseUserId + for rp in ur.recordPermissions: + record_uid = utils.base64_url_encode(rp.recordUid) + self._update_permissions_lookup(record_uid, user_id, rp.permissionBits) + permissions_count += 1 + + logger.debug(f'Processed {permissions_count} user record permissions from {len(user_records)} users') + + def _process_shared_folder_users(self, sf_users) -> None: + """Process users with direct access to shared folders.""" + for sf_user in sf_users: + folder_uid = utils.base64_url_encode(sf_user.sharedFolderUid) + if folder_uid not in self._shared_folders: + continue + + folder_records = self._shared_folders[folder_uid].records + for user_id in sf_user.enterpriseUserIds: + self._shared_folders[folder_uid].users.add(user_id) + for record_uid, perm_bits in folder_records.items(): + self._update_permissions_lookup(record_uid, user_id, perm_bits) + + logger.debug(f'Processed {len(sf_users)} shared folder user links') + + def _process_shared_folder_teams(self, sf_teams) -> None: + """Process teams with access to shared folders.""" + for sf_team in sf_teams: + folder_uid = utils.base64_url_encode(sf_team.sharedFolderUid) + if folder_uid not in self._shared_folders: + continue + + team_uid = utils.base64_url_encode(sf_team.teamUid) + self._shared_folders[folder_uid].teams.add(team_uid) + + folder_records = self._shared_folders[folder_uid].records + team_members = self._team_members.get(team_uid, set()) + for record_uid, perm_bits in folder_records.items(): + for user_id in team_members: + self._update_permissions_lookup(record_uid, user_id, perm_bits) + + logger.debug(f'Processed {len(sf_teams)} shared folder team links') + + def _build_permissions_lookup(self) -> Dict[Tuple[str, str], str]: + """Build final permissions lookup from all sources.""" + permissions_lookup = {} + + for (record_uid, user_id), permission_bits in self._record_permissions.items(): + email = self._user_id_to_email.get(user_id, '') + if email: + permissions_lookup[(record_uid, email)] = permissions_to_string(permission_bits) + + return permissions_lookup + + def _get_record_shared_folders(self, record_uid: str) -> List[str]: + """Get list of shared folder UIDs that contain this record.""" + return self._record_shared_folders.get(record_uid, []) + + def generate_default_report(self) -> List[ComplianceReportEntry]: + """Generate default compliance report with record permissions.""" + self._build_user_lookups() + self._build_user_teams_lookup() + self._fetch_preliminary_compliance_data() + self._fetch_full_compliance_data() + + filtered_user_ids = None + if self._config.node_id: + filtered_user_ids = {u.enterprise_user_id for u in self._enterprise_data.users.get_all_entities() + if u.node_id == self._config.node_id} + + permissions_lookup = self._build_permissions_lookup() + entries = [] + + for record_uid, record_info in self._records.items(): + users_with_access = {user_id for (r_uid, user_id) in self._record_permissions if r_uid == record_uid} + + for user_id in users_with_access: + if filtered_user_ids is not None and user_id not in filtered_user_ids: + continue + + email = self._user_id_to_email.get(user_id, '') + if not email: + continue + + entry = ComplianceReportEntry( + record_uid=record_uid, + title=record_info.title, + record_type=record_info.record_type, + username=email, + permissions=permissions_lookup.get((record_uid, email), 'read-only'), + url=record_info.url, + in_trash=record_info.in_trash, + shared=record_info.shared, + shared_folder_uid=self._get_record_shared_folders(record_uid) or None + ) + + if self._should_include_entry(entry): + entries.append(entry) + + return entries + + def _should_include_entry(self, entry: ComplianceReportEntry) -> bool: + """Check if entry should be included based on config filters.""" + config = self._config + + if config.username: + if not any(p.lower() == entry.username.lower() for p in config.username): + return False + + if config.record: + if not any(p == entry.record_uid for p in config.record): + return False + + if config.url: + if not any(p.lower() in entry.url.lower() for p in config.url): + return False + + if config.shared and not entry.shared: + return False + + if config.deleted_items and not entry.in_trash: + return False + + if config.active_items and entry.in_trash: + return False + + if config.team: + team_user_ids = self._get_team_filter_user_ids() + if team_user_ids is not None: + if not team_user_ids: + return False + user_id = self._email_to_user_id.get(entry.username.lower()) + if user_id is None or user_id not in team_user_ids: + return False + + if config.job_title: + user_id = self._email_to_user_id.get(entry.username.lower()) + if user_id is not None: + user = next((u for u in self._enterprise_data.users.get_all_entities() + if u.enterprise_user_id == user_id), None) + if user is None: + return False + job_title = getattr(user, 'job_title', '') or '' + if not any(jt.lower() in job_title.lower() for jt in config.job_title): + return False + + return True + + def generate_team_report(self) -> List[TeamReportEntry]: + """Generate team report showing team access to shared folders.""" + self._build_user_lookups() + self._build_user_teams_lookup() + self._fetch_preliminary_compliance_data() + self._fetch_full_compliance_data() + + entries = [] + team_names = {team.team_uid: team.name for team in self._enterprise_data.teams.get_all_entities()} + + for folder_uid, folder_info in self._shared_folders.items(): + folder_teams = folder_info.teams + folder_records = folder_info.records + + for team_uid in folder_teams: + team_name = team_names.get(team_uid, team_uid) + + team_users = None + if self._config.show_team_users: + team_user_ids = self._team_members.get(team_uid, set()) + team_users = [self._user_id_to_email.get(uid, '') for uid in team_user_ids] + + team_permissions = 0 + for record_uid, perm_bits in folder_records.items(): + team_permissions |= perm_bits + + entry = TeamReportEntry( + team_name=team_name, + team_uid=team_uid, + shared_folder_name=folder_uid, + shared_folder_uid=folder_uid, + permissions=permissions_to_string(team_permissions), + records=len(folder_records), + team_users=team_users + ) + entries.append(entry) + + return entries + + def generate_record_access_report(self, report_type: str = REPORT_TYPE_HISTORY) -> List[RecordAccessReportEntry]: + """Generate record access report with usage history.""" + self._build_user_lookups() + self._fetch_preliminary_compliance_data() + self._fetch_full_compliance_data() + + access_events = self._fetch_record_access_events(report_type) + record_uids = list(self._records.keys()) + aging_data = self._fetch_aging_data(record_uids) + + entries = [] + + for record_uid, record_info in self._records.items(): + users_with_access = {user_id for (r_uid, user_id) in self._record_permissions if r_uid == record_uid} + + for user_id in users_with_access: + email = self._user_id_to_email.get(user_id, '') + if not email: + continue + + if self._config.username: + if not any(pattern.lower() in email.lower() for pattern in self._config.username): + continue + + if report_type == REPORT_TYPE_VAULT: + has_direct_access = (record_uid, user_id) in self._record_permissions + if not has_direct_access: + continue + + access_key = (email, record_uid) + access_event = access_events.get(access_key, {}) + aging_stats = aging_data.get(record_uid, {}) + + entry = RecordAccessReportEntry( + vault_owner=email, + record_uid=record_uid, + record_title=record_info.title, + record_type=record_info.record_type, + record_url=record_info.url, + has_attachments=record_info.has_attachments, + in_trash=record_info.in_trash, + record_owner=record_info.owner_email, + ip_address=access_event.get('ip_address', '') or '', + device=access_event.get('keeper_version', '') or '', + last_access=self._ts_to_datetime(access_event.get('last_created')) if access_event else None, + created=aging_stats.get('created'), + last_pw_change=aging_stats.get('last_pw_change'), + last_modified=aging_stats.get('last_modified'), + last_rotation=aging_stats.get('last_rotation') + ) + entries.append(entry) + + return entries + + def _fetch_aging_data(self, record_uids: List[str]) -> Dict[str, Dict[str, Optional[datetime.datetime]]]: + """Fetch aging data for records.""" + if not record_uids: + return {} + + aging_data = { + r: { + 'created': None, + 'last_pw_change': None, + 'last_modified': None, + 'last_rotation': None + } for r in record_uids + } + + try: + logger.debug(f'Fetching aging data for {len(record_uids)} records...') + + aging_configs = { + 'created': { + 'event_types': [], + 'aggregate': 'first_created', + 'order': 'ascending' + }, + 'last_modified': { + 'event_types': ['record_update'], + 'aggregate': 'last_created', + 'order': 'descending' + }, + 'last_rotation': { + 'event_types': ['record_rotation_scheduled_ok', 'record_rotation_on_demand_ok'], + 'aggregate': 'last_created', + 'order': 'descending' + }, + 'last_pw_change': { + 'event_types': ['record_password_change'], + 'aggregate': 'last_created', + 'order': 'descending' + } + } + + for stat_name, config in aging_configs.items(): + try: + audit_filter: Dict[str, Any] = {'record_uid': record_uids} + + if config['event_types']: + audit_filter['audit_event_type'] = config['event_types'] + + rq: Dict[str, Any] = { + 'command': 'get_audit_event_reports', + 'scope': 'enterprise', + 'report_type': 'span', + 'filter': audit_filter, + 'columns': ['record_uid'], + 'aggregate': [config['aggregate']], + 'order': config['order'], + 'limit': API_EVENT_SUMMARY_ROW_LIMIT + } + + rs = self._auth.execute_auth_command(rq) + events = rs.get('audit_event_overview_report_rows', []) + + logger.debug(f'Fetched {len(events)} events for {stat_name}') + + for event in events: + record_uid = event.get('record_uid', '') + if record_uid in aging_data: + timestamp = event.get(config['aggregate'], 0) + if timestamp: + aging_data[record_uid][stat_name] = self._ts_to_datetime(timestamp) + + except Exception as e: + logger.debug(f'Error fetching {stat_name} data: {e}') + continue + + for record_uid, stats in aging_data.items(): + if stats['last_modified'] is None and stats['created']: + stats['last_modified'] = stats['created'] + + if stats['last_pw_change'] is None and stats['created']: + stats['last_pw_change'] = stats['created'] + + except Exception as e: + logger.warning(f'Error fetching aging data: {e}') + + return aging_data + + def _ts_to_datetime(self, timestamp: Any) -> Optional[datetime.datetime]: + """Convert timestamp to datetime.""" + if not timestamp: + return None + try: + ts = int(timestamp) + if ts > 0: + return datetime.datetime.fromtimestamp(ts) + except (ValueError, TypeError, OSError): + pass + return None + + def _fetch_record_access_events(self, report_type: str = 'history') -> Dict[Tuple[str, str], Dict[str, Any]]: + """Fetch record access events from audit logs.""" + access_events = {} + + try: + user_emails = list(self._email_to_user_id.keys()) + if not user_emails: + return access_events + + logger.debug(f'Fetching access events for {len(user_emails)} users...') + + for email in user_emails: + try: + user_filter: Dict[str, Any] = {'username': email} + + if report_type == REPORT_TYPE_VAULT: + user_records = [] + user_id = self._email_to_user_id.get(email.lower()) + if user_id: + for (r_uid, u_id), _ in self._record_permissions.items(): + if u_id == user_id: + user_records.append(r_uid) + if not user_records: + continue + user_filter['record_uid'] = user_records + + rq: Dict[str, Any] = { + 'command': 'get_audit_event_reports', + 'scope': 'enterprise', + 'report_type': 'span', + 'filter': user_filter, + 'columns': ['record_uid', 'ip_address', 'keeper_version'], + 'aggregate': ['last_created'], + 'limit': API_EVENT_SUMMARY_ROW_LIMIT + } + + rs = self._auth.execute_auth_command(rq) + events = rs.get('audit_event_overview_report_rows', []) + + logger.debug(f'Fetched {len(events)} access events for {email}') + + for event in events: + record_uid = event.get('record_uid', '') + if record_uid: + access_key = (email, record_uid) + access_events[access_key] = { + 'record_uid': record_uid, + 'ip_address': event.get('ip_address', ''), + 'keeper_version': event.get('keeper_version', ''), + 'last_created': event.get('last_created', 0) + } + + except Exception as e: + logger.debug(f'Error fetching audit events for {email}: {e}') + continue + + except Exception as e: + logger.warning(f'Error fetching record access events: {e}') + + logger.debug(f'Total access events fetched: {len(access_events)}') + return access_events + + def generate_summary_report(self) -> List[SummaryReportEntry]: + """Generate summary statistics report by user.""" + self._build_user_lookups() + self._fetch_preliminary_compliance_data() + self._fetch_full_compliance_data() + + filtered_user_ids = None + if self._config.node_id: + filtered_user_ids = {u.enterprise_user_id for u in self._enterprise_data.users.get_all_entities() + if u.node_id == self._config.node_id} + + user_stats = {} + for user in self._enterprise_data.users.get_all_entities(): + if filtered_user_ids is not None and user.enterprise_user_id not in filtered_user_ids: + continue + + email = self._user_id_to_email.get(user.enterprise_user_id, '') + if email: + user_stats[email] = { + 'total_items': 0, + 'total_owned': 0, + 'active_owned': 0, + 'deleted_owned': 0 + } + + for record_uid, record_info in self._records.items(): + owner_email = record_info.owner_email + owner_user_id = self._email_to_user_id.get(owner_email.lower(), None) if owner_email else None + in_trash = record_info.in_trash + + if filtered_user_ids is not None and owner_user_id not in filtered_user_ids: + continue + + if owner_email and owner_email in user_stats: + user_stats[owner_email]['total_owned'] += 1 + if in_trash: + user_stats[owner_email]['deleted_owned'] += 1 + else: + user_stats[owner_email]['active_owned'] += 1 + + for (r_uid, user_id), _ in self._record_permissions.items(): + if r_uid == record_uid: + if filtered_user_ids is not None and user_id not in filtered_user_ids: + continue + + email = self._user_id_to_email.get(user_id, '') + if email and email in user_stats: + user_stats[email]['total_items'] += 1 + + entries = [] + for email, stats in user_stats.items(): + entry = SummaryReportEntry( + email=email, + total_items=stats['total_items'], + total_owned=stats['total_owned'], + active_owned=stats['active_owned'], + deleted_owned=stats['deleted_owned'] + ) + entries.append(entry) + + return entries + + def generate_shared_folder_report(self) -> List[SharedFolderReportEntry]: + """Generate shared folder access details report.""" + self._build_user_lookups() + self._build_user_teams_lookup() + self._fetch_preliminary_compliance_data() + self._fetch_full_compliance_data() + + entries = [] + team_names = {team.team_uid: team.name for team in self._enterprise_data.teams.get_all_entities()} + + for folder_uid, folder_info in self._shared_folders.items(): + folder_teams = list(folder_info.teams) + folder_users = list(folder_info.users) + folder_records = list(folder_info.records.keys()) + + emails = [] + + if self._config.show_team_users: + for team_uid in folder_teams: + team_members = self._team_members.get(team_uid, set()) + for user_id in team_members: + email = self._user_id_to_email.get(user_id, '') + if email: + emails.append(f'(TU){email}') + + for user_id in folder_users: + email = self._user_id_to_email.get(user_id, '') + if email: + emails.append(email) + + teams_list = [team_names.get(uid, uid) for uid in folder_teams] if folder_teams else None + + record_titles = [] + if folder_records: + for rec_uid in folder_records: + record = self._records.get(rec_uid) + title = record.title if record else '' + record_titles.append(title) + + entry = SharedFolderReportEntry( + shared_folder_uid=folder_uid, + team_uid=folder_teams if folder_teams else None, + team_name=teams_list, + record_uid=folder_records if folder_records else None, + record_title=record_titles if record_titles else None, + email=emails if emails else None + ) + entries.append(entry) + + return entries + + @staticmethod + def get_headers(report_type: str, show_team_users: bool = False, aging: bool = False) -> List[str]: + """Get column headers for the specified report type.""" + if report_type == REPORT_TYPE_DEFAULT: + return ['record_uid', 'title', 'record_type', 'username', 'permissions', 'url', 'in_trash', 'shared_folder_uid'] + elif report_type == REPORT_TYPE_TEAM: + headers = ['team_name', 'team_uid', 'shared_folder_name', 'shared_folder_uid', 'permissions', 'records'] + if show_team_users: + headers.append('team_users') + return headers + elif report_type == REPORT_TYPE_RECORD_ACCESS: + headers = ['vault_owner', 'record_uid', 'record_title', 'record_type', 'record_url', 'has_attachments', + 'in_trash', 'record_owner', 'ip_address', 'device', 'last_access'] + if aging: + headers.extend(['created', 'last_pw_change', 'last_modified', 'last_rotation']) + return headers + elif report_type == REPORT_TYPE_SUMMARY: + return ['email', 'total_items', 'total_owned', 'active_owned', 'deleted_owned'] + elif report_type == REPORT_TYPE_SHARED_FOLDER: + return ['shared_folder_uid', 'team_uid', 'team_name', 'record_uid', 'record_title', 'email'] + else: + return [] + + def generate_report_rows( + self, + report_category: str, + blank_duplicate_uids: bool = False, + report_type: str = REPORT_TYPE_HISTORY + ) -> Iterable[List[Any]]: + """Generate report rows for the specified report category.""" + if report_category == REPORT_TYPE_DEFAULT: + entries = self.generate_default_report() + entries.sort(key=lambda e: e.record_uid) + + last_record_uid = '' + for entry in entries: + display_uid = entry.record_uid + if blank_duplicate_uids and entry.record_uid == last_record_uid: + display_uid = '' + last_record_uid = entry.record_uid + + yield [ + display_uid, + entry.title, + entry.record_type, + entry.username, + entry.permissions, + entry.url, + entry.in_trash, + entry.shared_folder_uid + ] + + elif report_category == REPORT_TYPE_TEAM: + entries = self.generate_team_report() + for entry in entries: + row = [ + entry.team_name, + entry.team_uid, + entry.shared_folder_name, + entry.shared_folder_uid, + entry.permissions, + entry.records + ] + if self._config.show_team_users: + row.append(entry.team_users) + yield row + + elif report_category == REPORT_TYPE_RECORD_ACCESS: + include_aging = self._config.aging + entries = self.generate_record_access_report(report_type=report_type) + for entry in entries: + row = [ + entry.vault_owner or '', + entry.record_uid or '', + entry.record_title or '', + entry.record_type or '', + entry.record_url or '', + entry.has_attachments if entry.has_attachments is not None else '', + entry.in_trash if entry.in_trash is not None else False, + entry.record_owner or '', + entry.ip_address or '', + entry.device or '', + entry.last_access if entry.last_access else '' + ] + if include_aging: + row.extend([ + entry.created if entry.created else '', + entry.last_pw_change if entry.last_pw_change else '', + entry.last_modified if entry.last_modified else '', + entry.last_rotation if entry.last_rotation else '' + ]) + yield row + + elif report_category == REPORT_TYPE_SUMMARY: + entries = self.generate_summary_report() + for entry in entries: + yield [ + entry.email, + entry.total_items, + entry.total_owned, + entry.active_owned, + entry.deleted_owned + ] + + elif report_category == REPORT_TYPE_SHARED_FOLDER: + entries = self.generate_shared_folder_report() + for entry in entries: + yield [ + entry.shared_folder_uid, + entry.team_uid, + entry.team_name, + entry.record_uid, + entry.record_title, + entry.email + ] + + +def get_preliminary_compliance_data( + enterprise_data: enterprise_types.IEnterpriseData, + auth: keeper_auth.KeeperAuth +) -> Dict[str, Dict[str, Any]]: + """Convenience function to fetch preliminary compliance data. + + Args: + enterprise_data: Enterprise data interface + auth: Keeper authentication + + Returns: + Dictionary mapping record UIDs to their basic information + """ + generator = ComplianceReportGenerator(enterprise_data, auth) + return generator.get_preliminary_records() diff --git a/keepersdk-package/src/keepersdk/plugins/sox/compliance_storage.py b/keepersdk-package/src/keepersdk/plugins/sox/compliance_storage.py new file mode 100644 index 00000000..6180a9e6 --- /dev/null +++ b/keepersdk-package/src/keepersdk/plugins/sox/compliance_storage.py @@ -0,0 +1,308 @@ +"""SQLite Storage for Compliance Data.""" + +import datetime +import logging +import os +import sqlite3 +import threading +from typing import Callable, Optional + +from ... import sqlite_dao +from ...storage import sqlite + +from . import storage_types + + +logger = logging.getLogger(__name__) + + +class SqliteComplianceStorage: + """SQLite storage for compliance reporting with full caching support.""" + + def __init__(self, get_connection: Callable[[], sqlite3.Connection], enterprise_id: int, owner: str = '') -> None: + self.get_connection = get_connection + self.enterprise_id = enterprise_id + self.database_name = None + self.close_connection = None + + metadata_schema = sqlite_dao.TableSchema.load_schema( + storage_types.Metadata, 'account_uid') + + user_schema = sqlite_dao.TableSchema.load_schema( + storage_types.StorageUser, 'user_uid') + + record_schema = sqlite_dao.TableSchema.load_schema( + storage_types.StorageRecord, 'record_uid') + + record_aging_schema = sqlite_dao.TableSchema.load_schema( + storage_types.StorageRecordAging, 'record_uid') + + user_record_schema = sqlite_dao.TableSchema.load_schema( + storage_types.StorageUserRecordLink, + ['record_uid', 'user_uid'], + indexes={'UserUID': 'user_uid'}) + + team_schema = sqlite_dao.TableSchema.load_schema( + storage_types.StorageTeam, 'team_uid') + + team_user_schema = sqlite_dao.TableSchema.load_schema( + storage_types.StorageTeamUserLink, + ['team_uid', 'user_uid'], + indexes={'UserUID': 'user_uid'}) + + role_schema = sqlite_dao.TableSchema.load_schema( + storage_types.StorageRole, 'role_id') + + record_permissions_schema = sqlite_dao.TableSchema.load_schema( + storage_types.StorageRecordPermissions, + ['record_uid', 'user_uid'], + indexes={'UserUID': 'user_uid'}) + + shared_folder_record_schema = sqlite_dao.TableSchema.load_schema( + storage_types.StorageSharedFolderRecordLink, + ['folder_uid', 'record_uid'], + indexes={'RecordUID': 'record_uid'}) + + shared_folder_user_schema = sqlite_dao.TableSchema.load_schema( + storage_types.StorageSharedFolderUserLink, + ['folder_uid', 'user_uid'], + indexes={'UserUID': 'user_uid'}) + + shared_folder_team_schema = sqlite_dao.TableSchema.load_schema( + storage_types.StorageSharedFolderTeamLink, + ['folder_uid', 'team_uid'], + indexes={'TeamUID': 'team_uid'}) + + shared_folder_schema = sqlite_dao.TableSchema.load_schema( + storage_types.StorageSharedFolder, 'folder_uid') + + sqlite_dao.verify_database( + self.get_connection(), + (metadata_schema, user_schema, record_schema, record_aging_schema, + user_record_schema, team_schema, team_user_schema, role_schema, + record_permissions_schema, shared_folder_record_schema, + shared_folder_user_schema, shared_folder_team_schema, + shared_folder_schema)) + + self._metadata = sqlite.SqliteEntityStorage(self.get_connection, metadata_schema) + self._users = sqlite.SqliteEntityStorage(self.get_connection, user_schema) + self._records = sqlite.SqliteEntityStorage(self.get_connection, record_schema) + self._record_aging = sqlite.SqliteEntityStorage(self.get_connection, record_aging_schema) + self._user_record_links = sqlite.SqliteLinkStorage(self.get_connection, user_record_schema) + self._teams = sqlite.SqliteEntityStorage(self.get_connection, team_schema) + self._team_user_links = sqlite.SqliteLinkStorage(self.get_connection, team_user_schema) + self._roles = sqlite.SqliteEntityStorage(self.get_connection, role_schema) + self._record_permissions = sqlite.SqliteLinkStorage(self.get_connection, record_permissions_schema) + self._sf_record_links = sqlite.SqliteLinkStorage(self.get_connection, shared_folder_record_schema) + self._sf_user_links = sqlite.SqliteLinkStorage(self.get_connection, shared_folder_user_schema) + self._sf_team_links = sqlite.SqliteLinkStorage(self.get_connection, shared_folder_team_schema) + self._shared_folders = sqlite.SqliteEntityStorage(self.get_connection, shared_folder_schema) + + def _get_history(self) -> storage_types.Metadata: + """Get or create metadata record.""" + # Use a fixed key for the singleton metadata record + history = self._metadata.get_entity('_default_') + if history is None: + history = storage_types.Metadata() + history.account_uid = '_default_' + return history + + @property + def last_prelim_data_update(self) -> int: + """Timestamp of last preliminary data sync.""" + return self._get_history().prelim_data_last_update + + @property + def last_compliance_data_update(self) -> int: + """Timestamp of last full compliance sync.""" + return self._get_history().compliance_data_last_update + + @property + def records_dated(self) -> int: + """Timestamp when aging data was last fetched.""" + return self._get_history().records_dated + + @property + def last_pw_audit(self) -> int: + """Timestamp of last password audit.""" + return self._get_history().last_pw_audit + + @property + def shared_records_only(self) -> bool: + """Flag indicating if only shared records cached.""" + return self._get_history().shared_records_only + + def set_prelim_data_updated(self, ts: Optional[int] = None) -> None: + """Mark preliminary data as updated.""" + ts = int(datetime.datetime.now().timestamp()) if ts is None else ts + history = self._get_history() + history.prelim_data_last_update = ts + self._metadata.put_entities([history]) + + def set_compliance_data_updated(self, ts: Optional[int] = None) -> None: + """Mark compliance data as updated.""" + ts = int(datetime.datetime.now().timestamp()) if ts is None else ts + history = self._get_history() + history.compliance_data_last_update = ts + self._metadata.put_entities([history]) + + def set_records_dated(self, ts: int) -> None: + """Set records dated timestamp.""" + history = self._get_history() + history.records_dated = ts + self._metadata.put_entities([history]) + + def set_last_pw_audit(self, ts: int) -> None: + """Set last password audit timestamp.""" + history = self._get_history() + history.last_pw_audit = ts + self._metadata.put_entities([history]) + + def set_shared_records_only(self, value: bool) -> None: + """Set shared records only flag.""" + history = self._get_history() + history.shared_records_only = value + self._metadata.put_entities([history]) + + @property + def users(self): + return self._users + + @property + def records(self): + return self._records + + @property + def record_aging(self): + return self._record_aging + + @property + def user_record_links(self): + return self._user_record_links + + @property + def teams(self): + return self._teams + + @property + def team_user_links(self): + return self._team_user_links + + @property + def roles(self): + return self._roles + + @property + def record_permissions(self): + return self._record_permissions + + @property + def sf_record_links(self): + return self._sf_record_links + + @property + def sf_user_links(self): + return self._sf_user_links + + @property + def sf_team_links(self): + return self._sf_team_links + + @property + def shared_folders(self): + return self._shared_folders + + def clear_aging_data(self) -> None: + """Clear only aging data.""" + self._record_aging.delete_all() + self.set_records_dated(0) + self.set_last_pw_audit(0) + + def clear_non_aging_data(self) -> None: + """Clear all data except aging.""" + self._records.delete_all() + self._users.delete_all() + self._user_record_links.delete_all() + self._teams.delete_all() + self._roles.delete_all() + self._sf_team_links.delete_all() + self._sf_user_links.delete_all() + self._sf_record_links.delete_all() + self._team_user_links.delete_all() + self._record_permissions.delete_all() + self._shared_folders.delete_all() + self.set_prelim_data_updated(0) + self.set_compliance_data_updated(0) + + def clear_all(self) -> None: + """Clear everything including metadata.""" + self.clear_non_aging_data() + self._record_aging.delete_all() + self._metadata.delete_all() + + def delete_db(self) -> None: + """Completely remove the database file.""" + try: + if self.close_connection: + self.close_connection() + else: + conn = self.get_connection() + conn.close() + if self.database_name and os.path.isfile(self.database_name): + os.remove(self.database_name) + except Exception as e: + logger.info(f'Could not delete db from filesystem, name = {self.database_name}') + logger.info(f'Exception: {e}') + + +def get_compliance_database_name(config_path: str, enterprise_id: int) -> str: + """Get the compliance database file path. + + The database file is placed in the directory of config_path as compliance_{enterprise_id}.db. + config_path should be a trusted path (e.g. application config file path). + + Args: + config_path: Path to config file directory + enterprise_id: Enterprise ID + + Returns: + Full path to the compliance database file + """ + path = os.path.dirname(os.path.abspath(config_path or '')) + return os.path.join(path, f'compliance_{enterprise_id}.db') + + +# Module-level connection cache to ensure single connection per database (thread-safe) +_connection_cache: dict[str, sqlite3.Connection] = {} +_connection_cache_lock = threading.Lock() + + +def get_cached_connection(database_name: str) -> sqlite3.Connection: + """Get or create a cached connection for the given database (thread-safe). + + Args: + database_name: Full path to the database file + + Returns: + SQLite connection object + """ + with _connection_cache_lock: + if database_name not in _connection_cache: + _connection_cache[database_name] = sqlite3.connect(database_name) + return _connection_cache[database_name] + + +def close_cached_connection(database_name: str) -> None: + """Close and remove a cached connection (thread-safe). + + Args: + database_name: Full path to the database file + """ + with _connection_cache_lock: + if database_name not in _connection_cache: + return + try: + _connection_cache[database_name].close() + except sqlite3.Error as e: + logger.debug('Error closing cached connection for %s: %s', database_name, e) + del _connection_cache[database_name] diff --git a/keepersdk-package/src/keepersdk/plugins/sox/storage_types.py b/keepersdk-package/src/keepersdk/plugins/sox/storage_types.py new file mode 100644 index 00000000..fbc32517 --- /dev/null +++ b/keepersdk-package/src/keepersdk/plugins/sox/storage_types.py @@ -0,0 +1,118 @@ +"""SOX Storage Types for Compliance Reporting.""" + +from dataclasses import dataclass + + +@dataclass +class Metadata: + """Metadata tracking cache timestamps.""" + account_uid: str = '' + prelim_data_last_update: int = 0 + records_dated: int = 0 + last_pw_audit: int = 0 + compliance_data_last_update: int = 0 + shared_records_only: bool = False + + +@dataclass +class StorageUser: + """Enterprise user information.""" + user_uid: int = 0 + email: bytes = b'' + status: int = 0 + job_title: bytes = b'' + full_name: bytes = b'' + node_id: int = 0 + + +@dataclass +class StorageRecord: + """Record information.""" + record_uid: str = '' + record_uid_bytes: bytes = b'' + encrypted_data: bytes = b'' + shared: bool = False + in_trash: bool = False + has_attachments: bool = False + + +@dataclass +class StorageRecordAging: + """Record aging/lifecycle data.""" + record_uid: str = '' + created: int = 0 + last_pw_change: int = 0 + last_modified: int = 0 + last_rotation: int = 0 + + +@dataclass +class StorageUserRecordLink: + """Link users to records they own.""" + record_uid: str = '' + user_uid: int = 0 + + +@dataclass +class StorageTeam: + """Enterprise team information.""" + team_uid: str = '' + team_name: str = '' + restrict_edit: bool = False + restrict_share: bool = False + + +@dataclass +class StorageTeamUserLink: + """Link teams to member users.""" + team_uid: str = '' + user_uid: int = 0 + + +@dataclass +class StorageRole: + """Enterprise role information.""" + role_id: int = 0 + encrypted_data: bytes = b'' + restrict_share_outside_enterprise: bool = False + restrict_share_all: bool = False + restrict_share_of_attachments: bool = False + restrict_mask_passwords_while_editing: bool = False + + +@dataclass +class StorageRecordPermissions: + """User permissions on records.""" + record_uid: str = '' + user_uid: int = 0 + permissions: int = 0 + + +@dataclass +class StorageSharedFolderRecordLink: + """Link shared folders to records.""" + folder_uid: str = '' + record_uid: str = '' + permissions: int = 0 + + +@dataclass +class StorageSharedFolderUserLink: + """Link shared folders to users.""" + folder_uid: str = '' + user_uid: int = 0 + + +@dataclass +class StorageSharedFolderTeamLink: + """Link shared folders to teams.""" + folder_uid: str = '' + team_uid: str = '' + + +@dataclass +class StorageSharedFolder: + """Shared folder information.""" + folder_uid: str = '' + folder_name: str = '' + encrypted_data: bytes = b'' From b724808e497c474559860029a7acba5fe92ea4f0 Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Fri, 20 Feb 2026 12:31:35 +0530 Subject: [PATCH 32/37] breachwatch report command added --- .../breachwatch/breachwatch_report.py | 167 ++++++++++++++++++ .../src/keepercli/commands/breachwatch.py | 69 +++++++- .../enterprise/breachwatch_report.py | 146 +++++++++++++++ 3 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 examples/sdk_examples/breachwatch/breachwatch_report.py create mode 100644 keepersdk-package/src/keepersdk/enterprise/breachwatch_report.py diff --git a/examples/sdk_examples/breachwatch/breachwatch_report.py b/examples/sdk_examples/breachwatch/breachwatch_report.py new file mode 100644 index 00000000..673438c9 --- /dev/null +++ b/examples/sdk_examples/breachwatch/breachwatch_report.py @@ -0,0 +1,167 @@ +""" +BreachWatch Report SDK Example + +Usage: python breachwatch_report.py +Requirements: Enterprise admin account, BreachWatch enabled, Keeper SDK installed. +""" + +import getpass +import logging +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage, breachwatch_report +from keepersdk.errors import KeeperApiError +from keepersdk.constants import KEEPER_PUBLIC_HOSTS + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + +TABLE_WIDTH = 110 +COL_WIDTHS = (38, 22, 14, 10, 10, 10) +BANNER_WIDTH = 60 +DEFAULT_SERVER = 'keepersecurity.com' + + +def login(): + config = configuration.JsonConfigurationStorage() + if not config.get().last_server: + logger.info("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + logger.info(f" {region}: {host}") + server = input(f'Enter server (default: {DEFAULT_SERVER}): ').strip() or DEFAULT_SERVER + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + username = config.get().last_login or input('Enter username: ') + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + logger.info("Device approval request sent. Approve this device and press Enter to continue.") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + password = getpass.getpass('Enter password: ') + login_auth_context.login_step.verify_password(password) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + login_auth_context.login_step.send_code(channel.channel_uid, code) + else: + raise NotImplementedError( + f"Unsupported login step: {type(login_auth_context.login_step).__name__}" + ) + logged_in_with_persistent = False + + if logged_in_with_persistent: + logger.info("Successfully logged in with persistent login") + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + return None + + +def format_row(values, widths=COL_WIDTHS): + formatted = [] + for i, val in enumerate(values): + if i >= len(widths): + break + width = widths[i] + text = str(val if val is not None else '')[: max(1, width - 1)] + formatted.append(f"{text:<{width}}") + return ' '.join(formatted) + + +def log_breachwatch_report(result: breachwatch_report.BreachWatchReportResult): + title = result.report_title if not result.has_errors else result.error_title.split('\n')[0] + logger.info("\n" + "=" * TABLE_WIDTH) + logger.info(title) + logger.info("=" * TABLE_WIDTH) + + if result.has_errors: + headers = [h.replace('_', ' ').title() for h in result.error_headers] + logger.info(format_row(headers)) + logger.info("-" * TABLE_WIDTH) + for row in result.error_rows: + logger.info(format_row(row)) + logger.info("=" * TABLE_WIDTH) + logger.info("Note: %s", result.fix_instructions) + return + + headers = [h.replace('_', ' ').title() for h in result.headers] + logger.info(format_row(headers)) + logger.info("-" * TABLE_WIDTH) + for row in result.rows: + logger.info(format_row(row)) + logger.info("=" * TABLE_WIDTH) + logger.info("Total Users: %d", len(result.rows)) + + if result.rows: + total_at_risk = sum(r[3] for r in result.rows if len(r) > 3) + total_passed = sum(r[4] for r in result.rows if len(r) > 4) + total_ignored = sum(r[5] for r in result.rows if len(r) > 5) + logger.info("Summary: At Risk=%d, Passed=%d, Ignored=%d", total_at_risk, total_passed, total_ignored) + if result.saved_count: + logger.info("Saved %d updated security report(s) to the server.", result.saved_count) + + +def generate_breachwatch_report(keeper_auth_context: keeper_auth.KeeperAuth): + if not keeper_auth_context.auth_context.is_enterprise_admin: + logger.error("This operation requires enterprise admin privileges.") + keeper_auth_context.close() + return + if not keeper_auth_context.auth_context.license.get('breachWatchEnabled'): + logger.error("BreachWatch is not enabled for this account.") + keeper_auth_context.close() + return + + enterprise = None + try: + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + enterprise_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage( + lambda: conn, enterprise_id + ) + enterprise = enterprise_loader.EnterpriseLoader( + keeper_auth_context, enterprise_storage + ) + logger.info("Loading enterprise data...") + enterprise.load() + logger.info("Generating BreachWatch report...") + result = breachwatch_report.run_breachwatch_report( + enterprise.enterprise_data, + keeper_auth_context, + node_ids=None, + save_report=True, + ) + log_breachwatch_report(result) + except KeeperApiError as e: + logger.error("API Error: %s", e) + except Exception: + logger.exception("Error generating BreachWatch report") + finally: + if enterprise: + enterprise.close() + keeper_auth_context.close() + + +def main(): + logger.info("=" * BANNER_WIDTH) + logger.info("Keeper BreachWatch Report (SDK Example)") + logger.info("=" * BANNER_WIDTH) + logger.info("Generates a BreachWatch security audit report for all enterprise users.\n") + + keeper_auth_context = login() + if keeper_auth_context: + generate_breachwatch_report(keeper_auth_context) + else: + logger.error("Login failed. Unable to generate BreachWatch report.") + + +if __name__ == "__main__": + main() diff --git a/keepercli-package/src/keepercli/commands/breachwatch.py b/keepercli-package/src/keepercli/commands/breachwatch.py index 83f7e63b..cc33b258 100644 --- a/keepercli-package/src/keepercli/commands/breachwatch.py +++ b/keepercli-package/src/keepercli/commands/breachwatch.py @@ -2,13 +2,14 @@ import base64 import getpass import json -from typing import Any, Optional, Set +from typing import Any, List, Optional, Set +from keepersdk.enterprise import breachwatch_report from keepersdk.proto import breachwatch_pb2, client_pb2 from keepersdk.vault import vault_online, vault_record from keepersdk import crypto, utils -from . import base +from . import base, enterprise_utils from .. import api from ..helpers import report_utils, record_utils from ..params import KeeperParams @@ -22,6 +23,69 @@ } UPDATE_BW_RECORD_URL = 'breachwatch/update_record_data' +BW_REPORT_DEFAULT_FORMAT = 'table' + + +def _validate_breachwatch_report(context: KeeperParams) -> None: + base.require_login(context) + base.require_enterprise_admin(context) + if not context.auth.auth_context.license.get('breachWatchEnabled'): + raise base.CommandError( + 'BreachWatch is not enabled for this enterprise. ' + 'Please contact your administrator to enable this feature.' + ) + + +def _format_report_headers(headers: List[str], fmt: str) -> List[str]: + return [report_utils.field_to_title(h) for h in headers] if fmt == BW_REPORT_DEFAULT_FORMAT else headers + + +class BreachWatchReportCommand(base.ArgparseCommand, enterprise_utils.EnterpriseMixin): + def __init__(self): + parser = argparse.ArgumentParser( + prog='breachwatch report', + description='Run a BreachWatch security audit report (enterprise).', + parents=[base.report_output_parser] + ) + super().__init__(parser) + + def execute(self, context: KeeperParams, **kwargs) -> Any: + _validate_breachwatch_report(context) + logger.info('Generating BreachWatch security audit report...') + + result = breachwatch_report.run_breachwatch_report( + context.enterprise_data, context.auth, + node_ids=None, + save_report=True, + ) + fmt = kwargs.get('format', BW_REPORT_DEFAULT_FORMAT) + out = kwargs.get('output') + + if result.has_errors: + report_result = report_utils.dump_report_data( + result.error_rows, + _format_report_headers(result.error_headers, fmt), + fmt=fmt, + filename=out, + title=result.error_title, + ) + if report_result is None: + logger.error('\nNote: ' + result.fix_instructions) + else: + report_result += '\nNote: ' + result.fix_instructions + return report_result + + if result.saved_count: + logger.info(f'Saved {result.saved_count} updated security report(s).') + + return report_utils.dump_report_data( + result.rows, + _format_report_headers(result.headers, fmt), + fmt=fmt, + filename=out, + title=result.report_title, + ) + class BreachWatchCommand(base.GroupCommand): def __init__(self): @@ -30,6 +94,7 @@ def __init__(self): self.register_command(BreachWatchIgnoreCommand(), 'ignore') self.register_command(BreachWatchScanCommand(), 'scan') self.register_command(BreachWatchPasswordCommand(), 'password') + self.register_command(BreachWatchReportCommand(), 'report', 'r') class BreachWatchListCommand(base.ArgparseCommand): def __init__(self): diff --git a/keepersdk-package/src/keepersdk/enterprise/breachwatch_report.py b/keepersdk-package/src/keepersdk/enterprise/breachwatch_report.py new file mode 100644 index 00000000..571efc95 --- /dev/null +++ b/keepersdk-package/src/keepersdk/enterprise/breachwatch_report.py @@ -0,0 +1,146 @@ +"""BreachWatch report API: generate BreachWatch security audit reports for enterprise users.""" + +import dataclasses +from typing import Any, Iterable, List, Optional, Tuple + +from . import enterprise_types +from ..authentication import keeper_auth +from . import security_audit_report + + +BREACHWATCH_REPORT_HEADERS = ['email', 'name', 'sync_pending', 'at_risk', 'passed', 'ignored'] +REPORT_TITLE = 'Security Audit Report (BreachWatch)' +ERROR_REPORT_TITLE = ( + 'Security Audit Report (BreachWatch) - Problems Found\n' + 'Security data could not be parsed for the following vaults:' +) +FIX_INSTRUCTIONS = ( + 'To resolve the issues found above, run ' + 'security-audit-report -b -s --attempt-fix' +) + + +@dataclasses.dataclass +class BreachWatchReportResult: + """Result of running a BreachWatch report; all data needed for display.""" + rows: List[List[Any]] + headers: List[str] + report_title: str + has_errors: bool + error_rows: List[List[Any]] + error_headers: List[str] + error_title: str + saved_count: int + fix_instructions: str + + +class BreachWatchReportGenerator: + """Generates BreachWatch security audit reports (passed/at_risk/ignored per user).""" + + def __init__( + self, + enterprise_data: enterprise_types.IEnterpriseData, + auth: keeper_auth.KeeperAuth, + node_ids: Optional[List[int]] = None, + save_report: bool = True, + show_updated: bool = True, + ) -> None: + config = security_audit_report.SecurityAuditConfig( + node_ids=node_ids, + show_breachwatch=True, + show_updated=show_updated, + save_report=save_report, + score_type='default', + attempt_fix=False, + ) + self._generator = security_audit_report.SecurityAuditReportGenerator( + enterprise_data, auth, config + ) + + @property + def enterprise_data(self) -> enterprise_types.IEnterpriseData: + return self._generator.enterprise_data + + @property + def config(self) -> security_audit_report.SecurityAuditConfig: + return self._generator.config + + @property + def errors(self) -> List[security_audit_report.SecurityAuditError]: + return self._generator.errors + + @property + def has_errors(self) -> bool: + return self._generator.has_errors + + @property + def updated_reports(self) -> List[Any]: + return self._generator.updated_reports + + def generate_report_rows(self) -> Iterable[List[Any]]: + return self._generator.generate_report_rows(breachwatch=True) + + def generate_error_rows(self) -> Iterable[List[Any]]: + return self._generator.generate_error_rows() + + def save_updated_reports(self) -> None: + self._generator.save_updated_reports() + + def sync_problem_vaults(self, emails: List[str]) -> None: + self._generator.sync_problem_vaults(emails) + + @staticmethod + def get_headers() -> List[str]: + return list(BREACHWATCH_REPORT_HEADERS) + + @staticmethod + def get_error_headers() -> List[str]: + return security_audit_report.SecurityAuditReportGenerator.get_error_headers() + + +def run_breachwatch_report( + enterprise_data: enterprise_types.IEnterpriseData, + auth: keeper_auth.KeeperAuth, + node_ids: Optional[List[int]] = None, + save_report: bool = True, +) -> BreachWatchReportResult: + generator = BreachWatchReportGenerator( + enterprise_data, auth, + node_ids=node_ids, + save_report=save_report, + show_updated=save_report, + ) + rows = list(generator.generate_report_rows()) + error_rows = list(generator.generate_error_rows()) + error_rows.sort(key=lambda row: row[0] != 'Enterprise') + saved_count = 0 + if not generator.has_errors and generator.updated_reports: + generator.save_updated_reports() + saved_count = len(generator.updated_reports) + return BreachWatchReportResult( + rows=rows, + headers=BreachWatchReportGenerator.get_headers(), + report_title=REPORT_TITLE, + has_errors=generator.has_errors, + error_rows=error_rows, + error_headers=BreachWatchReportGenerator.get_error_headers(), + error_title=ERROR_REPORT_TITLE, + saved_count=saved_count, + fix_instructions=FIX_INSTRUCTIONS, + ) + + +def generate_breachwatch_report( + enterprise_data: enterprise_types.IEnterpriseData, + auth: keeper_auth.KeeperAuth, + node_ids: Optional[List[int]] = None, + save_report: bool = True, +) -> Tuple[List[List[Any]], BreachWatchReportGenerator]: + generator = BreachWatchReportGenerator( + enterprise_data, auth, + node_ids=node_ids, + save_report=save_report, + show_updated=save_report, + ) + rows = list(generator.generate_report_rows()) + return rows, generator From b4cef45970227c5e287beea35dc810f77fe3a258 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Mon, 23 Feb 2026 11:19:19 +0530 Subject: [PATCH 33/37] External shares report command added --- .../ext_shares_report.py | 183 ++++++++++++++ .../keepercli/commands/ext_shares_report.py | 232 ++++++++++++++++++ .../src/keepercli/register_commands.py | 3 +- .../src/keepersdk/enterprise/compliance.py | 85 ++++++- .../keepersdk/vault/external_shares_report.py | 176 +++++++++++++ 5 files changed, 675 insertions(+), 4 deletions(-) create mode 100644 examples/sdk_examples/external_shares_report/ext_shares_report.py create mode 100644 keepercli-package/src/keepercli/commands/ext_shares_report.py create mode 100644 keepersdk-package/src/keepersdk/vault/external_shares_report.py diff --git a/examples/sdk_examples/external_shares_report/ext_shares_report.py b/examples/sdk_examples/external_shares_report/ext_shares_report.py new file mode 100644 index 00000000..9c2f3c79 --- /dev/null +++ b/examples/sdk_examples/external_shares_report/ext_shares_report.py @@ -0,0 +1,183 @@ +"""External Shares Report SDK example.""" + +import getpass +import logging +import os +import sqlite3 + +from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +from keepersdk.constants import KEEPER_PUBLIC_HOSTS +from keepersdk.enterprise import enterprise_loader, sqlite_enterprise_storage, compliance +from keepersdk.errors import KeeperApiError +from keepersdk.plugins.sox import compliance_storage as cs + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + +TABLE_WIDTH = 120 +COL_WIDTHS = (22, 22, 15, 32, 18) +DEFAULT_SERVER = 'keepersecurity.com' +DEFAULT_CONFIG_PATH = '~/.keeper/config.json' +CACHE_MAX_AGE_DAYS = 1 +REPORT_TITLE = 'External Shares Report' + + +def login(): + config = configuration.JsonConfigurationStorage() + + if not config.get().last_server: + logger.info("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + logger.info(f" {region}: {host}") + server = input(f'Enter server (default: {DEFAULT_SERVER}): ').strip() or DEFAULT_SERVER + config.get().last_server = server + else: + server = config.get().last_server + + keeper_endpoint = endpoint.KeeperEndpoint(config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + username = config.get().last_login or input('Enter username: ') + + login_auth_context.resume_session = True + login_auth_context.login(username) + + logged_in_with_persistent = True + while not login_auth_context.login_step.is_final(): + if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): + login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + logger.info("Device approval request sent. Approve this device and press Enter to continue.") + input() + elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): + login_auth_context.login_step.verify_password(getpass.getpass('Enter password: ')) + elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): + channel = login_auth_context.login_step.get_channels()[0] + login_auth_context.login_step.send_code( + channel.channel_uid, + getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') + ) + else: + raise NotImplementedError( + f"Unsupported login step: {type(login_auth_context.login_step).__name__}" + ) + logged_in_with_persistent = False + + if logged_in_with_persistent: + logger.info("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + return None + + +def get_compliance_storage(config_path: str, enterprise_id: int): + db_name = cs.get_compliance_database_name(config_path, enterprise_id) + storage = cs.SqliteComplianceStorage(lambda: cs.get_cached_connection(db_name), enterprise_id) + storage.database_name = db_name + storage.close_connection = lambda: cs.close_cached_connection(db_name) + return storage + + +def format_row(values, widths=COL_WIDTHS): + formatted = [] + for i, val in enumerate(values): + if i >= len(widths): + break + text = str(val if val is not None else '')[: max(1, widths[i] - 1)] + formatted.append(f"{text:<{widths[i]}}") + return ' '.join(formatted) + + +def log_report(rows, headers): + logger.info("\n" + "=" * TABLE_WIDTH) + logger.info(REPORT_TITLE) + logger.info("=" * TABLE_WIDTH) + + display_headers = [h.replace('_', ' ').title() for h in headers] + logger.info(format_row(display_headers)) + logger.info("-" * TABLE_WIDTH) + + for row in rows: + logger.info(format_row(row)) + + logger.info("=" * TABLE_WIDTH) + logger.info("Total: %d", len(rows)) + + +def run_external_shares_report( + keeper_auth_context: keeper_auth.KeeperAuth, + share_type: str = 'all', + refresh_data: bool = False, +): + if not keeper_auth_context.auth_context.is_enterprise_admin: + logger.error("This operation requires enterprise admin privileges.") + keeper_auth_context.close() + return + + enterprise = None + compliance_storage = None + + try: + conn = sqlite3.Connection('file::memory:', uri=True) + enterprise_id = keeper_auth_context.auth_context.enterprise_id or 0 + enterprise_storage = sqlite_enterprise_storage.SqliteEnterpriseStorage(lambda: conn, enterprise_id) + enterprise = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + + config_path = os.path.expanduser(DEFAULT_CONFIG_PATH) + compliance_storage = get_compliance_storage(config_path, enterprise_id) + + logger.info("Loading compliance data%s...", " (refresh)" if refresh_data else " (from cache when available)") + + def progress_callback(msg): + if msg: + logger.info(msg) + + config = compliance.ComplianceReportConfig( + shared=True, + rebuild=refresh_data, + no_rebuild=not refresh_data, + external_share_type=share_type, + cache_max_age_days=CACHE_MAX_AGE_DAYS, + ) + generator = compliance.ComplianceReportGenerator( + enterprise.enterprise_data, + keeper_auth_context, + config, + compliance_storage=compliance_storage, + progress_callback=progress_callback, + ) + + rows = list(generator.generate_report_rows(compliance.REPORT_TYPE_EXTERNAL_SHARES)) + headers = compliance.ComplianceReportGenerator.get_headers(compliance.REPORT_TYPE_EXTERNAL_SHARES) + log_report(rows, headers) + + except KeeperApiError as e: + logger.error("API Error: %s", e) + except Exception as e: + logger.exception("Error: %s", e) + finally: + if compliance_storage and hasattr(compliance_storage, 'close_connection') and compliance_storage.close_connection: + compliance_storage.close_connection() + if enterprise: + enterprise.close() + keeper_auth_context.close() + + +def main(): + logger.info("=" * 60) + logger.info("Keeper External Shares Report (SDK Example)") + logger.info("=" * 60 + "\n") + + keeper_auth_context = login() + if not keeper_auth_context: + logger.error("Login failed.") + return + + run_external_shares_report( + keeper_auth_context, + share_type='all', + refresh_data=False, + ) + + +if __name__ == "__main__": + main() diff --git a/keepercli-package/src/keepercli/commands/ext_shares_report.py b/keepercli-package/src/keepercli/commands/ext_shares_report.py new file mode 100644 index 00000000..7012d558 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/ext_shares_report.py @@ -0,0 +1,232 @@ +"""External shares report command: records and shared folders shared with external users.""" + +import argparse +import os +from typing import Any, List + +from keepersdk.enterprise import compliance +from keepersdk.plugins.sox import compliance_storage as cs + +from . import base +from . import shares +from ..helpers import report_utils +from ..params import KeeperParams +from ..prompt_utils import user_choice +from .. import api + +logger = api.get_logger() + +DEFAULT_FORMAT = 'table' +REPORT_TITLE = 'External Shares Report' +PDF_OUTPUT_FILENAME = 'external_shares_report.txt' +ROW_UID, ROW_TYPE, ROW_SHARED_TO = 0, 2, 3 +SHARE_TYPE_DIRECT = 'Direct' +SHARE_TYPE_SHARED_FOLDER = 'Shared Folder' +SHARE_TYPE_DIRECT_FILTER = 'direct' +SHARE_TYPE_SF_FILTER = 'shared-folder' +SHARE_TYPE_ALL_FILTER = 'all' + + +def get_compliance_storage(context: KeeperParams): + if not context.auth or not context.auth.auth_context: + return None + enterprise_id = context.auth.auth_context.enterprise_id + if not enterprise_id: + return None + config_path = context.keeper_config.config_filename or os.path.expanduser('~/.keeper/config.json') + db_name = cs.get_compliance_database_name(config_path, enterprise_id) + + def get_connection(): + return cs.get_cached_connection(db_name) + + storage = cs.SqliteComplianceStorage(get_connection, enterprise_id) + storage.database_name = db_name + storage.close_connection = lambda: cs.close_cached_connection(db_name) + return storage + + +class ProgressSpinner: + def start(self, message: str = '') -> None: + if message: + logger.info(message) + + def update(self, message: str) -> None: + if message: + logger.info(message) + + def stop(self, final_message: str = '') -> None: + if final_message: + logger.info(final_message) + + +def _format_headers(headers: list, fmt: str) -> list: + return [report_utils.field_to_title(h) for h in headers] if fmt == DEFAULT_FORMAT else headers + + +def create_progress_callback(spinner: ProgressSpinner): + def callback(msg): + if msg: + spinner.update(msg) + else: + spinner.stop() + return callback + + +class ExtSharesReportCommand(base.ArgparseCommand): + def __init__(self): + parser = argparse.ArgumentParser( + prog='external-shares-report', + description='Run an external record sharing report', + ) + ExtSharesReportCommand.add_arguments_to_parser(parser) + super().__init__(parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + '--format', + dest='format', + action='store', + choices=['table', 'csv', 'json', 'pdf'], + default=DEFAULT_FORMAT, + help='format of output', + ) + parser.add_argument( + '--output', + dest='output', + action='store', + help='path to resulting output file (ignored for "table" format)', + ) + parser.add_argument( + '-a', '--action', + dest='action', + action='store', + choices=['remove', 'none'], + default='none', + help="action to perform on external shares, 'none' if omitted", + ) + parser.add_argument( + '-t', '--share-type', + dest='share_type', + action='store', + choices=[SHARE_TYPE_DIRECT_FILTER, SHARE_TYPE_SF_FILTER, SHARE_TYPE_ALL_FILTER], + default=SHARE_TYPE_ALL_FILTER, + help="filter report by share type, 'all' if omitted", + ) + parser.add_argument( + '-f', '--force', + dest='force', + action='store_true', + help='apply action w/o confirmation', + ) + parser.add_argument( + '-r', '--refresh-data', + dest='refresh_data', + action='store_true', + help='retrieve fresh data', + ) + + def execute(self, context: KeeperParams, **kwargs) -> Any: + base.require_login(context) + base.require_enterprise_admin(context) + + action = kwargs.get('action', 'none') + share_type = kwargs.get('share_type', SHARE_TYPE_ALL_FILTER) + force = kwargs.get('force', False) + refresh_data = kwargs.get('refresh_data', False) + fmt = kwargs.get('format', DEFAULT_FORMAT) + output_file = kwargs.get('output') + + if fmt == 'pdf': + fmt = 'table' + if not output_file: + output_file = PDF_OUTPUT_FILENAME + + config = compliance.ComplianceReportConfig( + shared=True, + rebuild=refresh_data, + no_rebuild=not refresh_data, + external_share_type=share_type, + ) + + spinner = ProgressSpinner() + spinner.start('Loading...') + + generator = compliance.ComplianceReportGenerator( + context.enterprise_data, + context.auth, + config, + compliance_storage=get_compliance_storage(context), + progress_callback=create_progress_callback(spinner), + ) + + rows = list(generator.generate_report_rows(compliance.REPORT_TYPE_EXTERNAL_SHARES)) + spinner.stop() + + if action == 'remove' and rows: + if not force: + answer = user_choice('\nDo you wish to proceed?', 'yn', 'n') + if answer and answer.lower() not in ('y', 'yes'): + logger.info('Action aborted.') + return self._dump_report(rows, fmt, output_file) + self._remove_external_shares(context, rows, share_type) + spinner.start('Reloading...') + config_reload = compliance.ComplianceReportConfig( + shared=True, + rebuild=True, + external_share_type=share_type, + ) + generator_reload = compliance.ComplianceReportGenerator( + context.enterprise_data, + context.auth, + config_reload, + compliance_storage=get_compliance_storage(context), + progress_callback=create_progress_callback(spinner), + ) + rows = list(generator_reload.generate_report_rows(compliance.REPORT_TYPE_EXTERNAL_SHARES)) + spinner.stop() + + return self._dump_report(rows, fmt, output_file) + + def _dump_report(self, rows: List[List[Any]], fmt: str, output_file: str) -> Any: + headers = compliance.ComplianceReportGenerator.get_headers(compliance.REPORT_TYPE_EXTERNAL_SHARES) + return report_utils.dump_report_data( + rows, + _format_headers(headers, fmt), + fmt=fmt, + filename=output_file, + title=REPORT_TITLE, + ) + + def _remove_external_shares(self, context: KeeperParams, rows: List[List[Any]], share_type: str) -> None: + share_record_cmd = shares.ShareRecordCommand() + share_folder_cmd = shares.ShareFolderCommand() + direct_emails_by_record = {} + sf_emails_by_folder = {} + + for row in rows: + if len(row) < 5: + continue + uid = row[ROW_UID] + row_type = row[ROW_TYPE] + shared_to = row[ROW_SHARED_TO] + if not shared_to: + continue + if row_type == SHARE_TYPE_DIRECT: + direct_emails_by_record.setdefault(uid, []).append(shared_to) + elif row_type == SHARE_TYPE_SHARED_FOLDER: + sf_emails_by_folder.setdefault(uid, []).append(shared_to) + + if share_type in (SHARE_TYPE_DIRECT_FILTER, SHARE_TYPE_ALL_FILTER): + for record_uid, emails in direct_emails_by_record.items(): + try: + share_record_cmd.execute(context, record=record_uid, email=emails, action='revoke') + except Exception as e: + logger.debug('Revoke failed for record %s: %s', record_uid, e) + + if share_type in (SHARE_TYPE_SF_FILTER, SHARE_TYPE_ALL_FILTER): + for folder_uid, emails in sf_emails_by_folder.items(): + try: + share_folder_cmd.execute(context, folder=[folder_uid], user=emails, action='remove') + except Exception as e: + logger.debug('Remove user failed for folder %s: %s', folder_uid, e) diff --git a/keepercli-package/src/keepercli/register_commands.py b/keepercli-package/src/keepercli/register_commands.py index 29e768e2..ef77a7d5 100644 --- a/keepercli-package/src/keepercli/register_commands.py +++ b/keepercli-package/src/keepercli/register_commands.py @@ -90,7 +90,7 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS if not scopes or bool(scopes & base.CommandScope.Enterprise): from .commands import (enterprise_info, enterprise_node, enterprise_role, enterprise_team, enterprise_user, enterprise_create_user, importer_commands, audit_report, audit_alert, audit_log, transfer_account, pedm_admin, msp, user_report, - aging_report, action_report, security_audit_report, enterprise_push, compliance) + aging_report, action_report, security_audit_report, enterprise_push, compliance, ext_shares_report) from .commands.pam import keeper_pam commands.register_command('create-user', enterprise_create_user.CreateEnterpriseUserCommand(), base.CommandScope.Enterprise, 'ecu') @@ -115,5 +115,6 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS commands.register_command('aging-report', aging_report.AgingReportCommand(), base.CommandScope.Enterprise, 'ar') commands.register_command('action-report', action_report.ActionReportCommand(), base.CommandScope.Enterprise, 'acr') commands.register_command('compliance', compliance.ComplianceCommand(), base.CommandScope.Enterprise, 'cr') + commands.register_command('external-shares-report', ext_shares_report.ExtSharesReportCommand(), base.CommandScope.Enterprise) commands.register_command('pam', keeper_pam.PAMControllerCommand(), base.CommandScope.Enterprise) commands.register_command('enterprise-push', enterprise_push.EnterprisePushCommand(), base.CommandScope.Enterprise, 'ep') diff --git a/keepersdk-package/src/keepersdk/enterprise/compliance.py b/keepersdk-package/src/keepersdk/enterprise/compliance.py index e0f96409..3673f7c1 100644 --- a/keepersdk-package/src/keepersdk/enterprise/compliance.py +++ b/keepersdk-package/src/keepersdk/enterprise/compliance.py @@ -25,6 +25,7 @@ REPORT_TYPE_SHARED_FOLDER = 'shared_folder' REPORT_TYPE_HISTORY = 'history' REPORT_TYPE_VAULT = 'vault' +REPORT_TYPE_EXTERNAL_SHARES = 'external_shares' logger = logging.getLogger(__name__) @@ -139,6 +140,7 @@ class ComplianceReportConfig: no_rebuild: bool = False no_cache: bool = False cache_max_age_days: int = 1 + external_share_type: str = 'all' @dataclass @@ -677,6 +679,7 @@ def _fetch_full_compliance_data(self) -> None: self._process_user_record_permissions(rs.userRecords) self._process_shared_folder_users(rs.sharedFolderUsers) self._process_shared_folder_teams(rs.sharedFolderTeams) + self._process_user_profiles(rs.userProfiles) if self._progress_callback: pct = ((batch_idx + 1) / total_batches) * 100 @@ -814,7 +817,18 @@ def _process_shared_folder_teams(self, sf_teams) -> None: self._update_permissions_lookup(record_uid, user_id, perm_bits) logger.debug(f'Processed {len(sf_teams)} shared folder team links') - + + def _process_user_profiles(self, user_profiles) -> None: + """Merge user profiles (e.g. from compliance response) into email lookup for external users.""" + for profile in user_profiles: + try: + user_id = profile.enterpriseUserId + email = getattr(profile, 'email', None) or getattr(profile, 'username', '') + if user_id and email: + self._user_id_to_email[user_id] = email if isinstance(email, str) else email.decode('utf-8') + except Exception: + continue + def _build_permissions_lookup(self) -> Dict[Tuple[str, str], str]: """Build final permissions lookup from all sources.""" permissions_lookup = {} @@ -1283,9 +1297,67 @@ def generate_shared_folder_report(self) -> List[SharedFolderReportEntry]: email=emails if emails else None ) entries.append(entry) - + return entries - + + def generate_external_shares_report(self, share_type: Optional[str] = None) -> List[List[Any]]: + if share_type is None: + share_type = getattr(self._config, 'external_share_type', 'all') or 'all' + self._build_user_lookups() + self._build_user_teams_lookup() + self._fetch_preliminary_compliance_data() + self._fetch_full_compliance_data() + + enterprise_user_ids = {u.enterprise_user_id for u in self._enterprise_data.users.get_all_entities()} + all_user_ids = set() + for (_, user_id) in self._record_permissions: + all_user_ids.add(user_id) + for folder_info in self._shared_folders.values(): + all_user_ids.update(folder_info.users) + external_user_ids = {uid for uid in all_user_ids if (uid >> 32) == 0 or uid not in enterprise_user_ids} + + permissions_lookup = self._build_permissions_lookup() + + def get_direct_shares(): + rec_shares = {} + for (record_uid, user_id) in self._record_permissions: + if user_id not in external_user_ids: + continue + if record_uid not in rec_shares: + rec_shares[record_uid] = set() + rec_shares[record_uid].add(user_id) + return {uid: uids for uid, uids in rec_shares.items() if self._records.get(uid, None) and self._records[uid].shared} + + def get_sf_shares(): + sf_shares = {} + for folder_uid, folder_info in self._shared_folders.items(): + ext_in_sf = external_user_ids & folder_info.users + if ext_in_sf: + sf_shares[folder_uid] = ext_in_sf + return sf_shares + + rows = [] + if share_type in ('direct', 'all'): + direct_shares = get_direct_shares() + for record_uid, target_ids in direct_shares.items(): + rec = self._records.get(record_uid) + name = (rec.title or '') if rec else '' + for target_id in target_ids: + email = self._user_id_to_email.get(target_id, '') + perms = permissions_lookup.get((record_uid, email), 'read-only') + row = [record_uid, name, 'Direct', email, perms] + rows.append(row) + + if share_type in ('shared-folder', 'all'): + sf_shares = get_sf_shares() + for folder_uid, target_ids in sf_shares.items(): + for target_id in target_ids: + email = self._user_id_to_email.get(target_id, '') + row = [folder_uid, '', 'Shared Folder', email, ''] + rows.append(row) + + return rows + @staticmethod def get_headers(report_type: str, show_team_users: bool = False, aging: bool = False) -> List[str]: """Get column headers for the specified report type.""" @@ -1306,6 +1378,8 @@ def get_headers(report_type: str, show_team_users: bool = False, aging: bool = F return ['email', 'total_items', 'total_owned', 'active_owned', 'deleted_owned'] elif report_type == REPORT_TYPE_SHARED_FOLDER: return ['shared_folder_uid', 'team_uid', 'team_name', 'record_uid', 'record_title', 'email'] + elif report_type == REPORT_TYPE_EXTERNAL_SHARES: + return ['uid', 'name', 'type', 'shared_to', 'permissions'] else: return [] @@ -1402,6 +1476,11 @@ def generate_report_rows( entry.email ] + elif report_category == REPORT_TYPE_EXTERNAL_SHARES: + rows = self.generate_external_shares_report() + for row in rows: + yield row + def get_preliminary_compliance_data( enterprise_data: enterprise_types.IEnterpriseData, diff --git a/keepersdk-package/src/keepersdk/vault/external_shares_report.py b/keepersdk-package/src/keepersdk/vault/external_shares_report.py new file mode 100644 index 00000000..23bd4142 --- /dev/null +++ b/keepersdk-package/src/keepersdk/vault/external_shares_report.py @@ -0,0 +1,176 @@ +"""One-time (external) shares report for Keeper SDK.""" + +import dataclasses +import datetime +from typing import Any, List + +from . import vault_online +from . import ksm_management, share_management_utils +from .. import utils + +SUPPORTED_RECORD_VERSIONS = (2, 3) +MAX_BATCH_SIZE = 990 +SHARE_TYPE_ALL = 'all' +SHARE_TYPE_DIRECT = 'direct' +SHARE_TYPE_SHARED_FOLDER = 'shared-folder' + +REPORT_HEADERS = [ + 'record_uid', + 'record_title', + 'share_name', + 'share_link_id', + 'created', + 'expires', + 'first_opened', + 'status', +] + +REPORT_TITLE = 'External Shares Report (One-Time Share Links)' + + +@dataclasses.dataclass +class ExternalSharesReportResult: + rows: List[List[Any]] + headers: List[str] + report_title: str + + +def _get_record_uids_for_report(vault: vault_online.VaultOnline) -> List[str]: + uids: List[str] = [] + for record_info in vault.vault_data.records(): + if record_info.version not in SUPPORTED_RECORD_VERSIONS: + continue + uids.append(record_info.record_uid) + return uids + + +def _record_title(vault: vault_online.VaultOnline, record_uid: str) -> str: + rec = vault.vault_data.get_record(record_uid=record_uid) + return rec.title if rec else '' + + +def _record_share_type_mask(vault: vault_online.VaultOnline, record_uids: List[str]) -> dict: + if not record_uids: + return {} + try: + shares_list = share_management_utils.get_record_shares( + vault, record_uids, is_share_admin=False + ) + except Exception: + return {uid: (True, True) for uid in record_uids} + + mask = {} + if shares_list: + for rec in shares_list: + uid = rec.get('record_uid') + if not uid: + continue + shares = rec.get('shares') or {} + up = shares.get('user_permissions') or [] + sfp = shares.get('shared_folder_permissions') or [] + has_direct = len(up) > 0 + has_sf = len(sfp) > 0 + mask[uid] = (has_direct, has_sf) + for uid in record_uids: + if uid not in mask: + mask[uid] = (False, False) + return mask + + +def _build_report_rows( + vault: vault_online.VaultOnline, + include_expired: bool, + share_type: str = SHARE_TYPE_ALL, +) -> List[List[Any]]: + record_uids = _get_record_uids_for_report(vault) + if not record_uids: + return [] + + rows: List[List[Any]] = [] + now = utils.current_milli_time() + + for i in range(0, len(record_uids), MAX_BATCH_SIZE): + batch = record_uids[i : i + MAX_BATCH_SIZE] + app_infos = ksm_management.get_app_info(vault=vault, app_uid=batch) + + for app_info in app_infos: + if not getattr(app_info, 'isExternalShare', False): + continue + + record_uid = utils.base64_url_encode(app_info.appRecordUid) + record_title = _record_title(vault, record_uid) + + for client in getattr(app_info, 'clients', []): + if not include_expired and client.accessExpireOn and now > client.accessExpireOn: + continue + + created_ts = client.createdOn or 0 + expires_ts = client.accessExpireOn or 0 + first_opened_ts = getattr(client, 'firstAccess', 0) or 0 + + created_dt = ( + datetime.datetime.fromtimestamp(created_ts / 1000) + if created_ts else None + ) + expires_dt = ( + datetime.datetime.fromtimestamp(expires_ts / 1000) + if expires_ts else None + ) + first_opened_dt = ( + datetime.datetime.fromtimestamp(first_opened_ts / 1000) + if first_opened_ts else None + ) + + if now > expires_ts: + status = 'Expired' + elif first_opened_ts > 0: + status = 'Opened' + else: + status = 'Active' + + share_name = client.id if client.id else '' + share_link_id = utils.base64_url_encode(client.clientId) + + rows.append([ + record_uid, + record_title, + share_name, + share_link_id, + created_dt, + expires_dt, + first_opened_dt, + status, + ]) + + if share_type != SHARE_TYPE_ALL and rows: + unique_uids = list({row[0] for row in rows}) + mask = _record_share_type_mask(vault, unique_uids) + filtered = [] + for row in rows: + uid = row[0] + has_direct, has_sf = mask.get(uid, (False, False)) + if share_type == SHARE_TYPE_DIRECT and not has_direct: + continue + if share_type == SHARE_TYPE_SHARED_FOLDER and not has_sf: + continue + filtered.append(row) + rows = filtered + + return rows + + +def run_external_shares_report( + vault: vault_online.VaultOnline, + include_expired: bool = False, + share_type: str = SHARE_TYPE_ALL, +) -> ExternalSharesReportResult: + rows = _build_report_rows( + vault, + include_expired=include_expired, + share_type=share_type, + ) + return ExternalSharesReportResult( + rows=rows, + headers=list(REPORT_HEADERS), + report_title=REPORT_TITLE, + ) From 0fb6c4634f5c265815678140b3e15264b21d4d07 Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Fri, 27 Feb 2026 19:21:21 +0530 Subject: [PATCH 34/37] Added SSO MFA login support in example and fixed import errors in yubikey flow (#143) --- examples/sdk_examples/auth/login.py | 449 ++++++++++++++-- examples/sdk_examples/records/list_records.py | 480 +++++++++++++++--- .../src/keepersdk/authentication/yubikey.py | 17 +- 3 files changed, 805 insertions(+), 141 deletions(-) diff --git a/examples/sdk_examples/auth/login.py b/examples/sdk_examples/auth/login.py index b2404fda..35ad11ce 100644 --- a/examples/sdk_examples/auth/login.py +++ b/examples/sdk_examples/auth/login.py @@ -1,64 +1,413 @@ import getpass +import json +import logging +from typing import Dict, Optional -from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth +import fido2 +import webbrowser + +from keepersdk import errors, utils +from keepersdk.authentication import ( + configuration, + endpoint, + keeper_auth, + login_auth, +) +from keepersdk.authentication.yubikey import ( + IKeeperUserInteraction, + yubikey_authenticate, +) from keepersdk.constants import KEEPER_PUBLIC_HOSTS +try: + import pyperclip +except ImportError: + pyperclip = None + +logger = utils.get_logger() +logger.setLevel(logging.WARNING) + + +class FidoCliInteraction(fido2.client.UserInteraction, IKeeperUserInteraction): + def output_text(self, text: str) -> None: + print(text) + + def prompt_up(self) -> None: + print( + "\nTouch the flashing Security key to authenticate or " + "press Ctrl-C to resume with the primary two factor authentication..." + ) + + def request_pin(self, permissions, rd_id): + return getpass.getpass("Enter Security Key PIN: ") + + def request_uv(self, permissions, rd_id): + print("User Verification required.") + return True + + +# Two-factor duration codes (used by LoginFlow) +_TWO_FACTOR_DURATION_CODES: Dict[login_auth.TwoFactorDuration, str] = { + login_auth.TwoFactorDuration.EveryLogin: "login", + login_auth.TwoFactorDuration.Every12Hours: "12_hours", + login_auth.TwoFactorDuration.EveryDay: "24_hours", + login_auth.TwoFactorDuration.Every30Days: "30_days", + login_auth.TwoFactorDuration.Forever: "forever", +} + + +class LoginFlow: + """ + Handles the full login process: server selection, username, password, + device approval, 2FA, SSO data key, and SSO token. + """ + + def __init__(self) -> None: + self._config = configuration.JsonConfigurationStorage() + self._logged_in_with_persistent = True + self._endpoint: Optional[endpoint.KeeperEndpoint] = None + + @property + def endpoint(self) -> Optional[endpoint.KeeperEndpoint]: + return self._endpoint + + def run(self) -> Optional[keeper_auth.KeeperAuth]: + """ + Run the login flow. + + Returns: + Authenticated Keeper context, or None if login fails. + """ + server = self._ensure_server() + keeper_endpoint = endpoint.KeeperEndpoint(self._config, server) + self._endpoint = keeper_endpoint + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = self._config.get().last_login or input("Enter username: ") + login_auth_context.resume_session = True + login_auth_context.login(username) + + while not login_auth_context.login_step.is_final(): + step = login_auth_context.login_step + if isinstance(step, login_auth.LoginStepDeviceApproval): + self._handle_device_approval(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepError): + print(f"Login error: ({step.code}) {step.message}") + return None + else: + raise NotImplementedError( + f"Unsupported login step type: {type(step).__name__}" + ) + self._logged_in_with_persistent = False + + if self._logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + def _ensure_server(self) -> str: + if not self._config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = ( + input("Enter server (default: keepersecurity.com): ").strip() + or "keepersecurity.com" + ) + self._config.get().last_server = server + else: + server = self._config.get().last_server + return server + + def _handle_device_approval( + self, step: login_auth.LoginStepDeviceApproval + ) -> None: + step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Device approval request sent. Login to existing vault/console or " + "ask admin to approve this device and then press return/enter to resume" + ) + input() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + password = getpass.getpass("Enter password: ") + step.verify_password(password) + + def _handle_two_factor(self, step: login_auth.LoginStepTwoFactor) -> None: + channels = [ + x + for x in step.get_channels() + if x.channel_type != login_auth.TwoFactorChannel.Other + ] + menu = [] + for i, channel in enumerate(channels): + desc = self._two_factor_channel_desc(channel.channel_type) + menu.append( + ( + str(i + 1), + f"{desc} {channel.channel_name} {channel.phone}", + ) + ) + menu.append(("q", "Quit authentication attempt and return to Commander prompt.")) + + lines = ["", "This account requires 2FA Authentication"] + lines.extend(f" {a}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + selection = input("Selection: ") + if selection is None: + return + if selection in ("q", "Q"): + raise KeyboardInterrupt() + try: + assert selection.isnumeric() + idx = 1 if not selection else int(selection) + assert 1 <= idx <= len(channels) + channel = channels[idx - 1] + desc = self._two_factor_channel_desc(channel.channel_type) + print(f"Selected {idx}. {desc}") + except AssertionError: + print( + "Invalid entry, additional factors of authentication shown " + "may be configured if not currently enabled." + ) + continue + + if channel.channel_type in ( + login_auth.TwoFactorChannel.TextMessage, + login_auth.TwoFactorChannel.KeeperDNA, + login_auth.TwoFactorChannel.DuoSecurity, + ): + action = next( + ( + x + for x in step.get_channel_push_actions(channel.channel_uid) + if x + in ( + login_auth.TwoFactorPushAction.TextMessage, + login_auth.TwoFactorPushAction.KeeperDna, + ) + ), + None, + ) + if action: + step.send_push(channel.channel_uid, action) + + if channel.channel_type == login_auth.TwoFactorChannel.SecurityKey: + try: + challenge = json.loads(channel.challenge) + signature = yubikey_authenticate(challenge, FidoCliInteraction()) + if signature: + print("Verified Security Key.") + step.send_code(channel.channel_uid, signature) + return + except Exception as e: + logger.error(e) + continue + + # 2FA code path + step.duration = min(step.duration, channel.max_expiration) + available_dura = sorted( + x for x in _TWO_FACTOR_DURATION_CODES if x <= channel.max_expiration + ) + available_codes = [ + _TWO_FACTOR_DURATION_CODES.get(x) or "login" for x in available_dura + ] + + while True: + mfa_desc = self._two_factor_duration_desc(step.duration) + prompt_exp = ( + f"\n2FA Code Duration: {mfa_desc}.\n" + f"To change duration: 2fa_duration={'|'.join(available_codes)}" + ) + print(prompt_exp) + + selection = input("\nEnter 2FA Code or Duration: ") + if not selection: + return + if selection in available_codes: + step.duration = self._two_factor_code_to_duration(selection) + elif selection.startswith("2fa_duration="): + code = selection[len("2fa_duration=") :] + if code in available_codes: + step.duration = self._two_factor_code_to_duration(code) + else: + print(f"Invalid 2FA duration: {code}") + else: + try: + step.send_code(channel.channel_uid, selection) + print("Successfully verified 2FA Code.") + return + except errors.KeeperApiError as kae: + print(f"Invalid 2FA code: ({kae.result_code}) {kae.message}") + + def _handle_sso_data_key( + self, step: login_auth.LoginStepSsoDataKey + ) -> None: + menu = [ + ("1", "Keeper Push. Send a push notification to your device."), + ("2", "Admin Approval. Request your admin to approve this device."), + ("r", "Resume SSO authentication after device is approved."), + ("q", "Quit SSO authentication attempt and return to Commander prompt."), + ] + lines = ["Approve this device by selecting a method below:"] + lines.extend(f" {cmd:>3}. {text}" for cmd, text in menu) + print("\n".join(lines)) + + while True: + answer = input("Selection: ") + if answer is None: + return + if answer == "q": + raise KeyboardInterrupt() + if answer == "r": + step.resume() + break + if answer in ("1", "2"): + step.request_data_key( + login_auth.DataKeyShareChannel.KeeperPush + if answer == "1" + else login_auth.DataKeyShareChannel.AdminApproval + ) + else: + print(f'Action "{answer}" is not supported.') + + def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: + menu = [ + ("a", "SSO User with a Master Password."), + ] + if pyperclip: + menu.append(("c", "Copy SSO Login URL to clipboard.")) + try: + wb = webbrowser.get() + menu.append(("o", "Navigate to SSO Login URL with the default web browser.")) + except Exception: + wb = None + if pyperclip: + menu.append(("p", "Paste SSO Token from clipboard.")) + menu.append(("q", "Quit SSO authentication attempt and return to Commander prompt.")) + + lines = [ + "", + "SSO Login URL:", + step.sso_login_url, + "Navigate to SSO Login URL with your browser and complete authentication.", + "Copy a returned SSO Token into clipboard." + + (" Paste that token into Commander." if pyperclip else " Then paste the token below (or install pyperclip for clipboard support)."), + 'NOTE: To copy SSO Token please click "Copy authentication token" ' + 'button on "SSO Connect" page.', + "", + ] + lines.extend(f" {a:>3}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + token = input("Selection: ") + if token == "q": + raise KeyboardInterrupt() + if token == "a": + step.login_with_password() + return + if token == "c": + token = None + if pyperclip: + try: + pyperclip.copy(step.sso_login_url) + print("SSO Login URL is copied to clipboard.") + except Exception: + print("Failed to copy SSO Login URL to clipboard.") + else: + print("Clipboard not available (install pyperclip).") + elif token == "o": + token = None + if wb: + try: + wb.open_new_tab(step.sso_login_url) + except Exception: + print("Failed to open web browser.") + elif token == "p": + if pyperclip: + try: + token = pyperclip.paste() + except Exception: + token = "" + print("Failed to paste from clipboard") + else: + token = "" + print("Clipboard not available (install pyperclip).") + else: + if len(token) < 10: + print(f"Unsupported menu option: {token}") + continue + + if token: + try: + step.set_sso_token(token) + break + except errors.KeeperApiError as kae: + print(f"SSO Login error: ({kae.result_code}) {kae.message}") + + @staticmethod + def _two_factor_channel_desc( + channel_type: login_auth.TwoFactorChannel, + ) -> str: + return { + login_auth.TwoFactorChannel.Authenticator: "TOTP (Google and Microsoft Authenticator)", + login_auth.TwoFactorChannel.TextMessage: "Send SMS Code", + login_auth.TwoFactorChannel.DuoSecurity: "DUO", + login_auth.TwoFactorChannel.RSASecurID: "RSA SecurID", + login_auth.TwoFactorChannel.SecurityKey: "WebAuthN (FIDO2 Security Key)", + login_auth.TwoFactorChannel.KeeperDNA: "Keeper DNA (Watch)", + login_auth.TwoFactorChannel.Backup: "Backup Code", + }.get(channel_type, "Not Supported") + + @staticmethod + def _two_factor_duration_desc( + duration: login_auth.TwoFactorDuration, + ) -> str: + return { + login_auth.TwoFactorDuration.EveryLogin: "Require Every Login", + login_auth.TwoFactorDuration.Forever: "Save on this Device Forever", + login_auth.TwoFactorDuration.Every12Hours: "Ask Every 12 hours", + login_auth.TwoFactorDuration.EveryDay: "Ask Every 24 hours", + login_auth.TwoFactorDuration.Every30Days: "Ask Every 30 days", + }.get(duration, "Require Every Login") + + @staticmethod + def _two_factor_code_to_duration( + text: str, + ) -> login_auth.TwoFactorDuration: + for dura, code in _TWO_FACTOR_DURATION_CODES.items(): + if code == text: + return dura + return login_auth.TwoFactorDuration.EveryLogin + def login(): """ Handle the login process including server selection, authentication, - and multi-factor authentication steps. - + and multi-factor authentication steps (device approval, password, 2FA + with channel selection and Security Key, SSO data key, SSO token). + Returns: tuple: (keeper_auth_context, keeper_endpoint) on success, or (None, None) if login fails. """ - config = configuration.JsonConfigurationStorage() - - if not config.get().last_server: - print("Available server options:") - for region, host in KEEPER_PUBLIC_HOSTS.items(): - print(f" {region}: {host}") - server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' - config.get().last_server = server - else: - server = config.get().last_server - - keeper_endpoint = endpoint.KeeperEndpoint(config, server) - login_auth_context = login_auth.LoginAuth(keeper_endpoint) - - username = None - if config.get().last_login: - username = config.get().last_login - if not username: - username = input('Enter username: ') - - login_auth_context.resume_session = True - login_auth_context.login(username) - - logged_in_with_persistent = True - while not login_auth_context.login_step.is_final(): - if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): - login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) - print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") - input() - elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): - password = getpass.getpass('Enter password: ') - login_auth_context.login_step.verify_password(password) - elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): - channel = login_auth_context.login_step.get_channels()[0] - code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') - login_auth_context.login_step.send_code(channel.channel_uid, code) - else: - raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") - logged_in_with_persistent = False - - if logged_in_with_persistent: - print("Successfully logged in with persistent login") - - if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): - return login_auth_context.login_step.take_keeper_auth(), keeper_endpoint - - return None, None + flow = LoginFlow() + keeper_auth_context = flow.run() + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint def display_login_info(keeper_auth_context: keeper_auth.KeeperAuth, keeper_endpoint: endpoint.KeeperEndpoint): diff --git a/examples/sdk_examples/records/list_records.py b/examples/sdk_examples/records/list_records.py index 811aa102..28842b84 100644 --- a/examples/sdk_examples/records/list_records.py +++ b/examples/sdk_examples/records/list_records.py @@ -1,111 +1,438 @@ import getpass +import json +import logging import sqlite3 +from typing import Dict, Optional -from keepersdk.authentication import login_auth, configuration, endpoint, keeper_auth -from keepersdk.vault import sqlite_storage, vault_online, vault_record +import fido2 +import webbrowser + +from keepersdk import errors, utils +from keepersdk.authentication import ( + configuration, + endpoint, + keeper_auth, + login_auth, +) +from keepersdk.authentication.yubikey import ( + IKeeperUserInteraction, + yubikey_authenticate, +) from keepersdk.constants import KEEPER_PUBLIC_HOSTS +from keepersdk.vault import sqlite_storage, vault_online, vault_record + +try: + import pyperclip +except ImportError: + pyperclip = None + + +logger = utils.get_logger() +logger.setLevel(logging.WARNING) + +class FidoCliInteraction(fido2.client.UserInteraction, IKeeperUserInteraction): + def output_text(self, text: str) -> None: + print(text) -def login(): + def prompt_up(self) -> None: + print( + "\nTouch the flashing Security key to authenticate or " + "press Ctrl-C to resume with the primary two factor authentication..." + ) + + def request_pin(self, permissions, rd_id): + return getpass.getpass("Enter Security Key PIN: ") + + def request_uv(self, permissions, rd_id): + print("User Verification required.") + return True + + +# Two-factor duration codes (used by LoginFlow) +_TWO_FACTOR_DURATION_CODES: Dict[login_auth.TwoFactorDuration, str] = { + login_auth.TwoFactorDuration.EveryLogin: "login", + login_auth.TwoFactorDuration.Every12Hours: "12_hours", + login_auth.TwoFactorDuration.EveryDay: "24_hours", + login_auth.TwoFactorDuration.Every30Days: "30_days", + login_auth.TwoFactorDuration.Forever: "forever", +} + + +class LoginFlow: """ - Handle the login process including server selection, authentication, - and multi-factor authentication steps. - - Returns: - keeper_auth_context: The authenticated Keeper context, or None if login fails. + Handles the full login process: server selection, username, password, + device approval, 2FA, SSO data key, and SSO token. """ - config = configuration.JsonConfigurationStorage() - - if not config.get().last_server: - print("Available server options:") - for region, host in KEEPER_PUBLIC_HOSTS.items(): - print(f" {region}: {host}") - server = input('Enter server (default: keepersecurity.com): ').strip() or 'keepersecurity.com' - config.get().last_server = server - else: - server = config.get().last_server - - keeper_endpoint = endpoint.KeeperEndpoint(config, server) - login_auth_context = login_auth.LoginAuth(keeper_endpoint) - - username = None - if config.get().last_login: - username = config.get().last_login - if not username: - username = input('Enter username: ') - - login_auth_context.resume_session = True - login_auth_context.login(username) - - logged_in_with_persistent = True - while not login_auth_context.login_step.is_final(): - if isinstance(login_auth_context.login_step, login_auth.LoginStepDeviceApproval): - login_auth_context.login_step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) - print("Device approval request sent. Login to existing vault/console or ask admin to approve this device and then press return/enter to resume") - input() - elif isinstance(login_auth_context.login_step, login_auth.LoginStepPassword): - password = getpass.getpass('Enter password: ') - login_auth_context.login_step.verify_password(password) - elif isinstance(login_auth_context.login_step, login_auth.LoginStepTwoFactor): - channel = login_auth_context.login_step.get_channels()[0] - code = getpass.getpass(f'Enter 2FA code for {channel.channel_name}: ') - login_auth_context.login_step.send_code(channel.channel_uid, code) + + def __init__(self) -> None: + self._config = configuration.JsonConfigurationStorage() + self._logged_in_with_persistent = True + + def run(self) -> Optional[keeper_auth.KeeperAuth]: + """ + Run the login flow. + + Returns: + Authenticated Keeper context, or None if login fails. + """ + server = self._ensure_server() + keeper_endpoint = endpoint.KeeperEndpoint(self._config, server) + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = self._config.get().last_login or input("Enter username: ") + login_auth_context.resume_session = True + login_auth_context.login(username) + + while not login_auth_context.login_step.is_final(): + step = login_auth_context.login_step + if isinstance(step, login_auth.LoginStepDeviceApproval): + self._handle_device_approval(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepError): + print(f"Login error: ({step.code}) {step.message}") + return None + else: + raise NotImplementedError( + f"Unsupported login step type: {type(step).__name__}" + ) + self._logged_in_with_persistent = False + + if self._logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + def _ensure_server(self) -> str: + if not self._config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = ( + input("Enter server (default: keepersecurity.com): ").strip() + or "keepersecurity.com" + ) + self._config.get().last_server = server else: - raise NotImplementedError(f"Unsupported login step type: {type(login_auth_context.login_step).__name__}") - logged_in_with_persistent = False - - if logged_in_with_persistent: - print("Succesfully logged in with persistent login") - - if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): - return login_auth_context.login_step.take_keeper_auth() - - return None - - -def list_records(keeper_auth_context: keeper_auth.KeeperAuth): + server = self._config.get().last_server + return server + + def _handle_device_approval( + self, step: login_auth.LoginStepDeviceApproval + ) -> None: + step.send_push(login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Device approval request sent. Login to existing vault/console or " + "ask admin to approve this device and then press return/enter to resume" + ) + input() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + password = getpass.getpass("Enter password: ") + step.verify_password(password) + + def _handle_two_factor(self, step: login_auth.LoginStepTwoFactor) -> None: + channels = [ + x + for x in step.get_channels() + if x.channel_type != login_auth.TwoFactorChannel.Other + ] + menu = [] + for i, channel in enumerate(channels): + desc = self._two_factor_channel_desc(channel.channel_type) + menu.append( + ( + str(i + 1), + f"{desc} {channel.channel_name} {channel.phone}", + ) + ) + menu.append(("q", "Quit authentication attempt and return to Commander prompt.")) + + lines = ["", "This account requires 2FA Authentication"] + lines.extend(f" {a}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + selection = input("Selection: ") + if selection is None: + return + if selection in ("q", "Q"): + raise KeyboardInterrupt() + try: + assert selection.isnumeric() + idx = 1 if not selection else int(selection) + assert 1 <= idx <= len(channels) + channel = channels[idx - 1] + desc = self._two_factor_channel_desc(channel.channel_type) + print(f"Selected {idx}. {desc}") + except AssertionError: + print( + "Invalid entry, additional factors of authentication shown " + "may be configured if not currently enabled." + ) + continue + + if channel.channel_type in ( + login_auth.TwoFactorChannel.TextMessage, + login_auth.TwoFactorChannel.KeeperDNA, + login_auth.TwoFactorChannel.DuoSecurity, + ): + action = next( + ( + x + for x in step.get_channel_push_actions(channel.channel_uid) + if x + in ( + login_auth.TwoFactorPushAction.TextMessage, + login_auth.TwoFactorPushAction.KeeperDna, + ) + ), + None, + ) + if action: + step.send_push(channel.channel_uid, action) + + if channel.channel_type == login_auth.TwoFactorChannel.SecurityKey: + try: + challenge = json.loads(channel.challenge) + signature = yubikey_authenticate(challenge, FidoCliInteraction()) + if signature: + print("Verified Security Key.") + step.send_code(channel.channel_uid, signature) + return + except Exception as e: + logger.error(e) + continue + + # 2FA code path + step.duration = min(step.duration, channel.max_expiration) + available_dura = sorted( + x for x in _TWO_FACTOR_DURATION_CODES if x <= channel.max_expiration + ) + available_codes = [ + _TWO_FACTOR_DURATION_CODES.get(x) or "login" for x in available_dura + ] + + while True: + mfa_desc = self._two_factor_duration_desc(step.duration) + prompt_exp = ( + f"\n2FA Code Duration: {mfa_desc}.\n" + f"To change duration: 2fa_duration={'|'.join(available_codes)}" + ) + print(prompt_exp) + + selection = input("\nEnter 2FA Code or Duration: ") + if not selection: + return + if selection in available_codes: + step.duration = self._two_factor_code_to_duration(selection) + elif selection.startswith("2fa_duration="): + code = selection[len("2fa_duration=") :] + if code in available_codes: + step.duration = self._two_factor_code_to_duration(code) + else: + print(f"Invalid 2FA duration: {code}") + else: + try: + step.send_code(channel.channel_uid, selection) + print("Successfully verified 2FA Code.") + return + except errors.KeeperApiError as kae: + print(f"Invalid 2FA code: ({kae.result_code}) {kae.message}") + + def _handle_sso_data_key( + self, step: login_auth.LoginStepSsoDataKey + ) -> None: + menu = [ + ("1", "Keeper Push. Send a push notification to your device."), + ("2", "Admin Approval. Request your admin to approve this device."), + ("r", "Resume SSO authentication after device is approved."), + ("q", "Quit SSO authentication attempt and return to Commander prompt."), + ] + lines = ["Approve this device by selecting a method below:"] + lines.extend(f" {cmd:>3}. {text}" for cmd, text in menu) + print("\n".join(lines)) + + while True: + answer = input("Selection: ") + if answer is None: + return + if answer == "q": + raise KeyboardInterrupt() + if answer == "r": + step.resume() + break + if answer in ("1", "2"): + step.request_data_key( + login_auth.DataKeyShareChannel.KeeperPush + if answer == "1" + else login_auth.DataKeyShareChannel.AdminApproval + ) + else: + print(f'Action "{answer}" is not supported.') + + def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: + menu = [ + ("a", "SSO User with a Master Password."), + ] + if pyperclip: + menu.append(("c", "Copy SSO Login URL to clipboard.")) + try: + wb = webbrowser.get() + menu.append(("o", "Navigate to SSO Login URL with the default web browser.")) + except Exception: + wb = None + if pyperclip: + menu.append(("p", "Paste SSO Token from clipboard.")) + menu.append(("q", "Quit SSO authentication attempt and return to Commander prompt.")) + + lines = [ + "", + "SSO Login URL:", + step.sso_login_url, + "Navigate to SSO Login URL with your browser and complete authentication.", + "Copy a returned SSO Token into clipboard.", + "Paste that token into Commander", + 'NOTE: To copy SSO Token please click "Copy authentication token" ' + 'button on "SSO Connect" page.', + "", + ] + lines.extend(f" {a:>3}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + token = input("Selection: ") + if token == "q": + raise KeyboardInterrupt() + if token == "a": + step.login_with_password() + return + if token == "c": + token = None + if pyperclip: + try: + pyperclip.copy(step.sso_login_url) + print("SSO Login URL is copied to clipboard.") + except Exception: + print("Failed to copy SSO Login URL to clipboard.") + else: + print("Clipboard not available (install pyperclip).") + elif token == "o": + token = None + if wb: + try: + wb.open_new_tab(step.sso_login_url) + except Exception: + print("Failed to open web browser.") + elif token == "p": + if pyperclip: + try: + token = pyperclip.paste() + except Exception: + token = "" + print("Failed to paste from clipboard") + else: + token = "" + print("Clipboard not available (install pyperclip).") + else: + if len(token) < 10: + print(f"Unsupported menu option: {token}") + continue + + if token: + try: + step.set_sso_token(token) + break + except errors.KeeperApiError as kae: + print(f"SSO Login error: ({kae.result_code}) {kae.message}") + + @staticmethod + def _two_factor_channel_desc( + channel_type: login_auth.TwoFactorChannel, + ) -> str: + return { + login_auth.TwoFactorChannel.Authenticator: "TOTP (Google and Microsoft Authenticator)", + login_auth.TwoFactorChannel.TextMessage: "Send SMS Code", + login_auth.TwoFactorChannel.DuoSecurity: "DUO", + login_auth.TwoFactorChannel.RSASecurID: "RSA SecurID", + login_auth.TwoFactorChannel.SecurityKey: "WebAuthN (FIDO2 Security Key)", + login_auth.TwoFactorChannel.KeeperDNA: "Keeper DNA (Watch)", + login_auth.TwoFactorChannel.Backup: "Backup Code", + }.get(channel_type, "Not Supported") + + @staticmethod + def _two_factor_duration_desc( + duration: login_auth.TwoFactorDuration, + ) -> str: + return { + login_auth.TwoFactorDuration.EveryLogin: "Require Every Login", + login_auth.TwoFactorDuration.Forever: "Save on this Device Forever", + login_auth.TwoFactorDuration.Every12Hours: "Ask Every 12 hours", + login_auth.TwoFactorDuration.EveryDay: "Ask Every 24 hours", + login_auth.TwoFactorDuration.Every30Days: "Ask Every 30 days", + }.get(duration, "Require Every Login") + + @staticmethod + def _two_factor_code_to_duration( + text: str, + ) -> login_auth.TwoFactorDuration: + for dura, code in _TWO_FACTOR_DURATION_CODES.items(): + if code == text: + return dura + return login_auth.TwoFactorDuration.EveryLogin + + +def list_records(keeper_auth_context: keeper_auth.KeeperAuth) -> None: """ List all records in the vault. - + Args: keeper_auth_context: The authenticated Keeper context. """ - conn = sqlite3.Connection('file::memory:', uri=True) + conn = sqlite3.Connection("file::memory:", uri=True) vault_storage = sqlite_storage.SqliteVaultStorage( lambda: conn, - vault_owner=bytes(keeper_auth_context.auth_context.username, 'utf-8') + vault_owner=bytes(keeper_auth_context.auth_context.username, "utf-8"), ) - + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) vault.sync_down() print("Vault Records:") print("-" * 50) for record in vault.vault_data.records(): - print(f'Title: {record.title}') - + print(f"Title: {record.title}") + if record.version == 2: legacy_record = vault.vault_data.load_record(record.record_uid) if isinstance(legacy_record, vault_record.PasswordRecord): - print(f'Username: {legacy_record.login}') - print(f'URL: {legacy_record.link}') - + print(f"Username: {legacy_record.login}") + print(f"URL: {legacy_record.link}") + elif record.version >= 3: - print(f'Record Type: {record.record_type}') - + print(f"Record Type: {record.record_type}") + print("-" * 50) - + vault.close() keeper_auth_context.close() -def main(): - """ - Main entry point for the list records script. - Performs login and lists all records. - """ - keeper_auth_context = login() - +def main() -> None: + """Run login and list all vault records.""" + login_flow = LoginFlow() + keeper_auth_context = login_flow.run() + if keeper_auth_context: list_records(keeper_auth_context) else: @@ -114,3 +441,4 @@ def main(): if __name__ == "__main__": main() + diff --git a/keepersdk-package/src/keepersdk/authentication/yubikey.py b/keepersdk-package/src/keepersdk/authentication/yubikey.py index 2b650c89..83218539 100644 --- a/keepersdk-package/src/keepersdk/authentication/yubikey.py +++ b/keepersdk-package/src/keepersdk/authentication/yubikey.py @@ -1,7 +1,6 @@ import abc import getpass import json -import logging import os import threading from typing import Optional, Any, Dict @@ -12,12 +11,6 @@ from fido2.webauthn import PublicKeyCredentialRequestOptions, UserVerificationRequirement, AuthenticationResponse, PublicKeyCredentialCreationOptions from fido2.ctap2 import Ctap2, ClientPin from .. import utils -from prompt_toolkit import PromptSession - - -prompt_session = None -if os.isatty(0) and os.isatty(1): - prompt_session = PromptSession(multiline=False, complete_while_typing=False) class IKeeperUserInteraction(abc.ABC): @@ -165,16 +158,10 @@ def yubikey_register(request, force_pin=False, user_interaction: Optional[UserIn return None prompt1 = ' PIN Code: ' prompt2 = ' PIN Code Again: ' - if prompt_session: - pin1 = prompt_session.prompt(prompt1, is_password=True) - else: - pin1 = getpass.getpass(prompt1) + pin1 = getpass.getpass(prompt1) if not pin1: raise Exception('PIN is required') - if prompt_session: - pin2 = prompt_session.prompt(prompt2, is_password=True) - else: - pin2 = getpass.getpass(prompt2) + pin2 = getpass.getpass(prompt2) if not pin2: raise Exception('PIN is required') if pin1 != pin2: From b3e4cb3c1a84f99ee029058cb08a39a7685bc4da Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Fri, 27 Feb 2026 22:46:18 +0530 Subject: [PATCH 35/37] Added persistent flow to example (#144) --- examples/sdk_examples/auth/login.py | 32 +++++++++++++++++-- examples/sdk_examples/records/list_records.py | 19 ++++++++--- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/examples/sdk_examples/auth/login.py b/examples/sdk_examples/auth/login.py index 35ad11ce..f20e8938 100644 --- a/examples/sdk_examples/auth/login.py +++ b/examples/sdk_examples/auth/login.py @@ -290,6 +290,8 @@ def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: ] if pyperclip: menu.append(("c", "Copy SSO Login URL to clipboard.")) + else: + menu.append(("u", "Show SSO Login URL.")) try: wb = webbrowser.get() menu.append(("o", "Navigate to SSO Login URL with the default web browser.")) @@ -297,6 +299,7 @@ def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: wb = None if pyperclip: menu.append(("p", "Paste SSO Token from clipboard.")) + menu.append(("t", "Enter SSO Token manually.")) menu.append(("q", "Quit SSO authentication attempt and return to Commander prompt.")) lines = [ @@ -305,7 +308,7 @@ def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: step.sso_login_url, "Navigate to SSO Login URL with your browser and complete authentication.", "Copy a returned SSO Token into clipboard." - + (" Paste that token into Commander." if pyperclip else " Then paste the token below (or install pyperclip for clipboard support)."), + + (" Paste that token into Commander." if pyperclip else " Then use option 't' to enter the token manually."), 'NOTE: To copy SSO Token please click "Copy authentication token" ' 'button on "SSO Connect" page.', "", @@ -330,6 +333,12 @@ def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: print("Failed to copy SSO Login URL to clipboard.") else: print("Clipboard not available (install pyperclip).") + elif token == "u": + token = None + if not pyperclip: + print("\nSSO Login URL:", step.sso_login_url, "\n") + else: + print("Unsupported menu option (use 'c' to copy URL).") elif token == "o": token = None if wb: @@ -345,8 +354,10 @@ def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: token = "" print("Failed to paste from clipboard") else: - token = "" - print("Clipboard not available (install pyperclip).") + token = None + print("Clipboard not available (use 't' to enter token manually).") + elif token == "t": + token = getpass.getpass("Enter SSO Token: ").strip() else: if len(token) < 10: print(f"Unsupported menu option: {token}") @@ -395,6 +406,19 @@ def _two_factor_code_to_duration( return login_auth.TwoFactorDuration.EveryLogin +def enable_persistent_login(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + """ + Enable persistent login and register data key for device. + Sets persistent_login to on and logout_timer to 30 days. + """ + keeper_auth.set_user_setting(keeper_auth_context, 'persistent_login', '1') + keeper_auth.register_data_key_for_device(keeper_auth_context) + mins_per_day = 60 * 24 + timeout_in_minutes = mins_per_day * 30 # 30 days + keeper_auth.set_user_setting(keeper_auth_context, 'logout_timer', str(timeout_in_minutes)) + print("Persistent login turned on successfully and device registered") + + def login(): """ Handle the login process including server selection, authentication, @@ -406,6 +430,8 @@ def login(): """ flow = LoginFlow() keeper_auth_context = flow.run() + if keeper_auth_context: + enable_persistent_login(keeper_auth_context) keeper_endpoint = flow.endpoint if keeper_auth_context else None return keeper_auth_context, keeper_endpoint diff --git a/examples/sdk_examples/records/list_records.py b/examples/sdk_examples/records/list_records.py index 28842b84..9e3ccad0 100644 --- a/examples/sdk_examples/records/list_records.py +++ b/examples/sdk_examples/records/list_records.py @@ -287,6 +287,8 @@ def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: ] if pyperclip: menu.append(("c", "Copy SSO Login URL to clipboard.")) + else: + menu.append(("u", "Show SSO Login URL.")) try: wb = webbrowser.get() menu.append(("o", "Navigate to SSO Login URL with the default web browser.")) @@ -294,6 +296,7 @@ def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: wb = None if pyperclip: menu.append(("p", "Paste SSO Token from clipboard.")) + menu.append(("t", "Enter SSO Token manually.")) menu.append(("q", "Quit SSO authentication attempt and return to Commander prompt.")) lines = [ @@ -301,8 +304,8 @@ def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: "SSO Login URL:", step.sso_login_url, "Navigate to SSO Login URL with your browser and complete authentication.", - "Copy a returned SSO Token into clipboard.", - "Paste that token into Commander", + "Copy a returned SSO Token into clipboard." + + (" Paste that token into Commander." if pyperclip else " Then use option 't' to enter the token manually."), 'NOTE: To copy SSO Token please click "Copy authentication token" ' 'button on "SSO Connect" page.', "", @@ -327,6 +330,12 @@ def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: print("Failed to copy SSO Login URL to clipboard.") else: print("Clipboard not available (install pyperclip).") + elif token == "u": + token = None + if not pyperclip: + print("\nSSO Login URL:", step.sso_login_url, "\n") + else: + print("Unsupported menu option (use 'c' to copy URL).") elif token == "o": token = None if wb: @@ -342,8 +351,10 @@ def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: token = "" print("Failed to paste from clipboard") else: - token = "" - print("Clipboard not available (install pyperclip).") + token = None + print("Clipboard not available (use 't' to enter token manually).") + elif token == "t": + token = getpass.getpass("Enter SSO Token: ").strip() else: if len(token) < 10: print(f"Unsupported menu option: {token}") From 1103a737c04f98171f9c82eab57ffc53df12952b Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Fri, 27 Feb 2026 09:28:53 -0800 Subject: [PATCH 36/37] SDK release 1.1.0 --- keepercli-package/src/keepercli/__init__.py | 2 +- keepersdk-package/src/keepersdk/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/keepercli-package/src/keepercli/__init__.py b/keepercli-package/src/keepercli/__init__.py index 6bd77a3d..e9c7de7d 100644 --- a/keepercli-package/src/keepercli/__init__.py +++ b/keepercli-package/src/keepercli/__init__.py @@ -9,5 +9,5 @@ # Contact: commander@keepersecurity.com # -__version__ = '1.0.0-beta01' +__version__ = '1.1.0' diff --git a/keepersdk-package/src/keepersdk/__init__.py b/keepersdk-package/src/keepersdk/__init__.py index f44b6d75..423455dd 100644 --- a/keepersdk-package/src/keepersdk/__init__.py +++ b/keepersdk-package/src/keepersdk/__init__.py @@ -10,6 +10,6 @@ # from . import background -__version__ = '1.0.1' +__version__ = '1.1.0' background.init() From 0ba6bcc52d1fd6177b1a3ba912fc8281bdd75bd5 Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Fri, 27 Feb 2026 10:41:21 -0800 Subject: [PATCH 37/37] Fix unite tests --- .../keepercli/commands/account_commands.py | 4 +- .../src/keepercli/commands/audit_alert.py | 4 +- .../src/keepercli/commands/base.py | 6 +- .../src/keepercli/commands/breachwatch.py | 30 ++++---- .../src/keepercli/commands/enterprise_info.py | 2 +- .../src/keepercli/commands/enterprise_push.py | 40 +++++------ .../src/keepercli/commands/enterprise_team.py | 6 +- .../src/keepercli/commands/record_edit.py | 26 +++---- .../commands/record_handling_commands.py | 42 +++++------ .../keepercli/commands/record_type_utils.py | 3 +- .../src/keepercli/commands/secrets_manager.py | 16 ++--- .../src/keepercli/commands/shares.py | 72 +++++++++---------- .../src/keepercli/commands/vault_folder.py | 4 +- .../src/keepercli/helpers/ksm_utils.py | 6 +- keepercli-package/src/keepercli/versioning.py | 18 ++--- .../keepersdk/authentication/configuration.py | 2 +- .../src/keepersdk/vault/ksm_management.py | 64 ++++++++--------- .../keepersdk/vault/share_management_utils.py | 6 +- .../src/keepersdk/vault/shares_management.py | 15 ++-- .../src/keepersdk/vault/vault_data.py | 2 +- 20 files changed, 186 insertions(+), 182 deletions(-) diff --git a/keepercli-package/src/keepercli/commands/account_commands.py b/keepercli-package/src/keepercli/commands/account_commands.py index c13b72d0..79ec4cde 100644 --- a/keepercli-package/src/keepercli/commands/account_commands.py +++ b/keepercli-package/src/keepercli/commands/account_commands.py @@ -2,7 +2,7 @@ import datetime import os import re -from typing import Tuple, Optional, List, Any +from typing import Tuple, Optional, List, Any, Dict from keepersdk import crypto, utils, errors from keepersdk.authentication import keeper_auth @@ -394,7 +394,7 @@ def _get_current_salt(self, auth: keeper_auth.KeeperAuth, is_sso_user: bool): else: raise kae - def _handle_sso_deletion(self, kwargs: dict, auth: keeper_auth.KeeperAuth, is_sso_user: bool, current_salt) -> bool: + def _handle_sso_deletion(self, kwargs: Dict, auth: keeper_auth.KeeperAuth, is_sso_user: bool, current_salt) -> bool: """Handle SSO password deletion request.""" if not kwargs.get('delete_sso'): return False diff --git a/keepercli-package/src/keepercli/commands/audit_alert.py b/keepercli-package/src/keepercli/commands/audit_alert.py index 2e4d45a4..9bc707de 100644 --- a/keepercli-package/src/keepercli/commands/audit_alert.py +++ b/keepercli-package/src/keepercli/commands/audit_alert.py @@ -30,8 +30,8 @@ def __init__(self): class AuditSettingMixin: LAST_USERNAME = "" LAST_ENTERPRISE_ID = 0 - SETTINGS = None # type: Optional[dict] - EVENT_TYPES = None # type: Optional[List[Tuple[int, str]]] + SETTINGS: Optional[Dict] = None + EVENT_TYPES: Optional[List[Tuple[int, str]]] = None @staticmethod def load_settings(auth: keeper_auth.KeeperAuth, reload: bool=False) -> Optional[Dict[str, Any]]: diff --git a/keepercli-package/src/keepercli/commands/base.py b/keepercli-package/src/keepercli/commands/base.py index bb40cc45..624b060f 100644 --- a/keepercli-package/src/keepercli/commands/base.py +++ b/keepercli-package/src/keepercli/commands/base.py @@ -4,7 +4,7 @@ import re import shlex import sys -from typing import Optional, Any, Dict, Iterable, Tuple +from typing import Optional, Any, Dict, Iterable, Tuple, Set, List from keepersdk import errors @@ -146,7 +146,7 @@ def __init__(self, parser: argparse.ArgumentParser): def execute(self, context: KeeperParams, **kwargs): pass - def _get_all_option_strings(self, parser: argparse.ArgumentParser) -> set: + def _get_all_option_strings(self, parser: argparse.ArgumentParser) -> Set: """Get all valid option strings from the parser and its parents.""" options = set() for action in parser._actions: @@ -154,7 +154,7 @@ def _get_all_option_strings(self, parser: argparse.ArgumentParser) -> set: options.add(opt) return options - def _validate_strict_options(self, arg_list: list, valid_options: set) -> None: + def _validate_strict_options(self, arg_list: List, valid_options: Set) -> None: """Validate that all option-like arguments are recognized. Raises ParseError if: diff --git a/keepercli-package/src/keepercli/commands/breachwatch.py b/keepercli-package/src/keepercli/commands/breachwatch.py index cc33b258..1f4ed675 100644 --- a/keepercli-package/src/keepercli/commands/breachwatch.py +++ b/keepercli-package/src/keepercli/commands/breachwatch.py @@ -2,7 +2,7 @@ import base64 import getpass import json -from typing import Any, List, Optional, Set +from typing import Any, List, Optional, Set, Dict from keepersdk.enterprise import breachwatch_report from keepersdk.proto import breachwatch_pb2, client_pb2 @@ -16,7 +16,7 @@ logger = api.get_logger() -STATUS_TO_TEXT: dict[int, str] = { +STATUS_TO_TEXT: Dict[int, str] = { client_pb2.BWStatus.GOOD: "GOOD", client_pb2.BWStatus.WEAK: "WEAK", client_pb2.BWStatus.BREACHED: "BREACHED" @@ -176,7 +176,7 @@ def execute(self, context: KeeperParams, **kwargs) -> Any: else: logger.info("No breach watch requests to process") - def _get_record_names(self, kwargs: dict) -> list[str]: + def _get_record_names(self, kwargs: Dict) -> List[str]: """Extract record names from kwargs.""" records = kwargs.get('records') if not records: @@ -187,14 +187,14 @@ def _get_record_names(self, kwargs: dict) -> list[str]: return records - def _resolve_record_uids(self, record_names: list[str], context: KeeperParams) -> Set[str]: + def _resolve_record_uids(self, record_names: List[str], context: KeeperParams) -> Set[str]: """Resolve record names to UIDs using the context.""" record_uids: Set[str] = set() for record_name in record_names: record_uids.update(record_utils.resolve_records(record_name, context)) return record_uids - def _get_breached_records(self, vault: vault_online.VaultOnline) -> dict[str, str]: + def _get_breached_records(self, vault: vault_online.VaultOnline) -> Dict[str, str]: """Get breached records and their passwords.""" record_passwords = {} @@ -219,7 +219,7 @@ def _extract_record_password(self, vault: vault_online.VaultOnline, record_uid: return None - def _create_breach_watch_requests(self, vault: vault_online.VaultOnline, record_passwords: dict[str, str], record_uids: Set[str]) -> list[breachwatch_pb2.BreachWatchRecordRequest]: + def _create_breach_watch_requests(self, vault: vault_online.VaultOnline, record_passwords: Dict[str, str], record_uids: Set[str]) -> List[breachwatch_pb2.BreachWatchRecordRequest]: """Create breach watch record requests for the given records.""" bw_requests = [] @@ -296,7 +296,7 @@ def _get_existing_breach_watch_euid(self, vault: vault_online.VaultOnline, recor return None - def _process_breach_watch_requests(self, vault: vault_online.VaultOnline, bw_requests: list[breachwatch_pb2.BreachWatchRecordRequest]) -> None: + def _process_breach_watch_requests(self, vault: vault_online.VaultOnline, bw_requests: List[breachwatch_pb2.BreachWatchRecordRequest]) -> None: """Process the breach watch requests.""" # Queue audit event self._queue_audit_event(vault) @@ -309,7 +309,7 @@ def _queue_audit_event(self, vault: vault_online.VaultOnline) -> None: if audit_plugin: audit_plugin.schedule_audit_event('bw_record_ignored') - def _send_breach_watch_requests(self, vault: vault_online.VaultOnline, bw_requests: list[breachwatch_pb2.BreachWatchRecordRequest]) -> None: + def _send_breach_watch_requests(self, vault: vault_online.VaultOnline, bw_requests: List[breachwatch_pb2.BreachWatchRecordRequest]) -> None: """Send breach watch requests in chunks.""" while bw_requests: chunk = bw_requests[0:999] @@ -321,7 +321,7 @@ def _send_breach_watch_requests(self, vault: vault_online.VaultOnline, bw_reques except Exception as e: logger.error(f'Error sending breach watch chunk: {e}') - def _send_breach_watch_chunk(self, vault: vault_online.VaultOnline, chunk: list[breachwatch_pb2.BreachWatchRecordRequest]) -> breachwatch_pb2.BreachWatchUpdateResponse: + def _send_breach_watch_chunk(self, vault: vault_online.VaultOnline, chunk: List[breachwatch_pb2.BreachWatchRecordRequest]) -> breachwatch_pb2.BreachWatchUpdateResponse: """Send a chunk of breach watch requests.""" rq = breachwatch_pb2.BreachWatchUpdateRequest() rq.breachWatchRecordRequest.extend(chunk) @@ -368,7 +368,7 @@ def _validate_context(self, context: KeeperParams) -> None: if not context.auth.auth_context.license.get('breachWatchEnabled'): raise ValueError("Breach watch is not enabled. Please contact your administrator to enable this feature.") - def _get_and_validate_record_uids(self, kwargs: dict) -> list[str]: + def _get_and_validate_record_uids(self, kwargs: Dict) -> List[str]: """Extract and validate record UIDs from kwargs.""" record_uids = kwargs.get('records') if not record_uids: @@ -479,7 +479,7 @@ def _is_vault_ready(self, context: KeeperParams) -> bool: raise base.CommandError('Breach watch is not enabled. Please contact your administrator to enable this feature.') return True - def _get_passwords_to_scan(self, kwargs: dict) -> list[str]: + def _get_passwords_to_scan(self, kwargs: Dict) -> List[str]: """Get passwords from command line arguments or prompt user.""" passwords = kwargs.get('passwords', []) if passwords: @@ -493,7 +493,7 @@ def _get_passwords_to_scan(self, kwargs: dict) -> list[str]: logger.info('') return [] - def _scan_passwords(self, breach_watch, passwords: list[str]) -> list: + def _scan_passwords(self, breach_watch, passwords: List[str]) -> List: """Scan passwords and return results with EUIDs for cleanup.""" scan_results = [] for result in breach_watch.scan_passwords(passwords): @@ -505,7 +505,7 @@ def _is_valid_scan_result(self, result) -> bool: """Validate scan result structure.""" return result and len(result) == 2 - def _display_results(self, scan_results: list, echo_passwords: bool) -> None: + def _display_results(self, scan_results: List, echo_passwords: bool) -> None: """Display scan results in a formatted way.""" for result in scan_results: password, scan_result = result @@ -523,7 +523,7 @@ def _get_status_text(self, scan_result) -> str: status_code = client_pb2.BWStatus.BREACHED if is_breached else client_pb2.BWStatus.GOOD return STATUS_TO_TEXT.get(status_code, "Unknown") - def _cleanup_scan_data(self, breach_watch, scan_results: list) -> None: + def _cleanup_scan_data(self, breach_watch, scan_results: List) -> None: """Clean up scan data by deleting EUIDs.""" euids = self._extract_euids(scan_results) if euids: @@ -532,7 +532,7 @@ def _cleanup_scan_data(self, breach_watch, scan_results: list) -> None: except Exception as e: logger.warning(f"Failed to cleanup scan data: {e}") - def _extract_euids(self, scan_results: list) -> list: + def _extract_euids(self, scan_results: List) -> List: """Extract EUIDs from scan results for cleanup.""" euids = [] for result in scan_results: diff --git a/keepercli-package/src/keepercli/commands/enterprise_info.py b/keepercli-package/src/keepercli/commands/enterprise_info.py index 0676d964..7bce8179 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_info.py +++ b/keepercli-package/src/keepercli/commands/enterprise_info.py @@ -137,7 +137,7 @@ def execute(self, context: KeeperParams, **kwargs): teams[queued_team.node_id] = [] queued_teams[queued_team.node_id].append(queued_team) - def tree_node(node: enterprise_types.Node) -> Tuple[str, Dict[str, dict]]: + def tree_node(node: enterprise_types.Node) -> Tuple[str, Dict[str, Dict]]: node_name = node.name if not node_name: node_name = enterprise_data.enterprise_info.enterprise_name diff --git a/keepercli-package/src/keepercli/commands/enterprise_push.py b/keepercli-package/src/keepercli/commands/enterprise_push.py index 19e54f78..02254e62 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_push.py +++ b/keepercli-package/src/keepercli/commands/enterprise_push.py @@ -3,7 +3,7 @@ import os import copy import re -from typing import Any +from typing import Any, List, Set, Dict, Optional from . import base from .. import api @@ -100,7 +100,7 @@ def load_template_records_from_file(file_path: str) -> list: TRANSFER_RECORD_SUCCESS = "transfer_record_success" -def _substitute_value(value: str, values: dict[str, str]) -> str: +def _substitute_value(value: str, values: Dict[str, str]) -> str: """Replace all ${key} placeholders in a string with values from the given dict.""" result = value while True: @@ -113,7 +113,7 @@ def _substitute_value(value: str, values: dict[str, str]) -> str: return result -def _substitute_in_dict(container: dict, values: dict[str, str]) -> None: +def _substitute_in_dict(container: Dict, values: Dict[str, str]) -> None: """Recursively substitute placeholders in dict (and nested dicts/lists) in place.""" for key, val in list(container.items()): if isinstance(val, str): @@ -126,7 +126,7 @@ def _substitute_in_dict(container: dict, values: dict[str, str]) -> None: container[key] = _substitute_in_list(val, values) -def _substitute_in_list(container: list, values: dict[str, str]) -> list: +def _substitute_in_list(container: list, values: Dict[str, str]) -> List: """Return a new list with placeholders substituted.""" result = [] for item in container: @@ -142,7 +142,7 @@ def _substitute_in_list(container: list, values: dict[str, str]) -> list: return result -def _get_substitution_values(enterprise: enterprise_types.IEnterpriseData, email: str) -> dict[str, str]: +def _get_substitution_values(enterprise: enterprise_types.IEnterpriseData, email: str) -> Dict[str, str]: """Build substitution map for a user: user_email, user_name, generate_password.""" values = { "user_email": email, @@ -156,14 +156,14 @@ def _get_substitution_values(enterprise: enterprise_types.IEnterpriseData, email def _substitute_record_params( - enterprise: enterprise_types.IEnterpriseData, email: str, record_data: dict + enterprise: enterprise_types.IEnterpriseData, email: str, record_data: Dict ) -> None: """Fill template parameters in record_data for the given user (in place).""" values = _get_substitution_values(enterprise, email) _substitute_in_dict(record_data, values) -def _resolve_user_to_email(enterprise: enterprise_types.IEnterpriseData, user_id: str) -> str | None: +def _resolve_user_to_email(enterprise: enterprise_types.IEnterpriseData, user_id: str) -> Optional[str]: """Resolve user identifier (email, name, or enterprise_user_id) to username (email).""" user_id_lower = user_id.lower() for u in enterprise.users.get_all_entities(): @@ -176,7 +176,7 @@ def _resolve_user_to_email(enterprise: enterprise_types.IEnterpriseData, user_id return None -def _resolve_team_to_uid(enterprise: enterprise_types.IEnterpriseData, team_id: str) -> str | None: +def _resolve_team_to_uid(enterprise: enterprise_types.IEnterpriseData, team_id: str) -> Optional[str]: """Resolve team identifier (name or team_uid) to team_uid.""" for t in enterprise.teams.get_all_entities() or []: if team_id == t.team_uid or team_id.lower() == t.name.lower(): @@ -187,9 +187,9 @@ def _resolve_team_to_uid(enterprise: enterprise_types.IEnterpriseData, team_id: def _collect_recipient_emails( enterprise: enterprise_types.IEnterpriseData, current_username: str, - user_ids: list[str], - team_ids: list[str], -) -> set[str]: + user_ids: List[str], + team_ids: List[str], +) -> Set[str]: """Resolve user_ids and team_ids to a set of recipient emails. Excludes current user.""" emails = set() @@ -231,8 +231,8 @@ def _collect_recipient_emails( def _build_typed_records_for_user( enterprise: enterprise_types.IEnterpriseData, email: str, - record_data: list[dict[str, Any]], -) -> list[TypedRecord]: + record_data: List[Dict[str, Any]], +) -> List[TypedRecord]: """Substitute template params and convert JSON templates to typed records.""" user_records = [] for template in record_data: @@ -247,10 +247,10 @@ def _build_typed_records_for_user( def _build_records_add_request( auth: keeper_auth.KeeperAuth, vault: vault_online.VaultOnline, - typed_records: list[TypedRecord], + typed_records: List[TypedRecord], user_ec_key: Any, user_rsa_key: Any, - record_keys_out: dict[str, bytes], + record_keys_out: Dict[str, bytes], ) -> record_pb2.RecordsAddRequest: """Build RecordsAddRequest and fill record_keys_out with uid -> encrypted_key for transfer.""" rq = record_pb2.RecordsAddRequest() @@ -303,7 +303,7 @@ def _add_transfer_and_cleanup( auth: keeper_auth.KeeperAuth, email: str, add_request: record_pb2.RecordsAddRequest, - record_keys_for_user: dict[str, Any], + record_keys_for_user: Dict[str, Any], ) -> None: """Execute records_add, transfer ownership to user, then unlink from admin (pre_delete + delete).""" rs = auth.execute_auth_rest( @@ -378,7 +378,7 @@ def _process_one_recipient( auth: keeper_auth.KeeperAuth, vault: vault_online.VaultOnline, email: str, - record_data: list[dict[str, Any]], + record_data: List[Dict[str, Any]], ) -> None: """Load user key, build records, add to vault, transfer ownership to user.""" user_key = auth.get_user_keys(email) @@ -428,9 +428,9 @@ def push_enterprise_records( enterprise: enterprise_types.IEnterpriseData, auth: keeper_auth.KeeperAuth, vault: vault_online.VaultOnline, - user_ids: list[str], - team_ids: list[str], - record_data: list[dict[str, Any]], + user_ids: List[str], + team_ids: List[str], + record_data: List[Dict[str, Any]], ) -> None: """Resolve recipients, then for each user substitute template params and add/transfer records.""" emails = list( diff --git a/keepercli-package/src/keepercli/commands/enterprise_team.py b/keepercli-package/src/keepercli/commands/enterprise_team.py index 6aeaabfc..f450d802 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_team.py +++ b/keepercli-package/src/keepercli/commands/enterprise_team.py @@ -1,6 +1,6 @@ import argparse import json -from typing import Dict, List, Optional, Any, Tuple +from typing import Dict, List, Optional, Any, Tuple, Set from keepersdk import utils, crypto from keepersdk.enterprise import enterprise_types, batch_management, enterprise_management @@ -573,7 +573,7 @@ def _build_user_approval_requests( def _collect_team_keys_and_users( self, queued_team_users, teams: Dict[str, Any], added_teams: Dict[str, Any], active_users: Dict[int, str] - ) -> Tuple[Dict[str, Any], set]: + ) -> Tuple[Dict[str, Any], Set]: """Collect team UIDs that need keys loaded and all user emails.""" team_keys = {} all_users = set() @@ -595,7 +595,7 @@ def _collect_team_keys_and_users( def _load_team_and_user_keys( self, vault, team_keys: Dict[str, Any], - added_team_keys: Dict[str, bytes], all_users: set + added_team_keys: Dict[str, bytes], all_users: Set ) -> None: """Load team keys and user public keys from the vault.""" vault.keeper_auth.load_team_keys(list(team_keys.keys())) diff --git a/keepercli-package/src/keepercli/commands/record_edit.py b/keepercli-package/src/keepercli/commands/record_edit.py index 784d2122..99bc79c3 100644 --- a/keepercli-package/src/keepercli/commands/record_edit.py +++ b/keepercli-package/src/keepercli/commands/record_edit.py @@ -6,7 +6,7 @@ import itertools import json import os -from typing import Iterable, Optional, List, Any, Sequence, Union +from typing import Iterable, Optional, List, Any, Sequence, Union, Dict from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ed25519 @@ -227,12 +227,12 @@ def is_generate_value(value: str, parameters: List[str]) -> Optional[bool]: value = value[4:] if value.startswith(':'): gen_parameters = value[1:] - if gen_parameters and isinstance(parameters, list): + if gen_parameters and isinstance(parameters, List): parameters.extend((x.strip() for x in gen_parameters.split(','))) return True @staticmethod - def generate_key_pair(key_type: str, passphrase: str) -> dict: + def generate_key_pair(key_type: str, passphrase: str) -> Dict: private_key: Any public_key: Any if key_type == 'ec': @@ -1276,7 +1276,7 @@ def _build_record_json_output(self, record, record_data, uid: str, unmask: bool return output - def _add_password_record_json_fields(self, record_data: vault_record.PasswordRecord, output: dict, unmask: bool = False): + def _add_password_record_json_fields(self, record_data: vault_record.PasswordRecord, output: Dict, unmask: bool = False): """Add password record specific fields to JSON output.""" output['Notes:'] = record_data.notes output['$login:'] = record_data.login @@ -1305,7 +1305,7 @@ def _add_password_record_json_fields(self, record_data: vault_record.PasswordRec custom_output.append(field_data) output['Custom fields:'] = custom_output - def _add_typed_record_json_fields(self, record_data: vault_record.TypedRecord, output: dict, unmask: bool = False): + def _add_typed_record_json_fields(self, record_data: vault_record.TypedRecord, output: Dict, unmask: bool = False): """Add typed record specific fields to JSON output.""" output['Notes:'] = record_data.notes @@ -1331,13 +1331,13 @@ def _add_typed_record_json_fields(self, record_data: vault_record.TypedRecord, o custom_output.append(field_data) output['Custom:'] = custom_output - def _add_file_record_json_fields(self, record_data: vault_record.FileRecord, output: dict): + def _add_file_record_json_fields(self, record_data: vault_record.FileRecord, output: Dict): """Add file record specific fields to JSON output.""" output['Name:'] = record_data.file_name output['MIME Type:'] = record_data.mime_type output['Size:'] = record_data.size - def _add_share_info_to_json(self, vault: vault_online.VaultOnline, uid: str, output: dict): + def _add_share_info_to_json(self, vault: vault_online.VaultOnline, uid: str, output: Dict): """Add share information to JSON output.""" share_infos = share_management_utils.get_record_shares(vault=vault, record_uids=[uid]) if share_infos and len(share_infos) > 0: @@ -1770,7 +1770,7 @@ def execute(self, context: KeeperParams, **kwargs): search_config = self._prepare_search_config(kwargs) self._perform_search(context.vault, search_config, context) - def _prepare_search_config(self, kwargs: dict) -> dict: + def _prepare_search_config(self, kwargs: Dict) -> Dict: """Prepare search configuration from command line arguments.""" pattern = kwargs.get('pattern') or '' @@ -1786,7 +1786,7 @@ def _prepare_search_config(self, kwargs: dict) -> dict: 'skip_details': not verbose } - def _perform_search(self, vault: vault_online.VaultOnline, config: dict, context: KeeperParams): + def _perform_search(self, vault: vault_online.VaultOnline, config: Dict, context: KeeperParams): """Perform the search across all specified categories.""" valid_categories = set('rst') @@ -1835,7 +1835,7 @@ def _perform_search(self, vault: vault_online.VaultOnline, config: dict, context self._display_all_search_results(search_results, config, context, vault) - def _display_all_search_results(self, search_results: dict, config: dict, context: KeeperParams, vault: vault_online.VaultOnline): + def _display_all_search_results(self, search_results: Dict, config: Dict, context: KeeperParams, vault: vault_online.VaultOnline): """Display all search results after all searches are completed.""" if 'records' in search_results and search_results['records']: logger.info('') @@ -1852,7 +1852,7 @@ def _display_all_search_results(self, search_results: dict, config: dict, contex logger.info('') self._display_teams(search_results['teams'], config['skip_details'], context) - def _search_records(self, config: dict, context: KeeperParams): + def _search_records(self, config: Dict, context: KeeperParams): """Search and display records matching the pattern.""" try: records = context.vault.vault_data.find_records(criteria=config['pattern'], record_type=None, record_version=None) @@ -1865,7 +1865,7 @@ def _search_records(self, config: dict, context: KeeperParams): except Exception as e: logger.error(f"Error searching records: {e}") - def _search_shared_folders(self, vault: vault_online.VaultOnline, config: dict): + def _search_shared_folders(self, vault: vault_online.VaultOnline, config: Dict): """Search and display shared folders matching the pattern.""" try: shared_folders = vault.vault_data.find_shared_folders(criteria=config['pattern']) @@ -1875,7 +1875,7 @@ def _search_shared_folders(self, vault: vault_online.VaultOnline, config: dict): except Exception as e: logger.error(f"Error searching shared folders: {e}") - def _search_teams(self, context: KeeperParams, config: dict): + def _search_teams(self, context: KeeperParams, config: Dict): """Search and display teams matching the pattern.""" try: teams = context.vault.vault_data.find_teams(criteria=config['pattern']) diff --git a/keepercli-package/src/keepercli/commands/record_handling_commands.py b/keepercli-package/src/keepercli/commands/record_handling_commands.py index 0e549e5f..a17bc980 100644 --- a/keepercli-package/src/keepercli/commands/record_handling_commands.py +++ b/keepercli-package/src/keepercli/commands/record_handling_commands.py @@ -3,7 +3,7 @@ import hashlib import json import re -from typing import Optional, List +from typing import Optional, List, Dict, Tuple import urllib from colorama import Fore, Back, Style @@ -258,7 +258,7 @@ def _load_record_with_revision(self, context: KeeperParams, record_uid: str, rev else: return context.vault.vault_data.load_record(record_uid) - def _extract_record_data(self, record, kwargs) -> tuple[str, str]: + def _extract_record_data(self, record, kwargs) -> Tuple[str, str]: """Extract data from record based on command options.""" if kwargs.get('copy_uid'): if kwargs.get('output', '') == 'clipboard': @@ -273,7 +273,7 @@ def _extract_record_data(self, record, kwargs) -> tuple[str, str]: else: return self._extract_password_data(record) - def _extract_totp_data(self, record) -> tuple[str, str]: + def _extract_totp_data(self, record) -> Tuple[str, str]: """Extract TOTP data from record.""" totp_url = None if isinstance(record, vault_record.PasswordRecord): @@ -291,7 +291,7 @@ def _extract_totp_data(self, record) -> tuple[str, str]: return 'TOTP Code', result[0] return 'TOTP Code', '' - def _extract_field_data(self, record, field_name: str) -> tuple[str, str]: + def _extract_field_data(self, record, field_name: str) -> Tuple[str, str]: """Extract custom field data from record.""" if field_name == 'notes': notes = record.notes if hasattr(record, 'notes') else '' @@ -299,7 +299,7 @@ def _extract_field_data(self, record, field_name: str) -> tuple[str, str]: else: return self._extract_custom_field_data(record, field_name) - def _extract_custom_field_data(self, record, field_name: str) -> tuple[str, str]: + def _extract_custom_field_data(self, record, field_name: str) -> Tuple[str, str]: """Extract custom field data from record.""" copy_item = f'Custom Field "{field_name}"' field_name, field_property = self._parse_field_name(field_name) @@ -314,14 +314,14 @@ def _extract_custom_field_data(self, record, field_name: str) -> tuple[str, str] return copy_item, '' - def _parse_field_name(self, field_name: str) -> tuple[str, str]: + def _parse_field_name(self, field_name: str) -> Tuple[str, str]: """Parse field name and property.""" pre, sep, prop = field_name.rpartition(':') if sep == ':': return pre, prop return field_name, '' - def _extract_typed_field_data(self, record, field_name: str, field_property: str, copy_item: str) -> tuple[str, str]: + def _extract_typed_field_data(self, record, field_name: str, field_property: str, copy_item: str) -> Tuple[str, str]: """Extract data from typed field.""" field_type, sep, field_label = field_name.partition('.') rf = record_types.RecordFields.get(field_type) @@ -351,7 +351,7 @@ def _extract_typed_field_data(self, record, field_name: str, field_property: str else: return copy_item, '\n'.join(field.get_external_value()) - def _extract_password_data(self, record) -> tuple[str, str]: + def _extract_password_data(self, record) -> Tuple[str, str]: """Extract password data from record.""" if isinstance(record, vault_record.PasswordRecord): return 'Password', record.password @@ -361,7 +361,7 @@ def _extract_password_data(self, record) -> tuple[str, str]: return 'Password', password_field.get_default_value(str) return 'Password', '' - def _output_data(self, copy_item: str, text: str, kwargs: dict, context: KeeperParams, record_uid: str): + def _output_data(self, copy_item: str, text: str, kwargs: Dict, context: KeeperParams, record_uid: str): """Output data to specified destination.""" output_type = kwargs.get('output', 'clipboard') @@ -384,7 +384,7 @@ def _output_data(self, copy_item: str, text: str, kwargs: dict, context: KeeperP if copy_item == 'Password' and text: context.vault.client_audit_event_plugin().schedule_audit_event('copy_password', record_uid=record_uid) - def _load_record_history(self, context: KeeperParams, record_uid: str) -> Optional[list]: + def _load_record_history(self, context: KeeperParams, record_uid: str) -> Optional[List]: """Load record history from server.""" if not context.vault: raise ValueError('Vault is not initialized. Login to initialize the vault.') @@ -392,7 +392,7 @@ def _load_record_history(self, context: KeeperParams, record_uid: str) -> Option return self._load_record_history_static(context.vault, record_uid) @staticmethod - def _load_record_history_static(vault: vault_online.VaultOnline, record_uid: str) -> Optional[list]: + def _load_record_history_static(vault: vault_online.VaultOnline, record_uid: str) -> Optional[List]: """Load record history from server (static method for sharing).""" current_rec = vault.vault_data._records[record_uid] record_key = current_rec.record_key @@ -535,7 +535,7 @@ def _find_record_in_folder(self, vault: vault_online.VaultOnline, folder, record return folder_record_uid return None - def _execute_action(self, action: str, vault: vault_online.VaultOnline, history: list, kwargs: dict): + def _execute_action(self, action: str, vault: vault_online.VaultOnline, history: List, kwargs: Dict): """Execute the specified history action.""" if action == 'list': return self._list_history(history, kwargs) @@ -546,7 +546,7 @@ def _execute_action(self, action: str, vault: vault_online.VaultOnline, history: elif action == 'restore': return self._restore_revision(vault, history, kwargs) - def _list_history(self, history: list, kwargs: dict): + def _list_history(self, history: List, kwargs: Dict): """List record history revisions.""" fmt = kwargs.get('format', '') headers = ['version', 'modified_by', 'time_modified'] @@ -564,7 +564,7 @@ def _list_history(self, history: list, kwargs: dict): return report_utils.dump_report_data(rows, headers, fmt=fmt, filename=kwargs.get('output')) - def _view_revision(self, history: list, kwargs: dict): + def _view_revision(self, history: List, kwargs: Dict): """View a specific revision.""" revision = kwargs.get('revision') or 0 length = len(history) @@ -602,7 +602,7 @@ def _view_revision(self, history: list, kwargs: dict): right_align=(0,) ) - def _show_diff(self, history: list, kwargs: dict): + def _show_diff(self, history: List, kwargs: Dict): """Show differences between revisions.""" revision = kwargs.get('revision') or 0 verbose = kwargs.get('verbose') or False @@ -617,7 +617,7 @@ def _show_diff(self, history: list, kwargs: dict): headers = ('Version', 'Field', 'New Value', 'Old Value') report_utils.dump_report_data(rows, headers) - def _generate_diff_rows(self, history: list, start_index: int, length: int, verbose: bool) -> list: + def _generate_diff_rows(self, history: List, start_index: int, length: int, verbose: bool) -> List: """Generate diff rows between revisions.""" count = MAX_VERSION_COUNT current = history[start_index].get('data_unencrypted') @@ -649,7 +649,7 @@ def _generate_diff_rows(self, history: list, start_index: int, length: int, verb return rows - def _get_record_fields(self, record: dict) -> dict: + def _get_record_fields(self, record: Dict) -> Dict: """Get record fields as dictionary.""" return_fields = {} return_fields['Title'] = record.get('title') @@ -663,7 +663,7 @@ def _get_record_fields(self, record: dict) -> dict: return_fields[name] = value return return_fields - def _add_field_differences(self, rows: list, current_fields: dict, previous_fields: dict): + def _add_field_differences(self, rows: List, current_fields: Dict, previous_fields: Dict): """Add field differences to rows.""" for name, value in current_fields.items(): if name in previous_fields: @@ -681,7 +681,7 @@ def _add_field_differences(self, rows: list, current_fields: dict, previous_fiel value = '\n'.join(value) rows.append(['', name, '', value]) - def _truncate_long_values(self, rows: list): + def _truncate_long_values(self, rows: List): """Truncate long values in diff rows for better readability.""" for row in rows: for index in (2, 3): @@ -694,7 +694,7 @@ def _truncate_long_values(self, rows: list): lines.append('...') row[index] = '\n'.join(lines) - def _restore_revision(self, vault: vault_online.VaultOnline, history: list, kwargs: dict): + def _restore_revision(self, vault: vault_online.VaultOnline, history: List, kwargs: Dict): """Restore a specific revision.""" revision = kwargs.get('revision') or 0 length = len(history) @@ -792,7 +792,7 @@ def _validate_context(self, context: KeeperParams): if not context.vault: raise base.CommandError('Vault is not initialized') - def _process_vault_duplicates(self, context: KeeperParams, kwargs: dict): + def _process_vault_duplicates(self, context: KeeperParams, kwargs: Dict): vault = context.vault match_fields = self._determine_match_fields(kwargs) diff --git a/keepercli-package/src/keepercli/commands/record_type_utils.py b/keepercli-package/src/keepercli/commands/record_type_utils.py index 5130171a..a1ee05bd 100644 --- a/keepercli-package/src/keepercli/commands/record_type_utils.py +++ b/keepercli-package/src/keepercli/commands/record_type_utils.py @@ -1,4 +1,5 @@ import json +from typing import List from .. import api @@ -99,7 +100,7 @@ def get_record_type_example(vault: vault_online.VaultOnline, record_type_name: s raise ValueError(f'No record type found with name {record_type_name}. Use "record-type-info" to list all record types') -def get_record_types(vault:vault_online.VaultOnline) -> list[vault_types.RecordType]: +def get_record_types(vault:vault_online.VaultOnline) -> List[vault_types.RecordType]: records = [] # (recordTypeId, name, scope) record_types = vault.vault_data.get_record_types() diff --git a/keepercli-package/src/keepercli/commands/secrets_manager.py b/keepercli-package/src/keepercli/commands/secrets_manager.py index 141c4586..25e2f41e 100644 --- a/keepercli-package/src/keepercli/commands/secrets_manager.py +++ b/keepercli-package/src/keepercli/commands/secrets_manager.py @@ -1,7 +1,7 @@ import argparse from enum import Enum import time -from typing import Optional +from typing import Optional, List, Set, Tuple from keepersdk import utils from keepersdk.proto.enterprise_pb2 import GENERAL @@ -454,7 +454,7 @@ def _confirm_remove_all_clients(clients_count: int) -> bool: return user_choice.lower() == USER_CHOICE_YES @staticmethod - def remove_client(vault: vault_online.VaultOnline, uid: str, client_names_and_ids: list[str], force: bool = False): + def remove_client(vault: vault_online.VaultOnline, uid: str, client_names_and_ids: List[str], force: bool = False): """Remove client devices from a KSM application.""" ksm_management.KSMClientManagement.remove_clients_from_ksm_app( vault=vault, @@ -539,7 +539,7 @@ def _get_app_uid_from_kwargs(self, vault, app_uid_or_name: Optional[str]) -> str return ksm_app.record_uid - def _parse_secret_uids(self, secret_uids_str: Optional[str]) -> list[str]: + def _parse_secret_uids(self, secret_uids_str: Optional[str]) -> List[str]: """Parse secret UIDs from string.""" if not secret_uids_str: return [] @@ -552,7 +552,7 @@ def _find_ksm_application(self, vault: vault_online.VaultOnline, app_uid_or_name None ) - def _handle_add_share(self, context: KeeperParams, app_uid: str, secret_uids: list[str], is_editable: bool) -> None: + def _handle_add_share(self, context: KeeperParams, app_uid: str, secret_uids: List[str], is_editable: bool) -> None: """Handle adding shares to a KSM application.""" if not context.vault: raise ValueError("Vault is not initialized.") @@ -600,7 +600,7 @@ def _handle_share_error(kae: base.errors.KeeperApiError) -> bool: raise ValueError(f"Failed to share secrets: {kae}") @staticmethod - def remove_share(vault: vault_online.VaultOnline, app_uid: str, secret_uids: list[str]) -> None: + def remove_share(vault: vault_online.VaultOnline, app_uid: str, secret_uids: List[str]) -> None: """Remove shares from a KSM application.""" if not secret_uids: logger.warning("No secret UIDs provided for removal.") @@ -623,7 +623,7 @@ def remove_share(vault: vault_online.VaultOnline, app_uid: str, secret_uids: lis logger.info("Shared secrets were successfully removed from the application\n") @staticmethod - def _get_current_shared_uids(vault: vault_online.VaultOnline, app_uid: str) -> set: + def _get_current_shared_uids(vault: vault_online.VaultOnline, app_uid: str) -> Set: """Get currently shared UIDs for the application.""" app_infos = ksm_management.get_app_info(vault=vault, app_uid=app_uid) if not app_infos: @@ -636,14 +636,14 @@ def _get_current_shared_uids(vault: vault_online.VaultOnline, app_uid: str) -> s } @staticmethod - def _validate_share_uids(secret_uids: list[str], current_shared_uids: set) -> tuple[list, list]: + def _validate_share_uids(secret_uids: List[str], current_shared_uids: Set) -> Tuple[List, List]: """Validate secret UIDs against currently shared UIDs.""" valid_uids = [uid for uid in secret_uids if uid in current_shared_uids] invalid_uids = [uid for uid in secret_uids if uid not in current_shared_uids] return valid_uids, invalid_uids @staticmethod - def _log_invalid_uids(invalid_uids: list[str]) -> None: + def _log_invalid_uids(invalid_uids: List[str]) -> None: """Log warnings for invalid UIDs.""" for uid in invalid_uids: logger.warning(f"Secret UID '{uid}' is not shared with this application. Skipping.") diff --git a/keepercli-package/src/keepercli/commands/shares.py b/keepercli-package/src/keepercli/commands/shares.py index 40cc05d0..2794969e 100644 --- a/keepercli-package/src/keepercli/commands/shares.py +++ b/keepercli-package/src/keepercli/commands/shares.py @@ -3,7 +3,7 @@ import math import re from enum import Enum -from typing import Optional +from typing import Optional, List, Set, Dict from keepersdk import utils from keepersdk.authentication import keeper_auth @@ -132,7 +132,7 @@ def execute(self, context: KeeperParams, **kwargs) -> None: if not context.vault: raise ValueError("Vault is not initialized.") vault = context.vault - + uid_or_name = kwargs.get('record') if not uid_or_name: return self.get_parser().print_help() @@ -154,7 +154,7 @@ def execute(self, context: KeeperParams, **kwargs) -> None: share_expiration = share_management_utils.get_share_expiration( kwargs.get('expire_at'), kwargs.get('expire_in') ) - + request = RecordShares.prep_request( vault=vault, enterprise=context.enterprise_data, @@ -177,7 +177,7 @@ def execute(self, context: KeeperParams, **kwargs) -> None: logger.error(f'Failed to process share request: {failed_response}') vault.sync_down() - def _validate_and_replace_contacts(self, vault, emails: list, force: bool) -> list: + def _validate_and_replace_contacts(self, vault, emails: List, force: bool) -> List: """Validate emails against known contacts and optionally replace with matches.""" shared_objects = share_management_utils.get_share_objects(vault=vault) known_users = shared_objects.get('users', {}) @@ -315,7 +315,7 @@ def execute(self, context: KeeperParams, **kwargs) -> None: logger.error(f'Failed to process share request: {failed_response}') vault.sync_down() - def _normalize_folder_names(self, folder_names) -> list: + def _normalize_folder_names(self, folder_names) -> List: """Normalize folder names list and check for wildcard.""" if not folder_names: return [] @@ -323,7 +323,7 @@ def _normalize_folder_names(self, folder_names) -> list: return [folder_names] return folder_names - def _resolve_shared_folder_uids(self, vault: vault_online.VaultOnline, names: list) -> set: + def _resolve_shared_folder_uids(self, vault: vault_online.VaultOnline, names: List) -> Set: """Resolve folder names to shared folder UIDs.""" all_folders = any(x == ALL_FOLDERS_WILDCARD for x in names) if all_folders: @@ -342,8 +342,8 @@ def _resolve_shared_folder_uids(self, vault: vault_online.VaultOnline, names: li return shared_folder_uids - def _resolve_specific_folders(self, vault: vault_online.VaultOnline, names: list, - shared_folder_cache: dict, folder_cache: dict) -> set: + def _resolve_specific_folders(self, vault: vault_online.VaultOnline, names: List, + shared_folder_cache: Dict, folder_cache: Dict) -> Set: """Resolve specific folder names to shared folder UIDs.""" shared_folder_uids = set() folder_uids = { @@ -371,7 +371,7 @@ def _resolve_specific_folders(self, vault: vault_online.VaultOnline, names: list return shared_folder_uids def _get_share_admin_obj_uids(self, vault: vault_online.VaultOnline, - obj_names: list, obj_type) -> Optional[set]: + obj_names: List, obj_type) -> Optional[Set]: """Get UIDs of objects where user is share admin.""" if not obj_names: return None @@ -403,7 +403,7 @@ def _get_share_admin_obj_uids(self, vault: vault_online.VaultOnline, except (ValueError, AttributeError) as e: raise ValueError(f'get_share_admin: msg = {e}') from e - def _get_record_uids(self, vault: vault_online.VaultOnline, name: str) -> set[str]: + def _get_record_uids(self, vault: vault_online.VaultOnline, name: str) -> Set[str]: """Get record UIDs by name or UID.""" record_uids = set() if not vault or not vault.vault_data: @@ -420,7 +420,7 @@ def _get_record_uids(self, vault: vault_online.VaultOnline, name: str) -> set[st return record_uids - def _get_share_expiration(self, action: str, kwargs: dict): + def _get_share_expiration(self, action: str, kwargs: Dict): """Get share expiration if action is grant.""" if action == ShareAction.GRANT.value: return share_management_utils.get_share_expiration( @@ -428,7 +428,7 @@ def _get_share_expiration(self, action: str, kwargs: dict): ) return None - def _parse_user_arguments(self, vault, kwargs: dict) -> dict: + def _parse_user_arguments(self, vault, kwargs: Dict) -> Dict: """Parse user arguments and return user data.""" as_users = set() as_teams = set() @@ -485,7 +485,7 @@ def _resolve_team_uid(self, vault, team_identifier: str) -> Optional[str]: return matches[0] - def _parse_record_arguments(self, vault, kwargs: dict) -> dict: + def _parse_record_arguments(self, vault, kwargs: Dict) -> Dict: """Parse record arguments and return record data.""" record_uids = set() all_records = False @@ -525,7 +525,7 @@ def _parse_record_arguments(self, vault, kwargs: dict) -> dict: 'default_record': default_record } - def _is_nothing_to_do(self, user_data: dict, record_data: dict) -> bool: + def _is_nothing_to_do(self, user_data: Dict, record_data: Dict) -> bool: """Check if there's nothing to do based on user and record data.""" return ( len(user_data['users']) == 0 and @@ -537,9 +537,9 @@ def _is_nothing_to_do(self, user_data: dict, record_data: dict) -> bool: not record_data['all_records'] ) - def _prepare_request_groups(self, vault: vault_online.VaultOnline, shared_folder_uids: set, - user_data: dict, record_data: dict, action: str, - share_expiration, kwargs: dict) -> list: + def _prepare_request_groups(self, vault: vault_online.VaultOnline, shared_folder_uids: Set, + user_data: Dict, record_data: Dict, action: str, + share_expiration, kwargs: Dict) -> List: """Prepare request groups for all shared folders.""" rq_groups = [] shared_folder_cache = {x.shared_folder_uid: x for x in vault.vault_data.shared_folders()} @@ -554,9 +554,9 @@ def _prepare_request_groups(self, vault: vault_online.VaultOnline, shared_folder return rq_groups def _prepare_folder_requests(self, vault: vault_online.VaultOnline, sf_uid: str, - shared_folder_cache: dict, user_data: dict, - record_data: dict, action: str, share_expiration, - kwargs: dict) -> list: + shared_folder_cache: Dict, user_data: Dict, + record_data: Dict, action: str, share_expiration, + kwargs: Dict) -> List: """Prepare requests for a single shared folder.""" sf_users = user_data['users'].copy() sf_teams = user_data['teams'].copy() @@ -576,8 +576,8 @@ def _prepare_folder_requests(self, vault: vault_online.VaultOnline, sf_uid: str, record_data['default_record'], user_data['default_account'], share_expiration ) - def _load_or_create_shared_folder(self, vault: vault_online.VaultOnline, sf_uid: str, shared_folder_cache: dict, - user_data: dict, record_data: dict, action: str): + def _load_or_create_shared_folder(self, vault: vault_online.VaultOnline, sf_uid: str, shared_folder_cache: Dict, + user_data: Dict, record_data: Dict, action: str): """Load existing shared folder or create a new one.""" if sf_uid in shared_folder_cache: return vault.vault_data.load_shared_folder(sf_uid) @@ -611,8 +611,8 @@ def _load_or_create_shared_folder(self, vault: vault_online.VaultOnline, sf_uid: } def _update_from_existing_folder(self, sh_fol, auth: keeper_auth.KeeperAuth, - user_data: dict, record_data: dict, - sf_users: set, sf_records: set): + user_data: Dict, record_data: Dict, + sf_users: Set, sf_records: Set): """Update user and record sets from existing folder permissions.""" if not (user_data['all_users'] or record_data['all_records']): return @@ -626,10 +626,10 @@ def _update_from_existing_folder(self, sh_fol, auth: keeper_auth.KeeperAuth, if record_data['all_records'] and sh_fol.record_permissions: sf_records.update(x.record_uid for x in sh_fol.record_permissions) - def _chunk_and_prepare_requests(self, vault: vault_online.VaultOnline, kwargs: dict, sh_fol, sf_uid: str, - sf_users: set, sf_teams: set, sf_records: set, + def _chunk_and_prepare_requests(self, vault: vault_online.VaultOnline, kwargs: Dict, sh_fol, sf_uid: str, + sf_users: Set, sf_teams: Set, sf_records: Set, default_record: bool, default_account: bool, - share_expiration) -> list: + share_expiration) -> List: """Chunk records and users, then prepare requests.""" rec_list = list(sf_records) user_list = list(sf_users) @@ -668,7 +668,7 @@ def _chunk_and_prepare_requests(self, vault: vault_online.VaultOnline, kwargs: d return rq_groups def _build_shared_folder_info(self, sh_fol, sf_uid: str, sf_unencrypted_key, - shared_folder_revision: int, group_idx: int) -> dict: + shared_folder_revision: int, group_idx: int) -> Dict: """Build shared folder info dictionary.""" if isinstance(sh_fol, dict): sf_info = sh_fol.copy() @@ -741,7 +741,7 @@ def execute(self, context: KeeperParams, **kwargs): return self._format_output(table_data, kwargs) - def _resolve_record_uids(self, context: KeeperParams, vault, records: list, recursive: bool) -> set: + def _resolve_record_uids(self, context: KeeperParams, vault, records: List, recursive: bool) -> Set: """Resolve record names/paths to UIDs.""" record_uids = set() @@ -774,7 +774,7 @@ def _resolve_record_uids(self, context: KeeperParams, vault, records: list, recu return record_uids - def _add_folder_records(self, vault, folder_uid: str, record_uids: set, recursive: bool): + def _add_folder_records(self, vault, folder_uid: str, record_uids: Set, recursive: bool): """Add records from a folder to the record_uids set.""" def on_folder(f): f_uid = f.folder_uid or '' @@ -790,7 +790,7 @@ def on_folder(f): else: on_folder(folder) - def _get_applications(self, vault, record_uids: set): + def _get_applications(self, vault, record_uids: Set): """Get application info for the given record UIDs.""" r_uids = list(record_uids) if len(r_uids) >= MAX_BATCH_SIZE: @@ -926,7 +926,7 @@ def _validate_and_parse_expiration(self, period_str): raise base.CommandError('URL expiration period cannot be greater than 6 months.') return period - def _create_share_urls(self, context: KeeperParams, vault, record_names: list, period, name: str, is_editable: bool): + def _create_share_urls(self, context: KeeperParams, vault, record_names: List, period, name: str, is_editable: bool): """Create share URLs for the given records.""" urls = {} for record_name in record_names: @@ -938,7 +938,7 @@ def _create_share_urls(self, context: KeeperParams, vault, record_names: list, p urls[record_uid] = str(url) return urls - def _handle_output(self, context: KeeperParams, urls: dict, kwargs): + def _handle_output(self, context: KeeperParams, urls: Dict, kwargs): """Handle different output formats for the URLs.""" if context.keeper_config.batch_mode: return '\n'.join(urls.values()) @@ -954,7 +954,7 @@ def _handle_output(self, context: KeeperParams, urls: dict, kwargs): else: return '\n'.join(urls.values()) - def _copy_to_clipboard(self, urls: dict): + def _copy_to_clipboard(self, urls: Dict): """Copy URL to clipboard.""" import pyperclip url = next(iter(urls.values())) @@ -962,7 +962,7 @@ def _copy_to_clipboard(self, urls: dict): logger.info('One-Time record share URL is copied to clipboard') return None - def _output_to_stdout(self, urls: dict): + def _output_to_stdout(self, urls: Dict): """Output URLs to stdout in table format.""" table = [list(x) for x in urls.items()] headers = ['Record UID', 'URL'] @@ -1042,7 +1042,7 @@ def _find_client_id(self, applications, share_name: str) -> Optional[bytes]: return self._resolve_partial_matches(partial_matches, share_name) - def _resolve_partial_matches(self, partial_matches: list[bytes], original_name: str) -> Optional[bytes]: + def _resolve_partial_matches(self, partial_matches: List[bytes], original_name: str) -> Optional[bytes]: """ Resolve partial matches to a single client ID. diff --git a/keepercli-package/src/keepercli/commands/vault_folder.py b/keepercli-package/src/keepercli/commands/vault_folder.py index b1f09cff..2d026ef3 100644 --- a/keepercli-package/src/keepercli/commands/vault_folder.py +++ b/keepercli-package/src/keepercli/commands/vault_folder.py @@ -203,11 +203,11 @@ def execute(self, context: KeeperParams, **kwargs): show_records: bool = kwargs.get('records') is True show_shares: bool = kwargs.get('shares') is True - def tree_node(node: vault_types.Folder) -> Tuple[str, dict]: + def tree_node(node: vault_types.Folder) -> Tuple[str, Dict]: if context.vault is None: raise base.CommandError('Vault is not initialized. Login to initialize the vault.') name = node.name - children: dict = OrderedDict() + children: Dict = OrderedDict() if verbose and node.folder_uid: name += f' ({node.folder_uid})' diff --git a/keepercli-package/src/keepercli/helpers/ksm_utils.py b/keepercli-package/src/keepercli/helpers/ksm_utils.py index 8f7bee88..56640cac 100644 --- a/keepercli-package/src/keepercli/helpers/ksm_utils.py +++ b/keepercli-package/src/keepercli/helpers/ksm_utils.py @@ -1,3 +1,5 @@ +from typing import List + from keepersdk.vault import ksm from .. import api @@ -5,7 +7,7 @@ logger = api.get_logger() -def print_client_device_info(client_devices: list[ksm.ClientDevice]) -> None: +def print_client_device_info(client_devices: List[ksm.ClientDevice]) -> None: for index, client_device in enumerate(client_devices, start=1): client_devices_str = f"\nClient Device {index}\n" \ f"=============================\n" \ @@ -19,7 +21,7 @@ def print_client_device_info(client_devices: list[ksm.ClientDevice]) -> None: f' IP Address: {client_device.ip_address or "--"}' logger.info(client_devices_str) -def print_shared_secrets_info(shared_secrets: list[ksm.SharedSecretsInfo]) -> None: +def print_shared_secrets_info(shared_secrets: List[ksm.SharedSecretsInfo]) -> None: shares_table_fields = ['Share Type', 'UID', 'Title', 'Permissions'] rows = [ [secrets.type, secrets.uid, secrets.name, secrets.permissions] diff --git a/keepercli-package/src/keepercli/versioning.py b/keepercli-package/src/keepercli/versioning.py index 73397904..aa9ff5db 100644 --- a/keepercli-package/src/keepercli/versioning.py +++ b/keepercli-package/src/keepercli/versioning.py @@ -92,12 +92,12 @@ def welcome_print_version(): this_app_version = __version__ - ver_info = is_up_to_date_version() - - if ver_info.get('is_up_to_date') is None: - api.get_logger().debug("It appears that the internet connection is offline.") - - elif not ver_info.get('is_up_to_date'): - prompt_utils.output_text(f" Your version of the Commander CLI is {this_app_version}, " - f"the current version is {ver_info.get('current_github_version')}.\n" - f" Use the ‘version’ command for more details.\n", color='WARNING') + # ver_info = is_up_to_date_version() + # + # if ver_info.get('is_up_to_date') is None: + # api.get_logger().debug("It appears that the internet connection is offline.") + # + # elif not ver_info.get('is_up_to_date'): + # prompt_utils.output_text(f" Your version of the Commander CLI is {this_app_version}, " + # f"the current version is {ver_info.get('current_github_version')}.\n" + # f" Use the ‘version’ command for more details.\n", color='WARNING') diff --git a/keepersdk-package/src/keepersdk/authentication/configuration.py b/keepersdk-package/src/keepersdk/authentication/configuration.py index f4154e49..dcf2c9d8 100644 --- a/keepersdk-package/src/keepersdk/authentication/configuration.py +++ b/keepersdk-package/src/keepersdk/authentication/configuration.py @@ -449,7 +449,7 @@ def put(self, configuration): class _JsonConfigurationCollection(list, IConfigurationCollection): - def __init__(self, entity_type: Union[Type[dict], Type[IEntityId]], lst:Optional[List[Dict]]=None) -> None: + def __init__(self, entity_type: Union[Type[Dict], Type[IEntityId]], lst:Optional[List[Dict]]=None) -> None: super(_JsonConfigurationCollection, self).__init__() if isinstance(lst, list): for entity in lst: diff --git a/keepersdk-package/src/keepersdk/vault/ksm_management.py b/keepersdk-package/src/keepersdk/vault/ksm_management.py index 31a6f883..1d5999ed 100644 --- a/keepersdk-package/src/keepersdk/vault/ksm_management.py +++ b/keepersdk-package/src/keepersdk/vault/ksm_management.py @@ -4,7 +4,7 @@ import logging import os -from typing import Callable, Optional, List, Union +from typing import Callable, Optional, List, Union, Tuple, Set, Dict from urllib import parse from . import ksm, record_management, shares_management, share_management_utils, vault_online, vault_record, vault_types @@ -163,7 +163,7 @@ def remove_secrets_manager_app(vault: vault_online.VaultOnline, uid_or_name: str def share_secrets_manager_app(vault: vault_online.VaultOnline, enterprise: enterprise_data.EnterpriseData, - app_uid: str, emails: list[str], action: str, can_edit: bool, can_share: bool) -> tuple[list, list]: + app_uid: str, emails: List[str], action: str, can_edit: bool, can_share: bool) -> Tuple[List, List]: request = shares_management.RecordShares.prep_request( vault=vault, emails=emails, action=action, uid_or_name=app_uid, @@ -181,7 +181,7 @@ def share_secrets_manager_app(vault: vault_online.VaultOnline, enterprise: enter return success_responses.extend(success_responses_content), failed_responses.extend(failed_responses_content) -def _update_shares_user_permissions(vault: vault_online.VaultOnline, enterprise: enterprise_data.EnterpriseData, uid: str, removed: bool) -> tuple[list, list]: +def _update_shares_user_permissions(vault: vault_online.VaultOnline, enterprise: enterprise_data.EnterpriseData, uid: str, removed: bool) -> Tuple[List, List]: # Get user permissions for the app user_perms = _get_app_user_permissions(vault=vault, uid=uid) @@ -203,7 +203,7 @@ def _update_shares_user_permissions(vault: vault_online.VaultOnline, enterprise: ) -def _get_app_user_permissions(vault: vault_online.VaultOnline, uid: str) -> list: +def _get_app_user_permissions(vault: vault_online.VaultOnline, uid: str) -> List: """Get user permissions for the application.""" share_info = share_management_utils.get_record_shares(vault=vault, record_uids=[uid], is_share_admin=False) user_perms = [] @@ -240,7 +240,7 @@ def _separate_shared_items(vault: vault_online.VaultOnline, shared_secrets): def _process_share_updates(vault: vault_online.VaultOnline, enterprise: enterprise_data.EnterpriseData, - user_perms: list, shared_recs: list, shared_folders: list, removed: bool) -> tuple[list, list]: + user_perms: List, shared_recs: List, shared_folders: List, removed: bool) -> Tuple[List, List]: """Process share updates for users.""" app_users_map = _categorize_app_users(vault, user_perms) @@ -251,7 +251,7 @@ def _process_share_updates(vault: vault_online.VaultOnline, enterprise: enterpri return _send_share_requests(vault, sf_requests, rec_requests) -def _categorize_app_users(vault: vault_online.VaultOnline, user_perms: list) -> dict: +def _categorize_app_users(vault: vault_online.VaultOnline, user_perms: List) -> Dict: """Categorize users into admins and viewers.""" current_username = vault.keeper_auth.auth_context.username admins = [ @@ -266,8 +266,8 @@ def _categorize_app_users(vault: vault_online.VaultOnline, user_perms: list) -> def _build_share_requests(vault: vault_online.VaultOnline, enterprise: enterprise_data.EnterpriseData, - app_users_map: dict, shared_recs: list, shared_folders: list, - removed: bool) -> tuple: + app_users_map: Dict, shared_recs: List, shared_folders: List, + removed: bool) -> Tuple: """Build share requests for folders and records.""" sf_requests = [] rec_requests = [] @@ -296,7 +296,7 @@ def _build_share_requests(vault: vault_online.VaultOnline, enterprise: enterpris return sf_requests, rec_requests -def _send_share_requests(vault: vault_online.VaultOnline, sf_requests: list, rec_requests: list) -> tuple[list, list]: +def _send_share_requests(vault: vault_online.VaultOnline, sf_requests: List, rec_requests: List) -> Tuple[List, List]: """Send share requests to the server.""" success_responses = [] failed_responses = [] @@ -311,7 +311,7 @@ def _send_share_requests(vault: vault_online.VaultOnline, sf_requests: list, rec return success_responses, failed_responses -def _user_needs_update(vault: vault_online.VaultOnline, user: str, share_uids: list, removed: bool) -> bool: +def _user_needs_update(vault: vault_online.VaultOnline, user: str, share_uids: List, removed: bool) -> bool: """Check if a user needs share permission updates.""" record_permissions = _get_record_permissions(vault, share_uids) record_cache = {x.record_uid: x for x in vault.vault_data.records()} @@ -331,7 +331,7 @@ def _user_needs_update(vault: vault_online.VaultOnline, user: str, share_uids: l return False -def _get_record_permissions(vault: vault_online.VaultOnline, share_uids: list) -> dict: +def _get_record_permissions(vault: vault_online.VaultOnline, share_uids: List) -> Dict: """Get record permissions for given share UIDs.""" record_share_info = share_management_utils.get_record_shares( vault=vault, @@ -351,7 +351,7 @@ def _get_record_permissions(vault: vault_online.VaultOnline, share_uids: list) - def _get_share_user_permissions(vault: vault_online.VaultOnline, share_uid: str, - record_cache: dict, record_permissions: dict) -> list: + record_cache: Dict, record_permissions: Dict) -> List: """Get user permissions for a share (record or folder).""" is_record_share = share_uid in record_cache @@ -365,8 +365,8 @@ def _get_share_user_permissions(vault: vault_online.VaultOnline, share_uid: str, return [] -def _create_folder_share_requests(vault: vault_online.VaultOnline, shared_folders: list, - users: list, removed: bool) -> list: +def _create_folder_share_requests(vault: vault_online.VaultOnline, shared_folders: List, + users: List, removed: bool) -> List: """Create folder share requests.""" if not shared_folders: return [] @@ -386,7 +386,7 @@ def _create_folder_share_requests(vault: vault_online.VaultOnline, shared_folder def _build_folder_share_request(vault: vault_online.VaultOnline, folder_uid: str, - user: str, action: str) -> dict: + user: str, action: str) -> Dict: """Build a single folder share request.""" shared_folder = vault.vault_data.load_shared_folder(folder_uid) shared_folder_revision = vault.vault_data.storage.shared_folders.get_entity(folder_uid).revision @@ -416,8 +416,8 @@ def _build_folder_share_request(vault: vault_online.VaultOnline, folder_uid: str ) -def _create_record_share_requests(vault: vault_online.VaultOnline, enterprise: enterprise_data.EnterpriseData, shared_recs: list, - users: list, removed: bool) -> list: +def _create_record_share_requests(vault: vault_online.VaultOnline, enterprise: enterprise_data.EnterpriseData, shared_recs: List, + users: List, removed: bool) -> List: """Create record share requests.""" if not shared_recs or not vault: return [] @@ -503,7 +503,7 @@ def add_client_to_ksm_app( first_access_expire_duration_ms: int, access_expire_in_ms: Optional[int], master_key: bytes, - server: str) -> dict: + server: str) -> Dict: """Generate a single client device and return token info and output string.""" # Generate secret and client ID @@ -664,7 +664,7 @@ def _format_timestamp(timestamp_ms: int) -> str: return 'Invalid timestamp' @staticmethod - def remove_clients_from_ksm_app(vault: vault_online.VaultOnline, uid: str, client_names_and_ids: list[str], callable: Callable = None): + def remove_clients_from_ksm_app(vault: vault_online.VaultOnline, uid: str, client_names_and_ids: List[str], callable: Callable = None): """Remove client devices from a KSM application.""" client_hashes = KSMClientManagement._convert_to_client_hashes( vault, uid, client_names_and_ids @@ -682,7 +682,7 @@ def remove_clients_from_ksm_app(vault: vault_online.VaultOnline, uid: str, clien @staticmethod def _convert_to_client_hashes(vault: vault_online.VaultOnline, uid: str, - client_names_and_ids: list[str]) -> list[bytes]: + client_names_and_ids: List[str]) -> List[bytes]: """Convert client names/IDs to client ID hashes.""" exact_matches, partial_matches = KSMClientManagement._categorize_client_matches( client_names_and_ids @@ -707,7 +707,7 @@ def _convert_to_client_hashes(vault: vault_online.VaultOnline, uid: str, return client_id_hashes_bytes @staticmethod - def _categorize_client_matches(client_names_and_ids: list[str]) -> tuple[set, set]: + def _categorize_client_matches(client_names_and_ids: List[str]) -> Tuple[Set, Set]: """Categorize client names/IDs into exact and partial matches.""" exact_matches = set() partial_matches = set() @@ -727,7 +727,7 @@ def _confirm_remove_clients(clients_count: int, callable: Callable) -> bool: @staticmethod def _send_remove_client_request(vault: vault_online.VaultOnline, uid: str, - client_hashes: list[bytes]) -> None: + client_hashes: List[bytes]) -> None: """Send remove client request to server.""" request = RemoveAppClientsRequest() request.appRecordUid = utils.base64_url_decode(uid) @@ -739,7 +739,7 @@ class KSMShareManagement: @staticmethod def add_secrets_to_ksm_app(vault: vault_online.VaultOnline, enterprise:enterprise_data.EnterpriseData, app_uid: str, master_key: bytes, - secret_uids: list[str], is_editable: bool = False) -> list: + secret_uids: List[str], is_editable: bool = False) -> List: """Share secrets with a KSM application.""" app_shares, added_secret_info = KSMShareManagement._process_all_secrets( @@ -760,8 +760,8 @@ def add_secrets_to_ksm_app(vault: vault_online.VaultOnline, enterprise:enterpris return added_secret_info @staticmethod - def _process_all_secrets(vault: vault_online.VaultOnline, secret_uids: list[str], - master_key: bytes, is_editable: bool) -> tuple[list, list]: + def _process_all_secrets(vault: vault_online.VaultOnline, secret_uids: List[str], + master_key: bytes, is_editable: bool) -> Tuple[List, List]: """Process all secrets and build share requests.""" app_shares = [] added_secret_info = [] @@ -779,7 +779,7 @@ def _process_all_secrets(vault: vault_online.VaultOnline, secret_uids: list[str] @staticmethod def _process_secret(vault: vault_online.VaultOnline, secret_uid: str, - master_key: bytes, is_editable: bool) -> Optional[dict]: + master_key: bytes, is_editable: bool) -> Optional[Dict]: """Process a single secret and create share request.""" secret_info = KSMShareManagement._get_secret_info(vault, secret_uid) @@ -802,7 +802,7 @@ def _process_secret(vault: vault_online.VaultOnline, secret_uid: str, } @staticmethod - def _get_secret_info(vault: vault_online.VaultOnline, secret_uid: str) -> Optional[tuple]: + def _get_secret_info(vault: vault_online.VaultOnline, secret_uid: str) -> Optional[Tuple]: """Get secret information (key, type, name) for a given UID.""" is_record = secret_uid in vault.vault_data._records is_shared_folder = secret_uid in vault.vault_data._shared_folders @@ -816,7 +816,7 @@ def _get_secret_info(vault: vault_online.VaultOnline, secret_uid: str) -> Option return None @staticmethod - def _get_record_secret_info(vault: vault_online.VaultOnline, secret_uid: str) -> Optional[tuple]: + def _get_record_secret_info(vault: vault_online.VaultOnline, secret_uid: str) -> Optional[Tuple]: """Get secret info for a record.""" record = vault.vault_data.load_record(record_uid=secret_uid) if not isinstance(record, vault_record.TypedRecord): @@ -829,7 +829,7 @@ def _get_record_secret_info(vault: vault_online.VaultOnline, secret_uid: str) -> return share_key_decrypted, share_type, secret_type_name @staticmethod - def _get_folder_secret_info(vault: vault_online.VaultOnline, secret_uid: str) -> tuple: + def _get_folder_secret_info(vault: vault_online.VaultOnline, secret_uid: str) -> Tuple: """Get secret info for a shared folder.""" share_key_decrypted = vault.vault_data.get_shared_folder_key(shared_folder_uid=secret_uid) share_type = ApplicationShareType.SHARE_TYPE_FOLDER @@ -858,7 +858,7 @@ def _build_app_share(secret_uid: str, share_key_decrypted: bytes, master_key: by @staticmethod def _send_share_request(vault: vault_online.VaultOnline, app_uid: str, - app_shares: list) -> bool: + app_shares: List) -> bool: """Send the share request to the server.""" request = KSMShareManagement._build_share_request(app_uid, app_shares) @@ -866,7 +866,7 @@ def _send_share_request(vault: vault_online.VaultOnline, app_uid: str, return True @staticmethod - def _build_share_request(app_uid: str, app_shares: list) -> AddAppSharesRequest: + def _build_share_request(app_uid: str, app_shares: List) -> AddAppSharesRequest: """Build share request object.""" request = AddAppSharesRequest() request.appRecordUid = utils.base64_url_decode(app_uid) @@ -875,7 +875,7 @@ def _build_share_request(app_uid: str, app_shares: list) -> AddAppSharesRequest: @staticmethod def remove_secrets_from_ksm_app(vault: vault_online.VaultOnline, app_uid: str, - secret_uids: list[str]) -> None: + secret_uids: List[str]) -> None: """Send remove share request to server.""" request = RemoveAppSharesRequest() request.appRecordUid = utils.base64_url_decode(app_uid) diff --git a/keepersdk-package/src/keepersdk/vault/share_management_utils.py b/keepersdk-package/src/keepersdk/vault/share_management_utils.py index e0bf115c..5b106907 100644 --- a/keepersdk-package/src/keepersdk/vault/share_management_utils.py +++ b/keepersdk-package/src/keepersdk/vault/share_management_utils.py @@ -2,7 +2,7 @@ import itertools import logging from re import findall -from typing import Optional, Dict, List, Any, Generator, Iterable, Set, Tuple +from typing import Optional, Dict, List, Any, Generator, Iterable, Set, Tuple, Union from .. import crypto, utils from ..proto import enterprise_pb2, record_pb2 @@ -161,7 +161,7 @@ def _process_teams(teams_data: Iterable[Any]) -> Dict[str, Dict[str, Any]]: def load_records_in_shared_folder( vault: vault_online.VaultOnline, shared_folder_uid: str, - record_uids: Optional[set[str]] = None + record_uids: Optional[Set[str]] = None ) -> None: try: shared_folder = _find_shared_folder(vault, shared_folder_uid) @@ -698,7 +698,7 @@ def _extract_team_uids_from_shares(shares: Optional[List[Dict[str, Any]]]) -> Se } -def _build_username_lookup(enterprise: enterprise_data.EnterpriseData) -> dict[int, str] | dict[Any, Any]: +def _build_username_lookup(enterprise: enterprise_data.EnterpriseData) -> Union[Dict[int, str], Dict[Any, Any]]: if not enterprise: return {} diff --git a/keepersdk-package/src/keepersdk/vault/shares_management.py b/keepersdk-package/src/keepersdk/vault/shares_management.py index 610604e6..b145464d 100644 --- a/keepersdk-package/src/keepersdk/vault/shares_management.py +++ b/keepersdk-package/src/keepersdk/vault/shares_management.py @@ -1,10 +1,11 @@ import logging from enum import Enum +from typing import List, Dict, Optional from .. import crypto, utils from ..proto import folder_pb2, record_pb2 from ..vault import vault_online, vault_utils, share_management_utils -from ..enterprise import enterprise_data +from ..enterprise import enterprise_types class ApiUrl(Enum): @@ -64,7 +65,7 @@ def set_expiration_fields(obj, expiration): class RecordShares(): @staticmethod - def cancel_share(vault: vault_online.VaultOnline, emails: list[str]): + def cancel_share(vault: vault_online.VaultOnline, emails: List[str]): for email in emails: request = { 'command': 'cancel_share', @@ -74,7 +75,7 @@ def cancel_share(vault: vault_online.VaultOnline, emails: list[str]): vault.sync_down() @staticmethod - def _resolve_uid_or_name(vault: vault_online.VaultOnline, uid_or_name: str, record_cache: dict, shared_folder_cache: dict, folder_cache: dict): + def _resolve_uid_or_name(vault: vault_online.VaultOnline, uid_or_name: str, record_cache: Dict, shared_folder_cache: Dict, folder_cache: Dict): """Resolve uid_or_name to record_uid, folder_uid, or shared_folder_uid.""" record_uid = None folder_uid = None @@ -306,12 +307,12 @@ def _process_record_shares(vault, record_uids, all_users, action, can_edit, @staticmethod def prep_request(vault: vault_online.VaultOnline, - emails: list[str], + emails: List[str], action: str, uid_or_name: str, - share_expiration: int, + share_expiration: Optional[int], dry_run: bool, - enterprise: enterprise_data.EnterpriseData, + enterprise: enterprise_types.IEnterpriseData, enterprise_access: bool = False, recursive: bool = False, can_edit: bool = False, @@ -443,7 +444,7 @@ def _has_pending_requests(request): len(request.removeSharedRecord) > 0) @staticmethod - def send_requests(vault: vault_online.VaultOnline, requests: list[record_pb2.RecordShareUpdateRequest]): + def send_requests(vault: vault_online.VaultOnline, requests: List[record_pb2.RecordShareUpdateRequest]): """Send record share update requests in batches.""" success_responses = [] failed_responses = [] diff --git a/keepersdk-package/src/keepersdk/vault/vault_data.py b/keepersdk-package/src/keepersdk/vault/vault_data.py index 3df7f656..7c17947a 100644 --- a/keepersdk-package/src/keepersdk/vault/vault_data.py +++ b/keepersdk-package/src/keepersdk/vault/vault_data.py @@ -129,7 +129,7 @@ def _match_entity(entity_words: Optional[Tuple[str, ...]], words: Optional[Union if isinstance(words, str): words = list(utils.tokenize_searchable_text(words)) elif isinstance(words, list): - search_words = [] + search_words: List[str] = [] for word in words: search_words.extend(utils.tokenize_searchable_text(word)) words = search_words