From 70c60f42232961456af90f506380f0053d008294 Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Thu, 2 Jul 2026 20:52:32 +0530 Subject: [PATCH 1/7] Enterprise admin device and account lock/unlock actions (#207) Co-authored-by: mtyagi-ks --- .../admin_account_lock_device.py | 549 ++++++++++++++++++ .../admin_account_unlock_device.py | 549 ++++++++++++++++++ .../device_management/admin_lock_device.py | 549 ++++++++++++++++++ .../device_management/admin_unlock_device.py | 549 ++++++++++++++++++ keepercli-package/src/keepercli/cli.py | 55 +- .../src/keepercli/commands/base.py | 1 + .../keepercli/commands/command_completer.py | 11 +- .../keepercli/commands/command_visibility.py | 20 + .../keepercli/commands/device_management.py | 83 ++- .../src/keepercli/register_commands.py | 10 +- .../authentication/device_management.py | 120 +++- .../keepersdk/authentication/login_auth.py | 8 +- .../unit_tests/test_device_management.py | 48 ++ keepersdk-package/unit_tests/test_login.py | 26 + 14 files changed, 2526 insertions(+), 52 deletions(-) create mode 100644 examples/sdk_examples/device_management/admin_account_lock_device.py create mode 100644 examples/sdk_examples/device_management/admin_account_unlock_device.py create mode 100644 examples/sdk_examples/device_management/admin_lock_device.py create mode 100644 examples/sdk_examples/device_management/admin_unlock_device.py create mode 100644 keepercli-package/src/keepercli/commands/command_visibility.py diff --git a/examples/sdk_examples/device_management/admin_account_lock_device.py b/examples/sdk_examples/device_management/admin_account_lock_device.py new file mode 100644 index 00000000..acf106a1 --- /dev/null +++ b/examples/sdk_examples/device_management/admin_account_lock_device.py @@ -0,0 +1,549 @@ +import getpass +import sqlite3 +import json +import logging +from typing import Dict, Optional + +import fido2 +import webbrowser + +from keepersdk import errors, utils +from keepersdk.authentication import ( + device_management, + 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, ksm_management + +try: + import pyperclip +except ImportError: + pyperclip = None + +logger = utils.get_logger() +logger.setLevel(logging.INFO) +if not logger.handlers: + _handler = logging.StreamHandler() + _handler.setLevel(logging.INFO) + _handler.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") + ) + logger.addHandler(_handler) + + +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 + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + 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.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(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: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + 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.")) + 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.")) + except Exception: + 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 = [ + "", + "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 use option 't' to enter the token manually."), + '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 == "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: + 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 = 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}") + 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 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, + 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. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + + +def print_admin_devices_table(devices): + if not devices: + print('\nNo devices found.') + return + print(f'\nAdmin Device List ({len(devices)} devices found)') + print('=' * 120) + print( + f"{'ID':<4} {'Enterprise User ID':<20} {'Device Name':<22} " + f"{'UI Category':<18} {'Device Status':<16} {'Login Status':<14} {'Last Accessed':<20}" + ) + print('-' * 120) + for d in devices: + last = d.last_accessed.strftime('%Y-%m-%d %H:%M:%S') if d.last_accessed else 'N/A' + print( + f"{d.list_index:<4} {d.enterprise_user_id:<20} {d.name[:21]:<22} " + f"{d.ui_category[:17]:<18} {d.device_status[:15]:<16} " + f"{d.login_status[:13]:<14} {last:<20}" + ) + print('-' * 120) + + +def main(): + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + + # Fill in your values here (enterprise admin required). + enterprise_user_id = 0 + device_identifiers = [''] + + try: + print(f'Account-locking {len(device_identifiers)} device(s) for user {enterprise_user_id}...') + for name in device_management.account_lock_admin_user_devices( + keeper_auth_context, enterprise_user_id, device_identifiers + ): + print( + f"Device action successfully completed: '{name}' account locked " + f'for user {enterprise_user_id}' + ) + print(f'\nUpdated device list for user {enterprise_user_id}:') + print_admin_devices_table( + device_management.list_admin_devices(keeper_auth_context, [enterprise_user_id]) + ) + except Exception as e: + print(f'Error account-locking admin devices: {e}') + finally: + keeper_auth_context.close() + + +if __name__ == '__main__': + main() diff --git a/examples/sdk_examples/device_management/admin_account_unlock_device.py b/examples/sdk_examples/device_management/admin_account_unlock_device.py new file mode 100644 index 00000000..dc1eadb2 --- /dev/null +++ b/examples/sdk_examples/device_management/admin_account_unlock_device.py @@ -0,0 +1,549 @@ +import getpass +import sqlite3 +import json +import logging +from typing import Dict, Optional + +import fido2 +import webbrowser + +from keepersdk import errors, utils +from keepersdk.authentication import ( + device_management, + 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, ksm_management + +try: + import pyperclip +except ImportError: + pyperclip = None + +logger = utils.get_logger() +logger.setLevel(logging.INFO) +if not logger.handlers: + _handler = logging.StreamHandler() + _handler.setLevel(logging.INFO) + _handler.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") + ) + logger.addHandler(_handler) + + +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 + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + 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.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(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: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + 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.")) + 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.")) + except Exception: + 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 = [ + "", + "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 use option 't' to enter the token manually."), + '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 == "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: + 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 = 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}") + 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 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, + 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. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + + +def print_admin_devices_table(devices): + if not devices: + print('\nNo devices found.') + return + print(f'\nAdmin Device List ({len(devices)} devices found)') + print('=' * 120) + print( + f"{'ID':<4} {'Enterprise User ID':<20} {'Device Name':<22} " + f"{'UI Category':<18} {'Device Status':<16} {'Login Status':<14} {'Last Accessed':<20}" + ) + print('-' * 120) + for d in devices: + last = d.last_accessed.strftime('%Y-%m-%d %H:%M:%S') if d.last_accessed else 'N/A' + print( + f"{d.list_index:<4} {d.enterprise_user_id:<20} {d.name[:21]:<22} " + f"{d.ui_category[:17]:<18} {d.device_status[:15]:<16} " + f"{d.login_status[:13]:<14} {last:<20}" + ) + print('-' * 120) + + +def main(): + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + + # Fill in your values here (enterprise admin required). + enterprise_user_id = 0 + device_identifiers = [''] + + try: + print(f'Account-unlocking {len(device_identifiers)} device(s) for user {enterprise_user_id}...') + for name in device_management.account_unlock_admin_user_devices( + keeper_auth_context, enterprise_user_id, device_identifiers + ): + print( + f"Device action successfully completed: '{name}' account unlocked " + f'for user {enterprise_user_id}' + ) + print(f'\nUpdated device list for user {enterprise_user_id}:') + print_admin_devices_table( + device_management.list_admin_devices(keeper_auth_context, [enterprise_user_id]) + ) + except Exception as e: + print(f'Error account-unlocking admin devices: {e}') + finally: + keeper_auth_context.close() + + +if __name__ == '__main__': + main() diff --git a/examples/sdk_examples/device_management/admin_lock_device.py b/examples/sdk_examples/device_management/admin_lock_device.py new file mode 100644 index 00000000..7c16c649 --- /dev/null +++ b/examples/sdk_examples/device_management/admin_lock_device.py @@ -0,0 +1,549 @@ +import getpass +import sqlite3 +import json +import logging +from typing import Dict, Optional + +import fido2 +import webbrowser + +from keepersdk import errors, utils +from keepersdk.authentication import ( + device_management, + 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, ksm_management + +try: + import pyperclip +except ImportError: + pyperclip = None + +logger = utils.get_logger() +logger.setLevel(logging.INFO) +if not logger.handlers: + _handler = logging.StreamHandler() + _handler.setLevel(logging.INFO) + _handler.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") + ) + logger.addHandler(_handler) + + +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 + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + 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.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(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: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + 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.")) + 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.")) + except Exception: + 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 = [ + "", + "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 use option 't' to enter the token manually."), + '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 == "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: + 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 = 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}") + 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 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, + 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. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + + +def print_admin_devices_table(devices): + if not devices: + print('\nNo devices found.') + return + print(f'\nAdmin Device List ({len(devices)} devices found)') + print('=' * 120) + print( + f"{'ID':<4} {'Enterprise User ID':<20} {'Device Name':<22} " + f"{'UI Category':<18} {'Device Status':<16} {'Login Status':<14} {'Last Accessed':<20}" + ) + print('-' * 120) + for d in devices: + last = d.last_accessed.strftime('%Y-%m-%d %H:%M:%S') if d.last_accessed else 'N/A' + print( + f"{d.list_index:<4} {d.enterprise_user_id:<20} {d.name[:21]:<22} " + f"{d.ui_category[:17]:<18} {d.device_status[:15]:<16} " + f"{d.login_status[:13]:<14} {last:<20}" + ) + print('-' * 120) + + +def main(): + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + + # Fill in your values here (enterprise admin required). + enterprise_user_id = 0 + device_identifiers = [''] + + try: + print(f'Locking {len(device_identifiers)} device(s) for user {enterprise_user_id}...') + for name in device_management.lock_admin_user_devices( + keeper_auth_context, enterprise_user_id, device_identifiers + ): + print( + f"Device action successfully completed: '{name}' locked " + f'for user {enterprise_user_id}' + ) + print(f'\nUpdated device list for user {enterprise_user_id}:') + print_admin_devices_table( + device_management.list_admin_devices(keeper_auth_context, [enterprise_user_id]) + ) + except Exception as e: + print(f'Error locking admin devices: {e}') + finally: + keeper_auth_context.close() + + +if __name__ == '__main__': + main() diff --git a/examples/sdk_examples/device_management/admin_unlock_device.py b/examples/sdk_examples/device_management/admin_unlock_device.py new file mode 100644 index 00000000..04fa79ed --- /dev/null +++ b/examples/sdk_examples/device_management/admin_unlock_device.py @@ -0,0 +1,549 @@ +import getpass +import sqlite3 +import json +import logging +from typing import Dict, Optional + +import fido2 +import webbrowser + +from keepersdk import errors, utils +from keepersdk.authentication import ( + device_management, + 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, ksm_management + +try: + import pyperclip +except ImportError: + pyperclip = None + +logger = utils.get_logger() +logger.setLevel(logging.INFO) +if not logger.handlers: + _handler = logging.StreamHandler() + _handler.setLevel(logging.INFO) + _handler.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") + ) + logger.addHandler(_handler) + + +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 + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + 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.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(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: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + 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.")) + 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.")) + except Exception: + 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 = [ + "", + "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 use option 't' to enter the token manually."), + '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 == "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: + 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 = 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}") + 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 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, + 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. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + + +def print_admin_devices_table(devices): + if not devices: + print('\nNo devices found.') + return + print(f'\nAdmin Device List ({len(devices)} devices found)') + print('=' * 120) + print( + f"{'ID':<4} {'Enterprise User ID':<20} {'Device Name':<22} " + f"{'UI Category':<18} {'Device Status':<16} {'Login Status':<14} {'Last Accessed':<20}" + ) + print('-' * 120) + for d in devices: + last = d.last_accessed.strftime('%Y-%m-%d %H:%M:%S') if d.last_accessed else 'N/A' + print( + f"{d.list_index:<4} {d.enterprise_user_id:<20} {d.name[:21]:<22} " + f"{d.ui_category[:17]:<18} {d.device_status[:15]:<16} " + f"{d.login_status[:13]:<14} {last:<20}" + ) + print('-' * 120) + + +def main(): + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + + # Fill in your values here (enterprise admin required). + enterprise_user_id = 0 + device_identifiers = [''] + + try: + print(f'Unlocking {len(device_identifiers)} device(s) for user {enterprise_user_id}...') + for name in device_management.unlock_admin_user_devices( + keeper_auth_context, enterprise_user_id, device_identifiers + ): + print( + f"Device action successfully completed: '{name}' unlocked " + f'for user {enterprise_user_id}' + ) + print(f'\nUpdated device list for user {enterprise_user_id}:') + print_admin_devices_table( + device_management.list_admin_devices(keeper_auth_context, [enterprise_user_id]) + ) + except Exception as e: + print(f'Error unlocking admin devices: {e}') + finally: + keeper_auth_context.close() + + +if __name__ == '__main__': + main() diff --git a/keepercli-package/src/keepercli/cli.py b/keepercli-package/src/keepercli/cli.py index 2b7e24f6..2c4cc910 100644 --- a/keepercli-package/src/keepercli/cli.py +++ b/keepercli-package/src/keepercli/cli.py @@ -1,12 +1,12 @@ import logging import sys -from typing import Optional, Any, Iterable, List +from typing import Optional, Any, Iterable, List, Callable from prompt_toolkit import PromptSession from prompt_toolkit.history import History from . import prompt_utils, api, autocomplete -from .commands import command_completer, base, command_history +from .commands import command_completer, base, command_history, command_visibility from .helpers import report_utils from .params import KeeperParams, KeeperConfig from keepersdk import constants @@ -30,6 +30,27 @@ def store_string(self, string: str) -> None: command_history.append(string) +_SCOPE_DISPLAY_NAMES = { + base.CommandScope.Account: 'Account Commands', + base.CommandScope.Vault: 'Vault Commands', + base.CommandScope.DeviceManagement: 'Device Management Commands', + base.CommandScope.Enterprise: 'Enterprise Commands', + base.CommandScope.MSP: 'MSP Commands', + base.CommandScope.Distributor: 'Distributor Commands', + base.CommandScope.Common: 'Miscellaneous Commands', +} + +_SCOPE_DISPLAY_ORDER = ( + base.CommandScope.Account, + base.CommandScope.Vault, + base.CommandScope.DeviceManagement, + base.CommandScope.Enterprise, + base.CommandScope.MSP, + base.CommandScope.Distributor, + base.CommandScope.Common, +) + + def do_command(command_line: str, context: KeeperParams, commands: base.CliCommands) -> Any: cmd, sep, args = command_line.partition(' ') orig_cmd = cmd @@ -44,7 +65,7 @@ def do_command(command_line: str, context: KeeperParams, commands: base.CliComma command, _ = commands.commands[cmd] return command.execute_args(context, args.strip(), command=orig_cmd) else: - display_command_help(commands) + display_command_help(commands, context) return None @@ -81,7 +102,8 @@ def get_prompt() -> str: if sys.stdin.isatty() and sys.stdout.isatty(): from prompt_toolkit.enums import EditingMode from prompt_toolkit.shortcuts import CompleteStyle - completer = command_completer.CommandCompleter(commands, autocomplete.standard_completer(context)) + completer = command_completer.CommandCompleter( + commands, autocomplete.standard_completer(context), context_getter=lambda: context) prompt_session = PromptSession( multiline=False, editing_mode=EditingMode.EMACS, complete_style=CompleteStyle.MULTI_COLUMN, complete_while_typing=False, completer=completer, auto_suggest=None, key_bindings=prompt_utils.kb, @@ -174,18 +196,31 @@ def get_prompt() -> str: context.clear_session() return 0 -def display_command_help(commands: base.CliCommands): +def display_command_help(commands: base.CliCommands, context: Optional[KeeperParams] = None): alias_lookup = {x[1]: x[0] for x in commands.aliases.items()} - all_scopes = {x[1]: x[1].name for x in commands.commands.values()} - scopes = sorted(all_scopes.keys()) + available_scopes = {value[1] for value in commands.commands.values()} headers = ['', 'Command', 'Alias', '', 'Description'] table = [] - for scope in scopes: - scope_commands = [key for key, value in commands.commands.items() if value[1] == scope] + for scope in _SCOPE_DISPLAY_ORDER: + if scope not in available_scopes: + continue + scope_commands = [ + key for key, value in commands.commands.items() + if value[1] == scope and command_visibility.is_command_visible(key, context) + ] + if not scope_commands: + continue + scope_name = _SCOPE_DISPLAY_NAMES.get(scope, scope.name) idx = 0 for cmd in sorted(scope_commands): c = commands.commands[cmd][0] - table.append([all_scopes[scope] if idx == 0 else '', cmd, alias_lookup.get(cmd) or '', '...', c.description()]) + table.append([ + scope_name if idx == 0 else '', + cmd, + alias_lookup.get(cmd) or '', + '...', + c.description(), + ]) idx += 1 prompt_utils.output_text('\nCommands:') diff --git a/keepercli-package/src/keepercli/commands/base.py b/keepercli-package/src/keepercli/commands/base.py index 624b060f..925ae48c 100644 --- a/keepercli-package/src/keepercli/commands/base.py +++ b/keepercli-package/src/keepercli/commands/base.py @@ -63,6 +63,7 @@ def description(self): class CommandScope(enum.IntFlag): Account = enum.auto() Vault = enum.auto() + DeviceManagement = enum.auto() Enterprise = enum.auto() MSP = enum.auto() Distributor = enum.auto() diff --git a/keepercli-package/src/keepercli/commands/command_completer.py b/keepercli-package/src/keepercli/commands/command_completer.py index db7fb32c..31105366 100644 --- a/keepercli-package/src/keepercli/commands/command_completer.py +++ b/keepercli-package/src/keepercli/commands/command_completer.py @@ -4,14 +4,17 @@ from . import base from .. import autocomplete +from .command_visibility import is_command_visible class CommandCompleter(completion.Completer): def __init__(self, command_collection: base.CommandCollection, - on_complete: Optional[Callable[[str, str], Iterable[str]]] = None) -> None: + on_complete: Optional[Callable[[str, str], Iterable[str]]] = None, + context_getter: Optional[Callable[[], object]] = None) -> None: self.commands = command_collection self.on_complete = on_complete + self.context_getter = context_getter def get_completions(self, document, complete_event): if not document.is_cursor_at_the_end: @@ -33,7 +36,11 @@ def get_completions(self, document, complete_event): command = self.commands.get_command_by_name(cmd) if command is None: if len(tokens) == 0 and document.char_before_cursor != ' ': - cmds = [x for x in self.commands.query_commands(cmd)] + context = self.context_getter() if self.context_getter else None + cmds = [ + x for x in self.commands.query_commands(cmd) + if is_command_visible(x, context) + ] cmds.sort() for c in cmds: yield completion.Completion(c, start_position=-len(document.text)) diff --git a/keepercli-package/src/keepercli/commands/command_visibility.py b/keepercli-package/src/keepercli/commands/command_visibility.py new file mode 100644 index 00000000..597159e9 --- /dev/null +++ b/keepercli-package/src/keepercli/commands/command_visibility.py @@ -0,0 +1,20 @@ +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ..params import KeeperParams + +DEVICE_ADMIN_COMMANDS = frozenset({'device-admin-list', 'device-admin-action'}) + + +def is_enterprise_admin(context: Optional['KeeperParams']) -> bool: + return bool( + context + and context.auth + and context.auth.auth_context.is_enterprise_admin + ) + + +def is_command_visible(command: str, context: Optional['KeeperParams']) -> bool: + if command in DEVICE_ADMIN_COMMANDS: + return is_enterprise_admin(context) + return True diff --git a/keepercli-package/src/keepercli/commands/device_management.py b/keepercli-package/src/keepercli/commands/device_management.py index 319d4322..5be6acab 100644 --- a/keepercli-package/src/keepercli/commands/device_management.py +++ b/keepercli-package/src/keepercli/commands/device_management.py @@ -4,6 +4,7 @@ from datetime import datetime from typing import Callable, Dict, List, Optional +from keepersdk import errors from keepersdk.authentication import device_management from . import base @@ -34,9 +35,35 @@ def _format_timestamp(dt: Optional[datetime]) -> str: def _sdk_error(exc: Exception) -> base.CommandError: + if isinstance(exc, errors.KeeperApiError): + if device_management.is_device_api_unavailable(exc): + return base.CommandError(device_management.DEVICE_FEATURE_UNAVAILABLE_MESSAGE) return base.CommandError(str(exc)) +def _validate_admin_enterprise_user_ids( + context: KeeperParams, + enterprise_user_ids: List[int], +) -> List[int]: + """Return enterprise user IDs known to enterprise data; warn when IDs are not found.""" + base.require_enterprise_admin(context) + resolved: List[int] = [] + seen: set[int] = set() + for user_id in enterprise_user_ids: + if user_id in seen: + continue + seen.add(user_id) + if context.enterprise_data.users.get_entity(user_id) is None: + logger.warning( + "Warning: No enterprise_user_id found matching '%s'", user_id + ) + else: + resolved.append(user_id) + if not resolved and enterprise_user_ids: + logger.info('No matching enterprise_user_id found') + return resolved + + def _run_device_action_command( context: KeeperParams, device_identifiers: List[str], @@ -47,7 +74,7 @@ def _run_device_action_command( try: for name in action_fn(context.auth, device_identifiers): logger.info(success_message, name) - except ValueError as e: + except (ValueError, errors.KeeperApiError) as e: raise _sdk_error(e) from e @@ -79,7 +106,7 @@ def _display_admin_devices( """Fetch and print the admin device list table for the given enterprise user IDs.""" try: devices = device_management.list_admin_devices(context.auth, enterprise_user_ids) - except ValueError as e: + except (ValueError, errors.KeeperApiError) as e: raise _sdk_error(e) from e if not devices: @@ -186,6 +213,31 @@ def _display_admin_devices( 'handler': device_management.remove_admin_user_devices, 'action_verb': 'removed', }, + 'lock': { + 'description': ( + 'Lock the device for all users on the devices and the associated auto linked devices. ' + 'Logout all users from the device' + ), + 'handler': device_management.lock_admin_user_devices, + 'action_verb': 'locked', + }, + 'unlock': { + 'description': ( + 'Unlock the devices and the associated auto linked devices for the calling user' + ), + 'handler': device_management.unlock_admin_user_devices, + 'action_verb': 'unlocked', + }, + 'account-lock': { + 'description': 'Lock the device for the user only. If user is logged in, logout', + 'handler': device_management.account_lock_admin_user_devices, + 'action_verb': 'account locked', + }, + 'account-unlock': { + 'description': 'Unlock the device for the user', + 'handler': device_management.account_unlock_admin_user_devices, + 'action_verb': 'account unlocked', + }, } DEVICE_ADMIN_ACTION_CHOICES = list(DEVICE_ADMIN_ACTION_DEFINITIONS.keys()) @@ -231,7 +283,7 @@ def execute(self, context: KeeperParams, **kwargs): base.require_login(context) try: devices = device_management.list_user_devices(context.auth) - except ValueError as e: + except (ValueError, errors.KeeperApiError) as e: raise _sdk_error(e) from e if not devices: @@ -277,7 +329,7 @@ class DeviceRenameCommand(base.ArgparseCommand): def __init__(self): parser = argparse.ArgumentParser( prog='device-rename', - description='Rename a device for the current user', + description='Rename user devices', ) DeviceRenameCommand.add_arguments_to_parser(parser) super().__init__(parser) @@ -302,7 +354,7 @@ def execute(self, context: KeeperParams, **kwargs): logger.info("Device name updated from '%s' to '%s'", old_name, updated_name) logger.info('') _display_user_devices(context, title_prefix='Updated ') - except ValueError as e: + except (ValueError, errors.KeeperApiError) as e: raise _sdk_error(e) from e @@ -391,7 +443,7 @@ def add_arguments_to_parser(parser: argparse.ArgumentParser): 'enterprise_user_ids', nargs='+', type=int, - help='List of Enterprise User IDs (required). You can get enterprise user IDs by running "ei --users" command', + help='List of Enterprise User IDs (required). You can get enterprise user IDs by running "enterprise-info user"', ) parser.error = base.ArgparseCommand.raise_parse_exception parser.exit = base.ArgparseCommand.suppress_exit @@ -399,11 +451,15 @@ def add_arguments_to_parser(parser: argparse.ArgumentParser): def execute(self, context: KeeperParams, **kwargs): """Display admin device list in table or JSON format for the given enterprise user IDs.""" base.require_enterprise_admin(context) - enterprise_user_ids = kwargs.get('enterprise_user_ids') or [] + enterprise_user_ids = _validate_admin_enterprise_user_ids( + context, kwargs.get('enterprise_user_ids') or [] + ) + if not enterprise_user_ids: + return try: devices = device_management.list_admin_devices(context.auth, enterprise_user_ids) - except ValueError as e: + except (ValueError, errors.KeeperApiError) as e: raise _sdk_error(e) from e if not devices: @@ -437,7 +493,7 @@ class DeviceAdminActionCommand(base.ArgparseCommand): def __init__(self): parser = argparse.ArgumentParser( prog='device-admin-action', - description='Perform various action on one or more devices that the Admin has control of.', + description='Perform actions on devices across enterprise users', ) DeviceAdminActionCommand.add_arguments_to_parser(parser) super().__init__(parser) @@ -488,7 +544,12 @@ def execute(self, context: KeeperParams, **kwargs): """Run the requested admin device action and refresh the device list.""" base.require_enterprise_admin(context) action = kwargs.get('action') - enterprise_user_id = kwargs.get('enterprise_user_id') + validated_user_ids = _validate_admin_enterprise_user_ids( + context, [kwargs.get('enterprise_user_id')] + ) + if not validated_user_ids: + return + enterprise_user_id = validated_user_ids[0] devices = kwargs.get('devices') or [] config = DEVICE_ADMIN_ACTION_DEFINITIONS.get(action or '') if not config: @@ -506,7 +567,7 @@ def execute(self, context: KeeperParams, **kwargs): "Device action successfully completed: '%s' %s for user %s", name, action_verb, enterprise_user_id, ) - except ValueError as e: + except (ValueError, errors.KeeperApiError) as e: raise _sdk_error(e) from e logger.info('Updated device list for user %s:', enterprise_user_id) diff --git a/keepercli-package/src/keepercli/register_commands.py b/keepercli-package/src/keepercli/register_commands.py index b7e87e23..20138886 100644 --- a/keepercli-package/src/keepercli/register_commands.py +++ b/keepercli-package/src/keepercli/register_commands.py @@ -23,9 +23,9 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS commands.register_command('biometric', BiometricCommand(), base.CommandScope.Account) commands.register_command('logout', account_commands.LogoutCommand(), base.CommandScope.Account) commands.register_command('this-device', account_commands.ThisDeviceCommand(), base.CommandScope.Account) - commands.register_command('device-list', device_management.DeviceListCommand(), base.CommandScope.Account) - commands.register_command('device-action', device_management.DeviceActionCommand(), base.CommandScope.Account) - commands.register_command('device-rename', device_management.DeviceRenameCommand(), base.CommandScope.Account) + commands.register_command('device-list', device_management.DeviceListCommand(), base.CommandScope.DeviceManagement) + commands.register_command('device-action', device_management.DeviceActionCommand(), base.CommandScope.DeviceManagement) + commands.register_command('device-rename', device_management.DeviceRenameCommand(), base.CommandScope.DeviceManagement) 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) @@ -127,8 +127,8 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS commands.register_command('download-membership', importer_commands.DownloadMembershipCommand(), base.CommandScope.Enterprise) commands.register_command('apply-membership', importer_commands.ApplyMembershipCommand(), base.CommandScope.Enterprise) commands.register_command('device-approve', enterprise_user.EnterpriseDeviceApprovalCommand(), base.CommandScope.Enterprise) - commands.register_command('device-admin-list', device_management.DeviceAdminListCommand(), base.CommandScope.Enterprise) - commands.register_command('device-admin-action', device_management.DeviceAdminActionCommand(), base.CommandScope.Enterprise) + commands.register_command('device-admin-list', device_management.DeviceAdminListCommand(), base.CommandScope.DeviceManagement) + commands.register_command('device-admin-action', device_management.DeviceAdminActionCommand(), base.CommandScope.DeviceManagement) commands.register_command('pedm', pedm_admin.PedmCommand(), base.CommandScope.Enterprise) commands.register_command('msp-down', msp.MspDownCommand(), base.CommandScope.Enterprise, 'md') commands.register_command('msp-info', msp.MspInfoCommand(), base.CommandScope.Enterprise, 'mi') diff --git a/keepersdk-package/src/keepersdk/authentication/device_management.py b/keepersdk-package/src/keepersdk/authentication/device_management.py index 871d4114..7eb03229 100644 --- a/keepersdk-package/src/keepersdk/authentication/device_management.py +++ b/keepersdk-package/src/keepersdk/authentication/device_management.py @@ -12,7 +12,7 @@ from datetime import datetime from typing import Callable, List, Optional, Tuple -from .. import utils +from .. import errors, utils from ..proto import APIRequest_pb2, DeviceManagement_pb2 from . import keeper_auth @@ -24,6 +24,33 @@ URL_DEVICE_ADMIN_LIST = 'dm/device_admin_list' URL_DEVICE_ADMIN_ACTION = 'dm/device_admin_action' +DEVICE_FEATURE_UNAVAILABLE_MESSAGE = ( + 'Notice: This feature is not in production yet. It will be available soon.' +) + + +def is_device_api_unavailable(error: errors.KeeperApiError) -> bool: + return error.result_code in (404, '404', 'invalid_path_or_method') + + +def _execute_device_rest( + auth: keeper_auth.KeeperAuth, + rest_endpoint: str, + request, + response_type, +): + """Call a device-management REST endpoint with Commander-aligned unavailable-API handling.""" + try: + return auth.execute_auth_rest( + rest_endpoint=rest_endpoint, + request=request, + response_type=response_type, + ) + except errors.KeeperApiError as exc: + if is_device_api_unavailable(exc): + raise ValueError(DEVICE_FEATURE_UNAVAILABLE_MESSAGE) from exc + raise + @dataclass(frozen=True) class UserDeviceInfo: @@ -92,10 +119,11 @@ def rename_user_device( dr.encryptedDeviceToken = device_token dr.deviceNewName = sanitized - rs = auth.execute_auth_rest( - rest_endpoint=URL_DEVICE_USER_RENAME, - request=rq, - response_type=DeviceManagement_pb2.DeviceRenameResponse, + rs = _execute_device_rest( + auth, + URL_DEVICE_USER_RENAME, + rq, + DeviceManagement_pb2.DeviceRenameResponse, ) if not rs or not rs.deviceRenameResult: raise ValueError('No response returned from device rename') @@ -161,7 +189,7 @@ def list_admin_devices( """ if not enterprise_user_ids: raise ValueError( - 'Enterprise User ID is required. You can get enterprise user IDs by running: ei --users' + 'Enterprise User ID is required. You can get enterprise user IDs by running: enterprise-info user' ) for user_id in enterprise_user_ids: _validate_enterprise_user_id(user_id) @@ -304,11 +332,56 @@ def _validate_link_unlink_identifiers(device_identifiers: List[str]) -> None: raise ValueError('At least two device identifiers are required for link/unlink') +def lock_admin_user_devices( + auth: keeper_auth.KeeperAuth, + enterprise_user_id: int, + device_identifiers: List[str], +) -> List[str]: + """Lock devices for all users and linked devices; log out all users (enterprise admin).""" + return _execute_admin_device_action( + auth, enterprise_user_id, device_identifiers, DeviceManagement_pb2.DA_LOCK + ) + + +def unlock_admin_user_devices( + auth: keeper_auth.KeeperAuth, + enterprise_user_id: int, + device_identifiers: List[str], +) -> List[str]: + """Unlock devices and linked devices for the enterprise user (enterprise admin).""" + return _execute_admin_device_action( + auth, enterprise_user_id, device_identifiers, DeviceManagement_pb2.DA_UNLOCK + ) + + +def account_lock_admin_user_devices( + auth: keeper_auth.KeeperAuth, + enterprise_user_id: int, + device_identifiers: List[str], +) -> List[str]: + """Account-lock devices for the enterprise user only (enterprise admin).""" + return _execute_admin_device_action( + auth, enterprise_user_id, device_identifiers, DeviceManagement_pb2.DA_DEVICE_ACCOUNT_LOCK + ) + + +def account_unlock_admin_user_devices( + auth: keeper_auth.KeeperAuth, + enterprise_user_id: int, + device_identifiers: List[str], +) -> List[str]: + """Account-unlock devices for the enterprise user (enterprise admin).""" + return _execute_admin_device_action( + auth, enterprise_user_id, device_identifiers, DeviceManagement_pb2.DA_DEVICE_ACCOUNT_UNLOCK + ) + + def _fetch_devices(auth: keeper_auth.KeeperAuth) -> List[DeviceManagement_pb2.Device]: - rs = auth.execute_auth_rest( - rest_endpoint=URL_DEVICE_USER_LIST, - request=None, - response_type=DeviceManagement_pb2.DeviceUserResponse, + rs = _execute_device_rest( + auth, + URL_DEVICE_USER_LIST, + None, + DeviceManagement_pb2.DeviceUserResponse, ) if not rs: return [] @@ -351,10 +424,11 @@ def _fetch_admin_device_entries( ) -> List[Tuple[int, DeviceManagement_pb2.Device]]: rq = DeviceManagement_pb2.DeviceAdminRequest() rq.enterpriseUserIds.extend(enterprise_user_ids) - rs = auth.execute_auth_rest( - rest_endpoint=URL_DEVICE_ADMIN_LIST, - request=rq, - response_type=DeviceManagement_pb2.DeviceAdminResponse, + rs = _execute_device_rest( + auth, + URL_DEVICE_ADMIN_LIST, + rq, + DeviceManagement_pb2.DeviceAdminResponse, ) if not rs: return [] @@ -507,10 +581,11 @@ def _execute_device_action( device_action.deviceActionType = action_type device_action.encryptedDeviceToken.extend(list(token_to_device.keys())) - rs = auth.execute_auth_rest( - rest_endpoint=URL_DEVICE_USER_ACTION, - request=rq, - response_type=DeviceManagement_pb2.DeviceActionResponse, + rs = _execute_device_rest( + auth, + URL_DEVICE_USER_ACTION, + rq, + DeviceManagement_pb2.DeviceActionResponse, ) if not rs or not rs.deviceActionResult: raise ValueError('No response returned from device action') @@ -557,10 +632,11 @@ def _execute_admin_device_action( admin_action.enterpriseUserId = enterprise_user_id admin_action.encryptedDeviceToken.extend(list(token_to_device.keys())) - rs = auth.execute_auth_rest( - rest_endpoint=URL_DEVICE_ADMIN_ACTION, - request=rq, - response_type=DeviceManagement_pb2.DeviceAdminActionResponse, + rs = _execute_device_rest( + auth, + URL_DEVICE_ADMIN_ACTION, + rq, + DeviceManagement_pb2.DeviceAdminActionResponse, ) if not rs or not rs.deviceAdminActionResults: raise ValueError('No response returned from device admin action') diff --git a/keepersdk-package/src/keepersdk/authentication/login_auth.py b/keepersdk-package/src/keepersdk/authentication/login_auth.py index 2ef6cea6..2ff77e48 100644 --- a/keepersdk-package/src/keepersdk/authentication/login_auth.py +++ b/keepersdk-package/src/keepersdk/authentication/login_auth.py @@ -570,8 +570,12 @@ def decrypt_with_device_key(encrypted_data_key): _on_sso_redirect(login, sso_login_info, response.encryptedLoginToken) elif response.loginState == APIRequest_pb2.REQUIRES_DEVICE_ENCRYPTED_DATA_KEY: _on_request_data_key(login, response.encryptedLoginToken) - elif response.loginState in (APIRequest_pb2.DEVICE_ACCOUNT_LOCKED, APIRequest_pb2.DEVICE_LOCKED): - raise errors.InvalidDeviceTokenError(response.message) + elif response.loginState == APIRequest_pb2.DEVICE_ACCOUNT_LOCKED: + login.login_step = LoginStepError( + 'device_account_locked', 'Device for this account is locked') + elif response.loginState == APIRequest_pb2.DEVICE_LOCKED: + login.login_step = LoginStepError( + 'device_locked', 'This device is locked') else: state = APIRequest_pb2.LoginState.Name(response.loginState) # type: ignore message = f'State {state}: Not implemented: {response.message}' diff --git a/keepersdk-package/unit_tests/test_device_management.py b/keepersdk-package/unit_tests/test_device_management.py index 7e7b012f..5fc8b9a1 100644 --- a/keepersdk-package/unit_tests/test_device_management.py +++ b/keepersdk-package/unit_tests/test_device_management.py @@ -133,6 +133,19 @@ def test_list_admin_devices_rejects_bool_user_id(self): with self.assertRaises(ValueError): device_management.list_admin_devices(auth, [True]) + def test_list_admin_devices_feature_unavailable(self): + from keepersdk import errors + + auth = MagicMock() + auth.execute_auth_rest.side_effect = errors.KeeperApiError( + 'invalid_path_or_method', + 'An error has occurred. (bad_path)', + ) + with self.assertRaisesRegex( + ValueError, device_management.DEVICE_FEATURE_UNAVAILABLE_MESSAGE + ): + device_management.list_admin_devices(auth, [12345]) + def test_logout_admin_user_devices(self): auth = MagicMock() list_rs = _admin_list_response(12345, _device('Laptop', 100)) @@ -301,6 +314,41 @@ def test_ambiguous_device_name_lists_matches(self): with self.assertRaisesRegex(ValueError, 'No matching devices found'): device_management.unlock_user_devices(auth, ['Web Vault Chrome']) + def _admin_action_test(self, fn, action_type, user_id=12345, ident='1', device_name='Laptop'): + auth = MagicMock() + list_rs = _admin_list_response(user_id, _device(device_name, 100)) + action_rs = DeviceManagement_pb2.DeviceAdminActionResponse() + ar = action_rs.deviceAdminActionResults.add() + ar.deviceActionStatus = DeviceManagement_pb2.SUCCESS + ar.encryptedDeviceToken.append(b'\x01\x02') + auth.execute_auth_rest.side_effect = [list_rs, action_rs] + names = fn(auth, user_id, [ident]) + self.assertEqual(names, [device_name]) + admin_action = auth.execute_auth_rest.call_args_list[1].kwargs.get('request').deviceAdminAction[0] + self.assertEqual(admin_action.deviceActionType, action_type) + self.assertEqual(admin_action.enterpriseUserId, user_id) + + def test_lock_admin_user_devices(self): + self._admin_action_test( + device_management.lock_admin_user_devices, DeviceManagement_pb2.DA_LOCK + ) + + def test_unlock_admin_user_devices(self): + self._admin_action_test( + device_management.unlock_admin_user_devices, DeviceManagement_pb2.DA_UNLOCK + ) + + def test_account_lock_admin_user_devices(self): + self._admin_action_test( + device_management.account_lock_admin_user_devices, + DeviceManagement_pb2.DA_DEVICE_ACCOUNT_LOCK, + ) + + def test_account_unlock_admin_user_devices(self): + self._admin_action_test( + device_management.account_unlock_admin_user_devices, + DeviceManagement_pb2.DA_DEVICE_ACCOUNT_UNLOCK, + ) if __name__ == '__main__': unittest.main() diff --git a/keepersdk-package/unit_tests/test_login.py b/keepersdk-package/unit_tests/test_login.py index f8050821..fbfdba7f 100644 --- a/keepersdk-package/unit_tests/test_login.py +++ b/keepersdk-package/unit_tests/test_login.py @@ -14,6 +14,7 @@ class TestLogin(TestCase): StopAtDeviceApproval = False StopAtTwoFactor = False StopAtPassword = False + StopAtDeviceAccountLocked = False @staticmethod def mock_execute_rest(keeper_endpoint, rest_endpoint, request=None, response_type=None, session_token=None, payload_version=None): @@ -32,6 +33,9 @@ def mock_execute_rest(keeper_endpoint, rest_endpoint, request=None, response_typ lrq: APIRequest_pb2.StartLoginRequest = request lrs = response_type() lrs.encryptedLoginToken = data_vault.EncryptedLoginToken + if TestLogin.StopAtDeviceAccountLocked: + lrs.loginState = APIRequest_pb2.DEVICE_ACCOUNT_LOCKED + return lrs if TestLogin.StopAtDeviceApproval: lrs.loginState = APIRequest_pb2.DEVICE_APPROVAL_REQUIRED elif TestLogin.StopAtTwoFactor: @@ -169,6 +173,7 @@ def reset_stops(): TestLogin.StopAtDeviceApproval = False TestLogin.StopAtTwoFactor = False TestLogin.StopAtPassword = False + TestLogin.StopAtDeviceAccountLocked = False def test_success_flow(self): TestLogin.reset_stops() @@ -353,3 +358,24 @@ def test_invalid_password(self): self.assertIsInstance(step, login_auth.LoginStepPassword) with self.assertRaises(errors.KeeperApiError): step.verify_password('wrong password') + + def test_device_account_locked(self): + TestLogin.reset_stops() + TestLogin.StopAtDeviceAccountLocked = True + + auth = self.get_auth_sync() + config = auth.keeper_endpoint.get_configuration_storage().get() + device_count_before = len(list(config.devices().list())) + + auth.login(data_vault.UserName) + + step = auth.login_step + self.assertIsInstance(step, login_auth.LoginStepError) + self.assertEqual(step.code, 'device_account_locked') + config = auth.keeper_endpoint.get_configuration_storage().get() + self.assertEqual(len(list(config.devices().list())), device_count_before) + register_calls = [ + c for c in auth.keeper_endpoint.execute_rest.call_args_list + if c.args and c.args[0] == 'authentication/register_device' + ] + self.assertEqual(register_calls, []) From b92b34c4df614b3f03be0d31949c1810394bfb75 Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Wed, 8 Jul 2026 20:40:51 +0530 Subject: [PATCH 2/7] Add Vault-style passphrase generation for record-add and record-update (#209) Co-authored-by: mtyagi-ks --- .../src/keepercli/commands/nsf_commands.py | 3 +- .../keepercli/commands/password_generate.py | 59 +++- .../src/keepercli/commands/record_edit.py | 63 +++- .../src/keepercli/helpers/password_utils.py | 13 + keepersdk-package/src/keepersdk/generator.py | 325 +++++++++++++++++- .../unit_tests/test_passphrase_generator.py | 142 ++++++++ 6 files changed, 589 insertions(+), 16 deletions(-) create mode 100644 keepersdk-package/unit_tests/test_passphrase_generator.py diff --git a/keepercli-package/src/keepercli/commands/nsf_commands.py b/keepercli-package/src/keepercli/commands/nsf_commands.py index fbcc0fc4..efd3f0a9 100644 --- a/keepercli-package/src/keepercli/commands/nsf_commands.py +++ b/keepercli-package/src/keepercli/commands/nsf_commands.py @@ -851,6 +851,8 @@ def execute(self, context: KeeperParams, **kwargs): raise base.CommandError('Folder name cannot be empty') if new_name is None and color is None: raise base.CommandError('New folder name and/or color parameters are required.') + + display = _folder_name_from_vault(vault, folder_arg) def _run(): return nsf_management.update_nsf_folder( @@ -858,7 +860,6 @@ def _run(): result = _wrap_nsf('nsf-rndir', _run) if not kwargs.get('quiet'): - display = _folder_name_from_vault(vault, result.folder_uid) if new_name: logger.info('Folder "%s" has been renamed to "%s"', display, new_name) elif color: diff --git a/keepercli-package/src/keepercli/commands/password_generate.py b/keepercli-package/src/keepercli/commands/password_generate.py index 16cbab89..0aa20eee 100644 --- a/keepercli-package/src/keepercli/commands/password_generate.py +++ b/keepercli-package/src/keepercli/commands/password_generate.py @@ -22,9 +22,11 @@ from . import base from .. import api +from keepersdk import generator + from ..helpers.password_utils import ( PasswordGenerationService, GenerationRequest, GeneratedPassword, - BreachStatus + BreachStatus, DEFAULT_PASSWORD_LENGTH, ) from ..params import KeeperParams @@ -86,6 +88,37 @@ def add_arguments_to_parser(parser: argparse.ArgumentParser) -> None: help='Generate crypto-style strong password') special_group.add_argument('--recoveryphrase', dest='recoveryphrase', action='store_true', help='Generate 24-word recovery phrase') + + passphrase_group = parser.add_argument_group('Keeper Passphrase') + passphrase_group.add_argument( + '--passphrase', dest='passphrase', action='store_true', + help='Generate a vault-style passphrase from the EFF word list', + ) + passphrase_group.add_argument( + '--pp-separator', '-pps', dest='pp_separator', action='store', + help=( + f'Word separator (single character, or "space"). Allowed: ' + f'{generator.PASSPHRASE_SEPARATOR_HELP}. ' + 'Overrides enterprise policy for this command.' + ), + ) + passphrase_group.add_argument( + '--pp-capitalize', '-ppc', dest='pp_capitalize', action='store_true', + help='Capitalize the first letter of each word. Overrides enterprise policy for this command.', + ) + passphrase_group.add_argument( + '--pp-no-capitalize', dest='pp_capitalize', action='store_false', + help='Do not capitalize words. Overrides enterprise policy for this command.', + ) + passphrase_group.add_argument( + '--pp-number', '-ppn', dest='pp_number', action='store_true', + help='Append a digit (0-9) to the first word only. Overrides enterprise policy for this command.', + ) + passphrase_group.add_argument( + '--pp-no-number', dest='pp_number', action='store_false', + help='Do not append a digit to the first word. Overrides enterprise policy for this command.', + ) + passphrase_group.set_defaults(pp_capitalize=None, pp_number=None) diceware_group = parser.add_argument_group('Diceware Options') diceware_group.add_argument('--dice-rolls', '-dr', dest='dice_rolls', type=int, @@ -139,13 +172,17 @@ def _generate_passwords(self, service: PasswordGenerationService, request: Gener 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)) + length = self._validate_length(kwargs.get('length', DEFAULT_PASSWORD_LENGTH)) 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')) - + pp_separator = self._parse_passphrase_separator(kwargs.get('pp_separator')) + passphrase_word_count = None + if algorithm == 'passphrase' and length != DEFAULT_PASSWORD_LENGTH: + passphrase_word_count = length + return GenerationRequest( length=length, count=count, @@ -158,6 +195,10 @@ def _create_generation_request(self, **kwargs) -> GenerationRequest: dice_rolls=dice_rolls, delimiter=kwargs.get('delimiter', ' '), word_list_file=kwargs.get('word_list'), + pp_separator=pp_separator, + pp_capitalize=kwargs.get('pp_capitalize'), + pp_number=kwargs.get('pp_number'), + passphrase_word_count=passphrase_word_count, enable_breach_scan=not kwargs.get('no_breachwatch', False) # max_breach_attempts uses GenerationRequest default value ) @@ -182,12 +223,24 @@ def _determine_algorithm(self, kwargs: Dict[str, Any]) -> str: """Determine password generation algorithm from arguments.""" if kwargs.get('crypto'): return 'crypto' + elif kwargs.get('passphrase'): + return 'passphrase' elif kwargs.get('recoveryphrase'): return 'recovery' elif kwargs.get('dice_rolls'): return 'diceware' else: return 'random' # default + + @staticmethod + def _parse_passphrase_separator(pp_separator: Optional[str]) -> Optional[str]: + """Parse and validate passphrase separator from CLI.""" + if not isinstance(pp_separator, str) or not pp_separator.strip(): + return None + separator, error = generator._parse_passphrase_separator_token(pp_separator.strip()) + if error: + raise base.CommandError(error) + return separator def _validate_complexity_parameters(self, kwargs: Dict[str, Any]) -> tuple: """Validate complexity parameters (symbols, digits, uppercase, lowercase).""" diff --git a/keepercli-package/src/keepercli/commands/record_edit.py b/keepercli-package/src/keepercli/commands/record_edit.py index 156eebaa..2010e73b 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, Dict +from typing import Iterable, Optional, List, Any, Sequence, Union, Dict, Tuple from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ed25519 @@ -118,8 +118,9 @@ class ParsedFieldValue: Value Field type Description Example ==================== =============== =================== ============== $GEN:[alg],[n] password Generates a random password $GEN:dice,5 - Default algorith is rand alg: [rand | dice | crypto] + Default algorith is rand alg: [rand | dice | crypto | passphrase] Optional: password length + passphrase: $GEN:passphrase[,word_count][,separator][,capitalize][,number] $GEN oneTimeCode Generates TOTP URL $GEN:[alg,][enc] keyPair Generates a key pair and $GEN:ec,enc optional passcode alg: [rsa | ec | ed25519], enc @@ -183,8 +184,13 @@ def assign_legacy_fields(self, record: vault_record.PasswordRecord, fields: List if parsed_field.type == 'login': record.login = parsed_field.value elif parsed_field.type == 'password': + action_params.clear() if self.is_generate_value(parsed_field.value, action_params): - record.password = self.generate_password(action_params) + password, gen_error = self.generate_password(action_params) + if gen_error: + self.on_warning(gen_error) + elif password is not None: + record.password = password else: record.password = parsed_field.value elif parsed_field.type == 'url': @@ -260,22 +266,49 @@ def generate_key_pair(key_type: str, passphrase: str) -> Dict: } @staticmethod - def generate_password(parameters: Optional[Sequence[str]]=None) -> str: + def generate_password(parameters: Optional[Sequence[str]] = None, + policy: Optional[dict] = None) -> Tuple[Optional[str], Optional[str]]: + algorithm, error = generator.resolve_gen_password_algorithm( + parameters if isinstance(parameters, (tuple, list, set)) else None) + if error: + return None, error + + length = None if isinstance(parameters, (tuple, list, set)): - algorithm = next((x for x in parameters if x in ('rand', 'dice', 'crypto')), 'rand') length = next((x for x in parameters if x.isnumeric()), None) if isinstance(length, str) and len(length) > 0: try: length = int(length) except ValueError: pass - else: - algorithm = 'rand' - length = None gen: generator.PasswordGenerator if algorithm == 'crypto': gen = generator.CryptoPassphraseGenerator() + elif algorithm == 'passphrase': + pp_opts, pp_error = generator.parse_passphrase_gen_parameters(parameters) + if pp_error: + return None, pp_error + if policy and policy.get('passphrase-allow') is False: + logger.warning( + 'Passphrase generation is disabled by enterprise policy; using random password.') + fallback_length = pp_opts.word_count or length + if isinstance(fallback_length, int): + if fallback_length < 4: + fallback_length = 4 + elif fallback_length > 200: + fallback_length = 200 + else: + fallback_length = 20 + gen = generator.KeeperPasswordGenerator(length=fallback_length) + else: + gen = generator.KeeperPassphraseGenerator.create_with_options( + policy, + word_count=pp_opts.word_count if pp_opts.word_count is not None else length, + separator=pp_opts.separator, + capitalize=pp_opts.capitalize, + append_number=pp_opts.append_number, + ) elif algorithm == 'dice': if isinstance(length, int): if length < 1: @@ -294,7 +327,7 @@ def generate_password(parameters: Optional[Sequence[str]]=None) -> str: else: length = 20 gen = generator.KeeperPasswordGenerator(length=length) - return gen.generate() + return gen.generate(), None @staticmethod def generate_totp_url() -> str: @@ -456,12 +489,20 @@ def assign_typed_fields(self, record: vault_record.TypedRecord, fields: List[Par value: Any = None if self.is_generate_value(parsed_field.value, action_params): if record_field.type == 'password': - value = self.generate_password(action_params) + value, gen_error = self.generate_password(action_params) + if gen_error: + self.on_warning(gen_error) + value = None elif record_field.type in ('oneTimeCode', 'otp'): value = self.generate_totp_url() elif record_field.type in ('keyPair', 'privateKey'): should_encrypt = 'enc' in action_params - passphrase = self.generate_password() if should_encrypt else '' + passphrase = '' + if should_encrypt: + passphrase, gen_error = self.generate_password() + if gen_error: + self.on_warning(gen_error) + continue key_type = next((x for x in action_params if x in ('rsa', 'ec', 'ed25519')), 'rsa') value = self.generate_key_pair(key_type, passphrase) if passphrase: diff --git a/keepercli-package/src/keepercli/helpers/password_utils.py b/keepercli-package/src/keepercli/helpers/password_utils.py index a9bdb229..5bb3d40d 100644 --- a/keepercli-package/src/keepercli/helpers/password_utils.py +++ b/keepercli-package/src/keepercli/helpers/password_utils.py @@ -125,6 +125,11 @@ class GenerationRequest: dice_rolls: Optional[int] = None delimiter: str = ' ' word_list_file: Optional[str] = None + + pp_separator: Optional[str] = None + pp_capitalize: Optional[bool] = None + pp_number: Optional[bool] = None + passphrase_word_count: Optional[int] = None enable_breach_scan: bool = True max_breach_attempts: int = BREACHWATCH_MAX @@ -278,6 +283,14 @@ def _create_generator(self, request: GenerationRequest) -> generator.PasswordGen word_list_file=request.word_list_file, delimiter=request.delimiter ) + elif algorithm == 'passphrase': + return generator.KeeperPassphraseGenerator.create_with_options( + None, + word_count=request.passphrase_word_count, + separator=request.pp_separator, + capitalize=request.pp_capitalize, + append_number=request.pp_number, + ) 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) diff --git a/keepersdk-package/src/keepersdk/generator.py b/keepersdk-package/src/keepersdk/generator.py index a9d94bc5..e30d5303 100644 --- a/keepersdk-package/src/keepersdk/generator.py +++ b/keepersdk-package/src/keepersdk/generator.py @@ -1,15 +1,242 @@ import abc +import difflib import hashlib import logging import os import secrets import string -from typing import Optional, List, Any, Iterator +from collections import namedtuple +from typing import Optional, List, Any, Iterator, Sequence, Tuple from . import crypto DEFAULT_PASSWORD_LENGTH = 32 PW_SPECIAL_CHARACTERS = '!@#$%()+;<>=?[]{}^.,' +PP_SEPARATOR_CHARACTERS = '-._?! ' +DEFAULT_PASSPHRASE_SEPARATOR = '-' +DEFAULT_PASSPHRASE_WORD_COUNT = 5 +MIN_PASSPHRASE_WORD_COUNT = 5 +MAX_PASSPHRASE_WORD_COUNT = 9 +DEFAULT_PASSPHRASE_CAPITALIZE = True +DEFAULT_PASSPHRASE_NUMBER = True +GEN_PASSWORD_ALGORITHMS = ('rand', 'dice', 'crypto', 'passphrase') +DEFAULT_DICEWARE_WORDLIST = 'diceware.wordlist.asc.txt' +PASSPHRASE_SEPARATOR_HELP = '- . _ ? ! space' + +PassphraseGenOptions = namedtuple( + 'PassphraseGenOptions', ('word_count', 'separator', 'capitalize', 'append_number')) +PassphraseGenOptions.__doc__ = ( + 'Parsed optional parameters for $GEN:passphrase. ' + 'None fields use Vault/CLI defaults when building a generator.' +) + + +def clamp_passphrase_word_count(word_count: Optional[int]) -> int: + """Clamp passphrase word count to the Vault range (5-9 words).""" + if not isinstance(word_count, int): + return DEFAULT_PASSPHRASE_WORD_COUNT + original = word_count + if word_count < MIN_PASSPHRASE_WORD_COUNT: + word_count = MIN_PASSPHRASE_WORD_COUNT + elif word_count > MAX_PASSPHRASE_WORD_COUNT: + word_count = MAX_PASSPHRASE_WORD_COUNT + if word_count != original: + logging.warning( + 'Passphrase word count must be between %d and %d; using %d.', + MIN_PASSPHRASE_WORD_COUNT, MAX_PASSPHRASE_WORD_COUNT, word_count) + return word_count + + +def format_passphrase_separators_for_display(separators: Optional[str] = None) -> str: + """Human-readable list of allowed passphrase separator characters.""" + if not separators: + separators = PP_SEPARATOR_CHARACTERS + parts: List[str] = [] + for ch in separators: + parts.append('space' if ch == ' ' else ch) + return ', '.join(parts) + + +def _normalize_passphrase_separator(separator: Optional[str]) -> str: + """Normalize a separator string to a single allowed character.""" + if not separator: + return DEFAULT_PASSPHRASE_SEPARATOR + if separator == '\u2423': # OPEN BOX (Vault UI glyph for space) + return ' ' + return separator[0] + + +def _passphrase_separators_from_policy(policy_sep: str) -> str: + """Return allowed separators in Vault order (see getPasswordRules.ts).""" + normalized = policy_sep.replace('\u2423', ' ') + allowed = '' + for ch in PP_SEPARATOR_CHARACTERS: + if ch in normalized: + allowed += ch + return allowed + + +def _default_passphrase_separator_from_policy(policy_sep: Optional[str]) -> str: + """Pick the default generation separator matching Vault / PowerCommander.""" + if not policy_sep or not isinstance(policy_sep, str) or not policy_sep.strip(): + return DEFAULT_PASSPHRASE_SEPARATOR + allowed = _passphrase_separators_from_policy(policy_sep.strip()) + return allowed[0] if allowed else DEFAULT_PASSPHRASE_SEPARATOR + + +def resolve_gen_password_algorithm( + parameters: Optional[Sequence[str]]) -> Tuple[Optional[str], Optional[str]]: + """Resolve $GEN password algorithm; return (algorithm, error_message).""" + if not parameters: + return 'rand', None + first = parameters[0].strip() + first_lower = first.lower() + if first_lower in GEN_PASSWORD_ALGORITHMS: + return first_lower, None + if first.isdigit(): + return 'rand', None + suggestions = difflib.get_close_matches(first_lower, GEN_PASSWORD_ALGORITHMS, n=1, cutoff=0.6) + message = f'Unknown $GEN password algorithm "{first}".' + if suggestions: + message += f' Did you mean "{suggestions[0]}"?' + message += f' Valid algorithms: {", ".join(GEN_PASSWORD_ALGORITHMS)}.' + return None, message + + +def _is_strict_gen_bool_token(value: str) -> bool: + """Return True if value is exactly 'true' or 'false' (case-insensitive).""" + return value.strip().lower() in ('true', 'false') + + +def _parse_gen_bool_strict(value: str, param_name: str) -> Tuple[Optional[bool], Optional[str]]: + """Parse a strict true/false token for $GEN:passphrase; return (value, error).""" + normalized = value.strip().lower() + if normalized == 'true': + return True, None + if normalized == 'false': + return False, None + return None, ( + f'Invalid $GEN:passphrase {param_name} parameter "{value}". ' + f'Expected true or false.') + + +def _is_passphrase_separator_token(token: str) -> bool: + """Return True if token is a valid passphrase separator or 'space'/'sp' alias.""" + if token.lower() in ('space', 'sp'): + return True + return len(token) == 1 and token in PP_SEPARATOR_CHARACTERS + + +def _parse_passphrase_separator_token(token: str) -> Tuple[Optional[str], Optional[str]]: + """Parse a separator token; return (separator_char, error_message).""" + if token.lower() in ('space', 'sp'): + return ' ', None + if len(token) == 1 and token in PP_SEPARATOR_CHARACTERS: + return token, None + return None, ( + f'Invalid passphrase separator "{token}". ' + f'Allowed: {format_passphrase_separators_for_display(PP_SEPARATOR_CHARACTERS)}.') + + +def parse_passphrase_gen_parameters( + parameters: Optional[Sequence[str]]) -> Tuple[PassphraseGenOptions, Optional[str]]: + """Parse $GEN:passphrase optional parameters. + + Format: $GEN:passphrase[,word_count][,separator][,capitalize][,number] + word_count must be between 5 and 9 (Vault range). + """ + empty = PassphraseGenOptions(None, None, None, None) + if not parameters: + return empty, None + + tokens = [p if isinstance(p, str) else str(p) for p in parameters] + if not tokens or tokens[0].strip().lower() != 'passphrase': + return empty, None + + extras = tokens[1:] + if any(t.strip() == '' for t in extras): + return empty, ( + 'Incomplete $GEN:passphrase parameters: missing value after comma. ' + 'Format: $GEN:passphrase[,word_count][,separator][,capitalize][,number]') + + word_count = None + separator = None + capitalize = None + append_number = None + idx = 0 + + if idx < len(extras): + token = extras[idx].strip() + if token.isdigit(): + word_count = int(token) + if word_count < MIN_PASSPHRASE_WORD_COUNT or word_count > MAX_PASSPHRASE_WORD_COUNT: + return empty, ( + f'Passphrase word count must be between {MIN_PASSPHRASE_WORD_COUNT} ' + f'and {MAX_PASSPHRASE_WORD_COUNT} (got {word_count}).') + idx += 1 + elif not _is_passphrase_separator_token(token) and not _is_strict_gen_bool_token(token): + return empty, ( + f'Invalid passphrase word count "{token}". ' + f'Expected an integer between {MIN_PASSPHRASE_WORD_COUNT} ' + f'and {MAX_PASSPHRASE_WORD_COUNT}.') + + if idx < len(extras) and not _is_strict_gen_bool_token(extras[idx].strip()): + separator, sep_error = _parse_passphrase_separator_token(extras[idx].strip()) + if sep_error: + return empty, sep_error + idx += 1 + + if idx < len(extras): + capitalize, cap_error = _parse_gen_bool_strict(extras[idx].strip(), 'capitalize') + if cap_error: + return empty, cap_error + idx += 1 + + if idx < len(extras): + append_number, num_error = _parse_gen_bool_strict(extras[idx].strip(), 'number') + if num_error: + return empty, num_error + idx += 1 + + if idx < len(extras): + return empty, f'Unexpected $GEN:passphrase parameter "{extras[idx].strip()}".' + + return PassphraseGenOptions(word_count, separator, capitalize, append_number), None + + +def _resolve_wordlist_path(word_list_file: Optional[str] = None) -> str: + """Resolve bundled or user-supplied diceware word list path.""" + if word_list_file: + dice_path = os.path.join(os.path.dirname(__file__), 'resources', word_list_file) + if not os.path.isfile(dice_path): + dice_path = os.path.expanduser(word_list_file) + else: + dice_path = os.path.join(os.path.dirname(__file__), 'resources', DEFAULT_DICEWARE_WORDLIST) + return dice_path + + +def _load_wordlist(word_list_file: Optional[str] = None) -> List[str]: + """Load and validate the diceware word list from disk.""" + dice_path = _resolve_wordlist_path(word_list_file) + if not os.path.isfile(dice_path): + raise Exception(f'Word list file \"{dice_path}\" not found.') + + vocabulary: List[str] = [] + unique_words = set() + with open(dice_path, 'r', encoding='utf-8') as dw: + for line in dw: + line = line.strip() + if not line or line.startswith('--'): + continue + if line.lower().startswith('source url:') or line.lower().startswith('title:'): + continue + parts = line.split() + word = parts[1] if len(parts) >= 2 else parts[0] + vocabulary.append(word) + unique_words.add(word.lower()) + if len(vocabulary) != len(unique_words): + raise Exception(f'Word list file \"{dice_path}\" contains non-unique words.') + return vocabulary class PasswordGenerator(abc.ABC): @@ -170,3 +397,99 @@ def generate(self): words.reverse() return ' '.join((self._vocabulary[x] for x in words)) + + +class KeeperPassphraseGenerator(PasswordGenerator): + """Vault-style passphrase generator using the bundled EFF large word list. + + Each word is chosen with a cryptographically secure random selector (``secrets``), + using configurable word count (5-9), separator, optional capitalization of every word, + and an optional single digit appended to the first word only. Words are never + repeated within a single passphrase. + + Use :meth:`create_with_options` or :meth:`create_from_policy` to apply + enterprise passphrase policy defaults with optional CLI/$GEN overrides. + """ + + def __init__(self, word_count: int = DEFAULT_PASSPHRASE_WORD_COUNT, + separator: str = DEFAULT_PASSPHRASE_SEPARATOR, + capitalize: bool = DEFAULT_PASSPHRASE_CAPITALIZE, + append_number: bool = DEFAULT_PASSPHRASE_NUMBER, + word_list_file: Optional[str] = None) -> None: + """Initialize a Vault-style passphrase generator with the given options.""" + self.word_count = clamp_passphrase_word_count( + word_count if isinstance(word_count, int) else DEFAULT_PASSPHRASE_WORD_COUNT) + self.separator = _normalize_passphrase_separator(separator) + self.capitalize = capitalize + self.append_number = append_number + self._vocabulary = _load_wordlist(word_list_file) + + def _select_unique_words(self) -> List[str]: + """Select word_count unique words using secrets.randbelow (CSPRNG).""" + pool = list(self._vocabulary) + words: List[str] = [] + for _ in range(self.word_count): + idx = secrets.randbelow(len(pool)) + words.append(pool.pop(idx)) + return words + + def generate(self) -> str: + """Generate a passphrase using the configured word count, separator, and formatting.""" + if not self._vocabulary: + raise Exception('Passphrase word list was not loaded') + + passphrase = '' + first_word = True + for word in self._select_unique_words(): + if self.capitalize and word: + word = word[0].upper() + word[1:] # Vault UI: capitalize every word + if self.append_number and first_word: + word += str(secrets.randbelow(10)) # Vault UI: one digit on first word only + if not first_word: + passphrase += self.separator + passphrase += word + first_word = False + return passphrase + + @classmethod + def create_with_options(cls, policy: Optional[dict] = None, word_count: Optional[int] = None, + separator: Optional[str] = None, capitalize: Optional[bool] = None, + append_number: Optional[bool] = None) -> 'KeeperPassphraseGenerator': + """Build a generator from CLI/$GEN overrides with optional policy defaults.""" + wc = word_count + if wc is None: + if policy: + wc = policy.get('passphrase-length', DEFAULT_PASSPHRASE_WORD_COUNT) + else: + wc = DEFAULT_PASSPHRASE_WORD_COUNT + + sep = separator + if sep is None: + if policy: + policy_sep = policy.get('passphrase-separator') + sep = _default_passphrase_separator_from_policy( + policy_sep if isinstance(policy_sep, str) else None) + else: + sep = DEFAULT_PASSPHRASE_SEPARATOR + + cap = capitalize + if cap is None: + cap = DEFAULT_PASSPHRASE_CAPITALIZE + + num = append_number + if num is None: + num = DEFAULT_PASSPHRASE_NUMBER + + return cls( + word_count=clamp_passphrase_word_count(wc) if isinstance(wc, int) else wc, + separator=sep, capitalize=cap, append_number=num) + + @classmethod + def create_from_policy(cls, policy: dict, length_override: Optional[int] = None, + separator_override: Optional[str] = None) -> 'KeeperPassphraseGenerator': + """Build a generator using enterprise passphrase policy defaults.""" + return cls.create_with_options( + policy, + word_count=length_override, + separator=separator_override, + ) diff --git a/keepersdk-package/unit_tests/test_passphrase_generator.py b/keepersdk-package/unit_tests/test_passphrase_generator.py new file mode 100644 index 00000000..c08305d8 --- /dev/null +++ b/keepersdk-package/unit_tests/test_passphrase_generator.py @@ -0,0 +1,142 @@ +from unittest import TestCase, mock + +from keepersdk import generator + + +class TestKeeperPassphraseGenerator(TestCase): + + def test_default_generates_five_hyphen_separated_words(self): + gen = generator.KeeperPassphraseGenerator() + gen._vocabulary = ['alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot'] + with mock.patch('secrets.randbelow', side_effect=[0, 0, 0, 0, 0, 3]): + result = gen.generate() + self.assertEqual(result, 'Alpha3-Bravo-Charlie-Delta-Echo') + + def test_does_not_shuffle_words_like_diceware(self): + gen = generator.KeeperPassphraseGenerator( + word_count=5, separator=' ', capitalize=False, append_number=False) + gen._vocabulary = ['one', 'two', 'three', 'four', 'five', 'six'] + with mock.patch('secrets.randbelow', side_effect=[0, 0, 0, 0, 0]): + result = gen.generate() + self.assertEqual(result, 'one two three four five') + + def test_capitalize_applies_to_every_word_number_to_first_word_only(self): + gen = generator.KeeperPassphraseGenerator( + word_count=5, separator='-', capitalize=True, append_number=True) + gen._vocabulary = ['alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot'] + with mock.patch('secrets.randbelow', side_effect=[0, 0, 0, 0, 0, 7]): + result = gen.generate() + self.assertEqual(result, 'Alpha7-Bravo-Charlie-Delta-Echo') + + def test_create_from_policy_honors_passphrase_fields(self): + gen = generator.KeeperPassphraseGenerator.create_from_policy({ + 'passphrase-length': 5, + 'passphrase-separator': '-', + 'passphrase-capitalize': True, + 'passphrase-number': True, + }) + gen._vocabulary = ['alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot'] + with mock.patch('secrets.randbelow', side_effect=[0, 0, 0, 0, 0, 4]): + result = gen.generate() + self.assertEqual(result, 'Alpha4-Bravo-Charlie-Delta-Echo') + + def test_generated_words_are_unique(self): + gen = generator.KeeperPassphraseGenerator(word_count=9, capitalize=False, append_number=False) + for _ in range(100): + words = gen.generate().split(gen.separator) + self.assertEqual(len(words), 9) + self.assertEqual(len(words), len(set(words))) + + def test_parse_passphrase_gen_parameters(self): + opts, error = generator.parse_passphrase_gen_parameters( + ['passphrase', '7', '_', 'true', 'false']) + self.assertIsNone(error) + self.assertEqual(opts.word_count, 7) + self.assertEqual(opts.separator, '_') + self.assertTrue(opts.capitalize) + self.assertFalse(opts.append_number) + + def test_parse_passphrase_rejects_invalid_separator(self): + _, error = generator.parse_passphrase_gen_parameters( + ['passphrase', '7', '@', 'true', 'true']) + self.assertIn('Invalid passphrase separator', error) + + def test_parse_passphrase_rejects_invalid_boolean(self): + _, error = generator.parse_passphrase_gen_parameters( + ['passphrase', '7', '_', 'tr', 'true']) + self.assertIn('capitalize', error) + + def test_parse_passphrase_rejects_trailing_comma(self): + _, error = generator.parse_passphrase_gen_parameters( + ['passphrase', '9', '_', 'true', '']) + self.assertIn('missing value after comma', error) + + def test_parse_passphrase_rejects_extra_parameters(self): + _, error = generator.parse_passphrase_gen_parameters( + ['passphrase', '7', '_', 'true', 'true', 'test']) + self.assertIn('Unexpected', error) + + def test_parse_passphrase_rejects_out_of_range_word_count(self): + _, error = generator.parse_passphrase_gen_parameters(['passphrase', '12']) + self.assertIn('between 5 and 9', error) + + def test_create_with_options_overrides_policy(self): + policy = { + 'passphrase-length': 5, + 'passphrase-separator': '-', + 'passphrase-capitalize': False, + 'passphrase-number': False, + } + gen = generator.KeeperPassphraseGenerator.create_with_options( + policy, word_count=3, separator='_', capitalize=True, append_number=True) + self.assertEqual(gen.word_count, 5) + self.assertEqual(gen.separator, '_') + self.assertTrue(gen.capitalize) + self.assertTrue(gen.append_number) + + def test_commander_defaults_override_policy_capitalize_and_number(self): + gen = generator.KeeperPassphraseGenerator.create_with_options({ + 'passphrase-capitalize': False, + 'passphrase-number': False, + }) + self.assertTrue(gen.capitalize) + self.assertTrue(gen.append_number) + + def test_policy_separator_uses_vault_order_not_raw_first_char(self): + gen = generator.KeeperPassphraseGenerator.create_with_options({ + 'passphrase-separator': '!._?-', + }) + self.assertEqual(gen.separator, '-') + + def test_invalid_separator_override_is_rejected_by_parser(self): + _, error = generator.parse_passphrase_gen_parameters( + ['passphrase', '7', '~', 'true', 'true']) + self.assertIn('Invalid passphrase separator', error) + + def test_word_count_clamped_to_vault_range(self): + self.assertEqual(generator.clamp_passphrase_word_count(2), 5) + self.assertEqual(generator.clamp_passphrase_word_count(9), 9) + self.assertEqual(generator.clamp_passphrase_word_count(12), 9) + + def test_word_count_clamp_logs_warning(self): + with mock.patch('keepersdk.generator.logging.warning') as mock_warning: + generator.clamp_passphrase_word_count(12) + mock_warning.assert_called_once() + args, _ = mock_warning.call_args + self.assertIn('between', args[0]) + self.assertEqual(args[1:], (5, 9, 9)) + + def test_loads_bundled_diceware_wordlist(self): + words = generator._load_wordlist() + self.assertEqual(len(words), 7776) + self.assertEqual(words[0], 'abacus') + + def test_resolve_gen_password_algorithm_rejects_typos(self): + algorithm, error = generator.resolve_gen_password_algorithm(['passphra']) + self.assertIsNone(algorithm) + self.assertIn('passphrase', error) + + def test_resolve_gen_password_algorithm_accepts_numeric_length(self): + algorithm, error = generator.resolve_gen_password_algorithm(['16']) + self.assertEqual(algorithm, 'rand') + self.assertIsNone(error) From 8f83254ce4411d34f5278c4c75b8c1242d640d1a Mon Sep 17 00:00:00 2001 From: mtyagi-ks Date: Tue, 7 Jul 2026 16:10:01 +0530 Subject: [PATCH 3/7] enterprise-team add and edit commands user/role operations support --- .../enterprise_team_membership.py | 95 ++- .../enterprise_team/enterprise_team_view.py | 10 +- .../enterprise_team/team_add_role.py | 579 +++++++++++++++++ .../enterprise_team/team_add_user.py | 570 +++++++++++++++++ .../team_hide_shared_folders.py | 605 ++++++++++++++++++ .../enterprise_team/team_remove_role.py | 575 +++++++++++++++++ .../enterprise_team/team_remove_user.py | 570 +++++++++++++++++ .../src/keepercli/commands/enterprise_team.py | 286 +++++++-- .../src/keepercli/commands/enterprise_user.py | 6 +- .../keepercli/commands/enterprise_utils.py | 4 + .../keepersdk/enterprise/batch_management.py | 76 ++- .../enterprise/enterprise_management.py | 12 + .../enterprise/enterprise_user_management.py | 6 +- 13 files changed, 3235 insertions(+), 159 deletions(-) create mode 100644 examples/sdk_examples/enterprise_team/team_add_role.py create mode 100644 examples/sdk_examples/enterprise_team/team_add_user.py create mode 100644 examples/sdk_examples/enterprise_team/team_hide_shared_folders.py create mode 100644 examples/sdk_examples/enterprise_team/team_remove_role.py create mode 100644 examples/sdk_examples/enterprise_team/team_remove_user.py diff --git a/examples/sdk_examples/enterprise_team/enterprise_team_membership.py b/examples/sdk_examples/enterprise_team/enterprise_team_membership.py index 3f715a0c..0d7e4162 100644 --- a/examples/sdk_examples/enterprise_team/enterprise_team_membership.py +++ b/examples/sdk_examples/enterprise_team/enterprise_team_membership.py @@ -516,60 +516,57 @@ def view_team_membership(keeper_auth_context: keeper_auth.KeeperAuth): enterprise = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) - team_search = input('Enter team name or UID: ').strip() + team_uid_or_name = "" - if not team_search: - print('No team specified') - else: - team_found = None + 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 '' - 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_uid_or_name.lower() in team_name.lower() or + team_uid_or_name == 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' - 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(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) - 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(f'\nNo team found matching: "{team_search}"') + 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_uid_or_name}"') enterprise.close() keeper_auth_context.close() diff --git a/examples/sdk_examples/enterprise_team/enterprise_team_view.py b/examples/sdk_examples/enterprise_team/enterprise_team_view.py index 63ba9126..d0ba02e4 100644 --- a/examples/sdk_examples/enterprise_team/enterprise_team_view.py +++ b/examples/sdk_examples/enterprise_team/enterprise_team_view.py @@ -516,21 +516,21 @@ def view_enterprise_teams(keeper_auth_context: keeper_auth.KeeperAuth): enterprise = enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) - team_search = input('Enter team name or UID (or leave empty for all teams): ').strip() + team_uid_or_name = "" # team name or UID to search for or leave empty to display all teams teams_to_display = [] - if team_search: + if team_uid_or_name: 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): + if (team_uid_or_name.lower() in team_name.lower() or + team_uid_or_name == team_uid): teams_to_display.append(team) if not teams_to_display: - print(f'\nNo teams found matching: "{team_search}"') + print(f'\nNo teams found matching: "{team_uid_or_name}"') else: teams_to_display = list(enterprise.enterprise_data.teams.get_all_entities()) diff --git a/examples/sdk_examples/enterprise_team/team_add_role.py b/examples/sdk_examples/enterprise_team/team_add_role.py new file mode 100644 index 00000000..af24a805 --- /dev/null +++ b/examples/sdk_examples/enterprise_team/team_add_role.py @@ -0,0 +1,579 @@ +import getpass +import sqlite3 +import json +import logging +from typing import Dict, Optional + +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.enterprise import ( + batch_management, + enterprise_loader, + enterprise_management, + enterprise_user_management, + sqlite_enterprise_storage, +) + + +try: + import pyperclip +except ImportError: + pyperclip = None + +logger = utils.get_logger() +logger.setLevel(logging.INFO) +if not logger.handlers: + _handler = logging.StreamHandler() + _handler.setLevel(logging.INFO) + _handler.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") + ) + logger.addHandler(_handler) + + +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 + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + 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.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(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: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + 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.")) + 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.")) + except Exception: + 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 = [ + "", + "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 use option 't' to enter the token manually."), + '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 == "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: + 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 = 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}") + 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 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, + 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. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + + +def find_team(enterprise_data, team_name_or_uid: str): + search = team_name_or_uid.strip() + for team in enterprise_data.teams.get_all_entities(): + team_name = team.name if team.name else '' + if search.lower() in team_name.lower() or search == team.team_uid: + return team + return None + + +def find_user_by_email(enterprise_data, email: str): + email_lower = email.strip().lower() + for user in enterprise_data.users.get_all_entities(): + if user.username.lower() == email_lower: + return user + return None + + +def load_enterprise(keeper_auth_context): + if not keeper_auth_context.auth_context.is_enterprise_admin: + raise RuntimeError('This operation requires enterprise admin privileges.') + 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() + return loader + + +def main(): + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + + # Fill in your values here (enterprise admin required). + team_name_or_uid = '' # team name or UID + role_name_or_id = '' # role name or ID + + try: + loader = load_enterprise(keeper_auth_context) + enterprise_data = loader.enterprise_data + + team = find_team(enterprise_data, team_name_or_uid) + if not team: + print(f'Team not found: {team_name_or_uid}') + return + role = enterprise_user_management.resolve_role(enterprise_data, role_name_or_id) + + if any(enterprise_data.managed_nodes.get_links_by_subject(role.role_id)): + print('Teams cannot be assigned to roles with administrative permissions.') + return + + existing_roles = { + x.role_id + for x in enterprise_data.role_teams.get_links_by_object(team.team_uid) + } + if role.role_id in existing_roles: + print(f"Role '{role.name}' is already assigned to team '{team.name}'") + return + + batch = batch_management.BatchManagement(loader=loader) + batch.modify_role_teams(to_add=[ + enterprise_management.RoleTeamEdit(role_id=role.role_id, team_uid=team.team_uid) + ]) + batch.apply() + print(f"Added role '{role.name}' to team '{team.name}'") + except Exception as e: + print(f'Error adding role to team: {e}') + finally: + keeper_auth_context.close() + + +if __name__ == '__main__': + main() diff --git a/examples/sdk_examples/enterprise_team/team_add_user.py b/examples/sdk_examples/enterprise_team/team_add_user.py new file mode 100644 index 00000000..567caf78 --- /dev/null +++ b/examples/sdk_examples/enterprise_team/team_add_user.py @@ -0,0 +1,570 @@ +import getpass +import sqlite3 +import json +import logging +from typing import Dict, Optional + +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.enterprise import ( + enterprise_loader, + enterprise_user_management, + sqlite_enterprise_storage, +) + + +try: + import pyperclip +except ImportError: + pyperclip = None + +logger = utils.get_logger() +logger.setLevel(logging.INFO) +if not logger.handlers: + _handler = logging.StreamHandler() + _handler.setLevel(logging.INFO) + _handler.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") + ) + logger.addHandler(_handler) + + +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 + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + 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.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(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: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + 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.")) + 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.")) + except Exception: + 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 = [ + "", + "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 use option 't' to enter the token manually."), + '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 == "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: + 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 = 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}") + 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 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, + 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. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + + +def find_team(enterprise_data, team_name_or_uid: str): + search = team_name_or_uid.strip() + for team in enterprise_data.teams.get_all_entities(): + team_name = team.name if team.name else '' + if search.lower() in team_name.lower() or search == team.team_uid: + return team + return None + + +def find_user_by_email(enterprise_data, email: str): + email_lower = email.strip().lower() + for user in enterprise_data.users.get_all_entities(): + if user.username.lower() == email_lower: + return user + return None + + +def load_enterprise(keeper_auth_context): + if not keeper_auth_context.auth_context.is_enterprise_admin: + raise RuntimeError('This operation requires enterprise admin privileges.') + 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() + return loader + + +def main(): + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + + # Fill in your values here (enterprise admin required). + team_name_or_uid = '' # team name or UID + user_email = '' # user email + + try: + loader = load_enterprise(keeper_auth_context) + enterprise_data = loader.enterprise_data + + team = find_team(enterprise_data, team_name_or_uid) + if not team: + print(f'Team not found: {team_name_or_uid}') + return + user = find_user_by_email(enterprise_data, user_email) + if not user: + print(f'User not found: {user_email}') + return + + result = enterprise_user_management.add_users_to_teams( + loader, + user_ids=[user.enterprise_user_id], + team_uids={team.team_uid}, + ) + print(result.message or 'Done') + if result.added_count: + print(f"Added user '{user.username}' to team '{team.name}'") + except Exception as e: + print(f'Error adding user to team: {e}') + finally: + keeper_auth_context.close() + + +if __name__ == '__main__': + main() diff --git a/examples/sdk_examples/enterprise_team/team_hide_shared_folders.py b/examples/sdk_examples/enterprise_team/team_hide_shared_folders.py new file mode 100644 index 00000000..c7bc5700 --- /dev/null +++ b/examples/sdk_examples/enterprise_team/team_hide_shared_folders.py @@ -0,0 +1,605 @@ +import getpass +import sqlite3 +import json +import logging +from typing import Dict, Optional + +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.enterprise import ( + batch_management, + enterprise_loader, + enterprise_management, + enterprise_user_management, + sqlite_enterprise_storage, +) + + +try: + import pyperclip +except ImportError: + pyperclip = None + +logger = utils.get_logger() +logger.setLevel(logging.INFO) +if not logger.handlers: + _handler = logging.StreamHandler() + _handler.setLevel(logging.INFO) + _handler.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") + ) + logger.addHandler(_handler) + + +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 + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + 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.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(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: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + 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.")) + 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.")) + except Exception: + 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 = [ + "", + "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 use option 't' to enter the token manually."), + '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 == "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: + 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 = 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}") + 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 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, + 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. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + + +def find_team(enterprise_data, team_name_or_uid: str): + search = team_name_or_uid.strip() + for team in enterprise_data.teams.get_all_entities(): + team_name = team.name if team.name else '' + if search.lower() in team_name.lower() or search == team.team_uid: + return team + return None + + +def find_user_by_email(enterprise_data, email: str): + email_lower = email.strip().lower() + for user in enterprise_data.users.get_all_entities(): + if user.username.lower() == email_lower: + return user + return None + + +def load_enterprise(keeper_auth_context): + if not keeper_auth_context.auth_context.is_enterprise_admin: + raise RuntimeError('This operation requires enterprise admin privileges.') + 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() + return loader + + +def main(): + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + + # Fill in your values here (enterprise admin required). + team_name_or_uid = '' + user_email = '' + # 'on' hides shared folders from the user on this team; 'off' shows them. + hide_shared_folders = 'on' + + try: + loader = load_enterprise(keeper_auth_context) + enterprise_data = loader.enterprise_data + + team = find_team(enterprise_data, team_name_or_uid) + if not team: + print(f'Team not found: {team_name_or_uid}') + return + user = find_user_by_email(enterprise_data, user_email) + if not user: + print(f'User not found: {user_email}') + return + + user_type = enterprise_management.team_user_type_from_hsf_flag(hide_shared_folders) + if user_type is None: + print("hide_shared_folders must be 'on' or 'off'") + return + + existing_users = { + x.enterprise_user_id + for x in enterprise_data.team_users.get_links_by_subject(team.team_uid) + } + if user.enterprise_user_id not in existing_users: + result = enterprise_user_management.add_users_to_teams( + loader, + user_ids=[user.enterprise_user_id], + team_uids={team.team_uid}, + hide_shared_folders=hide_shared_folders == 'on', + ) + print(result.message or 'Done') + if result.added_count: + hsf_label = 'hidden' if hide_shared_folders == 'on' else 'visible' + print( + f"Added user '{user.username}' to team '{team.name}' " + f"with shared folders {hsf_label}" + ) + return + + batch = batch_management.BatchManagement(loader=loader) + batch.modify_team_users(to_add=[ + enterprise_management.TeamUserEdit( + team_uid=team.team_uid, + enterprise_user_id=user.enterprise_user_id, + user_type=user_type, + ) + ]) + batch.apply() + hsf_label = 'hidden' if hide_shared_folders == 'on' else 'visible' + print( + f"Updated team '{team.name}' member '{user.username}': " + f"shared folders are now {hsf_label}" + ) + except Exception as e: + print(f'Error updating hide shared folders setting: {e}') + finally: + keeper_auth_context.close() + + +if __name__ == '__main__': + main() diff --git a/examples/sdk_examples/enterprise_team/team_remove_role.py b/examples/sdk_examples/enterprise_team/team_remove_role.py new file mode 100644 index 00000000..9eebea51 --- /dev/null +++ b/examples/sdk_examples/enterprise_team/team_remove_role.py @@ -0,0 +1,575 @@ +import getpass +import sqlite3 +import json +import logging +from typing import Dict, Optional + +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.enterprise import ( + batch_management, + enterprise_loader, + enterprise_management, + enterprise_user_management, + sqlite_enterprise_storage, +) + + +try: + import pyperclip +except ImportError: + pyperclip = None + +logger = utils.get_logger() +logger.setLevel(logging.INFO) +if not logger.handlers: + _handler = logging.StreamHandler() + _handler.setLevel(logging.INFO) + _handler.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") + ) + logger.addHandler(_handler) + + +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 + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + 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.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(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: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + 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.")) + 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.")) + except Exception: + 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 = [ + "", + "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 use option 't' to enter the token manually."), + '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 == "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: + 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 = 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}") + 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 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, + 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. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + + +def find_team(enterprise_data, team_name_or_uid: str): + search = team_name_or_uid.strip() + for team in enterprise_data.teams.get_all_entities(): + team_name = team.name if team.name else '' + if search.lower() in team_name.lower() or search == team.team_uid: + return team + return None + + +def find_user_by_email(enterprise_data, email: str): + email_lower = email.strip().lower() + for user in enterprise_data.users.get_all_entities(): + if user.username.lower() == email_lower: + return user + return None + + +def load_enterprise(keeper_auth_context): + if not keeper_auth_context.auth_context.is_enterprise_admin: + raise RuntimeError('This operation requires enterprise admin privileges.') + 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() + return loader + + +def main(): + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + + # Fill in your values here (enterprise admin required). + team_name_or_uid = '' # team name or UID + role_name_or_id = '' # role name or ID + + try: + loader = load_enterprise(keeper_auth_context) + enterprise_data = loader.enterprise_data + + team = find_team(enterprise_data, team_name_or_uid) + if not team: + print(f'Team not found: {team_name_or_uid}') + return + role = enterprise_user_management.resolve_role(enterprise_data, role_name_or_id) + + existing_roles = { + x.role_id + for x in enterprise_data.role_teams.get_links_by_object(team.team_uid) + } + if role.role_id not in existing_roles: + print(f"Role '{role.name}' is not assigned to team '{team.name}'") + return + + batch = batch_management.BatchManagement(loader=loader) + batch.modify_role_teams(to_remove=[ + enterprise_management.RoleTeamEdit(role_id=role.role_id, team_uid=team.team_uid) + ]) + batch.apply() + print(f"Removed role '{role.name}' from team '{team.name}'") + except Exception as e: + print(f'Error removing role from team: {e}') + finally: + keeper_auth_context.close() + + +if __name__ == '__main__': + main() diff --git a/examples/sdk_examples/enterprise_team/team_remove_user.py b/examples/sdk_examples/enterprise_team/team_remove_user.py new file mode 100644 index 00000000..6c919e95 --- /dev/null +++ b/examples/sdk_examples/enterprise_team/team_remove_user.py @@ -0,0 +1,570 @@ +import getpass +import sqlite3 +import json +import logging +from typing import Dict, Optional + +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.enterprise import ( + enterprise_loader, + enterprise_user_management, + sqlite_enterprise_storage, +) + + +try: + import pyperclip +except ImportError: + pyperclip = None + +logger = utils.get_logger() +logger.setLevel(logging.INFO) +if not logger.handlers: + _handler = logging.StreamHandler() + _handler.setLevel(logging.INFO) + _handler.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") + ) + logger.addHandler(_handler) + + +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 + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + 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.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(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: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + 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.")) + 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.")) + except Exception: + 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 = [ + "", + "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 use option 't' to enter the token manually."), + '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 == "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: + 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 = 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}") + 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 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, + 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. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + + +def find_team(enterprise_data, team_name_or_uid: str): + search = team_name_or_uid.strip() + for team in enterprise_data.teams.get_all_entities(): + team_name = team.name if team.name else '' + if search.lower() in team_name.lower() or search == team.team_uid: + return team + return None + + +def find_user_by_email(enterprise_data, email: str): + email_lower = email.strip().lower() + for user in enterprise_data.users.get_all_entities(): + if user.username.lower() == email_lower: + return user + return None + + +def load_enterprise(keeper_auth_context): + if not keeper_auth_context.auth_context.is_enterprise_admin: + raise RuntimeError('This operation requires enterprise admin privileges.') + 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() + return loader + + +def main(): + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + + # Fill in your values here (enterprise admin required). + team_name_or_uid = '' # team name or UID + user_email = '' # user email + + try: + loader = load_enterprise(keeper_auth_context) + enterprise_data = loader.enterprise_data + + team = find_team(enterprise_data, team_name_or_uid) + if not team: + print(f'Team not found: {team_name_or_uid}') + return + user = find_user_by_email(enterprise_data, user_email) + if not user: + print(f'User not found: {user_email}') + return + + result = enterprise_user_management.remove_users_from_teams( + loader, + user_ids=[user.enterprise_user_id], + team_uids={team.team_uid}, + ) + print(result.message or 'Done') + if result.removed_count: + print(f"Removed user '{user.username}' from team '{team.name}'") + except Exception as e: + print(f'Error removing user from team: {e}') + finally: + keeper_auth_context.close() + + +if __name__ == '__main__': + main() diff --git a/keepercli-package/src/keepercli/commands/enterprise_team.py b/keepercli-package/src/keepercli/commands/enterprise_team.py index f450d802..27e58fc3 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_team.py +++ b/keepercli-package/src/keepercli/commands/enterprise_team.py @@ -13,6 +13,182 @@ logger = api.get_logger() +def _add_team_membership_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument('-au', '--add-user', action='append', help='add user to team') + parser.add_argument('-ru', '--remove-user', action='append', help='remove user from team. @all') + parser.add_argument('-ar', '--add-role', action='append', help='add role to team') + parser.add_argument('-rr', '--remove-role', action='append', help='remove role from team. @all') + parser.add_argument( + '-hsf', '--hide-shared-folders', dest='hide_shared_folders', action='store', + choices=['on', 'off'], help='User does not see shared folders. --add-user only', + ) + + +def _validate_add_edit_membership(kwargs: Dict[str, Any], *, has_queued_teams: bool = False) -> None: + remove_users = kwargs.get('remove_user') + if isinstance(remove_users, list) and any(x == '@all' for x in remove_users): + raise base.CommandError( + '@all is not supported on enterprise-team add/edit. ' + 'Use enterprise-team membership with -ru @all.') + remove_roles = kwargs.get('remove_role') + if isinstance(remove_roles, list) and any(x == '@all' for x in remove_roles): + raise base.CommandError( + '@all is not supported on enterprise-team add/edit. ' + 'Use enterprise-team membership with -rr @all.') + if has_queued_teams and (kwargs.get('add_user') or kwargs.get('remove_user')): + raise base.CommandError( + 'User membership changes are not supported when adding queued teams. ' + 'Use enterprise-team membership.') + + +def _has_membership_changes(kwargs: Dict[str, Any]) -> bool: + return any(( + kwargs.get('add_user'), + kwargs.get('remove_user'), + kwargs.get('add_role'), + kwargs.get('remove_role'), + )) + + +class _TeamMembershipTarget: + __slots__ = ('team_uid', 'name', 'queued') + + def __init__(self, team_uid: str, name: str, *, queued: bool = False) -> None: + self.team_uid = team_uid + self.name = name + self.queued = queued + + @classmethod + def from_team(cls, team: enterprise_types.Team) -> '_TeamMembershipTarget': + return cls(team.team_uid, team.name or '') + + @classmethod + def from_queued_team(cls, team: enterprise_types.QueuedTeam) -> '_TeamMembershipTarget': + return cls(team.team_uid, team.name or '', queued=True) + + @classmethod + def from_team_edit(cls, team: enterprise_management.TeamEdit) -> '_TeamMembershipTarget': + return cls(team.team_uid, team.name or '') + + +def _queue_team_membership_changes( + batch: batch_management.BatchManagement, + context: KeeperParams, + logger: enterprise_management.IEnterpriseManagementLogger, + kwargs: Dict[str, Any], + active_teams: List[_TeamMembershipTarget], + queued_teams: Optional[List[_TeamMembershipTarget]] = None, + *, + users_only: bool = False, +) -> None: + users_to_add: Optional[List[enterprise_types.User]] = None + roles_to_add: Optional[List[enterprise_types.Role]] = None + users_to_remove: Optional[List[enterprise_types.User]] = None + roles_to_remove: Optional[List[enterprise_types.Role]] = None + has_remove_all_users = False + has_remove_all_roles = False + + add_users = kwargs.get('add_user') + if isinstance(add_users, list): + users_to_add = enterprise_utils.UserUtils.resolve_existing_users( + context.enterprise_data, add_users) + add_roles = kwargs.get('add_role') + if not users_only and isinstance(add_roles, list): + resolved_roles = enterprise_utils.RoleUtils.resolve_existing_roles( + context.enterprise_data, add_roles) + roles_to_add = [] + for role in resolved_roles: + if enterprise_utils.RoleUtils.is_admin_role(context.enterprise_data, role.role_id): + logger.warning( + 'Teams cannot be assigned to roles with administrative permissions.') + else: + roles_to_add.append(role) + if not roles_to_add: + roles_to_add = None + remove_users = kwargs.get('remove_user') + if isinstance(remove_users, list): + has_remove_all_users = not users_only and any(x == '@all' for x in remove_users) + if not has_remove_all_users: + users_to_remove = enterprise_utils.UserUtils.resolve_existing_users( + context.enterprise_data, remove_users) + remove_roles = kwargs.get('remove_role') + if not users_only and isinstance(remove_roles, list): + has_remove_all_roles = any(x == '@all' for x in remove_roles) + if not has_remove_all_roles: + roles_to_remove = enterprise_utils.RoleUtils.resolve_existing_roles( + context.enterprise_data, remove_roles) + + user_type = enterprise_management.team_user_type_from_hsf_flag(kwargs.get('hide_shared_folders')) + + for team in active_teams: + existing_users = { + x.enterprise_user_id + for x in context.enterprise_data.team_users.get_links_by_subject(team.team_uid) + } + existing_roles = { + x.role_id + for x in context.enterprise_data.role_teams.get_links_by_object(team.team_uid) + } + if users_to_add: + for user in users_to_add: + if user.enterprise_user_id in existing_users: + if user_type is None: + logger.warning( + 'User \"%s\" is already a member of team \"%s\"', + user.username, team.name) + continue + batch.modify_team_users(to_add=[enterprise_management.TeamUserEdit( + team_uid=team.team_uid, + enterprise_user_id=user.enterprise_user_id, + user_type=user_type)]) + if roles_to_add: + team_roles_to_add = [x for x in roles_to_add if x.role_id not in existing_roles] + if team_roles_to_add: + batch.modify_role_teams(to_add=[enterprise_management.RoleTeamEdit( + role_id=x.role_id, team_uid=team.team_uid) for x in team_roles_to_add]) + if has_remove_all_users: + batch.modify_team_users(to_remove=[enterprise_management.TeamUserEdit( + team_uid=team.team_uid, enterprise_user_id=x) for x in existing_users]) + elif users_to_remove: + batch.modify_team_users(to_remove=[enterprise_management.TeamUserEdit( + team_uid=team.team_uid, enterprise_user_id=x.enterprise_user_id) + for x in users_to_remove]) + if has_remove_all_roles: + batch.modify_role_teams(to_remove=[enterprise_management.RoleTeamEdit( + role_id=x, team_uid=team.team_uid) for x in existing_roles]) + elif roles_to_remove: + batch.modify_role_teams(to_remove=[enterprise_management.RoleTeamEdit( + role_id=x.role_id, team_uid=team.team_uid) for x in roles_to_remove]) + + if users_only: + return + + for team in queued_teams or []: + existing_users = { + x.enterprise_user_id + for x in context.enterprise_data.queued_team_users.get_links_by_subject(team.team_uid) + } + if users_to_add: + for user in users_to_add: + if user.enterprise_user_id in existing_users: + if user_type is None: + logger.warning( + 'User \"%s\" is already queued for team \"%s\"', + user.username, team.name) + continue + batch.modify_team_users(to_add=[enterprise_management.TeamUserEdit( + team_uid=team.team_uid, + enterprise_user_id=user.enterprise_user_id, + user_type=user_type)]) + if has_remove_all_users: + batch.modify_team_users(to_remove=[enterprise_management.TeamUserEdit( + team_uid=team.team_uid, enterprise_user_id=x) for x in existing_users]) + elif users_to_remove: + batch.modify_team_users(to_remove=[enterprise_management.TeamUserEdit( + team_uid=team.team_uid, enterprise_user_id=x.enterprise_user_id) + for x in users_to_remove]) + + class EnterpriseTeamCommand(base.GroupCommand): def __init__(self): super().__init__('Manage an enterprise team(s)') @@ -143,6 +319,7 @@ def __init__(self): 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') + _add_team_membership_arguments(parser) parser.add_argument('team', type=str, nargs='+', help='Team Name or Queued Team UID. Can be repeated.') super().__init__(parser) self.logger = api.get_logger() @@ -203,11 +380,13 @@ def execute(self, context: KeeperParams, **kwargs) -> None: restrict_view = r_view == 'on' batch = batch_management.BatchManagement(loader=context.enterprise_loader, logger=self) + new_team_edits: List[enterprise_management.TeamEdit] = [] if team_names: teams_to_add = [enterprise_management.TeamEdit( team_uid=utils.generate_uid(), name=x, node_id=parent_id, restrict_edit=restrict_edit, restrict_share=restrict_share, restrict_view=restrict_view) for x in team_names.values()] + new_team_edits = teams_to_add batch.modify_teams(to_add=teams_to_add) if queued_teams: @@ -217,6 +396,21 @@ def execute(self, context: KeeperParams, **kwargs) -> None: for x in queued_teams] batch.modify_teams(to_add=teams_to_add) + if _has_membership_changes(kwargs): + _validate_add_edit_membership(kwargs, has_queued_teams=bool(queued_teams)) + membership_targets = [ + _TeamMembershipTarget.from_team_edit(x) for x in new_team_edits + ] + if queued_teams: + membership_targets.extend( + _TeamMembershipTarget.from_team_edit(enterprise_management.TeamEdit( + team_uid=x.team_uid, name=x.name)) + for x in queued_teams + ) + _queue_team_membership_changes( + batch, context, self, kwargs, membership_targets, + ) + batch.apply() class EnterpriseTeamEditCommand(base.ArgparseCommand, enterprise_management.IEnterpriseManagementLogger): @@ -232,6 +426,7 @@ def __init__(self): 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') + _add_team_membership_arguments(parser) parser.add_argument('team', type=str, nargs='+', help='Team Name or UID. Can be repeated.') super().__init__(parser) self.logger = api.get_logger() @@ -281,6 +476,12 @@ def execute(self, context: KeeperParams, **kwargs) -> None: batch = batch_management.BatchManagement(loader=context.enterprise_loader, logger=self) batch.modify_teams(to_update=teams_to_edit) + if _has_membership_changes(kwargs): + _validate_add_edit_membership(kwargs) + _queue_team_membership_changes( + batch, context, self, kwargs, + [_TeamMembershipTarget.from_team(x) for x in team_list], + ) batch.apply() @@ -309,10 +510,7 @@ def execute(self, context: KeeperParams, **kwargs) -> None: class EnterpriseTeamMembershipCommand(base.ArgparseCommand, enterprise_management.IEnterpriseManagementLogger): def __init__(self): parser = argparse.ArgumentParser(prog='enterprise-team membership', description='Manage enterprise team membership.') - parser.add_argument('-au', '--add-user', action='append', help='add user to team') - parser.add_argument('-ru', '--remove-user', action='append', help='remove user from team. @all') - parser.add_argument('-ar', '--add-role', action='append', help='add user to team') - parser.add_argument('-rr', '--remove-role', action='append', help='remove user from team, @all') + _add_team_membership_arguments(parser) parser.add_argument('team', type=str, nargs='+', help='Team Name or UID. Can be repeated.') super().__init__(parser) self.logger = api.get_logger() @@ -323,81 +521,29 @@ def warning(self, message: str) -> None: def execute(self, context: KeeperParams, **kwargs) -> None: base.require_enterprise_admin(context) - team_list, missing_names = enterprise_utils.TeamUtils.resolve_existing_teams(context.enterprise_data, kwargs.get('team')) + if not _has_membership_changes(kwargs): + raise base.CommandError( + 'No membership changes specified. Use -au/--add-user, -ru/--remove-user, ' + '-ar/--add-role, or -rr/--remove-role.') + + team_list, missing_names = enterprise_utils.TeamUtils.resolve_existing_teams( + context.enterprise_data, kwargs.get('team')) queued_team_list: List[enterprise_types.QueuedTeam] if missing_names: - queued_team_list, missing_names = enterprise_utils.TeamUtils.resolve_queued_teams(context.enterprise_data, missing_names) + queued_team_list, missing_names = enterprise_utils.TeamUtils.resolve_queued_teams( + context.enterprise_data, missing_names) else: queued_team_list = [] if isinstance(missing_names, list) and len(missing_names) > 0: mn = ', '.join((str(x) for x in missing_names)) raise base.CommandError(f'Team name(s) \"{mn}\" could not be resolved') - users_to_add: Optional[List[enterprise_types.User]] = None - roles_to_add: Optional[List[enterprise_types.Role]] = None - users_to_remove: Optional[List[enterprise_types.User]] = None - roles_to_remove: Optional[List[enterprise_types.Role]] = None - has_remove_all_users: bool = False - has_remove_all_roles: bool = False - - add_users = kwargs.get('add_user') - if isinstance(add_users, list): - users_to_add = enterprise_utils.UserUtils.resolve_existing_users(context.enterprise_data, add_users) - add_roles = kwargs.get('add_role') - if isinstance(add_roles, list): - roles_to_add = enterprise_utils.RoleUtils.resolve_existing_roles(context.enterprise_data, add_roles) - remove_users = kwargs.get('remove_user') - if isinstance(remove_users, list): - has_remove_all_users = any((True for x in remove_users if x == '@all')) - if not has_remove_all_users: - users_to_remove = enterprise_utils.UserUtils.resolve_existing_users(context.enterprise_data, remove_users) - remove_roles = kwargs.get('remove_role') - if isinstance(remove_roles, list): - has_remove_all_roles = any((True for x in remove_roles if x == '@all')) - if not has_remove_all_roles: - roles_to_remove = enterprise_utils.RoleUtils.resolve_existing_roles(context.enterprise_data, remove_roles) - batch = batch_management.BatchManagement(loader=context.enterprise_loader, logger=self) - for team in team_list: - existing_users = {x.enterprise_user_id for x in context.enterprise_data.team_users.get_links_by_subject(team.team_uid)} - existing_roles = {x.role_id for x in context.enterprise_data.role_teams.get_links_by_object(team.team_uid)} - if users_to_add: - users_to_add = [x for x in users_to_add if x.enterprise_user_id not in existing_users] - if users_to_add: - batch.modify_team_users(to_add=[enterprise_management.TeamUserEdit( - team_uid=team.team_uid, enterprise_user_id=x.enterprise_user_id) for x in users_to_add]) - if roles_to_add: - roles_to_add = [x for x in roles_to_add if x.role_id not in existing_roles] - if roles_to_add: - batch.modify_role_teams(to_add=[enterprise_management.RoleTeamEdit( - role_id=x.role_id, team_uid=team.team_uid) for x in roles_to_add]) - if has_remove_all_users: - batch.modify_team_users(to_remove=[enterprise_management.TeamUserEdit( - team_uid=team.team_uid, enterprise_user_id=x) for x in existing_users]) - elif users_to_remove: - batch.modify_team_users(to_remove=[enterprise_management.TeamUserEdit( - team_uid=team.team_uid, enterprise_user_id=x.enterprise_user_id) for x in users_to_remove]) - if has_remove_all_roles: - batch.modify_role_teams(to_remove=[enterprise_management.RoleTeamEdit( - role_id=x, team_uid=team.team_uid) for x in existing_roles]) - elif roles_to_remove: - batch.modify_role_teams(to_remove=[enterprise_management.RoleTeamEdit( - role_id=x.role_id, team_uid=team.team_uid) for x in roles_to_remove]) - - for queued_team in queued_team_list: - existing_users = {x.enterprise_user_id for x in context.enterprise_data.queued_team_users.get_links_by_subject(queued_team.team_uid)} - if users_to_add: - users_to_add = [x for x in users_to_add if x.enterprise_user_id not in existing_users] - if users_to_add: - batch.modify_team_users(to_add=[enterprise_management.TeamUserEdit( - team_uid=queued_team.team_uid, enterprise_user_id=x.enterprise_user_id) for x in users_to_add]) - if has_remove_all_users: - batch.modify_team_users(to_remove=[enterprise_management.TeamUserEdit( - team_uid=queued_team.team_uid, enterprise_user_id=x) for x in existing_users]) - elif users_to_remove: - batch.modify_team_users(to_remove=[enterprise_management.TeamUserEdit( - team_uid=queued_team.team_uid, enterprise_user_id=x.enterprise_user_id) for x in users_to_remove]) - + _queue_team_membership_changes( + batch, context, self, kwargs, + [_TeamMembershipTarget.from_team(x) for x in team_list], + [_TeamMembershipTarget.from_queued_team(x) for x in queued_team_list], + ) batch.apply() diff --git a/keepercli-package/src/keepercli/commands/enterprise_user.py b/keepercli-package/src/keepercli/commands/enterprise_user.py index 1c77a374..16fbd01b 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_user.py +++ b/keepercli-package/src/keepercli/commands/enterprise_user.py @@ -624,10 +624,8 @@ def execute(self, context: KeeperParams, **kwargs) -> None: 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 + hide_shared_folders = hsf == 'on' + user_type = enterprise_management.team_user_type_from_hide_shared_folders(hide_shared_folders) for user in users: for team_uid in teams_to_add: team_membership_to_add.append(enterprise_management.TeamUserEdit( diff --git a/keepercli-package/src/keepercli/commands/enterprise_utils.py b/keepercli-package/src/keepercli/commands/enterprise_utils.py index 5050fe6a..6226038d 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_utils.py +++ b/keepercli-package/src/keepercli/commands/enterprise_utils.py @@ -189,6 +189,10 @@ def resolve_single_role(e_data: enterprise_types.IEnterpriseData, role_name: Any raise base.CommandError(f'Role name \"{role_name}\" does not exist') return role + @staticmethod + def is_admin_role(e_data: enterprise_types.IEnterpriseData, role_id: int) -> bool: + return any(e_data.managed_nodes.get_links_by_subject(role_id)) + @staticmethod def enforcement_value_from_file(filepath: str) -> str: diff --git a/keepersdk-package/src/keepersdk/enterprise/batch_management.py b/keepersdk-package/src/keepersdk/enterprise/batch_management.py index 81d95a68..8e253560 100644 --- a/keepersdk-package/src/keepersdk/enterprise/batch_management.py +++ b/keepersdk-package/src/keepersdk/enterprise/batch_management.py @@ -31,7 +31,7 @@ class EntityAction(int, enum.Enum): class BatchManagement(enterprise_management.IEnterpriseManagement): def __init__(self, loader: enterprise_types.IEnterpriseLoader, - logger: enterprise_management.IEnterpriseManagementLogger): + logger: Optional[enterprise_management.IEnterpriseManagementLogger] = None): self.loader = loader self.logger = logger or _NilLogger() self._record_types: Optional[Dict[str, Tuple[int, record_pb2.RecordTypeScope]]] = None @@ -911,34 +911,47 @@ def _to_team_user_requests(self) -> Tuple[List[Dict[str, Any]], List[Dict[str, A if not t and not qt: raise Exception('team not found') if u.status == 'active' and t: - team_keys: Optional[keeper_auth.UserKeys] - if self._team_keys and team_user.team_uid in self._team_keys: - team_keys = self._team_keys[team_user.team_uid] + is_member = enterprise_data.team_users.get_link( + team_user.team_uid, team_user.enterprise_user_id) is not None + user_type = team_user.user_type if team_user.user_type is not None else 0 + if is_member: + if team_user.user_type is None: + raise Exception('user is already a team member') + rq['command'] = 'team_enterprise_user_update' + rq['user_type'] = team_user.user_type else: - team_keys = self.loader.keeper_auth.get_team_keys(team_user.team_uid) - if not team_keys: - raise Exception('team key is not loaded') - if not team_keys.aes: - 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') - rq['command'] = 'team_enterprise_user_add' - rq['user_type'] = 0 - if self.loader.keeper_auth.auth_context.forbid_rsa: - if user_keys.ec: - ec_public_key = crypto.load_ec_public_key(user_keys.ec) - team_key = crypto.encrypt_ec(team_keys.aes, ec_public_key) - rq['team_key'] = utils.base64_url_encode(team_key) - rq['team_key_type'] = 'encrypted_by_public_key_ecc' - else: - if user_keys.rsa: - rsa_public_key = crypto.load_rsa_public_key(user_keys.rsa) - team_key = crypto.encrypt_rsa(team_keys.aes, rsa_public_key) - rq['team_key'] = utils.base64_url_encode(team_key) - rq['team_key_type'] = 'encrypted_by_public_key' + team_keys: Optional[keeper_auth.UserKeys] + if self._team_keys and team_user.team_uid in self._team_keys: + team_keys = self._team_keys[team_user.team_uid] + else: + team_keys = self.loader.keeper_auth.get_team_keys(team_user.team_uid) + if not team_keys: + raise Exception('team key is not loaded') + if not team_keys.aes: + 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') + rq['command'] = 'team_enterprise_user_add' + rq['user_type'] = user_type + if self.loader.keeper_auth.auth_context.forbid_rsa: + if user_keys.ec: + ec_public_key = crypto.load_ec_public_key(user_keys.ec) + team_key = crypto.encrypt_ec(team_keys.aes, ec_public_key) + rq['team_key'] = utils.base64_url_encode(team_key) + rq['team_key_type'] = 'encrypted_by_public_key_ecc' + else: + raise Exception('user does not have EC key') + else: + if user_keys.rsa: + rsa_public_key = crypto.load_rsa_public_key(user_keys.rsa) + team_key = crypto.encrypt_rsa(team_keys.aes, rsa_public_key) + rq['team_key'] = utils.base64_url_encode(team_key) + rq['team_key_type'] = 'encrypted_by_public_key' + else: + raise Exception('user does not have RSA key') else: rq['command'] = 'team_queue_user' elif action == EntityAction.Remove: @@ -1026,7 +1039,14 @@ def _to_role_team_requests(self) -> Tuple[List[enterprise_pb2.RoleTeam], List[en add_rt_requests: List[enterprise_pb2.RoleTeam] = [] remove_rt_requests: List[enterprise_pb2.RoleTeam] = [] if self._role_teams: + enterprise_data = self.loader.enterprise_data for action, role_team in self._role_teams.values(): + if action == EntityAction.Add: + is_admin_role = any(enterprise_data.managed_nodes.get_links_by_subject(role_team.role_id)) + if is_admin_role: + self.logger.warning( + 'Teams cannot be assigned to roles with administrative permissions.') + continue rqs = add_rt_requests if action == EntityAction.Add else remove_rt_requests rt = enterprise_pb2.RoleTeam() rt.role_id = role_team.role_id diff --git a/keepersdk-package/src/keepersdk/enterprise/enterprise_management.py b/keepersdk-package/src/keepersdk/enterprise/enterprise_management.py index 6cff5db2..1df0e10e 100644 --- a/keepersdk-package/src/keepersdk/enterprise/enterprise_management.py +++ b/keepersdk-package/src/keepersdk/enterprise/enterprise_management.py @@ -8,6 +8,18 @@ from . import enterprise_types +def team_user_type_from_hide_shared_folders(hide_shared_folders: Optional[bool]) -> Optional[int]: + if hide_shared_folders is None: + return None + return 2 if hide_shared_folders else 1 + + +def team_user_type_from_hsf_flag(hsf_flag: Optional[str]) -> Optional[int]: + if not hsf_flag: + return None + return 2 if hsf_flag == 'on' else 1 + + @attrs.define(kw_only=True) class NodeEdit: _node_id: int diff --git a/keepersdk-package/src/keepersdk/enterprise/enterprise_user_management.py b/keepersdk-package/src/keepersdk/enterprise/enterprise_user_management.py index 4757cf9b..bf8cb4cb 100644 --- a/keepersdk-package/src/keepersdk/enterprise/enterprise_user_management.py +++ b/keepersdk-package/src/keepersdk/enterprise/enterprise_user_management.py @@ -574,9 +574,9 @@ def add_users_to_teams( 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 + user_type = enterprise_management.team_user_type_from_hide_shared_folders( + hide_shared_folders if isinstance(hide_shared_folders, bool) else None + ) batch = batch_management.BatchManagement(loader=loader, logger=logger) From b74ebf5a00f68efac52387d29c8f0a1a87402f9d Mon Sep 17 00:00:00 2001 From: mtyagi-ks Date: Wed, 22 Jul 2026 16:49:13 +0530 Subject: [PATCH 4/7] Add NSF batch record creation with 1000-record support --- .../nested_shared_folders/nsf_record_add.py | 22 +- .../nsf_record_add_batch.py | 565 ++++++++++++++++++ .../src/keepercli/commands/nsf_commands.py | 122 +++- .../src/keepersdk/vault/nsf_management.py | 233 ++++++-- .../unit_tests/test_nsf_record_add_batch.py | 148 +++++ 5 files changed, 1013 insertions(+), 77 deletions(-) create mode 100644 examples/sdk_examples/nested_shared_folders/nsf_record_add_batch.py create mode 100644 keepersdk-package/unit_tests/test_nsf_record_add_batch.py diff --git a/examples/sdk_examples/nested_shared_folders/nsf_record_add.py b/examples/sdk_examples/nested_shared_folders/nsf_record_add.py index 2eaac9e1..9eea775b 100644 --- a/examples/sdk_examples/nested_shared_folders/nsf_record_add.py +++ b/examples/sdk_examples/nested_shared_folders/nsf_record_add.py @@ -511,22 +511,20 @@ def close_vault(vault: vault_online.VaultOnline, keeper_auth_context: keeper_aut keeper_auth_context.close() def nsf_record_add(vault: vault_online.VaultOnline) -> None: - """Add a record to NSF (nsf-record-add).""" - TITLE = "My NSF Login" - RECORD_TYPE = "login" # e.g. login, password, general - FOLDER_UID_OR_NAME = "Projects" # NSF folder name or UID; None for root - NOTES = "Created via SDK example" + """Add a single NSF record (nsf-record-add / create_nsf_record).""" + TITLE = 'My NSF Login' + RECORD_TYPE = 'login' + FOLDER = 'Projects' # NSF folder name or UID; set None for root + NOTES = 'Created via SDK example' FIELDS = { - "login": "user@example.com", - "password": "changeme", - "url": "https://example.com", + 'login': 'user@example.com', + 'password': 'changeme', + 'url': 'https://example.com', } folder_uid = None - if FOLDER_UID_OR_NAME: - folder_uid = nsf_management.resolve_nsf_folder_uid(vault, FOLDER_UID_OR_NAME) - if not folder_uid: - raise ValueError(f"NSF folder not found: {FOLDER_UID_OR_NAME}") + if FOLDER: + folder_uid = nsf_management.resolve_nsf_folder_uid(vault, FOLDER) or FOLDER result = nsf_management.create_nsf_record( vault, diff --git a/examples/sdk_examples/nested_shared_folders/nsf_record_add_batch.py b/examples/sdk_examples/nested_shared_folders/nsf_record_add_batch.py new file mode 100644 index 00000000..d7e9f6d7 --- /dev/null +++ b/examples/sdk_examples/nested_shared_folders/nsf_record_add_batch.py @@ -0,0 +1,565 @@ +import getpass +import json +import logging +import sqlite3 +from typing import Dict, Optional + +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 nsf_management, sqlite_storage, vault_online + +try: + import pyperclip +except ImportError: + pyperclip = None + +logger = utils.get_logger() +logger.setLevel(logging.INFO) +if not logger.handlers: + _handler = logging.StreamHandler() + _handler.setLevel(logging.INFO) + _handler.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") + ) + logger.addHandler(_handler) + + +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 + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + 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.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(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: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + 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.")) + 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.")) + except Exception: + 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 = [ + "", + "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 use option 't' to enter the token manually."), + '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 == "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: + 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 = 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}") + 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 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, + 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. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + + +def open_vault(keeper_auth_context: keeper_auth.KeeperAuth) -> vault_online.VaultOnline: + 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() + return vault + + +def close_vault(vault: vault_online.VaultOnline, keeper_auth_context: keeper_auth.KeeperAuth) -> None: + vault.close() + keeper_auth_context.close() + +def nsf_record_add_batch(vault: vault_online.VaultOnline) -> None: + """Batch record add execution (nsf-record-add --batch / create_nsf_records).""" + RECORDS = [ + { + 'title': 'My NSF Login', + 'record_type': 'login', + 'folder': 'Projects', # NSF folder name or UID; omit for root + 'notes': 'Created via SDK batch example', + 'fields': { + 'login': 'user@example.com', + 'password': 'changeme', + 'url': 'https://example.com', + }, + }, + { + 'title': 'My NSF Login 2', + 'record_type': 'login', + 'folder': 'Projects', + 'fields': { + 'login': 'user2@example.com', + 'password': 'changeme2', + }, + }, + ] + + results = nsf_management.create_nsf_records(vault, RECORDS) + for result in results: + if result.success: + print(f"NSF record created: {result.record_uid} (status: {result.status})") + else: + print(f"NSF record failed: {result.record_uid} ({result.message or result.status})") + + +def nsf_record_add_batch_run(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + vault = open_vault(keeper_auth_context) + try: + nsf_record_add_batch(vault) + except Exception as e: + print(f"Error: {e}") + finally: + close_vault(vault, keeper_auth_context) + + +def main() -> None: + keeper_auth_context, _ = login() + if keeper_auth_context: + nsf_record_add_batch_run(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/keepercli-package/src/keepercli/commands/nsf_commands.py b/keepercli-package/src/keepercli/commands/nsf_commands.py index efd3f0a9..4444c0cd 100644 --- a/keepercli-package/src/keepercli/commands/nsf_commands.py +++ b/keepercli-package/src/keepercli/commands/nsf_commands.py @@ -1,12 +1,13 @@ import argparse import json -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Mapping, Optional, Union from keepersdk.vault import nsf_folder_records, nsf_management, nsf_sharing, vault_record, nsf_common from keepersdk.vault.share_management_utils import parse_nsf_share_expiration from keepersdk.vault.nsf_management import ( NsfError, NsfListRow, + NsfRecordAddSpec, NsfRemovePreviewItem, NsfRemoveResult, ) @@ -156,6 +157,77 @@ def build_nsf_record_data( return _typed_record_to_data(record, title, notes) +def _build_batch_record_data( + mixin: _NsfRecordDataMixin, + context: KeeperParams, + record_type: str, + title: str, + notes: Optional[str], + raw_fields: Union[Mapping[str, Any], List[str], None], +) -> Optional[Dict[str, Any]]: + if raw_fields is None: + return None + if isinstance(raw_fields, Mapping): + return None + record_fields: List[ParsedFieldValue] = [] + for field in raw_fields: + if not isinstance(field, str): + raise base.CommandError('Batch record fields must be strings or a field object') + parsed = RecordEditMixin.parse_field(field) + if parsed.type == 'file': + raise base.CommandError( + 'File attachments are not supported in nsf-record-add batch mode') + record_fields.append(parsed) + return mixin.build_nsf_record_data(context, record_type, title, notes, record_fields) + + +def _load_nsf_record_add_batch_specs( + mixin: _NsfRecordDataMixin, + context: KeeperParams, + batch_path: str, +) -> List[NsfRecordAddSpec]: + try: + with open(batch_path, 'r', encoding='utf-8') as handle: + payload = json.load(handle) + except OSError as exc: + raise base.CommandError(f'Unable to read batch file: {exc}') from exc + except json.JSONDecodeError as exc: + raise base.CommandError(f'Invalid JSON in batch file: {exc}') from exc + + if not isinstance(payload, list): + raise base.CommandError('Batch file must contain a JSON array of record definitions') + if not payload: + raise base.CommandError('Batch file must contain at least one record definition') + + specs: List[NsfRecordAddSpec] = [] + for index, entry in enumerate(payload, start=1): + if not isinstance(entry, dict): + raise base.CommandError(f'Batch record #{index} must be a JSON object') + title = entry.get('title') + record_type = entry.get('record_type') or entry.get('type') + if not title: + raise base.CommandError(f'Batch record #{index} is missing title') + if not record_type: + raise base.CommandError(f'Batch record #{index} is missing record_type') + + notes = entry.get('notes') + raw_fields = entry.get('fields') + record_data = entry.get('record_data') + field_map = raw_fields if isinstance(raw_fields, Mapping) else None + if record_data is None and isinstance(raw_fields, list): + record_data = _build_batch_record_data( + mixin, context, record_type, title, notes, raw_fields) + specs.append(NsfRecordAddSpec( + title=title, + record_type=record_type, + folder_uid=entry.get('folder_uid') or entry.get('folder'), + fields=field_map, + notes=notes, + record_data=record_data, + )) + return specs + + class NsfListCommand(base.ArgparseCommand): def __init__(self): @@ -485,6 +557,10 @@ def add_arguments_to_parser(parser: argparse.ArgumentParser) -> None: parser.add_argument('-n', '--notes', dest='notes', type=str, help='record notes') parser.add_argument('--folder', dest='folder_uid', metavar='FOLDER', type=str, help='folder name or UID to store record') + parser.add_argument( + '--batch-file', dest='batch_file', metavar='FILE', type=str, + help='JSON file containing up to 1000 NSF records per API batch', + ) parser.add_argument('fields', nargs='*', type=str, help='load record type data from strings with dot notation') @@ -494,6 +570,10 @@ def execute(self, context: KeeperParams, **kwargs): prompt_utils.output_text(record_fields_description) return + batch_file = kwargs.get('batch_file') + if batch_file: + return self._execute_batch(context, vault, batch_file, kwargs.get('force') is True) + title = kwargs.get('title') if not title: raise base.CommandError('Title parameter is required.') @@ -547,6 +627,46 @@ def _run(): logger.info('NSF record created: %s', result.record_uid) return result.record_uid + def _execute_batch( + self, + context: KeeperParams, + vault, + batch_file: str, + force: bool) -> List[str]: + specs = _load_nsf_record_add_batch_specs(self, context, batch_file) + if self.warnings: + for w in self.warnings: + logger.warning(w) + if not force: + return [] + + def _run(): + return nsf_management.create_nsf_records(vault, specs) + + results = _wrap_nsf('nsf-record-add', _run) + created: List[str] = [] + failed = 0 + for result in results: + if result.success: + created.append(result.record_uid) + logger.info('NSF record created: %s (%s)', result.record_uid, result.status) + else: + failed += 1 + logger.warning( + 'NSF record add failed: %s (%s)', + result.record_uid, + result.message or result.status, + ) + logger.info( + 'NSF batch add complete: %d created, %d failed, %d total', + len(created), + failed, + len(results), + ) + if failed and not created: + raise base.CommandError('All NSF records in the batch failed to create') + return created + class NsfRecordUpdateCommand(base.ArgparseCommand, _NsfRecordDataMixin): diff --git a/keepersdk-package/src/keepersdk/vault/nsf_management.py b/keepersdk-package/src/keepersdk/vault/nsf_management.py index 090d11a1..e00efb88 100644 --- a/keepersdk-package/src/keepersdk/vault/nsf_management.py +++ b/keepersdk-package/src/keepersdk/vault/nsf_management.py @@ -3,7 +3,7 @@ import json import os from dataclasses import dataclass -from typing import Any, Dict, Iterable, List, Mapping, Optional +from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, Union from .. import crypto, utils from ..errors import KeeperApiError @@ -14,6 +14,9 @@ ROOT_FOLDER_UID = 'AAAAAAAAAAAAAAAAAPmtNA' """Sentinel UID the server uses for the NSF root folder.""" +NSF_RECORD_ADD_BATCH_LIMIT = 1000 +"""Maximum number of NSF records per ``vault/records/v3/add`` request.""" + class NsfError(ValueError): """Raised when NSF operations cannot proceed (missing cache, bad identifier, etc.).""" @@ -37,6 +40,17 @@ class NsfModifyResult: revision: int = 0 +@dataclass(frozen=True) +class NsfRecordAddSpec: + """Specification for creating one NSF record in a batch add request.""" + title: str + record_type: str + folder_uid: Optional[str] = None + fields: Optional[Mapping[str, Any]] = None + notes: Optional[str] = None + record_data: Optional[Mapping[str, Any]] = None + + @dataclass class NsfFolderModifyResult: folder_uid: str @@ -534,42 +548,157 @@ def _build_record_add_message( return ra -def _build_legacy_record_add_message( - record_uid: str, - record_key: bytes, - data: Dict[str, Any], - auth_data_key: bytes, - folder_uid: Optional[str], - folder_key: Optional[bytes]) -> record_pb2.RecordAdd: - ra = record_pb2.RecordAdd() - ra.record_uid = utils.base64_url_decode(record_uid) - ra.client_modified_time = utils.current_milli_time() - json_bytes = vault_extensions.get_padded_json_bytes(data) - if folder_uid and folder_key: - ra.folder_uid = utils.base64_url_decode(folder_uid) - ra.record_key = crypto.encrypt_aes_v2(record_key, folder_key) - else: - ra.record_key = crypto.encrypt_aes_v2(record_key, auth_data_key) - ra.data = crypto.encrypt_aes_v2(json_bytes, record_key) - return ra +def _normalize_record_add_spec( + spec: Union[NsfRecordAddSpec, Mapping[str, Any]]) -> NsfRecordAddSpec: + if isinstance(spec, NsfRecordAddSpec): + return spec + record_type = spec.get('record_type') or spec.get('type') + if not record_type: + raise NsfError('record_type is required for each batch record spec') + title = spec.get('title') + if not title: + raise NsfError('title is required for each batch record spec') + return NsfRecordAddSpec( + title=title, + record_type=record_type, + folder_uid=spec.get('folder_uid') or spec.get('folder'), + fields=spec.get('fields'), + notes=spec.get('notes'), + record_data=spec.get('record_data'), + ) + + +def _resolve_nsf_folder_for_add(vault: VaultOnline, folder_uid: str) -> str: + resolved = resolve_nsf_folder_uid(vault, folder_uid) or folder_uid + if not is_nsf_folder(vault, resolved): + raise NsfError(f'NSF folder not found: {folder_uid}') + return resolved def _parse_modify_response( response: record_pb2.RecordsModifyResponse, record_uid: str) -> NsfModifyResult: + results = _parse_batch_modify_response(response, [record_uid]) + return results[0] + + +def _parse_batch_modify_response( + response: record_pb2.RecordsModifyResponse, + record_uids: List[str]) -> List[NsfModifyResult]: if not response.records: raise KeeperApiError('no_results', 'No results from record modify response') - for row in response.records: - if utils.base64_url_encode(row.record_uid) == record_uid: - status_name = record_pb2.RecordModifyResult.Name(row.status) - return NsfModifyResult( - record_uid=record_uid, - success=row.status == record_pb2.RS_SUCCESS, - status=status_name, - message=row.message, - revision=getattr(response, 'revision', 0), - ) - raise KeeperApiError('no_results', f'Record {record_uid} not present in modify response') + if len(response.records) != len(record_uids): + raise KeeperApiError( + 'no_results', + f'Expected {len(record_uids)} record results, received {len(response.records)}') + revision = getattr(response, 'revision', 0) + results: List[NsfModifyResult] = [] + for idx, row in enumerate(response.records): + status_name = record_pb2.RecordModifyResult.Name(row.status) + results.append(NsfModifyResult( + record_uid=record_uids[idx], + success=row.status == record_pb2.RS_SUCCESS, + status=status_name, + message=row.message, + revision=revision, + )) + return results + + +def _prepare_nsf_record_add_messages( + vault: VaultOnline, + specs: List[NsfRecordAddSpec], + folder_key_cache: Optional[Dict[str, bytes]] = None, +) -> Tuple[List[str], List[record_endpoints_pb2.RecordAdd]]: + auth = vault.keeper_auth + data_key = auth.auth_context.data_key + cache = folder_key_cache if folder_key_cache is not None else {} + record_uids: List[str] = [] + messages: List[record_endpoints_pb2.RecordAdd] = [] + + for spec in specs: + folder_uid = None + if spec.folder_uid: + folder_uid = _resolve_nsf_folder_for_add(vault, spec.folder_uid) + data = _build_record_data( + spec.record_type, spec.title, spec.fields, spec.notes, spec.record_data) + record_uid = utils.generate_uid() + record_key = os.urandom(32) + folder_key = None + if folder_uid: + if folder_uid not in cache: + cache[folder_uid] = _get_folder_key(vault, folder_uid) + folder_key = cache[folder_uid] + messages.append(_build_record_add_message( + record_uid, record_key, data, data_key, folder_uid, folder_key)) + record_uids.append(record_uid) + return record_uids, messages + + +def _execute_nsf_records_add( + vault: VaultOnline, + record_adds: List[record_endpoints_pb2.RecordAdd], +) -> record_pb2.RecordsModifyResponse: + """Create NSF records via ``vault/records/v3/add`` endpoint.""" + if not record_adds or len(record_adds) > NSF_RECORD_ADD_BATCH_LIMIT: + raise ValueError(f'Provide 1..{NSF_RECORD_ADD_BATCH_LIMIT} records') + + auth = vault.keeper_auth + rq = record_endpoints_pb2.RecordsAddRequest() + rq.clientTime = utils.current_milli_time() + rq.records.extend(record_adds) + response = auth.execute_auth_rest( + 'vault/records/v3/add', rq, response_type=record_pb2.RecordsModifyResponse) + if response is None: + raise KeeperApiError('no_results', 'No results from NSF record add') + return response + + +def create_nsf_records_batch( + vault: VaultOnline, + record_specs: Iterable[Union[NsfRecordAddSpec, Mapping[str, Any]]], + *, + request_sync: bool = True, + folder_key_cache: Optional[Dict[str, bytes]] = None) -> List[NsfModifyResult]: + """Create up to 1000 NSF records in a single ``vault/records/v3/add`` request.""" + specs = [_normalize_record_add_spec(spec) for spec in record_specs] + if not specs: + raise ValueError('At least one record spec is required') + if len(specs) > NSF_RECORD_ADD_BATCH_LIMIT: + raise ValueError(f'Maximum {NSF_RECORD_ADD_BATCH_LIMIT} records at a time') + + record_uids, record_adds = _prepare_nsf_record_add_messages( + vault, specs, folder_key_cache=folder_key_cache) + response = _execute_nsf_records_add(vault, record_adds) + results = _parse_batch_modify_response(response, record_uids) + if request_sync: + vault.sync_requested = True + vault.run_pending_jobs() + return results + + +def create_nsf_records( + vault: VaultOnline, + record_specs: Iterable[Union[NsfRecordAddSpec, Mapping[str, Any]]], + *, + request_sync: bool = True) -> List[NsfModifyResult]: + """Create NSF records, chunking into batches of up to 1000 per API request.""" + specs = [_normalize_record_add_spec(spec) for spec in record_specs] + if not specs: + raise ValueError('At least one record spec is required') + + all_results: List[NsfModifyResult] = [] + folder_key_cache: Dict[str, bytes] = {} + for batch_start in range(0, len(specs), NSF_RECORD_ADD_BATCH_LIMIT): + batch = specs[batch_start:batch_start + NSF_RECORD_ADD_BATCH_LIMIT] + is_last = batch_start + len(batch) >= len(specs) + all_results.extend(create_nsf_records_batch( + vault, + batch, + request_sync=request_sync and is_last, + folder_key_cache=folder_key_cache, + )) + return all_results def create_nsf_record( @@ -583,42 +712,18 @@ def create_nsf_record( record_data: Optional[Mapping[str, Any]] = None, request_sync: bool = True) -> NsfModifyResult: """Create an NSF record.""" - if folder_uid: - resolved = resolve_nsf_folder_uid(vault, folder_uid) or folder_uid - if not is_nsf_folder(vault, resolved): - raise NsfError(f'NSF folder not found: {folder_uid}') - folder_uid = resolved - - data = _build_record_data(record_type, title, fields, notes, record_data) - record_uid = utils.generate_uid() - record_key = os.urandom(32) - auth = vault.keeper_auth - folder_key = _get_folder_key(vault, folder_uid) if folder_uid else None - - ra = _build_record_add_message( - record_uid, record_key, data, auth.auth_context.data_key, folder_uid, folder_key) - rq = record_endpoints_pb2.RecordsAddRequest() - rq.clientTime = utils.current_milli_time() - rq.records.append(ra) - - response = auth.execute_auth_rest( - 'vault/records/v3/add', rq, response_type=record_pb2.RecordsModifyResponse) - if response is None: - legacy_ra = _build_legacy_record_add_message( - record_uid, record_key, data, auth.auth_context.data_key, folder_uid, folder_key) - legacy_rq = record_pb2.RecordsAddRequest() - legacy_rq.client_time = utils.current_milli_time() - legacy_rq.records.append(legacy_ra) - response = auth.execute_auth_rest( - 'vault/records_add', legacy_rq, response_type=record_pb2.RecordsModifyResponse) - assert response is not None - - result = _parse_modify_response(response, record_uid) + spec = NsfRecordAddSpec( + title=title, + record_type=record_type, + folder_uid=folder_uid, + fields=fields, + notes=notes, + record_data=record_data, + ) + results = create_nsf_records_batch(vault, [spec], request_sync=request_sync) + result = results[0] if not result.success: raise KeeperApiError(result.status, result.message) - if request_sync: - vault.sync_requested = True - vault.run_pending_jobs() return result diff --git a/keepersdk-package/unit_tests/test_nsf_record_add_batch.py b/keepersdk-package/unit_tests/test_nsf_record_add_batch.py new file mode 100644 index 00000000..5a5b794a --- /dev/null +++ b/keepersdk-package/unit_tests/test_nsf_record_add_batch.py @@ -0,0 +1,148 @@ +import unittest +from typing import List +from unittest.mock import MagicMock, patch + +from keepersdk import utils +from keepersdk.errors import KeeperApiError +from keepersdk.proto import record_pb2 +from keepersdk.vault import nsf_management + + +class TestNsfRecordAddBatch(unittest.TestCase): + def _vault(self): + vault = MagicMock() + vault.keeper_auth.auth_context.data_key = b'0' * 32 + vault.keeper_auth.execute_auth_rest.return_value = None + return vault + + def _success_response(self, record_uids): + response = record_pb2.RecordsModifyResponse() + for uid in record_uids: + row = response.records.add() + row.record_uid = utils.base64_url_decode(uid) + row.status = record_pb2.RS_SUCCESS + row.message = '' + return response + + @patch.object(nsf_management, 'is_nsf_folder', return_value=True) + @patch.object(nsf_management, 'resolve_nsf_folder_uid', side_effect=lambda _v, uid: uid) + @patch.object(nsf_management, '_get_folder_key', return_value=b'1' * 32) + @patch.object( + nsf_management.utils, + 'generate_uid', + side_effect=[utils.generate_uid() for _ in range(5)], + ) + def test_create_nsf_records_batch_single_request(self, *_mocks): + vault = self._vault() + expected_uids: List[str] = [] + + def _execute(rest_endpoint, request, response_type=None): + expected_uids.extend( + utils.base64_url_encode(record.recordUid) for record in request.records) + return self._success_response(expected_uids) + + vault.keeper_auth.execute_auth_rest.side_effect = _execute + + specs = [ + {'title': f'Record {i}', 'record_type': 'login', 'fields': {'login': f'user{i}'}} + for i in range(3) + ] + results = nsf_management.create_nsf_records_batch( + vault, specs, request_sync=False) + + self.assertEqual(len(results), 3) + self.assertTrue(all(result.success for result in results)) + vault.keeper_auth.execute_auth_rest.assert_called_once() + request = vault.keeper_auth.execute_auth_rest.call_args.args[1] + self.assertEqual(len(request.records), 3) + + def test_create_nsf_records_batch_rejects_over_limit(self): + vault = self._vault() + specs = [ + {'title': f'Record {i}', 'record_type': 'login'} + for i in range(nsf_management.NSF_RECORD_ADD_BATCH_LIMIT + 1) + ] + with self.assertRaisesRegex(ValueError, '1000'): + nsf_management.create_nsf_records_batch(vault, specs, request_sync=False) + + @patch.object(nsf_management, 'is_nsf_folder', return_value=True) + @patch.object(nsf_management, 'resolve_nsf_folder_uid', side_effect=lambda _v, uid: uid) + @patch.object(nsf_management, '_get_folder_key', return_value=b'1' * 32) + @patch.object( + nsf_management.utils, + 'generate_uid', + side_effect=[utils.generate_uid() for _ in range(1001)], + ) + def test_create_nsf_records_chunks_large_input(self, generate_uid_mock, *_mocks): + vault = self._vault() + + call_count = {'value': 0} + + def _execute(rest_endpoint, request, response_type=None): + call_count['value'] += 1 + uids = [utils.base64_url_encode(record.recordUid) for record in request.records] + return self._success_response(uids) + + vault.keeper_auth.execute_auth_rest.side_effect = _execute + + specs = [ + {'title': f'Record {i}', 'record_type': 'login'} + for i in range(1001) + ] + results = nsf_management.create_nsf_records(vault, specs, request_sync=False) + + self.assertEqual(len(results), 1001) + self.assertEqual(call_count['value'], 2) + first_batch_size = vault.keeper_auth.execute_auth_rest.call_args_list[0].args[1].records + second_batch_size = vault.keeper_auth.execute_auth_rest.call_args_list[1].args[1].records + self.assertEqual(len(first_batch_size), 1000) + self.assertEqual(len(second_batch_size), 1) + + @patch.object(nsf_management, 'is_nsf_folder', return_value=True) + @patch.object(nsf_management, 'resolve_nsf_folder_uid', side_effect=lambda _v, uid: uid) + @patch.object(nsf_management, '_get_folder_key', return_value=b'1' * 32) + @patch.object(nsf_management.utils, 'generate_uid', return_value='AAAAAAAAAAAAAAAAAAAAAA') + def test_create_nsf_record_raises_on_failure(self, *_mocks): + vault = self._vault() + response = record_pb2.RecordsModifyResponse() + row = response.records.add() + row.record_uid = utils.base64_url_decode('AAAAAAAAAAAAAAAAAAAAAA') + row.status = record_pb2.RS_ACCESS_DENIED + row.message = 'denied' + vault.keeper_auth.execute_auth_rest.return_value = response + + with self.assertRaises(KeeperApiError): + nsf_management.create_nsf_record( + vault, + title='Test', + record_type='login', + request_sync=False, + ) + + @patch.object(nsf_management.utils, 'generate_uid', return_value='AAAAAAAAAAAAAAAAAAAAAA') + def test_v3_none_response_raises_no_results(self, *_mocks): + vault = self._vault() + vault.keeper_auth.execute_auth_rest.return_value = None + + with self.assertRaises(KeeperApiError) as ctx: + nsf_management.create_nsf_records_batch( + vault, + [{'title': 'Test', 'record_type': 'login'}], + request_sync=False, + ) + self.assertEqual(ctx.exception.result_code, 'no_results') + vault.keeper_auth.execute_auth_rest.assert_called_once() + self.assertEqual( + vault.keeper_auth.execute_auth_rest.call_args.args[0], + 'vault/records/v3/add', + ) + + def test_normalize_record_add_spec_requires_title_and_type(self): + with self.assertRaisesRegex(nsf_management.NsfError, 'title'): + nsf_management._normalize_record_add_spec({'record_type': 'login'}) + with self.assertRaisesRegex(nsf_management.NsfError, 'record_type'): + nsf_management._normalize_record_add_spec({'title': 'Test'}) + + +if __name__ == '__main__': + unittest.main() From c4df40d2a7f1ad12466ae06b56c497917721d685 Mon Sep 17 00:00:00 2001 From: Ivan Dimov <78815270+idimov-keeper@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:44:21 -0500 Subject: [PATCH 5/7] Fix NSF folder titles for team-shared folders. Mirror Web Vault decrypt for ENCRYPTED_BY_TEAM_KEY and folderAccesses team unwrap so sharees see real names instead of placeholders. Co-authored-by: Cursor --- .../src/keepersdk/vault/nsf_crypto.py | 162 +++++++++++--- .../src/keepersdk/vault/nsf_data.py | 10 +- .../src/keepersdk/vault/nsf_management.py | 3 +- .../src/keepersdk/vault/vault_data.py | 12 ++ .../src/keepersdk/vault/vault_online.py | 7 +- .../unit_tests/test_nsf_folder_key_decrypt.py | 198 ++++++++++++++++++ 6 files changed, 354 insertions(+), 38 deletions(-) create mode 100644 keepersdk-package/unit_tests/test_nsf_folder_key_decrypt.py diff --git a/keepersdk-package/src/keepersdk/vault/nsf_crypto.py b/keepersdk-package/src/keepersdk/vault/nsf_crypto.py index 43cd3e57..23530202 100644 --- a/keepersdk-package/src/keepersdk/vault/nsf_crypto.py +++ b/keepersdk-package/src/keepersdk/vault/nsf_crypto.py @@ -1,7 +1,8 @@ from __future__ import annotations import json -from typing import Dict, List, Optional +from dataclasses import dataclass +from typing import Any, Dict, List, Mapping, Optional from .. import crypto, utils from ..authentication import keeper_auth @@ -11,6 +12,15 @@ _FOLDER_KEY_ENCRYPTION = folder_pb2.FolderKeyEncryptionType _ENCRYPTED_KEY_TYPE = folder_pb2.EncryptedKeyType +_ACCESS_TYPE = folder_pb2.AccessType + + +@dataclass(frozen=True) +class TeamKeyMaterial: + """Decrypted team keys used to unwrap team-shared NSF folder keys.""" + team_key: bytes + rsa_private_key: Optional[Any] = None + ec_private_key: Optional[Any] = None def try_decrypt_symmetric(encrypted_key: bytes, symmetric_key: bytes) -> Optional[bytes]: @@ -42,30 +52,85 @@ def try_decrypt_with_user_keys(encrypted_key: bytes, auth_context: keeper_auth.A return None +def try_decrypt_with_typed_key( + encrypted_key: bytes, + key_type: int, + *, + aes_key: Optional[bytes] = None, + rsa_key: Optional[Any] = None, + ecc_key: Optional[Any] = None) -> Optional[bytes]: + """Decrypt using the algorithm indicated by *key_type* (Vault decryptFolderKeyByType).""" + try: + if key_type == int(_ENCRYPTED_KEY_TYPE.encrypted_by_data_key_gcm): + if aes_key is not None: + return crypto.decrypt_aes_v2(encrypted_key, aes_key) + elif key_type == int(_ENCRYPTED_KEY_TYPE.encrypted_by_data_key): + if aes_key is not None: + return crypto.decrypt_aes_v1(encrypted_key, aes_key) + elif key_type == int(_ENCRYPTED_KEY_TYPE.encrypted_by_public_key): + if rsa_key is not None: + return crypto.decrypt_rsa(encrypted_key, rsa_key) + elif key_type == int(_ENCRYPTED_KEY_TYPE.encrypted_by_public_key_ecc): + if ecc_key is not None: + return crypto.decrypt_ec(encrypted_key, ecc_key) + except Exception: + return None + return None + + def try_decrypt_from_folder_access( folder_uid: str, storage: INSFStorage, - auth_context: keeper_auth.AuthContext) -> Optional[bytes]: + auth_context: keeper_auth.AuthContext, + teams: Optional[Mapping[str, TeamKeyMaterial]] = None) -> Optional[bytes]: + """Unwrap folder key from folderAccesses (user or team), mirroring Web Vault.""" + teams = teams or {} for fa in storage.folder_accesses.get_links_by_subject(folder_uid): if not fa.folder_key_encrypted: continue try: enc_key = utils.base64_url_decode(fa.folder_key_encrypted) key_type = fa.folder_key_type - if key_type == int(_ENCRYPTED_KEY_TYPE.encrypted_by_data_key_gcm): - return crypto.decrypt_aes_v2(enc_key, auth_context.data_key) - if key_type == int(_ENCRYPTED_KEY_TYPE.encrypted_by_data_key): - return crypto.decrypt_aes_v1(enc_key, auth_context.data_key) - if key_type == int(_ENCRYPTED_KEY_TYPE.encrypted_by_public_key): - if auth_context.rsa_private_key is not None: - return crypto.decrypt_rsa(enc_key, auth_context.rsa_private_key) - elif key_type == int(_ENCRYPTED_KEY_TYPE.encrypted_by_public_key_ecc): - if auth_context.ec_private_key is not None: - return crypto.decrypt_ec(enc_key, auth_context.ec_private_key) - else: - result = try_decrypt_with_user_keys(enc_key, auth_context) - if result is not None: - return result + access_uid = fa.access_type_uid + use_team = ( + fa.access_type == int(_ACCESS_TYPE.AT_TEAM) + or (access_uid in teams) + ) + + folder_key: Optional[bytes] = None + if use_team and access_uid in teams: + team = teams[access_uid] + folder_key = try_decrypt_with_typed_key( + enc_key, key_type, + aes_key=team.team_key, + rsa_key=team.rsa_private_key, + ecc_key=team.ec_private_key, + ) + if folder_key is None: + folder_key = try_decrypt_symmetric(enc_key, team.team_key) + if folder_key is None and team.rsa_private_key is not None: + try: + folder_key = crypto.decrypt_rsa(enc_key, team.rsa_private_key) + except Exception: + pass + if folder_key is None and team.ec_private_key is not None: + try: + folder_key = crypto.decrypt_ec(enc_key, team.ec_private_key) + except Exception: + pass + + if folder_key is None: + folder_key = try_decrypt_with_typed_key( + enc_key, key_type, + aes_key=auth_context.data_key, + rsa_key=auth_context.rsa_private_key, + ecc_key=auth_context.ec_private_key, + ) + if folder_key is None: + folder_key = try_decrypt_with_user_keys(enc_key, auth_context) + + if folder_key is not None and len(folder_key) == 32: + return folder_key except Exception: continue return None @@ -74,18 +139,28 @@ def try_decrypt_from_folder_access( def try_decrypt_folder_key( fk: nsf.NSFFolderKey, auth_context: keeper_auth.AuthContext, - decrypted_folder_keys: Dict[str, bytes]) -> Optional[bytes]: + decrypted_folder_keys: Dict[str, bytes], + teams: Optional[Mapping[str, TeamKeyMaterial]] = None) -> Optional[bytes]: + """ + Attempt decrypt from a FolderKey link. + + Returns None for ENCRYPTED_BY_TEAM_KEY (caller must use folderAccesses). + For PARENT_KEY without a decrypted parent, returns None so caller can fall back. + """ + enc_key_type = fk.encrypted_by + if enc_key_type == int(_FOLDER_KEY_ENCRYPTION.ENCRYPTED_BY_TEAM_KEY): + return None # key lives in folderAccesses if not fk.folder_key: return None try: - enc_key_type = fk.encrypted_by encrypted_key = utils.base64_url_decode(fk.folder_key) if enc_key_type == int(_FOLDER_KEY_ENCRYPTION.ENCRYPTED_BY_USER_KEY): return try_decrypt_with_user_keys(encrypted_key, auth_context) if enc_key_type == int(_FOLDER_KEY_ENCRYPTION.ENCRYPTED_BY_PARENT_KEY): - if not fk.parent_uid: + parent_uid = fk.parent_uid + if not parent_uid: return None - parent_key = decrypted_folder_keys.get(fk.parent_uid) + parent_key = decrypted_folder_keys.get(parent_uid) if parent_key is None: return None return try_decrypt_symmetric(encrypted_key, parent_key) @@ -114,9 +189,31 @@ def try_decrypt_folder_entity_key( return None +def _folder_needs_access_fallback( + folder_uid: str, + keys_by_folder: Mapping[str, List[nsf.NSFFolderKey]], + decrypted_keys: Mapping[str, bytes]) -> bool: + """True when FolderKey links require folderAccesses (TEAM_KEY or failed PARENT/USER).""" + if folder_uid in decrypted_keys: + return False + for fk in keys_by_folder.get(folder_uid, []): + if fk.encrypted_by == int(_FOLDER_KEY_ENCRYPTION.ENCRYPTED_BY_TEAM_KEY): + return True + if fk.encrypted_by == int(_FOLDER_KEY_ENCRYPTION.ENCRYPTED_BY_PARENT_KEY): + parent_uid = fk.parent_uid + if not parent_uid or parent_uid not in decrypted_keys: + return True + if fk.encrypted_by == int(_FOLDER_KEY_ENCRYPTION.ENCRYPTED_BY_USER_KEY): + return True # USER_KEY already tried; fall back to accesses + return True + + def decrypt_folder_keys( storage: INSFStorage, - auth_context: keeper_auth.AuthContext) -> Dict[str, bytes]: + auth_context: keeper_auth.AuthContext, + teams: Optional[Mapping[str, TeamKeyMaterial]] = None) -> Dict[str, bytes]: + """Decrypt NSF folder keys. Pass *teams* for team-shared folder unwrap.""" + teams = teams or {} decrypted_keys: Dict[str, bytes] = {} keys_by_folder: Dict[str, List[nsf.NSFFolderKey]] = {} for fk in storage.folder_keys.get_all_links(): @@ -130,7 +227,7 @@ def decrypt_folder_keys( if folder_uid in decrypted_keys: continue for fk in folder_keys: - key = try_decrypt_folder_key(fk, auth_context, decrypted_keys) + key = try_decrypt_folder_key(fk, auth_context, decrypted_keys, teams) if key is not None: decrypted_keys[folder_uid] = key progress = True @@ -143,17 +240,16 @@ def decrypt_folder_keys( decrypted_keys[row.folder_uid] = key progress = True - for folder_uid in keys_by_folder: - if folder_uid not in decrypted_keys: - key = try_decrypt_from_folder_access(folder_uid, storage, auth_context) - if key is not None: - decrypted_keys[folder_uid] = key - - for row in storage.folders.get_all_entities(): - if row.folder_uid not in decrypted_keys: - key = try_decrypt_from_folder_access(row.folder_uid, storage, auth_context) - if key is not None: - decrypted_keys[row.folder_uid] = key + # folderAccesses fallback (TEAM_KEY, PARENT without parent, USER_KEY miss, bare accesses) + candidates = set(keys_by_folder.keys()) | {row.folder_uid for row in folder_rows} + for folder_uid in candidates: + if folder_uid in decrypted_keys: + continue + if not _folder_needs_access_fallback(folder_uid, keys_by_folder, decrypted_keys): + continue + key = try_decrypt_from_folder_access(folder_uid, storage, auth_context, teams) + if key is not None: + decrypted_keys[folder_uid] = key return decrypted_keys diff --git a/keepersdk-package/src/keepersdk/vault/nsf_data.py b/keepersdk-package/src/keepersdk/vault/nsf_data.py index 6e053fec..619beb73 100644 --- a/keepersdk-package/src/keepersdk/vault/nsf_data.py +++ b/keepersdk-package/src/keepersdk/vault/nsf_data.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Dict, Iterable, List, Optional, Set +from typing import Dict, Iterable, List, Mapping, Optional, Set from ..authentication import keeper_auth from . import nsf_crypto, nsf_storage_types as nsf @@ -103,13 +103,17 @@ def rebuild_data(self, changes: Optional[NSFRebuildTask] = None) -> None: del changes self.rebuild_nsf(self._auth_context) - def rebuild_nsf(self, auth_context: Optional[keeper_auth.AuthContext]) -> None: + def rebuild_nsf( + self, + auth_context: Optional[keeper_auth.AuthContext], + teams: Optional[Mapping[str, nsf_crypto.TeamKeyMaterial]] = None) -> None: self._folders.clear() self._records.clear() if auth_context is None: return - decrypted_folder_keys = nsf_crypto.decrypt_folder_keys(self._storage, auth_context) + decrypted_folder_keys = nsf_crypto.decrypt_folder_keys( + self._storage, auth_context, teams=teams) decrypted_record_keys = nsf_crypto.decrypt_record_keys( self._storage, decrypted_folder_keys, auth_context) diff --git a/keepersdk-package/src/keepersdk/vault/nsf_management.py b/keepersdk-package/src/keepersdk/vault/nsf_management.py index e00efb88..2c2492d0 100644 --- a/keepersdk-package/src/keepersdk/vault/nsf_management.py +++ b/keepersdk-package/src/keepersdk/vault/nsf_management.py @@ -479,7 +479,8 @@ def _get_folder_key(vault: VaultOnline, folder_uid: str) -> bytes: return folder.folder_key auth_context = vault.keeper_auth.auth_context - decrypted = nsf_crypto.decrypt_folder_keys(view.storage, auth_context) + teams = vault.vault_data.get_nsf_team_key_materials() + decrypted = nsf_crypto.decrypt_folder_keys(view.storage, auth_context, teams=teams) key = decrypted.get(folder_uid) if key is None: label = folder.name if folder and folder.name and folder.name != '(NSF Folder)' else folder_uid diff --git a/keepersdk-package/src/keepersdk/vault/vault_data.py b/keepersdk-package/src/keepersdk/vault/vault_data.py index 4e4a2851..8a117c2f 100644 --- a/keepersdk-package/src/keepersdk/vault/vault_data.py +++ b/keepersdk-package/src/keepersdk/vault/vault_data.py @@ -260,6 +260,18 @@ def get_team_key(self, team_uid: str) -> Optional[bytes]: if t: return t.team_key + def get_nsf_team_key_materials(self) -> Dict[str, 'nsf_crypto.TeamKeyMaterial']: + """Decrypted team keys for NSF folderAccesses unwrap (team-shared folders).""" + from . import nsf_crypto + return { + uid: nsf_crypto.TeamKeyMaterial( + team_key=t.team_key, + rsa_private_key=t.rsa_private_key, + ec_private_key=t.ec_private_key, + ) + for uid, t in self._teams.items() + } + def teams(self) -> Iterable[vault_types.TeamInfo]: return (x.info for x in self._teams.values()) diff --git a/keepersdk-package/src/keepersdk/vault/vault_online.py b/keepersdk-package/src/keepersdk/vault/vault_online.py index 6d2504da..42279c6c 100644 --- a/keepersdk-package/src/keepersdk/vault/vault_online.py +++ b/keepersdk-package/src/keepersdk/vault/vault_online.py @@ -116,7 +116,12 @@ def sync_down(self, force=False): self._sync_record_types = False self._vault_data.rebuild_data(result.vault) if self._nsf_data is not None: - self._nsf_data.rebuild_nsf(self._keeper_auth.auth_context) + # Teams are decrypted during vault rebuild; NSF team-shared folders + # need those keys to unwrap folderAccesses (ENCRYPTED_BY_TEAM_KEY). + self._nsf_data.rebuild_nsf( + self._keeper_auth.auth_context, + teams=self._vault_data.get_nsf_team_key_materials(), + ) def _background_task(self): if self._keeper_auth.auth_context.enterprise_ec_public_key: diff --git a/keepersdk-package/unit_tests/test_nsf_folder_key_decrypt.py b/keepersdk-package/unit_tests/test_nsf_folder_key_decrypt.py new file mode 100644 index 00000000..324994d1 --- /dev/null +++ b/keepersdk-package/unit_tests/test_nsf_folder_key_decrypt.py @@ -0,0 +1,198 @@ +"""Unit tests for NSF folder key / name decrypt (team + shared paths).""" + +import json +import unittest +from unittest.mock import Mock + +from keepersdk import crypto, utils +from keepersdk.proto import folder_pb2 +from keepersdk.vault import nsf_crypto, nsf_storage_types as nsf, memory_nsf_storage + + +def _auth(data_key=None): + ctx = Mock() + ctx.data_key = data_key or utils.generate_aes_key() + ctx.rsa_private_key = None + ctx.ec_private_key = None + return ctx + + +def _put_folder(storage, name, folder_key, parent_uid=''): + folder_uid = utils.generate_uid() + data_b64 = utils.base64_url_encode( + crypto.encrypt_aes_v2(json.dumps({'name': name}).encode('utf-8'), folder_key) + ) + storage.folders.put_entities([ + nsf.NSFFolder( + folder_uid=folder_uid, + parent_uid=parent_uid, + data=data_b64, + ), + ]) + return folder_uid + + +class TestNsfFolderKeyDecrypt(unittest.TestCase): + + def test_user_key_owner_path(self): + storage = memory_nsf_storage.InMemoryNSFStorage() + auth = _auth() + folder_key = utils.generate_aes_key() + folder_uid = _put_folder(storage, 'Owner Folder', folder_key) + storage.folder_keys.put_links([ + nsf.NSFFolderKey( + folder_uid=folder_uid, + parent_uid='', + folder_key=utils.base64_url_encode( + crypto.encrypt_aes_v2(folder_key, auth.data_key) + ), + encrypted_by=int(folder_pb2.ENCRYPTED_BY_USER_KEY), + ), + ]) + + keys = nsf_crypto.decrypt_folder_keys(storage, auth) + name = nsf_crypto.decrypt_folder_name( + storage.folders.get_entity(folder_uid).data, keys[folder_uid] + ) + self.assertEqual(name, 'Owner Folder') + + def test_team_key_via_folder_access(self): + storage = memory_nsf_storage.InMemoryNSFStorage() + auth = _auth() + team_uid = utils.generate_uid() + team_aes = utils.generate_aes_key() + folder_key = utils.generate_aes_key() + folder_uid = _put_folder(storage, 'Team Shared NSF', folder_key) + storage.folder_keys.put_links([ + nsf.NSFFolderKey( + folder_uid=folder_uid, + parent_uid='', + folder_key='', + encrypted_by=int(folder_pb2.ENCRYPTED_BY_TEAM_KEY), + ), + ]) + storage.folder_accesses.put_links([ + nsf.NSFFolderAccess( + folder_uid=folder_uid, + access_type_uid=team_uid, + access_type=int(folder_pb2.AT_TEAM), + folder_key_encrypted=utils.base64_url_encode( + crypto.encrypt_aes_v2(folder_key, team_aes) + ), + folder_key_type=int(folder_pb2.encrypted_by_data_key_gcm), + ), + ]) + teams = { + team_uid: nsf_crypto.TeamKeyMaterial(team_key=team_aes), + } + + keys = nsf_crypto.decrypt_folder_keys(storage, auth, teams=teams) + self.assertIn(folder_uid, keys) + name = nsf_crypto.decrypt_folder_name( + storage.folders.get_entity(folder_uid).data, keys[folder_uid] + ) + self.assertEqual(name, 'Team Shared NSF') + + def test_team_key_fails_without_team_materials(self): + storage = memory_nsf_storage.InMemoryNSFStorage() + auth = _auth() + team_uid = utils.generate_uid() + team_aes = utils.generate_aes_key() + folder_key = utils.generate_aes_key() + folder_uid = _put_folder(storage, 'Hidden', folder_key) + storage.folder_keys.put_links([ + nsf.NSFFolderKey( + folder_uid=folder_uid, + parent_uid='', + folder_key='', + encrypted_by=int(folder_pb2.ENCRYPTED_BY_TEAM_KEY), + ), + ]) + storage.folder_accesses.put_links([ + nsf.NSFFolderAccess( + folder_uid=folder_uid, + access_type_uid=team_uid, + access_type=int(folder_pb2.AT_TEAM), + folder_key_encrypted=utils.base64_url_encode( + crypto.encrypt_aes_v2(folder_key, team_aes) + ), + folder_key_type=int(folder_pb2.encrypted_by_data_key_gcm), + ), + ]) + + keys = nsf_crypto.decrypt_folder_keys(storage, auth, teams={}) + self.assertNotIn(folder_uid, keys) + + def test_parent_key_without_parent_falls_back_to_team_access(self): + storage = memory_nsf_storage.InMemoryNSFStorage() + auth = _auth() + team_uid = utils.generate_uid() + team_aes = utils.generate_aes_key() + folder_key = utils.generate_aes_key() + parent_uid = utils.generate_uid() + folder_uid = _put_folder(storage, 'Child Shared', folder_key, parent_uid=parent_uid) + storage.folder_keys.put_links([ + nsf.NSFFolderKey( + folder_uid=folder_uid, + parent_uid=parent_uid, + folder_key=utils.base64_url_encode( + crypto.encrypt_aes_v2(folder_key, utils.generate_aes_key()) + ), + encrypted_by=int(folder_pb2.ENCRYPTED_BY_PARENT_KEY), + ), + ]) + storage.folder_accesses.put_links([ + nsf.NSFFolderAccess( + folder_uid=folder_uid, + access_type_uid=team_uid, + access_type=int(folder_pb2.AT_TEAM), + folder_key_encrypted=utils.base64_url_encode( + crypto.encrypt_aes_v2(folder_key, team_aes) + ), + folder_key_type=int(folder_pb2.encrypted_by_data_key_gcm), + ), + ]) + teams = {team_uid: nsf_crypto.TeamKeyMaterial(team_key=team_aes)} + + keys = nsf_crypto.decrypt_folder_keys(storage, auth, teams=teams) + name = nsf_crypto.decrypt_folder_name( + storage.folders.get_entity(folder_uid).data, keys[folder_uid] + ) + self.assertEqual(name, 'Child Shared') + + def test_user_access_fallback(self): + storage = memory_nsf_storage.InMemoryNSFStorage() + auth = _auth() + folder_key = utils.generate_aes_key() + folder_uid = _put_folder(storage, 'Access Shared', folder_key) + storage.folder_keys.put_links([ + nsf.NSFFolderKey( + folder_uid=folder_uid, + parent_uid='', + folder_key=utils.base64_url_encode( + crypto.encrypt_aes_v2(folder_key, utils.generate_aes_key()) + ), + encrypted_by=int(folder_pb2.ENCRYPTED_BY_USER_KEY), + ), + ]) + storage.folder_accesses.put_links([ + nsf.NSFFolderAccess( + folder_uid=folder_uid, + access_type_uid=utils.generate_uid(), + access_type=int(folder_pb2.AT_USER), + folder_key_encrypted=utils.base64_url_encode( + crypto.encrypt_aes_v2(folder_key, auth.data_key) + ), + folder_key_type=int(folder_pb2.encrypted_by_data_key_gcm), + ), + ]) + + keys = nsf_crypto.decrypt_folder_keys(storage, auth) + name = nsf_crypto.decrypt_folder_name( + storage.folders.get_entity(folder_uid).data, keys[folder_uid] + ) + self.assertEqual(name, 'Access Shared') + + +if __name__ == '__main__': + unittest.main() From 480307c608fa33090d5aa8356f66c9a4758681d5 Mon Sep 17 00:00:00 2001 From: mtyagi-ks Date: Wed, 8 Jul 2026 20:41:35 +0530 Subject: [PATCH 6/7] Add enterprise team management SDK and wire CLI get commands (#208) * Add enterprise team management SDK and wire CLI get/list commands * added sdk examples for get and list commands * non member behavior addition and comment resolution * bug resolution * removed performace over head --- .../enterprise_team/enterprise_team_get.py | 589 ++++++++++++++++++ .../enterprise_team/enterprise_team_list.py | 561 +++++++++++++++++ .../src/keepercli/commands/enterprise_team.py | 81 ++- .../src/keepercli/commands/record_edit.py | 140 +++-- .../src/keepercli/commands/vault_record.py | 17 +- .../enterprise/enterprise_team_management.py | 587 +++++++++++++++++ .../test_enterprise_team_management.py | 252 ++++++++ 7 files changed, 2129 insertions(+), 98 deletions(-) create mode 100644 examples/sdk_examples/enterprise_team/enterprise_team_get.py create mode 100644 examples/sdk_examples/enterprise_team/enterprise_team_list.py create mode 100644 keepersdk-package/src/keepersdk/enterprise/enterprise_team_management.py create mode 100644 keepersdk-package/unit_tests/test_enterprise_team_management.py diff --git a/examples/sdk_examples/enterprise_team/enterprise_team_get.py b/examples/sdk_examples/enterprise_team/enterprise_team_get.py new file mode 100644 index 00000000..5239e1c7 --- /dev/null +++ b/examples/sdk_examples/enterprise_team/enterprise_team_get.py @@ -0,0 +1,589 @@ +import getpass +import sqlite3 +import json +import logging +from typing import Dict, Optional + +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.enterprise import enterprise_loader, enterprise_team_management, sqlite_enterprise_storage +from keepersdk.vault import sqlite_storage, vault_online + +try: + import pyperclip +except ImportError: + pyperclip = None + +logger = utils.get_logger() +logger.setLevel(logging.INFO) +if not logger.handlers: + _handler = logging.StreamHandler() + _handler.setLevel(logging.INFO) + _handler.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") + ) + logger.addHandler(_handler) + + +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 + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + 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.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(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: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + 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.")) + 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.")) + except Exception: + 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 = [ + "", + "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 use option 't' to enter the token manually."), + '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 == "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: + 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 = 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}") + 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 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, + 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. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + + +def _load_enterprise(keeper_auth_context: keeper_auth.KeeperAuth) -> enterprise_loader.EnterpriseLoader: + 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) + return enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + + +def _load_vault(keeper_auth_context: keeper_auth.KeeperAuth) -> vault_online.VaultOnline: + conn = sqlite3.Connection('file::memory:', uri=True) + storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, keeper_auth_context.auth_context.account_uid + ) + vault = vault_online.VaultOnline(keeper_auth_context, storage) + vault.sync_down() + return vault + + +def print_team_info(team_info: enterprise_team_management.EnterpriseTeamInfo) -> None: + print(f"\nTeam UID: {team_info.team_uid}") + print(f"Team Name: {team_info.team_name}") + if team_info.node_name: + print(f"Node: {team_info.node_name} [{team_info.node_id}]") + print(f"Access Level: {team_info.access_level}") + print(f"Restrict Edit: {team_info.restrict_edit}") + print(f"Restrict Share: {team_info.restrict_share}") + print(f"Restrict View: {team_info.restrict_view}") + + if team_info.team_roles: + print(f"Role(s): {', '.join(x.role_name for x in team_info.team_roles)}") + + if team_info.team_users: + print(f"User(s): {', '.join(x.username for x in team_info.team_users)}") + + if team_info.queued_team_users: + print(f"Queued User(s): {', '.join(x.username for x in team_info.queued_team_users)}") + + if team_info.members: + print(f"\n{'Enterprise User ID':<20} {'Email':<40} {'Share Admin':<12}") + print('-' * 74) + for member in team_info.members: + print( + f"{member.enterprise_user_id:<20} " + f"{member.email[:39]:<40} " + f"{'Yes' if member.is_share_admin else 'No':<12}" + ) + + +def main(): + keeper_auth_context, _ = login() + if not keeper_auth_context: + print('Login failed.') + return + + # Fill in your values here. + team_name_or_uid = '' + fetch_live_members = True + + is_admin = bool(keeper_auth_context.auth_context.is_enterprise_admin) + enterprise = None + vault = None + + try: + if is_admin: + enterprise = _load_enterprise(keeper_auth_context) + vault = _load_vault(keeper_auth_context) + + team_info = enterprise_team_management.get_team( + team_name_or_uid, + enterprise_data=enterprise.enterprise_data if enterprise else None, + vault_data_obj=vault.vault_data, + auth=keeper_auth_context, + vault=vault, + is_enterprise_admin=is_admin, + include_share_objects=True, + fetch_live_members=fetch_live_members, + ) + print_team_info(team_info) + except enterprise_team_management.EnterpriseTeamManagementError as exc: + print(f'Error getting team: {exc}') + except Exception as exc: + print(f'Error getting team: {exc}') + finally: + if enterprise is not None: + enterprise.close() + if vault is not None: + vault.close() + keeper_auth_context.close() + + +if __name__ == '__main__': + main() diff --git a/examples/sdk_examples/enterprise_team/enterprise_team_list.py b/examples/sdk_examples/enterprise_team/enterprise_team_list.py new file mode 100644 index 00000000..12a09058 --- /dev/null +++ b/examples/sdk_examples/enterprise_team/enterprise_team_list.py @@ -0,0 +1,561 @@ +import getpass +import sqlite3 +import json +import logging +from typing import Dict, Optional + +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.enterprise import enterprise_loader, enterprise_team_management, sqlite_enterprise_storage + +try: + import pyperclip +except ImportError: + pyperclip = None + +logger = utils.get_logger() +logger.setLevel(logging.INFO) +if not logger.handlers: + _handler = logging.StreamHandler() + _handler.setLevel(logging.INFO) + _handler.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") + ) + logger.addHandler(_handler) + + +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 + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + 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.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(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: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + 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.")) + 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.")) + except Exception: + 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 = [ + "", + "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 use option 't' to enter the token manually."), + '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 == "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: + 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 = 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}") + 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 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, + 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. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + + +def _load_enterprise(keeper_auth_context: keeper_auth.KeeperAuth) -> enterprise_loader.EnterpriseLoader: + 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) + return enterprise_loader.EnterpriseLoader(keeper_auth_context, enterprise_storage) + + +def print_teams_table(summaries) -> None: + if not summaries: + print('\nNo teams found.') + return + + print(f'\nEnterprise Teams ({len(summaries)} found)') + print('=' * 120) + print( + f"{'Team Name':<30} {'Team UID':<28} {'Node':<25} " + f"{'Users':<8} {'Roles':<8} {'Restricts':<12}" + ) + print('-' * 120) + for team in summaries: + restricts = ( + f"{'R' if team.restrict_view else '-'}" + f"{'W' if team.restrict_edit else '-'}" + f"{'S' if team.restrict_share else '-'}" + ) + print( + f"{team.team_name[:29]:<30} {team.team_uid[:27]:<28} " + f"{team.node_name[:24]:<25} {team.user_count:<8} {team.role_count:<8} {restricts:<12}" + ) + print('-' * 120) + + +def main(): + keeper_auth_context, _ = login() + if not keeper_auth_context: + print('Login failed.') + return + + if not keeper_auth_context.auth_context.is_enterprise_admin: + print('ERROR: This operation requires enterprise admin privileges.') + keeper_auth_context.close() + return + + # Fill in your values here. + pattern = '' # Optional filter, e.g. 'Testing Team' + + enterprise = None + try: + enterprise = _load_enterprise(keeper_auth_context) + summaries = enterprise_team_management.list_teams( + enterprise.enterprise_data, + pattern=pattern or None, + ) + print_teams_table(summaries) + except Exception as exc: + print(f'Error listing teams: {exc}') + finally: + if enterprise is not None: + enterprise.close() + keeper_auth_context.close() + + +if __name__ == '__main__': + main() diff --git a/keepercli-package/src/keepercli/commands/enterprise_team.py b/keepercli-package/src/keepercli/commands/enterprise_team.py index 27e58fc3..76fec1ed 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_team.py +++ b/keepercli-package/src/keepercli/commands/enterprise_team.py @@ -3,7 +3,7 @@ from typing import Dict, List, Optional, Any, Tuple, Set from keepersdk import utils, crypto -from keepersdk.enterprise import enterprise_types, batch_management, enterprise_management +from keepersdk.enterprise import enterprise_types, batch_management, enterprise_management, enterprise_team_management from . import base, enterprise_utils from .. import api, prompt_utils from ..helpers import report_utils @@ -200,61 +200,43 @@ def __init__(self): class EnterpriseTeamViewCommand(base.ArgparseCommand): + command_prog = 'enterprise-team view' + def __init__(self): - parser = argparse.ArgumentParser(prog='enterprise-team view', parents=[base.json_output_parser], description='View enterprise team.') + parser = argparse.ArgumentParser( + prog=self.command_prog, + parents=[base.json_output_parser], + description='View enterprise team.', + ) parser.add_argument('-v', '--verbose', dest='verbose', action='store_true', help='print verbose information') parser.add_argument('team', help='Team Name or UID') super().__init__(parser) def execute(self, context: KeeperParams, **kwargs) -> Any: + return self._execute_team_view(context, **kwargs) + + def _execute_team_view(self, context: KeeperParams, **kwargs) -> Any: 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 + team_name = kwargs.get('team') - enterprise_data = context.enterprise_data - team_name = kwargs.get('team') - team = enterprise_utils.TeamUtils.resolve_single_team(enterprise_data, team_name) - if team is None: - raise base.CommandError(f'Team name \"{team_name}\" does not exist') - node_name = enterprise_utils.NodeUtils.get_node_path(enterprise_data, team.node_id, omit_root=False) - team_obj = { - 'team_uid': team.team_uid, - 'team_name': team.name, - 'node_id': team.node_id, - 'node_name': node_name, - 'restrict_edit': team.restrict_edit, - 'restrict_share': team.restrict_share, - 'restrict_view': team.restrict_view, - } - role_ids = {x.role_id for x in enterprise_data.role_teams.get_links_by_object(team.team_uid)} - if role_ids: - roles = [r for r in (enterprise_data.roles.get_entity(x) for x in role_ids) if r] - if len(roles) > 0: - team_obj['team_roles'] = [{ - 'role_id': x.role_id, - 'role_name': x.name, - } for x in roles] - - user_ids = {x.enterprise_user_id for x in enterprise_data.team_users.get_links_by_subject(team.team_uid)} - if len(user_ids) > 0: - users = [u for u in (enterprise_data.users.get_entity(x) for x in user_ids) if u is not None] - if len(users) > 0: - team_obj['team_users'] = [{ - 'enterprise_user_id': x.enterprise_user_id, - 'username': x.username, - } for x in users] - - user_ids = {x.enterprise_user_id for x in enterprise_data.queued_team_users.get_links_by_subject(team.team_uid)} - if len(user_ids) > 0: - users = [u for u in (enterprise_data.users.get_entity(x) for x in user_ids) if u] - if len(users) > 0: - team_obj['queued_team_users'] = [{ - 'enterprise_user_id': x.enterprise_user_id, - 'username': x.username, - } for x in users] + try: + team_info = enterprise_team_management.get_team( + team_name, + enterprise_data=context.enterprise_data, + vault_data_obj=context.vault.vault_data if context.vault else None, + auth=context.auth, + vault=context.vault, + is_enterprise_admin=True, + fetch_live_members=verbose, + ) + except enterprise_team_management.EnterpriseTeamManagementError as exc: + raise base.CommandError(str(exc)) from exc + team_obj = team_info.to_dict() if kwargs.get('format') == 'json': json_text = json.dumps(team_obj, indent=4) @@ -273,10 +255,13 @@ def execute(self, context: KeeperParams, **kwargs) -> Any: if field_value is not None: row = [field_title, field_value] if verbose: - if field == 'node': + if field == 'node_name': row.append(team_obj.get('node_id')) table.append(row) + if team_obj.get('access_level'): + table.append(['Access Level', team_obj['access_level']]) + trs = team_obj.get('team_roles') if isinstance(trs, list) and len(trs) > 0: row = ['Role(s)'] @@ -301,6 +286,14 @@ def execute(self, context: KeeperParams, **kwargs) -> Any: row.append([x['enterprise_user_id'] for x in qtus]) table.append(row) + members = team_obj.get('members') + if isinstance(members, list) and len(members) > 0: + row = ['Member Email(s)'] + row.append([x['email'] for x in members]) + if verbose: + row.append([x['enterprise_user_id'] for x in members]) + table.append(row) + headers = ['', ''] if verbose: headers.append('') diff --git a/keepercli-package/src/keepercli/commands/record_edit.py b/keepercli-package/src/keepercli/commands/record_edit.py index 2010e73b..d6d295e9 100644 --- a/keepercli-package/src/keepercli/commands/record_edit.py +++ b/keepercli-package/src/keepercli/commands/record_edit.py @@ -14,6 +14,7 @@ from keepersdk.vault import (record_types, typed_field_utils, vault_record, attachment, record_facades, one_time_share, record_management, vault_online, vault_data, vault_types, vault_utils, vault_extensions, share_management_utils) from keepersdk import crypto, generator +from keepersdk.enterprise import enterprise_team_management from . import base, enterprise_utils from .. import prompt_utils, api, constants @@ -1157,7 +1158,7 @@ def execute(self, context: KeeperParams, **kwargs): else: raise base.CommandError('The given UID or title is not a valid folder') elif team: - team = self._find_team(context, team) + team = self._find_team(context, team, include_share_objects=True) if team: target_object = ('team', team) else: @@ -1235,13 +1236,32 @@ def _find_folder(self, vault: vault_online.VaultOnline, uid_or_title: str): None ) - def _find_team(self, context: KeeperParams, uid_or_title: str): - """Find a team by UID or name.""" - if not context.enterprise_data: - raise base.CommandError('You must be an enterprise admin to use this command') - - team = enterprise_utils.TeamUtils.resolve_single_team(context.enterprise_data, uid_or_title) - return team + def _find_team( + self, + context: KeeperParams, + uid_or_title: str, + *, + include_share_objects: bool = False, + ): + """Find a team by UID or name using the SDK.""" + is_admin = bool( + context.auth + and context.auth.auth_context + and context.auth.auth_context.is_enterprise_admin + ) + try: + return enterprise_team_management.get_team( + uid_or_title, + enterprise_data=context.enterprise_data if is_admin else None, + vault_data_obj=context.vault.vault_data if context.vault else None, + auth=context.auth, + vault=context.vault, + is_enterprise_admin=is_admin, + include_share_objects=include_share_objects, + fetch_live_members=True, + ) + except enterprise_team_management.EnterpriseTeamManagementError: + return None def _display_object(self, context: KeeperParams, target_object, output_format: str, unmask: bool): """Display the target object in the specified format.""" @@ -1282,12 +1302,28 @@ def _display_folder(self, vault: vault_online.VaultOnline, folder, output_format else: # detail format self._display_folder_detail(vault, folder.folder_uid) - def _display_team(self, context: KeeperParams, team, output_format: str): + def _display_team(self, context: KeeperParams, team_info: enterprise_team_management.EnterpriseTeamInfo, output_format: str): """Display a team in the specified format.""" if output_format == 'json': - self._display_team_json(context, team.team_uid) - else: # detail format - self._display_team_detail(context, team.team_uid) + self._display_team_json(team_info) + elif not team_info.is_member: + self._display_non_member_team(context, team_info) + else: + self._display_team_detail(team_info) + + def _display_non_member_team( + self, + context: KeeperParams, + team_info: enterprise_team_management.EnterpriseTeamInfo, + ): + """Display team info when the logged-in user is not a team member.""" + username = context.auth.auth_context.username if context.auth else '' + logger.info('') + logger.info('User {} does not belong to team {}'.format(username, team_info.team_name)) + logger.info('') + logger.info('{0:>20s}: {1:<20s}'.format('Team UID', team_info.team_uid)) + logger.info('{0:>20s}: {1}'.format('Name', team_info.team_name)) + logger.info('') def _display_record_json(self, vault: vault_online.VaultOnline, uid: str, unmask: bool = False): """Display record information in JSON format.""" @@ -1439,18 +1475,52 @@ def _display_folder_json(self, vault: vault_online.VaultOnline, uid: str): } logger.info(json.dumps(output, indent=2)) - def _display_team_json(self, context: KeeperParams, uid: str): + def _display_team_json(self, team_info: enterprise_team_management.EnterpriseTeamInfo): """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.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.auth.auth_context.username} does not belong to team {team.name}') - output = { - 'Team UID:': uid, - 'Name:': team.name - } - logger.info(json.dumps(output, indent=2)) + logger.info(json.dumps(team_info.to_dict(), indent=2)) + + def _display_team_detail(self, team_info: enterprise_team_management.EnterpriseTeamInfo): + """Display team information in detailed format.""" + logger.info('') + logger.info('{0:>20s}: {1:<20s}'.format('Team UID', team_info.team_uid)) + logger.info('{0:>20s}: {1}'.format('Name', team_info.team_name)) + if team_info.node_name: + logger.info('{0:>20s}: {1}'.format('Node', team_info.node_name)) + logger.info('{0:>20s}: {1}'.format('Access Level', team_info.access_level)) + logger.info('{0:>20s}: {1}'.format('Restrict Edit', team_info.restrict_edit)) + logger.info('{0:>20s}: {1}'.format('Restrict View', team_info.restrict_view)) + logger.info('{0:>20s}: {1}'.format('Restrict Share', team_info.restrict_share)) + + if team_info.team_roles: + logger.info('{0:>20s}: {1}'.format('Role(s)', ', '.join(x.role_name for x in team_info.team_roles))) + + if team_info.team_users: + logger.info('{0:>20s}: {1}'.format('User(s)', ', '.join(x.username for x in team_info.team_users))) + + if team_info.queued_team_users: + logger.info( + '{0:>20s}: {1}'.format( + 'Queued User(s)', + ', '.join(x.username for x in team_info.queued_team_users), + ) + ) + + if team_info.members: + logger.info('') + logger.info('{0:>20s} {1:<40s} {2:<40s} {3}'.format( + 'Enterprise User ID', 'Email', 'Enterprise Username', 'Share Admin' + )) + for member in team_info.members: + logger.info('{0:>20d} {1:<40s} {2:<40s} {3}'.format( + member.enterprise_user_id, + member.email, + member.enterprise_username, + 'Yes' if member.is_share_admin else 'No', + )) + elif not team_info.team_users: + logger.info('') + logger.info('No team members found.') + logger.info('') def _display_record_detail(self, vault: vault_online.VaultOnline, uid: str, unmask: bool): """Display record information in detailed format.""" @@ -1672,26 +1742,6 @@ def _display_folder_detail(self, vault: vault_online.VaultOnline, uid: str): if folder.folder_type == 'shared_folder_folder': logger.info('{0:>20s}: {1:<20s}'.format('Shared Folder UID', folder.folder_scope_uid)) - 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.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.auth.auth_context.username} does not belong to team {team.name}') - team_user = False - - logger.info('') - logger.info('{0:>20s}: {1:<20s}'.format('Team UID', team.team_uid)) - logger.info('{0:>20s}: {1}'.format('Name', team.name)) - if team_user: - logger.info('{0:>20s}: {1}'.format('Restrict Edit', team.restrict_edit)) - logger.info('{0:>20s}: {1}'.format('Restrict View', team.restrict_view)) - logger.info('{0:>20s}: {1}'.format('Restrict Share', team.restrict_share)) - logger.info('') - 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) @@ -2017,4 +2067,6 @@ def _display_team_details(self, teams: Iterable[vault_types.TeamInfo], context: """Display detailed information for teams.""" get_command = RecordGetCommand() for team in teams: - get_command._display_team_detail(context=context, uid=team.team_uid) + team_info = get_command._find_team(context, team.team_uid) + if team_info is not None: + get_command._display_team(context, team_info, 'detail') diff --git a/keepercli-package/src/keepercli/commands/vault_record.py b/keepercli-package/src/keepercli/commands/vault_record.py index 4557d6c3..365c3ae0 100644 --- a/keepercli-package/src/keepercli/commands/vault_record.py +++ b/keepercli-package/src/keepercli/commands/vault_record.py @@ -9,8 +9,7 @@ from .. import api, prompt_utils from ..params import KeeperParams from ..helpers import folder_utils, report_utils -from keepersdk import utils -from keepersdk.proto import enterprise_pb2 +from keepersdk.enterprise import enterprise_team_management from keepersdk.vault import record_management, vault_data, vault_types, vault_record, vault_utils, share_management_utils @@ -310,14 +309,11 @@ def get_enterprise_teams(): def fetch_members(team_uid: str) -> List[str]: if not allow_fetch: return [] - rq = enterprise_pb2.GetTeamMemberRequest() - rq.teamUid = utils.base64_url_decode(team_uid) - rs = context.vault.keeper_auth.execute_auth_rest( - rest_endpoint='vault/get_team_members', - request=rq, - response_type=enterprise_pb2.GetTeamMemberResponse - ) - return [x.email for x in rs.enterpriseUser] + auth = context.auth or (context.vault.keeper_auth if context.vault else None) + if auth is None: + return [] + members = enterprise_team_management.get_team_members(auth, team_uid) + return [x.email for x in members if x.email] enterprise_teams = get_enterprise_teams() for t in teams: @@ -325,6 +321,7 @@ def fetch_members(team_uid: str) -> List[str]: return teams + class ShortcutCommand(base.GroupCommand): def __init__(self): super(ShortcutCommand, self).__init__('Manage record shortcuts') diff --git a/keepersdk-package/src/keepersdk/enterprise/enterprise_team_management.py b/keepersdk-package/src/keepersdk/enterprise/enterprise_team_management.py new file mode 100644 index 00000000..75639baf --- /dev/null +++ b/keepersdk-package/src/keepersdk/enterprise/enterprise_team_management.py @@ -0,0 +1,587 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bool: + return self.team_uid is not None + + +@dataclass +class EnterpriseTeamInfo: + team_uid: str + team_name: str + node_id: int + node_name: str + restrict_edit: bool + restrict_share: bool + restrict_view: bool + access_level: str = 'enterprise_admin' + is_member: bool = True + team_roles: List[EnterpriseTeamRoleInfo] = field(default_factory=list) + team_users: List[EnterpriseTeamUserInfo] = field(default_factory=list) + queued_team_users: List[EnterpriseTeamUserInfo] = field(default_factory=list) + members: List[TeamMemberInfo] = field(default_factory=list) + + def to_dict(self) -> dict: + result = { + 'team_uid': self.team_uid, + 'team_name': self.team_name, + 'node_id': self.node_id, + 'node_name': self.node_name, + 'restrict_edit': self.restrict_edit, + 'restrict_share': self.restrict_share, + 'restrict_view': self.restrict_view, + 'access_level': self.access_level, + 'is_member': self.is_member, + } + if self.team_roles: + result['team_roles'] = [ + {'role_id': x.role_id, 'role_name': x.role_name} for x in self.team_roles + ] + if self.team_users: + result['team_users'] = [ + {'enterprise_user_id': x.enterprise_user_id, 'username': x.username} + for x in self.team_users + ] + if self.queued_team_users: + result['queued_team_users'] = [ + {'enterprise_user_id': x.enterprise_user_id, 'username': x.username} + for x in self.queued_team_users + ] + if self.members: + result['members'] = [ + { + 'enterprise_user_id': x.enterprise_user_id, + 'email': x.email, + 'enterprise_username': x.enterprise_username, + 'is_share_admin': x.is_share_admin, + } + for x in self.members + ] + return result + + +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) + + +def _teams_by_name( + teams: List[enterprise_types.Team], + team_name: str, +) -> List[enterprise_types.Team]: + name_lower = team_name.lower() + return [t for t in teams if t.name.lower() == name_lower] + + +def _vault_teams_by_name( + vault_data_obj: vault_data.VaultData, + team_name: str, +) -> List[vault_types.TeamInfo]: + name_lower = team_name.lower() + return [t for t in vault_data_obj.teams() if t.name.lower() == name_lower] + + +def _shareable_teams_by_name( + teams: List[vault_types.TeamInfo], + team_name: str, +) -> List[vault_types.TeamInfo]: + name_lower = team_name.lower() + return [t for t in teams if t.name.lower() == name_lower] + + +def _collect_shareable_teams( + *, + auth: Optional[keeper_auth.KeeperAuth] = None, + vault: Optional[vault_online.VaultOnline] = None, +) -> List[vault_types.TeamInfo]: + """Teams visible via share objects and get_available_teams (same sources as list-team).""" + teams: List[vault_types.TeamInfo] = [] + seen: set = set() + + if vault is not None: + share_objects = share_management_utils.get_share_objects(vault=vault) + for team_uid, team_info in share_objects.get('teams', {}).items(): + if team_uid in seen: + continue + seen.add(team_uid) + teams.append( + vault_types.TeamInfo( + team_uid=team_uid, + name=team_info.get('name') or '', + ) + ) + + if auth is not None: + for team in vault_utils.load_available_teams(auth): + if team.team_uid in seen: + continue + seen.add(team.team_uid) + teams.append(team) + + return teams + + +def resolve_team( + team_name_or_uid: str, + *, + vault_data_obj: Optional[vault_data.VaultData] = None, + enterprise_data: Optional[enterprise_types.IEnterpriseData] = None, + is_enterprise_admin: bool = False, + auth: Optional[keeper_auth.KeeperAuth] = None, + vault: Optional[vault_online.VaultOnline] = None, + include_share_objects: bool = False, +) -> TeamResolveResult: + """ + Resolve a team by UID or case-insensitive name. + + Resolution order: + 1. Vault cache by UID + 2. Vault cache by name + 3. Enterprise cache by UID (enterprise admin) + 4. Enterprise cache by name (enterprise admin) + 5. Share objects / available teams by UID (when include_share_objects) + 6. Share objects / available teams by name (when include_share_objects) + """ + if not team_name_or_uid: + return TeamResolveResult() + + if vault_data_obj is not None: + vault_team = vault_data_obj.get_team(team_name_or_uid) + if vault_team is not None: + return TeamResolveResult(team_uid=vault_team.team_uid, vault_team=vault_team) + + vault_matches = _vault_teams_by_name(vault_data_obj, team_name_or_uid) + if len(vault_matches) > 1: + return TeamResolveResult(multiple_found=True) + if len(vault_matches) == 1: + team = vault_matches[0] + return TeamResolveResult(team_uid=team.team_uid, vault_team=team) + + if enterprise_data is not None and is_enterprise_admin: + enterprise_team = enterprise_data.teams.get_entity(team_name_or_uid) + if enterprise_team is not None: + return TeamResolveResult( + team_uid=enterprise_team.team_uid, + enterprise_team=enterprise_team, + ) + + enterprise_matches = _teams_by_name( + list(enterprise_data.teams.get_all_entities()), + team_name_or_uid, + ) + if len(enterprise_matches) > 1: + return TeamResolveResult(multiple_found=True) + if len(enterprise_matches) == 1: + team = enterprise_matches[0] + return TeamResolveResult(team_uid=team.team_uid, enterprise_team=team) + + if include_share_objects and (auth is not None or vault is not None): + shareable_teams = _collect_shareable_teams(auth=auth, vault=vault) + share_team = next( + (team for team in shareable_teams if team.team_uid == team_name_or_uid), + None, + ) + if share_team is not None: + return TeamResolveResult( + team_uid=share_team.team_uid, + share_team=share_team, + ) + + share_matches = _shareable_teams_by_name(shareable_teams, team_name_or_uid) + if len(share_matches) > 1: + return TeamResolveResult(multiple_found=True) + if len(share_matches) == 1: + team = share_matches[0] + return TeamResolveResult(team_uid=team.team_uid, share_team=team) + + return TeamResolveResult() + + +def resolve_enterprise_team( + enterprise_data: enterprise_types.IEnterpriseData, + team_name_or_uid: str, +) -> enterprise_types.Team: + """Resolve an enterprise team by UID or case-insensitive name.""" + team = enterprise_data.teams.get_entity(team_name_or_uid) + if team is not None: + return team + + matches = _teams_by_name(list(enterprise_data.teams.get_all_entities()), team_name_or_uid) + if not matches: + raise EnterpriseTeamManagementError( + ERROR_MSG_TEAM_NOT_FOUND.format(team_name_or_uid) + ) + if len(matches) > 1: + raise EnterpriseTeamManagementError( + ERROR_MSG_MULTIPLE_TEAMS.format(team_name_or_uid) + ) + return matches[0] + + +def get_team_members( + auth: keeper_auth.KeeperAuth, + team_uid: str, +) -> List[TeamMemberInfo]: + """Return team members from vault/get_team_members.""" + if not team_uid: + return [] + + request = enterprise_pb2.GetTeamMemberRequest() + request.teamUid = utils.base64_url_decode(team_uid) + response = auth.execute_auth_rest( + rest_endpoint=TEAM_MEMBERS_ENDPOINT, + request=request, + response_type=enterprise_pb2.GetTeamMemberResponse, + ) + if response is None or not response.enterpriseUser: + return [] + + return [ + TeamMemberInfo( + enterprise_user_id=user.enterpriseUserId, + email=user.email or '', + enterprise_username=user.enterpriseUsername or '', + is_share_admin=bool(user.isShareAdmin), + ) + for user in response.enterpriseUser + ] + + +def _build_team_users( + enterprise_data: enterprise_types.IEnterpriseData, + user_ids: set, +) -> List[EnterpriseTeamUserInfo]: + users: List[EnterpriseTeamUserInfo] = [] + for user_id in user_ids: + user = enterprise_data.users.get_entity(user_id) + if user is None: + continue + users.append( + EnterpriseTeamUserInfo( + enterprise_user_id=user.enterprise_user_id, + username=user.username, + full_name=user.full_name, + ) + ) + users.sort(key=lambda x: x.username.lower()) + return users + + +def _build_team_roles( + enterprise_data: enterprise_types.IEnterpriseData, + role_ids: set, +) -> List[EnterpriseTeamRoleInfo]: + roles: List[EnterpriseTeamRoleInfo] = [] + for role_id in role_ids: + role = enterprise_data.roles.get_entity(role_id) + if role is None: + continue + roles.append(EnterpriseTeamRoleInfo(role_id=role.role_id, role_name=role.name)) + roles.sort(key=lambda x: x.role_name.lower()) + return roles + + +def _user_is_team_member( + auth: Optional[keeper_auth.KeeperAuth], + members: List[TeamMemberInfo], +) -> bool: + if auth is None: + return False + username = auth.auth_context.username.lower() + if not username: + return False + return any( + username in (member.email.lower(), member.enterprise_username.lower()) + for member in members + ) + + +def _build_basic_team_info( + team_uid: str, + team_name: str, + *, + auth: Optional[keeper_auth.KeeperAuth], + fetch_live_members: bool, + access_level: str, + is_member: Optional[bool] = None, +) -> EnterpriseTeamInfo: + members: List[TeamMemberInfo] = [] + if auth is not None and fetch_live_members: + members = get_team_members(auth, team_uid) + if is_member is None: + is_member = access_level == 'full_member' or _user_is_team_member(auth, members) + return EnterpriseTeamInfo( + team_uid=team_uid, + team_name=team_name, + node_id=0, + node_name='', + restrict_edit=False, + restrict_share=False, + restrict_view=False, + access_level=access_level, + is_member=is_member, + members=members, + ) + + +def get_team( + team_name_or_uid: str, + *, + enterprise_data: Optional[enterprise_types.IEnterpriseData] = None, + vault_data_obj: Optional[vault_data.VaultData] = None, + auth: Optional[keeper_auth.KeeperAuth] = None, + vault: Optional[vault_online.VaultOnline] = None, + is_enterprise_admin: bool = False, + include_share_objects: bool = False, + include_roles: bool = True, + include_users: bool = True, + include_queued_users: bool = True, + fetch_live_members: bool = False, +) -> EnterpriseTeamInfo: + """ + Get detailed information for a team by UID or name. + + When enterprise_data is available the result includes cached roles and users. + When auth is provided and fetch_live_members is True, members are loaded from + vault/get_team_members. + """ + resolved = resolve_team( + team_name_or_uid, + vault_data_obj=vault_data_obj, + enterprise_data=enterprise_data, + is_enterprise_admin=is_enterprise_admin, + auth=auth, + vault=vault, + include_share_objects=include_share_objects, + ) + if resolved.multiple_found: + raise EnterpriseTeamManagementError( + ERROR_MSG_MULTIPLE_TEAMS.format(team_name_or_uid) + ) + if not resolved.found: + raise EnterpriseTeamManagementError( + ERROR_MSG_TEAM_NOT_FOUND.format(team_name_or_uid) + ) + + enterprise_team = resolved.enterprise_team + if enterprise_team is None and enterprise_data is not None and resolved.team_uid: + enterprise_team = enterprise_data.teams.get_entity(resolved.team_uid) + + if enterprise_team is not None and enterprise_data is not None: + access_level = 'full_member' if resolved.vault_team is not None else 'enterprise_admin' + node_name = _get_node_path(enterprise_data, enterprise_team.node_id, omit_root=False) + + team_roles: List[EnterpriseTeamRoleInfo] = [] + team_users: List[EnterpriseTeamUserInfo] = [] + queued_team_users: List[EnterpriseTeamUserInfo] = [] + + if include_roles: + role_ids = { + x.role_id + for x in enterprise_data.role_teams.get_links_by_object(enterprise_team.team_uid) + } + team_roles = _build_team_roles(enterprise_data, role_ids) + + if include_users: + user_ids = { + x.enterprise_user_id + for x in enterprise_data.team_users.get_links_by_subject(enterprise_team.team_uid) + } + team_users = _build_team_users(enterprise_data, user_ids) + + if include_queued_users: + queued_user_ids = { + x.enterprise_user_id + for x in enterprise_data.queued_team_users.get_links_by_subject(enterprise_team.team_uid) + } + queued_team_users = _build_team_users(enterprise_data, queued_user_ids) + + members: List[TeamMemberInfo] = [] + if auth is not None and fetch_live_members: + members = get_team_members(auth, enterprise_team.team_uid) + + is_member = ( + resolved.vault_team is not None + or _user_is_team_member(auth, members) + ) + + return EnterpriseTeamInfo( + team_uid=enterprise_team.team_uid, + team_name=enterprise_team.name, + node_id=enterprise_team.node_id, + node_name=node_name, + restrict_edit=enterprise_team.restrict_edit, + restrict_share=enterprise_team.restrict_share, + restrict_view=enterprise_team.restrict_view, + access_level=access_level, + is_member=is_member, + team_roles=team_roles, + team_users=team_users, + queued_team_users=queued_team_users, + members=members, + ) + + if resolved.vault_team is not None: + return _build_basic_team_info( + resolved.vault_team.team_uid, + resolved.vault_team.name, + auth=auth, + fetch_live_members=fetch_live_members, + access_level='full_member', + ) + + if resolved.share_team is not None: + return _build_basic_team_info( + resolved.share_team.team_uid, + resolved.share_team.name, + auth=auth, + fetch_live_members=fetch_live_members, + access_level='share_reference', + ) + + raise EnterpriseTeamManagementError( + ERROR_MSG_TEAM_NOT_FOUND.format(team_name_or_uid) + ) + + +def list_teams( + enterprise_data: enterprise_types.IEnterpriseData, + pattern: Optional[str] = None, +) -> List[EnterpriseTeamSummary]: + """List enterprise teams, optionally filtered by case-insensitive substring.""" + pattern_lower = (pattern or '').lower() + + user_teams: dict = {} + for team_user in enterprise_data.team_users.get_all_links(): + user_teams.setdefault(team_user.team_uid, set()).add(team_user.enterprise_user_id) + + role_teams: dict = {} + for role_team in enterprise_data.role_teams.get_all_links(): + role_teams.setdefault(role_team.team_uid, set()).add(role_team.role_id) + + summaries: List[EnterpriseTeamSummary] = [] + for team in enterprise_data.teams.get_all_entities(): + if pattern_lower: + searchable = ' '.join( + str(x) + for x in ( + team.team_uid, + team.name, + team.node_id, + team.restrict_edit, + team.restrict_share, + team.restrict_view, + ) + ).lower() + if pattern_lower not in searchable: + continue + + node_name = _get_node_path(enterprise_data, team.node_id, omit_root=True) + user_count = len(user_teams.get(team.team_uid, set())) + role_count = len(role_teams.get(team.team_uid, set())) + summaries.append( + EnterpriseTeamSummary( + team_uid=team.team_uid, + team_name=team.name, + node_id=team.node_id, + node_name=node_name, + restrict_edit=team.restrict_edit, + restrict_share=team.restrict_share, + restrict_view=team.restrict_view, + user_count=user_count, + role_count=role_count, + ) + ) + + summaries.sort(key=lambda x: x.team_name.lower()) + return summaries diff --git a/keepersdk-package/unit_tests/test_enterprise_team_management.py b/keepersdk-package/unit_tests/test_enterprise_team_management.py new file mode 100644 index 00000000..76f7b91b --- /dev/null +++ b/keepersdk-package/unit_tests/test_enterprise_team_management.py @@ -0,0 +1,252 @@ +import unittest +from unittest.mock import MagicMock + +from keepersdk.enterprise import enterprise_team_management, enterprise_types + + +def _enterprise_data( + teams=None, + users=None, + roles=None, + team_users=None, + role_teams=None, + queued_team_users=None, + nodes=None, +): + enterprise_data = MagicMock() + enterprise_data.teams = MagicMock() + enterprise_data.users = MagicMock() + enterprise_data.roles = MagicMock() + enterprise_data.team_users = MagicMock() + enterprise_data.role_teams = MagicMock() + enterprise_data.queued_team_users = MagicMock() + enterprise_data.nodes = MagicMock() + enterprise_data.root_node = enterprise_types.Node(node_id=1, parent_id=0, name='Root') + enterprise_data.enterprise_info = MagicMock() + enterprise_data.enterprise_info.enterprise_name = 'Metronlabs' + + team_map = {t.team_uid: t for t in (teams or [])} + user_map = {u.enterprise_user_id: u for u in (users or [])} + role_map = {r.role_id: r for r in (roles or [])} + node_map = {n.node_id: n for n in (nodes or [])} + + enterprise_data.teams.get_entity.side_effect = lambda uid: team_map.get(uid) + enterprise_data.teams.get_all_entities.return_value = list(team_map.values()) + enterprise_data.users.get_entity.side_effect = lambda uid: user_map.get(uid) + enterprise_data.roles.get_entity.side_effect = lambda role_id: role_map.get(role_id) + enterprise_data.nodes.get_entity.side_effect = lambda node_id: node_map.get(node_id) + enterprise_data.team_users.get_links_by_subject.return_value = team_users or [] + enterprise_data.team_users.get_all_links.return_value = team_users or [] + enterprise_data.role_teams.get_links_by_object.return_value = role_teams or [] + enterprise_data.role_teams.get_all_links.return_value = role_teams or [] + enterprise_data.queued_team_users.get_links_by_subject.return_value = queued_team_users or [] + return enterprise_data + + +class EnterpriseTeamManagementTests(unittest.TestCase): + def test_resolve_enterprise_team_by_uid(self): + team = enterprise_types.Team(team_uid='uid-1', name='Testing Team', node_id=10) + enterprise_data = _enterprise_data(teams=[team]) + + resolved = enterprise_team_management.resolve_enterprise_team(enterprise_data, 'uid-1') + self.assertEqual(resolved.team_uid, 'uid-1') + + def test_resolve_enterprise_team_by_name(self): + team = enterprise_types.Team(team_uid='uid-1', name='Testing Team', node_id=10) + enterprise_data = _enterprise_data(teams=[team]) + + resolved = enterprise_team_management.resolve_enterprise_team(enterprise_data, 'testing team') + self.assertEqual(resolved.name, 'Testing Team') + + def test_resolve_enterprise_team_multiple_matches(self): + teams = [ + enterprise_types.Team(team_uid='uid-1', name='Testing Team', node_id=10), + enterprise_types.Team(team_uid='uid-2', name='testing team', node_id=10), + ] + enterprise_data = _enterprise_data(teams=teams) + + with self.assertRaises(enterprise_team_management.EnterpriseTeamManagementError): + enterprise_team_management.resolve_enterprise_team(enterprise_data, 'Testing Team') + + def test_get_team_includes_roles_and_users(self): + team = enterprise_types.Team( + team_uid='uid-1', + name='Testing Team', + node_id=10, + restrict_edit=True, + ) + user = enterprise_types.User( + enterprise_user_id=100, + username='user@example.com', + node_id=10, + status='active', + full_name='Test User', + ) + role = enterprise_types.Role(role_id=200, name='Role Name', node_id=10) + node = enterprise_types.Node(node_id=10, parent_id=1, name='TestNode') + team_user = enterprise_types.TeamUser(team_uid='uid-1', enterprise_user_id=100) + role_team = enterprise_types.RoleTeam(role_id=200, team_uid='uid-1') + + enterprise_data = _enterprise_data( + teams=[team], + users=[user], + roles=[role], + nodes=[node, enterprise_types.Node(node_id=1, parent_id=0, name='Root')], + team_users=[team_user], + role_teams=[role_team], + ) + + info = enterprise_team_management.get_team( + 'Testing Team', + enterprise_data=enterprise_data, + is_enterprise_admin=True, + ) + + self.assertEqual(info.team_name, 'Testing Team') + self.assertEqual(info.node_name, 'Root\\TestNode') + self.assertTrue(info.restrict_edit) + self.assertEqual(len(info.team_users), 1) + self.assertEqual(info.team_users[0].username, 'user@example.com') + self.assertEqual(len(info.team_roles), 1) + self.assertEqual(info.team_roles[0].role_name, 'Role Name') + + def test_list_teams_with_pattern(self): + teams = [ + enterprise_types.Team(team_uid='uid-1', name='Testing Team', node_id=10), + enterprise_types.Team(team_uid='uid-2', name='Developers', node_id=10), + ] + node = enterprise_types.Node(node_id=10, parent_id=1, name='TestNode') + enterprise_data = _enterprise_data( + teams=teams, + nodes=[node, enterprise_types.Node(node_id=1, parent_id=0, name='Root')], + ) + + summaries = enterprise_team_management.list_teams(enterprise_data, pattern='testing') + self.assertEqual(len(summaries), 1) + self.assertEqual(summaries[0].team_name, 'Testing Team') + + def test_get_team_members(self): + auth = MagicMock() + response = MagicMock() + user = MagicMock() + user.enterpriseUserId = 100 + user.email = 'user@example.com' + user.enterpriseUsername = 'User Name' + user.isShareAdmin = True + response.enterpriseUser = [user] + auth.execute_auth_rest.return_value = response + + members = enterprise_team_management.get_team_members(auth, 'dGVhbQ') + self.assertEqual(len(members), 1) + self.assertEqual(members[0].email, 'user@example.com') + self.assertTrue(members[0].is_share_admin) + + def test_resolve_team_prefers_vault_cache(self): + vault_data_obj = MagicMock() + vault_team = MagicMock() + vault_team.team_uid = 'vault-uid' + vault_team.name = 'Vault Team' + vault_data_obj.get_team.return_value = vault_team + vault_data_obj.teams.return_value = [vault_team] + + resolved = enterprise_team_management.resolve_team( + 'vault-uid', + vault_data_obj=vault_data_obj, + enterprise_data=_enterprise_data(), + is_enterprise_admin=True, + ) + self.assertEqual(resolved.team_uid, 'vault-uid') + self.assertIsNotNone(resolved.vault_team) + + def test_resolve_team_from_share_objects(self): + vault = MagicMock() + with unittest.mock.patch( + 'keepersdk.enterprise.enterprise_team_management.share_management_utils.get_share_objects', + return_value={ + 'teams': { + 'IuiVKCcPSjW1BZ-85o9bwA': { + 'name': 'Testing Team', + 'enterprise_id': 123, + } + } + }, + ): + resolved = enterprise_team_management.resolve_team( + 'Testing Team', + vault=vault, + include_share_objects=True, + ) + self.assertEqual(resolved.team_uid, 'IuiVKCcPSjW1BZ-85o9bwA') + self.assertIsNotNone(resolved.share_team) + self.assertEqual(resolved.share_team.name, 'Testing Team') + + def test_resolve_team_skips_share_objects_by_default(self): + vault = MagicMock() + with unittest.mock.patch( + 'keepersdk.enterprise.enterprise_team_management.share_management_utils.get_share_objects', + ) as get_share_objects: + resolved = enterprise_team_management.resolve_team( + 'record-uid-not-a-team', + vault=vault, + ) + get_share_objects.assert_not_called() + self.assertFalse(resolved.found) + + def test_get_team_marks_non_member_for_share_reference(self): + auth = MagicMock() + auth.auth_context.username = 'user@example.com' + auth.execute_auth_rest.return_value = MagicMock(enterpriseUser=[]) + + vault = MagicMock() + with unittest.mock.patch( + 'keepersdk.enterprise.enterprise_team_management.share_management_utils.get_share_objects', + return_value={ + 'teams': { + 'team-uid': {'name': 'Developers', 'enterprise_id': 123}, + } + }, + ): + info = enterprise_team_management.get_team( + 'Developers', + auth=auth, + vault=vault, + include_share_objects=True, + fetch_live_members=True, + ) + + self.assertFalse(info.is_member) + self.assertEqual(info.access_level, 'share_reference') + + def test_get_team_marks_member_when_listed_in_team_members(self): + auth = MagicMock() + auth.auth_context.username = 'user@example.com' + member = MagicMock() + member.enterpriseUserId = 100 + member.email = 'user@example.com' + member.enterpriseUsername = 'user@example.com' + member.isShareAdmin = False + auth.execute_auth_rest.return_value = MagicMock(enterpriseUser=[member]) + + vault = MagicMock() + with unittest.mock.patch( + 'keepersdk.enterprise.enterprise_team_management.share_management_utils.get_share_objects', + return_value={ + 'teams': { + 'team-uid': {'name': 'Testing Team', 'enterprise_id': 123}, + } + }, + ): + info = enterprise_team_management.get_team( + 'Testing Team', + auth=auth, + vault=vault, + include_share_objects=True, + fetch_live_members=True, + ) + + self.assertTrue(info.is_member) + self.assertEqual(len(info.members), 1) + + +if __name__ == '__main__': + unittest.main() From 6e41ec8029975987ccbbc5a4abfd2e4eceab135d Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Thu, 23 Jul 2026 09:59:25 -0700 Subject: [PATCH 7/7] Release 1.2.3 --- .github/workflows/publish-sdk.yml | 24 +++++++++---------- .github/workflows/run-unit-tests.yml | 2 +- keepersdk-package/mypy.ini | 2 +- keepersdk-package/setup.cfg | 26 ++++++++++++--------- keepersdk-package/src/keepersdk/__init__.py | 2 +- 5 files changed, 30 insertions(+), 26 deletions(-) diff --git a/.github/workflows/publish-sdk.yml b/.github/workflows/publish-sdk.yml index 3ecaaddd..b9dea09d 100644 --- a/.github/workflows/publish-sdk.yml +++ b/.github/workflows/publish-sdk.yml @@ -20,10 +20,10 @@ jobs: with: ref: ${{ github.event.inputs.version }} - - name: Set up Python 3.13 - uses: actions/setup-python@v5 + - name: Set up Python 3.14 + uses: actions/setup-python@v7 with: - python-version: '3.13' + python-version: '3.14' - name: Install dependencies run: | @@ -38,7 +38,7 @@ jobs: python3 -m build --wheel keepersdk-package - name: Archive the package - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: KeeperSdkWheel retention-days: 1 @@ -52,15 +52,15 @@ jobs: environment: test steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: KeeperSdkWheel path: keepersdk-package/dist - - name: Set up Python 3.13 - uses: actions/setup-python@v5 + - name: Set up Python 3.14 + uses: actions/setup-python@v7 with: - python-version: '3.13' + python-version: '3.14' - name: Publish to Test PyPI env: @@ -77,15 +77,15 @@ jobs: environment: prod steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: KeeperSdkWheel path: keepersdk-package/dist - - name: Set up Python 3.13 - uses: actions/setup-python@v5 + - name: Set up Python 3.14 + uses: actions/setup-python@v7 with: - python-version: '3.13' + python-version: '3.14' - name: Publish to PyPI env: diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 1c6460c2..a6c1200c 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -13,7 +13,7 @@ jobs: test-with-unittest: strategy: matrix: - python-version: ['3.8', '3.14'] + python-version: ['3.10', '3.14'] runs-on: ubuntu-latest permissions: diff --git a/keepersdk-package/mypy.ini b/keepersdk-package/mypy.ini index 4839e50a..f14dcf8b 100644 --- a/keepersdk-package/mypy.ini +++ b/keepersdk-package/mypy.ini @@ -1,7 +1,7 @@ [mypy] warn_no_return = False files = src/ -python_version = 3.9 +python_version = 3.10 [mypy-keepersdk.proto.*] ignore_errors = True diff --git a/keepersdk-package/setup.cfg b/keepersdk-package/setup.cfg index 6d933c6b..5b313776 100644 --- a/keepersdk-package/setup.cfg +++ b/keepersdk-package/setup.cfg @@ -15,25 +15,29 @@ classifiers = Operating System :: OS Independent Natural Language :: English Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 + Programming Language :: Python :: 3.13 + Programming Language :: Python :: 3.14 Topic :: Security keywords = security, password [options] -python_requires = >=3.8 +python_requires = >=3.10 package_dir = = src include_package_data = True install_requires = - attrs>=23.1.0 - requests>=2.32.2 - cryptography>=45.0.1 - protobuf>=5.28.3 - websockets>=13.1 - fido2>=2.0.0; python_version>='3.10' - email-validator>=2.0.0 - pydantic>=2.6.4; python_version>='3.8' - google-api-core>=2.16.0 + attrs>=25.4.0 + requests>=2.32.5 + cryptography>=45.0.7 + protobuf>=5.29.5 + websockets>=14.2 + fido2>=2.1.0 + email-validator>=2.3.0 + pydantic>=2.12.5 + google-api-core>=2.25.2 [options.package_data] diff --git a/keepersdk-package/src/keepersdk/__init__.py b/keepersdk-package/src/keepersdk/__init__.py index f807aa9f..87e4dd10 100644 --- a/keepersdk-package/src/keepersdk/__init__.py +++ b/keepersdk-package/src/keepersdk/__init__.py @@ -10,6 +10,6 @@ # from . import background -__version__ = '1.2.2' +__version__ = '1.2.3' background.init()