diff --git a/examples/sdk_examples/nested_shared_folders/nsf_access_batch_execution.py b/examples/sdk_examples/nested_shared_folders/nsf_access_batch_execution.py new file mode 100644 index 00000000..f73ae792 --- /dev/null +++ b/examples/sdk_examples/nested_shared_folders/nsf_access_batch_execution.py @@ -0,0 +1,640 @@ +import getpass +import json +import logging +import sqlite3 +from typing import Dict, List, 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_access_list( + vault: vault_online.VaultOnline, + folder_uids: List[str], + record_uids: List[str]) -> None: + """ + Print access details for a caller-supplied list of NSF folder and + record UIDs. + + Access details are not pre-populated in the NSF in-memory cache, so + this fetches them for exactly the requested UIDs via + ``get_nsf_folder_access()`` / ``get_nsf_record_accesses()``, caches + them onto ``vault.nsf_data``, and then prints only those entries. + """ + view = vault.nsf_data + if view is None: + print("NSF storage is not available on this vault.") + return + + print("\nNSF FOLDERS") + print("=" * 100) + + if not folder_uids: + print("No NSF folder UIDs requested.") + else: + access = nsf_management.get_nsf_folder_access(vault, folder_uids) + for fr in access.get("results") or []: + fuid = fr.get("folder_uid") + if fuid: + view.set_nsf_folder_access_detail(fuid, fr) + + for folder_uid in folder_uids: + folder = view.get_folder(folder_uid) + access_detail = view.get_nsf_folder_access_detail(folder_uid) + + print(f"\nFolder: {folder.name if folder else '(NSF Folder)'}") + print(f"UID: {folder_uid}") + + accessors = (access_detail or {}).get("accessors") + if not accessors: + print("Access: No cached access details found.") + continue + + print("Access:") + for access in accessors: + print( + f" - accessor_uid={access.get('accessor_uid')}" + f" | access_type={access.get('access_type')}" + f" | role={access.get('role')}" + f" | inherited={access.get('inherited')}" + f" | hidden={access.get('hidden')}" + f" | owner={access.get('owner', False)}" + ) + + permissions = access.get("permissions") + if permissions: + print(f" permissions={json.dumps(permissions)}") + + print("\nNSF RECORDS") + print("=" * 100) + + if not record_uids: + print("No NSF record UIDs requested.") + else: + access = nsf_management.get_nsf_record_accesses(vault, record_uids) + by_uid: Dict[str, List] = {} + for ao in access.get("record_accesses") or []: + by_uid.setdefault(ao["record_uid"], []).append(ao) + for record_uid in record_uids: + view.set_nsf_record_access_detail(record_uid, by_uid.get(record_uid, [])) + + for record_uid in record_uids: + access_details = view.get_nsf_record_access_detail(record_uid) + print(f"\nRecord UID: {record_uid}") + + if not access_details: + print("Access: No cached access details found.") + continue + + print("Access:") + for access in access_details: + print( + f" - accessor_uid={access.get('access_type_uid')}" + f" | access_type={access.get('access_type')}" + f" | role={access.get('access_role_type')}" + f" | owner={access.get('owner')}" + f" | inherited={access.get('inherited')}" + f" | denied={access.get('denied_access')}" + ) + + permissions = { + "can_view_title": access.get("can_view_title"), + "can_view": access.get("can_view"), + "can_edit": access.get("can_edit"), + "can_list_access": access.get("can_list_access"), + "can_update_access": access.get("can_update_access"), + "can_delete": access.get("can_delete"), + "can_change_ownership": access.get("can_change_ownership"), + "can_request_access": access.get("can_request_access"), + "can_approve_access": access.get("can_approve_access"), + } + print(f" permissions={json.dumps(permissions)}") + + +def nsf_access_list_run(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + # Sample UIDs — replace with the NSF folder/record UIDs you want to inspect. + folder_uids = ["", ""] + record_uids = ["", ""] + + vault = open_vault(keeper_auth_context) + try: + nsf_access_list(vault, folder_uids, record_uids) + 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_access_list_run(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/sdk_examples/nested_shared_folders/nsf_access_list.py b/examples/sdk_examples/nested_shared_folders/nsf_access_list.py new file mode 100644 index 00000000..62daa13d --- /dev/null +++ b/examples/sdk_examples/nested_shared_folders/nsf_access_list.py @@ -0,0 +1,608 @@ +import getpass +import json +import logging +import sqlite3 +from typing import Dict, List, 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_access_list( + vault: vault_online.VaultOnline, + folder_uids: List[str], + record_uids: List[str], + show_inherited: bool = False, + show_denied: bool = False +) -> None: + load_folder = bool(folder_uids) + load_record = bool(record_uids) + view = vault.nsf_data + if view is None: + print("NSF storage is not available on this vault.") + return + nsf_management.load_nsf_access_details(vault, load_folder=load_folder, load_record=load_record, show_inherited=show_inherited, show_denied=show_denied) + + for folder_uid in folder_uids: + folder = view.get_folder(folder_uid) + print(f"\nFolder: {folder.name if folder else '(NSF Folder)'}") + print(f"UID: {folder_uid}") + folder_access_detail = view.get_nsf_folder_access_detail(folder_uid) + accessors = (folder_access_detail or {}).get("accessors") + if not accessors: + print("Access: No cached access details found.") + continue + print("Access:") + for access in accessors: + print( + f" - accessor_uid={access.get('accessor_uid')}" + f" | access_type={access.get('access_type')}" + f" | role={access.get('role')}" + f" | inherited={access.get('inherited')}" + f" | hidden={access.get('hidden')}" + f" | owner={access.get('owner', False)}" + ) + permissions = access.get("permissions") + if permissions: + print(f" permissions={json.dumps(permissions)}") + + for record_uid in record_uids: + print(f"\nRecord UID: {record_uid}") + record_access_detail = view.get_nsf_record_access_detail(record_uid) + if not record_access_detail: + print("Access: No cached access details found.") + continue + print("Access:") + for access in record_access_detail: + print( + f" - accessor_uid={access.get('access_type_uid')}" + f" | access_type={access.get('access_type')}" + f" | role={access.get('access_role_type')}" + f" | owner={access.get('owner')}" + f" | inherited={access.get('inherited')}" + f" | denied={access.get('denied_access')}" + ) + permissions = { + "can_view_title": access.get("can_view_title"), + "can_view": access.get("can_view"), + "can_edit": access.get("can_edit"), + "can_list_access": access.get("can_list_access"), + "can_update_access": access.get("can_update_access"), + "can_delete": access.get("can_delete"), + "can_change_ownership": access.get("can_change_ownership"), + "can_request_access": access.get("can_request_access"), + "can_approve_access": access.get("can_approve_access"), + } + print(f" permissions={json.dumps(permissions)}") + + +def nsf_access_list_run(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + # Sample UIDs — replace with the NSF folder/record UIDs you want to inspect. + folder_uids = ["", ""] + record_uids = ["", ""] + + show_inherited = True + show_denied = True + + vault = open_vault(keeper_auth_context) + try: + nsf_access_list(vault, folder_uids, record_uids, show_inherited, show_denied) + 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_access_list_run(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/sdk_examples/nested_shared_folders/nsf_get.py b/examples/sdk_examples/nested_shared_folders/nsf_get.py index b1db4581..c56e1e91 100644 --- a/examples/sdk_examples/nested_shared_folders/nsf_get.py +++ b/examples/sdk_examples/nested_shared_folders/nsf_get.py @@ -19,8 +19,6 @@ yubikey_authenticate, ) from keepersdk.constants import KEEPER_PUBLIC_HOSTS -import json - from keepersdk.vault import nsf_management, sqlite_storage, vault_online try: diff --git a/examples/sdk_examples/pam_workflow/add_approver_workflow.py b/examples/sdk_examples/pam_workflow/add_approver_workflow.py new file mode 100644 index 00000000..53159dce --- /dev/null +++ b/examples/sdk_examples/pam_workflow/add_approver_workflow.py @@ -0,0 +1,622 @@ +import getpass +import json +import logging +import sqlite3 +from typing import Dict, List, 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.helpers.workflow import ( + WorkflowError, + add_workflow_approvers, + read_workflow, +) +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 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 add_approvers( + vault: vault_online.VaultOnline, + record_identifier: str, + users: List[str], + teams: List[str], + escalation: bool, + escalation_after: Optional[str], +) -> dict: + """ + Add approvers to an existing PAM workflow. + + add_workflow_approvers() does not verify a workflow exists on the + record before attaching approvers, so this checks first via + read_workflow() and raises a clear WorkflowError if none is found. + """ + existing = read_workflow(vault, record_identifier, enterprise_data=None) + if existing.get("status") == "no_workflow": + raise WorkflowError( + f"No workflow configured for record " + f"{existing.get('record_name')} ({existing.get('record_uid')}). " + f"Create one first with create_workflow.py." + ) + + return add_workflow_approvers( + vault, + record_identifier, + users=users, + teams=teams, + escalation=escalation, + escalation_after=escalation_after, + enterprise_data=None, + ) + + +def print_approvers_added(result: dict) -> None: + print("\nApprovers added") + print(f" Record : {result.get('record_name')} ({result.get('record_uid')})") + print(f" Total added : {result.get('approvers_added')}") + + users = result.get("users") or [] + teams = result.get("teams") or [] + if users: + print(" Users:") + for user in users: + print(f" - {user}") + if teams: + print(" Teams:") + for team in teams: + print(f" - {team}") + + if result.get("escalation"): + print(" Escalation approvers : Yes") + if result.get("escalation_after"): + print(f" Notified after : {result['escalation_after']}") + else: + print(" Escalation approvers : No (notified immediately, same as primary approvers)") + + +def add_approver_workflow_run(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + # Record identifier accepts either the record's UID or its title. + # A workflow must already exist on this record. + record_identifier = "" + + # Users/teams to add as approvers. Leave a list empty if not adding + # that type -- at least one of the two must be non-empty. + approver_users: List[str] = [""] + approver_teams: List[str] = [] + + # If True, these approvers are only notified after escalation_after + # elapses without a decision from the primary approvers. + escalation = False + escalation_after: Optional[str] = None + + vault = open_vault(keeper_auth_context) + try: + result = add_approvers( + vault, + record_identifier, + approver_users, + approver_teams, + escalation, + escalation_after, + ) + print_approvers_added(result) + except WorkflowError as exc: + print(f"Workflow error: {exc}") + except errors.KeeperApiError as exc: + print(f"Keeper API error: ({exc.result_code}) {exc.message}") + except KeyboardInterrupt: + print("\nCancelled.") + finally: + close_vault(vault, keeper_auth_context) + + +def main() -> None: + keeper_auth_context, _ = login() + if keeper_auth_context: + add_approver_workflow_run(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/pam_workflow/approve_workflow.py b/examples/sdk_examples/pam_workflow/approve_workflow.py new file mode 100644 index 00000000..fab6695c --- /dev/null +++ b/examples/sdk_examples/pam_workflow/approve_workflow.py @@ -0,0 +1,592 @@ +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.helpers.workflow import ( + WorkflowError, + approve_workflow, + get_pending_approvals, +) +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 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 find_pending_request(vault: vault_online.VaultOnline, flow_uid: str) -> Optional[dict]: + """ + Pre-check: approve_workflow() itself does not verify the flow_uid is a + real, currently-pending request before decoding and posting it -- it + will happily send an approval for an invalid or already-decided + flow_uid and let the router reject it. This look-up is an addition on + top of the SDK function's own behavior, so a bad flow_uid fails here + with a clear message before anything is sent. + """ + result = get_pending_approvals(vault, enterprise_data=None) + for request in result.get("requests") or []: + if request.get("flow_uid") == flow_uid: + return request + return None + + +def print_request_summary(request: dict) -> None: + print("\nRequest to approve:") + print(f" Record : {request.get('record_name')} ({request.get('record_uid')})") + print(f" Requested by : {request.get('requested_by')}") + if request.get("reason"): + print(f" Reason : {request['reason']}") + if request.get("ticket"): + print(f" Ticket : {request['ticket']}") + print(f" Escalated : {'Yes' if request.get('escalated') else 'No'}") + + +def approve_workflow_run(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + # Flow UID of the access request to approve. Run the pending-requests + # listing first to get a current, exact flow_uid. + flow_uid = "" + + vault = open_vault(keeper_auth_context) + try: + # Confirm this flow_uid is actually a pending request before + # approving it -- see find_pending_request()'s docstring for why. + request = find_pending_request(vault, flow_uid) + if request is None: + print(f"Flow UID {flow_uid!r} was not found among your current pending approval requests.") + print("It may already have been decided, may not exist, or you may not be an approver for it.") + return + + print_request_summary(request) + + result = approve_workflow(vault, flow_uid) + print(f"\nApproved. Flow UID: {result.get('flow_uid')}") + print( + "\nNote: if this workflow requires more than one approval " + "(approvals_needed > 1), this may be one of several approvals " + "still needed -- check pending requests or workflow state on " + "the record to see whether access has actually started." + ) + except WorkflowError as exc: + print(f"Workflow error: {exc}") + except errors.KeeperApiError as exc: + print(f"Keeper API error: ({exc.result_code}) {exc.message}") + except KeyboardInterrupt: + print("\nCancelled.") + except Exception as exc: + print(f"Unexpected error: {exc}") + finally: + close_vault(vault, keeper_auth_context) + + +def main() -> None: + keeper_auth_context, _ = login() + if keeper_auth_context: + approve_workflow_run(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/pam_workflow/create_workflow.py b/examples/sdk_examples/pam_workflow/create_workflow.py new file mode 100644 index 00000000..26b86e94 --- /dev/null +++ b/examples/sdk_examples/pam_workflow/create_workflow.py @@ -0,0 +1,634 @@ +import getpass +import json +import logging +import sqlite3 +from typing import Dict, List, 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.helpers.workflow import WorkflowError, create_workflow +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 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 print_workflow_created(result: dict, requested_approvers: List[str]) -> None: + print("\nWorkflow created") + print(f" Record : {result.get('record_name')} ({result.get('record_uid')})") + + cfg = result.get("workflow_config") or {} + print("\nAccess Parameters:") + print(f" Approvals needed : {cfg.get('approvals_needed')}") + print(f" Check-in/out required : {'Yes' if cfg.get('checkout_needed') else 'No'}") + print(f" Access duration : {cfg.get('access_duration')}") + + print("\nRequirements:") + print(f" Reason required : {'Yes' if cfg.get('require_reason') else 'No'}") + print(f" Ticket required : {'Yes' if cfg.get('require_ticket') else 'No'}") + print(f" MFA required : {'Yes' if cfg.get('require_mfa') else 'No'}") + + # create_workflow()'s 'workflow_config' does not echo back + # start_access_on_approval or allowed_times -- run read_workflow() + # against this record to confirm those settings were applied. + print( + "\n(start_access_on_approval and allowed_times are not echoed back " + "by create -- run pam_workflow_read.py against this record to " + "confirm them.)" + ) + + approvers_added = result.get("approvers") or [] + print(f"\nApprovers requested : {len(requested_approvers)}") + print(f"Approvers attached : {len(approvers_added)}") + for approver in approvers_added: + print(f" - {approver}") + + if len(approvers_added) < len(requested_approvers): + missing = [a for a in requested_approvers if a not in approvers_added] + print( + "\nWARNING: the workflow was created, but not all requested " + "approvers were attached. This can happen even though " + "create_workflow() reported success -- attaching approvers is " + "a best-effort follow-up step, not part of the same " + "transaction as creating the workflow itself." + ) + print(f" Missing: {', '.join(missing)}") + print(" Retry with pam_workflow_add_approver.py for the missing entries.") + + +def create_workflow_run(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + # Record identifier accepts either the record's UID or its title. + record_identifier = "" + + # Number of approvals required before access is granted. 0 means no + # approval is required (approvers may still be attached, but + # create_workflow() will just log a warning -- it will never block + # access). + approvals_needed = 1 + + # Users required to approve an access request. Required (non-empty) + # when approvals_needed > 0, or create_workflow() raises WorkflowError + # before calling the API at all. + approvers: List[str] = [""] + + # Single-user check-in/check-out mode: the user must explicitly + # "start" (check out) and "end" (check in) the workflow around access. + checkout_needed = False + + # If True, the access timer starts the moment the request is approved. + # If False (default), it starts when the user checks out. + start_on_approval = False + + require_reason = False + require_ticket = False + require_mfa = False + + # Access duration. Accepts "d", "h", "m", or a bare integer + # (interpreted as minutes). Must be a positive value. + duration = "1d" + + # Optional: restrict access to specific days / a daily time window. + # allowed_days: comma-separated day names or 3-letter abbreviations, + # e.g. "mon,tue,wed,thu,fri". time_range: "HH:MM-HH:MM" (24-hour). + # Leave either as None to leave that restriction unset. + allowed_days: Optional[str] = None + time_range: Optional[str] = None + + vault = open_vault(keeper_auth_context) + try: + result = create_workflow( + vault, + record_identifier, + approvals_needed=approvals_needed, + checkout=checkout_needed, + start_on_approval=start_on_approval, + require_reason=require_reason, + require_ticket=require_ticket, + require_mfa=require_mfa, + duration=duration, + allowed_days=allowed_days, + time_range=time_range, + approvers=approvers, + ) + print_workflow_created(result, approvers) + except WorkflowError as exc: + print(f"Workflow error: {exc}") + except errors.KeeperApiError as exc: + print(f"Keeper API error: ({exc.result_code}) {exc.message}") + except KeyboardInterrupt: + print("\nCancelled.") + except Exception as exc: + print(f"Unexpected error: {exc}") + finally: + close_vault(vault, keeper_auth_context) + + +def main() -> None: + keeper_auth_context, _ = login() + if keeper_auth_context: + create_workflow_run(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/pam_workflow/delete_workflow.py b/examples/sdk_examples/pam_workflow/delete_workflow.py new file mode 100644 index 00000000..f1a85414 --- /dev/null +++ b/examples/sdk_examples/pam_workflow/delete_workflow.py @@ -0,0 +1,590 @@ +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.helpers.workflow import WorkflowError, delete_workflow, read_workflow +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 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 confirm_deletion(record_name: str, record_uid: str) -> bool: + """ + Interactive safety check -- not part of delete_workflow() or the CLI's + own behavior; delete_workflow() itself (and `pam workflow delete`) + deletes immediately and irreversibly with no confirmation step. + """ + print(f"\nAbout to permanently delete the workflow configuration for {record_name} ({record_uid}).") + print( + "This cannot be undone. Any approvers, approval requirements, and " + "access restrictions configured on this workflow will be removed. " + "The record itself is not affected -- only its workflow configuration." + ) + typed = input( + f'\nType the record name exactly ("{record_name}") to confirm, or press Enter to cancel: ' + ).strip() + return typed == record_name + + +def delete_workflow_run(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + # Record identifier accepts either the record's UID or its title. + record_identifier = "" + + # Set True to skip the interactive confirmation prompt below (e.g. for + # unattended/automated use). Leave False to be asked to type the + # record's exact name before anything is deleted. + confirm_delete = False + + vault = open_vault(keeper_auth_context) + try: + # Read first, purely so the confirmation prompt (and the "nothing + # to delete" message) can show the real record name/UID rather + # than just echoing back record_identifier as typed. + # delete_workflow() does its own existence check internally and + # will raise WorkflowError regardless of this read. + current = read_workflow(vault, record_identifier, enterprise_data=None) + if current.get("status") == "no_workflow": + print( + f"No workflow configured for record {current.get('record_name')} " + f"({current.get('record_uid')}). Nothing to delete." + ) + return + + record_name = current.get("record_name") + record_uid = current.get("record_uid") + + if not confirm_delete: + if not confirm_deletion(record_name, record_uid): + print("\nCancelled -- no changes made.") + return + else: + print("\nconfirm_delete is True; skipping interactive confirmation.") + + result = delete_workflow(vault, record_identifier) + print(f"\nWorkflow deleted for {result.get('record_name')} ({result.get('record_uid')}).") + except WorkflowError as exc: + print(f"Workflow error: {exc}") + except errors.KeeperApiError as exc: + print(f"Keeper API error: ({exc.result_code}) {exc.message}") + except KeyboardInterrupt: + print("\nCancelled.") + except Exception as exc: + print(f"Unexpected error: {exc}") + finally: + close_vault(vault, keeper_auth_context) + + +def main() -> None: + keeper_auth_context, _ = login() + if keeper_auth_context: + delete_workflow_run(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/pam_workflow/deny_approver.py b/examples/sdk_examples/pam_workflow/deny_approver.py new file mode 100644 index 00000000..baa5c23a --- /dev/null +++ b/examples/sdk_examples/pam_workflow/deny_approver.py @@ -0,0 +1,591 @@ +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.helpers.workflow import ( + WorkflowError, + deny_workflow, + get_pending_approvals, +) +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 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 find_pending_request(vault: vault_online.VaultOnline, flow_uid: str) -> Optional[dict]: + """ + Look up a single pending approval request by flow UID. Uses + get_pending_approvals() and filters for a matching flow_uid. Returns + the request dict, or None if the flow UID is not currently pending + for this user. + """ + pending = get_pending_approvals(vault, enterprise_data=None) + for request in pending.get("requests") or []: + if request.get("flow_uid") == flow_uid: + return request + return None + + +def print_workflow_denied(result: dict) -> None: + print("\nAccess request denied") + print(f" Flow UID : {result.get('flow_uid')}") + if result.get("reason"): + print(f" Reason : {result['reason']}") + else: + print(" Reason : (none sent -- either none was configured, or the requester's public key could not be resolved)") + + +def deny_approver_run(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + # Flow UID of the pending access request to deny. Get this from the + # "Flow UID" column of the pending-requests listing. + flow_uid = "" + + # Optional reason for the denial. Set to None to send no reason. + denial_reason: Optional[str] = "" + + vault = open_vault(keeper_auth_context) + try: + # deny_workflow() does not itself verify the flow UID is currently + # pending, so look it up first to show who requested access and + # fail with a clear message if it's no longer pending. + pending_request = find_pending_request(vault, flow_uid) + if pending_request is None: + print(f"Flow UID {flow_uid!r} is not currently pending approval for this user.") + print("It may have already been approved, denied, or cancelled, or it may belong to a different approver.") + return + + print(f"\n Requested by : {pending_request.get('requested_by')}") + print(f" Resource : {pending_request.get('record_name')} ({pending_request.get('record_uid')})") + if pending_request.get("reason"): + print(f" Reason given : {pending_request['reason']}") + if pending_request.get("ticket"): + print(f" Ticket : {pending_request['ticket']}") + + result = deny_workflow(vault, flow_uid, reason=denial_reason, enterprise_data=None) + print_workflow_denied(result) + except WorkflowError as exc: + print(f"Workflow error: {exc}") + except errors.KeeperApiError as exc: + print(f"Keeper API error: ({exc.result_code}) {exc.message}") + except KeyboardInterrupt: + print("\nCancelled.") + except Exception as exc: + print(f"Unexpected error: {exc}") + finally: + close_vault(vault, keeper_auth_context) + + +def main() -> None: + keeper_auth_context, _ = login() + if keeper_auth_context: + deny_approver_run(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/pam_workflow/end_workflow.py b/examples/sdk_examples/pam_workflow/end_workflow.py new file mode 100644 index 00000000..fcda31e1 --- /dev/null +++ b/examples/sdk_examples/pam_workflow/end_workflow.py @@ -0,0 +1,624 @@ +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.helpers.workflow import ( + WorkflowError, + end_workflow, + get_workflow_state, +) +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 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 print_current_state(state_result: dict, force_checkin: bool) -> None: + """ + Best-effort context only -- see note in end_workflow_run() about why + this lookup can legitimately fail and is never treated as an error. + Particularly relevant when force_checkin is True: confirming who + currently has the resource checked out, before forcing them out, is + worth the extra call. + """ + status = state_result.get("status") + if status == "no_workflow": + print( + "\nNo workflow is configured for this record. end_workflow() " + "will still be attempted, but is expected to fail unless the " + "identifier is actually a flow UID." + ) + return + if status != "success": + return + print(f"\nCurrent stage : {state_result.get('stage')}") + conditions = state_result.get("conditions") or [] + if conditions: + print(f"Conditions : {', '.join(conditions)}") + checked_out_by = state_result.get("checked_out_by") + if checked_out_by: + print(f"Checked out by: {checked_out_by}") + if force_checkin: + print( + f"\nforce_checkin is enabled -- this will end " + f"{checked_out_by}'s active session, not your own." + ) + + +def print_workflow_ended(result: dict) -> None: + """ + Handles both the normal end ('ended') and force check-in + ('force_checkin') action shapes -- see end_workflow()'s docstring for + which one runs based on the force argument. + """ + action = result.get("action") + if action == "force_checkin": + print("\nSession force checked in") + else: + print("\nWorkflow ended (checked in)") + + if result.get("record_uid"): + print(f" Record : {result.get('record_name')} ({result.get('record_uid')})") + if result.get("flow_uid"): + print(f" Flow UID : {result.get('flow_uid')}") + + if action != "force_checkin": + print( + "\nCredentials may have been rotated as part of check-in, " + "depending on the resource's rotation configuration." + ) + + +def end_workflow_run(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + # Record UID, record name, or Flow UID to check in. + # end_workflow() accepts either form -- if it resolves as a record + # (UID or name), the check-in applies to that record's active + # workflow; otherwise the value is sent as a flow UID directly. + workflow_uid = "" + + # Force check-in another user's active session. + # Only meaningful when single-user check-in/check-out is enabled on + # the workflow, and only approvers can force a check-in -- ending + # your own session does not require this flag. + force_checkin = False + + vault = open_vault(keeper_auth_context) + try: + try: + current_state = get_workflow_state(vault, workflow_uid, enterprise_data=None) + print_current_state(current_state, force_checkin) + except WorkflowError: + logger.debug( + "Skipping current-state preview: %r did not resolve as a " + "record identifier (it may be a flow UID instead).", + workflow_uid, + ) + + print("\nForce checking in..." if force_checkin else "\nEnding workflow (checking in)...") + result = end_workflow(vault, workflow_uid, force=force_checkin) + print_workflow_ended(result) + except WorkflowError as exc: + print(f"Workflow error: {exc}") + except errors.KeeperApiError as exc: + print(f"Keeper API error: ({exc.result_code}) {exc.message}") + except KeyboardInterrupt: + print("\nCancelled.") + except Exception as exc: + print(f"Unexpected error: {exc}") + finally: + close_vault(vault, keeper_auth_context) + + +def main() -> None: + keeper_auth_context, _ = login() + if keeper_auth_context: + end_workflow_run(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/pam_workflow/my_access_workflow.py b/examples/sdk_examples/pam_workflow/my_access_workflow.py new file mode 100644 index 00000000..bd613b69 --- /dev/null +++ b/examples/sdk_examples/pam_workflow/my_access_workflow.py @@ -0,0 +1,576 @@ +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.helpers.workflow import ( + WorkflowError, + get_user_access_state, +) +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 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 print_user_access(result: dict) -> None: + """ + Each entry in 'workflows' is a workflow_state_to_dict() dict -- the + same shape used by get_workflow_state(). Column choice here (flow_uid / + record_name / stage / started / expires) matches `pam workflow + my-access`'s documented table output; other fields in that same dict + (conditions, checked_out_by, approved_by) are omitted here for width. + """ + workflows = result.get("workflows") or [] + if not workflows: + print("\nNo active workflows.") + return + + print(f"\nMy Active Workflows ({len(workflows)})\n") + header = f" {'Flow UID':<14} {'Resource':<20} {'Stage':<10} {'Started':<20} {'Expires':<20}" + print(header) + print(f" {'-' * 14} {'-' * 20} {'-' * 10} {'-' * 20} {'-' * 20}") + + for workflow in workflows: + flow_uid = str(workflow.get("flow_uid") or "") + resource = str(workflow.get("record_name") or "") + stage = str(workflow.get("stage") or "") + started = str(workflow.get("started") or "") + expires = str(workflow.get("expires") or "") + print(f" {flow_uid:<14} {resource:<20} {stage:<10} {started:<20} {expires:<20}") + + +def my_access_workflow_run(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + # get_user_access_state() takes no per-call parameters -- it always + # returns every active workflow session for the currently authenticated + # user, across all resources. Nothing to configure here. + enterprise_data = None + + vault = open_vault(keeper_auth_context) + try: + result = get_user_access_state(vault, enterprise_data=enterprise_data) + print_user_access(result) + except WorkflowError as exc: + print(f"Workflow error: {exc}") + except errors.KeeperApiError as exc: + print(f"Keeper API error: ({exc.result_code}) {exc.message}") + except KeyboardInterrupt: + print("\nCancelled.") + except Exception as exc: + print(f"Unexpected error: {exc}") + finally: + close_vault(vault, keeper_auth_context) + + +def main() -> None: + keeper_auth_context, _ = login() + if keeper_auth_context: + my_access_workflow_run(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/pam_workflow/pending_workflow.py b/examples/sdk_examples/pam_workflow/pending_workflow.py new file mode 100644 index 00000000..99a9661c --- /dev/null +++ b/examples/sdk_examples/pam_workflow/pending_workflow.py @@ -0,0 +1,588 @@ +import getpass +import json +import logging +import sqlite3 +from datetime import datetime +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.helpers.workflow import ( + WorkflowError, + get_pending_approvals, +) +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 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 format_timestamp(ts_ms: Optional[int]) -> str: + """started_on / expires_on come back as epoch milliseconds (0/None if unset).""" + if not ts_ms: + return "(not set)" + dt = datetime.fromtimestamp(ts_ms / 1000).astimezone() + tz_label = dt.strftime("%Z") or dt.tzname() or "local" + return dt.strftime(f"%Y-%m-%d %H:%M:%S {tz_label}") + + +def print_pending_requests(requests: list) -> None: + if not requests: + print("\nNo pending approval requests.") + return + + print(f"\nPending approval requests ({len(requests)}):") + for i, req in enumerate(requests, start=1): + print(f"\n[{i}] {req.get('record_name')} ({req.get('record_uid')})") + print(f" Flow UID : {req.get('flow_uid')}") + print(f" Requested by : {req.get('requested_by')}") + print(f" Started on : {format_timestamp(req.get('started_on'))}") + print(f" Expires on : {format_timestamp(req.get('expires_on'))}") + if req.get("duration"): + print(f" Duration : {req['duration']}") + print(f" Escalated : {'Yes' if req.get('escalated') else 'No'}") + if req.get("reason"): + print(f" Reason : {req['reason']}") + if req.get("ticket"): + print(f" Ticket : {req['ticket']}") + print("\n To act on this request:") + print(f' pam_workflow_approve.py -> flow_uid="{req.get("flow_uid")}"') + print(f' pam_workflow_deny.py -> flow_uid="{req.get("flow_uid")}"') + + +def pending_workflow_run(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + # get_pending_approvals() takes no record identifier -- it returns every + # pending access request across all PAM records the current logged-in + # user is an approver for. + # + # enterprise_data is optional and best-effort: it's only used to resolve + # a requester's numeric user ID to an email/name when the request itself + # doesn't already carry the email. Leave it None for a non-enterprise-admin + # session, or wire in your own IEnterpriseData source for fuller resolution. + enterprise_data = None + + vault = open_vault(keeper_auth_context) + try: + result = get_pending_approvals(vault, enterprise_data=enterprise_data) + print_pending_requests(result.get("requests") or []) + except WorkflowError as exc: + print(f"Workflow error: {exc}") + except errors.KeeperApiError as exc: + print(f"Keeper API error: ({exc.result_code}) {exc.message}") + except KeyboardInterrupt: + print("\nCancelled.") + except Exception as exc: + print(f"Unexpected error: {exc}") + finally: + close_vault(vault, keeper_auth_context) + + +def main() -> None: + keeper_auth_context, _ = login() + if keeper_auth_context: + pending_workflow_run(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/pam_workflow/read_workflow.py b/examples/sdk_examples/pam_workflow/read_workflow.py new file mode 100644 index 00000000..97869714 --- /dev/null +++ b/examples/sdk_examples/pam_workflow/read_workflow.py @@ -0,0 +1,600 @@ +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.helpers.workflow import WorkflowError, read_workflow +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 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 print_workflow(result: dict) -> None: + if result.get("status") == "no_workflow": + print( + f"\nNo workflow configured for record: " + f"{result.get('record_name')} ({result.get('record_uid')})" + ) + print( + f"\nTo create one, see create_workflow.py against record " + f"{result.get('record_uid')}." + ) + return + + print("\nWorkflow Configuration") + print(f" Record : {result.get('record_name')} ({result.get('record_uid')})") + if result.get("created_on"): + print(f" Created on : {result['created_on']}") + + params = result.get("parameters") or {} + print("\nAccess Parameters:") + print(f" Approvals needed : {params.get('approvals_needed')}") + print(f" Check-in/out required : {'Yes' if params.get('checkout_needed') else 'No'}") + print(f" Access duration : {params.get('access_duration')}") + print( + f" Timer starts : " + f"{'On approval' if params.get('start_access_on_approval') else 'On check-out'}" + ) + + print("\nRequirements:") + print(f" Reason required : {'Yes' if params.get('require_reason') else 'No'}") + print(f" Ticket required : {'Yes' if params.get('require_ticket') else 'No'}") + print(f" MFA required : {'Yes' if params.get('require_mfa') else 'No'}") + + allowed_times = params.get("allowed_times") or {} + if allowed_times: + print("\nAllowed Times:") + if allowed_times.get("allowed_days"): + print(f" Days : {', '.join(allowed_times['allowed_days'])}") + if allowed_times.get("time_range"): + print(f" Time : {allowed_times['time_range']}") + + approvers = result.get("approvers") or [] + print(f"\nApprovers ({len(approvers)}):") + if not approvers: + print(" (none)") + for approver in approvers: + if approver.get("type") == "user": + identity = approver.get("email", "") + else: + identity = f"team ({approver.get('type')})" + escalation_note = "" + if approver.get("escalation"): + escalation_note = f" [escalation, after {approver.get('escalation_after')}]" + print(f" - {identity}{escalation_note}") + + +def read_workflow_run(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + # Identifier of the PAM record whose workflow configuration should be + # read. Accepts either the record's UID (base64url) or its title. + record_identifier = "" + + vault = open_vault(keeper_auth_context) + try: + result = read_workflow(vault, record_identifier, enterprise_data=None) + print_workflow(result) + except WorkflowError as exc: + print(f"Workflow error: {exc}") + except errors.KeeperApiError as exc: + print(f"Keeper API error: ({exc.result_code}) {exc.message}") + except KeyboardInterrupt: + print("\nCancelled.") + except Exception as exc: + print(f"Unexpected error: {exc}") + finally: + close_vault(vault, keeper_auth_context) + + +def main() -> None: + keeper_auth_context, _ = login() + if keeper_auth_context: + read_workflow_run(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/pam_workflow/remove_approver_workflow.py b/examples/sdk_examples/pam_workflow/remove_approver_workflow.py new file mode 100644 index 00000000..6c504b4c --- /dev/null +++ b/examples/sdk_examples/pam_workflow/remove_approver_workflow.py @@ -0,0 +1,625 @@ +import getpass +import json +import logging +import sqlite3 +from typing import Dict, List, 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.helpers.workflow import ( + WorkflowError, + read_workflow, + remove_workflow_approvers, +) +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 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 print_current_approvers(approvers: list) -> None: + print(f"\nCurrent approvers ({len(approvers)}):") + if not approvers: + print(" (none)") + return + + for approver in approvers: + if approver.get("type") == "team": + identity = f"team: {approver.get('team_name')} ({approver.get('team_uid')})" + else: + identity = approver.get("email", "(unresolved)") + escalation_note = " [escalation]" if approver.get("escalation") else "" + print(f" - {identity}{escalation_note}") + + +def warn_if_removal_makes_workflow_unsatisfiable( + current_params: dict, + current_approvers: list, + users_to_remove: List[str], +) -> None: + """ + remove_workflow_approvers() does not check this itself -- it will + happily remove approvers below the approvals_needed threshold, leaving + a workflow that can never collect enough approvals. This is an + addition on top of the SDK function's own behavior, not something it + enforces internally. + """ + approvals_needed = current_params.get("approvals_needed") or 0 + if approvals_needed <= 0: + return + + current_user_count = sum(1 for a in current_approvers if a.get("type") != "team") + requested_removals = len( + set(users_to_remove) & {a.get("email") for a in current_approvers} + ) + remaining = current_user_count - requested_removals + + if remaining < approvals_needed: + print( + f"\nWARNING: this workflow requires {approvals_needed} approval(s), " + f"but removing the requested user approver(s) would leave only " + f"{remaining} user approver(s) on the record (team approvers are not " + f"counted here since a team's effective approver count isn't known " + f"from this data). A workflow with fewer approvers than " + f"approvals_needed can never be satisfied. Consider adding " + f"replacement approvers first (add_approver_workflow.py) or lowering " + f"approvals_needed before removing." + ) + + +def remove_approver_workflow_run(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + # Record identifier accepts either the record's UID or its title. + record_identifier = "" + + # Users/teams to remove as approvers. Must exactly match how the + # approver is currently stored -- if the API rejects an entry here, + # read the workflow first to see the exact identifiers currently set. + approver_users_to_remove: List[str] = [""] + approver_teams_to_remove: List[str] = [] + + vault = open_vault(keeper_auth_context) + try: + current = read_workflow(vault, record_identifier, enterprise_data=None) + if current.get("status") == "no_workflow": + print(f"No workflow configured for record {current.get('record_name')} ({current.get('record_uid')}). Nothing to remove approvers from.") + return + + print_current_approvers(current.get("approvers") or []) + warn_if_removal_makes_workflow_unsatisfiable( + current.get("parameters") or {}, + current.get("approvers") or [], + approver_users_to_remove, + ) + + result = remove_workflow_approvers( + vault, + record_identifier, + users=approver_users_to_remove, + teams=approver_teams_to_remove, + enterprise_data=None, + ) + print(f"\nRemoved {result.get('approvers_removed')} approver(s) from {result.get('record_name')} ({result.get('record_uid')}).") + + after = read_workflow(vault, record_identifier, enterprise_data=None) + print_current_approvers(after.get("approvers") or []) + except WorkflowError as exc: + print(f"Workflow error: {exc}") + except errors.KeeperApiError as exc: + print(f"Keeper API error: ({exc.result_code}) {exc.message}") + except KeyboardInterrupt: + print("\nCancelled.") + except Exception as exc: + print(f"Unexpected error: {exc}") + finally: + close_vault(vault, keeper_auth_context) + + +def main() -> None: + keeper_auth_context, _ = login() + if keeper_auth_context: + remove_approver_workflow_run(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/pam_workflow/request_workflow.py b/examples/sdk_examples/pam_workflow/request_workflow.py new file mode 100644 index 00000000..b6f1b60f --- /dev/null +++ b/examples/sdk_examples/pam_workflow/request_workflow.py @@ -0,0 +1,609 @@ +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.helpers.workflow import ( + WorkflowError, + request_workflow_access, +) +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 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 print_request_result(result: dict) -> None: + status = result.get("status") + action = result.get("action") + + if status == "exempt": + # NOTE: the exempt result only carries 'record_uid', not 'record_name' -- + # is_workflow_exempt() short-circuits before the SDK resolves the record's title. + print("\nNo request was sent.") + print(f" {result.get('message')}") + print(f" Record UID : {result.get('record_uid')}") + print( + "\nYou are a record owner or approver on this resource, so you can " + "access it directly without going through the workflow." + ) + return + + if action == "escalated": + print("\nRequest escalated") + print(f" Record : {result.get('record_name')} ({result.get('record_uid')})") + print("\nEscalation approvers have been notified.") + return + + if action == "cancelled": + print("\nRequest cancelled") + print(f" Flow UID : {result.get('flow_uid')}") + print(f" Record : {result.get('record_name')} ({result.get('record_uid')})") + return + + print("\nAccess request submitted") + print(f" Record : {result.get('record_name')} ({result.get('record_uid')})") + print(f" {result.get('message')}") + if result.get("reason"): + print(f" Reason : {result['reason']}") + if result.get("ticket"): + print(f" Ticket : {result['ticket']}") + print( + "\nRun pending_workflow.py as an approver to see this request, or " + "check on it yourself against this record." + ) + + +def request_workflow_run(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + # Record identifier accepts either the record's UID (base64url) or its title. + record_identifier = "" + + # request_workflow_access() supports three mutually exclusive modes: + # 1. Submit a new request -- escalate=False, cancel=False + # 2. Escalate a pending request -- escalate=True, cancel=False + # 3. Cancel a pending/active request -- escalate=False, cancel=True + # Setting both True raises WorkflowError before any network call. Cancel + # takes no reason or ticket -- clear them below if cancelling. + escalate = False + cancel = False + + # Reason/ticket for the access request. Only used in submit mode -- + # must be empty when cancel is True. + reason = "" + ticket = "" + + vault = open_vault(keeper_auth_context) + try: + result = request_workflow_access( + vault, + record_identifier, + reason=reason, + ticket=ticket, + escalate=escalate, + cancel=cancel, + ) + print_request_result(result) + except WorkflowError as exc: + print(f"Workflow error: {exc}") + except errors.KeeperApiError as exc: + print(f"Keeper API error: ({exc.result_code}) {exc.message}") + except KeyboardInterrupt: + print("\nCancelled.") + except Exception as exc: + print(f"Unexpected error: {exc}") + finally: + close_vault(vault, keeper_auth_context) + + +def main() -> None: + keeper_auth_context, _ = login() + if keeper_auth_context: + request_workflow_run(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/pam_workflow/start_workflow.py b/examples/sdk_examples/pam_workflow/start_workflow.py new file mode 100644 index 00000000..e7e4ecf8 --- /dev/null +++ b/examples/sdk_examples/pam_workflow/start_workflow.py @@ -0,0 +1,603 @@ +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.helpers.workflow import ( + WorkflowError, + get_workflow_state, + start_workflow, +) +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 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 print_current_state(state_result: dict) -> None: + """ + Best-effort context only -- get_workflow_state() only resolves record + identifiers (UID or title), not flow UIDs, unlike start_workflow() + itself. If the identifier is actually a flow UID this lookup fails + and the failure is swallowed by the caller; it only skips this + preview, it does not affect the check-out itself. + """ + status = state_result.get("status") + if status == "no_workflow": + print( + "\nNo workflow is configured for this record. start_workflow() " + "will still be attempted, but is expected to fail unless the " + "identifier is actually a flow UID." + ) + return + if status != "success": + return + print(f"\nCurrent stage : {state_result.get('stage')}") + conditions = state_result.get("conditions") or [] + if conditions: + print(f"Conditions : {', '.join(conditions)}") + checked_out_by = state_result.get("checked_out_by") + if checked_out_by: + print(f"Checked out by: {checked_out_by}") + + +def print_workflow_started(result: dict) -> None: + print("\nWorkflow started (checked out)") + if result.get("record_uid"): + print(f" Record : {result.get('record_name')} ({result.get('record_uid')})") + else: + print(f" Flow UID : {result.get('flow_uid')}") + print( + "\nYou may now connect via `pam tunnel` or `pam launch`. Remember to " + "check in when finished with end_workflow.py -- especially important " + "if the workflow uses single-user check-in/check-out, which blocks " + "every other user from checking out the same resource until you do." + ) + + +def start_workflow_run(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + # Record UID, record name, or Flow UID to check out. + # start_workflow() accepts either form -- if it resolves as a record + # (UID or name), the check-out applies to that record's active + # workflow; otherwise the value is sent as a flow UID directly. + workflow_uid = "" + + vault = open_vault(keeper_auth_context) + try: + try: + current_state = get_workflow_state(vault, workflow_uid, enterprise_data=None) + print_current_state(current_state) + except WorkflowError: + logger.debug( + "Skipping current-state preview: %r did not resolve as a " + "record identifier (it may be a flow UID instead).", + workflow_uid, + ) + + print("\nStarting workflow (checking out)...") + result = start_workflow(vault, workflow_uid) + print_workflow_started(result) + except WorkflowError as exc: + print(f"Workflow error: {exc}") + except errors.KeeperApiError as exc: + print(f"Keeper API error: ({exc.result_code}) {exc.message}") + except KeyboardInterrupt: + print("\nCancelled.") + except Exception as exc: + print(f"Unexpected error: {exc}") + finally: + close_vault(vault, keeper_auth_context) + + +def main() -> None: + keeper_auth_context, _ = login() + if keeper_auth_context: + start_workflow_run(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/pam_workflow/state_workflow.py b/examples/sdk_examples/pam_workflow/state_workflow.py new file mode 100644 index 00000000..0e76c481 --- /dev/null +++ b/examples/sdk_examples/pam_workflow/state_workflow.py @@ -0,0 +1,607 @@ +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.helpers.workflow import WorkflowError, get_workflow_state +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 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 print_workflow_state(result: dict) -> None: + """ + Field names below mirror `pam workflow state`'s documented CLI output + (Stage / Conditions / Checked out by / Started / Expires / Approved + by). Every field is read with .get() so a mismatch prints nothing for + that line rather than raising. + """ + status = result.get("status") + + if status == "exempt": + # Same shape as request_workflow_access()'s exempt result -- + # only 'record_uid' is present, no 'record_name'. + print("\nNo workflow restrictions apply.") + print(f" {result.get('message')}") + print(f" Record UID : {result.get('record_uid')}") + print( + "\nYou are a record owner or approver on this resource, so you " + "can access it directly without going through the workflow." + ) + return + + if status == "no_workflow": + print("\nNo workflow found for this record.") + print(f" {result.get('message')}") + print(f" Record UID : {result.get('record_uid')}") + return + + # status == "success" + print("\nWorkflow State") + record_name = result.get("record_name") + record_uid = result.get("record_uid") + if record_name or record_uid: + print(f"Record: {record_name} ({record_uid})") + + print(f"\n Stage: {result.get('stage')}") + conditions = result.get("conditions") or [] + print(f" Conditions: {', '.join(conditions) if conditions else 'None'}") + + checked_out_by = result.get("checked_out_by") + if checked_out_by: + print(f" Checked out by: {checked_out_by}") + + started = result.get("started") + if started: + print(f" Started: {started}") + + expires = result.get("expires") + if expires: + print(f" Expires: {expires}") + + approved_by = result.get("approved_by") or [] + if approved_by: + print(" Approved by:") + for approval in approved_by: + if isinstance(approval, dict): + who = approval.get("user") or approval.get("email") or "unknown" + when = approval.get("approved_on") or approval.get("timestamp") or "" + print(f" - {who} at {when}" if when else f" - {who}") + else: + print(f" - {approval}") + + +def state_workflow_run(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + # Identifier of the PAM record to check workflow state for. + # Accepts either the record's UID (base64url) or its title. + record_identifier = "" + + vault = open_vault(keeper_auth_context) + try: + result = get_workflow_state(vault, record_identifier, enterprise_data=None) + print_workflow_state(result) + except WorkflowError as exc: + print(f"Workflow error: {exc}") + except errors.KeeperApiError as exc: + print(f"Keeper API error: ({exc.result_code}) {exc.message}") + except KeyboardInterrupt: + print("\nCancelled.") + except Exception as exc: + print(f"Unexpected error: {exc}") + finally: + close_vault(vault, keeper_auth_context) + + +def main() -> None: + keeper_auth_context, _ = login() + if keeper_auth_context: + state_workflow_run(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/examples/sdk_examples/pam_workflow/update_workflow.py b/examples/sdk_examples/pam_workflow/update_workflow.py new file mode 100644 index 00000000..16f227b0 --- /dev/null +++ b/examples/sdk_examples/pam_workflow/update_workflow.py @@ -0,0 +1,699 @@ +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.helpers.workflow import WorkflowError, read_workflow, update_workflow +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 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 print_parameters(label: str, params: dict) -> None: + print(f"\n{label}:") + print(f" Approvals needed : {params.get('approvals_needed')}") + print(f" Check-in/out required : {'Yes' if params.get('checkout_needed') else 'No'}") + print( + f" Timer starts : " + f"{'On approval' if params.get('start_access_on_approval') else 'On check-out'}" + ) + print(f" Access duration : {params.get('access_duration')}") + print(f" Reason required : {'Yes' if params.get('require_reason') else 'No'}") + print(f" Ticket required : {'Yes' if params.get('require_ticket') else 'No'}") + print(f" MFA required : {'Yes' if params.get('require_mfa') else 'No'}") + + allowed_times = params.get("allowed_times") or {} + if allowed_times: + print(f" Allowed days : {', '.join(allowed_times.get('allowed_days') or []) or '(any)'}") + print(f" Allowed time range(s) : {', '.join(allowed_times.get('time_ranges') or []) or '(any)'}") + else: + print(" Allowed days/time range : (unrestricted)") + + +def warn_if_revert_to_default_will_be_ignored( + current_params: dict, + checkout_needed: Optional[bool], + start_on_approval: Optional[bool], + require_reason: Optional[bool], + require_ticket: Optional[bool], + require_mfa: Optional[bool], + approvals_needed: Optional[int], +) -> None: + checks = [ + ("checkout_needed", checkout_needed, "checkout_needed"), + ("start_on_approval", start_on_approval, "start_access_on_approval"), + ("require_reason", require_reason, "require_reason"), + ("require_ticket", require_ticket, "require_ticket"), + ("require_mfa", require_mfa, "require_mfa"), + ] + for name, new_value, current_key in checks: + if new_value is False and current_params.get(current_key): + print( + f"\nWARNING: {name} is set to False, and the record currently " + f"has it as True. This revert will very likely be silently " + f"ignored -- use delete + create instead to actually turn it off." + ) + + if approvals_needed == 0 and (current_params.get("approvals_needed") or 0) > 0: + print( + "\nWARNING: approvals_needed is set to 0, and the record currently " + "requires approvals. This revert will very likely be silently " + "ignored -- use delete + create instead." + ) + + +def warn_if_temporal_update_is_destructive( + current_params: dict, + allowed_days: Optional[str], + time_range: Optional[str], +) -> None: + """ + update_workflow() rebuilds the record's entire temporal filter from + whichever of allowed_days/time_range is passed to *this call* -- it + does not merge with what's already on the record. So setting only one + of the two while the record currently has the other configured will + silently drop it. This checks the record's current state and warns + before sending the update if that's about to happen. + """ + if allowed_days is None and time_range is None: + return + + current_allowed_times = current_params.get("allowed_times") or {} + current_has_days = bool(current_allowed_times.get("allowed_days")) + current_has_range = bool(current_allowed_times.get("time_ranges")) + + if allowed_days is not None and time_range is None and current_has_range: + print( + "\nWARNING: allowed_days is set but time_range is not, and this " + "record currently has a time range configured. Sending this " + "update will DROP the existing time range -- set time_range " + "explicitly to the current value if you want to keep it." + ) + + if time_range is not None and allowed_days is None and current_has_days: + print( + "\nWARNING: time_range is set but allowed_days is not, and this " + "record currently has allowed days configured. Sending this " + "update will DROP the existing allowed days -- set allowed_days " + "explicitly to the current value if you want to keep them." + ) + + +def update_workflow_run(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + # Record identifier accepts either the record's UID or its title. + # A workflow must already exist on this record. + record_identifier = "" + + # Sparse update: every field below defaults to None, which means + # "leave this field exactly as it currently is." update_workflow() + # fetches the existing config first and only overwrites fields set to + # a non-None value here. At least one field must be non-None. + approvals_needed: Optional[int] = 1 + checkout_needed: Optional[bool] = None + start_on_approval: Optional[bool] = None + require_reason: Optional[bool] = None + require_ticket: Optional[bool] = None + require_mfa: Optional[bool] = False + + # Same "d"/"h"/"m" format as create. None leaves the existing + # duration untouched. + duration: Optional[str] = None + + # NOTE: unlike every other field above, these two are not + # independently sparse -- setting either one overwrites the record's + # entire allowed-times filter rather than merging with the existing + # allowed_days/time_range. warn_if_temporal_update_is_destructive() + # below checks the record's current state and warns if this would + # silently drop the other one. + allowed_days: Optional[str] = None + time_range: Optional[str] = None + + vault = open_vault(keeper_auth_context) + try: + # Read the current configuration first: it lets us print a real + # before/after diff (update_workflow() doesn't echo the resulting + # config back), and lets us check the temporal-filter landmine + # above against the record's actual current state. + before = read_workflow(vault, record_identifier, enterprise_data=None) + if before.get("status") == "no_workflow": + print( + f"No workflow configured for record {before.get('record_name')} " + f"({before.get('record_uid')}). Create one first with create_workflow.py." + ) + return + + print_parameters("Current configuration", before.get("parameters") or {}) + warn_if_temporal_update_is_destructive( + before.get("parameters") or {}, allowed_days, time_range + ) + warn_if_revert_to_default_will_be_ignored( + before.get("parameters") or {}, + checkout_needed, + start_on_approval, + require_reason, + require_ticket, + require_mfa, + approvals_needed, + ) + + update_workflow( + vault, + record_identifier, + approvals_needed=approvals_needed, + checkout=checkout_needed, + start_on_approval=start_on_approval, + require_reason=require_reason, + require_ticket=require_ticket, + require_mfa=require_mfa, + duration=duration, + allowed_days=allowed_days, + time_range=time_range, + ) + + after = read_workflow(vault, record_identifier, enterprise_data=None) + print_parameters("Configuration after update", after.get("parameters") or {}) + except WorkflowError as exc: + print(f"Workflow error: {exc}") + except errors.KeeperApiError as exc: + print(f"Keeper API error: ({exc.result_code}) {exc.message}") + except KeyboardInterrupt: + print("\nCancelled.") + except Exception as exc: + print(f"Unexpected error: {exc}") + finally: + close_vault(vault, keeper_auth_context) + + +def main() -> None: + keeper_auth_context, _ = login() + if keeper_auth_context: + update_workflow_run(keeper_auth_context) + else: + print("Login failed.") + + +if __name__ == "__main__": + main() diff --git a/keepercli-package/src/keepercli/__init__.py b/keepercli-package/src/keepercli/__init__.py index ba3f4bde..37b8df1a 100644 --- a/keepercli-package/src/keepercli/__init__.py +++ b/keepercli-package/src/keepercli/__init__.py @@ -9,5 +9,5 @@ # Contact: commander@keepersecurity.com # -__version__ = '1.2.4' +__version__ = '1.2.8' diff --git a/keepercli-package/src/keepercli/commands/nsf_commands.py b/keepercli-package/src/keepercli/commands/nsf_commands.py index 14384095..887ffea5 100644 --- a/keepercli-package/src/keepercli/commands/nsf_commands.py +++ b/keepercli-package/src/keepercli/commands/nsf_commands.py @@ -11,7 +11,6 @@ NsfRemovePreviewItem, NsfRemoveResult, ) -from keepersdk.vault import share_management_utils from . import base from .record_edit import RecordEditMixin, record_fields_description, ParsedFieldValue @@ -317,6 +316,14 @@ def add_arguments_to_parser(parser: argparse.ArgumentParser) -> None: '--unmask', dest='unmask', action='store_true', help='Reveal masked field values (passwords, secrets)', ) + parser.add_argument( + '--show-inherited', dest='show_inherited', action='store_true', + help='Include inherited permission entries in the accessor list', + ) + parser.add_argument( + '--show-denied', dest='show_denied', action='store_true', + help='Include denied-access permission entries in the accessor list', + ) def execute(self, context: KeeperParams, **kwargs): vault = _require_vault(context) @@ -327,16 +334,18 @@ def execute(self, context: KeeperParams, **kwargs): fmt = kwargs.get('format') or 'detail' verbose = kwargs.get('verbose', False) unmask = kwargs.get('unmask', False) + show_inherited = kwargs.get('show_inherited', False) + show_denied = kwargs.get('show_denied', False) def _run(): - return nsf_management.get_nsf_item(vault, uid) + return nsf_management.get_nsf_item(vault, uid, show_inherited=show_inherited, show_denied=show_denied) detail = _wrap_nsf('nsf-get', _run) if detail.get('item_type') == 'folder': if fmt == 'json': - self._print_folder_json(detail, verbose) + self._print_folder_json(detail, verbose, show_inherited, show_denied) else: - self._print_folder_detail(detail, verbose) + self._print_folder_detail(detail, verbose, show_inherited, show_denied) return if unmask: @@ -346,12 +355,16 @@ def _run(): detail.get('record_uid', uid), ) if fmt == 'json': - self._print_record_json(vault, detail, verbose, unmask) + self._print_record_json(vault, detail, verbose, unmask, show_inherited, show_denied) else: - self._print_record_detail(detail, verbose, unmask) + self._print_record_detail(detail, verbose, unmask, show_inherited, show_denied) @staticmethod - def _print_folder_detail(detail: Dict[str, Any], verbose: bool) -> None: + def _print_folder_detail( + detail: Dict[str, Any], + verbose: bool, + show_inherited: bool, + show_denied: bool) -> None: logger.info('') logger.info('{0:>25s}: {1}'.format('NSF Folder UID', detail.get('nsf_folder_uid', ''))) logger.info('{0:>25s}: {1}'.format('Name', detail.get('name', ''))) @@ -365,10 +378,16 @@ def _print_folder_detail(detail: Dict[str, Any], verbose: bool) -> None: verbose, owner_username=detail.get('owner_username'), owner_account_uid=detail.get('owner_account_uid'), + show_inherited=show_inherited, + show_denied=show_denied, ) @staticmethod - def _print_folder_json(detail: Dict[str, Any], verbose: bool) -> None: + def _print_folder_json( + detail: Dict[str, Any], + verbose: bool, + show_inherited: bool, + show_denied: bool) -> None: fo = { 'nsf_folder_uid': detail.get('nsf_folder_uid'), 'name': detail.get('name'), @@ -383,18 +402,27 @@ def _print_folder_json(detail: Dict[str, Any], verbose: bool) -> None: for fr in access.get('results') or []: if not fr.get('success'): continue - accessors = fr.get('accessors') or [] + accessors = [ + a for a in (fr.get('accessors') or []) + if (show_inherited or not a.get('inherited')) + and (show_denied or not a.get('denied_access')) + ] if accessors: owner_username = detail.get('owner_username') owner_account_uid = detail.get('owner_account_uid') - fo['accessors'] = accessors if verbose else [ - { - 'username': a.get('username'), - 'role': nsf_common.folder_access_role_label( - a, owner_username, owner_account_uid), - } - for a in accessors - ] + if verbose: + fo['accessors'] = accessors + else: + fo['accessors'] = [ + { + 'username': a.get('username'), + 'role': nsf_common.folder_access_role_label( + a, owner_username, owner_account_uid), + 'inherited': bool(a.get('inherited')), + 'denied_access': bool(a.get('denied_access')), + } + for a in accessors + ] logger.info(json.dumps(fo, indent=2)) @staticmethod @@ -402,13 +430,19 @@ def _print_folder_access( access: Dict[str, Any], verbose: bool, owner_username: Optional[str] = None, - owner_account_uid: Optional[str] = None) -> None: + owner_account_uid: Optional[str] = None, + show_inherited: bool = False, + show_denied: bool = False) -> None: for fr in access.get('results') or []: if not fr.get('success'): err = fr.get('error') or {} logger.warning(' Access error: %s — %s', err.get('status'), err.get('message')) continue - accessors = fr.get('accessors') or [] + accessors = [ + a for a in (fr.get('accessors') or []) + if (show_inherited or not a.get('inherited')) + and (show_denied or not a.get('denied_access')) + ] if not accessors: continue logger.info('') @@ -418,10 +452,20 @@ def _print_folder_access( role = nsf_common.folder_access_role_label( a, owner_username, owner_account_uid) logger.info('{0:>25s}: {1}'.format(label, role)) + if a.get('inherited'): + logger.info('{0:>25s}: Yes'.format('Inherited')) + if a.get('denied_access'): + logger.info('{0:>25s}: Yes'.format('Denied Access')) if verbose and a.get('permissions'): logger.info('{0:>25s}: {1}'.format('', json.dumps(a.get('permissions', {})))) - def _print_record_detail(self, detail: Dict[str, Any], verbose: bool, unmask: bool) -> None: + def _print_record_detail( + self, + detail: Dict[str, Any], + verbose: bool, + unmask: bool, + show_inherited: bool, + show_denied: bool) -> None: record_uid = detail.get('record_uid', '') logger.info('') logger.info('{0:>20s}: {1}'.format('UID', record_uid)) @@ -462,14 +506,17 @@ def _print_record_detail(self, detail: Dict[str, Any], verbose: bool, unmask: bo for i, line in enumerate(notes.split('\n')): logger.info('{0:>21s} {1}'.format('Notes:' if i == 0 else '', line.strip())) - self._print_record_permissions(detail.get('record_accesses') or [], verbose) + self._print_record_permissions( + detail.get('record_accesses') or [], verbose, show_inherited, show_denied) def _print_record_json( self, vault, detail: Dict[str, Any], verbose: bool, - unmask: bool) -> None: + unmask: bool, + show_inherited: bool, + show_denied: bool) -> None: ro: Dict[str, Any] = { 'record_uid': detail.get('record_uid'), 'title': detail.get('title'), @@ -484,7 +531,11 @@ def _print_record_json( if detail.get('notes'): ro['notes'] = detail['notes'] - accesses = detail.get('record_accesses') or [] + accesses = [ + a for a in (detail.get('record_accesses') or []) + if (show_inherited or not a.get('inherited')) + and (show_denied or not a.get('denied_access')) + ] if accesses: ro['user_permissions'] = [ { @@ -492,6 +543,8 @@ def _print_record_json( 'owner': a.get('owner', False), 'editable': a.get('can_edit', False), 'role': nsf_common.access_role_label(a), + 'inherited': bool(a.get('inherited')), + 'denied_access': bool(a.get('denied_access')), **({flag: a.get(flag) for flag in ( 'can_view_title', 'can_edit', 'can_view', 'can_list_access', 'can_update_access', 'can_delete', @@ -517,7 +570,16 @@ def _extract_field_value(fields: List[Any], field_type: str) -> str: return '' @staticmethod - def _print_record_permissions(accesses: List[Dict[str, Any]], verbose: bool) -> None: + def _print_record_permissions( + accesses: List[Dict[str, Any]], + verbose: bool, + show_inherited: bool = False, + show_denied: bool = False) -> None: + accesses = [ + a for a in accesses + if (show_inherited or not a.get('inherited')) + and (show_denied or not a.get('denied_access')) + ] if not accesses: return logger.info('') @@ -534,6 +596,10 @@ def _print_record_permissions(accesses: List[Dict[str, Any]], verbose: bool) -> can_share = a.get('can_approve_access', False) or a.get('can_update_access', False) logger.info(' Shareable: ' + ('Yes' if can_share else 'No')) logger.info(' Read-Only: ' + ('Yes' if not can_edit else 'No')) + if a.get('inherited'): + logger.info(' Inherited: Yes') + if a.get('denied_access'): + logger.info(' Denied Access: Yes') if verbose: logger.info(f' {"Permission":<20} Value') logger.info(f' {"-"*20} -----') @@ -1270,7 +1336,7 @@ def _collect_targets(cls, vault, recipients, folder_arg, context): for raw in recipients: if raw in ('@existing', '@current'): access = nsf_management.get_nsf_folder_access(vault, [ - nsf_management.resolve_nsf_folder_uid(vault, folder_arg) or folder_arg]) + nsf_management.resolve_nsf_folder_uid(vault, folder_arg) or folder_arg], show_inherited=True, show_denied=True) for fr in access.get('results') or []: if not fr.get('success'): continue @@ -1615,9 +1681,10 @@ def __init__(self): prog='nsf-load-access', description='Load access details for every NSF folder and record into the in-memory vault cache', ) + self.add_arguments_to_parser(parser) super().__init__(parser) - def add_arguments_to_parser(parser: argparse.ArgumentParser) -> None: + def add_arguments_to_parser(self, parser: argparse.ArgumentParser) -> None: parser.add_argument('--folders', dest='load_folder', action='store_true', help='Load access details for folders') parser.add_argument('--records', dest='load_record', action='store_true', @@ -1631,7 +1698,7 @@ def execute(self, context: KeeperParams, **kwargs): raise base.CommandError('At least one of --folders or --records is required') def _run(): - return nsf_management.load_nsf_access_details(vault) + return nsf_management.load_nsf_access_details(vault, load_folder=load_folder, load_record=load_record) loaded = _wrap_nsf('nsf-load-access', _run) logger.info( diff --git a/keepersdk-package/src/keepersdk/__init__.py b/keepersdk-package/src/keepersdk/__init__.py index f189b014..6b7ebdc9 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.7' +__version__ = '1.2.8' background.init() diff --git a/keepersdk-package/src/keepersdk/constants.py b/keepersdk-package/src/keepersdk/constants.py index 0aebc2f4..368dec7f 100644 --- a/keepersdk-package/src/keepersdk/constants.py +++ b/keepersdk-package/src/keepersdk/constants.py @@ -10,4 +10,4 @@ DEFAULT_KEEPER_SERVER = KEEPER_PUBLIC_HOSTS['US'] DEFAULT_DEVICE_NAME = 'Python Keeper API' -CLIENT_VERSION = 'c18.0.0' +CLIENT_VERSION = 'c18.1.0' diff --git a/keepersdk-package/src/keepersdk/vault/nsf_common.py b/keepersdk-package/src/keepersdk/vault/nsf_common.py index 26cba3ce..1c8d0a0e 100644 --- a/keepersdk-package/src/keepersdk/vault/nsf_common.py +++ b/keepersdk-package/src/keepersdk/vault/nsf_common.py @@ -507,7 +507,7 @@ def collect_nsf_folder_accessors(vault: VaultOnline, folder_uid: str) -> List[Di return accessors from .nsf_management import get_nsf_folder_access try: - info = get_nsf_folder_access(vault, [folder_uid]) + info = get_nsf_folder_access(vault, [folder_uid], show_inherited=True, show_denied=True) for result in info.get('results', []): if result.get('success'): accessors.extend(result.get('accessors', [])) @@ -568,7 +568,7 @@ def collect_nsf_record_accessors(vault: VaultOnline, record_uid: str) -> List[Di return accessors from .nsf_management import get_nsf_record_accesses try: - info = get_nsf_record_accesses(vault, [record_uid]) + info = get_nsf_record_accesses(vault, [record_uid], show_inherited=True, show_denied=True) accessors.extend(info.get('record_accesses', [])) except Exception: pass @@ -683,14 +683,10 @@ def folder_inherits_parent_permissions(vault: VaultOnline, folder_uid: str) -> b def ensure_folder_direct_permissions( vault: VaultOnline, - folder_uid: str, - *, - request_sync: bool = False) -> bool: - """Disable parent permission inheritance so folder access changes apply locally.""" + folder_uid: str) -> bool: + """Check whether folder permission inheritance is enabled.""" if not folder_inherits_parent_permissions(vault, folder_uid): return False - from .nsf_management import update_nsf_folder - update_nsf_folder(vault, folder_uid, inherit_permissions=False, request_sync=request_sync) return True @@ -712,3 +708,103 @@ def access_role_label(access: Dict[str, Any]) -> str: if access.get('can_view') or access.get('can_view_title'): return 'viewer' return 'unknown' + + +# --------------------------------------------------------------------------- +# Folder access state and request planning for child-folder access changes +# --------------------------------------------------------------------------- +# +# A child folder accessor is one of: +# * direct - its own access row (and folder-key edge) on this folder +# * inherited - access flowing from a parent; no folder-key edge here +# * denied - inherited access explicitly denied on this folder +# * none - no access +# +# The backend refuses to change the folder key on another user's existing +# access row, so turning inherited access into an explicit role must be an +# *add* carrying the recipient-encrypted folder key, not an update. Without +# that key edge the child disappears from the recipient's vault when the +# parent grant is later revoked. + +FOLDER_ACCESS_NONE = 'none' +FOLDER_ACCESS_INHERITED = 'inherited' +FOLDER_ACCESS_DIRECT = 'direct' +FOLDER_ACCESS_DENIED = 'denied' + +FOLDER_ACCESS_ADD = 'folderAccessAdds' +FOLDER_ACCESS_UPDATE = 'folderAccessUpdates' +FOLDER_ACCESS_REMOVE = 'folderAccessRemoves' + + +def folder_access_state_from_accessors(accessors: List[Dict[str, Any]]) -> str: + """Classify the accessor rows of one actor on one folder. + + ``accessors`` are rows in ``get_nsf_folder_access`` shape (fetched with + ``show_inherited=True, show_denied=True``). The server may report an + inherited row alongside a direct or denied one: a denial wins, then any + direct (non-inherited) row, then inherited. + """ + rows = [a for a in (accessors or []) if a] + if not rows: + return FOLDER_ACCESS_NONE + if any(a.get('denied_access') for a in rows): + return FOLDER_ACCESS_DENIED + if any(not a.get('inherited') for a in rows): + return FOLDER_ACCESS_DIRECT + return FOLDER_ACCESS_INHERITED + + +def plan_folder_access_change(state: str, action: str) -> List[Dict[str, Any]]: + """Return the ordered folder access requests for *action* given *state*. + + ``action`` is ``'grant'`` (set/change a role), ``'deny'`` or ``'remove'``. + Each step is ``{'request': , 'include_folder_key': bool, + 'denied_access': bool}``. Steps are sent sequentially and a later step is + sent only when the previous one succeeded. + + * inherited -> grant : folderAccessAdds + recipient-encrypted folderKey + * denied -> grant : folderAccessRemoves (denial), then + folderAccessAdds + folderKey + * none -> grant : folderAccessAdds + folderKey + * direct -> grant : folderAccessUpdates, no folderKey + * inherited -> deny : folderAccessUpdates + deniedAccess=True, no folderKey + * denied -> deny / remove : nothing to send + * direct -> remove: folderAccessRemoves + * none -> remove: folderAccessRemoves (the server reports the outcome) + + Raises ValueError for transitions that are not allowed. + """ + add_with_key = {'request': FOLDER_ACCESS_ADD, 'include_folder_key': True, 'denied_access': False} + update = {'request': FOLDER_ACCESS_UPDATE, 'include_folder_key': False, 'denied_access': False} + remove = {'request': FOLDER_ACCESS_REMOVE, 'include_folder_key': False, 'denied_access': False} + + if action == 'grant': + if state == FOLDER_ACCESS_DIRECT: + return [update] + if state == FOLDER_ACCESS_DENIED: + return [remove, add_with_key] + return [add_with_key] + + if action == 'deny': + if state == FOLDER_ACCESS_INHERITED: + return [dict(update, denied_access=True)] + if state == FOLDER_ACCESS_DENIED: + return [] + raise ValueError(f'Only inherited access can be denied (current state: {state})') + + if action == 'remove': + if state in (FOLDER_ACCESS_DIRECT, FOLDER_ACCESS_NONE): + return [remove] + if state == FOLDER_ACCESS_DENIED: + return [] + raise ValueError('Inherited access cannot be removed directly; it comes from a parent folder') + + raise ValueError(f'Unknown folder access action: {action}') + + +def current_folder_role_name(accessors: List[Dict[str, Any]]) -> Optional[str]: + """AccessRoleType name of the most specific row (direct before inherited).""" + for a in sorted(accessors or [], key=lambda r: bool(r.get('inherited'))): + if a.get('role'): + return a['role'] + return None diff --git a/keepersdk-package/src/keepersdk/vault/nsf_management.py b/keepersdk-package/src/keepersdk/vault/nsf_management.py index 69cc210b..b0c33936 100644 --- a/keepersdk-package/src/keepersdk/vault/nsf_management.py +++ b/keepersdk-package/src/keepersdk/vault/nsf_management.py @@ -391,7 +391,9 @@ def get_nsf_folder_detail( vault: VaultOnline, folder_uid: str, *, - include_access: bool = True) -> Dict[str, Any]: + include_access: bool = True, + show_inherited: bool = False, + show_denied: bool = False) -> Dict[str, Any]: """Folder detail payload for ``nsf-get`` (folder branch).""" folder = _nsf_view(vault).get_folder(folder_uid) if folder is None: @@ -422,7 +424,7 @@ def get_nsf_folder_detail( if include_access: try: - access = get_nsf_folder_access(vault, [folder_uid]) + access = get_nsf_folder_access(vault, [folder_uid], show_inherited=show_inherited, show_denied=show_denied) owner_username = result.get('owner_username') or '' owner_account_uid = result.get('owner_account_uid') or '' for fr in access.get('results') or []: @@ -440,7 +442,9 @@ def get_nsf_record_detail( vault: VaultOnline, record_uid: str, *, - include_access: bool = True) -> Dict[str, Any]: + include_access: bool = True, + show_inherited: bool = False, + show_denied: bool = False) -> Dict[str, Any]: """Record detail payload for ``nsf-get`` (record branch).""" meta = load_nsf_record_metadata(vault, record_uid) entry = _nsf_view(vault).get_record(record_uid) @@ -460,7 +464,7 @@ def get_nsf_record_detail( result['folder'] = meta['folder_location'] if include_access: try: - result['record_accesses'] = get_nsf_record_accesses(vault, [record_uid]).get( + result['record_accesses'] = get_nsf_record_accesses(vault, [record_uid], show_inherited=show_inherited, show_denied=show_denied).get( 'record_accesses', []) except Exception: result['record_accesses'] = [] @@ -471,16 +475,18 @@ def get_nsf_item( vault: VaultOnline, uid_or_title: str, *, - include_access: bool = True) -> Dict[str, Any]: + include_access: bool = True, + show_inherited: bool = False, + show_denied: bool = False) -> Dict[str, Any]: """Resolve and return folder or record detail.""" folder_uid = resolve_nsf_folder_uid(vault, uid_or_title) if folder_uid: return {'item_type': 'folder', **get_nsf_folder_detail( - vault, folder_uid, include_access=include_access)} + vault, folder_uid, include_access=include_access, show_inherited=show_inherited, show_denied=show_denied)} record_uid = resolve_nsf_record_uid(vault, uid_or_title) if record_uid: return {'item_type': 'record', **get_nsf_record_detail( - vault, record_uid, include_access=include_access)} + vault, record_uid, include_access=include_access, show_inherited=show_inherited, show_denied=show_denied)} raise NsfError(f'Cannot find NSF folder or record: {uid_or_title}') @@ -1038,7 +1044,9 @@ def get_nsf_record_details( def get_nsf_record_accesses( vault: VaultOnline, - record_uids: Iterable[str]) -> Dict[str, Any]: + record_uids: Iterable[str], + show_inherited: bool = False, + show_denied: bool = False) -> Dict[str, Any]: """``vault/records/v3/details/access``, chunked at 100 UIDs per request.""" uids = [resolve_nsf_record_uid(vault, u) or u for u in record_uids] uids = [u for u in uids if u] @@ -1058,6 +1066,8 @@ def get_nsf_record_accesses( for ra in rs.recordAccesses: d = ra.data ai = ra.accessorInfo + if (not show_inherited and d.inherited) or not (show_denied and d.deniedAccess): + continue ao = { 'record_uid': utils.base64_url_encode(d.recordUid), 'accessor_name': ai.name, @@ -1103,17 +1113,21 @@ def _resolve_uid_to_username(vault: VaultOnline, uid_b64: str) -> Optional[str]: """Safety cap on continuation-token pages consumed per UID chunk.""" -def _parse_folder_accessor(vault: VaultOnline, a: Any) -> Dict[str, Any]: +def _parse_folder_accessor(vault: VaultOnline, a: Any, show_inherited: bool = False, show_denied: bool = False) -> Dict[str, Any]: auid = utils.base64_url_encode(a.accessTypeUid) at = folder_pb2.AccessType.Name(a.accessType) rt = folder_pb2.AccessRoleType.Name(a.accessRoleType) username = None if at == 'AT_USER': username = _resolve_uid_to_username(vault, auid) + if (not show_inherited and a.inherited) or (not show_denied and a.deniedAccess): + return {} ai = { 'accessor_uid': auid, 'access_type': at, 'role': rt, 'access_role_type': int(a.accessRoleType), - 'inherited': bool(a.inherited), 'hidden': bool(a.hidden), + 'inherited': bool(a.inherited), + 'denied_access': bool(a.deniedAccess), + 'hidden': bool(a.hidden), 'username': username, 'date_created': a.dateCreated or None, 'last_modified': a.lastModified or None, @@ -1140,7 +1154,7 @@ def _parse_folder_accessor(vault: VaultOnline, a: Any) -> Dict[str, Any]: def _fetch_nsf_folder_access_chunk( - vault: VaultOnline, chunk: List[str]) -> Dict[str, Dict[str, Any]]: + vault: VaultOnline, chunk: List[str], show_inherited: bool = False, show_denied: bool = False) -> Dict[str, Dict[str, Any]]: """Fetch access details for one <=100-UID chunk, fully draining continuation pages.""" accumulated: Dict[str, Dict[str, Any]] = {} continuation_token = None @@ -1165,7 +1179,7 @@ def _fetch_nsf_folder_access_chunk( 'message': err.message}, 'success': False} else: - accessors = [_parse_folder_accessor(vault, a) for a in fr.accessors] + accessors = [_parse_folder_accessor(vault, a, show_inherited, show_denied) for a in fr.accessors] entry = accumulated.setdefault( fuid, {'folder_uid': fuid, 'accessors': [], 'success': True}) entry['accessors'].extend(accessors) @@ -1178,7 +1192,9 @@ def _fetch_nsf_folder_access_chunk( def get_nsf_folder_access( vault: VaultOnline, - folder_uids: Iterable[str]) -> Dict[str, Any]: + folder_uids: Iterable[str], + show_inherited: bool = False, + show_denied: bool = False) -> Dict[str, Any]: """``vault/folders/v3/access``, chunked at 100 UIDs per request. Fully drains continuation-token pagination within each chunk before @@ -1196,12 +1212,12 @@ def get_nsf_folder_access( results: List[Dict[str, Any]] = [] for i in range(0, len(uids), _FOLDER_ACCESS_CHUNK): chunk = uids[i:i + _FOLDER_ACCESS_CHUNK] - accumulated = _fetch_nsf_folder_access_chunk(vault, chunk) + accumulated = _fetch_nsf_folder_access_chunk(vault, chunk, show_inherited, show_denied) results.extend(accumulated.values()) return {'results': results} -def load_nsf_access_details(vault: VaultOnline, load_folder: bool = False, load_record: bool = False) -> Dict[str, int]: +def load_nsf_access_details(vault: VaultOnline, load_folder: bool = False, load_record: bool = False, show_inherited: bool = False, show_denied: bool = False) -> Dict[str, int]: """Fetch access details for every NSF folder/record and cache them on ``vault.nsf_data``. Scans all folder and record UIDs currently known to the NSF cache @@ -1212,7 +1228,7 @@ def load_nsf_access_details(vault: VaultOnline, load_folder: bool = False, load_ by record UID (the list of accessor rows for that record). """ view = _nsf_view(vault) - if not view.folders() and not view.records(): + if not load_folder and not load_record: raise NsfError('Pass at least one of load_folder or load_record to load access details for NSF cache') folder_uid_list = [f.folder_uid for f in view.folders()] if load_folder else [] record_uid_list = [r.record_uid for r in view.records()] if load_record else [] @@ -1222,7 +1238,7 @@ def load_nsf_access_details(vault: VaultOnline, load_folder: bool = False, load_ loaded = {'folders': 0, 'records': 0} if folder_uid_list: - access = get_nsf_folder_access(vault, folder_uid_list) + access = get_nsf_folder_access(vault, folder_uid_list, show_inherited=show_inherited, show_denied=show_denied) for fr in access.get('results') or []: fuid = fr.get('folder_uid') if not fuid: @@ -1231,7 +1247,7 @@ def load_nsf_access_details(vault: VaultOnline, load_folder: bool = False, load_ loaded['folders'] += 1 if record_uid_list: - access = get_nsf_record_accesses(vault, record_uid_list) + access = get_nsf_record_accesses(vault, record_uid_list, show_inherited=show_inherited, show_denied=show_denied) by_uid: Dict[str, List[Dict[str, Any]]] = {} for ao in access.get('record_accesses') or []: by_uid.setdefault(ao['record_uid'], []).append(ao) diff --git a/keepersdk-package/src/keepersdk/vault/nsf_sharing.py b/keepersdk-package/src/keepersdk/vault/nsf_sharing.py index 7a0dae29..988240cf 100644 --- a/keepersdk-package/src/keepersdk/vault/nsf_sharing.py +++ b/keepersdk-package/src/keepersdk/vault/nsf_sharing.py @@ -49,8 +49,8 @@ def _ensure_record_ownership_permission(vault: VaultOnline, record_uid: str) -> def _prepare_folder_for_access_change(vault: VaultOnline, folder_uid: str) -> None: - """Break parent permission inheritance before changing folder accessors.""" - nsf_common.ensure_folder_direct_permissions(vault, folder_uid, request_sync=False) + """Check folder permission inheritance before changing folder accessors.""" + nsf_common.ensure_folder_direct_permissions(vault, folder_uid) @dataclass @@ -123,7 +123,7 @@ def _check_existing_nsf_folder_access( """Return the existing AccessRoleType name for an accessor, or None.""" try: uid_encoded = utils.base64_url_encode(uid_bytes) - info = get_nsf_folder_access(vault, [folder_uid]) + info = get_nsf_folder_access(vault, [folder_uid], show_inherited=True, show_denied=True) for result in info.get('results', []): if not result.get('success'): continue @@ -136,6 +136,147 @@ def _check_existing_nsf_folder_access( return None +def _lookup_nsf_folder_accessors( + vault: VaultOnline, + folder_uid: str, + uid_bytes: bytes, + access_type_label: Optional[str] = None) -> List[Dict[str, Any]]: + """Every server accessor row (including inherited and denied) for one actor. + + Rows matching *access_type_label* are preferred; otherwise all rows for the + UID are returned. Lookup failures return an empty list (state ``none``). + """ + uid_encoded = utils.base64_url_encode(uid_bytes) + rows: List[Dict[str, Any]] = [] + try: + info = get_nsf_folder_access(vault, [folder_uid], show_inherited=True, show_denied=True) + except Exception: + return rows + for result in info.get('results', []): + if not result.get('success'): + continue + rows.extend(a for a in result.get('accessors', []) + if a and a.get('accessor_uid') == uid_encoded) + if access_type_label: + typed = [a for a in rows if a.get('access_type') == access_type_label] + if typed: + return typed + return rows + + +def _encrypted_folder_key_for( + vault: VaultOnline, + folder_uid: str, + uid_bytes: bytes, + recipient: str, + *, + as_team: bool) -> folder_pb2.EncryptedDataKey: + """Encrypt the folder key for a user (public key) or a team.""" + fk = _get_folder_key(vault, folder_uid) + ek = folder_pb2.EncryptedDataKey() + if as_team: + team_uid_b64 = utils.base64_url_encode(uid_bytes) + try: + team_keys = nsf_common.get_team_keys(vault, team_uid_b64) + except ValueError as exc: + raise NsfError(f'Team keys not available for {recipient}') from exc + efk, key_type = nsf_common.encrypt_for_team( + fk, team_keys, + forbid_rsa=vault.keeper_auth.auth_context.forbid_rsa) + else: + pub_key, use_ecc, _, _ = nsf_common.get_user_public_key(vault, recipient) + if not pub_key: + raise NsfError(f"Public key not available for user '{recipient}'") + efk = nsf_common.encrypt_for_recipient(fk, pub_key, use_ecc) + key_type = ( + folder_pb2.encrypted_by_public_key_ecc if use_ecc + else folder_pb2.encrypted_by_public_key) + ek.encryptedKey = efk + ek.encryptedKeyType = key_type + return ek + + +def _build_folder_access_step( + folder_uid: str, + uid_bytes: bytes, + access_type_enum: int, + step: Dict[str, Any], + *, + role: Optional[str] = None, + hidden: Optional[bool] = None, + expiration_timestamp: Optional[int] = None, + folder_key: Optional[folder_pb2.EncryptedDataKey] = None) -> folder_pb2.FolderAccessData: + ad = folder_pb2.FolderAccessData() + ad.folderUid = utils.base64_url_decode(folder_uid) + ad.accessTypeUid = uid_bytes + ad.accessType = access_type_enum + if step['request'] == nsf_common.FOLDER_ACCESS_REMOVE: + return ad + if step['denied_access']: + # A denial is not a direct grant: no role change and never a folder key. + ad.deniedAccess = True + return ad + if role is not None: + access_role = nsf_common.resolve_nsf_role(role) + ad.accessRoleType = access_role + ad.permissions.CopyFrom(nsf_common.get_folder_permissions_for_role(access_role)) + if hidden is not None: + ad.hidden = hidden + if expiration_timestamp is not None: + ad.tlaProperties.expiration = expiration_timestamp + if step['include_folder_key']: + if folder_key is None: + raise NsfError('Folder key is required to create direct folder access') + ad.folderKey.CopyFrom(folder_key) + return ad + + +_FOLDER_STEP_MESSAGES = { + nsf_common.FOLDER_ACCESS_ADD: 'Access granted successfully', + nsf_common.FOLDER_ACCESS_UPDATE: 'Access updated successfully', + nsf_common.FOLDER_ACCESS_REMOVE: 'Access revoked successfully', +} + + +def _execute_folder_access_plan( + vault: VaultOnline, + folder_uid: str, + label: str, + uid_bytes: bytes, + access_type_enum: int, + plan: List[Dict[str, Any]], + *, + folder_key_factory=None, + messages: Optional[Dict[str, str]] = None, + **fields: Any) -> Dict[str, Any]: + """Send *plan* steps one request at a time; raise on the first failure. + + For the denied re-add, the add is only sent once the denial removal + succeeded, so no add is attempted while the denied row still exists. + """ + msgs = dict(_FOLDER_STEP_MESSAGES, **(messages or {})) + result: Dict[str, Any] = {} + for idx, step in enumerate(plan): + folder_key = folder_key_factory() if step['include_folder_key'] else None + ad = _build_folder_access_step( + folder_uid, uid_bytes, access_type_enum, step, folder_key=folder_key, **fields) + request = step['request'] + response = _folder_access_update( + vault, + adds=[ad] if request == nsf_common.FOLDER_ACCESS_ADD else None, + updates=[ad] if request == nsf_common.FOLDER_ACCESS_UPDATE else None, + removes=[ad] if request == nsf_common.FOLDER_ACCESS_REMOVE else None) + message = 'Access denied successfully' if step['denied_access'] else msgs[request] + result = nsf_common.parse_folder_access_result(response, folder_uid, label, message) + result['request'] = request + if not result['success']: + msg = result['message'] + if idx < len(plan) - 1: + msg = f'{msg} (step {idx + 1} of {len(plan)}; remaining steps skipped)' + raise KeeperApiError(result['status'], msg) + return result + + def collect_nsf_records_in_folder( vault: VaultOnline, folder_identifier: Optional[str], @@ -177,7 +318,17 @@ def grant_nsf_folder_access( expiration_timestamp: Optional[int] = None, as_team: bool = False, request_sync: bool = True) -> Dict[str, Any]: - """Grant user or team access to an NSF folder.""" + """Grant user or team access to an NSF folder. + + The request depends on the accessor's current state on this folder: + + * direct - role/expiration change via ``folderAccessUpdates`` (no new key) + * inherited - becomes a direct grant via ``folderAccessAdds`` with the + recipient-encrypted folder key (even when the role is unchanged) + * denied - ``folderAccessRemoves`` for the denial, then + ``folderAccessAdds`` with the folder key + * none - ``folderAccessAdds`` with the folder key + """ folder_uid = resolve_nsf_folder_uid(vault, folder_identifier) or folder_identifier if not is_nsf_folder(vault, folder_uid): raise NsfError(f'NSF folder not found: {folder_identifier}') @@ -191,10 +342,12 @@ def grant_nsf_folder_access( uid_bytes, label, access_type_enum = _resolve_folder_accessor( vault, recipient, as_team=as_team) - existing_role = _check_existing_nsf_folder_access( - vault, folder_uid, uid_bytes, access_type_label) - if existing_role is not None: - if existing_role == target_role_name and expiration_timestamp is None: + accessors = _lookup_nsf_folder_accessors(vault, folder_uid, uid_bytes, access_type_label) + state = nsf_common.folder_access_state_from_accessors(accessors) + + if state == nsf_common.FOLDER_ACCESS_DIRECT: + if (nsf_common.current_folder_role_name(accessors) == target_role_name + and expiration_timestamp is None): return { 'folder_uid': folder_uid, 'accessor': label, @@ -203,51 +356,24 @@ def grant_nsf_folder_access( 'message': f"{'Team' if as_team else 'User'} already has {role} access", 'success': True, 'action_taken': 'already_had_access', + 'previous_access_state': state, } result = update_nsf_folder_access( vault, folder_uid, recipient, role=role, as_team=as_team, - expiration_timestamp=expiration_timestamp, request_sync=request_sync) + expiration_timestamp=expiration_timestamp, request_sync=request_sync, + _known_state=state) result['action_taken'] = 'updated' return result - ad = folder_pb2.FolderAccessData() - ad.folderUid = utils.base64_url_decode(folder_uid) - ad.accessTypeUid = uid_bytes - ad.accessType = access_type_enum - ad.accessRoleType = access_role - ad.permissions.CopyFrom(nsf_common.get_folder_permissions_for_role(access_role)) - - if expiration_timestamp is not None: - ad.tlaProperties.expiration = expiration_timestamp - - fk = _get_folder_key(vault, folder_uid) - ek = folder_pb2.EncryptedDataKey() - if as_team: - team_uid_b64 = utils.base64_url_encode(uid_bytes) - try: - team_keys = nsf_common.get_team_keys(vault, team_uid_b64) - except ValueError as exc: - raise NsfError(f'Team keys not available for {recipient}') from exc - efk, key_type = nsf_common.encrypt_for_team( - fk, team_keys, - forbid_rsa=vault.keeper_auth.auth_context.forbid_rsa) - else: - pub_key, use_ecc, _, _ = nsf_common.get_user_public_key(vault, recipient) - efk = nsf_common.encrypt_for_recipient(fk, pub_key, use_ecc) - key_type = ( - folder_pb2.encrypted_by_public_key_ecc if use_ecc - else folder_pb2.encrypted_by_public_key) - ek.encryptedKey = efk - ek.encryptedKeyType = key_type - ad.folderKey.CopyFrom(ek) - - response = _folder_access_update(vault, adds=[ad]) - result = nsf_common.parse_folder_access_result( - response, folder_uid, label, 'Access granted successfully') + plan = nsf_common.plan_folder_access_change(state, 'grant') + result = _execute_folder_access_plan( + vault, folder_uid, label, uid_bytes, access_type_enum, plan, + folder_key_factory=lambda: _encrypted_folder_key_for( + vault, folder_uid, uid_bytes, recipient, as_team=as_team), + role=role, expiration_timestamp=expiration_timestamp) result['access_type'] = access_type_label - result.setdefault('action_taken', 'granted' if result['success'] else 'grant_failed') - if not result['success']: - raise KeeperApiError(result['status'], result['message']) + result['previous_access_state'] = state + result.setdefault('action_taken', 'granted') _request_sync(vault, request_sync) return result @@ -396,8 +522,15 @@ def update_nsf_folder_access( hidden: Optional[bool] = None, expiration_timestamp: Optional[int] = None, as_team: bool = False, - request_sync: bool = True) -> Dict[str, Any]: - """Update role, visibility, or expiration for an existing NSF folder accessor.""" + request_sync: bool = True, + _known_state: Optional[str] = None) -> Dict[str, Any]: + """Update role, visibility, or expiration for an NSF folder accessor. + + Direct access is changed with ``folderAccessUpdates`` and keeps its folder + key. Inherited (or denied) access is turned into a direct grant with + ``folderAccessAdds`` plus the recipient-encrypted folder key, so the child + keeps its own key edge if access to the parent is later revoked. + """ if role is None and hidden is None and expiration_timestamp is None: raise NsfError('At least one field (role, hidden, or expiration) is required') @@ -410,25 +543,36 @@ def update_nsf_folder_access( uid_bytes, label, access_type_enum = _resolve_folder_accessor( vault, recipient, as_team=as_team) - ad = folder_pb2.FolderAccessData() - ad.folderUid = utils.base64_url_decode(folder_uid) - ad.accessTypeUid = uid_bytes - ad.accessType = access_type_enum - if role is not None: - access_role = nsf_common.resolve_nsf_role(role) - ad.accessRoleType = access_role - ad.permissions.CopyFrom(nsf_common.get_folder_permissions_for_role(access_role)) - if hidden is not None: - ad.hidden = hidden - if expiration_timestamp is not None: - ad.tlaProperties.expiration = expiration_timestamp - - response = _folder_access_update(vault, updates=[ad]) - result = nsf_common.parse_folder_access_result( - response, folder_uid, label, 'Access updated successfully') + accessors: List[Dict[str, Any]] = [] + state = _known_state + if state is None: + accessors = _lookup_nsf_folder_accessors( + vault, folder_uid, uid_bytes, folder_pb2.AccessType.Name(access_type_enum)) + state = nsf_common.folder_access_state_from_accessors(accessors) + + if state in (nsf_common.FOLDER_ACCESS_DIRECT, nsf_common.FOLDER_ACCESS_NONE): + # NONE: nothing known to convert; let the server validate the update. + plan = [{'request': nsf_common.FOLDER_ACCESS_UPDATE, + 'include_folder_key': False, 'denied_access': False}] + else: + plan = nsf_common.plan_folder_access_change(state, 'grant') + if role is None: + # An add needs a role; keep the one currently in effect. + current = (nsf_common.current_folder_role_name(accessors) or '').lower() + if current not in nsf_common.ROLE_NAME_MAP: + raise NsfError( + 'A role is required to change inherited or denied access on ' + 'this folder (it becomes a direct grant)') + role = current + + result = _execute_folder_access_plan( + vault, folder_uid, label, uid_bytes, access_type_enum, plan, + folder_key_factory=lambda: _encrypted_folder_key_for( + vault, folder_uid, uid_bytes, recipient, as_team=as_team), + messages={nsf_common.FOLDER_ACCESS_ADD: 'Access updated successfully'}, + role=role, hidden=hidden, expiration_timestamp=expiration_timestamp) result['access_type'] = 'AT_TEAM' if as_team else 'AT_USER' - if not result['success']: - raise KeeperApiError(result['status'], result['message']) + result['previous_access_state'] = state _request_sync(vault, request_sync) return result @@ -440,7 +584,14 @@ def revoke_nsf_folder_access( *, as_team: bool = False, request_sync: bool = True) -> Dict[str, Any]: - """Revoke user or team access from an NSF folder.""" + """Revoke user or team access from an NSF folder. + + * Direct access is removed with ``folderAccessRemoves``. + * Inherited access cannot be removed on the child (it comes from the + parent), so it is denied with ``folderAccessUpdates`` + + ``deniedAccess=True`` and no folder key. + * Already-denied access needs no request. + """ folder_uid = resolve_nsf_folder_uid(vault, folder_identifier) or folder_identifier if not is_nsf_folder(vault, folder_uid): raise NsfError(f'NSF folder not found: {folder_identifier}') @@ -449,18 +600,44 @@ def revoke_nsf_folder_access( uid_bytes, label, access_type_enum = _resolve_folder_accessor( vault, recipient, as_team=as_team) + access_type_label = 'AT_TEAM' if as_team else 'AT_USER' - ad = folder_pb2.FolderAccessData() - ad.folderUid = utils.base64_url_decode(folder_uid) - ad.accessTypeUid = uid_bytes - ad.accessType = access_type_enum - - response = _folder_access_update(vault, removes=[ad]) - result = nsf_common.parse_folder_access_result( - response, folder_uid, label, 'Access revoked successfully') - result['access_type'] = 'AT_TEAM' if as_team else 'AT_USER' - if not result['success']: - raise KeeperApiError(result['status'], result['message']) + accessors = _lookup_nsf_folder_accessors(vault, folder_uid, uid_bytes, access_type_label) + if accessors: + # Use the server-reported UID / access type (e.g. inherited team rows). + server_uid = accessors[0].get('accessor_uid') + if server_uid: + uid_bytes = utils.base64_url_decode(server_uid) + server_type = accessors[0].get('access_type') + if server_type: + try: + access_type_enum = folder_pb2.AccessType.Value(server_type) + access_type_label = server_type + except ValueError as exc: + raise NsfError( + f"Unrecognised access type '{server_type}' returned by server " + f"for folder '{folder_uid}'") from exc + state = nsf_common.folder_access_state_from_accessors(accessors) + + action = 'deny' if state == nsf_common.FOLDER_ACCESS_INHERITED else 'remove' + plan = nsf_common.plan_folder_access_change(state, action) + if not plan: + return { + 'folder_uid': folder_uid, + 'accessor': label, + 'access_type': access_type_label, + 'status': 'SUCCESS', + 'message': 'Access is already denied on this folder', + 'success': True, + 'action_taken': 'already_denied', + 'previous_access_state': state, + } + + result = _execute_folder_access_plan( + vault, folder_uid, label, uid_bytes, access_type_enum, plan) + result['access_type'] = access_type_label + result['previous_access_state'] = state + result['action_taken'] = 'denied' if action == 'deny' else 'revoked' _request_sync(vault, request_sync) return result @@ -762,7 +939,7 @@ def plan_nsf_record_permissions( if not record_uids: raise NsfError('No records found in the specified folder') - accesses_result = get_nsf_record_accesses(vault, list(record_uids)) + accesses_result = get_nsf_record_accesses(vault, list(record_uids), show_inherited=True, show_denied=True) role_map = {name: nsf_common.resolve_nsf_role(name) for name in ( 'viewer', 'share-manager', 'content-manager', 'content-share-manager', 'full-manager')} diff --git a/keepersdk-package/src/keepersdk/vault/sync_down.py b/keepersdk-package/src/keepersdk/vault/sync_down.py index 550c284d..001cbeb5 100644 --- a/keepersdk-package/src/keepersdk/vault/sync_down.py +++ b/keepersdk-package/src/keepersdk/vault/sync_down.py @@ -583,6 +583,7 @@ def to_notification(nw: NotificationCenter_pb2.NotificationWrapper) -> StorageNo rt_rq.standard = True rt_rq.user = True rt_rq.enterprise = True + rt_rq.pam = True rt_rs = auth.execute_auth_rest( 'vault/get_record_types', rt_rq, response_type=record_pb2.RecordTypesResponse) assert rt_rs is not None diff --git a/keepersdk-package/src/keepersdk/vault/vault_online.py b/keepersdk-package/src/keepersdk/vault/vault_online.py index b11765b0..3ae1a53f 100644 --- a/keepersdk-package/src/keepersdk/vault/vault_online.py +++ b/keepersdk-package/src/keepersdk/vault/vault_online.py @@ -160,6 +160,7 @@ def sync_down(self, force=False): self._keeper_auth.auth_context, teams=self._vault_data.get_nsf_team_key_materials(), ) + self._ingest_record_rotations(result) def _background_task(self): if self._keeper_auth.auth_context.enterprise_ec_public_key: diff --git a/keepersdk-package/unit_tests/data_vault.py b/keepersdk-package/unit_tests/data_vault.py index 729a8db6..e691c0e1 100644 --- a/keepersdk-package/unit_tests/data_vault.py +++ b/keepersdk-package/unit_tests/data_vault.py @@ -400,7 +400,7 @@ def get_connected_auth() -> keeper_auth.KeeperAuth: return keeper_auth.KeeperAuth(keeper_endpoint, auth_context) -TestClientVersion = 'c18.0.0' +TestClientVersion = 'c18.1.0' DefaultEnvironment = 'env.company.com' AccountUid = crypto.get_random_bytes(16) UserName = 'some_fake_user@company.com' diff --git a/keepersdk-package/unit_tests/test_nsf_folder_access_transitions.py b/keepersdk-package/unit_tests/test_nsf_folder_access_transitions.py new file mode 100644 index 00000000..d6bb1ee0 --- /dev/null +++ b/keepersdk-package/unit_tests/test_nsf_folder_access_transitions.py @@ -0,0 +1,218 @@ +"""Child-folder access transitions for NSF folder sharing (inherited / denied / direct).""" + +import unittest +from unittest import mock + +from keepersdk import utils +from keepersdk.errors import KeeperApiError +from keepersdk.proto import folder_pb2 +from keepersdk.vault import nsf_common, nsf_sharing + +_S = 'keepersdk.vault.nsf_sharing.' + + +def _row(uid_b64, *, inherited=False, denied=False, role='VIEWER', access_type='AT_USER'): + return {'accessor_uid': uid_b64, 'access_type': access_type, 'role': role, + 'inherited': inherited, 'denied_access': denied} + + +def _response(status=None): + rs = folder_pb2.FolderAccessResponse() + if status is not None: + r = rs.folderAccessResults.add() + r.status = status + return rs + + +_FAILURE = next(v for v in folder_pb2.FolderModifyStatus.values() if v != 0) + + +class TestPlanFolderAccessChange(unittest.TestCase): + + def test_state_from_accessors(self): + f = nsf_common.folder_access_state_from_accessors + self.assertEqual(f([]), 'none') + self.assertEqual(f([{'inherited': True}]), 'inherited') + self.assertEqual(f([{'inherited': True}, {'inherited': False}]), 'direct') + self.assertEqual(f([{'inherited': False}, {'denied_access': True}]), 'denied') + + def test_transitions(self): + plan = nsf_common.plan_folder_access_change + reqs = lambda st, act: [s['request'] for s in plan(st, act)] + self.assertEqual(reqs('inherited', 'grant'), ['folderAccessAdds']) + self.assertEqual(reqs('none', 'grant'), ['folderAccessAdds']) + self.assertEqual(reqs('denied', 'grant'), ['folderAccessRemoves', 'folderAccessAdds']) + self.assertEqual(reqs('direct', 'grant'), ['folderAccessUpdates']) + self.assertEqual(reqs('inherited', 'deny'), ['folderAccessUpdates']) + self.assertEqual(reqs('denied', 'remove'), []) + self.assertEqual(reqs('direct', 'remove'), ['folderAccessRemoves']) + self.assertTrue(plan('inherited', 'grant')[0]['include_folder_key']) + self.assertFalse(plan('direct', 'grant')[0]['include_folder_key']) + deny = plan('inherited', 'deny')[0] + self.assertTrue(deny['denied_access']) + self.assertFalse(deny['include_folder_key']) + with self.assertRaises(ValueError): + plan('inherited', 'remove') + with self.assertRaises(ValueError): + plan('direct', 'deny') + + +class TestNsfFolderAccessTransitions(unittest.TestCase): + + def setUp(self): + self.folder_uid = utils.generate_uid() + self.uid_bytes = utils.base64_url_decode(utils.generate_uid()) + self.uid_b64 = utils.base64_url_encode(self.uid_bytes) + self.email = 'user@example.com' + self.vault = mock.Mock() + self.fake_key = folder_pb2.EncryptedDataKey( + encryptedKey=b'enc', encryptedKeyType=folder_pb2.encrypted_by_public_key) + patches = [ + mock.patch(_S + 'resolve_nsf_folder_uid', return_value=self.folder_uid), + mock.patch(_S + 'is_nsf_folder', return_value=True), + mock.patch(_S + '_ensure_folder_share_permission'), + mock.patch(_S + '_prepare_folder_for_access_change'), + mock.patch(_S + '_request_sync'), + mock.patch(_S + '_resolve_folder_accessor', + return_value=(self.uid_bytes, self.email, folder_pb2.AT_USER)), + ] + for p in patches: + p.start() + self.addCleanup(p.stop) + self.enc = mock.patch(_S + '_encrypted_folder_key_for', return_value=self.fake_key).start() + self.addCleanup(mock.patch.stopall) + + def _rows(self, *rows): + info = {'results': [{'folder_uid': self.folder_uid, 'success': True, + 'accessors': list(rows)}]} + p = mock.patch(_S + 'get_nsf_folder_access', return_value=info) + p.start() + self.addCleanup(p.stop) + + def _access(self, *responses): + p = mock.patch(_S + '_folder_access_update', side_effect=list(responses)) + m = p.start() + self.addCleanup(p.stop) + return m + + def test_grant_inherited_becomes_add_with_folder_key(self): + self._rows(_row(self.uid_b64, inherited=True)) + upd = self._access(_response(0)) + result = nsf_sharing.grant_nsf_folder_access( + self.vault, self.folder_uid, self.email, role='content-manager') + self.assertTrue(result['success']) + self.assertEqual(result['previous_access_state'], 'inherited') + upd.assert_called_once() + kw = upd.call_args.kwargs + self.assertIsNone(kw['updates']) + ad = kw['adds'][0] + self.assertTrue(ad.HasField('folderKey')) + self.assertEqual(ad.folderKey.encryptedKeyType, folder_pb2.encrypted_by_public_key) + self.enc.assert_called_once() + + def test_grant_inherited_same_role_still_creates_direct_grant(self): + self._rows(_row(self.uid_b64, inherited=True, role='CONTENT_MANAGER')) + upd = self._access(_response(0)) + result = nsf_sharing.grant_nsf_folder_access( + self.vault, self.folder_uid, self.email, role='content-manager') + self.assertNotEqual(result['action_taken'], 'already_had_access') + self.assertTrue(upd.call_args.kwargs['adds']) + + def test_grant_denied_same_role_is_not_already_had_access(self): + self._rows(_row(self.uid_b64, inherited=True, denied=True)) + upd = self._access(_response(0), _response(0)) + result = nsf_sharing.grant_nsf_folder_access( + self.vault, self.folder_uid, self.email, role='viewer') + self.assertEqual(result['action_taken'], 'granted') + self.assertEqual(upd.call_count, 2) + first, second = upd.call_args_list + self.assertTrue(first.kwargs['removes']) + self.assertFalse(first.kwargs['removes'][0].HasField('folderKey')) + add = second.kwargs['adds'][0] + self.assertTrue(add.HasField('folderKey')) + self.assertFalse(add.deniedAccess) + + def test_grant_denied_skips_add_when_removal_fails(self): + self._rows(_row(self.uid_b64, denied=True)) + upd = self._access(_response(_FAILURE)) + with self.assertRaises(KeeperApiError): + nsf_sharing.grant_nsf_folder_access( + self.vault, self.folder_uid, self.email, role='viewer') + upd.assert_called_once() + self.assertTrue(upd.call_args.kwargs['removes']) + + def test_grant_none_adds_with_key(self): + self._rows() + upd = self._access(_response(0)) + nsf_sharing.grant_nsf_folder_access(self.vault, self.folder_uid, self.email) + self.assertTrue(upd.call_args.kwargs['adds'][0].HasField('folderKey')) + + def test_grant_direct_same_role_is_noop(self): + self._rows(_row(self.uid_b64)) + upd = self._access() + result = nsf_sharing.grant_nsf_folder_access( + self.vault, self.folder_uid, self.email, role='viewer') + self.assertEqual(result['action_taken'], 'already_had_access') + upd.assert_not_called() + + def test_grant_direct_new_role_updates_without_key(self): + self._rows(_row(self.uid_b64)) + upd = self._access(_response(0)) + result = nsf_sharing.grant_nsf_folder_access( + self.vault, self.folder_uid, self.email, role='content-manager') + self.assertEqual(result['action_taken'], 'updated') + ad = upd.call_args.kwargs['updates'][0] + self.assertFalse(ad.HasField('folderKey')) + self.enc.assert_not_called() + + def test_update_inherited_keeps_current_role_and_adds_key(self): + self._rows(_row(self.uid_b64, inherited=True)) + upd = self._access(_response(0)) + nsf_sharing.update_nsf_folder_access( + self.vault, self.folder_uid, self.email, expiration_timestamp=1_900_000_000_000) + ad = upd.call_args.kwargs['adds'][0] + self.assertEqual(ad.accessRoleType, folder_pb2.VIEWER) + self.assertTrue(ad.HasField('folderKey')) + self.assertEqual(ad.tlaProperties.expiration, 1_900_000_000_000) + + def test_update_direct_uses_update(self): + self._rows(_row(self.uid_b64)) + upd = self._access(_response(0)) + nsf_sharing.update_nsf_folder_access( + self.vault, self.folder_uid, self.email, hidden=True) + ad = upd.call_args.kwargs['updates'][0] + self.assertTrue(ad.hidden) + self.assertFalse(ad.HasField('folderKey')) + + def test_revoke_inherited_sends_denial_without_key(self): + self._rows(_row(self.uid_b64, inherited=True)) + upd = self._access(_response(0)) + result = nsf_sharing.revoke_nsf_folder_access(self.vault, self.folder_uid, self.email) + self.assertEqual(result['action_taken'], 'denied') + ad = upd.call_args.kwargs['updates'][0] + self.assertTrue(ad.deniedAccess) + self.assertFalse(ad.HasField('folderKey')) + + def test_revoke_direct_sends_remove(self): + self._rows(_row(self.uid_b64)) + upd = self._access(_response(0)) + result = nsf_sharing.revoke_nsf_folder_access(self.vault, self.folder_uid, self.email) + self.assertEqual(result['action_taken'], 'revoked') + self.assertTrue(upd.call_args.kwargs['removes']) + + def test_revoke_already_denied_sends_nothing(self): + self._rows(_row(self.uid_b64, denied=True)) + upd = self._access() + result = nsf_sharing.revoke_nsf_folder_access(self.vault, self.folder_uid, self.email) + self.assertEqual(result['action_taken'], 'already_denied') + upd.assert_not_called() + + def test_revoke_failure_raises(self): + self._rows(_row(self.uid_b64)) + self._access(_response(_FAILURE)) + with self.assertRaises(KeeperApiError): + nsf_sharing.revoke_nsf_folder_access(self.vault, self.folder_uid, self.email) + + +if __name__ == '__main__': + unittest.main() diff --git a/keepersdk-package/unit_tests/test_nsf_permissions.py b/keepersdk-package/unit_tests/test_nsf_permissions.py index b38ba997..59d8481a 100644 --- a/keepersdk-package/unit_tests/test_nsf_permissions.py +++ b/keepersdk-package/unit_tests/test_nsf_permissions.py @@ -1,5 +1,5 @@ import unittest -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from keepersdk import utils from keepersdk.proto import folder_pb2 @@ -83,12 +83,12 @@ def test_record_share_denied_without_permission(self): def test_record_access_inheritance_helpers(self): record_uid = utils.generate_uid() vault = self._vault() - from unittest.mock import patch api_access = [{ 'record_uid': record_uid, 'accessor_name': 'alice@example.com', 'access_type': 'AT_USER', 'inherited': True, + 'denied_access': False, 'owner': False, }] with patch.object( @@ -127,6 +127,212 @@ def test_folder_inherit_detection(self): ]) self.assertFalse(nsf_common.folder_inherits_parent_permissions(vault, folder_uid)) + def test_record_access_filter_hides_inherited_by_default(self): + accesses = [ + { + 'accessor_name': 'direct@example.com', + 'inherited': False, + 'denied_access': False, + }, + { + 'accessor_name': 'inherited@example.com', + 'inherited': True, + 'denied_access': False, + }, + ] + + filtered = [ + access for access in accesses + if not access.get('inherited') and not access.get('denied_access') + ] + + self.assertEqual( + ['direct@example.com'], + [access['accessor_name'] for access in filtered], + ) + + def test_record_access_filter_shows_inherited_when_requested(self): + accesses = [ + { + 'accessor_name': 'direct@example.com', + 'inherited': False, + 'denied_access': False, + }, + { + 'accessor_name': 'inherited@example.com', + 'inherited': True, + 'denied_access': False, + }, + ] + + show_inherited = True + show_denied = False + + filtered = [ + access for access in accesses + if (show_inherited or not access.get('inherited')) + and (show_denied or not access.get('denied_access')) + ] + + self.assertEqual( + [ + 'direct@example.com', + 'inherited@example.com', + ], + [access['accessor_name'] for access in filtered], + ) + + def test_record_access_filter_hides_denied_by_default(self): + accesses = [ + { + 'accessor_name': 'direct@example.com', + 'inherited': False, + 'denied_access': False, + }, + { + 'accessor_name': 'denied@example.com', + 'inherited': False, + 'denied_access': True, + }, + ] + + filtered = [ + access for access in accesses + if not access.get('inherited') and not access.get('denied_access') + ] + + self.assertEqual( + ['direct@example.com'], + [access['accessor_name'] for access in filtered], + ) + + def test_record_access_filter_shows_denied_when_requested(self): + accesses = [ + { + 'accessor_name': 'direct@example.com', + 'inherited': False, + 'denied_access': False, + }, + { + 'accessor_name': 'denied@example.com', + 'inherited': False, + 'denied_access': True, + }, + ] + + show_inherited = False + show_denied = True + + filtered = [ + access for access in accesses + if (show_inherited or not access.get('inherited')) + and (show_denied or not access.get('denied_access')) + ] + + self.assertEqual( + [ + 'direct@example.com', + 'denied@example.com', + ], + [access['accessor_name'] for access in filtered], + ) + + def test_record_access_filter_shows_inherited_and_denied_when_requested(self): + accesses = [ + { + 'accessor_name': 'direct@example.com', + 'inherited': False, + 'denied_access': False, + }, + { + 'accessor_name': 'inherited@example.com', + 'inherited': True, + 'denied_access': False, + }, + { + 'accessor_name': 'denied@example.com', + 'inherited': False, + 'denied_access': True, + }, + { + 'accessor_name': 'inherited-denied@example.com', + 'inherited': True, + 'denied_access': True, + }, + ] + + show_inherited = True + show_denied = True + + filtered = [ + access for access in accesses + if (show_inherited or not access.get('inherited')) + and (show_denied or not access.get('denied_access')) + ] + + self.assertEqual( + [ + 'direct@example.com', + 'inherited@example.com', + 'denied@example.com', + 'inherited-denied@example.com', + ], + [access['accessor_name'] for access in filtered], + ) + + def test_record_access_filter_hides_inherited_denied_by_default(self): + accesses = [ + { + 'accessor_name': 'direct@example.com', + 'inherited': False, + 'denied_access': False, + }, + { + 'accessor_name': 'inherited@example.com', + 'inherited': True, + 'denied_access': False, + }, + { + 'accessor_name': 'denied@example.com', + 'inherited': False, + 'denied_access': True, + }, + { + 'accessor_name': 'inherited-denied@example.com', + 'inherited': True, + 'denied_access': True, + }, + ] + + filtered = [ + access for access in accesses + if not access.get('inherited') and not access.get('denied_access') + ] + + self.assertEqual( + ['direct@example.com'], + [access['accessor_name'] for access in filtered], + ) + + def test_permission_state_values_are_preserved(self): + access = { + 'accessor_name': 'user@example.com', + 'inherited': True, + 'denied_access': False, + } + + self.assertTrue(access['inherited']) + self.assertFalse(access['denied_access']) + + access = { + 'accessor_name': 'user@example.com', + 'inherited': False, + 'denied_access': True, + } + + self.assertFalse(access['inherited']) + self.assertTrue(access['denied_access']) + class TestEncryptForTeam(unittest.TestCase): def test_prefer_asymmetric_then_aes_fallback(self):