From b6c623aaee23278968c337aec9056b022d6cad9e Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Wed, 26 Nov 2025 16:55:42 +0530 Subject: [PATCH 01/11] Implementation of all commands for PAM Workflow --- keepercommander/cli.py | 1 + keepercommander/commands/discoveryrotation.py | 2 + keepercommander/commands/workflow/__init__.py | 27 + .../commands/workflow/workflow_commands.py | 1474 +++++++++++++++++ keepercommander/proto/GraphSync_pb2.py | 4 +- keepercommander/proto/GraphSync_pb2.pyi | 33 +- keepercommander/proto/workflow_pb2.py | 56 + keepercommander/proto/workflow_pb2.pyi | 208 +++ 8 files changed, 1787 insertions(+), 18 deletions(-) create mode 100644 keepercommander/commands/workflow/__init__.py create mode 100644 keepercommander/commands/workflow/workflow_commands.py create mode 100644 keepercommander/proto/workflow_pb2.py create mode 100644 keepercommander/proto/workflow_pb2.pyi diff --git a/keepercommander/cli.py b/keepercommander/cli.py index 64593fd26..b80961270 100644 --- a/keepercommander/cli.py +++ b/keepercommander/cli.py @@ -126,6 +126,7 @@ def clean_description(desc): ('pam rotation', 'Manage Rotations'), ('pam split', 'Split credentials from legacy PAM Machine'), ('pam tunnel', 'Manage Tunnels'), + ('pam workflow', 'Manage PAM Workflows'), ] domain_subcommands = [ ('domain list (dl)', 'List all reserved domains for the enterprise'), diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index b4db65add..b8166f5ad 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -76,6 +76,7 @@ from .pam_debug.vertex import PAMDebugVertexCommand from .pam_import.commands import PAMProjectCommand from .pam_launch.launch import PAMLaunchCommand +from .workflow.workflow_commands import PAMWorkflowCommand from .pam_service.list import PAMActionServiceListCommand from .pam_service.add import PAMActionServiceAddCommand from .pam_service.remove import PAMActionServiceRemoveCommand @@ -186,6 +187,7 @@ def __init__(self): self.register_command('rbi', PAMRbiCommand(), 'Manage Remote Browser Isolation', 'b') self.register_command('project', PAMProjectCommand(), 'PAM Project Import/Export', 'p') self.register_command('launch', PAMLaunchCommand(), 'Launch a connection to a PAM resource', 'l') + self.register_command('workflow', PAMWorkflowCommand(), 'Manage PAM Workflows', 'w') class PAMGatewayCommand(GroupCommand): diff --git a/keepercommander/commands/workflow/__init__.py b/keepercommander/commands/workflow/__init__.py new file mode 100644 index 000000000..721aa0bc0 --- /dev/null +++ b/keepercommander/commands/workflow/__init__.py @@ -0,0 +1,27 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' +""" + +__all__ = ['PAMWorkflowCommand'] + +from .workflow_commands import PAMWorkflowCommand + diff --git a/keepercommander/commands/workflow/workflow_commands.py b/keepercommander/commands/workflow/workflow_commands.py new file mode 100644 index 000000000..23334c696 --- /dev/null +++ b/keepercommander/commands/workflow/workflow_commands.py @@ -0,0 +1,1474 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bytes: + """ + Convert record UID string to bytes and validate it exists. + + Args: + params: Keeper parameters with session info + record_uid: Record UID as string (e.g., "abc123") + + Returns: + bytes: Record UID as bytes + + Raises: + CommandError: If record not found or invalid + """ + # Convert UID to bytes + uid_bytes = utils.base64_url_decode(record_uid) + + # Validate record exists in vault + if record_uid not in params.record_cache: + raise CommandError('', f'Record {record_uid} not found') + + return uid_bytes + + +def create_record_ref(record_uid_bytes: bytes, record_name: str = '') -> GraphSync_pb2.GraphSyncRef: + """ + Create a GraphSyncRef for a record. + + GraphSyncRef is used throughout Keeper's protobuf APIs to reference + different types of resources (records, folders, users, workflows, etc.) + + Args: + record_uid_bytes: Record UID as bytes + record_name: Optional record name/title + + Returns: + GraphSyncRef: Protobuf reference object + """ + ref = GraphSync_pb2.GraphSyncRef() + ref.type = GraphSync_pb2.RFT_REC # RefType.RFT_REC means "Record" + ref.value = record_uid_bytes + if record_name: + ref.name = record_name + return ref + + +def create_workflow_ref(flow_uid_bytes: bytes) -> GraphSync_pb2.GraphSyncRef: + """ + Create a GraphSyncRef for a workflow. + + Args: + flow_uid_bytes: Workflow flow UID as bytes + + Returns: + GraphSyncRef: Protobuf reference object for workflow + """ + ref = GraphSync_pb2.GraphSyncRef() + ref.type = GraphSync_pb2.RFT_WORKFLOW # RefType.RFT_WORKFLOW + ref.value = flow_uid_bytes + return ref + + +def parse_duration_to_milliseconds(duration_str: str) -> int: + """ + Parse duration string to milliseconds. + + Supports formats: + - "2h" = 2 hours + - "30m" = 30 minutes + - "1d" = 1 day + - "90" = 90 minutes (default unit) + + Args: + duration_str: Duration string (e.g., "2h", "30m", "1d") + + Returns: + int: Duration in milliseconds + + Raises: + CommandError: If duration format is invalid + """ + duration_str = duration_str.lower().strip() + + try: + # Check for unit suffix + if duration_str.endswith('d'): + # Days + days = int(duration_str[:-1]) + return days * 24 * 60 * 60 * 1000 + elif duration_str.endswith('h'): + # Hours + hours = int(duration_str[:-1]) + return hours * 60 * 60 * 1000 + elif duration_str.endswith('m'): + # Minutes + minutes = int(duration_str[:-1]) + return minutes * 60 * 1000 + else: + # Default to minutes if no unit specified + minutes = int(duration_str) + return minutes * 60 * 1000 + except ValueError: + raise CommandError('', f'Invalid duration format: {duration_str}. Use format like "2h", "30m", or "1d"') + + +def format_duration_from_milliseconds(milliseconds: int) -> str: + """ + Format milliseconds to human-readable duration. + + Args: + milliseconds: Duration in milliseconds + + Returns: + str: Formatted duration (e.g., "2 hours", "30 minutes", "1 day") + """ + seconds = milliseconds // 1000 + minutes = seconds // 60 + hours = minutes // 60 + days = hours // 24 + + if days > 0: + return f"{days} day{'s' if days != 1 else ''}" + elif hours > 0: + return f"{hours} hour{'s' if hours != 1 else ''}" + elif minutes > 0: + return f"{minutes} minute{'s' if minutes != 1 else ''}" + else: + return f"{seconds} second{'s' if seconds != 1 else ''}" + + +def format_workflow_stage(stage: int) -> str: + """ + Convert workflow stage enum to readable string. + + Args: + stage: WorkflowStage enum value + + Returns: + str: Human-readable stage name + """ + stage_map = { + workflow_pb2.WS_READY_TO_START: 'Ready to Start', + workflow_pb2.WS_STARTED: 'Started', + workflow_pb2.WS_NEEDS_ACTION: 'Needs Action', + workflow_pb2.WS_WAITING: 'Waiting' + } + return stage_map.get(stage, f'Unknown ({stage})') + + +def format_access_conditions(conditions: List[int]) -> str: + """ + Convert access condition enums to readable string. + + Args: + conditions: List of AccessCondition enum values + + Returns: + str: Human-readable conditions (comma-separated) + """ + condition_map = { + workflow_pb2.AC_APPROVAL: 'Approval Required', + workflow_pb2.AC_CHECKIN: 'Check-in Required', + workflow_pb2.AC_MFA: 'MFA Required', + workflow_pb2.AC_TIME: 'Time Restriction', + workflow_pb2.AC_REASON: 'Reason Required', + workflow_pb2.AC_TICKET: 'Ticket Required' + } + return ', '.join([condition_map.get(c, f'Unknown ({c})') for c in conditions]) + + +# ============================================================================ +# CONFIGURATION COMMANDS +# ============================================================================ + +class WorkflowCreateCommand(Command): + """ + Create a new workflow configuration for a PAM record. + + This enables Just-in-Time PAM features like: + - Approval requirements before access + - Single-user check-in/check-out + - Time-based access controls + - MFA requirements + - Justification requirements + + Example: + pam workflow create --approvals-needed 2 --duration 2h --checkout + """ + parser = argparse.ArgumentParser(prog='pam workflow create', + description='Create workflow configuration for a PAM record') + parser.add_argument('record', help='Record UID or name to configure workflow for') + parser.add_argument('-n', '--approvals-needed', type=int, default=1, + help='Number of approvals required (default: 1)') + parser.add_argument('-co', '--checkout', action='store_true', + help='Enable single-user check-in/check-out mode') + parser.add_argument('-sa', '--start-on-approval', action='store_true', + help='Start access timer when approved (vs when checked out)') + parser.add_argument('-rr', '--require-reason', action='store_true', + help='Require user to provide reason for access') + parser.add_argument('-rt', '--require-ticket', action='store_true', + help='Require user to provide ticket number') + parser.add_argument('-rm', '--require-mfa', action='store_true', + help='Require MFA verification for access') + parser.add_argument('-d', '--duration', type=str, default='1d', + help='Access duration (e.g., "2h", "30m", "1d"). Default: 1d') + parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], + default='table', help='Output format') + + def get_parser(self): + return WorkflowCreateCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + """Execute workflow creation.""" + record_uid = kwargs.get('record') + + # Resolve record UID if name provided + if record_uid not in params.record_cache: + # Try to search for record by name + records = list(params.record_cache.keys()) + for uid in records: + rec = vault.KeeperRecord.load(params, uid) + if rec and rec.title == record_uid: + record_uid = uid + break + else: + raise CommandError('', f'Record "{record_uid}" not found') + + # Get record details + record = vault.KeeperRecord.load(params, record_uid) + record_uid_bytes = utils.base64_url_decode(record_uid) + + # Create workflow parameters - EXPLICITLY SET ALL FIELDS + # (Protobuf3 defaults can cause issues, so we set everything explicitly) + parameters = workflow_pb2.WorkflowParameters() + parameters.resource.CopyFrom(create_record_ref(record_uid_bytes, record.title)) + + # Set all required fields explicitly + parameters.approvalsNeeded = kwargs.get('approvals_needed', 1) + parameters.checkoutNeeded = kwargs.get('checkout', False) + parameters.startAccessOnApproval = kwargs.get('start_on_approval', False) + parameters.requireReason = kwargs.get('require_reason', False) + parameters.requireTicket = kwargs.get('require_ticket', False) + parameters.requireMFA = kwargs.get('require_mfa', False) + + # Parse duration + duration_str = kwargs.get('duration', '1d') + parameters.accessLength = parse_duration_to_milliseconds(duration_str) + + # IMPORTANT: allowedTimes field (field #9) - leave unset for now + # If workflow requires time-based restrictions, this would be set + # For now, leaving it unset means "no time restrictions" + + # Make API call + try: + response = _post_request_to_router( + params, + 'create_workflow_config', + rq_proto=parameters + ) + + # Success (router returns empty response or 200 status) + if kwargs.get('format') == 'json': + result = { + 'status': 'success', + 'record_uid': record_uid, + 'record_title': record.title, + 'workflow_config': { + 'approvals_needed': parameters.approvalsNeeded, + 'checkout_needed': parameters.checkoutNeeded, + 'require_reason': parameters.requireReason, + 'require_ticket': parameters.requireTicket, + 'require_mfa': parameters.requireMFA, + 'access_duration': format_duration_from_milliseconds(parameters.accessLength) + } + } + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.OKGREEN}✓ Workflow created successfully{bcolors.ENDC}\n") + print(f"Record: {record.title} ({record_uid})") + print(f"Approvals needed: {parameters.approvalsNeeded}") + print(f"Check-in/out: {'Yes' if parameters.checkoutNeeded else 'No'}") + print(f"Duration: {format_duration_from_milliseconds(parameters.accessLength)}") + if parameters.requireReason: + print(f"Requires reason: Yes") + if parameters.requireTicket: + print(f"Requires ticket: Yes") + if parameters.requireMFA: + print(f"Requires MFA: Yes") + print() + + except Exception as e: + raise CommandError('', f'Failed to create workflow: {str(e)}') + + +class WorkflowUpdateCommand(Command): + """ + Update an existing workflow configuration. + + Reads the current configuration first, then applies only the + specified changes. Unspecified fields retain their current values. + + Example: + pam workflow update --approvals-needed 3 --duration 4h + """ + parser = argparse.ArgumentParser(prog='pam workflow update', + description='Update existing workflow configuration. ' + 'Only specified fields are changed; unspecified fields ' + 'retain their current values.') + parser.add_argument('record', help='Record UID or name with workflow to update') + parser.add_argument('-n', '--approvals-needed', type=int, help='Number of approvals required') + parser.add_argument('-co', '--checkout', type=lambda x: x.lower() == 'true', + help='Enable/disable check-in/check-out (true/false)') + parser.add_argument('-sa', '--start-on-approval', type=lambda x: x.lower() == 'true', + help='Start timer on approval vs check-out (true/false)') + parser.add_argument('-rr', '--require-reason', type=lambda x: x.lower() == 'true', + help='Require reason (true/false)') + parser.add_argument('-rt', '--require-ticket', type=lambda x: x.lower() == 'true', + help='Require ticket (true/false)') + parser.add_argument('-rm', '--require-mfa', type=lambda x: x.lower() == 'true', + help='Require MFA (true/false)') + parser.add_argument('-d', '--duration', type=str, help='Access duration (e.g., "2h", "30m", "1d")') + parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], + default='table', help='Output format') + + def get_parser(self): + return WorkflowUpdateCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + """Execute workflow update.""" + record_uid = kwargs.get('record') + + # Resolve record UID + if record_uid not in params.record_cache: + records = list(params.record_cache.keys()) + for uid in records: + rec = vault.KeeperRecord.load(params, uid) + if rec and rec.title == record_uid: + record_uid = uid + break + else: + raise CommandError('', f'Record "{record_uid}" not found') + + record = vault.KeeperRecord.load(params, record_uid) + record_uid_bytes = utils.base64_url_decode(record_uid) + + try: + # Fetch current workflow config using read_workflow_config + # This ensures we preserve existing values when doing partial updates + ref = create_record_ref(record_uid_bytes, record.title) + current_config = _post_request_to_router( + params, + 'read_workflow_config', + rq_proto=ref, + rs_type=workflow_pb2.WorkflowConfig + ) + + if not current_config: + raise CommandError('', 'No workflow found for record. Create one first with "pam workflow create"') + + # Start with current config values, then override with user-provided values + parameters = workflow_pb2.WorkflowParameters() + parameters.CopyFrom(current_config.parameters) + + # Override with user-provided values + updates_provided = False + if kwargs.get('approvals_needed') is not None: + parameters.approvalsNeeded = kwargs['approvals_needed'] + updates_provided = True + if kwargs.get('checkout') is not None: + parameters.checkoutNeeded = kwargs['checkout'] + updates_provided = True + if kwargs.get('start_on_approval') is not None: + parameters.startAccessOnApproval = kwargs['start_on_approval'] + updates_provided = True + if kwargs.get('require_reason') is not None: + parameters.requireReason = kwargs['require_reason'] + updates_provided = True + if kwargs.get('require_ticket') is not None: + parameters.requireTicket = kwargs['require_ticket'] + updates_provided = True + if kwargs.get('require_mfa') is not None: + parameters.requireMFA = kwargs['require_mfa'] + updates_provided = True + if kwargs.get('duration') is not None: + parameters.accessLength = parse_duration_to_milliseconds(kwargs['duration']) + updates_provided = True + + if not updates_provided: + raise CommandError('', 'No updates provided. Specify at least one option to update (e.g., --approvals-needed, --duration)') + + # Make API call + response = _post_request_to_router( + params, + 'update_workflow_config', + rq_proto=parameters + ) + + if kwargs.get('format') == 'json': + result = {'status': 'success', 'record_uid': record_uid} + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.OKGREEN}✓ Workflow updated successfully{bcolors.ENDC}\n") + print(f"Record: {record.title} ({record_uid})") + print() + + except CommandError: + raise + except Exception as e: + raise CommandError('', f'Failed to update workflow: {str(e)}') + + +class WorkflowReadCommand(Command): + """ + Read/display workflow configuration. + + Shows the complete workflow configuration including all parameters, + approvers, and metadata. + + Example: + pam workflow read + """ + parser = argparse.ArgumentParser(prog='pam workflow read', + description='Read and display workflow configuration') + parser.add_argument('record', help='Record UID or name') + parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], + default='table', help='Output format') + + def get_parser(self): + return WorkflowReadCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + """Execute workflow read.""" + record_uid = kwargs.get('record') + + # Resolve record UID + if record_uid not in params.record_cache: + records = list(params.record_cache.keys()) + for uid in records: + rec = vault.KeeperRecord.load(params, uid) + if rec and rec.title == record_uid: + record_uid = uid + break + else: + raise CommandError('', f'Record "{record_uid}" not found') + + record = vault.KeeperRecord.load(params, record_uid) + record_uid_bytes = utils.base64_url_decode(record_uid) + + # Create reference to record + ref = create_record_ref(record_uid_bytes, record.title) + + # Make API call + try: + response = _post_request_to_router( + params, + 'read_workflow_config', + rq_proto=ref, + rs_type=workflow_pb2.WorkflowConfig + ) + + if not response: + if kwargs.get('format') == 'json': + print(json.dumps({'status': 'no_workflow', 'message': 'No workflow configured'}, indent=2)) + else: + print(f"\n{bcolors.WARNING}No workflow configured for this record{bcolors.ENDC}\n") + print(f"Record: {record.title} ({record_uid})") + print(f"\nTo create a workflow, run:") + print(f" pam workflow create {record_uid}") + print() + return + + if kwargs.get('format') == 'json': + # JSON output + result = { + 'record_uid': record_uid, + 'record_name': response.parameters.resource.name, + 'created_on': response.createdOn, + 'parameters': { + 'approvals_needed': response.parameters.approvalsNeeded, + 'checkout_needed': response.parameters.checkoutNeeded, + 'start_access_on_approval': response.parameters.startAccessOnApproval, + 'require_reason': response.parameters.requireReason, + 'require_ticket': response.parameters.requireTicket, + 'require_mfa': response.parameters.requireMFA, + 'access_length_ms': response.parameters.accessLength, + 'access_duration': format_duration_from_milliseconds(response.parameters.accessLength) + }, + 'approvers': [] + } + + # Add approvers + for approver in response.approvers: + approver_info = {'escalation': approver.escalation} + if approver.HasField('user'): + approver_info['type'] = 'user' + approver_info['email'] = approver.user + elif approver.HasField('userId'): + approver_info['type'] = 'user_id' + approver_info['user_id'] = approver.userId + elif approver.HasField('teamUid'): + approver_info['type'] = 'team' + approver_info['team_uid'] = utils.base64_url_encode(approver.teamUid) + result['approvers'].append(approver_info) + + print(json.dumps(result, indent=2)) + else: + # Table output + print(f"\n{bcolors.OKBLUE}Workflow Configuration{bcolors.ENDC}\n") + print(f"Record: {response.parameters.resource.name}") + print(f"Record UID: {record_uid}") + + # Display creation date if available + if response.createdOn: + created_date = datetime.fromtimestamp(response.createdOn / 1000) + print(f"Created: {created_date.strftime('%Y-%m-%d %H:%M:%S')}") + + print(f"\n{bcolors.BOLD}Access Parameters:{bcolors.ENDC}") + print(f" Approvals needed: {response.parameters.approvalsNeeded}") + print(f" Check-in/out required: {'Yes' if response.parameters.checkoutNeeded else 'No'}") + print(f" Access duration: {format_duration_from_milliseconds(response.parameters.accessLength)}") + print(f" Timer starts: {'On approval' if response.parameters.startAccessOnApproval else 'On check-out'}") + + print(f"\n{bcolors.BOLD}Requirements:{bcolors.ENDC}") + print(f" Reason required: {'Yes' if response.parameters.requireReason else 'No'}") + print(f" Ticket required: {'Yes' if response.parameters.requireTicket else 'No'}") + print(f" MFA required: {'Yes' if response.parameters.requireMFA else 'No'}") + + # Display approvers + if response.approvers: + print(f"\n{bcolors.BOLD}Approvers ({len(response.approvers)}):{bcolors.ENDC}") + for idx, approver in enumerate(response.approvers, 1): + escalation = ' (Escalation)' if approver.escalation else '' + if approver.HasField('user'): + print(f" {idx}. User: {approver.user}{escalation}") + elif approver.HasField('userId'): + print(f" {idx}. User ID: {approver.userId}{escalation}") + elif approver.HasField('teamUid'): + team_uid = utils.base64_url_encode(approver.teamUid) + print(f" {idx}. Team: {team_uid}{escalation}") + else: + print(f"\n{bcolors.WARNING}⚠ No approvers configured!{bcolors.ENDC}") + print(f"Add approvers with: pam workflow add-approver {record_uid} --user ") + + print() + + except Exception as e: + raise CommandError('', f'Failed to read workflow: {str(e)}') + + +class WorkflowDeleteCommand(Command): + """ + Delete a workflow configuration from a record. + + This removes all workflow restrictions and returns the record + to normal access mode. + + Example: + pam workflow delete + """ + parser = argparse.ArgumentParser(prog='pam workflow delete', + description='Delete workflow configuration from a record') + parser.add_argument('record', help='Record UID or name to remove workflow from') + parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], + default='table', help='Output format') + + def get_parser(self): + return WorkflowDeleteCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + """Execute workflow deletion.""" + record_uid = kwargs.get('record') + + # Resolve record UID + if record_uid not in params.record_cache: + records = list(params.record_cache.keys()) + for uid in records: + rec = vault.KeeperRecord.load(params, uid) + if rec and rec.title == record_uid: + record_uid = uid + break + else: + raise CommandError('', f'Record "{record_uid}" not found') + + record = vault.KeeperRecord.load(params, record_uid) + record_uid_bytes = utils.base64_url_decode(record_uid) + + # Create reference to record + ref = create_record_ref(record_uid_bytes, record.title) + + # Make API call + try: + response = _post_request_to_router( + params, + 'delete_workflow_config', + rq_proto=ref + ) + + if kwargs.get('format') == 'json': + result = {'status': 'success', 'record_uid': record_uid} + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.OKGREEN}✓ Workflow deleted successfully{bcolors.ENDC}\n") + print(f"Record: {record.title} ({record_uid})") + print() + + except Exception as e: + raise CommandError('', f'Failed to delete workflow: {str(e)}') + + +# ============================================================================ +# APPROVER MANAGEMENT COMMANDS +# ============================================================================ + +class WorkflowAddApproversCommand(Command): + """ + Add approvers to a workflow. + + Approvers are users or teams who can approve access requests. + You can mark approvers as "escalated" for handling delayed approvals. + + Example: + pam workflow add-approver --user alice@company.com + pam workflow add-approver --team --escalation + """ + parser = argparse.ArgumentParser(prog='pam workflow add-approver', + description='Add approvers to a workflow') + parser.add_argument('record', help='Record UID or name') + parser.add_argument('-u', '--user', action='append', + help='User email to add as approver (can specify multiple times)') + parser.add_argument('-t', '--team', action='append', + help='Team UID to add as approver (can specify multiple times)') + parser.add_argument('-e', '--escalation', action='store_true', help='Mark as escalation approver') + parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], + default='table', help='Output format') + + def get_parser(self): + return WorkflowAddApproversCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + """Execute add approvers.""" + record_uid = kwargs.get('record') + users = kwargs.get('user') or [] + teams = kwargs.get('team') or [] + is_escalation = kwargs.get('escalation', False) + + if not users and not teams: + raise CommandError('', 'Must specify at least one --user or --team') + + # Resolve record UID + if record_uid not in params.record_cache: + records = list(params.record_cache.keys()) + for uid in records: + rec = vault.KeeperRecord.load(params, uid) + if rec and rec.title == record_uid: + record_uid = uid + break + else: + raise CommandError('', f'Record "{record_uid}" not found') + + record = vault.KeeperRecord.load(params, record_uid) + record_uid_bytes = utils.base64_url_decode(record_uid) + + # Create workflow config with approvers + config = workflow_pb2.WorkflowConfig() + config.parameters.resource.CopyFrom(create_record_ref(record_uid_bytes, record.title)) + + # Add user approvers + for user_email in users: + approver = workflow_pb2.WorkflowApprover() + approver.user = user_email + approver.escalation = is_escalation + config.approvers.append(approver) + + # Add team approvers + for team_uid in teams: + approver = workflow_pb2.WorkflowApprover() + approver.teamUid = utils.base64_url_decode(team_uid) + approver.escalation = is_escalation + config.approvers.append(approver) + + # Make API call + try: + response = _post_request_to_router( + params, + 'add_workflow_approvers', + rq_proto=config + ) + + if kwargs.get('format') == 'json': + result = { + 'status': 'success', + 'record_uid': record_uid, + 'approvers_added': len(users) + len(teams), + 'escalation': is_escalation + } + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.OKGREEN}✓ Approvers added successfully{bcolors.ENDC}\n") + print(f"Record: {record.title} ({record_uid})") + print(f"Added {len(users) + len(teams)} approver(s)") + if is_escalation: + print("Type: Escalation approver") + print() + + except Exception as e: + raise CommandError('', f'Failed to add approvers: {str(e)}') + + +class WorkflowDeleteApproversCommand(Command): + """ + Remove approvers from a workflow. + + Example: + pam workflow remove-approver --user alice@company.com + """ + parser = argparse.ArgumentParser(prog='pam workflow remove-approver', + description='Remove approvers from a workflow') + parser.add_argument('record', help='Record UID or name') + parser.add_argument('-u', '--user', action='append', help='User email to remove as approver') + parser.add_argument('-t', '--team', action='append', help='Team UID to remove as approver') + parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], + default='table', help='Output format') + + def get_parser(self): + return WorkflowDeleteApproversCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + """Execute delete approvers.""" + record_uid = kwargs.get('record') + users = kwargs.get('user') or [] + teams = kwargs.get('team') or [] + + if not users and not teams: + raise CommandError('', 'Must specify at least one --user or --team') + + # Resolve record UID + if record_uid not in params.record_cache: + records = list(params.record_cache.keys()) + for uid in records: + rec = vault.KeeperRecord.load(params, uid) + if rec and rec.title == record_uid: + record_uid = uid + break + else: + raise CommandError('', f'Record "{record_uid}" not found') + + record = vault.KeeperRecord.load(params, record_uid) + record_uid_bytes = utils.base64_url_decode(record_uid) + + # Create workflow config with approvers to remove + config = workflow_pb2.WorkflowConfig() + config.parameters.resource.CopyFrom(create_record_ref(record_uid_bytes, record.title)) + + # Add user approvers to remove + for user_email in users: + approver = workflow_pb2.WorkflowApprover() + approver.user = user_email + config.approvers.append(approver) + + # Add team approvers to remove + for team_uid in teams: + approver = workflow_pb2.WorkflowApprover() + approver.teamUid = utils.base64_url_decode(team_uid) + config.approvers.append(approver) + + # Make API call + try: + response = _post_request_to_router( + params, + 'delete_workflow_approvers', + rq_proto=config + ) + + if kwargs.get('format') == 'json': + result = { + 'status': 'success', + 'record_uid': record_uid, + 'approvers_removed': len(users) + len(teams) + } + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.OKGREEN}✓ Approvers removed successfully{bcolors.ENDC}\n") + print(f"Record: {record.title} ({record_uid})") + print(f"Removed {len(users) + len(teams)} approver(s)") + print() + + except Exception as e: + raise CommandError('', f'Failed to remove approvers: {str(e)}') + + +# ============================================================================ +# STATE INSPECTION COMMANDS +# ============================================================================ + +class WorkflowGetStateCommand(Command): + """ + Get the current state of a workflow. + + Shows whether a workflow is ready to start, waiting for approval, + in progress, etc. + + Example: + pam workflow state --record + pam workflow state --flow-uid + """ + parser = argparse.ArgumentParser(prog='pam workflow state', + description='Get workflow state for a record or flow') + _state_group = parser.add_mutually_exclusive_group(required=True) + _state_group.add_argument('-r', '--record', help='Record UID or name') + _state_group.add_argument('-f', '--flow-uid', help='Flow UID of active workflow') + parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], + default='table', help='Output format') + + def get_parser(self): + return WorkflowGetStateCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + """Execute get workflow state.""" + record_uid = kwargs.get('record') + flow_uid = kwargs.get('flow_uid') + + # Create state request + state = workflow_pb2.WorkflowState() + + if flow_uid: + # Query by flow UID + state.flowUid = utils.base64_url_decode(flow_uid) + else: + # Query by record UID + if record_uid not in params.record_cache: + records = list(params.record_cache.keys()) + for uid in records: + rec = vault.KeeperRecord.load(params, uid) + if rec and rec.title == record_uid: + record_uid = uid + break + else: + raise CommandError('', f'Record "{record_uid}" not found') + + record = vault.KeeperRecord.load(params, record_uid) + record_uid_bytes = utils.base64_url_decode(record_uid) + state.resource.CopyFrom(create_record_ref(record_uid_bytes, record.title)) + + # Make API call + try: + response = _post_request_to_router( + params, + 'get_workflow_state', + rq_proto=state, + rs_type=workflow_pb2.WorkflowState + ) + + if response is None: + if kwargs.get('format') == 'json': + print(json.dumps({'status': 'no_workflow', 'message': 'No workflow found'}, indent=2)) + else: + print(f"\n{bcolors.WARNING}No workflow found for this record{bcolors.ENDC}\n") + return + + if kwargs.get('format') == 'json': + result = { + 'flow_uid': utils.base64_url_encode(response.flowUid), + 'record_uid': utils.base64_url_encode(response.resource.value), + 'record_name': response.resource.name, + 'stage': format_workflow_stage(response.status.stage), + 'conditions': [format_access_conditions([c]) for c in response.status.conditions], + 'escalated': response.status.escalated, + 'started_on': response.status.startedOn, + 'expires_on': response.status.expiresOn, + 'approved_by': [ + {'user': a.user, 'user_id': a.userId, 'approved_on': a.approvedOn} + for a in response.status.approvedBy + ] + } + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.OKBLUE}Workflow State{bcolors.ENDC}\n") + print(f"Flow UID: {utils.base64_url_encode(response.flowUid)}") + print(f"Record: {response.resource.name}") + print(f"Stage: {format_workflow_stage(response.status.stage)}") + if response.status.conditions: + print(f"Conditions: {format_access_conditions(response.status.conditions)}") + if response.status.escalated: + print(f"Escalated: Yes") + if response.status.startedOn: + started = datetime.fromtimestamp(response.status.startedOn / 1000) + print(f"Started: {started.strftime('%Y-%m-%d %H:%M:%S')}") + if response.status.expiresOn: + expires = datetime.fromtimestamp(response.status.expiresOn / 1000) + print(f"Expires: {expires.strftime('%Y-%m-%d %H:%M:%S')}") + if response.status.approvedBy: + print(f"Approved by:") + for a in response.status.approvedBy: + name = a.user if a.user else f"User ID {a.userId}" + ts = '' + if a.approvedOn: + ts = f" at {datetime.fromtimestamp(a.approvedOn / 1000).strftime('%Y-%m-%d %H:%M:%S')}" + print(f" - {name}{ts}") + print() + + except Exception as e: + raise CommandError('', f'Failed to get workflow state: {str(e)}') + + +class WorkflowGetUserAccessStateCommand(Command): + """ + Get all workflows for the current user. + + Shows all active workflows, pending approvals, and available workflows + for the logged-in user. + + Example: + pam workflow my-access + """ + parser = argparse.ArgumentParser(prog='pam workflow my-access', + description='Get all workflow states for current user') + parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], + default='table', help='Output format') + + def get_parser(self): + return WorkflowGetUserAccessStateCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + """Execute get user access state.""" + try: + response = _post_request_to_router( + params, + 'get_user_access_state', + rs_type=workflow_pb2.UserAccessState + ) + + if not response or not response.workflows: + if kwargs.get('format') == 'json': + print(json.dumps({'workflows': []}, indent=2)) + else: + print(f"\n{bcolors.WARNING}No active workflows{bcolors.ENDC}\n") + return + + if kwargs.get('format') == 'json': + result = { + 'workflows': [ + { + 'flow_uid': utils.base64_url_encode(wf.flowUid), + 'record_uid': utils.base64_url_encode(wf.resource.value), + 'record_name': wf.resource.name, + 'stage': format_workflow_stage(wf.status.stage), + 'conditions': [format_access_conditions([c]) for c in wf.status.conditions], + 'escalated': wf.status.escalated, + 'started_on': wf.status.startedOn, + 'expires_on': wf.status.expiresOn, + 'approved_by': [ + {'user': a.user, 'user_id': a.userId, 'approved_on': a.approvedOn} + for a in wf.status.approvedBy + ] + } + for wf in response.workflows + ] + } + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.OKBLUE}Your Active Workflows{bcolors.ENDC}\n") + for idx, wf in enumerate(response.workflows, 1): + print(f"{idx}. {wf.resource.name}") + print(f" Flow UID: {utils.base64_url_encode(wf.flowUid)}") + print(f" Stage: {format_workflow_stage(wf.status.stage)}") + if wf.status.conditions: + print(f" Conditions: {format_access_conditions(wf.status.conditions)}") + if wf.status.escalated: + print(f" Escalated: Yes") + if wf.status.startedOn: + started = datetime.fromtimestamp(wf.status.startedOn / 1000) + print(f" Started: {started.strftime('%Y-%m-%d %H:%M:%S')}") + if wf.status.expiresOn: + expires = datetime.fromtimestamp(wf.status.expiresOn / 1000) + print(f" Expires: {expires.strftime('%Y-%m-%d %H:%M:%S')}") + if wf.status.approvedBy: + approved_names = [a.user if a.user else f"ID:{a.userId}" for a in wf.status.approvedBy] + print(f" Approved by: {', '.join(approved_names)}") + print() + + except Exception as e: + raise CommandError('', f'Failed to get user access state: {str(e)}') + + +class WorkflowGetApprovalRequestsCommand(Command): + """ + Get pending approval requests for the current user. + + Shows all workflows waiting for your approval. + + Example: + pam workflow pending + """ + parser = argparse.ArgumentParser(prog='pam workflow pending', + description='Get pending approval requests') + parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], + default='table', help='Output format') + + def get_parser(self): + return WorkflowGetApprovalRequestsCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + """Execute get approval requests.""" + try: + response = _post_request_to_router( + params, + 'get_approval_requests', + rs_type=workflow_pb2.ApprovalRequests + ) + + if not response or not response.workflows: + if kwargs.get('format') == 'json': + print(json.dumps({'requests': []}, indent=2)) + else: + print(f"\n{bcolors.WARNING}No pending approval requests{bcolors.ENDC}\n") + return + + if kwargs.get('format') == 'json': + result = { + 'requests': [ + { + 'flow_uid': utils.base64_url_encode(wf.flowUid), + 'user_id': wf.userId, + 'record_uid': utils.base64_url_encode(wf.resource.value), + 'record_name': wf.resource.name, + 'started_on': wf.startedOn, + 'expires_on': wf.expiresOn, + 'reason': wf.reason.decode('utf-8') if wf.reason else '', + 'external_ref': wf.externalRef.decode('utf-8') if wf.externalRef else '', + 'mfa_verified': wf.mfaVerified + } + for wf in response.workflows + ] + } + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.OKBLUE}Pending Approval Requests{bcolors.ENDC}\n") + for idx, wf in enumerate(response.workflows, 1): + print(f"{idx}. {wf.resource.name}") + print(f" Flow UID: {utils.base64_url_encode(wf.flowUid)}") + print(f" Requested by: User ID {wf.userId}") + if wf.startedOn: + started = datetime.fromtimestamp(wf.startedOn / 1000) + print(f" Requested on: {started.strftime('%Y-%m-%d %H:%M:%S')}") + if wf.expiresOn: + expires = datetime.fromtimestamp(wf.expiresOn / 1000) + print(f" Expires: {expires.strftime('%Y-%m-%d %H:%M:%S')}") + if wf.reason: + print(f" Reason: {wf.reason.decode('utf-8')}") + if wf.externalRef: + print(f" Ticket: {wf.externalRef.decode('utf-8')}") + print(f" MFA Verified: {'Yes' if wf.mfaVerified else 'No'}") + print() + + except Exception as e: + raise CommandError('', f'Failed to get approval requests: {str(e)}') + + +# ============================================================================ +# ACTION COMMANDS +# ============================================================================ + +class WorkflowStartCommand(Command): + """ + Start a workflow (check-out). + + Explicitly starts a workflow and checks out the resource for use. + Can also be started automatically by approval or when attempting + to access a PAM resource. + + Example: + pam workflow start + """ + parser = argparse.ArgumentParser(prog='pam workflow start', + description='Start a workflow (check-out)') + parser.add_argument('record', help='Record UID or name') + parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], + default='table', help='Output format') + + def get_parser(self): + return WorkflowStartCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + """Execute workflow start.""" + record_uid = kwargs.get('record') + + # Resolve record UID + if record_uid not in params.record_cache: + records = list(params.record_cache.keys()) + for uid in records: + rec = vault.KeeperRecord.load(params, uid) + if rec and rec.title == record_uid: + record_uid = uid + break + else: + raise CommandError('', f'Record "{record_uid}" not found') + + record = vault.KeeperRecord.load(params, record_uid) + record_uid_bytes = utils.base64_url_decode(record_uid) + + # Create workflow state + state = workflow_pb2.WorkflowState() + state.resource.CopyFrom(create_record_ref(record_uid_bytes, record.title)) + + # Make API call + try: + response = _post_request_to_router( + params, + 'start_workflow', + rq_proto=state + ) + + if kwargs.get('format') == 'json': + result = {'status': 'success', 'record_uid': record_uid, 'action': 'checked_out'} + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.OKGREEN}✓ Workflow started (checked out){bcolors.ENDC}\n") + print(f"Record: {record.title} ({record_uid})") + print() + + except Exception as e: + raise CommandError('', f'Failed to start workflow: {str(e)}') + + +class WorkflowRequestAccessCommand(Command): + """ + Request access to a PAM resource with workflow. + + Sends approval request to configured approvers. + + Example: + pam workflow request --reason "Fix bug" --ticket INC-1234 + """ + parser = argparse.ArgumentParser(prog='pam workflow request', + description='Request access to a PAM resource') + parser.add_argument('record', help='Record UID or name') + parser.add_argument('-r', '--reason', help='Reason for access request') + parser.add_argument('-t', '--ticket', help='External ticket/reference number') + parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], + default='table', help='Output format') + + def get_parser(self): + return WorkflowRequestAccessCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + """Execute workflow access request.""" + record_uid = kwargs.get('record') + reason = kwargs.get('reason') or '' + ticket = kwargs.get('ticket') or '' + + # Resolve record UID + if record_uid not in params.record_cache: + records = list(params.record_cache.keys()) + for uid in records: + rec = vault.KeeperRecord.load(params, uid) + if rec and rec.title == record_uid: + record_uid = uid + break + else: + raise CommandError('', f'Record "{record_uid}" not found') + + record = vault.KeeperRecord.load(params, record_uid) + record_uid_bytes = utils.base64_url_decode(record_uid) + + # Use WorkflowAccessRequest which properly supports reason and ticket + # (Updated proto: WorkflowAccessRequest { resource, reason, ticket }) + access_request = workflow_pb2.WorkflowAccessRequest() + access_request.resource.CopyFrom(create_record_ref(record_uid_bytes, record.title)) + if reason: + access_request.reason = reason + if ticket: + access_request.ticket = ticket + + # Make API call + try: + response = _post_request_to_router( + params, + 'request_workflow_access', + rq_proto=access_request + ) + + if kwargs.get('format') == 'json': + result = { + 'status': 'success', + 'record_uid': record_uid, + 'message': 'Access request sent to approvers' + } + if reason: + result['reason'] = reason + if ticket: + result['ticket'] = ticket + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.OKGREEN}✓ Access request sent{bcolors.ENDC}\n") + print(f"Record: {record.title} ({record_uid})") + if reason: + print(f"Reason: {reason}") + if ticket: + print(f"Ticket: {ticket}") + print("\nApprovers have been notified.") + print() + + except Exception as e: + raise CommandError('', f'Failed to request access: {str(e)}') + + +class WorkflowApproveCommand(Command): + """ + Approve a workflow access request. + + Example: + pam workflow approve + """ + parser = argparse.ArgumentParser(prog='pam workflow approve', + description='Approve a workflow access request') + parser.add_argument('flow_uid', help='Flow UID of the workflow to approve') + parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], + default='table', help='Output format') + + def get_parser(self): + return WorkflowApproveCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + """Execute workflow approval.""" + flow_uid = kwargs.get('flow_uid') + flow_uid_bytes = utils.base64_url_decode(flow_uid) + + # Create workflow reference + ref = create_workflow_ref(flow_uid_bytes) + + # Make API call + try: + response = _post_request_to_router( + params, + 'approve_workflow_access', + rq_proto=ref + ) + + if kwargs.get('format') == 'json': + result = {'status': 'success', 'flow_uid': flow_uid, 'action': 'approved'} + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.OKGREEN}✓ Access request approved{bcolors.ENDC}\n") + print(f"Flow UID: {flow_uid}") + print() + + except Exception as e: + raise CommandError('', f'Failed to approve request: {str(e)}') + + +class WorkflowDenyCommand(Command): + """ + Deny a workflow access request. + + Example: + pam workflow deny + """ + parser = argparse.ArgumentParser(prog='pam workflow deny', + description='Deny a workflow access request') + parser.add_argument('flow_uid', help='Flow UID of the workflow to deny') + parser.add_argument('-r', '--reason', help='Reason for denial') + parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], + default='table', help='Output format') + + def get_parser(self): + return WorkflowDenyCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + """Execute workflow denial.""" + flow_uid = kwargs.get('flow_uid') + reason = kwargs.get('reason') + flow_uid_bytes = utils.base64_url_decode(flow_uid) + + # Create workflow reference + ref = create_workflow_ref(flow_uid_bytes) + + # Make API call + try: + response = _post_request_to_router( + params, + 'deny_workflow_access', + rq_proto=ref + ) + + if kwargs.get('format') == 'json': + result = {'status': 'success', 'flow_uid': flow_uid, 'action': 'denied'} + if reason: + result['reason'] = reason + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.WARNING}Access request denied{bcolors.ENDC}\n") + print(f"Flow UID: {flow_uid}") + if reason: + print(f"Reason: {reason}") + print() + + except Exception as e: + raise CommandError('', f'Failed to deny request: {str(e)}') + + +class WorkflowEndCommand(Command): + """ + End a workflow (check-in). + + Explicitly ends the workflow and triggers side effects like + credential rotation. + + Example: + pam workflow end + pam workflow end # Auto-detects active flow for record + """ + parser = argparse.ArgumentParser(prog='pam workflow end', + description='End a workflow (check-in). ' + 'Can use either flow UID or record UID.') + parser.add_argument('uid', help='Flow UID or Record UID of the workflow to end') + parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], + default='table', help='Output format') + + def get_parser(self): + return WorkflowEndCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + """Execute workflow end.""" + uid = kwargs.get('uid') + uid_bytes = utils.base64_url_decode(uid) + + # Try to determine if this is a flow UID or record UID + # First, assume it's a flow UID and try to end it + ref = create_workflow_ref(uid_bytes) + + try: + response = _post_request_to_router( + params, + 'end_workflow', + rq_proto=ref + ) + + # Success - it was a flow UID + if kwargs.get('format') == 'json': + result = {'status': 'success', 'flow_uid': uid, 'action': 'ended'} + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.OKGREEN}✓ Workflow ended (checked in){bcolors.ENDC}\n") + print(f"Flow UID: {uid}") + print("\nCredentials may have been rotated.") + print() + return + + except Exception as first_error: + # Failed - might be because uid is a record UID, not flow UID + # Try to get the active workflow for this record + try: + # Query workflow state by record UID + state_query = workflow_pb2.WorkflowState() + state_query.resource.CopyFrom(create_record_ref(uid_bytes)) + + workflow_state = _post_request_to_router( + params, + 'get_workflow_state', + rq_proto=state_query, + rs_type=workflow_pb2.WorkflowState + ) + + if not workflow_state or not workflow_state.flowUid: + raise CommandError('', f'No active workflow found for this record. ' + f'The workflow may have already ended or never started.') + + # Found the flow UID, now end it + flow_ref = create_workflow_ref(workflow_state.flowUid) + response = _post_request_to_router( + params, + 'end_workflow', + rq_proto=flow_ref + ) + + # Success + flow_uid_str = utils.base64_url_encode(workflow_state.flowUid) + if kwargs.get('format') == 'json': + result = { + 'status': 'success', + 'flow_uid': flow_uid_str, + 'record_uid': uid, + 'action': 'ended' + } + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.OKGREEN}✓ Workflow ended (checked in){bcolors.ENDC}\n") + print(f"Record: {workflow_state.resource.name}") + print(f"Flow UID: {flow_uid_str}") + print("\nCredentials may have been rotated.") + print() + + except CommandError: + raise + except Exception as second_error: + # Both attempts failed - show the original error + raise CommandError('', f'Failed to end workflow: {str(first_error)}') + + +# ============================================================================ +# GROUP COMMAND (for PAM hierarchy) +# ============================================================================ + +class PAMWorkflowCommand(GroupCommand): + """ + PAM Workflow management commands. + + Groups all workflow-related commands under 'pam workflow' hierarchy. + """ + + def __init__(self): + super(PAMWorkflowCommand, self).__init__() + + # Configuration commands + self.register_command('create', WorkflowCreateCommand(), 'Create workflow configuration', 'c') + self.register_command('read', WorkflowReadCommand(), 'Read workflow configuration', 'r') + self.register_command('update', WorkflowUpdateCommand(), 'Update workflow configuration', 'u') + self.register_command('delete', WorkflowDeleteCommand(), 'Delete workflow configuration', 'd') + + # Approver management + self.register_command('add-approver', WorkflowAddApproversCommand(), 'Add approvers', 'aa') + self.register_command('remove-approver', WorkflowDeleteApproversCommand(), 'Remove approvers', 'ra') + + # State inspection + self.register_command('state', WorkflowGetStateCommand(), 'Get workflow state', 'st') + self.register_command('my-access', WorkflowGetUserAccessStateCommand(), 'Get my workflow access', 'ma') + self.register_command('pending', WorkflowGetApprovalRequestsCommand(), 'Get pending approvals', 'p') + + # Actions + self.register_command('start', WorkflowStartCommand(), 'Start workflow (check-out)', 's') + self.register_command('request', WorkflowRequestAccessCommand(), 'Request access', 'rq') + self.register_command('approve', WorkflowApproveCommand(), 'Approve access', 'a') + self.register_command('deny', WorkflowDenyCommand(), 'Deny access', 'dn') + self.register_command('end', WorkflowEndCommand(), 'End workflow (check-in)', 'e') + + self.default_verb = 'state' + diff --git a/keepercommander/proto/GraphSync_pb2.py b/keepercommander/proto/GraphSync_pb2.py index a12dd3ce6..892f43c4f 100644 --- a/keepercommander/proto/GraphSync_pb2.py +++ b/keepercommander/proto/GraphSync_pb2.py @@ -18,8 +18,8 @@ _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'GraphSync_pb2', _globals) -if _descriptor._USE_C_DESCRIPTORS == False: - _globals['DESCRIPTOR']._options = None +if not _descriptor._USE_C_DESCRIPTORS: + _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'\n\030com.keepersecurity.protoB\tGraphSync' _globals['_REFTYPE']._serialized_start=1074 _globals['_REFTYPE']._serialized_end=1431 diff --git a/keepercommander/proto/GraphSync_pb2.pyi b/keepercommander/proto/GraphSync_pb2.pyi index 44c355c68..26c41dc37 100644 --- a/keepercommander/proto/GraphSync_pb2.pyi +++ b/keepercommander/proto/GraphSync_pb2.pyi @@ -2,12 +2,13 @@ from google.protobuf.internal import containers as _containers from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper from google.protobuf import descriptor as _descriptor from google.protobuf import message as _message -from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union +from collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union DESCRIPTOR: _descriptor.FileDescriptor class RefType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): - __slots__ = [] + __slots__ = () RFT_GENERAL: _ClassVar[RefType] RFT_USER: _ClassVar[RefType] RFT_DEVICE: _ClassVar[RefType] @@ -29,7 +30,7 @@ class RefType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): RFT_ROLE: _ClassVar[RefType] class GraphSyncDataType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): - __slots__ = [] + __slots__ = () GSE_DATA: _ClassVar[GraphSyncDataType] GSE_KEY: _ClassVar[GraphSyncDataType] GSE_LINK: _ClassVar[GraphSyncDataType] @@ -37,7 +38,7 @@ class GraphSyncDataType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): GSE_DELETION: _ClassVar[GraphSyncDataType] class GraphSyncActorType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): - __slots__ = [] + __slots__ = () GSA_USER: _ClassVar[GraphSyncActorType] GSA_SERVICE: _ClassVar[GraphSyncActorType] GSA_PAM_GATEWAY: _ClassVar[GraphSyncActorType] @@ -70,7 +71,7 @@ GSA_SERVICE: GraphSyncActorType GSA_PAM_GATEWAY: GraphSyncActorType class GraphSyncRef(_message.Message): - __slots__ = ["type", "value", "name"] + __slots__ = ("type", "value", "name") TYPE_FIELD_NUMBER: _ClassVar[int] VALUE_FIELD_NUMBER: _ClassVar[int] NAME_FIELD_NUMBER: _ClassVar[int] @@ -80,7 +81,7 @@ class GraphSyncRef(_message.Message): def __init__(self, type: _Optional[_Union[RefType, str]] = ..., value: _Optional[bytes] = ..., name: _Optional[str] = ...) -> None: ... class GraphSyncActor(_message.Message): - __slots__ = ["type", "id", "name", "effectiveUserId"] + __slots__ = ("type", "id", "name", "effectiveUserId") TYPE_FIELD_NUMBER: _ClassVar[int] ID_FIELD_NUMBER: _ClassVar[int] NAME_FIELD_NUMBER: _ClassVar[int] @@ -92,7 +93,7 @@ class GraphSyncActor(_message.Message): def __init__(self, type: _Optional[_Union[GraphSyncActorType, str]] = ..., id: _Optional[bytes] = ..., name: _Optional[str] = ..., effectiveUserId: _Optional[bytes] = ...) -> None: ... class GraphSyncData(_message.Message): - __slots__ = ["type", "ref", "parentRef", "content", "path"] + __slots__ = ("type", "ref", "parentRef", "content", "path") TYPE_FIELD_NUMBER: _ClassVar[int] REF_FIELD_NUMBER: _ClassVar[int] PARENTREF_FIELD_NUMBER: _ClassVar[int] @@ -106,7 +107,7 @@ class GraphSyncData(_message.Message): def __init__(self, type: _Optional[_Union[GraphSyncDataType, str]] = ..., ref: _Optional[_Union[GraphSyncRef, _Mapping]] = ..., parentRef: _Optional[_Union[GraphSyncRef, _Mapping]] = ..., content: _Optional[bytes] = ..., path: _Optional[str] = ...) -> None: ... class GraphSyncDataPlus(_message.Message): - __slots__ = ["data", "timestamp", "actor"] + __slots__ = ("data", "timestamp", "actor") DATA_FIELD_NUMBER: _ClassVar[int] TIMESTAMP_FIELD_NUMBER: _ClassVar[int] ACTOR_FIELD_NUMBER: _ClassVar[int] @@ -116,7 +117,7 @@ class GraphSyncDataPlus(_message.Message): def __init__(self, data: _Optional[_Union[GraphSyncData, _Mapping]] = ..., timestamp: _Optional[int] = ..., actor: _Optional[_Union[GraphSyncActor, _Mapping]] = ...) -> None: ... class GraphSyncQuery(_message.Message): - __slots__ = ["streamId", "origin", "syncPoint", "maxCount"] + __slots__ = ("streamId", "origin", "syncPoint", "maxCount") STREAMID_FIELD_NUMBER: _ClassVar[int] ORIGIN_FIELD_NUMBER: _ClassVar[int] SYNCPOINT_FIELD_NUMBER: _ClassVar[int] @@ -128,7 +129,7 @@ class GraphSyncQuery(_message.Message): def __init__(self, streamId: _Optional[bytes] = ..., origin: _Optional[bytes] = ..., syncPoint: _Optional[int] = ..., maxCount: _Optional[int] = ...) -> None: ... class GraphSyncResult(_message.Message): - __slots__ = ["streamId", "syncPoint", "data", "hasMore"] + __slots__ = ("streamId", "syncPoint", "data", "hasMore") STREAMID_FIELD_NUMBER: _ClassVar[int] SYNCPOINT_FIELD_NUMBER: _ClassVar[int] DATA_FIELD_NUMBER: _ClassVar[int] @@ -137,22 +138,22 @@ class GraphSyncResult(_message.Message): syncPoint: int data: _containers.RepeatedCompositeFieldContainer[GraphSyncDataPlus] hasMore: bool - def __init__(self, streamId: _Optional[bytes] = ..., syncPoint: _Optional[int] = ..., data: _Optional[_Iterable[_Union[GraphSyncDataPlus, _Mapping]]] = ..., hasMore: bool = ...) -> None: ... + def __init__(self, streamId: _Optional[bytes] = ..., syncPoint: _Optional[int] = ..., data: _Optional[_Iterable[_Union[GraphSyncDataPlus, _Mapping]]] = ..., hasMore: _Optional[bool] = ...) -> None: ... class GraphSyncMultiQuery(_message.Message): - __slots__ = ["queries"] + __slots__ = ("queries",) QUERIES_FIELD_NUMBER: _ClassVar[int] queries: _containers.RepeatedCompositeFieldContainer[GraphSyncQuery] def __init__(self, queries: _Optional[_Iterable[_Union[GraphSyncQuery, _Mapping]]] = ...) -> None: ... class GraphSyncMultiResult(_message.Message): - __slots__ = ["results"] + __slots__ = ("results",) RESULTS_FIELD_NUMBER: _ClassVar[int] results: _containers.RepeatedCompositeFieldContainer[GraphSyncResult] def __init__(self, results: _Optional[_Iterable[_Union[GraphSyncResult, _Mapping]]] = ...) -> None: ... class GraphSyncAddDataRequest(_message.Message): - __slots__ = ["origin", "data"] + __slots__ = ("origin", "data") ORIGIN_FIELD_NUMBER: _ClassVar[int] DATA_FIELD_NUMBER: _ClassVar[int] origin: GraphSyncRef @@ -160,13 +161,13 @@ class GraphSyncAddDataRequest(_message.Message): def __init__(self, origin: _Optional[_Union[GraphSyncRef, _Mapping]] = ..., data: _Optional[_Iterable[_Union[GraphSyncData, _Mapping]]] = ...) -> None: ... class GraphSyncLeafsQuery(_message.Message): - __slots__ = ["vertices"] + __slots__ = ("vertices",) VERTICES_FIELD_NUMBER: _ClassVar[int] vertices: _containers.RepeatedScalarFieldContainer[bytes] def __init__(self, vertices: _Optional[_Iterable[bytes]] = ...) -> None: ... class GraphSyncRefsResult(_message.Message): - __slots__ = ["refs"] + __slots__ = ("refs",) REFS_FIELD_NUMBER: _ClassVar[int] refs: _containers.RepeatedCompositeFieldContainer[GraphSyncRef] def __init__(self, refs: _Optional[_Iterable[_Union[GraphSyncRef, _Mapping]]] = ...) -> None: ... diff --git a/keepercommander/proto/workflow_pb2.py b/keepercommander/proto/workflow_pb2.py new file mode 100644 index 000000000..5530e85fc --- /dev/null +++ b/keepercommander/proto/workflow_pb2.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: workflow.proto +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder + +_sym_db = _symbol_database.Default() + + +from . import GraphSync_pb2 as GraphSync__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0eworkflow.proto\x12\x08Workflow\x1a\x0fGraphSync.proto\"g\n\x10WorkflowApprover\x12\x0e\n\x04user\x18\x01 \x01(\tH\x00\x12\x10\n\x06userId\x18\x02 \x01(\x05H\x00\x12\x11\n\x07teamUid\x18\x03 \x01(\x0cH\x00\x12\x12\n\nescalation\x18\x04 \x01(\x08\x42\n\n\x08\x61pprover\"\x9d\x02\n\x12WorkflowParameters\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x17\n\x0f\x61pprovalsNeeded\x18\x02 \x01(\x05\x12\x16\n\x0e\x63heckoutNeeded\x18\x03 \x01(\x08\x12\x1d\n\x15startAccessOnApproval\x18\x04 \x01(\x08\x12\x15\n\rrequireReason\x18\x05 \x01(\x08\x12\x15\n\rrequireTicket\x18\x06 \x01(\x08\x12\x12\n\nrequireMFA\x18\x07 \x01(\x08\x12\x14\n\x0c\x61\x63\x63\x65ssLength\x18\x08 \x01(\x03\x12\x34\n\x0c\x61llowedTimes\x18\t \x01(\x0b\x32\x1e.Workflow.TemporalAccessFilter\"\x84\x01\n\x0eWorkflowConfig\x12\x30\n\nparameters\x18\x01 \x01(\x0b\x32\x1c.Workflow.WorkflowParameters\x12-\n\tapprovers\x18\x02 \x03(\x0b\x32\x1a.Workflow.WorkflowApprover\x12\x11\n\tcreatedOn\x18\x03 \x01(\x03\"\xd0\x01\n\x0eWorkflowStatus\x12&\n\x05stage\x18\x01 \x01(\x0e\x32\x17.Workflow.WorkflowStage\x12-\n\nconditions\x18\x02 \x03(\x0e\x32\x19.Workflow.AccessCondition\x12.\n\napprovedBy\x18\x03 \x03(\x0b\x32\x1a.Workflow.WorkflowApproval\x12\x11\n\tstartedOn\x18\x04 \x01(\x03\x12\x11\n\texpiresOn\x18\x05 \x01(\x03\x12\x11\n\tescalated\x18\x06 \x01(\x08\"\xbd\x01\n\x0fWorkflowProcess\x12\x0f\n\x07\x66lowUid\x18\x01 \x01(\x0c\x12\x0e\n\x06userId\x18\x02 \x01(\x03\x12)\n\x08resource\x18\x03 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x11\n\tstartedOn\x18\x04 \x01(\x03\x12\x11\n\texpiresOn\x18\x05 \x01(\x03\x12\x0e\n\x06reason\x18\x06 \x01(\x0c\x12\x13\n\x0bmfaVerified\x18\x07 \x01(\x08\x12\x13\n\x0b\x65xternalRef\x18\x08 \x01(\x0c\"U\n\x10WorkflowApproval\x12\x0e\n\x06userId\x18\x01 \x01(\x03\x12\x0c\n\x04user\x18\x02 \x01(\t\x12\x0f\n\x07\x66lowUid\x18\x03 \x01(\x0c\x12\x12\n\napprovedOn\x18\x04 \x01(\x03\"\xcb\x01\n\x0fWorkflowContext\x12\x30\n\x0eworkflowConfig\x18\x01 \x01(\x0b\x32\x18.Workflow.WorkflowConfig\x12+\n\x08workflow\x18\x02 \x01(\x0b\x32\x19.Workflow.WorkflowProcess\x12-\n\tapprovals\x18\x03 \x03(\x0b\x32\x1a.Workflow.WorkflowApproval\x12*\n\x07\x62locker\x18\x04 \x01(\x0b\x32\x19.Workflow.WorkflowProcess\"u\n\rWorkflowState\x12\x0f\n\x07\x66lowUid\x18\x01 \x01(\x0c\x12)\n\x08resource\x18\x02 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12(\n\x06status\x18\x03 \x01(\x0b\x32\x18.Workflow.WorkflowStatus\"b\n\x15WorkflowAccessRequest\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0e\n\x06ticket\x18\x03 \x01(\t\"=\n\x0fUserAccessState\x12*\n\tworkflows\x18\x01 \x03(\x0b\x32\x17.Workflow.WorkflowState\"@\n\x10\x41pprovalRequests\x12,\n\tworkflows\x18\x01 \x03(\x0b\x32\x19.Workflow.WorkflowProcess\"4\n\x0eTimeOfDayRange\x12\x11\n\tstartTime\x18\x01 \x01(\x05\x12\x0f\n\x07\x65ndTime\x18\x02 \x01(\x05\"\x80\x01\n\x14TemporalAccessFilter\x12,\n\ntimeRanges\x18\x01 \x03(\x0b\x32\x18.Workflow.TimeOfDayRange\x12(\n\x0b\x61llowedDays\x18\x02 \x03(\x0e\x32\x13.Workflow.DayOfWeek\x12\x10\n\x08timeZone\x18\x03 \x01(\t*[\n\rWorkflowStage\x12\x15\n\x11WS_READY_TO_START\x10\x00\x12\x0e\n\nWS_STARTED\x10\x01\x12\x13\n\x0fWS_NEEDS_ACTION\x10\x02\x12\x0e\n\nWS_WAITING\x10\x03*i\n\x0f\x41\x63\x63\x65ssCondition\x12\x0f\n\x0b\x41\x43_APPROVAL\x10\x00\x12\x0e\n\nAC_CHECKIN\x10\x01\x12\n\n\x06\x41\x43_MFA\x10\x02\x12\x0b\n\x07\x41\x43_TIME\x10\x03\x12\r\n\tAC_REASON\x10\x04\x12\r\n\tAC_TICKET\x10\x05*\x84\x01\n\tDayOfWeek\x12\x1b\n\x17\x44\x41Y_OF_WEEK_UNSPECIFIED\x10\x00\x12\n\n\x06MONDAY\x10\x01\x12\x0b\n\x07TUESDAY\x10\x02\x12\r\n\tWEDNESDAY\x10\x03\x12\x0c\n\x08THURSDAY\x10\x04\x12\n\n\x06\x46RIDAY\x10\x05\x12\x0c\n\x08SATURDAY\x10\x06\x12\n\n\x06SUNDAY\x10\x07\x42$\n\x18\x63om.keepersecurity.protoB\x08Workflowb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'workflow_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + _globals['DESCRIPTOR']._loaded_options = None + _globals['DESCRIPTOR']._serialized_options = b'\n\030com.keepersecurity.protoB\010Workflow' + _globals['_WORKFLOWSTAGE']._serialized_start=1802 + _globals['_WORKFLOWSTAGE']._serialized_end=1893 + _globals['_ACCESSCONDITION']._serialized_start=1895 + _globals['_ACCESSCONDITION']._serialized_end=2000 + _globals['_DAYOFWEEK']._serialized_start=2003 + _globals['_DAYOFWEEK']._serialized_end=2135 + _globals['_WORKFLOWAPPROVER']._serialized_start=45 + _globals['_WORKFLOWAPPROVER']._serialized_end=148 + _globals['_WORKFLOWPARAMETERS']._serialized_start=151 + _globals['_WORKFLOWPARAMETERS']._serialized_end=436 + _globals['_WORKFLOWCONFIG']._serialized_start=439 + _globals['_WORKFLOWCONFIG']._serialized_end=571 + _globals['_WORKFLOWSTATUS']._serialized_start=574 + _globals['_WORKFLOWSTATUS']._serialized_end=782 + _globals['_WORKFLOWPROCESS']._serialized_start=785 + _globals['_WORKFLOWPROCESS']._serialized_end=974 + _globals['_WORKFLOWAPPROVAL']._serialized_start=976 + _globals['_WORKFLOWAPPROVAL']._serialized_end=1061 + _globals['_WORKFLOWCONTEXT']._serialized_start=1064 + _globals['_WORKFLOWCONTEXT']._serialized_end=1267 + _globals['_WORKFLOWSTATE']._serialized_start=1269 + _globals['_WORKFLOWSTATE']._serialized_end=1386 + _globals['_WORKFLOWACCESSREQUEST']._serialized_start=1388 + _globals['_WORKFLOWACCESSREQUEST']._serialized_end=1486 + _globals['_USERACCESSSTATE']._serialized_start=1488 + _globals['_USERACCESSSTATE']._serialized_end=1549 + _globals['_APPROVALREQUESTS']._serialized_start=1551 + _globals['_APPROVALREQUESTS']._serialized_end=1615 + _globals['_TIMEOFDAYRANGE']._serialized_start=1617 + _globals['_TIMEOFDAYRANGE']._serialized_end=1669 + _globals['_TEMPORALACCESSFILTER']._serialized_start=1672 + _globals['_TEMPORALACCESSFILTER']._serialized_end=1800 +# @@protoc_insertion_point(module_scope) diff --git a/keepercommander/proto/workflow_pb2.pyi b/keepercommander/proto/workflow_pb2.pyi new file mode 100644 index 000000000..1606b8aba --- /dev/null +++ b/keepercommander/proto/workflow_pb2.pyi @@ -0,0 +1,208 @@ +import GraphSync_pb2 as _GraphSync_pb2 +from google.protobuf.internal import containers as _containers +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class WorkflowStage(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + WS_READY_TO_START: _ClassVar[WorkflowStage] + WS_STARTED: _ClassVar[WorkflowStage] + WS_NEEDS_ACTION: _ClassVar[WorkflowStage] + WS_WAITING: _ClassVar[WorkflowStage] + +class AccessCondition(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + AC_APPROVAL: _ClassVar[AccessCondition] + AC_CHECKIN: _ClassVar[AccessCondition] + AC_MFA: _ClassVar[AccessCondition] + AC_TIME: _ClassVar[AccessCondition] + AC_REASON: _ClassVar[AccessCondition] + AC_TICKET: _ClassVar[AccessCondition] + +class DayOfWeek(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + DAY_OF_WEEK_UNSPECIFIED: _ClassVar[DayOfWeek] + MONDAY: _ClassVar[DayOfWeek] + TUESDAY: _ClassVar[DayOfWeek] + WEDNESDAY: _ClassVar[DayOfWeek] + THURSDAY: _ClassVar[DayOfWeek] + FRIDAY: _ClassVar[DayOfWeek] + SATURDAY: _ClassVar[DayOfWeek] + SUNDAY: _ClassVar[DayOfWeek] +WS_READY_TO_START: WorkflowStage +WS_STARTED: WorkflowStage +WS_NEEDS_ACTION: WorkflowStage +WS_WAITING: WorkflowStage +AC_APPROVAL: AccessCondition +AC_CHECKIN: AccessCondition +AC_MFA: AccessCondition +AC_TIME: AccessCondition +AC_REASON: AccessCondition +AC_TICKET: AccessCondition +DAY_OF_WEEK_UNSPECIFIED: DayOfWeek +MONDAY: DayOfWeek +TUESDAY: DayOfWeek +WEDNESDAY: DayOfWeek +THURSDAY: DayOfWeek +FRIDAY: DayOfWeek +SATURDAY: DayOfWeek +SUNDAY: DayOfWeek + +class WorkflowApprover(_message.Message): + __slots__ = ("user", "userId", "teamUid", "escalation") + USER_FIELD_NUMBER: _ClassVar[int] + USERID_FIELD_NUMBER: _ClassVar[int] + TEAMUID_FIELD_NUMBER: _ClassVar[int] + ESCALATION_FIELD_NUMBER: _ClassVar[int] + user: str + userId: int + teamUid: bytes + escalation: bool + def __init__(self, user: _Optional[str] = ..., userId: _Optional[int] = ..., teamUid: _Optional[bytes] = ..., escalation: _Optional[bool] = ...) -> None: ... + +class WorkflowParameters(_message.Message): + __slots__ = ("resource", "approvalsNeeded", "checkoutNeeded", "startAccessOnApproval", "requireReason", "requireTicket", "requireMFA", "accessLength", "allowedTimes") + RESOURCE_FIELD_NUMBER: _ClassVar[int] + APPROVALSNEEDED_FIELD_NUMBER: _ClassVar[int] + CHECKOUTNEEDED_FIELD_NUMBER: _ClassVar[int] + STARTACCESSONAPPROVAL_FIELD_NUMBER: _ClassVar[int] + REQUIREREASON_FIELD_NUMBER: _ClassVar[int] + REQUIRETICKET_FIELD_NUMBER: _ClassVar[int] + REQUIREMFA_FIELD_NUMBER: _ClassVar[int] + ACCESSLENGTH_FIELD_NUMBER: _ClassVar[int] + ALLOWEDTIMES_FIELD_NUMBER: _ClassVar[int] + resource: _GraphSync_pb2.GraphSyncRef + approvalsNeeded: int + checkoutNeeded: bool + startAccessOnApproval: bool + requireReason: bool + requireTicket: bool + requireMFA: bool + accessLength: int + allowedTimes: TemporalAccessFilter + def __init__(self, resource: _Optional[_Union[_GraphSync_pb2.GraphSyncRef, _Mapping]] = ..., approvalsNeeded: _Optional[int] = ..., checkoutNeeded: _Optional[bool] = ..., startAccessOnApproval: _Optional[bool] = ..., requireReason: _Optional[bool] = ..., requireTicket: _Optional[bool] = ..., requireMFA: _Optional[bool] = ..., accessLength: _Optional[int] = ..., allowedTimes: _Optional[_Union[TemporalAccessFilter, _Mapping]] = ...) -> None: ... + +class WorkflowConfig(_message.Message): + __slots__ = ("parameters", "approvers", "createdOn") + PARAMETERS_FIELD_NUMBER: _ClassVar[int] + APPROVERS_FIELD_NUMBER: _ClassVar[int] + CREATEDON_FIELD_NUMBER: _ClassVar[int] + parameters: WorkflowParameters + approvers: _containers.RepeatedCompositeFieldContainer[WorkflowApprover] + createdOn: int + def __init__(self, parameters: _Optional[_Union[WorkflowParameters, _Mapping]] = ..., approvers: _Optional[_Iterable[_Union[WorkflowApprover, _Mapping]]] = ..., createdOn: _Optional[int] = ...) -> None: ... + +class WorkflowStatus(_message.Message): + __slots__ = ("stage", "conditions", "approvedBy", "startedOn", "expiresOn", "escalated") + STAGE_FIELD_NUMBER: _ClassVar[int] + CONDITIONS_FIELD_NUMBER: _ClassVar[int] + APPROVEDBY_FIELD_NUMBER: _ClassVar[int] + STARTEDON_FIELD_NUMBER: _ClassVar[int] + EXPIRESON_FIELD_NUMBER: _ClassVar[int] + ESCALATED_FIELD_NUMBER: _ClassVar[int] + stage: WorkflowStage + conditions: _containers.RepeatedScalarFieldContainer[AccessCondition] + approvedBy: _containers.RepeatedCompositeFieldContainer[WorkflowApproval] + startedOn: int + expiresOn: int + escalated: bool + def __init__(self, stage: _Optional[_Union[WorkflowStage, str]] = ..., conditions: _Optional[_Iterable[_Union[AccessCondition, str]]] = ..., approvedBy: _Optional[_Iterable[_Union[WorkflowApproval, _Mapping]]] = ..., startedOn: _Optional[int] = ..., expiresOn: _Optional[int] = ..., escalated: _Optional[bool] = ...) -> None: ... + +class WorkflowProcess(_message.Message): + __slots__ = ("flowUid", "userId", "resource", "startedOn", "expiresOn", "reason", "mfaVerified", "externalRef") + FLOWUID_FIELD_NUMBER: _ClassVar[int] + USERID_FIELD_NUMBER: _ClassVar[int] + RESOURCE_FIELD_NUMBER: _ClassVar[int] + STARTEDON_FIELD_NUMBER: _ClassVar[int] + EXPIRESON_FIELD_NUMBER: _ClassVar[int] + REASON_FIELD_NUMBER: _ClassVar[int] + MFAVERIFIED_FIELD_NUMBER: _ClassVar[int] + EXTERNALREF_FIELD_NUMBER: _ClassVar[int] + flowUid: bytes + userId: int + resource: _GraphSync_pb2.GraphSyncRef + startedOn: int + expiresOn: int + reason: bytes + mfaVerified: bool + externalRef: bytes + def __init__(self, flowUid: _Optional[bytes] = ..., userId: _Optional[int] = ..., resource: _Optional[_Union[_GraphSync_pb2.GraphSyncRef, _Mapping]] = ..., startedOn: _Optional[int] = ..., expiresOn: _Optional[int] = ..., reason: _Optional[bytes] = ..., mfaVerified: _Optional[bool] = ..., externalRef: _Optional[bytes] = ...) -> None: ... + +class WorkflowApproval(_message.Message): + __slots__ = ("userId", "user", "flowUid", "approvedOn") + USERID_FIELD_NUMBER: _ClassVar[int] + USER_FIELD_NUMBER: _ClassVar[int] + FLOWUID_FIELD_NUMBER: _ClassVar[int] + APPROVEDON_FIELD_NUMBER: _ClassVar[int] + userId: int + user: str + flowUid: bytes + approvedOn: int + def __init__(self, userId: _Optional[int] = ..., user: _Optional[str] = ..., flowUid: _Optional[bytes] = ..., approvedOn: _Optional[int] = ...) -> None: ... + +class WorkflowContext(_message.Message): + __slots__ = ("workflowConfig", "workflow", "approvals", "blocker") + WORKFLOWCONFIG_FIELD_NUMBER: _ClassVar[int] + WORKFLOW_FIELD_NUMBER: _ClassVar[int] + APPROVALS_FIELD_NUMBER: _ClassVar[int] + BLOCKER_FIELD_NUMBER: _ClassVar[int] + workflowConfig: WorkflowConfig + workflow: WorkflowProcess + approvals: _containers.RepeatedCompositeFieldContainer[WorkflowApproval] + blocker: WorkflowProcess + def __init__(self, workflowConfig: _Optional[_Union[WorkflowConfig, _Mapping]] = ..., workflow: _Optional[_Union[WorkflowProcess, _Mapping]] = ..., approvals: _Optional[_Iterable[_Union[WorkflowApproval, _Mapping]]] = ..., blocker: _Optional[_Union[WorkflowProcess, _Mapping]] = ...) -> None: ... + +class WorkflowState(_message.Message): + __slots__ = ("flowUid", "resource", "status") + FLOWUID_FIELD_NUMBER: _ClassVar[int] + RESOURCE_FIELD_NUMBER: _ClassVar[int] + STATUS_FIELD_NUMBER: _ClassVar[int] + flowUid: bytes + resource: _GraphSync_pb2.GraphSyncRef + status: WorkflowStatus + def __init__(self, flowUid: _Optional[bytes] = ..., resource: _Optional[_Union[_GraphSync_pb2.GraphSyncRef, _Mapping]] = ..., status: _Optional[_Union[WorkflowStatus, _Mapping]] = ...) -> None: ... + +class WorkflowAccessRequest(_message.Message): + __slots__ = ("resource", "reason", "ticket") + RESOURCE_FIELD_NUMBER: _ClassVar[int] + REASON_FIELD_NUMBER: _ClassVar[int] + TICKET_FIELD_NUMBER: _ClassVar[int] + resource: _GraphSync_pb2.GraphSyncRef + reason: str + ticket: str + def __init__(self, resource: _Optional[_Union[_GraphSync_pb2.GraphSyncRef, _Mapping]] = ..., reason: _Optional[str] = ..., ticket: _Optional[str] = ...) -> None: ... + +class UserAccessState(_message.Message): + __slots__ = ("workflows",) + WORKFLOWS_FIELD_NUMBER: _ClassVar[int] + workflows: _containers.RepeatedCompositeFieldContainer[WorkflowState] + def __init__(self, workflows: _Optional[_Iterable[_Union[WorkflowState, _Mapping]]] = ...) -> None: ... + +class ApprovalRequests(_message.Message): + __slots__ = ("workflows",) + WORKFLOWS_FIELD_NUMBER: _ClassVar[int] + workflows: _containers.RepeatedCompositeFieldContainer[WorkflowProcess] + def __init__(self, workflows: _Optional[_Iterable[_Union[WorkflowProcess, _Mapping]]] = ...) -> None: ... + +class TimeOfDayRange(_message.Message): + __slots__ = ("startTime", "endTime") + STARTTIME_FIELD_NUMBER: _ClassVar[int] + ENDTIME_FIELD_NUMBER: _ClassVar[int] + startTime: int + endTime: int + def __init__(self, startTime: _Optional[int] = ..., endTime: _Optional[int] = ...) -> None: ... + +class TemporalAccessFilter(_message.Message): + __slots__ = ("timeRanges", "allowedDays", "timeZone") + TIMERANGES_FIELD_NUMBER: _ClassVar[int] + ALLOWEDDAYS_FIELD_NUMBER: _ClassVar[int] + TIMEZONE_FIELD_NUMBER: _ClassVar[int] + timeRanges: _containers.RepeatedCompositeFieldContainer[TimeOfDayRange] + allowedDays: _containers.RepeatedScalarFieldContainer[DayOfWeek] + timeZone: str + def __init__(self, timeRanges: _Optional[_Iterable[_Union[TimeOfDayRange, _Mapping]]] = ..., allowedDays: _Optional[_Iterable[_Union[DayOfWeek, str]]] = ..., timeZone: _Optional[str] = ...) -> None: ... From 83c95a82156823f1ce4a168bf1ceab39d488ad49 Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Wed, 11 Feb 2026 11:47:17 +0530 Subject: [PATCH 02/11] Update protobuf for approve and deny endpoints --- .../commands/workflow/workflow_commands.py | 68 +++++++++++++------ keepercommander/proto/workflow_pb2.py | 34 ++++++---- keepercommander/proto/workflow_pb2.pyi | 22 +++++- 3 files changed, 86 insertions(+), 38 deletions(-) diff --git a/keepercommander/commands/workflow/workflow_commands.py b/keepercommander/commands/workflow/workflow_commands.py index 23334c696..7e0c13443 100644 --- a/keepercommander/commands/workflow/workflow_commands.py +++ b/keepercommander/commands/workflow/workflow_commands.py @@ -97,6 +97,29 @@ def create_workflow_ref(flow_uid_bytes: bytes) -> GraphSync_pb2.GraphSyncRef: return ref +def resolve_record_name(params, resource_ref) -> str: + """ + Resolve the display name for a record from a GraphSyncRef. + + The backend doesn't always populate the 'name' field in the GraphSyncRef + response, so we fall back to looking up the record in the local vault cache. + + Args: + params: KeeperParams instance + resource_ref: GraphSyncRef protobuf object with value (record UID bytes) + + Returns: + str: Record title, UID, or empty string + """ + if resource_ref.name: + return resource_ref.name + if resource_ref.value: + rec_uid = utils.base64_url_encode(resource_ref.value) + rec = vault.KeeperRecord.load(params, rec_uid) + return rec.title if rec else rec_uid + return '' + + def parse_duration_to_milliseconds(duration_str: str) -> int: """ Parse duration string to milliseconds. @@ -510,7 +533,7 @@ def execute(self, params: KeeperParams, **kwargs): # JSON output result = { 'record_uid': record_uid, - 'record_name': response.parameters.resource.name, + 'record_name': resolve_record_name(params, response.parameters.resource), 'created_on': response.createdOn, 'parameters': { 'approvals_needed': response.parameters.approvalsNeeded, @@ -543,7 +566,7 @@ def execute(self, params: KeeperParams, **kwargs): else: # Table output print(f"\n{bcolors.OKBLUE}Workflow Configuration{bcolors.ENDC}\n") - print(f"Record: {response.parameters.resource.name}") + print(f"Record: {resolve_record_name(params, response.parameters.resource)}") print(f"Record UID: {record_uid}") # Display creation date if available @@ -898,7 +921,7 @@ def execute(self, params: KeeperParams, **kwargs): result = { 'flow_uid': utils.base64_url_encode(response.flowUid), 'record_uid': utils.base64_url_encode(response.resource.value), - 'record_name': response.resource.name, + 'record_name': resolve_record_name(params, response.resource), 'stage': format_workflow_stage(response.status.stage), 'conditions': [format_access_conditions([c]) for c in response.status.conditions], 'escalated': response.status.escalated, @@ -913,7 +936,7 @@ def execute(self, params: KeeperParams, **kwargs): else: print(f"\n{bcolors.OKBLUE}Workflow State{bcolors.ENDC}\n") print(f"Flow UID: {utils.base64_url_encode(response.flowUid)}") - print(f"Record: {response.resource.name}") + print(f"Record: {resolve_record_name(params, response.resource)}") print(f"Stage: {format_workflow_stage(response.status.stage)}") if response.status.conditions: print(f"Conditions: {format_access_conditions(response.status.conditions)}") @@ -979,7 +1002,7 @@ def execute(self, params: KeeperParams, **kwargs): { 'flow_uid': utils.base64_url_encode(wf.flowUid), 'record_uid': utils.base64_url_encode(wf.resource.value), - 'record_name': wf.resource.name, + 'record_name': resolve_record_name(params, wf.resource), 'stage': format_workflow_stage(wf.status.stage), 'conditions': [format_access_conditions([c]) for c in wf.status.conditions], 'escalated': wf.status.escalated, @@ -997,7 +1020,7 @@ def execute(self, params: KeeperParams, **kwargs): else: print(f"\n{bcolors.OKBLUE}Your Active Workflows{bcolors.ENDC}\n") for idx, wf in enumerate(response.workflows, 1): - print(f"{idx}. {wf.resource.name}") + print(f"{idx}. {resolve_record_name(params, wf.resource)}") print(f" Flow UID: {utils.base64_url_encode(wf.flowUid)}") print(f" Stage: {format_workflow_stage(wf.status.stage)}") if wf.status.conditions: @@ -1059,7 +1082,7 @@ def execute(self, params: KeeperParams, **kwargs): 'flow_uid': utils.base64_url_encode(wf.flowUid), 'user_id': wf.userId, 'record_uid': utils.base64_url_encode(wf.resource.value), - 'record_name': wf.resource.name, + 'record_name': resolve_record_name(params, wf.resource), 'started_on': wf.startedOn, 'expires_on': wf.expiresOn, 'reason': wf.reason.decode('utf-8') if wf.reason else '', @@ -1073,7 +1096,7 @@ def execute(self, params: KeeperParams, **kwargs): else: print(f"\n{bcolors.OKBLUE}Pending Approval Requests{bcolors.ENDC}\n") for idx, wf in enumerate(response.workflows, 1): - print(f"{idx}. {wf.resource.name}") + print(f"{idx}. {resolve_record_name(params, wf.resource)}") print(f" Flow UID: {utils.base64_url_encode(wf.flowUid)}") print(f" Requested by: User ID {wf.userId}") if wf.startedOn: @@ -1199,14 +1222,13 @@ def execute(self, params: KeeperParams, **kwargs): record = vault.KeeperRecord.load(params, record_uid) record_uid_bytes = utils.base64_url_decode(record_uid) - # Use WorkflowAccessRequest which properly supports reason and ticket - # (Updated proto: WorkflowAccessRequest { resource, reason, ticket }) + # Use WorkflowAccessRequest which supports reason and ticket access_request = workflow_pb2.WorkflowAccessRequest() access_request.resource.CopyFrom(create_record_ref(record_uid_bytes, record.title)) if reason: - access_request.reason = reason + access_request.reason = reason.encode('utf-8') if isinstance(reason, str) else reason if ticket: - access_request.ticket = ticket + access_request.ticket = ticket.encode('utf-8') if isinstance(ticket, str) else ticket # Make API call try: @@ -1262,15 +1284,17 @@ def execute(self, params: KeeperParams, **kwargs): flow_uid = kwargs.get('flow_uid') flow_uid_bytes = utils.base64_url_decode(flow_uid) - # Create workflow reference - ref = create_workflow_ref(flow_uid_bytes) + # Create WorkflowApprovalOrDenial with deny=False for approval + approval = workflow_pb2.WorkflowApprovalOrDenial() + approval.resource.CopyFrom(create_workflow_ref(flow_uid_bytes)) + approval.deny = False # Make API call try: response = _post_request_to_router( params, 'approve_workflow_access', - rq_proto=ref + rq_proto=approval ) if kwargs.get('format') == 'json': @@ -1305,18 +1329,22 @@ def get_parser(self): def execute(self, params: KeeperParams, **kwargs): """Execute workflow denial.""" flow_uid = kwargs.get('flow_uid') - reason = kwargs.get('reason') + reason = kwargs.get('reason') or '' flow_uid_bytes = utils.base64_url_decode(flow_uid) - # Create workflow reference - ref = create_workflow_ref(flow_uid_bytes) + # Create WorkflowApprovalOrDenial with deny=True for denial + denial = workflow_pb2.WorkflowApprovalOrDenial() + denial.resource.CopyFrom(create_workflow_ref(flow_uid_bytes)) + denial.deny = True + if reason: + denial.denialReason = reason # Make API call try: response = _post_request_to_router( params, 'deny_workflow_access', - rq_proto=ref + rq_proto=denial ) if kwargs.get('format') == 'json': @@ -1422,7 +1450,7 @@ def execute(self, params: KeeperParams, **kwargs): print(json.dumps(result, indent=2)) else: print(f"\n{bcolors.OKGREEN}✓ Workflow ended (checked in){bcolors.ENDC}\n") - print(f"Record: {workflow_state.resource.name}") + print(f"Record: {resolve_record_name(params, workflow_state.resource)}") print(f"Flow UID: {flow_uid_str}") print("\nCredentials may have been rotated.") print() diff --git a/keepercommander/proto/workflow_pb2.py b/keepercommander/proto/workflow_pb2.py index 5530e85fc..dc6dfd073 100644 --- a/keepercommander/proto/workflow_pb2.py +++ b/keepercommander/proto/workflow_pb2.py @@ -13,7 +13,7 @@ from . import GraphSync_pb2 as GraphSync__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0eworkflow.proto\x12\x08Workflow\x1a\x0fGraphSync.proto\"g\n\x10WorkflowApprover\x12\x0e\n\x04user\x18\x01 \x01(\tH\x00\x12\x10\n\x06userId\x18\x02 \x01(\x05H\x00\x12\x11\n\x07teamUid\x18\x03 \x01(\x0cH\x00\x12\x12\n\nescalation\x18\x04 \x01(\x08\x42\n\n\x08\x61pprover\"\x9d\x02\n\x12WorkflowParameters\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x17\n\x0f\x61pprovalsNeeded\x18\x02 \x01(\x05\x12\x16\n\x0e\x63heckoutNeeded\x18\x03 \x01(\x08\x12\x1d\n\x15startAccessOnApproval\x18\x04 \x01(\x08\x12\x15\n\rrequireReason\x18\x05 \x01(\x08\x12\x15\n\rrequireTicket\x18\x06 \x01(\x08\x12\x12\n\nrequireMFA\x18\x07 \x01(\x08\x12\x14\n\x0c\x61\x63\x63\x65ssLength\x18\x08 \x01(\x03\x12\x34\n\x0c\x61llowedTimes\x18\t \x01(\x0b\x32\x1e.Workflow.TemporalAccessFilter\"\x84\x01\n\x0eWorkflowConfig\x12\x30\n\nparameters\x18\x01 \x01(\x0b\x32\x1c.Workflow.WorkflowParameters\x12-\n\tapprovers\x18\x02 \x03(\x0b\x32\x1a.Workflow.WorkflowApprover\x12\x11\n\tcreatedOn\x18\x03 \x01(\x03\"\xd0\x01\n\x0eWorkflowStatus\x12&\n\x05stage\x18\x01 \x01(\x0e\x32\x17.Workflow.WorkflowStage\x12-\n\nconditions\x18\x02 \x03(\x0e\x32\x19.Workflow.AccessCondition\x12.\n\napprovedBy\x18\x03 \x03(\x0b\x32\x1a.Workflow.WorkflowApproval\x12\x11\n\tstartedOn\x18\x04 \x01(\x03\x12\x11\n\texpiresOn\x18\x05 \x01(\x03\x12\x11\n\tescalated\x18\x06 \x01(\x08\"\xbd\x01\n\x0fWorkflowProcess\x12\x0f\n\x07\x66lowUid\x18\x01 \x01(\x0c\x12\x0e\n\x06userId\x18\x02 \x01(\x03\x12)\n\x08resource\x18\x03 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x11\n\tstartedOn\x18\x04 \x01(\x03\x12\x11\n\texpiresOn\x18\x05 \x01(\x03\x12\x0e\n\x06reason\x18\x06 \x01(\x0c\x12\x13\n\x0bmfaVerified\x18\x07 \x01(\x08\x12\x13\n\x0b\x65xternalRef\x18\x08 \x01(\x0c\"U\n\x10WorkflowApproval\x12\x0e\n\x06userId\x18\x01 \x01(\x03\x12\x0c\n\x04user\x18\x02 \x01(\t\x12\x0f\n\x07\x66lowUid\x18\x03 \x01(\x0c\x12\x12\n\napprovedOn\x18\x04 \x01(\x03\"\xcb\x01\n\x0fWorkflowContext\x12\x30\n\x0eworkflowConfig\x18\x01 \x01(\x0b\x32\x18.Workflow.WorkflowConfig\x12+\n\x08workflow\x18\x02 \x01(\x0b\x32\x19.Workflow.WorkflowProcess\x12-\n\tapprovals\x18\x03 \x03(\x0b\x32\x1a.Workflow.WorkflowApproval\x12*\n\x07\x62locker\x18\x04 \x01(\x0b\x32\x19.Workflow.WorkflowProcess\"u\n\rWorkflowState\x12\x0f\n\x07\x66lowUid\x18\x01 \x01(\x0c\x12)\n\x08resource\x18\x02 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12(\n\x06status\x18\x03 \x01(\x0b\x32\x18.Workflow.WorkflowStatus\"b\n\x15WorkflowAccessRequest\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0e\n\x06ticket\x18\x03 \x01(\t\"=\n\x0fUserAccessState\x12*\n\tworkflows\x18\x01 \x03(\x0b\x32\x17.Workflow.WorkflowState\"@\n\x10\x41pprovalRequests\x12,\n\tworkflows\x18\x01 \x03(\x0b\x32\x19.Workflow.WorkflowProcess\"4\n\x0eTimeOfDayRange\x12\x11\n\tstartTime\x18\x01 \x01(\x05\x12\x0f\n\x07\x65ndTime\x18\x02 \x01(\x05\"\x80\x01\n\x14TemporalAccessFilter\x12,\n\ntimeRanges\x18\x01 \x03(\x0b\x32\x18.Workflow.TimeOfDayRange\x12(\n\x0b\x61llowedDays\x18\x02 \x03(\x0e\x32\x13.Workflow.DayOfWeek\x12\x10\n\x08timeZone\x18\x03 \x01(\t*[\n\rWorkflowStage\x12\x15\n\x11WS_READY_TO_START\x10\x00\x12\x0e\n\nWS_STARTED\x10\x01\x12\x13\n\x0fWS_NEEDS_ACTION\x10\x02\x12\x0e\n\nWS_WAITING\x10\x03*i\n\x0f\x41\x63\x63\x65ssCondition\x12\x0f\n\x0b\x41\x43_APPROVAL\x10\x00\x12\x0e\n\nAC_CHECKIN\x10\x01\x12\n\n\x06\x41\x43_MFA\x10\x02\x12\x0b\n\x07\x41\x43_TIME\x10\x03\x12\r\n\tAC_REASON\x10\x04\x12\r\n\tAC_TICKET\x10\x05*\x84\x01\n\tDayOfWeek\x12\x1b\n\x17\x44\x41Y_OF_WEEK_UNSPECIFIED\x10\x00\x12\n\n\x06MONDAY\x10\x01\x12\x0b\n\x07TUESDAY\x10\x02\x12\r\n\tWEDNESDAY\x10\x03\x12\x0c\n\x08THURSDAY\x10\x04\x12\n\n\x06\x46RIDAY\x10\x05\x12\x0c\n\x08SATURDAY\x10\x06\x12\n\n\x06SUNDAY\x10\x07\x42$\n\x18\x63om.keepersecurity.protoB\x08Workflowb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0eworkflow.proto\x12\x08Workflow\x1a\x0fGraphSync.proto\"g\n\x10WorkflowApprover\x12\x0e\n\x04user\x18\x01 \x01(\tH\x00\x12\x10\n\x06userId\x18\x02 \x01(\x05H\x00\x12\x11\n\x07teamUid\x18\x03 \x01(\x0cH\x00\x12\x12\n\nescalation\x18\x04 \x01(\x08\x42\n\n\x08\x61pprover\"\x9d\x02\n\x12WorkflowParameters\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x17\n\x0f\x61pprovalsNeeded\x18\x02 \x01(\x05\x12\x16\n\x0e\x63heckoutNeeded\x18\x03 \x01(\x08\x12\x1d\n\x15startAccessOnApproval\x18\x04 \x01(\x08\x12\x15\n\rrequireReason\x18\x05 \x01(\x08\x12\x15\n\rrequireTicket\x18\x06 \x01(\x08\x12\x12\n\nrequireMFA\x18\x07 \x01(\x08\x12\x14\n\x0c\x61\x63\x63\x65ssLength\x18\x08 \x01(\x03\x12\x34\n\x0c\x61llowedTimes\x18\t \x01(\x0b\x32\x1e.Workflow.TemporalAccessFilter\"\x84\x01\n\x0eWorkflowConfig\x12\x30\n\nparameters\x18\x01 \x01(\x0b\x32\x1c.Workflow.WorkflowParameters\x12-\n\tapprovers\x18\x02 \x03(\x0b\x32\x1a.Workflow.WorkflowApprover\x12\x11\n\tcreatedOn\x18\x03 \x01(\x03\"\xd0\x01\n\x0eWorkflowStatus\x12&\n\x05stage\x18\x01 \x01(\x0e\x32\x17.Workflow.WorkflowStage\x12-\n\nconditions\x18\x02 \x03(\x0e\x32\x19.Workflow.AccessCondition\x12.\n\napprovedBy\x18\x03 \x03(\x0b\x32\x1a.Workflow.WorkflowApproval\x12\x11\n\tstartedOn\x18\x04 \x01(\x03\x12\x11\n\texpiresOn\x18\x05 \x01(\x03\x12\x11\n\tescalated\x18\x06 \x01(\x08\"\xbd\x01\n\x0fWorkflowProcess\x12\x0f\n\x07\x66lowUid\x18\x01 \x01(\x0c\x12\x0e\n\x06userId\x18\x02 \x01(\x03\x12)\n\x08resource\x18\x03 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x11\n\tstartedOn\x18\x04 \x01(\x03\x12\x11\n\texpiresOn\x18\x05 \x01(\x03\x12\x0e\n\x06reason\x18\x06 \x01(\x0c\x12\x13\n\x0bmfaVerified\x18\x07 \x01(\x08\x12\x13\n\x0b\x65xternalRef\x18\x08 \x01(\x0c\"U\n\x10WorkflowApproval\x12\x0e\n\x06userId\x18\x01 \x01(\x03\x12\x0c\n\x04user\x18\x02 \x01(\t\x12\x0f\n\x07\x66lowUid\x18\x03 \x01(\x0c\x12\x12\n\napprovedOn\x18\x04 \x01(\x03\"\xcb\x01\n\x0fWorkflowContext\x12\x30\n\x0eworkflowConfig\x18\x01 \x01(\x0b\x32\x18.Workflow.WorkflowConfig\x12+\n\x08workflow\x18\x02 \x01(\x0b\x32\x19.Workflow.WorkflowProcess\x12-\n\tapprovals\x18\x03 \x03(\x0b\x32\x1a.Workflow.WorkflowApproval\x12*\n\x07\x62locker\x18\x04 \x01(\x0b\x32\x19.Workflow.WorkflowProcess\"u\n\rWorkflowState\x12\x0f\n\x07\x66lowUid\x18\x01 \x01(\x0c\x12)\n\x08resource\x18\x02 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12(\n\x06status\x18\x03 \x01(\x0b\x32\x18.Workflow.WorkflowStatus\"b\n\x15WorkflowAccessRequest\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x0e\n\x06reason\x18\x02 \x01(\x0c\x12\x0e\n\x06ticket\x18\x03 \x01(\x0c\"i\n\x18WorkflowApprovalOrDenial\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x0c\n\x04\x64\x65ny\x18\x02 \x01(\x08\x12\x14\n\x0c\x64\x65nialReason\x18\x03 \x01(\t\"=\n\x0fUserAccessState\x12*\n\tworkflows\x18\x01 \x03(\x0b\x32\x17.Workflow.WorkflowState\"@\n\x10\x41pprovalRequests\x12,\n\tworkflows\x18\x01 \x03(\x0b\x32\x19.Workflow.WorkflowProcess\"4\n\x0eTimeOfDayRange\x12\x11\n\tstartTime\x18\x01 \x01(\x05\x12\x0f\n\x07\x65ndTime\x18\x02 \x01(\x05\"\x80\x01\n\x14TemporalAccessFilter\x12,\n\ntimeRanges\x18\x01 \x03(\x0b\x32\x18.Workflow.TimeOfDayRange\x12(\n\x0b\x61llowedDays\x18\x02 \x03(\x0e\x32\x13.Workflow.DayOfWeek\x12\x10\n\x08timeZone\x18\x03 \x01(\t\"#\n\x0f\x41uthorizedUsers\x12\x10\n\x08username\x18\x01 \x03(\t*[\n\rWorkflowStage\x12\x15\n\x11WS_READY_TO_START\x10\x00\x12\x0e\n\nWS_STARTED\x10\x01\x12\x13\n\x0fWS_NEEDS_ACTION\x10\x02\x12\x0e\n\nWS_WAITING\x10\x03*i\n\x0f\x41\x63\x63\x65ssCondition\x12\x0f\n\x0b\x41\x43_APPROVAL\x10\x00\x12\x0e\n\nAC_CHECKIN\x10\x01\x12\n\n\x06\x41\x43_MFA\x10\x02\x12\x0b\n\x07\x41\x43_TIME\x10\x03\x12\r\n\tAC_REASON\x10\x04\x12\r\n\tAC_TICKET\x10\x05*\x84\x01\n\tDayOfWeek\x12\x1b\n\x17\x44\x41Y_OF_WEEK_UNSPECIFIED\x10\x00\x12\n\n\x06MONDAY\x10\x01\x12\x0b\n\x07TUESDAY\x10\x02\x12\r\n\tWEDNESDAY\x10\x03\x12\x0c\n\x08THURSDAY\x10\x04\x12\n\n\x06\x46RIDAY\x10\x05\x12\x0c\n\x08SATURDAY\x10\x06\x12\n\n\x06SUNDAY\x10\x07\x42$\n\x18\x63om.keepersecurity.protoB\x08Workflowb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -21,12 +21,12 @@ if not _descriptor._USE_C_DESCRIPTORS: _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'\n\030com.keepersecurity.protoB\010Workflow' - _globals['_WORKFLOWSTAGE']._serialized_start=1802 - _globals['_WORKFLOWSTAGE']._serialized_end=1893 - _globals['_ACCESSCONDITION']._serialized_start=1895 - _globals['_ACCESSCONDITION']._serialized_end=2000 - _globals['_DAYOFWEEK']._serialized_start=2003 - _globals['_DAYOFWEEK']._serialized_end=2135 + _globals['_WORKFLOWSTAGE']._serialized_start=1946 + _globals['_WORKFLOWSTAGE']._serialized_end=2037 + _globals['_ACCESSCONDITION']._serialized_start=2039 + _globals['_ACCESSCONDITION']._serialized_end=2144 + _globals['_DAYOFWEEK']._serialized_start=2147 + _globals['_DAYOFWEEK']._serialized_end=2279 _globals['_WORKFLOWAPPROVER']._serialized_start=45 _globals['_WORKFLOWAPPROVER']._serialized_end=148 _globals['_WORKFLOWPARAMETERS']._serialized_start=151 @@ -45,12 +45,16 @@ _globals['_WORKFLOWSTATE']._serialized_end=1386 _globals['_WORKFLOWACCESSREQUEST']._serialized_start=1388 _globals['_WORKFLOWACCESSREQUEST']._serialized_end=1486 - _globals['_USERACCESSSTATE']._serialized_start=1488 - _globals['_USERACCESSSTATE']._serialized_end=1549 - _globals['_APPROVALREQUESTS']._serialized_start=1551 - _globals['_APPROVALREQUESTS']._serialized_end=1615 - _globals['_TIMEOFDAYRANGE']._serialized_start=1617 - _globals['_TIMEOFDAYRANGE']._serialized_end=1669 - _globals['_TEMPORALACCESSFILTER']._serialized_start=1672 - _globals['_TEMPORALACCESSFILTER']._serialized_end=1800 + _globals['_WORKFLOWAPPROVALORDENIAL']._serialized_start=1488 + _globals['_WORKFLOWAPPROVALORDENIAL']._serialized_end=1593 + _globals['_USERACCESSSTATE']._serialized_start=1595 + _globals['_USERACCESSSTATE']._serialized_end=1656 + _globals['_APPROVALREQUESTS']._serialized_start=1658 + _globals['_APPROVALREQUESTS']._serialized_end=1722 + _globals['_TIMEOFDAYRANGE']._serialized_start=1724 + _globals['_TIMEOFDAYRANGE']._serialized_end=1776 + _globals['_TEMPORALACCESSFILTER']._serialized_start=1779 + _globals['_TEMPORALACCESSFILTER']._serialized_end=1907 + _globals['_AUTHORIZEDUSERS']._serialized_start=1909 + _globals['_AUTHORIZEDUSERS']._serialized_end=1944 # @@protoc_insertion_point(module_scope) diff --git a/keepercommander/proto/workflow_pb2.pyi b/keepercommander/proto/workflow_pb2.pyi index 1606b8aba..ae967540f 100644 --- a/keepercommander/proto/workflow_pb2.pyi +++ b/keepercommander/proto/workflow_pb2.pyi @@ -173,9 +173,19 @@ class WorkflowAccessRequest(_message.Message): REASON_FIELD_NUMBER: _ClassVar[int] TICKET_FIELD_NUMBER: _ClassVar[int] resource: _GraphSync_pb2.GraphSyncRef - reason: str - ticket: str - def __init__(self, resource: _Optional[_Union[_GraphSync_pb2.GraphSyncRef, _Mapping]] = ..., reason: _Optional[str] = ..., ticket: _Optional[str] = ...) -> None: ... + reason: bytes + ticket: bytes + def __init__(self, resource: _Optional[_Union[_GraphSync_pb2.GraphSyncRef, _Mapping]] = ..., reason: _Optional[bytes] = ..., ticket: _Optional[bytes] = ...) -> None: ... + +class WorkflowApprovalOrDenial(_message.Message): + __slots__ = ("resource", "deny", "denialReason") + RESOURCE_FIELD_NUMBER: _ClassVar[int] + DENY_FIELD_NUMBER: _ClassVar[int] + DENIALREASON_FIELD_NUMBER: _ClassVar[int] + resource: _GraphSync_pb2.GraphSyncRef + deny: bool + denialReason: str + def __init__(self, resource: _Optional[_Union[_GraphSync_pb2.GraphSyncRef, _Mapping]] = ..., deny: _Optional[bool] = ..., denialReason: _Optional[str] = ...) -> None: ... class UserAccessState(_message.Message): __slots__ = ("workflows",) @@ -206,3 +216,9 @@ class TemporalAccessFilter(_message.Message): allowedDays: _containers.RepeatedScalarFieldContainer[DayOfWeek] timeZone: str def __init__(self, timeRanges: _Optional[_Iterable[_Union[TimeOfDayRange, _Mapping]]] = ..., allowedDays: _Optional[_Iterable[_Union[DayOfWeek, str]]] = ..., timeZone: _Optional[str] = ...) -> None: ... + +class AuthorizedUsers(_message.Message): + __slots__ = ("username",) + USERNAME_FIELD_NUMBER: _ClassVar[int] + username: _containers.RepeatedScalarFieldContainer[str] + def __init__(self, username: _Optional[_Iterable[str]] = ...) -> None: ... From 0fecc021b2f9bcbce170a0112db2782c7ee964c3 Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Thu, 12 Feb 2026 11:34:29 +0530 Subject: [PATCH 03/11] Add restriction for user to launch/tunnel PAM record --- keepercommander/commands/pam_launch/launch.py | 8 + .../commands/tunnel_and_connections.py | 13 ++ keepercommander/commands/workflow/__init__.py | 4 +- .../commands/workflow/workflow_commands.py | 187 +++++++++++++++--- 4 files changed, 187 insertions(+), 25 deletions(-) diff --git a/keepercommander/commands/pam_launch/launch.py b/keepercommander/commands/pam_launch/launch.py index 3646be303..55d1fe2da 100644 --- a/keepercommander/commands/pam_launch/launch.py +++ b/keepercommander/commands/pam_launch/launch.py @@ -284,6 +284,14 @@ def execute(self, params: KeeperParams, **kwargs): logging.debug(f"Found record: {record_uid}") + # Workflow access check — block if record requires checkout and user hasn't checked out + try: + from ..workflow.workflow_commands import check_workflow_access + if not check_workflow_access(params, record_uid): + return + except ImportError: + pass + # Validate --user and --host parameters against allowSupply flags # Note: cmdline options override record data when provided # launch_credential_uid = kwargs.get('launch_credential_uid') diff --git a/keepercommander/commands/tunnel_and_connections.py b/keepercommander/commands/tunnel_and_connections.py index 48cd8b2dd..b8265e7bb 100644 --- a/keepercommander/commands/tunnel_and_connections.py +++ b/keepercommander/commands/tunnel_and_connections.py @@ -411,6 +411,11 @@ def execute(self, params, **kwargs): if _remove_tunneling_override_port and pam_settings.value[0]['portForward'].get('port'): pam_settings.value[0]['portForward'].pop('port') dirty = True + # Persist the record changes (new pamSettings field or port modifications) + if dirty: + record_management.update_record(params, record) + api.sync_down(params) + dirty = False if not tmp_dag.is_tunneling_config_set_up(record_uid): print(f"{bcolors.FAIL}No PAM Configuration UID set. This must be set for tunneling to work. " f"This can be done by running " @@ -538,6 +543,14 @@ def execute(self, params, **kwargs): print(f"{bcolors.FAIL}Record {record_uid} not found.{bcolors.ENDC}") return + # Workflow access check — block if record requires checkout and user hasn't checked out + try: + from .workflow.workflow_commands import check_workflow_access + if not check_workflow_access(params, record_uid): + return + except ImportError: + pass + # Validate PAM settings pam_settings = record.get_typed_field('pamSettings') if not pam_settings: diff --git a/keepercommander/commands/workflow/__init__.py b/keepercommander/commands/workflow/__init__.py index 721aa0bc0..0c02b1910 100644 --- a/keepercommander/commands/workflow/__init__.py +++ b/keepercommander/commands/workflow/__init__.py @@ -21,7 +21,7 @@ Workflow commands are accessed via: pam workflow """ -__all__ = ['PAMWorkflowCommand'] +__all__ = ['PAMWorkflowCommand', 'check_workflow_access'] -from .workflow_commands import PAMWorkflowCommand +from .workflow_commands import PAMWorkflowCommand, check_workflow_access diff --git a/keepercommander/commands/workflow/workflow_commands.py b/keepercommander/commands/workflow/workflow_commands.py index 7e0c13443..e9a1e75f4 100644 --- a/keepercommander/commands/workflow/workflow_commands.py +++ b/keepercommander/commands/workflow/workflow_commands.py @@ -120,6 +120,111 @@ def resolve_record_name(params, resource_ref) -> str: return '' +def resolve_user_name(params: KeeperParams, user_id: int) -> str: + """ + Resolve an enterprise user ID to email/username. + + Uses params.enterprise['users'] when available (enterprise admin). + Falls back to displaying the numeric ID. + + Args: + params: KeeperParams instance + user_id: Enterprise user ID (int64) + + Returns: + str: User email or 'User ID ' as fallback + """ + if params.enterprise and 'users' in params.enterprise: + for u in params.enterprise['users']: + if u.get('enterprise_user_id') == user_id: + return u.get('username', f'User ID {user_id}') + return f'User ID {user_id}' + + +def check_workflow_access(params: KeeperParams, record_uid: str) -> bool: + """ + Check whether the current user has active checkout access to a PAM record. + + This function should be called before connecting, tunneling, or launching + a PAM resource. It verifies: + 1. Whether the record has a workflow configured. + 2. If so, whether the user has an active checked-out session. + + If the user does not have access, a helpful message is printed guiding + them through the workflow process. + + Args: + params: KeeperParams instance with session info + record_uid: Record UID string to check + + Returns: + True if access is allowed (no workflow, or user has active checkout). + False if access is blocked by workflow. + """ + try: + # Step 1: Check if the record has a workflow configured + record_uid_bytes = utils.base64_url_decode(record_uid) + record = vault.KeeperRecord.load(params, record_uid) + record_name = record.title if record else record_uid + + ref = create_record_ref(record_uid_bytes, record_name) + config_response = _post_request_to_router( + params, + 'read_workflow_config', + rq_proto=ref, + rs_type=workflow_pb2.WorkflowConfig + ) + + if config_response is None: + # No workflow configured on this record — access is unrestricted + return True + + # Step 2: Workflow exists — check user's access state for this record + state_rq = workflow_pb2.WorkflowState() + state_rq.resource.CopyFrom(ref) + state_response = _post_request_to_router( + params, + 'get_workflow_state', + rq_proto=state_rq, + rs_type=workflow_pb2.WorkflowState + ) + + if state_response and state_response.status: + stage = state_response.status.stage + + if stage == workflow_pb2.WS_STARTED: + # User has an active checkout — allow access + return True + + if stage == workflow_pb2.WS_READY_TO_START: + print(f"\n{bcolors.WARNING}Workflow access approved but not yet checked out.{bcolors.ENDC}") + print(f"Run: {bcolors.OKBLUE}pam workflow start {record_uid}{bcolors.ENDC} to check out the record.\n") + return False + + if stage == workflow_pb2.WS_WAITING: + conditions = state_response.status.conditions + cond_str = format_access_conditions(conditions) if conditions else 'approval' + print(f"\n{bcolors.WARNING}Workflow access is pending: waiting for {cond_str}.{bcolors.ENDC}") + print(f"Your request is being processed. Please wait for approval.\n") + return False + + if stage == workflow_pb2.WS_NEEDS_ACTION: + print(f"\n{bcolors.WARNING}Workflow requires additional action before access is granted.{bcolors.ENDC}") + print(f"Run: {bcolors.OKBLUE}pam workflow state --record {record_uid}{bcolors.ENDC} to see details.\n") + return False + + # No active workflow state — user hasn't requested access yet + print(f"\n{bcolors.WARNING}This record is protected by a workflow.{bcolors.ENDC}") + print(f"You must request access before connecting.") + print(f"Run: {bcolors.OKBLUE}pam workflow request {record_uid}{bcolors.ENDC} to request access.\n") + return False + + except Exception: + # If workflow check fails (e.g. network issue), allow access. + # The backend/gateway should still enforce restrictions server-side. + return True + + def parse_duration_to_milliseconds(duration_str: str) -> int: """ Parse duration string to milliseconds. @@ -247,7 +352,7 @@ class WorkflowCreateCommand(Command): pam workflow create --approvals-needed 2 --duration 2h --checkout """ parser = argparse.ArgumentParser(prog='pam workflow create', - description='Create workflow configuration for a PAM record') + description='Create workflow configuration for a PAM record', allow_abbrev=False) parser.add_argument('record', help='Record UID or name to configure workflow for') parser.add_argument('-n', '--approvals-needed', type=int, default=1, help='Number of approvals required (default: 1)') @@ -318,7 +423,30 @@ def execute(self, params: KeeperParams, **kwargs): rq_proto=parameters ) - # Success (router returns empty response or 200 status) + # Auto-add record owner as the first approver (MRD Req #5: + # "By Default: The owner of the record must be added to this list") + owner_email = params.user + owner_added = False + if owner_email: + try: + approver_config = workflow_pb2.WorkflowConfig() + approver_config.parameters.resource.CopyFrom( + create_record_ref(record_uid_bytes, record.title) + ) + approver = workflow_pb2.WorkflowApprover() + approver.user = owner_email + approver_config.approvers.append(approver) + _post_request_to_router( + params, + 'add_workflow_approvers', + rq_proto=approver_config + ) + owner_added = True + except Exception: + # Non-fatal: workflow was created, approver add failed + pass + + # Success output if kwargs.get('format') == 'json': result = { 'status': 'success', @@ -331,7 +459,8 @@ def execute(self, params: KeeperParams, **kwargs): 'require_ticket': parameters.requireTicket, 'require_mfa': parameters.requireMFA, 'access_duration': format_duration_from_milliseconds(parameters.accessLength) - } + }, + 'owner_approver': owner_email if owner_added else None } print(json.dumps(result, indent=2)) else: @@ -346,6 +475,11 @@ def execute(self, params: KeeperParams, **kwargs): print(f"Requires ticket: Yes") if parameters.requireMFA: print(f"Requires MFA: Yes") + if owner_added: + print(f"\nApprover added: {owner_email} (record owner)") + else: + print(f"\n{bcolors.WARNING}Note: Add approvers with: " + f"pam workflow add-approver {record_uid} --user {bcolors.ENDC}") print() except Exception as e: @@ -534,7 +668,6 @@ def execute(self, params: KeeperParams, **kwargs): result = { 'record_uid': record_uid, 'record_name': resolve_record_name(params, response.parameters.resource), - 'created_on': response.createdOn, 'parameters': { 'approvals_needed': response.parameters.approvalsNeeded, 'checkout_needed': response.parameters.checkoutNeeded, @@ -542,7 +675,6 @@ def execute(self, params: KeeperParams, **kwargs): 'require_reason': response.parameters.requireReason, 'require_ticket': response.parameters.requireTicket, 'require_mfa': response.parameters.requireMFA, - 'access_length_ms': response.parameters.accessLength, 'access_duration': format_duration_from_milliseconds(response.parameters.accessLength) }, 'approvers': [] @@ -593,7 +725,7 @@ def execute(self, params: KeeperParams, **kwargs): if approver.HasField('user'): print(f" {idx}. User: {approver.user}{escalation}") elif approver.HasField('userId'): - print(f" {idx}. User ID: {approver.userId}{escalation}") + print(f" {idx}. User: {resolve_user_name(params, approver.userId)}{escalation}") elif approver.HasField('teamUid'): team_uid = utils.base64_url_encode(approver.teamUid) print(f" {idx}. Team: {team_uid}{escalation}") @@ -928,7 +1060,11 @@ def execute(self, params: KeeperParams, **kwargs): 'started_on': response.status.startedOn, 'expires_on': response.status.expiresOn, 'approved_by': [ - {'user': a.user, 'user_id': a.userId, 'approved_on': a.approvedOn} + { + 'user': a.user if a.user else resolve_user_name(params, a.userId), + 'user_id': a.userId, + 'approved_on': a.approvedOn + } for a in response.status.approvedBy ] } @@ -951,7 +1087,7 @@ def execute(self, params: KeeperParams, **kwargs): if response.status.approvedBy: print(f"Approved by:") for a in response.status.approvedBy: - name = a.user if a.user else f"User ID {a.userId}" + name = a.user if a.user else resolve_user_name(params, a.userId) ts = '' if a.approvedOn: ts = f" at {datetime.fromtimestamp(a.approvedOn / 1000).strftime('%Y-%m-%d %H:%M:%S')}" @@ -1009,7 +1145,11 @@ def execute(self, params: KeeperParams, **kwargs): 'started_on': wf.status.startedOn, 'expires_on': wf.status.expiresOn, 'approved_by': [ - {'user': a.user, 'user_id': a.userId, 'approved_on': a.approvedOn} + { + 'user': a.user if a.user else resolve_user_name(params, a.userId), + 'user_id': a.userId, + 'approved_on': a.approvedOn + } for a in wf.status.approvedBy ] } @@ -1034,7 +1174,7 @@ def execute(self, params: KeeperParams, **kwargs): expires = datetime.fromtimestamp(wf.status.expiresOn / 1000) print(f" Expires: {expires.strftime('%Y-%m-%d %H:%M:%S')}") if wf.status.approvedBy: - approved_names = [a.user if a.user else f"ID:{a.userId}" for a in wf.status.approvedBy] + approved_names = [a.user if a.user else resolve_user_name(params, a.userId) for a in wf.status.approvedBy] print(f" Approved by: {', '.join(approved_names)}") print() @@ -1081,10 +1221,12 @@ def execute(self, params: KeeperParams, **kwargs): { 'flow_uid': utils.base64_url_encode(wf.flowUid), 'user_id': wf.userId, + 'requested_by': resolve_user_name(params, wf.userId), 'record_uid': utils.base64_url_encode(wf.resource.value), 'record_name': resolve_record_name(params, wf.resource), 'started_on': wf.startedOn, 'expires_on': wf.expiresOn, + 'duration': format_duration_from_milliseconds(wf.expiresOn - wf.startedOn) if wf.expiresOn and wf.startedOn else None, 'reason': wf.reason.decode('utf-8') if wf.reason else '', 'external_ref': wf.externalRef.decode('utf-8') if wf.externalRef else '', 'mfa_verified': wf.mfaVerified @@ -1098,10 +1240,12 @@ def execute(self, params: KeeperParams, **kwargs): for idx, wf in enumerate(response.workflows, 1): print(f"{idx}. {resolve_record_name(params, wf.resource)}") print(f" Flow UID: {utils.base64_url_encode(wf.flowUid)}") - print(f" Requested by: User ID {wf.userId}") + print(f" Requested by: {resolve_user_name(params, wf.userId)}") if wf.startedOn: started = datetime.fromtimestamp(wf.startedOn / 1000) print(f" Requested on: {started.strftime('%Y-%m-%d %H:%M:%S')}") + if wf.expiresOn and wf.startedOn: + print(f" Duration: {format_duration_from_milliseconds(wf.expiresOn - wf.startedOn)}") if wf.expiresOn: expires = datetime.fromtimestamp(wf.expiresOn / 1000) print(f" Expires: {expires.strftime('%Y-%m-%d %H:%M:%S')}") @@ -1109,7 +1253,8 @@ def execute(self, params: KeeperParams, **kwargs): print(f" Reason: {wf.reason.decode('utf-8')}") if wf.externalRef: print(f" Ticket: {wf.externalRef.decode('utf-8')}") - print(f" MFA Verified: {'Yes' if wf.mfaVerified else 'No'}") + if wf.mfaVerified: + print(f" MFA Verified: Yes") print() except Exception as e: @@ -1476,27 +1621,23 @@ class PAMWorkflowCommand(GroupCommand): def __init__(self): super(PAMWorkflowCommand, self).__init__() - # Configuration commands + # --- Admin / Approver commands --- self.register_command('create', WorkflowCreateCommand(), 'Create workflow configuration', 'c') self.register_command('read', WorkflowReadCommand(), 'Read workflow configuration', 'r') self.register_command('update', WorkflowUpdateCommand(), 'Update workflow configuration', 'u') self.register_command('delete', WorkflowDeleteCommand(), 'Delete workflow configuration', 'd') - - # Approver management self.register_command('add-approver', WorkflowAddApproversCommand(), 'Add approvers', 'aa') self.register_command('remove-approver', WorkflowDeleteApproversCommand(), 'Remove approvers', 'ra') - - # State inspection - self.register_command('state', WorkflowGetStateCommand(), 'Get workflow state', 'st') - self.register_command('my-access', WorkflowGetUserAccessStateCommand(), 'Get my workflow access', 'ma') self.register_command('pending', WorkflowGetApprovalRequestsCommand(), 'Get pending approvals', 'p') + self.register_command('approve', WorkflowApproveCommand(), 'Approve access request', 'a') + self.register_command('deny', WorkflowDenyCommand(), 'Deny access request', 'dn') - # Actions - self.register_command('start', WorkflowStartCommand(), 'Start workflow (check-out)', 's') + # --- User commands --- self.register_command('request', WorkflowRequestAccessCommand(), 'Request access', 'rq') - self.register_command('approve', WorkflowApproveCommand(), 'Approve access', 'a') - self.register_command('deny', WorkflowDenyCommand(), 'Deny access', 'dn') + self.register_command('start', WorkflowStartCommand(), 'Start workflow (check-out)', 's') self.register_command('end', WorkflowEndCommand(), 'End workflow (check-in)', 'e') + self.register_command('my-access', WorkflowGetUserAccessStateCommand(), 'Get my access state', 'ma') + self.register_command('state', WorkflowGetStateCommand(), 'Get workflow state', 'st') self.default_verb = 'state' From 495671adb743eca9fa5de241c0eae28addfec593 Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Fri, 13 Feb 2026 11:22:39 +0530 Subject: [PATCH 04/11] Fix missing record_name in json format & team name validation --- .../commands/workflow/workflow_commands.py | 73 ++++++++++++++----- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/keepercommander/commands/workflow/workflow_commands.py b/keepercommander/commands/workflow/workflow_commands.py index e9a1e75f4..17aad27d5 100644 --- a/keepercommander/commands/workflow/workflow_commands.py +++ b/keepercommander/commands/workflow/workflow_commands.py @@ -120,6 +120,30 @@ def resolve_record_name(params, resource_ref) -> str: return '' +def validate_team(params: KeeperParams, team_input: str) -> str: + """ + Resolve and validate a team name or UID. + + Checks params.team_cache for matching UID or name (case-insensitive). + + Args: + params: KeeperParams instance + team_input: Team UID or team name + + Returns: + str: Resolved team UID + + Raises: + CommandError: If team is not found + """ + if team_input in params.team_cache: + return team_input + for uid, team_data in params.team_cache.items(): + if team_data.get('name', '').casefold() == team_input.casefold(): + return uid + raise CommandError('', f'Team "{team_input}" not found. Use a valid team UID or team name.') + + def resolve_user_name(params: KeeperParams, user_id: int) -> str: """ Resolve an enterprise user ID to email/username. @@ -451,7 +475,7 @@ def execute(self, params: KeeperParams, **kwargs): result = { 'status': 'success', 'record_uid': record_uid, - 'record_title': record.title, + 'record_name': record.title, 'workflow_config': { 'approvals_needed': parameters.approvalsNeeded, 'checkout_needed': parameters.checkoutNeeded, @@ -590,7 +614,7 @@ def execute(self, params: KeeperParams, **kwargs): ) if kwargs.get('format') == 'json': - result = {'status': 'success', 'record_uid': record_uid} + result = {'status': 'success', 'record_uid': record_uid, 'record_name': record.title} print(json.dumps(result, indent=2)) else: print(f"\n{bcolors.OKGREEN}✓ Workflow updated successfully{bcolors.ENDC}\n") @@ -728,7 +752,10 @@ def execute(self, params: KeeperParams, **kwargs): print(f" {idx}. User: {resolve_user_name(params, approver.userId)}{escalation}") elif approver.HasField('teamUid'): team_uid = utils.base64_url_encode(approver.teamUid) - print(f" {idx}. Team: {team_uid}{escalation}") + team_data = params.team_cache.get(team_uid, {}) + team_name = team_data.get('name', '') + team_display = f"{team_name} ({team_uid})" if team_name else team_uid + print(f" {idx}. Team: {team_display}{escalation}") else: print(f"\n{bcolors.WARNING}⚠ No approvers configured!{bcolors.ENDC}") print(f"Add approvers with: pam workflow add-approver {record_uid} --user ") @@ -788,7 +815,7 @@ def execute(self, params: KeeperParams, **kwargs): ) if kwargs.get('format') == 'json': - result = {'status': 'success', 'record_uid': record_uid} + result = {'status': 'success', 'record_uid': record_uid, 'record_name': record.title} print(json.dumps(result, indent=2)) else: print(f"\n{bcolors.OKGREEN}✓ Workflow deleted successfully{bcolors.ENDC}\n") @@ -820,7 +847,7 @@ class WorkflowAddApproversCommand(Command): parser.add_argument('-u', '--user', action='append', help='User email to add as approver (can specify multiple times)') parser.add_argument('-t', '--team', action='append', - help='Team UID to add as approver (can specify multiple times)') + help='Team name or UID to add as approver (can specify multiple times)') parser.add_argument('-e', '--escalation', action='store_true', help='Mark as escalation approver') parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], default='table', help='Output format') @@ -856,17 +883,18 @@ def execute(self, params: KeeperParams, **kwargs): config = workflow_pb2.WorkflowConfig() config.parameters.resource.CopyFrom(create_record_ref(record_uid_bytes, record.title)) - # Add user approvers + # Add user approvers (email validated by backend) for user_email in users: approver = workflow_pb2.WorkflowApprover() approver.user = user_email approver.escalation = is_escalation config.approvers.append(approver) - # Add team approvers - for team_uid in teams: + # Add team approvers (accepts team UID or team name) + for team_input in teams: + resolved_team_uid = validate_team(params, team_input) approver = workflow_pb2.WorkflowApprover() - approver.teamUid = utils.base64_url_decode(team_uid) + approver.teamUid = utils.base64_url_decode(resolved_team_uid) approver.escalation = is_escalation config.approvers.append(approver) @@ -882,6 +910,7 @@ def execute(self, params: KeeperParams, **kwargs): result = { 'status': 'success', 'record_uid': record_uid, + 'record_name': record.title, 'approvers_added': len(users) + len(teams), 'escalation': is_escalation } @@ -909,7 +938,7 @@ class WorkflowDeleteApproversCommand(Command): description='Remove approvers from a workflow') parser.add_argument('record', help='Record UID or name') parser.add_argument('-u', '--user', action='append', help='User email to remove as approver') - parser.add_argument('-t', '--team', action='append', help='Team UID to remove as approver') + parser.add_argument('-t', '--team', action='append', help='Team name or UID to remove as approver') parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], default='table', help='Output format') @@ -943,16 +972,17 @@ def execute(self, params: KeeperParams, **kwargs): config = workflow_pb2.WorkflowConfig() config.parameters.resource.CopyFrom(create_record_ref(record_uid_bytes, record.title)) - # Add user approvers to remove + # Add user approvers to remove (email validated by backend) for user_email in users: approver = workflow_pb2.WorkflowApprover() approver.user = user_email config.approvers.append(approver) - # Add team approvers to remove - for team_uid in teams: + # Add team approvers to remove (accepts team UID or team name) + for team_input in teams: + resolved_team_uid = validate_team(params, team_input) approver = workflow_pb2.WorkflowApprover() - approver.teamUid = utils.base64_url_decode(team_uid) + approver.teamUid = utils.base64_url_decode(resolved_team_uid) config.approvers.append(approver) # Make API call @@ -967,6 +997,7 @@ def execute(self, params: KeeperParams, **kwargs): result = { 'status': 'success', 'record_uid': record_uid, + 'record_name': record.title, 'approvers_removed': len(users) + len(teams) } print(json.dumps(result, indent=2)) @@ -1071,8 +1102,9 @@ def execute(self, params: KeeperParams, **kwargs): print(json.dumps(result, indent=2)) else: print(f"\n{bcolors.OKBLUE}Workflow State{bcolors.ENDC}\n") + rec_uid = utils.base64_url_encode(response.resource.value) if response.resource.value else '' + print(f"Record: {resolve_record_name(params, response.resource)} ({rec_uid})") print(f"Flow UID: {utils.base64_url_encode(response.flowUid)}") - print(f"Record: {resolve_record_name(params, response.resource)}") print(f"Stage: {format_workflow_stage(response.status.stage)}") if response.status.conditions: print(f"Conditions: {format_access_conditions(response.status.conditions)}") @@ -1160,7 +1192,8 @@ def execute(self, params: KeeperParams, **kwargs): else: print(f"\n{bcolors.OKBLUE}Your Active Workflows{bcolors.ENDC}\n") for idx, wf in enumerate(response.workflows, 1): - print(f"{idx}. {resolve_record_name(params, wf.resource)}") + rec_uid = utils.base64_url_encode(wf.resource.value) if wf.resource.value else '' + print(f"{idx}. Record: {resolve_record_name(params, wf.resource)} ({rec_uid})") print(f" Flow UID: {utils.base64_url_encode(wf.flowUid)}") print(f" Stage: {format_workflow_stage(wf.status.stage)}") if wf.status.conditions: @@ -1238,7 +1271,9 @@ def execute(self, params: KeeperParams, **kwargs): else: print(f"\n{bcolors.OKBLUE}Pending Approval Requests{bcolors.ENDC}\n") for idx, wf in enumerate(response.workflows, 1): - print(f"{idx}. {resolve_record_name(params, wf.resource)}") + rec_uid = utils.base64_url_encode(wf.resource.value) if wf.resource.value else '' + rec_name = resolve_record_name(params, wf.resource) + print(f"{idx}. Record: {rec_name} ({rec_uid})") print(f" Flow UID: {utils.base64_url_encode(wf.flowUid)}") print(f" Requested by: {resolve_user_name(params, wf.userId)}") if wf.startedOn: @@ -1316,7 +1351,7 @@ def execute(self, params: KeeperParams, **kwargs): ) if kwargs.get('format') == 'json': - result = {'status': 'success', 'record_uid': record_uid, 'action': 'checked_out'} + result = {'status': 'success', 'record_uid': record_uid, 'record_name': record.title, 'action': 'checked_out'} print(json.dumps(result, indent=2)) else: print(f"\n{bcolors.OKGREEN}✓ Workflow started (checked out){bcolors.ENDC}\n") @@ -1387,6 +1422,7 @@ def execute(self, params: KeeperParams, **kwargs): result = { 'status': 'success', 'record_uid': record_uid, + 'record_name': record.title, 'message': 'Access request sent to approvers' } if reason: @@ -1590,6 +1626,7 @@ def execute(self, params: KeeperParams, **kwargs): 'status': 'success', 'flow_uid': flow_uid_str, 'record_uid': uid, + 'record_name': resolve_record_name(params, workflow_state.resource), 'action': 'ended' } print(json.dumps(result, indent=2)) From 2df84d80125af062b57da7abc5816ca559c59c7d Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Fri, 13 Feb 2026 14:11:16 +0530 Subject: [PATCH 05/11] Fix record name display in output, Update start and end commands to use record title/uid and flow uid --- .../commands/workflow/workflow_commands.py | 216 ++++++++++-------- 1 file changed, 118 insertions(+), 98 deletions(-) diff --git a/keepercommander/commands/workflow/workflow_commands.py b/keepercommander/commands/workflow/workflow_commands.py index 17aad27d5..7a4480366 100644 --- a/keepercommander/commands/workflow/workflow_commands.py +++ b/keepercommander/commands/workflow/workflow_commands.py @@ -116,10 +116,22 @@ def resolve_record_name(params, resource_ref) -> str: if resource_ref.value: rec_uid = utils.base64_url_encode(resource_ref.value) rec = vault.KeeperRecord.load(params, rec_uid) - return rec.title if rec else rec_uid + return rec.title if rec else '' return '' +def format_record_label(params, resource_ref) -> str: + """ + Format a record label as 'Name (UID)' for display. + If the name can't be resolved, shows just the UID once (no duplication). + """ + rec_uid = utils.base64_url_encode(resource_ref.value) if resource_ref.value else '' + rec_name = resolve_record_name(params, resource_ref) + if rec_name and rec_name != rec_uid: + return f"{rec_name} ({rec_uid})" + return rec_uid or 'Unknown' + + def validate_team(params: KeeperParams, team_input: str) -> str: """ Resolve and validate a team name or UID. @@ -1088,13 +1100,12 @@ def execute(self, params: KeeperParams, **kwargs): 'stage': format_workflow_stage(response.status.stage), 'conditions': [format_access_conditions([c]) for c in response.status.conditions], 'escalated': response.status.escalated, - 'started_on': response.status.startedOn, - 'expires_on': response.status.expiresOn, + 'started_on': response.status.startedOn or None, + 'expires_on': response.status.expiresOn or None, 'approved_by': [ { 'user': a.user if a.user else resolve_user_name(params, a.userId), - 'user_id': a.userId, - 'approved_on': a.approvedOn + 'approved_on': a.approvedOn or None } for a in response.status.approvedBy ] @@ -1102,8 +1113,7 @@ def execute(self, params: KeeperParams, **kwargs): print(json.dumps(result, indent=2)) else: print(f"\n{bcolors.OKBLUE}Workflow State{bcolors.ENDC}\n") - rec_uid = utils.base64_url_encode(response.resource.value) if response.resource.value else '' - print(f"Record: {resolve_record_name(params, response.resource)} ({rec_uid})") + print(f"Record: {format_record_label(params, response.resource)}") print(f"Flow UID: {utils.base64_url_encode(response.flowUid)}") print(f"Stage: {format_workflow_stage(response.status.stage)}") if response.status.conditions: @@ -1174,13 +1184,12 @@ def execute(self, params: KeeperParams, **kwargs): 'stage': format_workflow_stage(wf.status.stage), 'conditions': [format_access_conditions([c]) for c in wf.status.conditions], 'escalated': wf.status.escalated, - 'started_on': wf.status.startedOn, - 'expires_on': wf.status.expiresOn, + 'started_on': wf.status.startedOn or None, + 'expires_on': wf.status.expiresOn or None, 'approved_by': [ { 'user': a.user if a.user else resolve_user_name(params, a.userId), - 'user_id': a.userId, - 'approved_on': a.approvedOn + 'approved_on': a.approvedOn or None } for a in wf.status.approvedBy ] @@ -1192,8 +1201,7 @@ def execute(self, params: KeeperParams, **kwargs): else: print(f"\n{bcolors.OKBLUE}Your Active Workflows{bcolors.ENDC}\n") for idx, wf in enumerate(response.workflows, 1): - rec_uid = utils.base64_url_encode(wf.resource.value) if wf.resource.value else '' - print(f"{idx}. Record: {resolve_record_name(params, wf.resource)} ({rec_uid})") + print(f"{idx}. Record: {format_record_label(params, wf.resource)}") print(f" Flow UID: {utils.base64_url_encode(wf.flowUid)}") print(f" Stage: {format_workflow_stage(wf.status.stage)}") if wf.status.conditions: @@ -1257,12 +1265,12 @@ def execute(self, params: KeeperParams, **kwargs): 'requested_by': resolve_user_name(params, wf.userId), 'record_uid': utils.base64_url_encode(wf.resource.value), 'record_name': resolve_record_name(params, wf.resource), - 'started_on': wf.startedOn, - 'expires_on': wf.expiresOn, + 'started_on': wf.startedOn or None, + 'expires_on': wf.expiresOn or None, 'duration': format_duration_from_milliseconds(wf.expiresOn - wf.startedOn) if wf.expiresOn and wf.startedOn else None, - 'reason': wf.reason.decode('utf-8') if wf.reason else '', - 'external_ref': wf.externalRef.decode('utf-8') if wf.externalRef else '', - 'mfa_verified': wf.mfaVerified + 'reason': wf.reason.decode('utf-8') if wf.reason else None, + 'external_ref': wf.externalRef.decode('utf-8') if wf.externalRef else None, + 'mfa_verified': wf.mfaVerified or None } for wf in response.workflows ] @@ -1271,9 +1279,7 @@ def execute(self, params: KeeperParams, **kwargs): else: print(f"\n{bcolors.OKBLUE}Pending Approval Requests{bcolors.ENDC}\n") for idx, wf in enumerate(response.workflows, 1): - rec_uid = utils.base64_url_encode(wf.resource.value) if wf.resource.value else '' - rec_name = resolve_record_name(params, wf.resource) - print(f"{idx}. Record: {rec_name} ({rec_uid})") + print(f"{idx}. Record: {format_record_label(params, wf.resource)}") print(f" Flow UID: {utils.base64_url_encode(wf.flowUid)}") print(f" Requested by: {resolve_user_name(params, wf.userId)}") if wf.startedOn: @@ -1310,10 +1316,12 @@ class WorkflowStartCommand(Command): Example: pam workflow start + pam workflow start """ parser = argparse.ArgumentParser(prog='pam workflow start', - description='Start a workflow (check-out)') - parser.add_argument('record', help='Record UID or name') + description='Start a workflow (check-out). ' + 'Can use either record UID/name or flow UID.') + parser.add_argument('uid', help='Record UID, record name, or Flow UID') parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], default='table', help='Output format') @@ -1322,25 +1330,30 @@ def get_parser(self): def execute(self, params: KeeperParams, **kwargs): """Execute workflow start.""" - record_uid = kwargs.get('record') + uid = kwargs.get('uid') - # Resolve record UID - if record_uid not in params.record_cache: - records = list(params.record_cache.keys()) - for uid in records: - rec = vault.KeeperRecord.load(params, uid) - if rec and rec.title == record_uid: - record_uid = uid + # Try as record UID or name first + record_uid = None + record = None + if uid in params.record_cache: + record_uid = uid + else: + for cache_uid in params.record_cache: + rec = vault.KeeperRecord.load(params, cache_uid) + if rec and rec.title == uid: + record_uid = cache_uid break - else: - raise CommandError('', f'Record "{record_uid}" not found') - record = vault.KeeperRecord.load(params, record_uid) - record_uid_bytes = utils.base64_url_decode(record_uid) - - # Create workflow state - state = workflow_pb2.WorkflowState() - state.resource.CopyFrom(create_record_ref(record_uid_bytes, record.title)) + if record_uid: + record = vault.KeeperRecord.load(params, record_uid) + record_uid_bytes = utils.base64_url_decode(record_uid) + state = workflow_pb2.WorkflowState() + state.resource.CopyFrom(create_record_ref(record_uid_bytes, record.title)) + else: + # Treat as flow UID — query state to get record info, then start + uid_bytes = utils.base64_url_decode(uid) + state = workflow_pb2.WorkflowState() + state.flowUid = uid_bytes # Make API call try: @@ -1349,15 +1362,23 @@ def execute(self, params: KeeperParams, **kwargs): 'start_workflow', rq_proto=state ) - + if kwargs.get('format') == 'json': - result = {'status': 'success', 'record_uid': record_uid, 'record_name': record.title, 'action': 'checked_out'} + result = {'status': 'success', 'action': 'checked_out'} + if record: + result['record_uid'] = record_uid + result['record_name'] = record.title + else: + result['flow_uid'] = uid print(json.dumps(result, indent=2)) else: print(f"\n{bcolors.OKGREEN}✓ Workflow started (checked out){bcolors.ENDC}\n") - print(f"Record: {record.title} ({record_uid})") + if record: + print(f"Record: {record.title} ({record_uid})") + else: + print(f"Flow UID: {uid}") print() - + except Exception as e: raise CommandError('', f'Failed to start workflow: {str(e)}') @@ -1553,12 +1574,13 @@ class WorkflowEndCommand(Command): Example: pam workflow end - pam workflow end # Auto-detects active flow for record + pam workflow end + pam workflow end "Record Name" """ parser = argparse.ArgumentParser(prog='pam workflow end', description='End a workflow (check-in). ' - 'Can use either flow UID or record UID.') - parser.add_argument('uid', help='Flow UID or Record UID of the workflow to end') + 'Can use flow UID, record UID, or record name.') + parser.add_argument('uid', help='Flow UID, Record UID, or record name of the workflow to end') parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], default='table', help='Output format') @@ -1568,80 +1590,78 @@ def get_parser(self): def execute(self, params: KeeperParams, **kwargs): """Execute workflow end.""" uid = kwargs.get('uid') - uid_bytes = utils.base64_url_decode(uid) - - # Try to determine if this is a flow UID or record UID - # First, assume it's a flow UID and try to end it - ref = create_workflow_ref(uid_bytes) - - try: - response = _post_request_to_router( - params, - 'end_workflow', - rq_proto=ref - ) - - # Success - it was a flow UID - if kwargs.get('format') == 'json': - result = {'status': 'success', 'flow_uid': uid, 'action': 'ended'} - print(json.dumps(result, indent=2)) - else: - print(f"\n{bcolors.OKGREEN}✓ Workflow ended (checked in){bcolors.ENDC}\n") - print(f"Flow UID: {uid}") - print("\nCredentials may have been rotated.") - print() - return - - except Exception as first_error: - # Failed - might be because uid is a record UID, not flow UID - # Try to get the active workflow for this record + + # Try as record UID or name first + record_uid = None + record = None + if uid in params.record_cache: + record_uid = uid + else: + for cache_uid in params.record_cache: + rec = vault.KeeperRecord.load(params, cache_uid) + if rec and rec.title == uid: + record_uid = cache_uid + break + + if record_uid: + # Record found — look up active workflow, then end it + record = vault.KeeperRecord.load(params, record_uid) try: - # Query workflow state by record UID state_query = workflow_pb2.WorkflowState() - state_query.resource.CopyFrom(create_record_ref(uid_bytes)) - + state_query.resource.CopyFrom( + create_record_ref(utils.base64_url_decode(record_uid), record.title if record else '') + ) workflow_state = _post_request_to_router( - params, - 'get_workflow_state', - rq_proto=state_query, + params, 'get_workflow_state', rq_proto=state_query, rs_type=workflow_pb2.WorkflowState ) - if not workflow_state or not workflow_state.flowUid: - raise CommandError('', f'No active workflow found for this record. ' - f'The workflow may have already ended or never started.') - - # Found the flow UID, now end it + raise CommandError('', 'No active workflow found for this record. ' + 'The workflow may have already ended or never started.') + flow_ref = create_workflow_ref(workflow_state.flowUid) - response = _post_request_to_router( - params, - 'end_workflow', - rq_proto=flow_ref - ) - - # Success + _post_request_to_router(params, 'end_workflow', rq_proto=flow_ref) + flow_uid_str = utils.base64_url_encode(workflow_state.flowUid) if kwargs.get('format') == 'json': result = { 'status': 'success', 'flow_uid': flow_uid_str, - 'record_uid': uid, - 'record_name': resolve_record_name(params, workflow_state.resource), + 'record_uid': record_uid, + 'record_name': record.title if record else '', 'action': 'ended' } print(json.dumps(result, indent=2)) else: print(f"\n{bcolors.OKGREEN}✓ Workflow ended (checked in){bcolors.ENDC}\n") - print(f"Record: {resolve_record_name(params, workflow_state.resource)}") + if record: + print(f"Record: {record.title} ({record_uid})") + else: + print(f"Record: {record_uid}") print(f"Flow UID: {flow_uid_str}") print("\nCredentials may have been rotated.") print() - except CommandError: raise - except Exception as second_error: - # Both attempts failed - show the original error - raise CommandError('', f'Failed to end workflow: {str(first_error)}') + except Exception as e: + raise CommandError('', f'Failed to end workflow: {str(e)}') + else: + # Treat as flow UID + try: + uid_bytes = utils.base64_url_decode(uid) + ref = create_workflow_ref(uid_bytes) + _post_request_to_router(params, 'end_workflow', rq_proto=ref) + + if kwargs.get('format') == 'json': + result = {'status': 'success', 'flow_uid': uid, 'action': 'ended'} + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.OKGREEN}✓ Workflow ended (checked in){bcolors.ENDC}\n") + print(f"Flow UID: {uid}") + print("\nCredentials may have been rotated.") + print() + except Exception as e: + raise CommandError('', f'Failed to end workflow: {str(e)}') # ============================================================================ From 21c3e1e781722b13b25606d05ef38c0183a880ea Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Mon, 16 Feb 2026 17:44:11 +0530 Subject: [PATCH 06/11] Separate pending and approved requests and update workflow with escalation delay --- .../commands/workflow/workflow_commands.py | 108 +++++++++++++----- keepercommander/proto/workflow_pb2.py | 76 ++++++------ keepercommander/proto/workflow_pb2.pyi | 13 ++- 3 files changed, 127 insertions(+), 70 deletions(-) diff --git a/keepercommander/commands/workflow/workflow_commands.py b/keepercommander/commands/workflow/workflow_commands.py index 7a4480366..87c83ad07 100644 --- a/keepercommander/commands/workflow/workflow_commands.py +++ b/keepercommander/commands/workflow/workflow_commands.py @@ -22,7 +22,7 @@ from datetime import datetime from typing import List -from ..base import Command, GroupCommand +from ..base import Command, GroupCommand, dump_report_data from ..pam.router_helper import _post_request_to_router from ...display import bcolors from ...error import CommandError @@ -719,6 +719,9 @@ def execute(self, params: KeeperParams, **kwargs): # Add approvers for approver in response.approvers: approver_info = {'escalation': approver.escalation} + if approver.escalationAfterMs: + approver_info['escalation_after'] = format_duration_from_milliseconds(approver.escalationAfterMs) + approver_info['escalation_after_ms'] = approver.escalationAfterMs if approver.HasField('user'): approver_info['type'] = 'user' approver_info['email'] = approver.user @@ -758,6 +761,8 @@ def execute(self, params: KeeperParams, **kwargs): print(f"\n{bcolors.BOLD}Approvers ({len(response.approvers)}):{bcolors.ENDC}") for idx, approver in enumerate(response.approvers, 1): escalation = ' (Escalation)' if approver.escalation else '' + if approver.escalation and approver.escalationAfterMs: + escalation += f' — after {format_duration_from_milliseconds(approver.escalationAfterMs)}' if approver.HasField('user'): print(f" {idx}. User: {approver.user}{escalation}") elif approver.HasField('userId'): @@ -847,11 +852,13 @@ class WorkflowAddApproversCommand(Command): Add approvers to a workflow. Approvers are users or teams who can approve access requests. - You can mark approvers as "escalated" for handling delayed approvals. + You can mark approvers as "escalated" for handling delayed approvals, + with an optional auto-escalation delay. Example: pam workflow add-approver --user alice@company.com pam workflow add-approver --team --escalation + pam workflow add-approver --user bob@company.com --escalation --escalation-after 30m """ parser = argparse.ArgumentParser(prog='pam workflow add-approver', description='Add approvers to a workflow') @@ -861,6 +868,8 @@ class WorkflowAddApproversCommand(Command): parser.add_argument('-t', '--team', action='append', help='Team name or UID to add as approver (can specify multiple times)') parser.add_argument('-e', '--escalation', action='store_true', help='Mark as escalation approver') + parser.add_argument('--escalation-after', dest='escalation_after', + help='Auto-escalate after duration (e.g. 30m, 1h, 2d). Requires --escalation') parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], default='table', help='Output format') @@ -873,7 +882,17 @@ def execute(self, params: KeeperParams, **kwargs): users = kwargs.get('user') or [] teams = kwargs.get('team') or [] is_escalation = kwargs.get('escalation', False) - + escalation_after = kwargs.get('escalation_after') + + if escalation_after and not is_escalation: + raise CommandError('', '--escalation-after requires --escalation flag') + + escalation_after_ms = 0 + if escalation_after: + escalation_after_ms = parse_duration_to_milliseconds(escalation_after) + if not escalation_after_ms: + raise CommandError('', f'Invalid escalation duration: {escalation_after}. Use format like 30m, 1h, 2d') + if not users and not teams: raise CommandError('', 'Must specify at least one --user or --team') @@ -900,6 +919,8 @@ def execute(self, params: KeeperParams, **kwargs): approver = workflow_pb2.WorkflowApprover() approver.user = user_email approver.escalation = is_escalation + if escalation_after_ms: + approver.escalationAfterMs = escalation_after_ms config.approvers.append(approver) # Add team approvers (accepts team UID or team name) @@ -908,6 +929,8 @@ def execute(self, params: KeeperParams, **kwargs): approver = workflow_pb2.WorkflowApprover() approver.teamUid = utils.base64_url_decode(resolved_team_uid) approver.escalation = is_escalation + if escalation_after_ms: + approver.escalationAfterMs = escalation_after_ms config.approvers.append(approver) # Make API call @@ -924,7 +947,8 @@ def execute(self, params: KeeperParams, **kwargs): 'record_uid': record_uid, 'record_name': record.title, 'approvers_added': len(users) + len(teams), - 'escalation': is_escalation + 'escalation': is_escalation, + 'escalation_after_ms': escalation_after_ms or None } print(json.dumps(result, indent=2)) else: @@ -933,6 +957,8 @@ def execute(self, params: KeeperParams, **kwargs): print(f"Added {len(users) + len(teams)} approver(s)") if is_escalation: print("Type: Escalation approver") + if escalation_after_ms: + print(f"Escalation after: {format_duration_from_milliseconds(escalation_after_ms)}") print() except Exception as e: @@ -1248,20 +1274,47 @@ def execute(self, params: KeeperParams, **kwargs): 'get_approval_requests', rs_type=workflow_pb2.ApprovalRequests ) - + if not response or not response.workflows: if kwargs.get('format') == 'json': print(json.dumps({'requests': []}, indent=2)) else: - print(f"\n{bcolors.WARNING}No pending approval requests{bcolors.ENDC}\n") + print(f"\n{bcolors.WARNING}No approval requests{bcolors.ENDC}\n") return - + + # Determine status for each workflow + # Items with startedOn are approved/active + # Items without startedOn need a state check for approved-but-not-started + def _resolve_status(wf): + if wf.startedOn: + return 'Approved' + try: + st = workflow_pb2.WorkflowState() + st.flowUid = wf.flowUid + ws = _post_request_to_router( + params, 'get_workflow_state', rq_proto=st, + rs_type=workflow_pb2.WorkflowState + ) + if ws and ws.status and ws.status.stage in ( + workflow_pb2.WS_READY_TO_START, workflow_pb2.WS_STARTED + ): + return 'Approved' + except Exception: + pass + return 'Pending' + + # Resolve status once per workflow + wf_data = [] + for wf in response.workflows: + status = _resolve_status(wf) + wf_data.append((wf, status)) + if kwargs.get('format') == 'json': result = { 'requests': [ { 'flow_uid': utils.base64_url_encode(wf.flowUid), - 'user_id': wf.userId, + 'status': status, 'requested_by': resolve_user_name(params, wf.userId), 'record_uid': utils.base64_url_encode(wf.resource.value), 'record_name': resolve_record_name(params, wf.resource), @@ -1270,34 +1323,27 @@ def execute(self, params: KeeperParams, **kwargs): 'duration': format_duration_from_milliseconds(wf.expiresOn - wf.startedOn) if wf.expiresOn and wf.startedOn else None, 'reason': wf.reason.decode('utf-8') if wf.reason else None, 'external_ref': wf.externalRef.decode('utf-8') if wf.externalRef else None, - 'mfa_verified': wf.mfaVerified or None } - for wf in response.workflows + for wf, status in wf_data ] } print(json.dumps(result, indent=2)) else: - print(f"\n{bcolors.OKBLUE}Pending Approval Requests{bcolors.ENDC}\n") - for idx, wf in enumerate(response.workflows, 1): - print(f"{idx}. Record: {format_record_label(params, wf.resource)}") - print(f" Flow UID: {utils.base64_url_encode(wf.flowUid)}") - print(f" Requested by: {resolve_user_name(params, wf.userId)}") - if wf.startedOn: - started = datetime.fromtimestamp(wf.startedOn / 1000) - print(f" Requested on: {started.strftime('%Y-%m-%d %H:%M:%S')}") - if wf.expiresOn and wf.startedOn: - print(f" Duration: {format_duration_from_milliseconds(wf.expiresOn - wf.startedOn)}") - if wf.expiresOn: - expires = datetime.fromtimestamp(wf.expiresOn / 1000) - print(f" Expires: {expires.strftime('%Y-%m-%d %H:%M:%S')}") - if wf.reason: - print(f" Reason: {wf.reason.decode('utf-8')}") - if wf.externalRef: - print(f" Ticket: {wf.externalRef.decode('utf-8')}") - if wf.mfaVerified: - print(f" MFA Verified: Yes") - print() - + rows = [] + for wf, status in wf_data: + record_uid = utils.base64_url_encode(wf.resource.value) if wf.resource.value else '' + record_name = resolve_record_name(params, wf.resource) + flow_uid = utils.base64_url_encode(wf.flowUid) + requested_by = resolve_user_name(params, wf.userId) + started = datetime.fromtimestamp(wf.startedOn / 1000).strftime('%Y-%m-%d %H:%M:%S') if wf.startedOn else '' + expires = datetime.fromtimestamp(wf.expiresOn / 1000).strftime('%Y-%m-%d %H:%M:%S') if wf.expiresOn else '' + duration = format_duration_from_milliseconds(wf.expiresOn - wf.startedOn) if wf.expiresOn and wf.startedOn else '' + rows.append([status, record_name, record_uid, flow_uid, requested_by, started, expires, duration]) + headers = ['Status', 'Record Name', 'Record UID', 'Flow UID', 'Requested By', 'Started', 'Expires', 'Duration'] + print() + dump_report_data(rows, headers=headers, sort_by=0) + print() + except Exception as e: raise CommandError('', f'Failed to get approval requests: {str(e)}') diff --git a/keepercommander/proto/workflow_pb2.py b/keepercommander/proto/workflow_pb2.py index dc6dfd073..e4e4059e6 100644 --- a/keepercommander/proto/workflow_pb2.py +++ b/keepercommander/proto/workflow_pb2.py @@ -13,7 +13,7 @@ from . import GraphSync_pb2 as GraphSync__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0eworkflow.proto\x12\x08Workflow\x1a\x0fGraphSync.proto\"g\n\x10WorkflowApprover\x12\x0e\n\x04user\x18\x01 \x01(\tH\x00\x12\x10\n\x06userId\x18\x02 \x01(\x05H\x00\x12\x11\n\x07teamUid\x18\x03 \x01(\x0cH\x00\x12\x12\n\nescalation\x18\x04 \x01(\x08\x42\n\n\x08\x61pprover\"\x9d\x02\n\x12WorkflowParameters\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x17\n\x0f\x61pprovalsNeeded\x18\x02 \x01(\x05\x12\x16\n\x0e\x63heckoutNeeded\x18\x03 \x01(\x08\x12\x1d\n\x15startAccessOnApproval\x18\x04 \x01(\x08\x12\x15\n\rrequireReason\x18\x05 \x01(\x08\x12\x15\n\rrequireTicket\x18\x06 \x01(\x08\x12\x12\n\nrequireMFA\x18\x07 \x01(\x08\x12\x14\n\x0c\x61\x63\x63\x65ssLength\x18\x08 \x01(\x03\x12\x34\n\x0c\x61llowedTimes\x18\t \x01(\x0b\x32\x1e.Workflow.TemporalAccessFilter\"\x84\x01\n\x0eWorkflowConfig\x12\x30\n\nparameters\x18\x01 \x01(\x0b\x32\x1c.Workflow.WorkflowParameters\x12-\n\tapprovers\x18\x02 \x03(\x0b\x32\x1a.Workflow.WorkflowApprover\x12\x11\n\tcreatedOn\x18\x03 \x01(\x03\"\xd0\x01\n\x0eWorkflowStatus\x12&\n\x05stage\x18\x01 \x01(\x0e\x32\x17.Workflow.WorkflowStage\x12-\n\nconditions\x18\x02 \x03(\x0e\x32\x19.Workflow.AccessCondition\x12.\n\napprovedBy\x18\x03 \x03(\x0b\x32\x1a.Workflow.WorkflowApproval\x12\x11\n\tstartedOn\x18\x04 \x01(\x03\x12\x11\n\texpiresOn\x18\x05 \x01(\x03\x12\x11\n\tescalated\x18\x06 \x01(\x08\"\xbd\x01\n\x0fWorkflowProcess\x12\x0f\n\x07\x66lowUid\x18\x01 \x01(\x0c\x12\x0e\n\x06userId\x18\x02 \x01(\x03\x12)\n\x08resource\x18\x03 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x11\n\tstartedOn\x18\x04 \x01(\x03\x12\x11\n\texpiresOn\x18\x05 \x01(\x03\x12\x0e\n\x06reason\x18\x06 \x01(\x0c\x12\x13\n\x0bmfaVerified\x18\x07 \x01(\x08\x12\x13\n\x0b\x65xternalRef\x18\x08 \x01(\x0c\"U\n\x10WorkflowApproval\x12\x0e\n\x06userId\x18\x01 \x01(\x03\x12\x0c\n\x04user\x18\x02 \x01(\t\x12\x0f\n\x07\x66lowUid\x18\x03 \x01(\x0c\x12\x12\n\napprovedOn\x18\x04 \x01(\x03\"\xcb\x01\n\x0fWorkflowContext\x12\x30\n\x0eworkflowConfig\x18\x01 \x01(\x0b\x32\x18.Workflow.WorkflowConfig\x12+\n\x08workflow\x18\x02 \x01(\x0b\x32\x19.Workflow.WorkflowProcess\x12-\n\tapprovals\x18\x03 \x03(\x0b\x32\x1a.Workflow.WorkflowApproval\x12*\n\x07\x62locker\x18\x04 \x01(\x0b\x32\x19.Workflow.WorkflowProcess\"u\n\rWorkflowState\x12\x0f\n\x07\x66lowUid\x18\x01 \x01(\x0c\x12)\n\x08resource\x18\x02 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12(\n\x06status\x18\x03 \x01(\x0b\x32\x18.Workflow.WorkflowStatus\"b\n\x15WorkflowAccessRequest\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x0e\n\x06reason\x18\x02 \x01(\x0c\x12\x0e\n\x06ticket\x18\x03 \x01(\x0c\"i\n\x18WorkflowApprovalOrDenial\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x0c\n\x04\x64\x65ny\x18\x02 \x01(\x08\x12\x14\n\x0c\x64\x65nialReason\x18\x03 \x01(\t\"=\n\x0fUserAccessState\x12*\n\tworkflows\x18\x01 \x03(\x0b\x32\x17.Workflow.WorkflowState\"@\n\x10\x41pprovalRequests\x12,\n\tworkflows\x18\x01 \x03(\x0b\x32\x19.Workflow.WorkflowProcess\"4\n\x0eTimeOfDayRange\x12\x11\n\tstartTime\x18\x01 \x01(\x05\x12\x0f\n\x07\x65ndTime\x18\x02 \x01(\x05\"\x80\x01\n\x14TemporalAccessFilter\x12,\n\ntimeRanges\x18\x01 \x03(\x0b\x32\x18.Workflow.TimeOfDayRange\x12(\n\x0b\x61llowedDays\x18\x02 \x03(\x0e\x32\x13.Workflow.DayOfWeek\x12\x10\n\x08timeZone\x18\x03 \x01(\t\"#\n\x0f\x41uthorizedUsers\x12\x10\n\x08username\x18\x01 \x03(\t*[\n\rWorkflowStage\x12\x15\n\x11WS_READY_TO_START\x10\x00\x12\x0e\n\nWS_STARTED\x10\x01\x12\x13\n\x0fWS_NEEDS_ACTION\x10\x02\x12\x0e\n\nWS_WAITING\x10\x03*i\n\x0f\x41\x63\x63\x65ssCondition\x12\x0f\n\x0b\x41\x43_APPROVAL\x10\x00\x12\x0e\n\nAC_CHECKIN\x10\x01\x12\n\n\x06\x41\x43_MFA\x10\x02\x12\x0b\n\x07\x41\x43_TIME\x10\x03\x12\r\n\tAC_REASON\x10\x04\x12\r\n\tAC_TICKET\x10\x05*\x84\x01\n\tDayOfWeek\x12\x1b\n\x17\x44\x41Y_OF_WEEK_UNSPECIFIED\x10\x00\x12\n\n\x06MONDAY\x10\x01\x12\x0b\n\x07TUESDAY\x10\x02\x12\r\n\tWEDNESDAY\x10\x03\x12\x0c\n\x08THURSDAY\x10\x04\x12\n\n\x06\x46RIDAY\x10\x05\x12\x0c\n\x08SATURDAY\x10\x06\x12\n\n\x06SUNDAY\x10\x07\x42$\n\x18\x63om.keepersecurity.protoB\x08Workflowb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0eworkflow.proto\x12\x08Workflow\x1a\x0fGraphSync.proto\"\x82\x01\n\x10WorkflowApprover\x12\x0e\n\x04user\x18\x01 \x01(\tH\x00\x12\x10\n\x06userId\x18\x02 \x01(\x05H\x00\x12\x11\n\x07teamUid\x18\x03 \x01(\x0cH\x00\x12\x12\n\nescalation\x18\x04 \x01(\x08\x12\x19\n\x11\x65scalationAfterMs\x18\x05 \x01(\x03\x42\n\n\x08\x61pprover\"\x9d\x02\n\x12WorkflowParameters\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x17\n\x0f\x61pprovalsNeeded\x18\x02 \x01(\x05\x12\x16\n\x0e\x63heckoutNeeded\x18\x03 \x01(\x08\x12\x1d\n\x15startAccessOnApproval\x18\x04 \x01(\x08\x12\x15\n\rrequireReason\x18\x05 \x01(\x08\x12\x15\n\rrequireTicket\x18\x06 \x01(\x08\x12\x12\n\nrequireMFA\x18\x07 \x01(\x08\x12\x14\n\x0c\x61\x63\x63\x65ssLength\x18\x08 \x01(\x03\x12\x34\n\x0c\x61llowedTimes\x18\t \x01(\x0b\x32\x1e.Workflow.TemporalAccessFilter\"\x84\x01\n\x0eWorkflowConfig\x12\x30\n\nparameters\x18\x01 \x01(\x0b\x32\x1c.Workflow.WorkflowParameters\x12-\n\tapprovers\x18\x02 \x03(\x0b\x32\x1a.Workflow.WorkflowApprover\x12\x11\n\tcreatedOn\x18\x03 \x01(\x03\"\xd0\x01\n\x0eWorkflowStatus\x12&\n\x05stage\x18\x01 \x01(\x0e\x32\x17.Workflow.WorkflowStage\x12-\n\nconditions\x18\x02 \x03(\x0e\x32\x19.Workflow.AccessCondition\x12.\n\napprovedBy\x18\x03 \x03(\x0b\x32\x1a.Workflow.WorkflowApproval\x12\x11\n\tstartedOn\x18\x04 \x01(\x03\x12\x11\n\texpiresOn\x18\x05 \x01(\x03\x12\x11\n\tescalated\x18\x06 \x01(\x08\"\xbd\x01\n\x0fWorkflowProcess\x12\x0f\n\x07\x66lowUid\x18\x01 \x01(\x0c\x12\x0e\n\x06userId\x18\x02 \x01(\x03\x12)\n\x08resource\x18\x03 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x11\n\tstartedOn\x18\x04 \x01(\x03\x12\x11\n\texpiresOn\x18\x05 \x01(\x03\x12\x0e\n\x06reason\x18\x06 \x01(\x0c\x12\x13\n\x0bmfaVerified\x18\x07 \x01(\x08\x12\x13\n\x0b\x65xternalRef\x18\x08 \x01(\x0c\"U\n\x10WorkflowApproval\x12\x0e\n\x06userId\x18\x01 \x01(\x03\x12\x0c\n\x04user\x18\x02 \x01(\t\x12\x0f\n\x07\x66lowUid\x18\x03 \x01(\x0c\x12\x12\n\napprovedOn\x18\x04 \x01(\x03\"\xcb\x01\n\x0fWorkflowContext\x12\x30\n\x0eworkflowConfig\x18\x01 \x01(\x0b\x32\x18.Workflow.WorkflowConfig\x12+\n\x08workflow\x18\x02 \x01(\x0b\x32\x19.Workflow.WorkflowProcess\x12-\n\tapprovals\x18\x03 \x03(\x0b\x32\x1a.Workflow.WorkflowApproval\x12*\n\x07\x62locker\x18\x04 \x01(\x0b\x32\x19.Workflow.WorkflowProcess\"u\n\rWorkflowState\x12\x0f\n\x07\x66lowUid\x18\x01 \x01(\x0c\x12)\n\x08resource\x18\x02 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12(\n\x06status\x18\x03 \x01(\x0b\x32\x18.Workflow.WorkflowStatus\"b\n\x15WorkflowAccessRequest\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x0e\n\x06reason\x18\x02 \x01(\x0c\x12\x0e\n\x06ticket\x18\x03 \x01(\x0c\"i\n\x18WorkflowApprovalOrDenial\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x0c\n\x04\x64\x65ny\x18\x02 \x01(\x08\x12\x14\n\x0c\x64\x65nialReason\x18\x03 \x01(\t\"=\n\x0fUserAccessState\x12*\n\tworkflows\x18\x01 \x03(\x0b\x32\x17.Workflow.WorkflowState\"@\n\x10\x41pprovalRequests\x12,\n\tworkflows\x18\x01 \x03(\x0b\x32\x19.Workflow.WorkflowProcess\"4\n\x0eTimeOfDayRange\x12\x11\n\tstartTime\x18\x01 \x01(\x05\x12\x0f\n\x07\x65ndTime\x18\x02 \x01(\x05\"\x80\x01\n\x14TemporalAccessFilter\x12,\n\ntimeRanges\x18\x01 \x03(\x0b\x32\x18.Workflow.TimeOfDayRange\x12(\n\x0b\x61llowedDays\x18\x02 \x03(\x0e\x32\x13.Workflow.DayOfWeek\x12\x10\n\x08timeZone\x18\x03 \x01(\t\"#\n\x0f\x41uthorizedUsers\x12\x10\n\x08username\x18\x01 \x03(\t*[\n\rWorkflowStage\x12\x15\n\x11WS_READY_TO_START\x10\x00\x12\x0e\n\nWS_STARTED\x10\x01\x12\x13\n\x0fWS_NEEDS_ACTION\x10\x02\x12\x0e\n\nWS_WAITING\x10\x03*i\n\x0f\x41\x63\x63\x65ssCondition\x12\x0f\n\x0b\x41\x43_APPROVAL\x10\x00\x12\x0e\n\nAC_CHECKIN\x10\x01\x12\n\n\x06\x41\x43_MFA\x10\x02\x12\x0b\n\x07\x41\x43_TIME\x10\x03\x12\r\n\tAC_REASON\x10\x04\x12\r\n\tAC_TICKET\x10\x05*\x84\x01\n\tDayOfWeek\x12\x1b\n\x17\x44\x41Y_OF_WEEK_UNSPECIFIED\x10\x00\x12\n\n\x06MONDAY\x10\x01\x12\x0b\n\x07TUESDAY\x10\x02\x12\r\n\tWEDNESDAY\x10\x03\x12\x0c\n\x08THURSDAY\x10\x04\x12\n\n\x06\x46RIDAY\x10\x05\x12\x0c\n\x08SATURDAY\x10\x06\x12\n\n\x06SUNDAY\x10\x07*9\n\x11\x41pprovalQueueKind\x12\x10\n\x0c\x41QK_APPROVAL\x10\x00\x12\x12\n\x0e\x41QK_ESCALATION\x10\x01\x42$\n\x18\x63om.keepersecurity.protoB\x08Workflowb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -21,40 +21,42 @@ if not _descriptor._USE_C_DESCRIPTORS: _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'\n\030com.keepersecurity.protoB\010Workflow' - _globals['_WORKFLOWSTAGE']._serialized_start=1946 - _globals['_WORKFLOWSTAGE']._serialized_end=2037 - _globals['_ACCESSCONDITION']._serialized_start=2039 - _globals['_ACCESSCONDITION']._serialized_end=2144 - _globals['_DAYOFWEEK']._serialized_start=2147 - _globals['_DAYOFWEEK']._serialized_end=2279 - _globals['_WORKFLOWAPPROVER']._serialized_start=45 - _globals['_WORKFLOWAPPROVER']._serialized_end=148 - _globals['_WORKFLOWPARAMETERS']._serialized_start=151 - _globals['_WORKFLOWPARAMETERS']._serialized_end=436 - _globals['_WORKFLOWCONFIG']._serialized_start=439 - _globals['_WORKFLOWCONFIG']._serialized_end=571 - _globals['_WORKFLOWSTATUS']._serialized_start=574 - _globals['_WORKFLOWSTATUS']._serialized_end=782 - _globals['_WORKFLOWPROCESS']._serialized_start=785 - _globals['_WORKFLOWPROCESS']._serialized_end=974 - _globals['_WORKFLOWAPPROVAL']._serialized_start=976 - _globals['_WORKFLOWAPPROVAL']._serialized_end=1061 - _globals['_WORKFLOWCONTEXT']._serialized_start=1064 - _globals['_WORKFLOWCONTEXT']._serialized_end=1267 - _globals['_WORKFLOWSTATE']._serialized_start=1269 - _globals['_WORKFLOWSTATE']._serialized_end=1386 - _globals['_WORKFLOWACCESSREQUEST']._serialized_start=1388 - _globals['_WORKFLOWACCESSREQUEST']._serialized_end=1486 - _globals['_WORKFLOWAPPROVALORDENIAL']._serialized_start=1488 - _globals['_WORKFLOWAPPROVALORDENIAL']._serialized_end=1593 - _globals['_USERACCESSSTATE']._serialized_start=1595 - _globals['_USERACCESSSTATE']._serialized_end=1656 - _globals['_APPROVALREQUESTS']._serialized_start=1658 - _globals['_APPROVALREQUESTS']._serialized_end=1722 - _globals['_TIMEOFDAYRANGE']._serialized_start=1724 - _globals['_TIMEOFDAYRANGE']._serialized_end=1776 - _globals['_TEMPORALACCESSFILTER']._serialized_start=1779 - _globals['_TEMPORALACCESSFILTER']._serialized_end=1907 - _globals['_AUTHORIZEDUSERS']._serialized_start=1909 - _globals['_AUTHORIZEDUSERS']._serialized_end=1944 + _globals['_WORKFLOWSTAGE']._serialized_start=1974 + _globals['_WORKFLOWSTAGE']._serialized_end=2065 + _globals['_ACCESSCONDITION']._serialized_start=2067 + _globals['_ACCESSCONDITION']._serialized_end=2172 + _globals['_DAYOFWEEK']._serialized_start=2175 + _globals['_DAYOFWEEK']._serialized_end=2307 + _globals['_APPROVALQUEUEKIND']._serialized_start=2309 + _globals['_APPROVALQUEUEKIND']._serialized_end=2366 + _globals['_WORKFLOWAPPROVER']._serialized_start=46 + _globals['_WORKFLOWAPPROVER']._serialized_end=176 + _globals['_WORKFLOWPARAMETERS']._serialized_start=179 + _globals['_WORKFLOWPARAMETERS']._serialized_end=464 + _globals['_WORKFLOWCONFIG']._serialized_start=467 + _globals['_WORKFLOWCONFIG']._serialized_end=599 + _globals['_WORKFLOWSTATUS']._serialized_start=602 + _globals['_WORKFLOWSTATUS']._serialized_end=810 + _globals['_WORKFLOWPROCESS']._serialized_start=813 + _globals['_WORKFLOWPROCESS']._serialized_end=1002 + _globals['_WORKFLOWAPPROVAL']._serialized_start=1004 + _globals['_WORKFLOWAPPROVAL']._serialized_end=1089 + _globals['_WORKFLOWCONTEXT']._serialized_start=1092 + _globals['_WORKFLOWCONTEXT']._serialized_end=1295 + _globals['_WORKFLOWSTATE']._serialized_start=1297 + _globals['_WORKFLOWSTATE']._serialized_end=1414 + _globals['_WORKFLOWACCESSREQUEST']._serialized_start=1416 + _globals['_WORKFLOWACCESSREQUEST']._serialized_end=1514 + _globals['_WORKFLOWAPPROVALORDENIAL']._serialized_start=1516 + _globals['_WORKFLOWAPPROVALORDENIAL']._serialized_end=1621 + _globals['_USERACCESSSTATE']._serialized_start=1623 + _globals['_USERACCESSSTATE']._serialized_end=1684 + _globals['_APPROVALREQUESTS']._serialized_start=1686 + _globals['_APPROVALREQUESTS']._serialized_end=1750 + _globals['_TIMEOFDAYRANGE']._serialized_start=1752 + _globals['_TIMEOFDAYRANGE']._serialized_end=1804 + _globals['_TEMPORALACCESSFILTER']._serialized_start=1807 + _globals['_TEMPORALACCESSFILTER']._serialized_end=1935 + _globals['_AUTHORIZEDUSERS']._serialized_start=1937 + _globals['_AUTHORIZEDUSERS']._serialized_end=1972 # @@protoc_insertion_point(module_scope) diff --git a/keepercommander/proto/workflow_pb2.pyi b/keepercommander/proto/workflow_pb2.pyi index ae967540f..410c64d35 100644 --- a/keepercommander/proto/workflow_pb2.pyi +++ b/keepercommander/proto/workflow_pb2.pyi @@ -34,6 +34,11 @@ class DayOfWeek(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): FRIDAY: _ClassVar[DayOfWeek] SATURDAY: _ClassVar[DayOfWeek] SUNDAY: _ClassVar[DayOfWeek] + +class ApprovalQueueKind(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + AQK_APPROVAL: _ClassVar[ApprovalQueueKind] + AQK_ESCALATION: _ClassVar[ApprovalQueueKind] WS_READY_TO_START: WorkflowStage WS_STARTED: WorkflowStage WS_NEEDS_ACTION: WorkflowStage @@ -52,18 +57,22 @@ THURSDAY: DayOfWeek FRIDAY: DayOfWeek SATURDAY: DayOfWeek SUNDAY: DayOfWeek +AQK_APPROVAL: ApprovalQueueKind +AQK_ESCALATION: ApprovalQueueKind class WorkflowApprover(_message.Message): - __slots__ = ("user", "userId", "teamUid", "escalation") + __slots__ = ("user", "userId", "teamUid", "escalation", "escalationAfterMs") USER_FIELD_NUMBER: _ClassVar[int] USERID_FIELD_NUMBER: _ClassVar[int] TEAMUID_FIELD_NUMBER: _ClassVar[int] ESCALATION_FIELD_NUMBER: _ClassVar[int] + ESCALATIONAFTERMS_FIELD_NUMBER: _ClassVar[int] user: str userId: int teamUid: bytes escalation: bool - def __init__(self, user: _Optional[str] = ..., userId: _Optional[int] = ..., teamUid: _Optional[bytes] = ..., escalation: _Optional[bool] = ...) -> None: ... + escalationAfterMs: int + def __init__(self, user: _Optional[str] = ..., userId: _Optional[int] = ..., teamUid: _Optional[bytes] = ..., escalation: _Optional[bool] = ..., escalationAfterMs: _Optional[int] = ...) -> None: ... class WorkflowParameters(_message.Message): __slots__ = ("resource", "approvalsNeeded", "checkoutNeeded", "startAccessOnApproval", "requireReason", "requireTicket", "requireMFA", "accessLength", "allowedTimes") From 50a02cb4abfc388978dd654575adaf1c7c13c66f Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Tue, 17 Feb 2026 12:11:39 +0530 Subject: [PATCH 07/11] Add table view for my-access command --- .../commands/workflow/workflow_commands.py | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/keepercommander/commands/workflow/workflow_commands.py b/keepercommander/commands/workflow/workflow_commands.py index 87c83ad07..53f8010b4 100644 --- a/keepercommander/commands/workflow/workflow_commands.py +++ b/keepercommander/commands/workflow/workflow_commands.py @@ -1120,7 +1120,7 @@ def execute(self, params: KeeperParams, **kwargs): if kwargs.get('format') == 'json': result = { - 'flow_uid': utils.base64_url_encode(response.flowUid), + 'flow_uid': utils.base64_url_encode(response.flowUid) if response.flowUid else None, 'record_uid': utils.base64_url_encode(response.resource.value), 'record_name': resolve_record_name(params, response.resource), 'stage': format_workflow_stage(response.status.stage), @@ -1140,7 +1140,8 @@ def execute(self, params: KeeperParams, **kwargs): else: print(f"\n{bcolors.OKBLUE}Workflow State{bcolors.ENDC}\n") print(f"Record: {format_record_label(params, response.resource)}") - print(f"Flow UID: {utils.base64_url_encode(response.flowUid)}") + if response.flowUid: + print(f"Flow UID: {utils.base64_url_encode(response.flowUid)}") print(f"Stage: {format_workflow_stage(response.status.stage)}") if response.status.conditions: print(f"Conditions: {format_access_conditions(response.status.conditions)}") @@ -1225,25 +1226,24 @@ def execute(self, params: KeeperParams, **kwargs): } print(json.dumps(result, indent=2)) else: - print(f"\n{bcolors.OKBLUE}Your Active Workflows{bcolors.ENDC}\n") - for idx, wf in enumerate(response.workflows, 1): - print(f"{idx}. Record: {format_record_label(params, wf.resource)}") - print(f" Flow UID: {utils.base64_url_encode(wf.flowUid)}") - print(f" Stage: {format_workflow_stage(wf.status.stage)}") - if wf.status.conditions: - print(f" Conditions: {format_access_conditions(wf.status.conditions)}") - if wf.status.escalated: - print(f" Escalated: Yes") - if wf.status.startedOn: - started = datetime.fromtimestamp(wf.status.startedOn / 1000) - print(f" Started: {started.strftime('%Y-%m-%d %H:%M:%S')}") - if wf.status.expiresOn: - expires = datetime.fromtimestamp(wf.status.expiresOn / 1000) - print(f" Expires: {expires.strftime('%Y-%m-%d %H:%M:%S')}") + rows = [] + for wf in response.workflows: + stage = format_workflow_stage(wf.status.stage) + record_name = resolve_record_name(params, wf.resource) + record_uid = utils.base64_url_encode(wf.resource.value) if wf.resource.value else '' + flow_uid = utils.base64_url_encode(wf.flowUid) if wf.flowUid else '' + conditions = format_access_conditions(wf.status.conditions) if wf.status.conditions else '' + started = datetime.fromtimestamp(wf.status.startedOn / 1000).strftime('%Y-%m-%d %H:%M:%S') if wf.status.startedOn else '' + expires = datetime.fromtimestamp(wf.status.expiresOn / 1000).strftime('%Y-%m-%d %H:%M:%S') if wf.status.expiresOn else '' + approved_by = '' if wf.status.approvedBy: approved_names = [a.user if a.user else resolve_user_name(params, a.userId) for a in wf.status.approvedBy] - print(f" Approved by: {', '.join(approved_names)}") - print() + approved_by = ', '.join(approved_names) + rows.append([stage, record_name, record_uid, flow_uid, approved_by, started, expires, conditions]) + headers = ['Stage', 'Record Name', 'Record UID', 'Flow UID', 'Approved By', 'Started', 'Expires', 'Conditions'] + print() + dump_report_data(rows, headers=headers) + print() except Exception as e: raise CommandError('', f'Failed to get user access state: {str(e)}') From 4745eb818411122dc46e35920f81c02d7f124cf8 Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Wed, 18 Feb 2026 15:04:22 +0530 Subject: [PATCH 08/11] Add MFA based tunnel or launch restrictions if respective workflow has MFA flag --- keepercommander/commands/pam_launch/launch.py | 9 +- .../pam_launch/terminal_connection.py | 5 + .../tunnel/port_forward/tunnel_helpers.py | 28 +- .../commands/tunnel_and_connections.py | 10 +- keepercommander/commands/workflow/__init__.py | 4 +- .../commands/workflow/workflow_commands.py | 250 +++++++++++++++++- keepercommander/proto/router_pb2.py | 138 +++++----- keepercommander/proto/router_pb2.pyi | 168 +++++++++--- keepercommander/proto/workflow_pb2.py | 28 +- keepercommander/proto/workflow_pb2.pyi | 14 + 10 files changed, 515 insertions(+), 139 deletions(-) diff --git a/keepercommander/commands/pam_launch/launch.py b/keepercommander/commands/pam_launch/launch.py index 55d1fe2da..456ba9370 100644 --- a/keepercommander/commands/pam_launch/launch.py +++ b/keepercommander/commands/pam_launch/launch.py @@ -284,11 +284,14 @@ def execute(self, params: KeeperParams, **kwargs): logging.debug(f"Found record: {record_uid}") - # Workflow access check — block if record requires checkout and user hasn't checked out + # Workflow access check and 2FA prompt try: - from ..workflow.workflow_commands import check_workflow_access - if not check_workflow_access(params, record_uid): + from ..workflow.workflow_commands import check_workflow_and_prompt_2fa + should_proceed, two_factor_value = check_workflow_and_prompt_2fa(params, record_uid) + if not should_proceed: return + if two_factor_value: + kwargs['two_factor_value'] = two_factor_value except ImportError: pass diff --git a/keepercommander/commands/pam_launch/terminal_connection.py b/keepercommander/commands/pam_launch/terminal_connection.py index 5e182b612..87be4dc55 100644 --- a/keepercommander/commands/pam_launch/terminal_connection.py +++ b/keepercommander/commands/pam_launch/terminal_connection.py @@ -1328,6 +1328,11 @@ def _open_terminal_webrtc_tunnel(params: KeeperParams, logging.debug("Using userSupplied credential type - user will provide credentials") # else: no credentialType - gateway uses pamMachine credentials directly (backward compatible) + # Add 2FA value if workflow requires MFA + two_factor_value = kwargs.get('two_factor_value') + if two_factor_value: + inputs['twoFactorValue'] = two_factor_value + # Router token is no longer extracted from cookies (removed in commit 338a9fda) # Router affinity is now handled server-side diff --git a/keepercommander/commands/tunnel/port_forward/tunnel_helpers.py b/keepercommander/commands/tunnel/port_forward/tunnel_helpers.py index 0e67930e7..4ea09e81a 100644 --- a/keepercommander/commands/tunnel/port_forward/tunnel_helpers.py +++ b/keepercommander/commands/tunnel/port_forward/tunnel_helpers.py @@ -1966,7 +1966,7 @@ def cleanup(self): logging.debug("TunnelSignalHandler cleaned up") def start_rust_tunnel(params, record_uid, gateway_uid, host, port, - seed, target_host, target_port, socks, trickle_ice=True, record_title=None, allow_supply_host=False): + seed, target_host, target_port, socks, trickle_ice=True, record_title=None, allow_supply_host=False, two_factor_value=None): """ Start a tunnel using Rust WebRTC with trickle ICE via HTTP POST and WebSocket responses. @@ -2284,23 +2284,29 @@ def start_rust_tunnel(params, record_uid, gateway_uid, host, port, } if trickle_ice and http_session is not None: offer_kwargs["http_session"] = http_session + + # Build tunnel inputs + inputs = { + "recordUid": record_uid, + "tubeId": commander_tube_id, + 'kind': 'start', + 'base64Nonce': base64_nonce, + 'conversationType': 'tunnel', + "data": encrypted_data, + "trickleICE": trickle_ice, + } + if two_factor_value: + inputs['twoFactorValue'] = two_factor_value + router_response = router_send_action_to_gateway( params=params, destination_gateway_uid_str=gateway_uid, gateway_action=GatewayActionWebRTCSession( conversation_id = conversation_id_original, - inputs={ - "recordUid": record_uid, - "tubeId": commander_tube_id, - 'kind': 'start', - 'base64Nonce': base64_nonce, - 'conversationType': 'tunnel', - "data": encrypted_data, - "trickleICE": trickle_ice, - } + inputs=inputs ), message_type=pam_pb2.CMT_CONNECT, - is_streaming=trickle_ice, # Streaming only for trickle ICE + is_streaming=trickle_ice, gateway_timeout=GATEWAY_TIMEOUT, **offer_kwargs ) diff --git a/keepercommander/commands/tunnel_and_connections.py b/keepercommander/commands/tunnel_and_connections.py index b8265e7bb..592bd5fb4 100644 --- a/keepercommander/commands/tunnel_and_connections.py +++ b/keepercommander/commands/tunnel_and_connections.py @@ -543,10 +543,12 @@ def execute(self, params, **kwargs): print(f"{bcolors.FAIL}Record {record_uid} not found.{bcolors.ENDC}") return - # Workflow access check — block if record requires checkout and user hasn't checked out + # Workflow access check and 2FA prompt + two_factor_value = None try: - from .workflow.workflow_commands import check_workflow_access - if not check_workflow_access(params, record_uid): + from .workflow.workflow_commands import check_workflow_and_prompt_2fa + should_proceed, two_factor_value = check_workflow_and_prompt_2fa(params, record_uid) + if not should_proceed: return except ImportError: pass @@ -646,7 +648,7 @@ def execute(self, params, **kwargs): # Use Rust WebRTC implementation with configurable trickle ICE trickle_ice = not no_trickle_ice - result = start_rust_tunnel(params, record_uid, gateway_uid, host, port, seed, target_host, target_port, socks, trickle_ice, record.title, allow_supply_host=allow_supply_host) + result = start_rust_tunnel(params, record_uid, gateway_uid, host, port, seed, target_host, target_port, socks, trickle_ice, record.title, allow_supply_host=allow_supply_host, two_factor_value=two_factor_value) if result and result.get("success"): # The helper will show endpoint table when local socket is actually listening diff --git a/keepercommander/commands/workflow/__init__.py b/keepercommander/commands/workflow/__init__.py index 0c02b1910..df4416aa8 100644 --- a/keepercommander/commands/workflow/__init__.py +++ b/keepercommander/commands/workflow/__init__.py @@ -21,7 +21,7 @@ Workflow commands are accessed via: pam workflow """ -__all__ = ['PAMWorkflowCommand', 'check_workflow_access'] +__all__ = ['PAMWorkflowCommand', 'check_workflow_access', 'check_workflow_and_prompt_2fa'] -from .workflow_commands import PAMWorkflowCommand, check_workflow_access +from .workflow_commands import PAMWorkflowCommand, check_workflow_access, check_workflow_and_prompt_2fa diff --git a/keepercommander/commands/workflow/workflow_commands.py b/keepercommander/commands/workflow/workflow_commands.py index 53f8010b4..0f5ca4b0a 100644 --- a/keepercommander/commands/workflow/workflow_commands.py +++ b/keepercommander/commands/workflow/workflow_commands.py @@ -177,7 +177,7 @@ def resolve_user_name(params: KeeperParams, user_id: int) -> str: return f'User ID {user_id}' -def check_workflow_access(params: KeeperParams, record_uid: str) -> bool: +def check_workflow_access(params: KeeperParams, record_uid: str): """ Check whether the current user has active checkout access to a PAM record. @@ -185,6 +185,7 @@ def check_workflow_access(params: KeeperParams, record_uid: str) -> bool: a PAM resource. It verifies: 1. Whether the record has a workflow configured. 2. If so, whether the user has an active checked-out session. + 3. Whether MFA is required for the workflow. If the user does not have access, a helpful message is printed guiding them through the workflow process. @@ -194,9 +195,13 @@ def check_workflow_access(params: KeeperParams, record_uid: str) -> bool: record_uid: Record UID string to check Returns: - True if access is allowed (no workflow, or user has active checkout). - False if access is blocked by workflow. + dict with keys: + 'allowed': bool — True if access is allowed + 'require_mfa': bool — True if the workflow requires 2FA + Returns {'allowed': True, 'require_mfa': False} if no workflow configured. + Returns {'allowed': False, 'require_mfa': False} if access is blocked. """ + result = {'allowed': True, 'require_mfa': False} try: # Step 1: Check if the record has a workflow configured record_uid_bytes = utils.base64_url_decode(record_uid) @@ -213,7 +218,11 @@ def check_workflow_access(params: KeeperParams, record_uid: str) -> bool: if config_response is None: # No workflow configured on this record — access is unrestricted - return True + return result + + # Check if MFA is required + if config_response.parameters and config_response.parameters.requireMFA: + result['require_mfa'] = True # Step 2: Workflow exists — check user's access state for this record state_rq = workflow_pb2.WorkflowState() @@ -230,35 +239,256 @@ def check_workflow_access(params: KeeperParams, record_uid: str) -> bool: if stage == workflow_pb2.WS_STARTED: # User has an active checkout — allow access - return True + return result if stage == workflow_pb2.WS_READY_TO_START: print(f"\n{bcolors.WARNING}Workflow access approved but not yet checked out.{bcolors.ENDC}") print(f"Run: {bcolors.OKBLUE}pam workflow start {record_uid}{bcolors.ENDC} to check out the record.\n") - return False + result['allowed'] = False + return result if stage == workflow_pb2.WS_WAITING: conditions = state_response.status.conditions cond_str = format_access_conditions(conditions) if conditions else 'approval' print(f"\n{bcolors.WARNING}Workflow access is pending: waiting for {cond_str}.{bcolors.ENDC}") print(f"Your request is being processed. Please wait for approval.\n") - return False + result['allowed'] = False + return result if stage == workflow_pb2.WS_NEEDS_ACTION: print(f"\n{bcolors.WARNING}Workflow requires additional action before access is granted.{bcolors.ENDC}") print(f"Run: {bcolors.OKBLUE}pam workflow state --record {record_uid}{bcolors.ENDC} to see details.\n") - return False + result['allowed'] = False + return result # No active workflow state — user hasn't requested access yet print(f"\n{bcolors.WARNING}This record is protected by a workflow.{bcolors.ENDC}") print(f"You must request access before connecting.") print(f"Run: {bcolors.OKBLUE}pam workflow request {record_uid}{bcolors.ENDC} to request access.\n") - return False + result['allowed'] = False + return result except Exception: # If workflow check fails (e.g. network issue), allow access. # The backend/gateway should still enforce restrictions server-side. - return True + return result + + +def check_workflow_and_prompt_2fa(params: KeeperParams, record_uid: str): + """ + Combined workflow access check and 2FA prompt for PAM connections/tunnels. + + Checks workflow access and prompts for 2FA if required. + Prints helpful messages if access is denied. + + Args: + params: KeeperParams instance + record_uid: Record UID to check + + Returns: + tuple: (should_proceed: bool, two_factor_value: str or None) + - (False, None): Access denied or 2FA failed - abort connection + - (True, None): Access allowed, no 2FA required - proceed + - (True, value): Access allowed, 2FA provided - proceed with value + """ + wf_result = check_workflow_access(params, record_uid) + if not wf_result.get('allowed', True): + return (False, None) + if wf_result.get('require_mfa', False): + two_factor_value = prompt_workflow_2fa(params) + if not two_factor_value: + return (False, None) + return (True, two_factor_value) + return (True, None) + + +def prompt_workflow_2fa(params: KeeperParams): + """ + Prompt the user for 2FA verification for a workflow-protected resource. + + Detects the user's configured 2FA methods and handles each type: + - TOTP: Prompts for code directly + - SMS: Sends SMS via router push, then prompts for code + - DUO: Sends DUO push via router, then prompts for code + - Security Key (WebAuthn): Gets challenge from router, authenticates with key + + Args: + params: KeeperParams instance with session info + + Returns: + str: The 2FA value to include in the connect payload, or None if cancelled/failed. + """ + import getpass + import json as _json + from ...proto import APIRequest_pb2, router_pb2 + from ... import api + + # List user's 2FA methods + try: + tfa_list = api.communicate_rest( + params, None, 'authentication/2fa_list', + rs_type=APIRequest_pb2.TwoFactorListResponse + ) + except Exception: + # Fall back to simple TOTP prompt if can't list methods + try: + code = getpass.getpass('2FA required. Enter TOTP code: ').strip() + return code if code else None + except (KeyboardInterrupt, EOFError): + return None + + if not tfa_list.channels: + print(f"{bcolors.FAIL}No 2FA methods configured on your account. Cannot proceed.{bcolors.ENDC}") + return None + + # Build a list of usable channels + supported_types = { + APIRequest_pb2.TWO_FA_CT_TOTP: 'TOTP (Authenticator App)', + APIRequest_pb2.TWO_FA_CT_SMS: 'SMS Text Message', + APIRequest_pb2.TWO_FA_CT_DUO: 'DUO Security', + APIRequest_pb2.TWO_FA_CT_WEBAUTHN: 'Security Key', + APIRequest_pb2.TWO_FA_CT_DNA: 'Keeper DNA (Watch)', + } + + channels = [] + for ch in tfa_list.channels: + if ch.channelType in supported_types: + channels.append(ch) + + if not channels: + print(f"{bcolors.FAIL}No supported 2FA methods found. Supported: TOTP, SMS, DUO, Security Key.{bcolors.ENDC}") + return None + + # If only one method, use it automatically + if len(channels) == 1: + selected = channels[0] + else: + # Let user pick + print(f"\n{bcolors.OKBLUE}2FA required. Select authentication method:{bcolors.ENDC}") + for idx, ch in enumerate(channels, 1): + name = supported_types.get(ch.channelType, 'Unknown') + extra = f' ({ch.channelName})' if ch.channelName else '' + print(f" {idx}. {name}{extra}") + print(f" q. Cancel") + try: + answer = input('Selection: ').strip() + except (KeyboardInterrupt, EOFError): + return None + if answer.lower() == 'q': + return None + try: + idx = int(answer) - 1 + if 0 <= idx < len(channels): + selected = channels[idx] + else: + print(f"{bcolors.FAIL}Invalid selection.{bcolors.ENDC}") + return None + except ValueError: + print(f"{bcolors.FAIL}Invalid selection.{bcolors.ENDC}") + return None + + # Handle based on channel type + channel_type = selected.channelType + + if channel_type == APIRequest_pb2.TWO_FA_CT_TOTP: + try: + code = getpass.getpass('Enter TOTP code: ').strip() + return code if code else None + except (KeyboardInterrupt, EOFError): + return None + + elif channel_type == APIRequest_pb2.TWO_FA_CT_SMS: + # Send SMS push via router + try: + push_rq = router_pb2.Router2FASendPushRequest() + push_rq.pushType = APIRequest_pb2.TWO_FA_PUSH_SMS + _post_request_to_router(params, '2fa_send_push', rq_proto=push_rq) + print(f"{bcolors.OKGREEN}SMS sent.{bcolors.ENDC}") + except Exception as e: + print(f"{bcolors.FAIL}Failed to send SMS: {e}{bcolors.ENDC}") + return None + try: + code = getpass.getpass('Enter SMS code: ').strip() + return code if code else None + except (KeyboardInterrupt, EOFError): + return None + + elif channel_type == APIRequest_pb2.TWO_FA_CT_DUO: + # Send DUO push via router + try: + push_rq = router_pb2.Router2FASendPushRequest() + push_rq.pushType = APIRequest_pb2.TWO_FA_PUSH_DUO_PUSH + _post_request_to_router(params, '2fa_send_push', rq_proto=push_rq) + print(f"{bcolors.OKGREEN}DUO push sent. Respond on your device, then enter the code.{bcolors.ENDC}") + except Exception as e: + print(f"{bcolors.FAIL}Failed to send DUO push: {e}{bcolors.ENDC}") + return None + try: + code = getpass.getpass('Enter DUO code: ').strip() + return code if code else None + except (KeyboardInterrupt, EOFError): + return None + + elif channel_type == APIRequest_pb2.TWO_FA_CT_DNA: + # Send Keeper DNA push via router + try: + push_rq = router_pb2.Router2FASendPushRequest() + push_rq.pushType = APIRequest_pb2.TWO_FA_PUSH_DNA + _post_request_to_router(params, '2fa_send_push', rq_proto=push_rq) + print(f"{bcolors.OKGREEN}Keeper DNA push sent. Approve on your watch, then enter the code.{bcolors.ENDC}") + except Exception as e: + print(f"{bcolors.FAIL}Failed to send DNA push: {e}{bcolors.ENDC}") + return None + try: + code = getpass.getpass('Enter DNA code: ').strip() + return code if code else None + except (KeyboardInterrupt, EOFError): + return None + + elif channel_type == APIRequest_pb2.TWO_FA_CT_WEBAUTHN: + # Get challenge from router, authenticate with security key + try: + challenge_rq = router_pb2.Router2FAGetWebAuthnChallengeRequest() + challenge_rs = _post_request_to_router( + params, '2fa_get_webauthn_challenge', rq_proto=challenge_rq, + rs_type=router_pb2.Router2FAGetWebAuthnChallengeResponse + ) + if not challenge_rs or not challenge_rs.challenge: + print(f"{bcolors.FAIL}Failed to get WebAuthn challenge from server.{bcolors.ENDC}") + return None + + challenge = _json.loads(challenge_rs.challenge) + + from ...yubikey.yubikey import yubikey_authenticate + print(f"\n{bcolors.OKBLUE}Touch the flashing Security key to authenticate...{bcolors.ENDC}\n") + response = yubikey_authenticate(challenge) + + if response: + signature = { + "id": response.id, + "rawId": utils.base64_url_encode(response.raw_id), + "response": { + "authenticatorData": utils.base64_url_encode(response.response.authenticator_data), + "clientDataJSON": response.response.client_data.b64, + "signature": utils.base64_url_encode(response.response.signature), + }, + "type": "public-key", + "clientExtensionResults": dict(response.client_extension_results) if response.client_extension_results else {} + } + return _json.dumps(signature) + else: + print(f"{bcolors.FAIL}Security key authentication failed or was cancelled.{bcolors.ENDC}") + return None + + except ImportError: + from ...yubikey import display_fido2_warning + display_fido2_warning() + return None + except Exception as e: + print(f"{bcolors.FAIL}Security key error: {e}{bcolors.ENDC}") + return None + + return None def parse_duration_to_milliseconds(duration_str: str) -> int: diff --git a/keepercommander/proto/router_pb2.py b/keepercommander/proto/router_pb2.py index eacef95b7..16b4301dc 100644 --- a/keepercommander/proto/router_pb2.py +++ b/keepercommander/proto/router_pb2.py @@ -14,68 +14,88 @@ from . import pam_pb2 as pam__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0crouter.proto\x12\x06Router\x1a\tpam.proto\"r\n\x0eRouterResponse\x12\x30\n\x0cresponseCode\x18\x01 \x01(\x0e\x32\x1a.Router.RouterResponseCode\x12\x14\n\x0c\x65rrorMessage\x18\x02 \x01(\t\x12\x18\n\x10\x65ncryptedPayload\x18\x03 \x01(\x0c\"\xaf\x01\n\x17RouterControllerMessage\x12/\n\x0bmessageType\x18\x01 \x01(\x0e\x32\x1a.PAM.ControllerMessageType\x12\x12\n\nmessageUid\x18\x02 \x01(\x0c\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x16\n\x0estreamResponse\x18\x04 \x01(\x08\x12\x0f\n\x07payload\x18\x05 \x01(\x0c\x12\x0f\n\x07timeout\x18\x06 \x01(\x05\"\xec\x01\n\x0eRouterUserAuth\x12\x17\n\x0ftransmissionKey\x18\x01 \x01(\x0c\x12\x14\n\x0csessionToken\x18\x02 \x01(\x0c\x12\x0e\n\x06userId\x18\x03 \x01(\x05\x12\x18\n\x10\x65nterpriseUserId\x18\x04 \x01(\x03\x12\x12\n\ndeviceName\x18\x05 \x01(\t\x12\x13\n\x0b\x64\x65viceToken\x18\x06 \x01(\x0c\x12\x17\n\x0f\x63lientVersionId\x18\x07 \x01(\x05\x12\x14\n\x0cneedUsername\x18\x08 \x01(\x08\x12\x10\n\x08username\x18\t \x01(\t\x12\x17\n\x0fmspEnterpriseId\x18\n \x01(\x05\"\x83\x02\n\x10RouterDeviceAuth\x12\x10\n\x08\x63lientId\x18\x01 \x01(\t\x12\x15\n\rclientVersion\x18\x02 \x01(\t\x12\x11\n\tsignature\x18\x03 \x01(\x0c\x12\x14\n\x0c\x65nterpriseId\x18\x04 \x01(\x05\x12\x0e\n\x06nodeId\x18\x05 \x01(\x03\x12\x12\n\ndeviceName\x18\x06 \x01(\t\x12\x13\n\x0b\x64\x65viceToken\x18\x07 \x01(\x0c\x12\x16\n\x0e\x63ontrollerName\x18\x08 \x01(\t\x12\x15\n\rcontrollerUid\x18\t \x01(\x0c\x12\x11\n\townerUser\x18\n \x01(\t\x12\x11\n\tchallenge\x18\x0b \x01(\t\x12\x0f\n\x07ownerId\x18\x0c \x01(\x05\"\x83\x01\n\x14RouterRecordRotation\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x18\n\x10\x63onfigurationUid\x18\x02 \x01(\x0c\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x13\n\x0bresourceUid\x18\x04 \x01(\x0c\x12\x12\n\nnoSchedule\x18\x05 \x01(\x08\"E\n\x1cRouterRecordRotationsRequest\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x05\x12\x0f\n\x07records\x18\x02 \x03(\x0c\"a\n\x1dRouterRecordRotationsResponse\x12/\n\trotations\x18\x01 \x03(\x0b\x32\x1c.Router.RouterRecordRotation\x12\x0f\n\x07hasMore\x18\x02 \x01(\x08\"\xed\x01\n\x12RouterRotationInfo\x12,\n\x06status\x18\x01 \x01(\x0e\x32\x1c.Router.RouterRotationStatus\x12\x18\n\x10\x63onfigurationUid\x18\x02 \x01(\x0c\x12\x13\n\x0bresourceUid\x18\x03 \x01(\x0c\x12\x0e\n\x06nodeId\x18\x04 \x01(\x03\x12\x15\n\rcontrollerUid\x18\x05 \x01(\x0c\x12\x16\n\x0e\x63ontrollerName\x18\x06 \x01(\t\x12\x12\n\nscriptName\x18\x07 \x01(\t\x12\x15\n\rpwdComplexity\x18\x08 \x01(\t\x12\x10\n\x08\x64isabled\x18\t \x01(\x08\"\x84\x02\n\x1bRouterRecordRotationRequest\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x10\n\x08revision\x18\x02 \x01(\x03\x12\x18\n\x10\x63onfigurationUid\x18\x03 \x01(\x0c\x12\x13\n\x0bresourceUid\x18\x04 \x01(\x0c\x12\x10\n\x08schedule\x18\x05 \x01(\t\x12\x18\n\x10\x65nterpriseUserId\x18\x06 \x01(\x03\x12\x15\n\rpwdComplexity\x18\x07 \x01(\x0c\x12\x10\n\x08\x64isabled\x18\x08 \x01(\x08\x12\x15\n\rremoteAddress\x18\t \x01(\t\x12\x17\n\x0f\x63lientVersionId\x18\n \x01(\x05\x12\x0c\n\x04noop\x18\x0b \x01(\x08\"<\n\x17UserRecordAccessRequest\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\"a\n\x18UserRecordAccessResponse\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x32\n\x0b\x61\x63\x63\x65ssLevel\x18\x02 \x01(\x0e\x32\x1d.Router.UserRecordAccessLevel\"8\n\x10RotationSchedule\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12\x10\n\x08schedule\x18\x02 \x01(\t\"\x90\x01\n\x12\x41piCallbackRequest\x12\x13\n\x0bresourceUid\x18\x01 \x01(\x0c\x12.\n\tschedules\x18\x02 \x03(\x0b\x32\x1b.Router.ApiCallbackSchedule\x12\x0b\n\x03url\x18\x03 \x01(\t\x12(\n\x0bserviceType\x18\x04 \x01(\x0e\x32\x13.Router.ServiceType\"5\n\x13\x41piCallbackSchedule\x12\x10\n\x08schedule\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\"@\n\x16RouterScheduledActions\x12\x10\n\x08schedule\x18\x01 \x01(\t\x12\x14\n\x0cresourceUids\x18\x02 \x03(\x0c\"Y\n\x1cRouterRecordsRotationRequest\x12\x39\n\x11rotationSchedules\x18\x01 \x03(\x0b\x32\x1e.Router.RouterScheduledActions\"\x85\x01\n\x14\x43onnectionParameters\x12\x15\n\rconnectionUid\x18\x01 \x01(\x0c\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\x12\x0e\n\x06userId\x18\x03 \x01(\x05\x12\x15\n\rcontrollerUid\x18\x04 \x01(\x0c\x12\x1c\n\x14\x63redentialsRecordUid\x18\x05 \x01(\x0c\"O\n\x1aValidateConnectionsRequest\x12\x31\n\x0b\x63onnections\x18\x01 \x03(\x0b\x32\x1c.Router.ConnectionParameters\"J\n\x1b\x43onnectionValidationFailure\x12\x15\n\rconnectionUid\x18\x01 \x01(\x0c\x12\x14\n\x0c\x65rrorMessage\x18\x02 \x01(\t\"]\n\x1bValidateConnectionsResponse\x12>\n\x11\x66\x61iledConnections\x18\x01 \x03(\x0b\x32#.Router.ConnectionValidationFailure\"1\n\x15GetEnforcementRequest\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\";\n\x0f\x45nforcementType\x12\x19\n\x11\x65nforcementTypeId\x18\x01 \x01(\x05\x12\r\n\x05value\x18\x02 \x01(\t\"p\n\x16GetEnforcementResponse\x12\x31\n\x10\x65nforcementTypes\x18\x01 \x03(\x0b\x32\x17.Router.EnforcementType\x12\x10\n\x08\x61\x64\x64OnIds\x18\x02 \x03(\x05\x12\x11\n\tisInTrial\x18\x03 \x01(\x08\"O\n\x17PEDMTOTPValidateRequest\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x14\n\x0c\x65nterpriseId\x18\x02 \x01(\x05\x12\x0c\n\x04\x63ode\x18\x03 \x01(\x05*\x98\x02\n\x12RouterResponseCode\x12\n\n\x06RRC_OK\x10\x00\x12\x15\n\x11RRC_GENERAL_ERROR\x10\x01\x12\x13\n\x0fRRC_NOT_ALLOWED\x10\x02\x12\x13\n\x0fRRC_BAD_REQUEST\x10\x03\x12\x0f\n\x0bRRC_TIMEOUT\x10\x04\x12\x11\n\rRRC_BAD_STATE\x10\x05\x12\x17\n\x13RRC_CONTROLLER_DOWN\x10\x06\x12\x16\n\x12RRC_WRONG_INSTANCE\x10\x07\x12+\n\'RRC_NOT_ALLOWED_ENFORCEMENT_NOT_ENABLED\x10\x08\x12\x33\n/RRC_NOT_ALLOWED_PAM_CONFIG_FEATURES_NOT_ENABLED\x10\t*k\n\x14RouterRotationStatus\x12\x0e\n\nRRS_ONLINE\x10\x00\x12\x13\n\x0fRRS_NO_ROTATION\x10\x01\x12\x15\n\x11RRS_NO_CONTROLLER\x10\x02\x12\x17\n\x13RRS_CONTROLLER_DOWN\x10\x03*}\n\x15UserRecordAccessLevel\x12\r\n\tRRAL_NONE\x10\x00\x12\r\n\tRRAL_READ\x10\x01\x12\x0e\n\nRRAL_SHARE\x10\x02\x12\r\n\tRRAL_EDIT\x10\x03\x12\x17\n\x13RRAL_EDIT_AND_SHARE\x10\x04\x12\x0e\n\nRRAL_OWNER\x10\x05*.\n\x0bServiceType\x12\x0f\n\x0bUNSPECIFIED\x10\x00\x12\x06\n\x02KA\x10\x01\x12\x06\n\x02\x42I\x10\x02\x42\"\n\x18\x63om.keepersecurity.protoB\x06Routerb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0crouter.proto\x12\x06Router\x1a\tpam.proto\x1a\x10\x41PIRequest.proto\"r\n\x0eRouterResponse\x12\x30\n\x0cresponseCode\x18\x01 \x01(\x0e\x32\x1a.Router.RouterResponseCode\x12\x14\n\x0c\x65rrorMessage\x18\x02 \x01(\t\x12\x18\n\x10\x65ncryptedPayload\x18\x03 \x01(\x0c\"\xaf\x01\n\x17RouterControllerMessage\x12/\n\x0bmessageType\x18\x01 \x01(\x0e\x32\x1a.PAM.ControllerMessageType\x12\x12\n\nmessageUid\x18\x02 \x01(\x0c\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x16\n\x0estreamResponse\x18\x04 \x01(\x08\x12\x0f\n\x07payload\x18\x05 \x01(\x0c\x12\x0f\n\x07timeout\x18\x06 \x01(\x05\"\x99\x02\n\x0eRouterUserAuth\x12\x17\n\x0ftransmissionKey\x18\x01 \x01(\x0c\x12\x14\n\x0csessionToken\x18\x02 \x01(\x0c\x12\x0e\n\x06userId\x18\x03 \x01(\x05\x12\x18\n\x10\x65nterpriseUserId\x18\x04 \x01(\x03\x12\x12\n\ndeviceName\x18\x05 \x01(\t\x12\x13\n\x0b\x64\x65viceToken\x18\x06 \x01(\x0c\x12\x17\n\x0f\x63lientVersionId\x18\x07 \x01(\x05\x12\x14\n\x0cneedUsername\x18\x08 \x01(\x08\x12\x10\n\x08username\x18\t \x01(\t\x12\x17\n\x0fmspEnterpriseId\x18\n \x01(\x05\x12\x13\n\x0bisPedmAdmin\x18\x0b \x01(\x08\x12\x16\n\x0emcEnterpriseId\x18\x0c \x01(\x05\"\x9d\x02\n\x10RouterDeviceAuth\x12\x10\n\x08\x63lientId\x18\x01 \x01(\t\x12\x15\n\rclientVersion\x18\x02 \x01(\t\x12\x11\n\tsignature\x18\x03 \x01(\x0c\x12\x14\n\x0c\x65nterpriseId\x18\x04 \x01(\x05\x12\x0e\n\x06nodeId\x18\x05 \x01(\x03\x12\x12\n\ndeviceName\x18\x06 \x01(\t\x12\x13\n\x0b\x64\x65viceToken\x18\x07 \x01(\x0c\x12\x16\n\x0e\x63ontrollerName\x18\x08 \x01(\t\x12\x15\n\rcontrollerUid\x18\t \x01(\x0c\x12\x11\n\townerUser\x18\n \x01(\t\x12\x11\n\tchallenge\x18\x0b \x01(\t\x12\x0f\n\x07ownerId\x18\x0c \x01(\x05\x12\x18\n\x10maxInstanceCount\x18\r \x01(\x05\"\x83\x01\n\x14RouterRecordRotation\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x18\n\x10\x63onfigurationUid\x18\x02 \x01(\x0c\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x13\n\x0bresourceUid\x18\x04 \x01(\x0c\x12\x12\n\nnoSchedule\x18\x05 \x01(\x08\"E\n\x1cRouterRecordRotationsRequest\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x05\x12\x0f\n\x07records\x18\x02 \x03(\x0c\"a\n\x1dRouterRecordRotationsResponse\x12/\n\trotations\x18\x01 \x03(\x0b\x32\x1c.Router.RouterRecordRotation\x12\x0f\n\x07hasMore\x18\x02 \x01(\x08\"\xed\x01\n\x12RouterRotationInfo\x12,\n\x06status\x18\x01 \x01(\x0e\x32\x1c.Router.RouterRotationStatus\x12\x18\n\x10\x63onfigurationUid\x18\x02 \x01(\x0c\x12\x13\n\x0bresourceUid\x18\x03 \x01(\x0c\x12\x0e\n\x06nodeId\x18\x04 \x01(\x03\x12\x15\n\rcontrollerUid\x18\x05 \x01(\x0c\x12\x16\n\x0e\x63ontrollerName\x18\x06 \x01(\t\x12\x12\n\nscriptName\x18\x07 \x01(\t\x12\x15\n\rpwdComplexity\x18\x08 \x01(\t\x12\x10\n\x08\x64isabled\x18\t \x01(\x08\"\xba\x02\n\x1bRouterRecordRotationRequest\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x10\n\x08revision\x18\x02 \x01(\x03\x12\x18\n\x10\x63onfigurationUid\x18\x03 \x01(\x0c\x12\x13\n\x0bresourceUid\x18\x04 \x01(\x0c\x12\x10\n\x08schedule\x18\x05 \x01(\t\x12\x18\n\x10\x65nterpriseUserId\x18\x06 \x01(\x03\x12\x15\n\rpwdComplexity\x18\x07 \x01(\x0c\x12\x10\n\x08\x64isabled\x18\x08 \x01(\x08\x12\x15\n\rremoteAddress\x18\t \x01(\t\x12\x17\n\x0f\x63lientVersionId\x18\n \x01(\x05\x12\x0c\n\x04noop\x18\x0b \x01(\x08\x12\x1e\n\x11saasConfiguration\x18\x0c \x01(\x0cH\x00\x88\x01\x01\x42\x14\n\x12_saasConfiguration\"<\n\x17UserRecordAccessRequest\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\"a\n\x18UserRecordAccessResponse\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x32\n\x0b\x61\x63\x63\x65ssLevel\x18\x02 \x01(\x0e\x32\x1d.Router.UserRecordAccessLevel\"M\n\x18UserRecordAccessRequests\x12\x31\n\x08requests\x18\x01 \x03(\x0b\x32\x1f.Router.UserRecordAccessRequest\"P\n\x19UserRecordAccessResponses\x12\x33\n\tresponses\x18\x01 \x03(\x0b\x32 .Router.UserRecordAccessResponse\"8\n\x10RotationSchedule\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12\x10\n\x08schedule\x18\x02 \x01(\t\"\x90\x01\n\x12\x41piCallbackRequest\x12\x13\n\x0bresourceUid\x18\x01 \x01(\x0c\x12.\n\tschedules\x18\x02 \x03(\x0b\x32\x1b.Router.ApiCallbackSchedule\x12\x0b\n\x03url\x18\x03 \x01(\t\x12(\n\x0bserviceType\x18\x04 \x01(\x0e\x32\x13.Router.ServiceType\"5\n\x13\x41piCallbackSchedule\x12\x10\n\x08schedule\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\"@\n\x16RouterScheduledActions\x12\x10\n\x08schedule\x18\x01 \x01(\t\x12\x14\n\x0cresourceUids\x18\x02 \x03(\x0c\"Y\n\x1cRouterRecordsRotationRequest\x12\x39\n\x11rotationSchedules\x18\x01 \x03(\x0b\x32\x1e.Router.RouterScheduledActions\"\x85\x01\n\x14\x43onnectionParameters\x12\x15\n\rconnectionUid\x18\x01 \x01(\x0c\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\x12\x0e\n\x06userId\x18\x03 \x01(\x05\x12\x15\n\rcontrollerUid\x18\x04 \x01(\x0c\x12\x1c\n\x14\x63redentialsRecordUid\x18\x05 \x01(\x0c\"O\n\x1aValidateConnectionsRequest\x12\x31\n\x0b\x63onnections\x18\x01 \x03(\x0b\x32\x1c.Router.ConnectionParameters\"J\n\x1b\x43onnectionValidationFailure\x12\x15\n\rconnectionUid\x18\x01 \x01(\x0c\x12\x14\n\x0c\x65rrorMessage\x18\x02 \x01(\t\"]\n\x1bValidateConnectionsResponse\x12>\n\x11\x66\x61iledConnections\x18\x01 \x03(\x0b\x32#.Router.ConnectionValidationFailure\"1\n\x15GetEnforcementRequest\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\";\n\x0f\x45nforcementType\x12\x19\n\x11\x65nforcementTypeId\x18\x01 \x01(\x05\x12\r\n\x05value\x18\x02 \x01(\t\"p\n\x16GetEnforcementResponse\x12\x31\n\x10\x65nforcementTypes\x18\x01 \x03(\x0b\x32\x17.Router.EnforcementType\x12\x10\n\x08\x61\x64\x64OnIds\x18\x02 \x03(\x05\x12\x11\n\tisInTrial\x18\x03 \x01(\x08\"O\n\x17PEDMTOTPValidateRequest\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x14\n\x0c\x65nterpriseId\x18\x02 \x01(\x05\x12\x0c\n\x04\x63ode\x18\x03 \x01(\x05\"H\n\x18GetPEDMAdminInfoResponse\x12\x13\n\x0bisPedmAdmin\x18\x01 \x01(\x08\x12\x17\n\x0fpedmAddonActive\x18\x02 \x01(\x08\"-\n\x12PAMNetworkSettings\x12\x17\n\x0f\x61llowedSettings\x18\x01 \x01(\x0c\"\xe4\x01\n\x1ePAMNetworkConfigurationRequest\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x38\n\x0fnetworkSettings\x18\x02 \x01(\x0b\x32\x1a.Router.PAMNetworkSettingsH\x00\x88\x01\x01\x12)\n\tresources\x18\x03 \x03(\x0b\x32\x16.PAM.PAMResourceConfig\x12\x36\n\trotations\x18\x04 \x03(\x0b\x32#.Router.RouterRecordRotationRequestB\x12\n\x10_networkSettings\"R\n\x1bPAMDiscoveryRulesSetRequest\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\x12\r\n\x05rules\x18\x02 \x01(\x0c\x12\x10\n\x08rulesKey\x18\x03 \x01(\x0c\"X\n\x18Router2FAValidateRequest\x12\x17\n\x0ftransmissionKey\x18\x01 \x01(\x0c\x12\x14\n\x0csessionToken\x18\x02 \x01(\x0c\x12\r\n\x05value\x18\x03 \x01(\t\"~\n\x18Router2FASendPushRequest\x12\x17\n\x0ftransmissionKey\x18\x01 \x01(\x0c\x12\x14\n\x0csessionToken\x18\x02 \x01(\x0c\x12\x33\n\x08pushType\x18\x03 \x01(\x0e\x32!.Authentication.TwoFactorPushType\"U\n$Router2FAGetWebAuthnChallengeRequest\x12\x17\n\x0ftransmissionKey\x18\x01 \x01(\x0c\x12\x14\n\x0csessionToken\x18\x02 \x01(\x0c\"P\n%Router2FAGetWebAuthnChallengeResponse\x12\x11\n\tchallenge\x18\x01 \x01(\t\x12\x14\n\x0c\x63\x61pabilities\x18\x02 \x03(\t*\x98\x02\n\x12RouterResponseCode\x12\n\n\x06RRC_OK\x10\x00\x12\x15\n\x11RRC_GENERAL_ERROR\x10\x01\x12\x13\n\x0fRRC_NOT_ALLOWED\x10\x02\x12\x13\n\x0fRRC_BAD_REQUEST\x10\x03\x12\x0f\n\x0bRRC_TIMEOUT\x10\x04\x12\x11\n\rRRC_BAD_STATE\x10\x05\x12\x17\n\x13RRC_CONTROLLER_DOWN\x10\x06\x12\x16\n\x12RRC_WRONG_INSTANCE\x10\x07\x12+\n\'RRC_NOT_ALLOWED_ENFORCEMENT_NOT_ENABLED\x10\x08\x12\x33\n/RRC_NOT_ALLOWED_PAM_CONFIG_FEATURES_NOT_ENABLED\x10\t*k\n\x14RouterRotationStatus\x12\x0e\n\nRRS_ONLINE\x10\x00\x12\x13\n\x0fRRS_NO_ROTATION\x10\x01\x12\x15\n\x11RRS_NO_CONTROLLER\x10\x02\x12\x17\n\x13RRS_CONTROLLER_DOWN\x10\x03*}\n\x15UserRecordAccessLevel\x12\r\n\tRRAL_NONE\x10\x00\x12\r\n\tRRAL_READ\x10\x01\x12\x0e\n\nRRAL_SHARE\x10\x02\x12\r\n\tRRAL_EDIT\x10\x03\x12\x17\n\x13RRAL_EDIT_AND_SHARE\x10\x04\x12\x0e\n\nRRAL_OWNER\x10\x05*.\n\x0bServiceType\x12\x0f\n\x0bUNSPECIFIED\x10\x00\x12\x06\n\x02KA\x10\x01\x12\x06\n\x02\x42I\x10\x02\x42\"\n\x18\x63om.keepersecurity.protoB\x06Routerb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'router_pb2', _globals) -if _descriptor._USE_C_DESCRIPTORS == False: - _globals['DESCRIPTOR']._options = None +if not _descriptor._USE_C_DESCRIPTORS: + _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'\n\030com.keepersecurity.protoB\006Router' - _globals['_ROUTERRESPONSECODE']._serialized_start=2911 - _globals['_ROUTERRESPONSECODE']._serialized_end=3191 - _globals['_ROUTERROTATIONSTATUS']._serialized_start=3193 - _globals['_ROUTERROTATIONSTATUS']._serialized_end=3300 - _globals['_USERRECORDACCESSLEVEL']._serialized_start=3302 - _globals['_USERRECORDACCESSLEVEL']._serialized_end=3427 - _globals['_SERVICETYPE']._serialized_start=3429 - _globals['_SERVICETYPE']._serialized_end=3475 - _globals['_ROUTERRESPONSE']._serialized_start=35 - _globals['_ROUTERRESPONSE']._serialized_end=149 - _globals['_ROUTERCONTROLLERMESSAGE']._serialized_start=152 - _globals['_ROUTERCONTROLLERMESSAGE']._serialized_end=327 - _globals['_ROUTERUSERAUTH']._serialized_start=330 - _globals['_ROUTERUSERAUTH']._serialized_end=566 - _globals['_ROUTERDEVICEAUTH']._serialized_start=569 - _globals['_ROUTERDEVICEAUTH']._serialized_end=828 - _globals['_ROUTERRECORDROTATION']._serialized_start=831 - _globals['_ROUTERRECORDROTATION']._serialized_end=962 - _globals['_ROUTERRECORDROTATIONSREQUEST']._serialized_start=964 - _globals['_ROUTERRECORDROTATIONSREQUEST']._serialized_end=1033 - _globals['_ROUTERRECORDROTATIONSRESPONSE']._serialized_start=1035 - _globals['_ROUTERRECORDROTATIONSRESPONSE']._serialized_end=1132 - _globals['_ROUTERROTATIONINFO']._serialized_start=1135 - _globals['_ROUTERROTATIONINFO']._serialized_end=1372 - _globals['_ROUTERRECORDROTATIONREQUEST']._serialized_start=1375 - _globals['_ROUTERRECORDROTATIONREQUEST']._serialized_end=1635 - _globals['_USERRECORDACCESSREQUEST']._serialized_start=1637 - _globals['_USERRECORDACCESSREQUEST']._serialized_end=1697 - _globals['_USERRECORDACCESSRESPONSE']._serialized_start=1699 - _globals['_USERRECORDACCESSRESPONSE']._serialized_end=1796 - _globals['_ROTATIONSCHEDULE']._serialized_start=1798 - _globals['_ROTATIONSCHEDULE']._serialized_end=1854 - _globals['_APICALLBACKREQUEST']._serialized_start=1857 - _globals['_APICALLBACKREQUEST']._serialized_end=2001 - _globals['_APICALLBACKSCHEDULE']._serialized_start=2003 - _globals['_APICALLBACKSCHEDULE']._serialized_end=2056 - _globals['_ROUTERSCHEDULEDACTIONS']._serialized_start=2058 - _globals['_ROUTERSCHEDULEDACTIONS']._serialized_end=2122 - _globals['_ROUTERRECORDSROTATIONREQUEST']._serialized_start=2124 - _globals['_ROUTERRECORDSROTATIONREQUEST']._serialized_end=2213 - _globals['_CONNECTIONPARAMETERS']._serialized_start=2216 - _globals['_CONNECTIONPARAMETERS']._serialized_end=2349 - _globals['_VALIDATECONNECTIONSREQUEST']._serialized_start=2351 - _globals['_VALIDATECONNECTIONSREQUEST']._serialized_end=2430 - _globals['_CONNECTIONVALIDATIONFAILURE']._serialized_start=2432 - _globals['_CONNECTIONVALIDATIONFAILURE']._serialized_end=2506 - _globals['_VALIDATECONNECTIONSRESPONSE']._serialized_start=2508 - _globals['_VALIDATECONNECTIONSRESPONSE']._serialized_end=2601 - _globals['_GETENFORCEMENTREQUEST']._serialized_start=2603 - _globals['_GETENFORCEMENTREQUEST']._serialized_end=2652 - _globals['_ENFORCEMENTTYPE']._serialized_start=2654 - _globals['_ENFORCEMENTTYPE']._serialized_end=2713 - _globals['_GETENFORCEMENTRESPONSE']._serialized_start=2715 - _globals['_GETENFORCEMENTRESPONSE']._serialized_end=2827 - _globals['_PEDMTOTPVALIDATEREQUEST']._serialized_start=2829 - _globals['_PEDMTOTPVALIDATEREQUEST']._serialized_end=2908 + _globals['_ROUTERRESPONSECODE']._serialized_start=4038 + _globals['_ROUTERRESPONSECODE']._serialized_end=4318 + _globals['_ROUTERROTATIONSTATUS']._serialized_start=4320 + _globals['_ROUTERROTATIONSTATUS']._serialized_end=4427 + _globals['_USERRECORDACCESSLEVEL']._serialized_start=4429 + _globals['_USERRECORDACCESSLEVEL']._serialized_end=4554 + _globals['_SERVICETYPE']._serialized_start=4556 + _globals['_SERVICETYPE']._serialized_end=4602 + _globals['_ROUTERRESPONSE']._serialized_start=53 + _globals['_ROUTERRESPONSE']._serialized_end=167 + _globals['_ROUTERCONTROLLERMESSAGE']._serialized_start=170 + _globals['_ROUTERCONTROLLERMESSAGE']._serialized_end=345 + _globals['_ROUTERUSERAUTH']._serialized_start=348 + _globals['_ROUTERUSERAUTH']._serialized_end=629 + _globals['_ROUTERDEVICEAUTH']._serialized_start=632 + _globals['_ROUTERDEVICEAUTH']._serialized_end=917 + _globals['_ROUTERRECORDROTATION']._serialized_start=920 + _globals['_ROUTERRECORDROTATION']._serialized_end=1051 + _globals['_ROUTERRECORDROTATIONSREQUEST']._serialized_start=1053 + _globals['_ROUTERRECORDROTATIONSREQUEST']._serialized_end=1122 + _globals['_ROUTERRECORDROTATIONSRESPONSE']._serialized_start=1124 + _globals['_ROUTERRECORDROTATIONSRESPONSE']._serialized_end=1221 + _globals['_ROUTERROTATIONINFO']._serialized_start=1224 + _globals['_ROUTERROTATIONINFO']._serialized_end=1461 + _globals['_ROUTERRECORDROTATIONREQUEST']._serialized_start=1464 + _globals['_ROUTERRECORDROTATIONREQUEST']._serialized_end=1778 + _globals['_USERRECORDACCESSREQUEST']._serialized_start=1780 + _globals['_USERRECORDACCESSREQUEST']._serialized_end=1840 + _globals['_USERRECORDACCESSRESPONSE']._serialized_start=1842 + _globals['_USERRECORDACCESSRESPONSE']._serialized_end=1939 + _globals['_USERRECORDACCESSREQUESTS']._serialized_start=1941 + _globals['_USERRECORDACCESSREQUESTS']._serialized_end=2018 + _globals['_USERRECORDACCESSRESPONSES']._serialized_start=2020 + _globals['_USERRECORDACCESSRESPONSES']._serialized_end=2100 + _globals['_ROTATIONSCHEDULE']._serialized_start=2102 + _globals['_ROTATIONSCHEDULE']._serialized_end=2158 + _globals['_APICALLBACKREQUEST']._serialized_start=2161 + _globals['_APICALLBACKREQUEST']._serialized_end=2305 + _globals['_APICALLBACKSCHEDULE']._serialized_start=2307 + _globals['_APICALLBACKSCHEDULE']._serialized_end=2360 + _globals['_ROUTERSCHEDULEDACTIONS']._serialized_start=2362 + _globals['_ROUTERSCHEDULEDACTIONS']._serialized_end=2426 + _globals['_ROUTERRECORDSROTATIONREQUEST']._serialized_start=2428 + _globals['_ROUTERRECORDSROTATIONREQUEST']._serialized_end=2517 + _globals['_CONNECTIONPARAMETERS']._serialized_start=2520 + _globals['_CONNECTIONPARAMETERS']._serialized_end=2653 + _globals['_VALIDATECONNECTIONSREQUEST']._serialized_start=2655 + _globals['_VALIDATECONNECTIONSREQUEST']._serialized_end=2734 + _globals['_CONNECTIONVALIDATIONFAILURE']._serialized_start=2736 + _globals['_CONNECTIONVALIDATIONFAILURE']._serialized_end=2810 + _globals['_VALIDATECONNECTIONSRESPONSE']._serialized_start=2812 + _globals['_VALIDATECONNECTIONSRESPONSE']._serialized_end=2905 + _globals['_GETENFORCEMENTREQUEST']._serialized_start=2907 + _globals['_GETENFORCEMENTREQUEST']._serialized_end=2956 + _globals['_ENFORCEMENTTYPE']._serialized_start=2958 + _globals['_ENFORCEMENTTYPE']._serialized_end=3017 + _globals['_GETENFORCEMENTRESPONSE']._serialized_start=3019 + _globals['_GETENFORCEMENTRESPONSE']._serialized_end=3131 + _globals['_PEDMTOTPVALIDATEREQUEST']._serialized_start=3133 + _globals['_PEDMTOTPVALIDATEREQUEST']._serialized_end=3212 + _globals['_GETPEDMADMININFORESPONSE']._serialized_start=3214 + _globals['_GETPEDMADMININFORESPONSE']._serialized_end=3286 + _globals['_PAMNETWORKSETTINGS']._serialized_start=3288 + _globals['_PAMNETWORKSETTINGS']._serialized_end=3333 + _globals['_PAMNETWORKCONFIGURATIONREQUEST']._serialized_start=3336 + _globals['_PAMNETWORKCONFIGURATIONREQUEST']._serialized_end=3564 + _globals['_PAMDISCOVERYRULESSETREQUEST']._serialized_start=3566 + _globals['_PAMDISCOVERYRULESSETREQUEST']._serialized_end=3648 + _globals['_ROUTER2FAVALIDATEREQUEST']._serialized_start=3650 + _globals['_ROUTER2FAVALIDATEREQUEST']._serialized_end=3738 + _globals['_ROUTER2FASENDPUSHREQUEST']._serialized_start=3740 + _globals['_ROUTER2FASENDPUSHREQUEST']._serialized_end=3866 + _globals['_ROUTER2FAGETWEBAUTHNCHALLENGEREQUEST']._serialized_start=3868 + _globals['_ROUTER2FAGETWEBAUTHNCHALLENGEREQUEST']._serialized_end=3953 + _globals['_ROUTER2FAGETWEBAUTHNCHALLENGERESPONSE']._serialized_start=3955 + _globals['_ROUTER2FAGETWEBAUTHNCHALLENGERESPONSE']._serialized_end=4035 # @@protoc_insertion_point(module_scope) diff --git a/keepercommander/proto/router_pb2.pyi b/keepercommander/proto/router_pb2.pyi index 17e9d9e07..486c4628c 100644 --- a/keepercommander/proto/router_pb2.pyi +++ b/keepercommander/proto/router_pb2.pyi @@ -1,14 +1,16 @@ import pam_pb2 as _pam_pb2 +import APIRequest_pb2 as _APIRequest_pb2 from google.protobuf.internal import containers as _containers from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper from google.protobuf import descriptor as _descriptor from google.protobuf import message as _message -from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union +from collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union DESCRIPTOR: _descriptor.FileDescriptor class RouterResponseCode(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): - __slots__ = [] + __slots__ = () RRC_OK: _ClassVar[RouterResponseCode] RRC_GENERAL_ERROR: _ClassVar[RouterResponseCode] RRC_NOT_ALLOWED: _ClassVar[RouterResponseCode] @@ -21,14 +23,14 @@ class RouterResponseCode(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): RRC_NOT_ALLOWED_PAM_CONFIG_FEATURES_NOT_ENABLED: _ClassVar[RouterResponseCode] class RouterRotationStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): - __slots__ = [] + __slots__ = () RRS_ONLINE: _ClassVar[RouterRotationStatus] RRS_NO_ROTATION: _ClassVar[RouterRotationStatus] RRS_NO_CONTROLLER: _ClassVar[RouterRotationStatus] RRS_CONTROLLER_DOWN: _ClassVar[RouterRotationStatus] class UserRecordAccessLevel(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): - __slots__ = [] + __slots__ = () RRAL_NONE: _ClassVar[UserRecordAccessLevel] RRAL_READ: _ClassVar[UserRecordAccessLevel] RRAL_SHARE: _ClassVar[UserRecordAccessLevel] @@ -37,7 +39,7 @@ class UserRecordAccessLevel(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): RRAL_OWNER: _ClassVar[UserRecordAccessLevel] class ServiceType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): - __slots__ = [] + __slots__ = () UNSPECIFIED: _ClassVar[ServiceType] KA: _ClassVar[ServiceType] BI: _ClassVar[ServiceType] @@ -66,7 +68,7 @@ KA: ServiceType BI: ServiceType class RouterResponse(_message.Message): - __slots__ = ["responseCode", "errorMessage", "encryptedPayload"] + __slots__ = ("responseCode", "errorMessage", "encryptedPayload") RESPONSECODE_FIELD_NUMBER: _ClassVar[int] ERRORMESSAGE_FIELD_NUMBER: _ClassVar[int] ENCRYPTEDPAYLOAD_FIELD_NUMBER: _ClassVar[int] @@ -76,7 +78,7 @@ class RouterResponse(_message.Message): def __init__(self, responseCode: _Optional[_Union[RouterResponseCode, str]] = ..., errorMessage: _Optional[str] = ..., encryptedPayload: _Optional[bytes] = ...) -> None: ... class RouterControllerMessage(_message.Message): - __slots__ = ["messageType", "messageUid", "controllerUid", "streamResponse", "payload", "timeout"] + __slots__ = ("messageType", "messageUid", "controllerUid", "streamResponse", "payload", "timeout") MESSAGETYPE_FIELD_NUMBER: _ClassVar[int] MESSAGEUID_FIELD_NUMBER: _ClassVar[int] CONTROLLERUID_FIELD_NUMBER: _ClassVar[int] @@ -89,10 +91,10 @@ class RouterControllerMessage(_message.Message): streamResponse: bool payload: bytes timeout: int - def __init__(self, messageType: _Optional[_Union[_pam_pb2.ControllerMessageType, str]] = ..., messageUid: _Optional[bytes] = ..., controllerUid: _Optional[bytes] = ..., streamResponse: bool = ..., payload: _Optional[bytes] = ..., timeout: _Optional[int] = ...) -> None: ... + def __init__(self, messageType: _Optional[_Union[_pam_pb2.ControllerMessageType, str]] = ..., messageUid: _Optional[bytes] = ..., controllerUid: _Optional[bytes] = ..., streamResponse: _Optional[bool] = ..., payload: _Optional[bytes] = ..., timeout: _Optional[int] = ...) -> None: ... class RouterUserAuth(_message.Message): - __slots__ = ["transmissionKey", "sessionToken", "userId", "enterpriseUserId", "deviceName", "deviceToken", "clientVersionId", "needUsername", "username", "mspEnterpriseId"] + __slots__ = ("transmissionKey", "sessionToken", "userId", "enterpriseUserId", "deviceName", "deviceToken", "clientVersionId", "needUsername", "username", "mspEnterpriseId", "isPedmAdmin", "mcEnterpriseId") TRANSMISSIONKEY_FIELD_NUMBER: _ClassVar[int] SESSIONTOKEN_FIELD_NUMBER: _ClassVar[int] USERID_FIELD_NUMBER: _ClassVar[int] @@ -103,6 +105,8 @@ class RouterUserAuth(_message.Message): NEEDUSERNAME_FIELD_NUMBER: _ClassVar[int] USERNAME_FIELD_NUMBER: _ClassVar[int] MSPENTERPRISEID_FIELD_NUMBER: _ClassVar[int] + ISPEDMADMIN_FIELD_NUMBER: _ClassVar[int] + MCENTERPRISEID_FIELD_NUMBER: _ClassVar[int] transmissionKey: bytes sessionToken: bytes userId: int @@ -113,10 +117,12 @@ class RouterUserAuth(_message.Message): needUsername: bool username: str mspEnterpriseId: int - def __init__(self, transmissionKey: _Optional[bytes] = ..., sessionToken: _Optional[bytes] = ..., userId: _Optional[int] = ..., enterpriseUserId: _Optional[int] = ..., deviceName: _Optional[str] = ..., deviceToken: _Optional[bytes] = ..., clientVersionId: _Optional[int] = ..., needUsername: bool = ..., username: _Optional[str] = ..., mspEnterpriseId: _Optional[int] = ...) -> None: ... + isPedmAdmin: bool + mcEnterpriseId: int + def __init__(self, transmissionKey: _Optional[bytes] = ..., sessionToken: _Optional[bytes] = ..., userId: _Optional[int] = ..., enterpriseUserId: _Optional[int] = ..., deviceName: _Optional[str] = ..., deviceToken: _Optional[bytes] = ..., clientVersionId: _Optional[int] = ..., needUsername: _Optional[bool] = ..., username: _Optional[str] = ..., mspEnterpriseId: _Optional[int] = ..., isPedmAdmin: _Optional[bool] = ..., mcEnterpriseId: _Optional[int] = ...) -> None: ... class RouterDeviceAuth(_message.Message): - __slots__ = ["clientId", "clientVersion", "signature", "enterpriseId", "nodeId", "deviceName", "deviceToken", "controllerName", "controllerUid", "ownerUser", "challenge", "ownerId"] + __slots__ = ("clientId", "clientVersion", "signature", "enterpriseId", "nodeId", "deviceName", "deviceToken", "controllerName", "controllerUid", "ownerUser", "challenge", "ownerId", "maxInstanceCount") CLIENTID_FIELD_NUMBER: _ClassVar[int] CLIENTVERSION_FIELD_NUMBER: _ClassVar[int] SIGNATURE_FIELD_NUMBER: _ClassVar[int] @@ -129,6 +135,7 @@ class RouterDeviceAuth(_message.Message): OWNERUSER_FIELD_NUMBER: _ClassVar[int] CHALLENGE_FIELD_NUMBER: _ClassVar[int] OWNERID_FIELD_NUMBER: _ClassVar[int] + MAXINSTANCECOUNT_FIELD_NUMBER: _ClassVar[int] clientId: str clientVersion: str signature: bytes @@ -141,10 +148,11 @@ class RouterDeviceAuth(_message.Message): ownerUser: str challenge: str ownerId: int - def __init__(self, clientId: _Optional[str] = ..., clientVersion: _Optional[str] = ..., signature: _Optional[bytes] = ..., enterpriseId: _Optional[int] = ..., nodeId: _Optional[int] = ..., deviceName: _Optional[str] = ..., deviceToken: _Optional[bytes] = ..., controllerName: _Optional[str] = ..., controllerUid: _Optional[bytes] = ..., ownerUser: _Optional[str] = ..., challenge: _Optional[str] = ..., ownerId: _Optional[int] = ...) -> None: ... + maxInstanceCount: int + def __init__(self, clientId: _Optional[str] = ..., clientVersion: _Optional[str] = ..., signature: _Optional[bytes] = ..., enterpriseId: _Optional[int] = ..., nodeId: _Optional[int] = ..., deviceName: _Optional[str] = ..., deviceToken: _Optional[bytes] = ..., controllerName: _Optional[str] = ..., controllerUid: _Optional[bytes] = ..., ownerUser: _Optional[str] = ..., challenge: _Optional[str] = ..., ownerId: _Optional[int] = ..., maxInstanceCount: _Optional[int] = ...) -> None: ... class RouterRecordRotation(_message.Message): - __slots__ = ["recordUid", "configurationUid", "controllerUid", "resourceUid", "noSchedule"] + __slots__ = ("recordUid", "configurationUid", "controllerUid", "resourceUid", "noSchedule") RECORDUID_FIELD_NUMBER: _ClassVar[int] CONFIGURATIONUID_FIELD_NUMBER: _ClassVar[int] CONTROLLERUID_FIELD_NUMBER: _ClassVar[int] @@ -155,10 +163,10 @@ class RouterRecordRotation(_message.Message): controllerUid: bytes resourceUid: bytes noSchedule: bool - def __init__(self, recordUid: _Optional[bytes] = ..., configurationUid: _Optional[bytes] = ..., controllerUid: _Optional[bytes] = ..., resourceUid: _Optional[bytes] = ..., noSchedule: bool = ...) -> None: ... + def __init__(self, recordUid: _Optional[bytes] = ..., configurationUid: _Optional[bytes] = ..., controllerUid: _Optional[bytes] = ..., resourceUid: _Optional[bytes] = ..., noSchedule: _Optional[bool] = ...) -> None: ... class RouterRecordRotationsRequest(_message.Message): - __slots__ = ["enterpriseId", "records"] + __slots__ = ("enterpriseId", "records") ENTERPRISEID_FIELD_NUMBER: _ClassVar[int] RECORDS_FIELD_NUMBER: _ClassVar[int] enterpriseId: int @@ -166,15 +174,15 @@ class RouterRecordRotationsRequest(_message.Message): def __init__(self, enterpriseId: _Optional[int] = ..., records: _Optional[_Iterable[bytes]] = ...) -> None: ... class RouterRecordRotationsResponse(_message.Message): - __slots__ = ["rotations", "hasMore"] + __slots__ = ("rotations", "hasMore") ROTATIONS_FIELD_NUMBER: _ClassVar[int] HASMORE_FIELD_NUMBER: _ClassVar[int] rotations: _containers.RepeatedCompositeFieldContainer[RouterRecordRotation] hasMore: bool - def __init__(self, rotations: _Optional[_Iterable[_Union[RouterRecordRotation, _Mapping]]] = ..., hasMore: bool = ...) -> None: ... + def __init__(self, rotations: _Optional[_Iterable[_Union[RouterRecordRotation, _Mapping]]] = ..., hasMore: _Optional[bool] = ...) -> None: ... class RouterRotationInfo(_message.Message): - __slots__ = ["status", "configurationUid", "resourceUid", "nodeId", "controllerUid", "controllerName", "scriptName", "pwdComplexity", "disabled"] + __slots__ = ("status", "configurationUid", "resourceUid", "nodeId", "controllerUid", "controllerName", "scriptName", "pwdComplexity", "disabled") STATUS_FIELD_NUMBER: _ClassVar[int] CONFIGURATIONUID_FIELD_NUMBER: _ClassVar[int] RESOURCEUID_FIELD_NUMBER: _ClassVar[int] @@ -193,10 +201,10 @@ class RouterRotationInfo(_message.Message): scriptName: str pwdComplexity: str disabled: bool - def __init__(self, status: _Optional[_Union[RouterRotationStatus, str]] = ..., configurationUid: _Optional[bytes] = ..., resourceUid: _Optional[bytes] = ..., nodeId: _Optional[int] = ..., controllerUid: _Optional[bytes] = ..., controllerName: _Optional[str] = ..., scriptName: _Optional[str] = ..., pwdComplexity: _Optional[str] = ..., disabled: bool = ...) -> None: ... + def __init__(self, status: _Optional[_Union[RouterRotationStatus, str]] = ..., configurationUid: _Optional[bytes] = ..., resourceUid: _Optional[bytes] = ..., nodeId: _Optional[int] = ..., controllerUid: _Optional[bytes] = ..., controllerName: _Optional[str] = ..., scriptName: _Optional[str] = ..., pwdComplexity: _Optional[str] = ..., disabled: _Optional[bool] = ...) -> None: ... class RouterRecordRotationRequest(_message.Message): - __slots__ = ["recordUid", "revision", "configurationUid", "resourceUid", "schedule", "enterpriseUserId", "pwdComplexity", "disabled", "remoteAddress", "clientVersionId", "noop"] + __slots__ = ("recordUid", "revision", "configurationUid", "resourceUid", "schedule", "enterpriseUserId", "pwdComplexity", "disabled", "remoteAddress", "clientVersionId", "noop", "saasConfiguration") RECORDUID_FIELD_NUMBER: _ClassVar[int] REVISION_FIELD_NUMBER: _ClassVar[int] CONFIGURATIONUID_FIELD_NUMBER: _ClassVar[int] @@ -208,6 +216,7 @@ class RouterRecordRotationRequest(_message.Message): REMOTEADDRESS_FIELD_NUMBER: _ClassVar[int] CLIENTVERSIONID_FIELD_NUMBER: _ClassVar[int] NOOP_FIELD_NUMBER: _ClassVar[int] + SAASCONFIGURATION_FIELD_NUMBER: _ClassVar[int] recordUid: bytes revision: int configurationUid: bytes @@ -219,10 +228,11 @@ class RouterRecordRotationRequest(_message.Message): remoteAddress: str clientVersionId: int noop: bool - def __init__(self, recordUid: _Optional[bytes] = ..., revision: _Optional[int] = ..., configurationUid: _Optional[bytes] = ..., resourceUid: _Optional[bytes] = ..., schedule: _Optional[str] = ..., enterpriseUserId: _Optional[int] = ..., pwdComplexity: _Optional[bytes] = ..., disabled: bool = ..., remoteAddress: _Optional[str] = ..., clientVersionId: _Optional[int] = ..., noop: bool = ...) -> None: ... + saasConfiguration: bytes + def __init__(self, recordUid: _Optional[bytes] = ..., revision: _Optional[int] = ..., configurationUid: _Optional[bytes] = ..., resourceUid: _Optional[bytes] = ..., schedule: _Optional[str] = ..., enterpriseUserId: _Optional[int] = ..., pwdComplexity: _Optional[bytes] = ..., disabled: _Optional[bool] = ..., remoteAddress: _Optional[str] = ..., clientVersionId: _Optional[int] = ..., noop: _Optional[bool] = ..., saasConfiguration: _Optional[bytes] = ...) -> None: ... class UserRecordAccessRequest(_message.Message): - __slots__ = ["userId", "recordUid"] + __slots__ = ("userId", "recordUid") USERID_FIELD_NUMBER: _ClassVar[int] RECORDUID_FIELD_NUMBER: _ClassVar[int] userId: int @@ -230,15 +240,27 @@ class UserRecordAccessRequest(_message.Message): def __init__(self, userId: _Optional[int] = ..., recordUid: _Optional[bytes] = ...) -> None: ... class UserRecordAccessResponse(_message.Message): - __slots__ = ["recordUid", "accessLevel"] + __slots__ = ("recordUid", "accessLevel") RECORDUID_FIELD_NUMBER: _ClassVar[int] ACCESSLEVEL_FIELD_NUMBER: _ClassVar[int] recordUid: bytes accessLevel: UserRecordAccessLevel def __init__(self, recordUid: _Optional[bytes] = ..., accessLevel: _Optional[_Union[UserRecordAccessLevel, str]] = ...) -> None: ... +class UserRecordAccessRequests(_message.Message): + __slots__ = ("requests",) + REQUESTS_FIELD_NUMBER: _ClassVar[int] + requests: _containers.RepeatedCompositeFieldContainer[UserRecordAccessRequest] + def __init__(self, requests: _Optional[_Iterable[_Union[UserRecordAccessRequest, _Mapping]]] = ...) -> None: ... + +class UserRecordAccessResponses(_message.Message): + __slots__ = ("responses",) + RESPONSES_FIELD_NUMBER: _ClassVar[int] + responses: _containers.RepeatedCompositeFieldContainer[UserRecordAccessResponse] + def __init__(self, responses: _Optional[_Iterable[_Union[UserRecordAccessResponse, _Mapping]]] = ...) -> None: ... + class RotationSchedule(_message.Message): - __slots__ = ["record_uid", "schedule"] + __slots__ = ("record_uid", "schedule") RECORD_UID_FIELD_NUMBER: _ClassVar[int] SCHEDULE_FIELD_NUMBER: _ClassVar[int] record_uid: bytes @@ -246,7 +268,7 @@ class RotationSchedule(_message.Message): def __init__(self, record_uid: _Optional[bytes] = ..., schedule: _Optional[str] = ...) -> None: ... class ApiCallbackRequest(_message.Message): - __slots__ = ["resourceUid", "schedules", "url", "serviceType"] + __slots__ = ("resourceUid", "schedules", "url", "serviceType") RESOURCEUID_FIELD_NUMBER: _ClassVar[int] SCHEDULES_FIELD_NUMBER: _ClassVar[int] URL_FIELD_NUMBER: _ClassVar[int] @@ -258,7 +280,7 @@ class ApiCallbackRequest(_message.Message): def __init__(self, resourceUid: _Optional[bytes] = ..., schedules: _Optional[_Iterable[_Union[ApiCallbackSchedule, _Mapping]]] = ..., url: _Optional[str] = ..., serviceType: _Optional[_Union[ServiceType, str]] = ...) -> None: ... class ApiCallbackSchedule(_message.Message): - __slots__ = ["schedule", "data"] + __slots__ = ("schedule", "data") SCHEDULE_FIELD_NUMBER: _ClassVar[int] DATA_FIELD_NUMBER: _ClassVar[int] schedule: str @@ -266,7 +288,7 @@ class ApiCallbackSchedule(_message.Message): def __init__(self, schedule: _Optional[str] = ..., data: _Optional[bytes] = ...) -> None: ... class RouterScheduledActions(_message.Message): - __slots__ = ["schedule", "resourceUids"] + __slots__ = ("schedule", "resourceUids") SCHEDULE_FIELD_NUMBER: _ClassVar[int] RESOURCEUIDS_FIELD_NUMBER: _ClassVar[int] schedule: str @@ -274,13 +296,13 @@ class RouterScheduledActions(_message.Message): def __init__(self, schedule: _Optional[str] = ..., resourceUids: _Optional[_Iterable[bytes]] = ...) -> None: ... class RouterRecordsRotationRequest(_message.Message): - __slots__ = ["rotationSchedules"] + __slots__ = ("rotationSchedules",) ROTATIONSCHEDULES_FIELD_NUMBER: _ClassVar[int] rotationSchedules: _containers.RepeatedCompositeFieldContainer[RouterScheduledActions] def __init__(self, rotationSchedules: _Optional[_Iterable[_Union[RouterScheduledActions, _Mapping]]] = ...) -> None: ... class ConnectionParameters(_message.Message): - __slots__ = ["connectionUid", "recordUid", "userId", "controllerUid", "credentialsRecordUid"] + __slots__ = ("connectionUid", "recordUid", "userId", "controllerUid", "credentialsRecordUid") CONNECTIONUID_FIELD_NUMBER: _ClassVar[int] RECORDUID_FIELD_NUMBER: _ClassVar[int] USERID_FIELD_NUMBER: _ClassVar[int] @@ -294,13 +316,13 @@ class ConnectionParameters(_message.Message): def __init__(self, connectionUid: _Optional[bytes] = ..., recordUid: _Optional[bytes] = ..., userId: _Optional[int] = ..., controllerUid: _Optional[bytes] = ..., credentialsRecordUid: _Optional[bytes] = ...) -> None: ... class ValidateConnectionsRequest(_message.Message): - __slots__ = ["connections"] + __slots__ = ("connections",) CONNECTIONS_FIELD_NUMBER: _ClassVar[int] connections: _containers.RepeatedCompositeFieldContainer[ConnectionParameters] def __init__(self, connections: _Optional[_Iterable[_Union[ConnectionParameters, _Mapping]]] = ...) -> None: ... class ConnectionValidationFailure(_message.Message): - __slots__ = ["connectionUid", "errorMessage"] + __slots__ = ("connectionUid", "errorMessage") CONNECTIONUID_FIELD_NUMBER: _ClassVar[int] ERRORMESSAGE_FIELD_NUMBER: _ClassVar[int] connectionUid: bytes @@ -308,19 +330,19 @@ class ConnectionValidationFailure(_message.Message): def __init__(self, connectionUid: _Optional[bytes] = ..., errorMessage: _Optional[str] = ...) -> None: ... class ValidateConnectionsResponse(_message.Message): - __slots__ = ["failedConnections"] + __slots__ = ("failedConnections",) FAILEDCONNECTIONS_FIELD_NUMBER: _ClassVar[int] failedConnections: _containers.RepeatedCompositeFieldContainer[ConnectionValidationFailure] def __init__(self, failedConnections: _Optional[_Iterable[_Union[ConnectionValidationFailure, _Mapping]]] = ...) -> None: ... class GetEnforcementRequest(_message.Message): - __slots__ = ["enterpriseUserId"] + __slots__ = ("enterpriseUserId",) ENTERPRISEUSERID_FIELD_NUMBER: _ClassVar[int] enterpriseUserId: int def __init__(self, enterpriseUserId: _Optional[int] = ...) -> None: ... class EnforcementType(_message.Message): - __slots__ = ["enforcementTypeId", "value"] + __slots__ = ("enforcementTypeId", "value") ENFORCEMENTTYPEID_FIELD_NUMBER: _ClassVar[int] VALUE_FIELD_NUMBER: _ClassVar[int] enforcementTypeId: int @@ -328,17 +350,17 @@ class EnforcementType(_message.Message): def __init__(self, enforcementTypeId: _Optional[int] = ..., value: _Optional[str] = ...) -> None: ... class GetEnforcementResponse(_message.Message): - __slots__ = ["enforcementTypes", "addOnIds", "isInTrial"] + __slots__ = ("enforcementTypes", "addOnIds", "isInTrial") ENFORCEMENTTYPES_FIELD_NUMBER: _ClassVar[int] ADDONIDS_FIELD_NUMBER: _ClassVar[int] ISINTRIAL_FIELD_NUMBER: _ClassVar[int] enforcementTypes: _containers.RepeatedCompositeFieldContainer[EnforcementType] addOnIds: _containers.RepeatedScalarFieldContainer[int] isInTrial: bool - def __init__(self, enforcementTypes: _Optional[_Iterable[_Union[EnforcementType, _Mapping]]] = ..., addOnIds: _Optional[_Iterable[int]] = ..., isInTrial: bool = ...) -> None: ... + def __init__(self, enforcementTypes: _Optional[_Iterable[_Union[EnforcementType, _Mapping]]] = ..., addOnIds: _Optional[_Iterable[int]] = ..., isInTrial: _Optional[bool] = ...) -> None: ... class PEDMTOTPValidateRequest(_message.Message): - __slots__ = ["username", "enterpriseId", "code"] + __slots__ = ("username", "enterpriseId", "code") USERNAME_FIELD_NUMBER: _ClassVar[int] ENTERPRISEID_FIELD_NUMBER: _ClassVar[int] CODE_FIELD_NUMBER: _ClassVar[int] @@ -346,3 +368,75 @@ class PEDMTOTPValidateRequest(_message.Message): enterpriseId: int code: int def __init__(self, username: _Optional[str] = ..., enterpriseId: _Optional[int] = ..., code: _Optional[int] = ...) -> None: ... + +class GetPEDMAdminInfoResponse(_message.Message): + __slots__ = ("isPedmAdmin", "pedmAddonActive") + ISPEDMADMIN_FIELD_NUMBER: _ClassVar[int] + PEDMADDONACTIVE_FIELD_NUMBER: _ClassVar[int] + isPedmAdmin: bool + pedmAddonActive: bool + def __init__(self, isPedmAdmin: _Optional[bool] = ..., pedmAddonActive: _Optional[bool] = ...) -> None: ... + +class PAMNetworkSettings(_message.Message): + __slots__ = ("allowedSettings",) + ALLOWEDSETTINGS_FIELD_NUMBER: _ClassVar[int] + allowedSettings: bytes + def __init__(self, allowedSettings: _Optional[bytes] = ...) -> None: ... + +class PAMNetworkConfigurationRequest(_message.Message): + __slots__ = ("recordUid", "networkSettings", "resources", "rotations") + RECORDUID_FIELD_NUMBER: _ClassVar[int] + NETWORKSETTINGS_FIELD_NUMBER: _ClassVar[int] + RESOURCES_FIELD_NUMBER: _ClassVar[int] + ROTATIONS_FIELD_NUMBER: _ClassVar[int] + recordUid: bytes + networkSettings: PAMNetworkSettings + resources: _containers.RepeatedCompositeFieldContainer[_pam_pb2.PAMResourceConfig] + rotations: _containers.RepeatedCompositeFieldContainer[RouterRecordRotationRequest] + def __init__(self, recordUid: _Optional[bytes] = ..., networkSettings: _Optional[_Union[PAMNetworkSettings, _Mapping]] = ..., resources: _Optional[_Iterable[_Union[_pam_pb2.PAMResourceConfig, _Mapping]]] = ..., rotations: _Optional[_Iterable[_Union[RouterRecordRotationRequest, _Mapping]]] = ...) -> None: ... + +class PAMDiscoveryRulesSetRequest(_message.Message): + __slots__ = ("networkUid", "rules", "rulesKey") + NETWORKUID_FIELD_NUMBER: _ClassVar[int] + RULES_FIELD_NUMBER: _ClassVar[int] + RULESKEY_FIELD_NUMBER: _ClassVar[int] + networkUid: bytes + rules: bytes + rulesKey: bytes + def __init__(self, networkUid: _Optional[bytes] = ..., rules: _Optional[bytes] = ..., rulesKey: _Optional[bytes] = ...) -> None: ... + +class Router2FAValidateRequest(_message.Message): + __slots__ = ("transmissionKey", "sessionToken", "value") + TRANSMISSIONKEY_FIELD_NUMBER: _ClassVar[int] + SESSIONTOKEN_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + transmissionKey: bytes + sessionToken: bytes + value: str + def __init__(self, transmissionKey: _Optional[bytes] = ..., sessionToken: _Optional[bytes] = ..., value: _Optional[str] = ...) -> None: ... + +class Router2FASendPushRequest(_message.Message): + __slots__ = ("transmissionKey", "sessionToken", "pushType") + TRANSMISSIONKEY_FIELD_NUMBER: _ClassVar[int] + SESSIONTOKEN_FIELD_NUMBER: _ClassVar[int] + PUSHTYPE_FIELD_NUMBER: _ClassVar[int] + transmissionKey: bytes + sessionToken: bytes + pushType: _APIRequest_pb2.TwoFactorPushType + def __init__(self, transmissionKey: _Optional[bytes] = ..., sessionToken: _Optional[bytes] = ..., pushType: _Optional[_Union[_APIRequest_pb2.TwoFactorPushType, str]] = ...) -> None: ... + +class Router2FAGetWebAuthnChallengeRequest(_message.Message): + __slots__ = ("transmissionKey", "sessionToken") + TRANSMISSIONKEY_FIELD_NUMBER: _ClassVar[int] + SESSIONTOKEN_FIELD_NUMBER: _ClassVar[int] + transmissionKey: bytes + sessionToken: bytes + def __init__(self, transmissionKey: _Optional[bytes] = ..., sessionToken: _Optional[bytes] = ...) -> None: ... + +class Router2FAGetWebAuthnChallengeResponse(_message.Message): + __slots__ = ("challenge", "capabilities") + CHALLENGE_FIELD_NUMBER: _ClassVar[int] + CAPABILITIES_FIELD_NUMBER: _ClassVar[int] + challenge: str + capabilities: _containers.RepeatedScalarFieldContainer[str] + def __init__(self, challenge: _Optional[str] = ..., capabilities: _Optional[_Iterable[str]] = ...) -> None: ... diff --git a/keepercommander/proto/workflow_pb2.py b/keepercommander/proto/workflow_pb2.py index e4e4059e6..3b54ba70e 100644 --- a/keepercommander/proto/workflow_pb2.py +++ b/keepercommander/proto/workflow_pb2.py @@ -13,7 +13,7 @@ from . import GraphSync_pb2 as GraphSync__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0eworkflow.proto\x12\x08Workflow\x1a\x0fGraphSync.proto\"\x82\x01\n\x10WorkflowApprover\x12\x0e\n\x04user\x18\x01 \x01(\tH\x00\x12\x10\n\x06userId\x18\x02 \x01(\x05H\x00\x12\x11\n\x07teamUid\x18\x03 \x01(\x0cH\x00\x12\x12\n\nescalation\x18\x04 \x01(\x08\x12\x19\n\x11\x65scalationAfterMs\x18\x05 \x01(\x03\x42\n\n\x08\x61pprover\"\x9d\x02\n\x12WorkflowParameters\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x17\n\x0f\x61pprovalsNeeded\x18\x02 \x01(\x05\x12\x16\n\x0e\x63heckoutNeeded\x18\x03 \x01(\x08\x12\x1d\n\x15startAccessOnApproval\x18\x04 \x01(\x08\x12\x15\n\rrequireReason\x18\x05 \x01(\x08\x12\x15\n\rrequireTicket\x18\x06 \x01(\x08\x12\x12\n\nrequireMFA\x18\x07 \x01(\x08\x12\x14\n\x0c\x61\x63\x63\x65ssLength\x18\x08 \x01(\x03\x12\x34\n\x0c\x61llowedTimes\x18\t \x01(\x0b\x32\x1e.Workflow.TemporalAccessFilter\"\x84\x01\n\x0eWorkflowConfig\x12\x30\n\nparameters\x18\x01 \x01(\x0b\x32\x1c.Workflow.WorkflowParameters\x12-\n\tapprovers\x18\x02 \x03(\x0b\x32\x1a.Workflow.WorkflowApprover\x12\x11\n\tcreatedOn\x18\x03 \x01(\x03\"\xd0\x01\n\x0eWorkflowStatus\x12&\n\x05stage\x18\x01 \x01(\x0e\x32\x17.Workflow.WorkflowStage\x12-\n\nconditions\x18\x02 \x03(\x0e\x32\x19.Workflow.AccessCondition\x12.\n\napprovedBy\x18\x03 \x03(\x0b\x32\x1a.Workflow.WorkflowApproval\x12\x11\n\tstartedOn\x18\x04 \x01(\x03\x12\x11\n\texpiresOn\x18\x05 \x01(\x03\x12\x11\n\tescalated\x18\x06 \x01(\x08\"\xbd\x01\n\x0fWorkflowProcess\x12\x0f\n\x07\x66lowUid\x18\x01 \x01(\x0c\x12\x0e\n\x06userId\x18\x02 \x01(\x03\x12)\n\x08resource\x18\x03 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x11\n\tstartedOn\x18\x04 \x01(\x03\x12\x11\n\texpiresOn\x18\x05 \x01(\x03\x12\x0e\n\x06reason\x18\x06 \x01(\x0c\x12\x13\n\x0bmfaVerified\x18\x07 \x01(\x08\x12\x13\n\x0b\x65xternalRef\x18\x08 \x01(\x0c\"U\n\x10WorkflowApproval\x12\x0e\n\x06userId\x18\x01 \x01(\x03\x12\x0c\n\x04user\x18\x02 \x01(\t\x12\x0f\n\x07\x66lowUid\x18\x03 \x01(\x0c\x12\x12\n\napprovedOn\x18\x04 \x01(\x03\"\xcb\x01\n\x0fWorkflowContext\x12\x30\n\x0eworkflowConfig\x18\x01 \x01(\x0b\x32\x18.Workflow.WorkflowConfig\x12+\n\x08workflow\x18\x02 \x01(\x0b\x32\x19.Workflow.WorkflowProcess\x12-\n\tapprovals\x18\x03 \x03(\x0b\x32\x1a.Workflow.WorkflowApproval\x12*\n\x07\x62locker\x18\x04 \x01(\x0b\x32\x19.Workflow.WorkflowProcess\"u\n\rWorkflowState\x12\x0f\n\x07\x66lowUid\x18\x01 \x01(\x0c\x12)\n\x08resource\x18\x02 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12(\n\x06status\x18\x03 \x01(\x0b\x32\x18.Workflow.WorkflowStatus\"b\n\x15WorkflowAccessRequest\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x0e\n\x06reason\x18\x02 \x01(\x0c\x12\x0e\n\x06ticket\x18\x03 \x01(\x0c\"i\n\x18WorkflowApprovalOrDenial\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x0c\n\x04\x64\x65ny\x18\x02 \x01(\x08\x12\x14\n\x0c\x64\x65nialReason\x18\x03 \x01(\t\"=\n\x0fUserAccessState\x12*\n\tworkflows\x18\x01 \x03(\x0b\x32\x17.Workflow.WorkflowState\"@\n\x10\x41pprovalRequests\x12,\n\tworkflows\x18\x01 \x03(\x0b\x32\x19.Workflow.WorkflowProcess\"4\n\x0eTimeOfDayRange\x12\x11\n\tstartTime\x18\x01 \x01(\x05\x12\x0f\n\x07\x65ndTime\x18\x02 \x01(\x05\"\x80\x01\n\x14TemporalAccessFilter\x12,\n\ntimeRanges\x18\x01 \x03(\x0b\x32\x18.Workflow.TimeOfDayRange\x12(\n\x0b\x61llowedDays\x18\x02 \x03(\x0e\x32\x13.Workflow.DayOfWeek\x12\x10\n\x08timeZone\x18\x03 \x01(\t\"#\n\x0f\x41uthorizedUsers\x12\x10\n\x08username\x18\x01 \x03(\t*[\n\rWorkflowStage\x12\x15\n\x11WS_READY_TO_START\x10\x00\x12\x0e\n\nWS_STARTED\x10\x01\x12\x13\n\x0fWS_NEEDS_ACTION\x10\x02\x12\x0e\n\nWS_WAITING\x10\x03*i\n\x0f\x41\x63\x63\x65ssCondition\x12\x0f\n\x0b\x41\x43_APPROVAL\x10\x00\x12\x0e\n\nAC_CHECKIN\x10\x01\x12\n\n\x06\x41\x43_MFA\x10\x02\x12\x0b\n\x07\x41\x43_TIME\x10\x03\x12\r\n\tAC_REASON\x10\x04\x12\r\n\tAC_TICKET\x10\x05*\x84\x01\n\tDayOfWeek\x12\x1b\n\x17\x44\x41Y_OF_WEEK_UNSPECIFIED\x10\x00\x12\n\n\x06MONDAY\x10\x01\x12\x0b\n\x07TUESDAY\x10\x02\x12\r\n\tWEDNESDAY\x10\x03\x12\x0c\n\x08THURSDAY\x10\x04\x12\n\n\x06\x46RIDAY\x10\x05\x12\x0c\n\x08SATURDAY\x10\x06\x12\n\n\x06SUNDAY\x10\x07*9\n\x11\x41pprovalQueueKind\x12\x10\n\x0c\x41QK_APPROVAL\x10\x00\x12\x12\n\x0e\x41QK_ESCALATION\x10\x01\x42$\n\x18\x63om.keepersecurity.protoB\x08Workflowb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0eworkflow.proto\x12\x08Workflow\x1a\x0fGraphSync.proto\"\x82\x01\n\x10WorkflowApprover\x12\x0e\n\x04user\x18\x01 \x01(\tH\x00\x12\x10\n\x06userId\x18\x02 \x01(\x05H\x00\x12\x11\n\x07teamUid\x18\x03 \x01(\x0cH\x00\x12\x12\n\nescalation\x18\x04 \x01(\x08\x12\x19\n\x11\x65scalationAfterMs\x18\x05 \x01(\x03\x42\n\n\x08\x61pprover\"\x9d\x02\n\x12WorkflowParameters\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x17\n\x0f\x61pprovalsNeeded\x18\x02 \x01(\x05\x12\x16\n\x0e\x63heckoutNeeded\x18\x03 \x01(\x08\x12\x1d\n\x15startAccessOnApproval\x18\x04 \x01(\x08\x12\x15\n\rrequireReason\x18\x05 \x01(\x08\x12\x15\n\rrequireTicket\x18\x06 \x01(\x08\x12\x12\n\nrequireMFA\x18\x07 \x01(\x08\x12\x14\n\x0c\x61\x63\x63\x65ssLength\x18\x08 \x01(\x03\x12\x34\n\x0c\x61llowedTimes\x18\t \x01(\x0b\x32\x1e.Workflow.TemporalAccessFilter\"\x84\x01\n\x0eWorkflowConfig\x12\x30\n\nparameters\x18\x01 \x01(\x0b\x32\x1c.Workflow.WorkflowParameters\x12-\n\tapprovers\x18\x02 \x03(\x0b\x32\x1a.Workflow.WorkflowApprover\x12\x11\n\tcreatedOn\x18\x03 \x01(\x03\"\xd0\x01\n\x0eWorkflowStatus\x12&\n\x05stage\x18\x01 \x01(\x0e\x32\x17.Workflow.WorkflowStage\x12-\n\nconditions\x18\x02 \x03(\x0e\x32\x19.Workflow.AccessCondition\x12.\n\napprovedBy\x18\x03 \x03(\x0b\x32\x1a.Workflow.WorkflowApproval\x12\x11\n\tstartedOn\x18\x04 \x01(\x03\x12\x11\n\texpiresOn\x18\x05 \x01(\x03\x12\x11\n\tescalated\x18\x06 \x01(\x08\"\xbd\x01\n\x0fWorkflowProcess\x12\x0f\n\x07\x66lowUid\x18\x01 \x01(\x0c\x12\x0e\n\x06userId\x18\x02 \x01(\x03\x12)\n\x08resource\x18\x03 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x11\n\tstartedOn\x18\x04 \x01(\x03\x12\x11\n\texpiresOn\x18\x05 \x01(\x03\x12\x0e\n\x06reason\x18\x06 \x01(\x0c\x12\x13\n\x0bmfaVerified\x18\x07 \x01(\x08\x12\x13\n\x0b\x65xternalRef\x18\x08 \x01(\x0c\"U\n\x10WorkflowApproval\x12\x0e\n\x06userId\x18\x01 \x01(\x03\x12\x0c\n\x04user\x18\x02 \x01(\t\x12\x0f\n\x07\x66lowUid\x18\x03 \x01(\x0c\x12\x12\n\napprovedOn\x18\x04 \x01(\x03\"\xcb\x01\n\x0fWorkflowContext\x12\x30\n\x0eworkflowConfig\x18\x01 \x01(\x0b\x32\x18.Workflow.WorkflowConfig\x12+\n\x08workflow\x18\x02 \x01(\x0b\x32\x19.Workflow.WorkflowProcess\x12-\n\tapprovals\x18\x03 \x03(\x0b\x32\x1a.Workflow.WorkflowApproval\x12*\n\x07\x62locker\x18\x04 \x01(\x0b\x32\x19.Workflow.WorkflowProcess\"u\n\rWorkflowState\x12\x0f\n\x07\x66lowUid\x18\x01 \x01(\x0c\x12)\n\x08resource\x18\x02 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12(\n\x06status\x18\x03 \x01(\x0b\x32\x18.Workflow.WorkflowStatus\"b\n\x15WorkflowAccessRequest\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x0e\n\x06reason\x18\x02 \x01(\x0c\x12\x0e\n\x06ticket\x18\x03 \x01(\x0c\"i\n\x18WorkflowApprovalOrDenial\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x0c\n\x04\x64\x65ny\x18\x02 \x01(\x08\x12\x14\n\x0c\x64\x65nialReason\x18\x03 \x01(\t\"=\n\x0fUserAccessState\x12*\n\tworkflows\x18\x01 \x03(\x0b\x32\x17.Workflow.WorkflowState\"@\n\x10\x41pprovalRequests\x12,\n\tworkflows\x18\x01 \x03(\x0b\x32\x19.Workflow.WorkflowProcess\"4\n\x0eTimeOfDayRange\x12\x11\n\tstartTime\x18\x01 \x01(\x05\x12\x0f\n\x07\x65ndTime\x18\x02 \x01(\x05\"\xdd\x01\n\x12\x41pprovalQueueEntry\x12(\n\x07\x66lowRef\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12,\n\x0b\x61pproverRef\x18\x02 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12)\n\x04kind\x18\x03 \x01(\x0e\x32\x1b.Workflow.ApprovalQueueKind\x12\x12\n\nnotifyAtMs\x18\x04 \x01(\x03\x12\x1c\n\x0frequesterUserId\x18\x05 \x01(\x03H\x00\x88\x01\x01\x42\x12\n\x10_requesterUserId\"\x80\x01\n\x14TemporalAccessFilter\x12,\n\ntimeRanges\x18\x01 \x03(\x0b\x32\x18.Workflow.TimeOfDayRange\x12(\n\x0b\x61llowedDays\x18\x02 \x03(\x0e\x32\x13.Workflow.DayOfWeek\x12\x10\n\x08timeZone\x18\x03 \x01(\t\"#\n\x0f\x41uthorizedUsers\x12\x10\n\x08username\x18\x01 \x03(\t*[\n\rWorkflowStage\x12\x15\n\x11WS_READY_TO_START\x10\x00\x12\x0e\n\nWS_STARTED\x10\x01\x12\x13\n\x0fWS_NEEDS_ACTION\x10\x02\x12\x0e\n\nWS_WAITING\x10\x03*i\n\x0f\x41\x63\x63\x65ssCondition\x12\x0f\n\x0b\x41\x43_APPROVAL\x10\x00\x12\x0e\n\nAC_CHECKIN\x10\x01\x12\n\n\x06\x41\x43_MFA\x10\x02\x12\x0b\n\x07\x41\x43_TIME\x10\x03\x12\r\n\tAC_REASON\x10\x04\x12\r\n\tAC_TICKET\x10\x05*\x84\x01\n\tDayOfWeek\x12\x1b\n\x17\x44\x41Y_OF_WEEK_UNSPECIFIED\x10\x00\x12\n\n\x06MONDAY\x10\x01\x12\x0b\n\x07TUESDAY\x10\x02\x12\r\n\tWEDNESDAY\x10\x03\x12\x0c\n\x08THURSDAY\x10\x04\x12\n\n\x06\x46RIDAY\x10\x05\x12\x0c\n\x08SATURDAY\x10\x06\x12\n\n\x06SUNDAY\x10\x07*9\n\x11\x41pprovalQueueKind\x12\x10\n\x0c\x41QK_APPROVAL\x10\x00\x12\x12\n\x0e\x41QK_ESCALATION\x10\x01\x42$\n\x18\x63om.keepersecurity.protoB\x08Workflowb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -21,14 +21,14 @@ if not _descriptor._USE_C_DESCRIPTORS: _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'\n\030com.keepersecurity.protoB\010Workflow' - _globals['_WORKFLOWSTAGE']._serialized_start=1974 - _globals['_WORKFLOWSTAGE']._serialized_end=2065 - _globals['_ACCESSCONDITION']._serialized_start=2067 - _globals['_ACCESSCONDITION']._serialized_end=2172 - _globals['_DAYOFWEEK']._serialized_start=2175 - _globals['_DAYOFWEEK']._serialized_end=2307 - _globals['_APPROVALQUEUEKIND']._serialized_start=2309 - _globals['_APPROVALQUEUEKIND']._serialized_end=2366 + _globals['_WORKFLOWSTAGE']._serialized_start=2198 + _globals['_WORKFLOWSTAGE']._serialized_end=2289 + _globals['_ACCESSCONDITION']._serialized_start=2291 + _globals['_ACCESSCONDITION']._serialized_end=2396 + _globals['_DAYOFWEEK']._serialized_start=2399 + _globals['_DAYOFWEEK']._serialized_end=2531 + _globals['_APPROVALQUEUEKIND']._serialized_start=2533 + _globals['_APPROVALQUEUEKIND']._serialized_end=2590 _globals['_WORKFLOWAPPROVER']._serialized_start=46 _globals['_WORKFLOWAPPROVER']._serialized_end=176 _globals['_WORKFLOWPARAMETERS']._serialized_start=179 @@ -55,8 +55,10 @@ _globals['_APPROVALREQUESTS']._serialized_end=1750 _globals['_TIMEOFDAYRANGE']._serialized_start=1752 _globals['_TIMEOFDAYRANGE']._serialized_end=1804 - _globals['_TEMPORALACCESSFILTER']._serialized_start=1807 - _globals['_TEMPORALACCESSFILTER']._serialized_end=1935 - _globals['_AUTHORIZEDUSERS']._serialized_start=1937 - _globals['_AUTHORIZEDUSERS']._serialized_end=1972 + _globals['_APPROVALQUEUEENTRY']._serialized_start=1807 + _globals['_APPROVALQUEUEENTRY']._serialized_end=2028 + _globals['_TEMPORALACCESSFILTER']._serialized_start=2031 + _globals['_TEMPORALACCESSFILTER']._serialized_end=2159 + _globals['_AUTHORIZEDUSERS']._serialized_start=2161 + _globals['_AUTHORIZEDUSERS']._serialized_end=2196 # @@protoc_insertion_point(module_scope) diff --git a/keepercommander/proto/workflow_pb2.pyi b/keepercommander/proto/workflow_pb2.pyi index 410c64d35..8a1074912 100644 --- a/keepercommander/proto/workflow_pb2.pyi +++ b/keepercommander/proto/workflow_pb2.pyi @@ -216,6 +216,20 @@ class TimeOfDayRange(_message.Message): endTime: int def __init__(self, startTime: _Optional[int] = ..., endTime: _Optional[int] = ...) -> None: ... +class ApprovalQueueEntry(_message.Message): + __slots__ = ("flowRef", "approverRef", "kind", "notifyAtMs", "requesterUserId") + FLOWREF_FIELD_NUMBER: _ClassVar[int] + APPROVERREF_FIELD_NUMBER: _ClassVar[int] + KIND_FIELD_NUMBER: _ClassVar[int] + NOTIFYATMS_FIELD_NUMBER: _ClassVar[int] + REQUESTERUSERID_FIELD_NUMBER: _ClassVar[int] + flowRef: _GraphSync_pb2.GraphSyncRef + approverRef: _GraphSync_pb2.GraphSyncRef + kind: ApprovalQueueKind + notifyAtMs: int + requesterUserId: int + def __init__(self, flowRef: _Optional[_Union[_GraphSync_pb2.GraphSyncRef, _Mapping]] = ..., approverRef: _Optional[_Union[_GraphSync_pb2.GraphSyncRef, _Mapping]] = ..., kind: _Optional[_Union[ApprovalQueueKind, str]] = ..., notifyAtMs: _Optional[int] = ..., requesterUserId: _Optional[int] = ...) -> None: ... + class TemporalAccessFilter(_message.Message): __slots__ = ("timeRanges", "allowedDays", "timeZone") TIMERANGES_FIELD_NUMBER: _ClassVar[int] From 596364c07ad1ebb4ddf3d28a5619e97e21034db0 Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Wed, 18 Feb 2026 15:36:05 +0530 Subject: [PATCH 09/11] Fix start workflow to use flow_uid --- keepercommander/commands/workflow/workflow_commands.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/keepercommander/commands/workflow/workflow_commands.py b/keepercommander/commands/workflow/workflow_commands.py index 0f5ca4b0a..ade4c8479 100644 --- a/keepercommander/commands/workflow/workflow_commands.py +++ b/keepercommander/commands/workflow/workflow_commands.py @@ -1626,14 +1626,18 @@ def execute(self, params: KeeperParams, **kwargs): state = workflow_pb2.WorkflowState() state.resource.CopyFrom(create_record_ref(record_uid_bytes, record.title)) else: - # Treat as flow UID — query state to get record info, then start - uid_bytes = utils.base64_url_decode(uid) + # Treat as flow UID + try: + uid_bytes = utils.base64_url_decode(uid) + except Exception: + raise CommandError('', f'"{uid}" is not a valid record UID/name or flow UID') state = workflow_pb2.WorkflowState() state.flowUid = uid_bytes + state.resource.CopyFrom(create_workflow_ref(uid_bytes)) # Make API call try: - response = _post_request_to_router( + _post_request_to_router( params, 'start_workflow', rq_proto=state From eb1cadbe052314693dc8903e6643e60b126119d0 Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Fri, 20 Feb 2026 11:03:10 +0530 Subject: [PATCH 10/11] Update workflow protobuf to fix state using -r --- .../commands/workflow/workflow_commands.py | 160 ++++++++---------- keepercommander/proto/workflow_pb2.py | 44 ++--- keepercommander/proto/workflow_pb2.pyi | 8 +- 3 files changed, 98 insertions(+), 114 deletions(-) diff --git a/keepercommander/commands/workflow/workflow_commands.py b/keepercommander/commands/workflow/workflow_commands.py index ade4c8479..f7920ff93 100644 --- a/keepercommander/commands/workflow/workflow_commands.py +++ b/keepercommander/commands/workflow/workflow_commands.py @@ -202,77 +202,87 @@ def check_workflow_access(params: KeeperParams, record_uid: str): Returns {'allowed': False, 'require_mfa': False} if access is blocked. """ result = {'allowed': True, 'require_mfa': False} - try: - # Step 1: Check if the record has a workflow configured - record_uid_bytes = utils.base64_url_decode(record_uid) - record = vault.KeeperRecord.load(params, record_uid) - record_name = record.title if record else record_uid + + # Step 1: Check if the record has a workflow configured + record_uid_bytes = utils.base64_url_decode(record_uid) + record = vault.KeeperRecord.load(params, record_uid) + record_name = record.title if record else record_uid - ref = create_record_ref(record_uid_bytes, record_name) + ref = create_record_ref(record_uid_bytes, record_name) + + try: config_response = _post_request_to_router( params, 'read_workflow_config', rq_proto=ref, rs_type=workflow_pb2.WorkflowConfig ) + except Exception: + # If config read fails (network issue, etc), allow access. + # Backend/gateway will still enforce restrictions server-side. + return result - if config_response is None: - # No workflow configured on this record — access is unrestricted - return result + if config_response is None: + # No workflow configured on this record — access is unrestricted + return result - # Check if MFA is required - if config_response.parameters and config_response.parameters.requireMFA: - result['require_mfa'] = True + # Remember MFA requirement — only applied if access is granted + mfa_required = bool(config_response.parameters and config_response.parameters.requireMFA) - # Step 2: Workflow exists — check user's access state for this record - state_rq = workflow_pb2.WorkflowState() - state_rq.resource.CopyFrom(ref) - state_response = _post_request_to_router( + # Step 2: Get all user's workflows and find the one for this record + try: + user_state = _post_request_to_router( params, - 'get_workflow_state', - rq_proto=state_rq, - rs_type=workflow_pb2.WorkflowState + 'get_user_access_state', + rs_type=workflow_pb2.UserAccessState ) - - if state_response and state_response.status: - stage = state_response.status.stage - - if stage == workflow_pb2.WS_STARTED: - # User has an active checkout — allow access - return result - - if stage == workflow_pb2.WS_READY_TO_START: - print(f"\n{bcolors.WARNING}Workflow access approved but not yet checked out.{bcolors.ENDC}") - print(f"Run: {bcolors.OKBLUE}pam workflow start {record_uid}{bcolors.ENDC} to check out the record.\n") - result['allowed'] = False - return result - - if stage == workflow_pb2.WS_WAITING: - conditions = state_response.status.conditions - cond_str = format_access_conditions(conditions) if conditions else 'approval' - print(f"\n{bcolors.WARNING}Workflow access is pending: waiting for {cond_str}.{bcolors.ENDC}") - print(f"Your request is being processed. Please wait for approval.\n") - result['allowed'] = False - return result - - if stage == workflow_pb2.WS_NEEDS_ACTION: - print(f"\n{bcolors.WARNING}Workflow requires additional action before access is granted.{bcolors.ENDC}") - print(f"Run: {bcolors.OKBLUE}pam workflow state --record {record_uid}{bcolors.ENDC} to see details.\n") - result['allowed'] = False - return result - - # No active workflow state — user hasn't requested access yet - print(f"\n{bcolors.WARNING}This record is protected by a workflow.{bcolors.ENDC}") - print(f"You must request access before connecting.") - print(f"Run: {bcolors.OKBLUE}pam workflow request {record_uid}{bcolors.ENDC} to request access.\n") - result['allowed'] = False - return result - except Exception: - # If workflow check fails (e.g. network issue), allow access. - # The backend/gateway should still enforce restrictions server-side. + # If user state check fails (network issue), allow access return result + # Find workflow for this specific record + workflow_for_record = None + if user_state and user_state.workflows: + for wf in user_state.workflows: + if wf.resource and wf.resource.value == record_uid_bytes: + workflow_for_record = wf + break + + if workflow_for_record and workflow_for_record.status: + stage = workflow_for_record.status.stage + + if stage == workflow_pb2.WS_STARTED: + # User has an active checkout — allow access, apply MFA if configured + result['require_mfa'] = mfa_required + return result + + if stage == workflow_pb2.WS_READY_TO_START: + print(f"\n{bcolors.WARNING}Workflow access approved but not yet checked out.{bcolors.ENDC}") + print(f"Run: {bcolors.OKBLUE}pam workflow start {record_uid}{bcolors.ENDC} to check out the record.\n") + result['allowed'] = False + return result + + if stage == workflow_pb2.WS_WAITING: + conditions = workflow_for_record.status.conditions + cond_str = format_access_conditions(conditions) if conditions else 'approval' + print(f"\n{bcolors.WARNING}Workflow access is pending: waiting for {cond_str}.{bcolors.ENDC}") + print(f"Your request is being processed. Please wait for approval.\n") + result['allowed'] = False + return result + + if stage == workflow_pb2.WS_NEEDS_ACTION: + print(f"\n{bcolors.WARNING}Workflow requires additional action before access is granted.{bcolors.ENDC}") + print(f"Run: {bcolors.OKBLUE}pam workflow state --flow-uid {utils.base64_url_encode(workflow_for_record.flowUid)}{bcolors.ENDC} to see details.\n") + result['allowed'] = False + return result + + # No active workflow for this record — user hasn't requested access yet + print(f"\n{bcolors.WARNING}This record is protected by a workflow.{bcolors.ENDC}") + print(f"You must request access before connecting.") + print(f"Run: {bcolors.OKBLUE}pam workflow request {record_uid}{bcolors.ENDC} to request access.\n") + result['allowed'] = False + return result + def check_workflow_and_prompt_2fa(params: KeeperParams, record_uid: str): """ @@ -338,7 +348,8 @@ def prompt_workflow_2fa(params: KeeperParams): return None if not tfa_list.channels: - print(f"{bcolors.FAIL}No 2FA methods configured on your account. Cannot proceed.{bcolors.ENDC}") + print(f"\n{bcolors.FAIL}This workflow requires 2FA verification{bcolors.ENDC}") + print(f"Your account does not have any 2FA methods configured. For available methods, run: {bcolors.OKBLUE}2fa add -h{bcolors.ENDC}") return None # Build a list of usable channels @@ -460,7 +471,6 @@ def prompt_workflow_2fa(params: KeeperParams): challenge = _json.loads(challenge_rs.challenge) from ...yubikey.yubikey import yubikey_authenticate - print(f"\n{bcolors.OKBLUE}Touch the flashing Security key to authenticate...{bcolors.ENDC}\n") response = yubikey_authenticate(challenge) if response: @@ -949,9 +959,6 @@ def execute(self, params: KeeperParams, **kwargs): # Add approvers for approver in response.approvers: approver_info = {'escalation': approver.escalation} - if approver.escalationAfterMs: - approver_info['escalation_after'] = format_duration_from_milliseconds(approver.escalationAfterMs) - approver_info['escalation_after_ms'] = approver.escalationAfterMs if approver.HasField('user'): approver_info['type'] = 'user' approver_info['email'] = approver.user @@ -991,8 +998,6 @@ def execute(self, params: KeeperParams, **kwargs): print(f"\n{bcolors.BOLD}Approvers ({len(response.approvers)}):{bcolors.ENDC}") for idx, approver in enumerate(response.approvers, 1): escalation = ' (Escalation)' if approver.escalation else '' - if approver.escalation and approver.escalationAfterMs: - escalation += f' — after {format_duration_from_milliseconds(approver.escalationAfterMs)}' if approver.HasField('user'): print(f" {idx}. User: {approver.user}{escalation}") elif approver.HasField('userId'): @@ -1082,13 +1087,11 @@ class WorkflowAddApproversCommand(Command): Add approvers to a workflow. Approvers are users or teams who can approve access requests. - You can mark approvers as "escalated" for handling delayed approvals, - with an optional auto-escalation delay. + You can mark approvers as "escalated" for handling delayed approvals. Example: pam workflow add-approver --user alice@company.com pam workflow add-approver --team --escalation - pam workflow add-approver --user bob@company.com --escalation --escalation-after 30m """ parser = argparse.ArgumentParser(prog='pam workflow add-approver', description='Add approvers to a workflow') @@ -1098,8 +1101,6 @@ class WorkflowAddApproversCommand(Command): parser.add_argument('-t', '--team', action='append', help='Team name or UID to add as approver (can specify multiple times)') parser.add_argument('-e', '--escalation', action='store_true', help='Mark as escalation approver') - parser.add_argument('--escalation-after', dest='escalation_after', - help='Auto-escalate after duration (e.g. 30m, 1h, 2d). Requires --escalation') parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], default='table', help='Output format') @@ -1112,16 +1113,6 @@ def execute(self, params: KeeperParams, **kwargs): users = kwargs.get('user') or [] teams = kwargs.get('team') or [] is_escalation = kwargs.get('escalation', False) - escalation_after = kwargs.get('escalation_after') - - if escalation_after and not is_escalation: - raise CommandError('', '--escalation-after requires --escalation flag') - - escalation_after_ms = 0 - if escalation_after: - escalation_after_ms = parse_duration_to_milliseconds(escalation_after) - if not escalation_after_ms: - raise CommandError('', f'Invalid escalation duration: {escalation_after}. Use format like 30m, 1h, 2d') if not users and not teams: raise CommandError('', 'Must specify at least one --user or --team') @@ -1149,8 +1140,6 @@ def execute(self, params: KeeperParams, **kwargs): approver = workflow_pb2.WorkflowApprover() approver.user = user_email approver.escalation = is_escalation - if escalation_after_ms: - approver.escalationAfterMs = escalation_after_ms config.approvers.append(approver) # Add team approvers (accepts team UID or team name) @@ -1159,8 +1148,6 @@ def execute(self, params: KeeperParams, **kwargs): approver = workflow_pb2.WorkflowApprover() approver.teamUid = utils.base64_url_decode(resolved_team_uid) approver.escalation = is_escalation - if escalation_after_ms: - approver.escalationAfterMs = escalation_after_ms config.approvers.append(approver) # Make API call @@ -1177,8 +1164,7 @@ def execute(self, params: KeeperParams, **kwargs): 'record_uid': record_uid, 'record_name': record.title, 'approvers_added': len(users) + len(teams), - 'escalation': is_escalation, - 'escalation_after_ms': escalation_after_ms or None + 'escalation': is_escalation } print(json.dumps(result, indent=2)) else: @@ -1187,8 +1173,6 @@ def execute(self, params: KeeperParams, **kwargs): print(f"Added {len(users) + len(teams)} approver(s)") if is_escalation: print("Type: Escalation approver") - if escalation_after_ms: - print(f"Escalation after: {format_duration_from_milliseconds(escalation_after_ms)}") print() except Exception as e: @@ -1768,7 +1752,7 @@ def execute(self, params: KeeperParams, **kwargs): # Create WorkflowApprovalOrDenial with deny=False for approval approval = workflow_pb2.WorkflowApprovalOrDenial() - approval.resource.CopyFrom(create_workflow_ref(flow_uid_bytes)) + approval.flowUid = flow_uid_bytes approval.deny = False # Make API call @@ -1816,7 +1800,7 @@ def execute(self, params: KeeperParams, **kwargs): # Create WorkflowApprovalOrDenial with deny=True for denial denial = workflow_pb2.WorkflowApprovalOrDenial() - denial.resource.CopyFrom(create_workflow_ref(flow_uid_bytes)) + denial.flowUid = flow_uid_bytes denial.deny = True if reason: denial.denialReason = reason diff --git a/keepercommander/proto/workflow_pb2.py b/keepercommander/proto/workflow_pb2.py index 3b54ba70e..b28d14dac 100644 --- a/keepercommander/proto/workflow_pb2.py +++ b/keepercommander/proto/workflow_pb2.py @@ -13,7 +13,7 @@ from . import GraphSync_pb2 as GraphSync__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0eworkflow.proto\x12\x08Workflow\x1a\x0fGraphSync.proto\"\x82\x01\n\x10WorkflowApprover\x12\x0e\n\x04user\x18\x01 \x01(\tH\x00\x12\x10\n\x06userId\x18\x02 \x01(\x05H\x00\x12\x11\n\x07teamUid\x18\x03 \x01(\x0cH\x00\x12\x12\n\nescalation\x18\x04 \x01(\x08\x12\x19\n\x11\x65scalationAfterMs\x18\x05 \x01(\x03\x42\n\n\x08\x61pprover\"\x9d\x02\n\x12WorkflowParameters\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x17\n\x0f\x61pprovalsNeeded\x18\x02 \x01(\x05\x12\x16\n\x0e\x63heckoutNeeded\x18\x03 \x01(\x08\x12\x1d\n\x15startAccessOnApproval\x18\x04 \x01(\x08\x12\x15\n\rrequireReason\x18\x05 \x01(\x08\x12\x15\n\rrequireTicket\x18\x06 \x01(\x08\x12\x12\n\nrequireMFA\x18\x07 \x01(\x08\x12\x14\n\x0c\x61\x63\x63\x65ssLength\x18\x08 \x01(\x03\x12\x34\n\x0c\x61llowedTimes\x18\t \x01(\x0b\x32\x1e.Workflow.TemporalAccessFilter\"\x84\x01\n\x0eWorkflowConfig\x12\x30\n\nparameters\x18\x01 \x01(\x0b\x32\x1c.Workflow.WorkflowParameters\x12-\n\tapprovers\x18\x02 \x03(\x0b\x32\x1a.Workflow.WorkflowApprover\x12\x11\n\tcreatedOn\x18\x03 \x01(\x03\"\xd0\x01\n\x0eWorkflowStatus\x12&\n\x05stage\x18\x01 \x01(\x0e\x32\x17.Workflow.WorkflowStage\x12-\n\nconditions\x18\x02 \x03(\x0e\x32\x19.Workflow.AccessCondition\x12.\n\napprovedBy\x18\x03 \x03(\x0b\x32\x1a.Workflow.WorkflowApproval\x12\x11\n\tstartedOn\x18\x04 \x01(\x03\x12\x11\n\texpiresOn\x18\x05 \x01(\x03\x12\x11\n\tescalated\x18\x06 \x01(\x08\"\xbd\x01\n\x0fWorkflowProcess\x12\x0f\n\x07\x66lowUid\x18\x01 \x01(\x0c\x12\x0e\n\x06userId\x18\x02 \x01(\x03\x12)\n\x08resource\x18\x03 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x11\n\tstartedOn\x18\x04 \x01(\x03\x12\x11\n\texpiresOn\x18\x05 \x01(\x03\x12\x0e\n\x06reason\x18\x06 \x01(\x0c\x12\x13\n\x0bmfaVerified\x18\x07 \x01(\x08\x12\x13\n\x0b\x65xternalRef\x18\x08 \x01(\x0c\"U\n\x10WorkflowApproval\x12\x0e\n\x06userId\x18\x01 \x01(\x03\x12\x0c\n\x04user\x18\x02 \x01(\t\x12\x0f\n\x07\x66lowUid\x18\x03 \x01(\x0c\x12\x12\n\napprovedOn\x18\x04 \x01(\x03\"\xcb\x01\n\x0fWorkflowContext\x12\x30\n\x0eworkflowConfig\x18\x01 \x01(\x0b\x32\x18.Workflow.WorkflowConfig\x12+\n\x08workflow\x18\x02 \x01(\x0b\x32\x19.Workflow.WorkflowProcess\x12-\n\tapprovals\x18\x03 \x03(\x0b\x32\x1a.Workflow.WorkflowApproval\x12*\n\x07\x62locker\x18\x04 \x01(\x0b\x32\x19.Workflow.WorkflowProcess\"u\n\rWorkflowState\x12\x0f\n\x07\x66lowUid\x18\x01 \x01(\x0c\x12)\n\x08resource\x18\x02 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12(\n\x06status\x18\x03 \x01(\x0b\x32\x18.Workflow.WorkflowStatus\"b\n\x15WorkflowAccessRequest\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x0e\n\x06reason\x18\x02 \x01(\x0c\x12\x0e\n\x06ticket\x18\x03 \x01(\x0c\"i\n\x18WorkflowApprovalOrDenial\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x0c\n\x04\x64\x65ny\x18\x02 \x01(\x08\x12\x14\n\x0c\x64\x65nialReason\x18\x03 \x01(\t\"=\n\x0fUserAccessState\x12*\n\tworkflows\x18\x01 \x03(\x0b\x32\x17.Workflow.WorkflowState\"@\n\x10\x41pprovalRequests\x12,\n\tworkflows\x18\x01 \x03(\x0b\x32\x19.Workflow.WorkflowProcess\"4\n\x0eTimeOfDayRange\x12\x11\n\tstartTime\x18\x01 \x01(\x05\x12\x0f\n\x07\x65ndTime\x18\x02 \x01(\x05\"\xdd\x01\n\x12\x41pprovalQueueEntry\x12(\n\x07\x66lowRef\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12,\n\x0b\x61pproverRef\x18\x02 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12)\n\x04kind\x18\x03 \x01(\x0e\x32\x1b.Workflow.ApprovalQueueKind\x12\x12\n\nnotifyAtMs\x18\x04 \x01(\x03\x12\x1c\n\x0frequesterUserId\x18\x05 \x01(\x03H\x00\x88\x01\x01\x42\x12\n\x10_requesterUserId\"\x80\x01\n\x14TemporalAccessFilter\x12,\n\ntimeRanges\x18\x01 \x03(\x0b\x32\x18.Workflow.TimeOfDayRange\x12(\n\x0b\x61llowedDays\x18\x02 \x03(\x0e\x32\x13.Workflow.DayOfWeek\x12\x10\n\x08timeZone\x18\x03 \x01(\t\"#\n\x0f\x41uthorizedUsers\x12\x10\n\x08username\x18\x01 \x03(\t*[\n\rWorkflowStage\x12\x15\n\x11WS_READY_TO_START\x10\x00\x12\x0e\n\nWS_STARTED\x10\x01\x12\x13\n\x0fWS_NEEDS_ACTION\x10\x02\x12\x0e\n\nWS_WAITING\x10\x03*i\n\x0f\x41\x63\x63\x65ssCondition\x12\x0f\n\x0b\x41\x43_APPROVAL\x10\x00\x12\x0e\n\nAC_CHECKIN\x10\x01\x12\n\n\x06\x41\x43_MFA\x10\x02\x12\x0b\n\x07\x41\x43_TIME\x10\x03\x12\r\n\tAC_REASON\x10\x04\x12\r\n\tAC_TICKET\x10\x05*\x84\x01\n\tDayOfWeek\x12\x1b\n\x17\x44\x41Y_OF_WEEK_UNSPECIFIED\x10\x00\x12\n\n\x06MONDAY\x10\x01\x12\x0b\n\x07TUESDAY\x10\x02\x12\r\n\tWEDNESDAY\x10\x03\x12\x0c\n\x08THURSDAY\x10\x04\x12\n\n\x06\x46RIDAY\x10\x05\x12\x0c\n\x08SATURDAY\x10\x06\x12\n\n\x06SUNDAY\x10\x07*9\n\x11\x41pprovalQueueKind\x12\x10\n\x0c\x41QK_APPROVAL\x10\x00\x12\x12\n\x0e\x41QK_ESCALATION\x10\x01\x42$\n\x18\x63om.keepersecurity.protoB\x08Workflowb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0eworkflow.proto\x12\x08Workflow\x1a\x0fGraphSync.proto\"\x82\x01\n\x10WorkflowApprover\x12\x0e\n\x04user\x18\x01 \x01(\tH\x00\x12\x10\n\x06userId\x18\x02 \x01(\x05H\x00\x12\x11\n\x07teamUid\x18\x03 \x01(\x0cH\x00\x12\x12\n\nescalation\x18\x04 \x01(\x08\x12\x19\n\x11\x65scalationAfterMs\x18\x05 \x01(\x03\x42\n\n\x08\x61pprover\"\x9d\x02\n\x12WorkflowParameters\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x17\n\x0f\x61pprovalsNeeded\x18\x02 \x01(\x05\x12\x16\n\x0e\x63heckoutNeeded\x18\x03 \x01(\x08\x12\x1d\n\x15startAccessOnApproval\x18\x04 \x01(\x08\x12\x15\n\rrequireReason\x18\x05 \x01(\x08\x12\x15\n\rrequireTicket\x18\x06 \x01(\x08\x12\x12\n\nrequireMFA\x18\x07 \x01(\x08\x12\x14\n\x0c\x61\x63\x63\x65ssLength\x18\x08 \x01(\x03\x12\x34\n\x0c\x61llowedTimes\x18\t \x01(\x0b\x32\x1e.Workflow.TemporalAccessFilter\"\x84\x01\n\x0eWorkflowConfig\x12\x30\n\nparameters\x18\x01 \x01(\x0b\x32\x1c.Workflow.WorkflowParameters\x12-\n\tapprovers\x18\x02 \x03(\x0b\x32\x1a.Workflow.WorkflowApprover\x12\x11\n\tcreatedOn\x18\x03 \x01(\x03\"\xd0\x01\n\x0eWorkflowStatus\x12&\n\x05stage\x18\x01 \x01(\x0e\x32\x17.Workflow.WorkflowStage\x12-\n\nconditions\x18\x02 \x03(\x0e\x32\x19.Workflow.AccessCondition\x12.\n\napprovedBy\x18\x03 \x03(\x0b\x32\x1a.Workflow.WorkflowApproval\x12\x11\n\tstartedOn\x18\x04 \x01(\x03\x12\x11\n\texpiresOn\x18\x05 \x01(\x03\x12\x11\n\tescalated\x18\x06 \x01(\x08\"\xbd\x01\n\x0fWorkflowProcess\x12\x0f\n\x07\x66lowUid\x18\x01 \x01(\x0c\x12\x0e\n\x06userId\x18\x02 \x01(\x03\x12)\n\x08resource\x18\x03 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x11\n\tstartedOn\x18\x04 \x01(\x03\x12\x11\n\texpiresOn\x18\x05 \x01(\x03\x12\x0e\n\x06reason\x18\x06 \x01(\x0c\x12\x13\n\x0bmfaVerified\x18\x07 \x01(\x08\x12\x13\n\x0b\x65xternalRef\x18\x08 \x01(\x0c\"U\n\x10WorkflowApproval\x12\x0e\n\x06userId\x18\x01 \x01(\x03\x12\x0c\n\x04user\x18\x02 \x01(\t\x12\x0f\n\x07\x66lowUid\x18\x03 \x01(\x0c\x12\x12\n\napprovedOn\x18\x04 \x01(\x03\"\xcb\x01\n\x0fWorkflowContext\x12\x30\n\x0eworkflowConfig\x18\x01 \x01(\x0b\x32\x18.Workflow.WorkflowConfig\x12+\n\x08workflow\x18\x02 \x01(\x0b\x32\x19.Workflow.WorkflowProcess\x12-\n\tapprovals\x18\x03 \x03(\x0b\x32\x1a.Workflow.WorkflowApproval\x12*\n\x07\x62locker\x18\x04 \x01(\x0b\x32\x19.Workflow.WorkflowProcess\"u\n\rWorkflowState\x12\x0f\n\x07\x66lowUid\x18\x01 \x01(\x0c\x12)\n\x08resource\x18\x02 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12(\n\x06status\x18\x03 \x01(\x0b\x32\x18.Workflow.WorkflowStatus\"b\n\x15WorkflowAccessRequest\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12\x0e\n\x06reason\x18\x02 \x01(\x0c\x12\x0e\n\x06ticket\x18\x03 \x01(\x0c\"O\n\x18WorkflowApprovalOrDenial\x12\x0f\n\x07\x66lowUid\x18\x01 \x01(\x0c\x12\x0c\n\x04\x64\x65ny\x18\x02 \x01(\x08\x12\x14\n\x0c\x64\x65nialReason\x18\x03 \x01(\t\"=\n\x0fUserAccessState\x12*\n\tworkflows\x18\x01 \x03(\x0b\x32\x17.Workflow.WorkflowState\"@\n\x10\x41pprovalRequests\x12,\n\tworkflows\x18\x01 \x03(\x0b\x32\x19.Workflow.WorkflowProcess\"4\n\x0eTimeOfDayRange\x12\x11\n\tstartTime\x18\x01 \x01(\x05\x12\x0f\n\x07\x65ndTime\x18\x02 \x01(\x05\"\xdd\x01\n\x12\x41pprovalQueueEntry\x12(\n\x07\x66lowRef\x18\x01 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12,\n\x0b\x61pproverRef\x18\x02 \x01(\x0b\x32\x17.GraphSync.GraphSyncRef\x12)\n\x04kind\x18\x03 \x01(\x0e\x32\x1b.Workflow.ApprovalQueueKind\x12\x12\n\nnotifyAtMs\x18\x04 \x01(\x03\x12\x1c\n\x0frequesterUserId\x18\x05 \x01(\x03H\x00\x88\x01\x01\x42\x12\n\x10_requesterUserId\"\x80\x01\n\x14TemporalAccessFilter\x12,\n\ntimeRanges\x18\x01 \x03(\x0b\x32\x18.Workflow.TimeOfDayRange\x12(\n\x0b\x61llowedDays\x18\x02 \x03(\x0e\x32\x13.Workflow.DayOfWeek\x12\x10\n\x08timeZone\x18\x03 \x01(\t\"#\n\x0f\x41uthorizedUsers\x12\x10\n\x08username\x18\x01 \x03(\t*[\n\rWorkflowStage\x12\x15\n\x11WS_READY_TO_START\x10\x00\x12\x0e\n\nWS_STARTED\x10\x01\x12\x13\n\x0fWS_NEEDS_ACTION\x10\x02\x12\x0e\n\nWS_WAITING\x10\x03*i\n\x0f\x41\x63\x63\x65ssCondition\x12\x0f\n\x0b\x41\x43_APPROVAL\x10\x00\x12\x0e\n\nAC_CHECKIN\x10\x01\x12\n\n\x06\x41\x43_MFA\x10\x02\x12\x0b\n\x07\x41\x43_TIME\x10\x03\x12\r\n\tAC_REASON\x10\x04\x12\r\n\tAC_TICKET\x10\x05*\x84\x01\n\tDayOfWeek\x12\x1b\n\x17\x44\x41Y_OF_WEEK_UNSPECIFIED\x10\x00\x12\n\n\x06MONDAY\x10\x01\x12\x0b\n\x07TUESDAY\x10\x02\x12\r\n\tWEDNESDAY\x10\x03\x12\x0c\n\x08THURSDAY\x10\x04\x12\n\n\x06\x46RIDAY\x10\x05\x12\x0c\n\x08SATURDAY\x10\x06\x12\n\n\x06SUNDAY\x10\x07*9\n\x11\x41pprovalQueueKind\x12\x10\n\x0c\x41QK_APPROVAL\x10\x00\x12\x12\n\x0e\x41QK_ESCALATION\x10\x01\x42$\n\x18\x63om.keepersecurity.protoB\x08Workflowb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -21,14 +21,14 @@ if not _descriptor._USE_C_DESCRIPTORS: _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'\n\030com.keepersecurity.protoB\010Workflow' - _globals['_WORKFLOWSTAGE']._serialized_start=2198 - _globals['_WORKFLOWSTAGE']._serialized_end=2289 - _globals['_ACCESSCONDITION']._serialized_start=2291 - _globals['_ACCESSCONDITION']._serialized_end=2396 - _globals['_DAYOFWEEK']._serialized_start=2399 - _globals['_DAYOFWEEK']._serialized_end=2531 - _globals['_APPROVALQUEUEKIND']._serialized_start=2533 - _globals['_APPROVALQUEUEKIND']._serialized_end=2590 + _globals['_WORKFLOWSTAGE']._serialized_start=2172 + _globals['_WORKFLOWSTAGE']._serialized_end=2263 + _globals['_ACCESSCONDITION']._serialized_start=2265 + _globals['_ACCESSCONDITION']._serialized_end=2370 + _globals['_DAYOFWEEK']._serialized_start=2373 + _globals['_DAYOFWEEK']._serialized_end=2505 + _globals['_APPROVALQUEUEKIND']._serialized_start=2507 + _globals['_APPROVALQUEUEKIND']._serialized_end=2564 _globals['_WORKFLOWAPPROVER']._serialized_start=46 _globals['_WORKFLOWAPPROVER']._serialized_end=176 _globals['_WORKFLOWPARAMETERS']._serialized_start=179 @@ -48,17 +48,17 @@ _globals['_WORKFLOWACCESSREQUEST']._serialized_start=1416 _globals['_WORKFLOWACCESSREQUEST']._serialized_end=1514 _globals['_WORKFLOWAPPROVALORDENIAL']._serialized_start=1516 - _globals['_WORKFLOWAPPROVALORDENIAL']._serialized_end=1621 - _globals['_USERACCESSSTATE']._serialized_start=1623 - _globals['_USERACCESSSTATE']._serialized_end=1684 - _globals['_APPROVALREQUESTS']._serialized_start=1686 - _globals['_APPROVALREQUESTS']._serialized_end=1750 - _globals['_TIMEOFDAYRANGE']._serialized_start=1752 - _globals['_TIMEOFDAYRANGE']._serialized_end=1804 - _globals['_APPROVALQUEUEENTRY']._serialized_start=1807 - _globals['_APPROVALQUEUEENTRY']._serialized_end=2028 - _globals['_TEMPORALACCESSFILTER']._serialized_start=2031 - _globals['_TEMPORALACCESSFILTER']._serialized_end=2159 - _globals['_AUTHORIZEDUSERS']._serialized_start=2161 - _globals['_AUTHORIZEDUSERS']._serialized_end=2196 + _globals['_WORKFLOWAPPROVALORDENIAL']._serialized_end=1595 + _globals['_USERACCESSSTATE']._serialized_start=1597 + _globals['_USERACCESSSTATE']._serialized_end=1658 + _globals['_APPROVALREQUESTS']._serialized_start=1660 + _globals['_APPROVALREQUESTS']._serialized_end=1724 + _globals['_TIMEOFDAYRANGE']._serialized_start=1726 + _globals['_TIMEOFDAYRANGE']._serialized_end=1778 + _globals['_APPROVALQUEUEENTRY']._serialized_start=1781 + _globals['_APPROVALQUEUEENTRY']._serialized_end=2002 + _globals['_TEMPORALACCESSFILTER']._serialized_start=2005 + _globals['_TEMPORALACCESSFILTER']._serialized_end=2133 + _globals['_AUTHORIZEDUSERS']._serialized_start=2135 + _globals['_AUTHORIZEDUSERS']._serialized_end=2170 # @@protoc_insertion_point(module_scope) diff --git a/keepercommander/proto/workflow_pb2.pyi b/keepercommander/proto/workflow_pb2.pyi index 8a1074912..2e186538d 100644 --- a/keepercommander/proto/workflow_pb2.pyi +++ b/keepercommander/proto/workflow_pb2.pyi @@ -187,14 +187,14 @@ class WorkflowAccessRequest(_message.Message): def __init__(self, resource: _Optional[_Union[_GraphSync_pb2.GraphSyncRef, _Mapping]] = ..., reason: _Optional[bytes] = ..., ticket: _Optional[bytes] = ...) -> None: ... class WorkflowApprovalOrDenial(_message.Message): - __slots__ = ("resource", "deny", "denialReason") - RESOURCE_FIELD_NUMBER: _ClassVar[int] + __slots__ = ("flowUid", "deny", "denialReason") + FLOWUID_FIELD_NUMBER: _ClassVar[int] DENY_FIELD_NUMBER: _ClassVar[int] DENIALREASON_FIELD_NUMBER: _ClassVar[int] - resource: _GraphSync_pb2.GraphSyncRef + flowUid: bytes deny: bool denialReason: str - def __init__(self, resource: _Optional[_Union[_GraphSync_pb2.GraphSyncRef, _Mapping]] = ..., deny: _Optional[bool] = ..., denialReason: _Optional[str] = ...) -> None: ... + def __init__(self, flowUid: _Optional[bytes] = ..., deny: _Optional[bool] = ..., denialReason: _Optional[str] = ...) -> None: ... class UserAccessState(_message.Message): __slots__ = ("workflows",) From 124940246000abd0b492a6fe4ec4ccde7cbb3cea Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Mon, 23 Feb 2026 17:22:42 +0530 Subject: [PATCH 11/11] Refactor workflow code into files and folders --- keepercommander/commands/discoveryrotation.py | 2 +- keepercommander/commands/pam_launch/launch.py | 2 +- .../commands/tunnel_and_connections.py | 2 +- keepercommander/commands/workflow/__init__.py | 18 +- .../commands/workflow/approver_commands.py | 211 ++ .../commands/workflow/config_commands.py | 484 ++++ keepercommander/commands/workflow/helpers.py | 155 ++ keepercommander/commands/workflow/mfa.py | 309 +++ keepercommander/commands/workflow/registry.py | 82 + .../commands/workflow/requester_commands.py | 216 ++ .../commands/workflow/state_commands.py | 211 ++ .../commands/workflow/workflow_commands.py | 1964 ----------------- 12 files changed, 1674 insertions(+), 1982 deletions(-) create mode 100644 keepercommander/commands/workflow/approver_commands.py create mode 100644 keepercommander/commands/workflow/config_commands.py create mode 100644 keepercommander/commands/workflow/helpers.py create mode 100644 keepercommander/commands/workflow/mfa.py create mode 100644 keepercommander/commands/workflow/registry.py create mode 100644 keepercommander/commands/workflow/requester_commands.py create mode 100644 keepercommander/commands/workflow/state_commands.py delete mode 100644 keepercommander/commands/workflow/workflow_commands.py diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index b8166f5ad..15a789ae7 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -76,7 +76,7 @@ from .pam_debug.vertex import PAMDebugVertexCommand from .pam_import.commands import PAMProjectCommand from .pam_launch.launch import PAMLaunchCommand -from .workflow.workflow_commands import PAMWorkflowCommand +from .workflow import PAMWorkflowCommand from .pam_service.list import PAMActionServiceListCommand from .pam_service.add import PAMActionServiceAddCommand from .pam_service.remove import PAMActionServiceRemoveCommand diff --git a/keepercommander/commands/pam_launch/launch.py b/keepercommander/commands/pam_launch/launch.py index 456ba9370..b4e0458ee 100644 --- a/keepercommander/commands/pam_launch/launch.py +++ b/keepercommander/commands/pam_launch/launch.py @@ -286,7 +286,7 @@ def execute(self, params: KeeperParams, **kwargs): # Workflow access check and 2FA prompt try: - from ..workflow.workflow_commands import check_workflow_and_prompt_2fa + from ..workflow import check_workflow_and_prompt_2fa should_proceed, two_factor_value = check_workflow_and_prompt_2fa(params, record_uid) if not should_proceed: return diff --git a/keepercommander/commands/tunnel_and_connections.py b/keepercommander/commands/tunnel_and_connections.py index 592bd5fb4..9522a1dc4 100644 --- a/keepercommander/commands/tunnel_and_connections.py +++ b/keepercommander/commands/tunnel_and_connections.py @@ -546,7 +546,7 @@ def execute(self, params, **kwargs): # Workflow access check and 2FA prompt two_factor_value = None try: - from .workflow.workflow_commands import check_workflow_and_prompt_2fa + from .workflow import check_workflow_and_prompt_2fa should_proceed, two_factor_value = check_workflow_and_prompt_2fa(params, record_uid) if not should_proceed: return diff --git a/keepercommander/commands/workflow/__init__.py b/keepercommander/commands/workflow/__init__.py index df4416aa8..a25cc6909 100644 --- a/keepercommander/commands/workflow/__init__.py +++ b/keepercommander/commands/workflow/__init__.py @@ -5,23 +5,11 @@ # |_| # # Keeper Commander -# Copyright 2024 Keeper Security Inc. +# Copyright 2026 Keeper Security Inc. # Contact: ops@keepersecurity.com # -""" -Keeper PAM Workflow Commands - -This module implements commands for managing PAM workflows including: -- Configuration management (create, update, delete workflows) -- Approver management (add, remove approvers) -- Workflow state inspection (get status, list requests) -- Workflow actions (request access, approve, deny, check-in/out) - -Workflow commands are accessed via: pam workflow -""" - __all__ = ['PAMWorkflowCommand', 'check_workflow_access', 'check_workflow_and_prompt_2fa'] -from .workflow_commands import PAMWorkflowCommand, check_workflow_access, check_workflow_and_prompt_2fa - +from .registry import PAMWorkflowCommand +from .mfa import check_workflow_access, check_workflow_and_prompt_2fa diff --git a/keepercommander/commands/workflow/approver_commands.py b/keepercommander/commands/workflow/approver_commands.py new file mode 100644 index 000000000..23f282472 --- /dev/null +++ b/keepercommander/commands/workflow/approver_commands.py @@ -0,0 +1,211 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' {bcolors.ENDC}") + print() + + except Exception as e: + raise CommandError('', f'Failed to create workflow: {str(e)}') + + +class WorkflowReadCommand(Command): + parser = argparse.ArgumentParser( + prog='pam workflow read', + description='Read and display workflow configuration', + ) + parser.add_argument('record', help='Record UID or name') + parser.add_argument('--format', dest='format', action='store', + choices=['table', 'json'], default='table', help='Output format') + + def get_parser(self): + return WorkflowReadCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + record_uid, record = RecordResolver.resolve(params, kwargs.get('record')) + record_uid_bytes = utils.base64_url_decode(record_uid) + ref = ProtobufRefBuilder.record_ref(record_uid_bytes, record.title) + + try: + response = _post_request_to_router( + params, 'read_workflow_config', + rq_proto=ref, rs_type=workflow_pb2.WorkflowConfig, + ) + + if not response: + if kwargs.get('format') == 'json': + print(json.dumps({'status': 'no_workflow', 'message': 'No workflow configured'}, indent=2)) + else: + print(f"\n{bcolors.WARNING}No workflow configured for this record{bcolors.ENDC}\n") + print(f"Record: {record.title} ({record_uid})") + print(f"\nTo create a workflow, run:") + print(f" pam workflow create {record_uid}") + print() + return + + if kwargs.get('format') == 'json': + self._print_json(params, response, record_uid) + else: + self._print_table(params, response, record_uid) + + except Exception as e: + raise CommandError('', f'Failed to read workflow: {str(e)}') + + @staticmethod + def _print_json(params, response, record_uid): + result = { + 'record_uid': record_uid, + 'record_name': RecordResolver.resolve_name(params, response.parameters.resource), + 'parameters': { + 'approvals_needed': response.parameters.approvalsNeeded, + 'checkout_needed': response.parameters.checkoutNeeded, + 'start_access_on_approval': response.parameters.startAccessOnApproval, + 'require_reason': response.parameters.requireReason, + 'require_ticket': response.parameters.requireTicket, + 'require_mfa': response.parameters.requireMFA, + 'access_duration': WorkflowFormatter.format_duration(response.parameters.accessLength), + }, + 'approvers': [], + } + + for approver in response.approvers: + approver_info = {'escalation': approver.escalation} + if approver.HasField('user'): + approver_info['type'] = 'user' + approver_info['email'] = approver.user + elif approver.HasField('userId'): + approver_info['type'] = 'user_id' + approver_info['user_id'] = approver.userId + elif approver.HasField('teamUid'): + approver_info['type'] = 'team' + approver_info['team_uid'] = utils.base64_url_encode(approver.teamUid) + result['approvers'].append(approver_info) + + print(json.dumps(result, indent=2)) + + @staticmethod + def _print_table(params, response, record_uid): + print(f"\n{bcolors.OKBLUE}Workflow Configuration{bcolors.ENDC}\n") + print(f"Record: {RecordResolver.resolve_name(params, response.parameters.resource)}") + print(f"Record UID: {record_uid}") + + if response.createdOn: + created_date = datetime.fromtimestamp(response.createdOn / 1000) + print(f"Created: {created_date.strftime('%Y-%m-%d %H:%M:%S')}") + + p = response.parameters + print(f"\n{bcolors.BOLD}Access Parameters:{bcolors.ENDC}") + print(f" Approvals needed: {p.approvalsNeeded}") + print(f" Check-in/out required: {'Yes' if p.checkoutNeeded else 'No'}") + print(f" Access duration: {WorkflowFormatter.format_duration(p.accessLength)}") + print(f" Timer starts: {'On approval' if p.startAccessOnApproval else 'On check-out'}") + + print(f"\n{bcolors.BOLD}Requirements:{bcolors.ENDC}") + print(f" Reason required: {'Yes' if p.requireReason else 'No'}") + print(f" Ticket required: {'Yes' if p.requireTicket else 'No'}") + print(f" MFA required: {'Yes' if p.requireMFA else 'No'}") + + if response.approvers: + print(f"\n{bcolors.BOLD}Approvers ({len(response.approvers)}):{bcolors.ENDC}") + for idx, approver in enumerate(response.approvers, 1): + escalation = ' (Escalation)' if approver.escalation else '' + if approver.HasField('user'): + print(f" {idx}. User: {approver.user}{escalation}") + elif approver.HasField('userId'): + print(f" {idx}. User: {RecordResolver.resolve_user(params, approver.userId)}{escalation}") + elif approver.HasField('teamUid'): + team_uid = utils.base64_url_encode(approver.teamUid) + team_data = params.team_cache.get(team_uid, {}) + team_name = team_data.get('name', '') + team_display = f"{team_name} ({team_uid})" if team_name else team_uid + print(f" {idx}. Team: {team_display}{escalation}") + else: + print(f"\n{bcolors.WARNING}⚠ No approvers configured!{bcolors.ENDC}") + print(f"Add approvers with: pam workflow add-approver {record_uid} --user ") + + print() + + +class WorkflowUpdateCommand(Command): + parser = argparse.ArgumentParser( + prog='pam workflow update', + description='Update existing workflow configuration. ' + 'Only specified fields are changed; unspecified fields retain their current values.', + ) + parser.add_argument('record', help='Record UID or name with workflow to update') + parser.add_argument('-n', '--approvals-needed', type=int, help='Number of approvals required') + parser.add_argument('-co', '--checkout', type=lambda x: x.lower() == 'true', + help='Enable/disable check-in/check-out (true/false)') + parser.add_argument('-sa', '--start-on-approval', type=lambda x: x.lower() == 'true', + help='Start timer on approval vs check-out (true/false)') + parser.add_argument('-rr', '--require-reason', type=lambda x: x.lower() == 'true', + help='Require reason (true/false)') + parser.add_argument('-rt', '--require-ticket', type=lambda x: x.lower() == 'true', + help='Require ticket (true/false)') + parser.add_argument('-rm', '--require-mfa', type=lambda x: x.lower() == 'true', + help='Require MFA (true/false)') + parser.add_argument('-d', '--duration', type=str, help='Access duration (e.g., "2h", "30m", "1d")') + parser.add_argument('--format', dest='format', action='store', + choices=['table', 'json'], default='table', help='Output format') + + def get_parser(self): + return WorkflowUpdateCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + record_uid, record = RecordResolver.resolve(params, kwargs.get('record')) + record_uid_bytes = utils.base64_url_decode(record_uid) + + try: + ref = ProtobufRefBuilder.record_ref(record_uid_bytes, record.title) + current_config = _post_request_to_router( + params, 'read_workflow_config', + rq_proto=ref, rs_type=workflow_pb2.WorkflowConfig, + ) + + if not current_config: + raise CommandError('', 'No workflow found for record. Create one first with "pam workflow create"') + + parameters = workflow_pb2.WorkflowParameters() + parameters.CopyFrom(current_config.parameters) + + updatable_fields = { + 'approvals_needed': 'approvalsNeeded', + 'checkout': 'checkoutNeeded', + 'start_on_approval': 'startAccessOnApproval', + 'require_reason': 'requireReason', + 'require_ticket': 'requireTicket', + 'require_mfa': 'requireMFA', + } + + updates_provided = False + for kwarg_key, proto_field in updatable_fields.items(): + if kwargs.get(kwarg_key) is not None: + setattr(parameters, proto_field, kwargs[kwarg_key]) + updates_provided = True + + if kwargs.get('duration') is not None: + parameters.accessLength = WorkflowFormatter.parse_duration(kwargs['duration']) + updates_provided = True + + if not updates_provided: + raise CommandError( + '', 'No updates provided. Specify at least one option to update ' + '(e.g., --approvals-needed, --duration)', + ) + + _post_request_to_router(params, 'update_workflow_config', rq_proto=parameters) + + if kwargs.get('format') == 'json': + result = {'status': 'success', 'record_uid': record_uid, 'record_name': record.title} + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.OKGREEN}✓ Workflow updated successfully{bcolors.ENDC}\n") + print(f"Record: {record.title} ({record_uid})") + print() + + except CommandError: + raise + except Exception as e: + raise CommandError('', f'Failed to update workflow: {str(e)}') + + +class WorkflowDeleteCommand(Command): + parser = argparse.ArgumentParser( + prog='pam workflow delete', + description='Delete workflow configuration from a record', + ) + parser.add_argument('record', help='Record UID or name to remove workflow from') + parser.add_argument('--format', dest='format', action='store', + choices=['table', 'json'], default='table', help='Output format') + + def get_parser(self): + return WorkflowDeleteCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + record_uid, record = RecordResolver.resolve(params, kwargs.get('record')) + record_uid_bytes = utils.base64_url_decode(record_uid) + ref = ProtobufRefBuilder.record_ref(record_uid_bytes, record.title) + + try: + _post_request_to_router(params, 'delete_workflow_config', rq_proto=ref) + + if kwargs.get('format') == 'json': + result = {'status': 'success', 'record_uid': record_uid, 'record_name': record.title} + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.OKGREEN}✓ Workflow deleted successfully{bcolors.ENDC}\n") + print(f"Record: {record.title} ({record_uid})") + print() + + except Exception as e: + raise CommandError('', f'Failed to delete workflow: {str(e)}') + + +class WorkflowAddApproversCommand(Command): + parser = argparse.ArgumentParser( + prog='pam workflow add-approver', + description='Add approvers to a workflow', + ) + parser.add_argument('record', help='Record UID or name') + parser.add_argument('-u', '--user', action='append', + help='User email to add as approver (can specify multiple times)') + parser.add_argument('-t', '--team', action='append', + help='Team name or UID to add as approver (can specify multiple times)') + parser.add_argument('-e', '--escalation', action='store_true', help='Mark as escalation approver') + parser.add_argument('--format', dest='format', action='store', + choices=['table', 'json'], default='table', help='Output format') + + def get_parser(self): + return WorkflowAddApproversCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + users = kwargs.get('user') or [] + teams = kwargs.get('team') or [] + is_escalation = kwargs.get('escalation', False) + + if not users and not teams: + raise CommandError('', 'Must specify at least one --user or --team') + + record_uid, record = RecordResolver.resolve(params, kwargs.get('record')) + record_uid_bytes = utils.base64_url_decode(record_uid) + + config = workflow_pb2.WorkflowConfig() + config.parameters.resource.CopyFrom(ProtobufRefBuilder.record_ref(record_uid_bytes, record.title)) + + for user_email in users: + approver = workflow_pb2.WorkflowApprover() + approver.user = user_email + approver.escalation = is_escalation + config.approvers.append(approver) + + for team_input in teams: + resolved_team_uid = RecordResolver.validate_team(params, team_input) + approver = workflow_pb2.WorkflowApprover() + approver.teamUid = utils.base64_url_decode(resolved_team_uid) + approver.escalation = is_escalation + config.approvers.append(approver) + + try: + _post_request_to_router(params, 'add_workflow_approvers', rq_proto=config) + + total = len(users) + len(teams) + if kwargs.get('format') == 'json': + result = { + 'status': 'success', + 'record_uid': record_uid, + 'record_name': record.title, + 'approvers_added': total, + 'escalation': is_escalation, + } + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.OKGREEN}✓ Approvers added successfully{bcolors.ENDC}\n") + print(f"Record: {record.title} ({record_uid})") + print(f"Added {total} approver(s)") + if is_escalation: + print("Type: Escalation approver") + print() + + except Exception as e: + raise CommandError('', f'Failed to add approvers: {str(e)}') + + +class WorkflowDeleteApproversCommand(Command): + parser = argparse.ArgumentParser( + prog='pam workflow remove-approver', + description='Remove approvers from a workflow', + ) + parser.add_argument('record', help='Record UID or name') + parser.add_argument('-u', '--user', action='append', help='User email to remove as approver') + parser.add_argument('-t', '--team', action='append', help='Team name or UID to remove as approver') + parser.add_argument('--format', dest='format', action='store', + choices=['table', 'json'], default='table', help='Output format') + + def get_parser(self): + return WorkflowDeleteApproversCommand.parser + + def execute(self, params: KeeperParams, **kwargs): + users = kwargs.get('user') or [] + teams = kwargs.get('team') or [] + + if not users and not teams: + raise CommandError('', 'Must specify at least one --user or --team') + + record_uid, record = RecordResolver.resolve(params, kwargs.get('record')) + record_uid_bytes = utils.base64_url_decode(record_uid) + + config = workflow_pb2.WorkflowConfig() + config.parameters.resource.CopyFrom(ProtobufRefBuilder.record_ref(record_uid_bytes, record.title)) + + for user_email in users: + approver = workflow_pb2.WorkflowApprover() + approver.user = user_email + config.approvers.append(approver) + + for team_input in teams: + resolved_team_uid = RecordResolver.validate_team(params, team_input) + approver = workflow_pb2.WorkflowApprover() + approver.teamUid = utils.base64_url_decode(resolved_team_uid) + config.approvers.append(approver) + + try: + _post_request_to_router(params, 'delete_workflow_approvers', rq_proto=config) + + total = len(users) + len(teams) + if kwargs.get('format') == 'json': + result = { + 'status': 'success', + 'record_uid': record_uid, + 'record_name': record.title, + 'approvers_removed': total, + } + print(json.dumps(result, indent=2)) + else: + print(f"\n{bcolors.OKGREEN}✓ Approvers removed successfully{bcolors.ENDC}\n") + print(f"Record: {record.title} ({record_uid})") + print(f"Removed {total} approver(s)") + print() + + except Exception as e: + raise CommandError('', f'Failed to remove approvers: {str(e)}') diff --git a/keepercommander/commands/workflow/helpers.py b/keepercommander/commands/workflow/helpers.py new file mode 100644 index 000000000..5c34e471b --- /dev/null +++ b/keepercommander/commands/workflow/helpers.py @@ -0,0 +1,155 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bytes: + uid_bytes = utils.base64_url_decode(record_uid) + if record_uid not in params.record_cache: + raise CommandError('', f'Record {record_uid} not found') + return uid_bytes + + @staticmethod + def resolve_name(params, resource_ref) -> str: + if resource_ref.name: + return resource_ref.name + if resource_ref.value: + rec_uid = utils.base64_url_encode(resource_ref.value) + rec = vault.KeeperRecord.load(params, rec_uid) + return rec.title if rec else '' + return '' + + @staticmethod + def format_label(params, resource_ref) -> str: + rec_uid = utils.base64_url_encode(resource_ref.value) if resource_ref.value else '' + rec_name = RecordResolver.resolve_name(params, resource_ref) + if rec_name and rec_name != rec_uid: + return f"{rec_name} ({rec_uid})" + return rec_uid or 'Unknown' + + @staticmethod + def resolve_user(params: KeeperParams, user_id: int) -> str: + if params.enterprise and 'users' in params.enterprise: + for u in params.enterprise['users']: + if u.get('enterprise_user_id') == user_id: + return u.get('username', f'User ID {user_id}') + return f'User ID {user_id}' + + @staticmethod + def validate_team(params: KeeperParams, team_input: str) -> str: + if team_input in params.team_cache: + return team_input + for uid, team_data in params.team_cache.items(): + if team_data.get('name', '').casefold() == team_input.casefold(): + return uid + raise CommandError('', f'Team "{team_input}" not found. Use a valid team UID or team name.') + + +class ProtobufRefBuilder: + + @staticmethod + def record_ref(record_uid_bytes: bytes, record_name: str = '') -> GraphSync_pb2.GraphSyncRef: + ref = GraphSync_pb2.GraphSyncRef() + ref.type = GraphSync_pb2.RFT_REC + ref.value = record_uid_bytes + if record_name: + ref.name = record_name + return ref + + @staticmethod + def workflow_ref(flow_uid_bytes: bytes) -> GraphSync_pb2.GraphSyncRef: + ref = GraphSync_pb2.GraphSyncRef() + ref.type = GraphSync_pb2.RFT_WORKFLOW + ref.value = flow_uid_bytes + return ref + + +class WorkflowFormatter: + + STAGE_MAP = { + workflow_pb2.WS_READY_TO_START: 'Ready to Start', + workflow_pb2.WS_STARTED: 'Started', + workflow_pb2.WS_NEEDS_ACTION: 'Needs Action', + workflow_pb2.WS_WAITING: 'Waiting', + } + + CONDITION_MAP = { + workflow_pb2.AC_APPROVAL: 'Approval Required', + workflow_pb2.AC_CHECKIN: 'Check-in Required', + workflow_pb2.AC_MFA: 'MFA Required', + workflow_pb2.AC_TIME: 'Time Restriction', + workflow_pb2.AC_REASON: 'Reason Required', + workflow_pb2.AC_TICKET: 'Ticket Required', + } + + DURATION_MULTIPLIERS = {'d': 86_400_000, 'h': 3_600_000, 'm': 60_000} + + @staticmethod + def format_stage(stage: int) -> str: + return WorkflowFormatter.STAGE_MAP.get(stage, f'Unknown ({stage})') + + @staticmethod + def format_conditions(conditions: List[int]) -> str: + return ', '.join( + WorkflowFormatter.CONDITION_MAP.get(c, f'Unknown ({c})') + for c in conditions + ) + + @staticmethod + def parse_duration(duration_str: str) -> int: + duration_str = duration_str.lower().strip() + try: + for suffix, factor in WorkflowFormatter.DURATION_MULTIPLIERS.items(): + if duration_str.endswith(suffix): + return int(duration_str[:-1]) * factor + return int(duration_str) * 60_000 + except ValueError: + raise CommandError( + '', f'Invalid duration format: {duration_str}. ' + 'Use format like "2h", "30m", or "1d"', + ) + + @staticmethod + def format_duration(milliseconds: int) -> str: + seconds = milliseconds // 1000 + minutes = seconds // 60 + hours = minutes // 60 + days = hours // 24 + + if days > 0: + return f"{days} day{'s' if days != 1 else ''}" + if hours > 0: + return f"{hours} hour{'s' if hours != 1 else ''}" + if minutes > 0: + return f"{minutes} minute{'s' if minutes != 1 else ''}" + return f"{seconds} second{'s' if seconds != 1 else ''}" diff --git a/keepercommander/commands/workflow/mfa.py b/keepercommander/commands/workflow/mfa.py new file mode 100644 index 000000000..844592d77 --- /dev/null +++ b/keepercommander/commands/workflow/mfa.py @@ -0,0 +1,309 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' dict: + config = self._read_workflow_config() + if config is None: + return dict(self._DEFAULT_RESULT) + + mfa_required = bool(config.parameters and config.parameters.requireMFA) + + workflow = self._find_active_workflow() + if workflow is None: + self._print_no_workflow() + return {'allowed': False, 'require_mfa': False} + + return self._evaluate_stage(workflow, mfa_required) + + def _read_workflow_config(self): + ref = ProtobufRefBuilder.record_ref(self.record_uid_bytes, self.record_name) + try: + return _post_request_to_router( + self.params, 'read_workflow_config', + rq_proto=ref, rs_type=workflow_pb2.WorkflowConfig, + ) + except Exception: + return None + + def _find_active_workflow(self): + try: + user_state = _post_request_to_router( + self.params, 'get_user_access_state', + rs_type=workflow_pb2.UserAccessState, + ) + except Exception: + return None + + if user_state and user_state.workflows: + for wf in user_state.workflows: + if wf.resource and wf.resource.value == self.record_uid_bytes: + return wf + return None + + def _evaluate_stage(self, workflow, mfa_required: bool) -> dict: + if not workflow.status: + self._print_no_workflow() + return {'allowed': False, 'require_mfa': False} + + stage = workflow.status.stage + + if stage == workflow_pb2.WS_STARTED: + return {'allowed': True, 'require_mfa': mfa_required} + + if stage == workflow_pb2.WS_READY_TO_START: + print(f"\n{bcolors.WARNING}Workflow access approved but not yet checked out.{bcolors.ENDC}") + print(f"Run: {bcolors.OKBLUE}pam workflow start {self.record_uid}{bcolors.ENDC} to check out the record.\n") + return {'allowed': False, 'require_mfa': False} + + if stage == workflow_pb2.WS_WAITING: + conditions = workflow.status.conditions + cond_str = WorkflowFormatter.format_conditions(conditions) if conditions else 'approval' + print(f"\n{bcolors.WARNING}Workflow access is pending: waiting for {cond_str}.{bcolors.ENDC}") + print("Your request is being processed. Please wait for approval.\n") + return {'allowed': False, 'require_mfa': False} + + if stage == workflow_pb2.WS_NEEDS_ACTION: + flow_uid_str = utils.base64_url_encode(workflow.flowUid) + print(f"\n{bcolors.WARNING}Workflow requires additional action before access is granted.{bcolors.ENDC}") + print(f"Run: {bcolors.OKBLUE}pam workflow state --flow-uid {flow_uid_str}{bcolors.ENDC} to see details.\n") + return {'allowed': False, 'require_mfa': False} + + self._print_no_workflow() + return {'allowed': False, 'require_mfa': False} + + def _print_no_workflow(self): + print(f"\n{bcolors.WARNING}This record is protected by a workflow.{bcolors.ENDC}") + print("You must request access before connecting.") + print(f"Run: {bcolors.OKBLUE}pam workflow request {self.record_uid}{bcolors.ENDC} to request access.\n") + + +class WorkflowMfaPrompt: + + def __init__(self, params: KeeperParams): + self.params = params + + def prompt(self): + import getpass + from ...proto import APIRequest_pb2 + from ... import api + + tfa_list = self._fetch_2fa_list(self.params, api, APIRequest_pb2, getpass) + if tfa_list is None: + return None + + supported_types = { + APIRequest_pb2.TWO_FA_CT_TOTP: 'TOTP (Authenticator App)', + APIRequest_pb2.TWO_FA_CT_SMS: 'SMS Text Message', + APIRequest_pb2.TWO_FA_CT_DUO: 'DUO Security', + APIRequest_pb2.TWO_FA_CT_WEBAUTHN: 'Security Key', + APIRequest_pb2.TWO_FA_CT_DNA: 'Keeper DNA (Watch)', + } + + channels = [ch for ch in tfa_list.channels if ch.channelType in supported_types] + + if not channels: + print(f"{bcolors.FAIL}No supported 2FA methods found. Supported: TOTP, SMS, DUO, Security Key.{bcolors.ENDC}") + return None + + selected = self._select_channel(channels, supported_types) + if selected is None: + return None + + return self._dispatch(selected.channelType, APIRequest_pb2) + + @staticmethod + def _fetch_2fa_list(params, api, APIRequest_pb2, getpass): + try: + tfa_list = api.communicate_rest( + params, None, 'authentication/2fa_list', + rs_type=APIRequest_pb2.TwoFactorListResponse, + ) + except Exception: + try: + code = getpass.getpass('2FA required. Enter TOTP code: ').strip() + return code if code else None + except (KeyboardInterrupt, EOFError): + return None + + if not tfa_list.channels: + print(f"\n{bcolors.FAIL}This workflow requires 2FA verification{bcolors.ENDC}") + print( + "Your account does not have any 2FA methods configured. " + f"For available methods, run: {bcolors.OKBLUE}2fa add -h{bcolors.ENDC}" + ) + return None + + return tfa_list + + @staticmethod + def _select_channel(channels, supported_types): + if len(channels) == 1: + return channels[0] + + print(f"\n{bcolors.OKBLUE}2FA required. Select authentication method:{bcolors.ENDC}") + for idx, ch in enumerate(channels, 1): + name = supported_types.get(ch.channelType, 'Unknown') + extra = f' ({ch.channelName})' if ch.channelName else '' + print(f" {idx}. {name}{extra}") + print(" q. Cancel") + + try: + answer = input('Selection: ').strip() + except (KeyboardInterrupt, EOFError): + return None + if answer.lower() == 'q': + return None + try: + idx = int(answer) - 1 + if 0 <= idx < len(channels): + return channels[idx] + except ValueError: + pass + + print(f"{bcolors.FAIL}Invalid selection.{bcolors.ENDC}") + return None + + def _dispatch(self, channel_type, APIRequest_pb2): + import getpass + + if channel_type == APIRequest_pb2.TWO_FA_CT_TOTP: + try: + code = getpass.getpass('Enter TOTP code: ').strip() + return code if code else None + except (KeyboardInterrupt, EOFError): + return None + + push_config = { + APIRequest_pb2.TWO_FA_CT_SMS: ( + APIRequest_pb2.TWO_FA_PUSH_SMS, + 'SMS sent.', 'SMS', + ), + APIRequest_pb2.TWO_FA_CT_DUO: ( + APIRequest_pb2.TWO_FA_PUSH_DUO_PUSH, + 'DUO push sent. Respond on your device, then enter the code.', 'DUO', + ), + APIRequest_pb2.TWO_FA_CT_DNA: ( + APIRequest_pb2.TWO_FA_PUSH_DNA, + 'Keeper DNA push sent. Approve on your watch, then enter the code.', 'DNA', + ), + } + + if channel_type in push_config: + push_type, sent_msg, label = push_config[channel_type] + return self._send_push_and_prompt(push_type, sent_msg, label) + + if channel_type == APIRequest_pb2.TWO_FA_CT_WEBAUTHN: + return self._handle_webauthn() + + return None + + def _send_push_and_prompt(self, push_type, sent_message, prompt_label): + import getpass + from ...proto import router_pb2 + + try: + push_rq = router_pb2.Router2FASendPushRequest() + push_rq.pushType = push_type + _post_request_to_router(self.params, '2fa_send_push', rq_proto=push_rq) + print(f"{bcolors.OKGREEN}{sent_message}{bcolors.ENDC}") + except Exception as e: + print(f"{bcolors.FAIL}Failed to send {prompt_label} push: {e}{bcolors.ENDC}") + return None + + try: + code = getpass.getpass(f'Enter {prompt_label} code: ').strip() + return code if code else None + except (KeyboardInterrupt, EOFError): + return None + + def _handle_webauthn(self): + import json as _json + from ...proto import router_pb2 + + try: + challenge_rq = router_pb2.Router2FAGetWebAuthnChallengeRequest() + challenge_rs = _post_request_to_router( + self.params, '2fa_get_webauthn_challenge', rq_proto=challenge_rq, + rs_type=router_pb2.Router2FAGetWebAuthnChallengeResponse, + ) + if not challenge_rs or not challenge_rs.challenge: + print(f"{bcolors.FAIL}Failed to get WebAuthn challenge from server.{bcolors.ENDC}") + return None + + challenge = _json.loads(challenge_rs.challenge) + + from ...yubikey.yubikey import yubikey_authenticate + response = yubikey_authenticate(challenge) + + if response: + signature = { + "id": response.id, + "rawId": utils.base64_url_encode(response.raw_id), + "response": { + "authenticatorData": utils.base64_url_encode(response.response.authenticator_data), + "clientDataJSON": response.response.client_data.b64, + "signature": utils.base64_url_encode(response.response.signature), + }, + "type": "public-key", + "clientExtensionResults": ( + dict(response.client_extension_results) + if response.client_extension_results else {} + ), + } + return _json.dumps(signature) + + print(f"{bcolors.FAIL}Security key authentication failed or was cancelled.{bcolors.ENDC}") + return None + + except ImportError: + from ...yubikey import display_fido2_warning + display_fido2_warning() + return None + except Exception as e: + print(f"{bcolors.FAIL}Security key error: {e}{bcolors.ENDC}") + return None + + +def check_workflow_access(params: KeeperParams, record_uid: str) -> dict: + return WorkflowAccessValidator(params, record_uid).validate() + + +def check_workflow_and_prompt_2fa(params: KeeperParams, record_uid: str): + result = check_workflow_access(params, record_uid) + if not result.get('allowed', True): + return (False, None) + if result.get('require_mfa', False): + value = WorkflowMfaPrompt(params).prompt() + if not value: + return (False, None) + return (True, value) + return (True, None) diff --git a/keepercommander/commands/workflow/registry.py b/keepercommander/commands/workflow/registry.py new file mode 100644 index 000000000..770c32700 --- /dev/null +++ b/keepercommander/commands/workflow/registry.py @@ -0,0 +1,82 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bytes: - """ - Convert record UID string to bytes and validate it exists. - - Args: - params: Keeper parameters with session info - record_uid: Record UID as string (e.g., "abc123") - - Returns: - bytes: Record UID as bytes - - Raises: - CommandError: If record not found or invalid - """ - # Convert UID to bytes - uid_bytes = utils.base64_url_decode(record_uid) - - # Validate record exists in vault - if record_uid not in params.record_cache: - raise CommandError('', f'Record {record_uid} not found') - - return uid_bytes - - -def create_record_ref(record_uid_bytes: bytes, record_name: str = '') -> GraphSync_pb2.GraphSyncRef: - """ - Create a GraphSyncRef for a record. - - GraphSyncRef is used throughout Keeper's protobuf APIs to reference - different types of resources (records, folders, users, workflows, etc.) - - Args: - record_uid_bytes: Record UID as bytes - record_name: Optional record name/title - - Returns: - GraphSyncRef: Protobuf reference object - """ - ref = GraphSync_pb2.GraphSyncRef() - ref.type = GraphSync_pb2.RFT_REC # RefType.RFT_REC means "Record" - ref.value = record_uid_bytes - if record_name: - ref.name = record_name - return ref - - -def create_workflow_ref(flow_uid_bytes: bytes) -> GraphSync_pb2.GraphSyncRef: - """ - Create a GraphSyncRef for a workflow. - - Args: - flow_uid_bytes: Workflow flow UID as bytes - - Returns: - GraphSyncRef: Protobuf reference object for workflow - """ - ref = GraphSync_pb2.GraphSyncRef() - ref.type = GraphSync_pb2.RFT_WORKFLOW # RefType.RFT_WORKFLOW - ref.value = flow_uid_bytes - return ref - - -def resolve_record_name(params, resource_ref) -> str: - """ - Resolve the display name for a record from a GraphSyncRef. - - The backend doesn't always populate the 'name' field in the GraphSyncRef - response, so we fall back to looking up the record in the local vault cache. - - Args: - params: KeeperParams instance - resource_ref: GraphSyncRef protobuf object with value (record UID bytes) - - Returns: - str: Record title, UID, or empty string - """ - if resource_ref.name: - return resource_ref.name - if resource_ref.value: - rec_uid = utils.base64_url_encode(resource_ref.value) - rec = vault.KeeperRecord.load(params, rec_uid) - return rec.title if rec else '' - return '' - - -def format_record_label(params, resource_ref) -> str: - """ - Format a record label as 'Name (UID)' for display. - If the name can't be resolved, shows just the UID once (no duplication). - """ - rec_uid = utils.base64_url_encode(resource_ref.value) if resource_ref.value else '' - rec_name = resolve_record_name(params, resource_ref) - if rec_name and rec_name != rec_uid: - return f"{rec_name} ({rec_uid})" - return rec_uid or 'Unknown' - - -def validate_team(params: KeeperParams, team_input: str) -> str: - """ - Resolve and validate a team name or UID. - - Checks params.team_cache for matching UID or name (case-insensitive). - - Args: - params: KeeperParams instance - team_input: Team UID or team name - - Returns: - str: Resolved team UID - - Raises: - CommandError: If team is not found - """ - if team_input in params.team_cache: - return team_input - for uid, team_data in params.team_cache.items(): - if team_data.get('name', '').casefold() == team_input.casefold(): - return uid - raise CommandError('', f'Team "{team_input}" not found. Use a valid team UID or team name.') - - -def resolve_user_name(params: KeeperParams, user_id: int) -> str: - """ - Resolve an enterprise user ID to email/username. - - Uses params.enterprise['users'] when available (enterprise admin). - Falls back to displaying the numeric ID. - - Args: - params: KeeperParams instance - user_id: Enterprise user ID (int64) - - Returns: - str: User email or 'User ID ' as fallback - """ - if params.enterprise and 'users' in params.enterprise: - for u in params.enterprise['users']: - if u.get('enterprise_user_id') == user_id: - return u.get('username', f'User ID {user_id}') - return f'User ID {user_id}' - - -def check_workflow_access(params: KeeperParams, record_uid: str): - """ - Check whether the current user has active checkout access to a PAM record. - - This function should be called before connecting, tunneling, or launching - a PAM resource. It verifies: - 1. Whether the record has a workflow configured. - 2. If so, whether the user has an active checked-out session. - 3. Whether MFA is required for the workflow. - - If the user does not have access, a helpful message is printed guiding - them through the workflow process. - - Args: - params: KeeperParams instance with session info - record_uid: Record UID string to check - - Returns: - dict with keys: - 'allowed': bool — True if access is allowed - 'require_mfa': bool — True if the workflow requires 2FA - Returns {'allowed': True, 'require_mfa': False} if no workflow configured. - Returns {'allowed': False, 'require_mfa': False} if access is blocked. - """ - result = {'allowed': True, 'require_mfa': False} - - # Step 1: Check if the record has a workflow configured - record_uid_bytes = utils.base64_url_decode(record_uid) - record = vault.KeeperRecord.load(params, record_uid) - record_name = record.title if record else record_uid - - ref = create_record_ref(record_uid_bytes, record_name) - - try: - config_response = _post_request_to_router( - params, - 'read_workflow_config', - rq_proto=ref, - rs_type=workflow_pb2.WorkflowConfig - ) - except Exception: - # If config read fails (network issue, etc), allow access. - # Backend/gateway will still enforce restrictions server-side. - return result - - if config_response is None: - # No workflow configured on this record — access is unrestricted - return result - - # Remember MFA requirement — only applied if access is granted - mfa_required = bool(config_response.parameters and config_response.parameters.requireMFA) - - # Step 2: Get all user's workflows and find the one for this record - try: - user_state = _post_request_to_router( - params, - 'get_user_access_state', - rs_type=workflow_pb2.UserAccessState - ) - except Exception: - # If user state check fails (network issue), allow access - return result - - # Find workflow for this specific record - workflow_for_record = None - if user_state and user_state.workflows: - for wf in user_state.workflows: - if wf.resource and wf.resource.value == record_uid_bytes: - workflow_for_record = wf - break - - if workflow_for_record and workflow_for_record.status: - stage = workflow_for_record.status.stage - - if stage == workflow_pb2.WS_STARTED: - # User has an active checkout — allow access, apply MFA if configured - result['require_mfa'] = mfa_required - return result - - if stage == workflow_pb2.WS_READY_TO_START: - print(f"\n{bcolors.WARNING}Workflow access approved but not yet checked out.{bcolors.ENDC}") - print(f"Run: {bcolors.OKBLUE}pam workflow start {record_uid}{bcolors.ENDC} to check out the record.\n") - result['allowed'] = False - return result - - if stage == workflow_pb2.WS_WAITING: - conditions = workflow_for_record.status.conditions - cond_str = format_access_conditions(conditions) if conditions else 'approval' - print(f"\n{bcolors.WARNING}Workflow access is pending: waiting for {cond_str}.{bcolors.ENDC}") - print(f"Your request is being processed. Please wait for approval.\n") - result['allowed'] = False - return result - - if stage == workflow_pb2.WS_NEEDS_ACTION: - print(f"\n{bcolors.WARNING}Workflow requires additional action before access is granted.{bcolors.ENDC}") - print(f"Run: {bcolors.OKBLUE}pam workflow state --flow-uid {utils.base64_url_encode(workflow_for_record.flowUid)}{bcolors.ENDC} to see details.\n") - result['allowed'] = False - return result - - # No active workflow for this record — user hasn't requested access yet - print(f"\n{bcolors.WARNING}This record is protected by a workflow.{bcolors.ENDC}") - print(f"You must request access before connecting.") - print(f"Run: {bcolors.OKBLUE}pam workflow request {record_uid}{bcolors.ENDC} to request access.\n") - result['allowed'] = False - return result - - -def check_workflow_and_prompt_2fa(params: KeeperParams, record_uid: str): - """ - Combined workflow access check and 2FA prompt for PAM connections/tunnels. - - Checks workflow access and prompts for 2FA if required. - Prints helpful messages if access is denied. - - Args: - params: KeeperParams instance - record_uid: Record UID to check - - Returns: - tuple: (should_proceed: bool, two_factor_value: str or None) - - (False, None): Access denied or 2FA failed - abort connection - - (True, None): Access allowed, no 2FA required - proceed - - (True, value): Access allowed, 2FA provided - proceed with value - """ - wf_result = check_workflow_access(params, record_uid) - if not wf_result.get('allowed', True): - return (False, None) - if wf_result.get('require_mfa', False): - two_factor_value = prompt_workflow_2fa(params) - if not two_factor_value: - return (False, None) - return (True, two_factor_value) - return (True, None) - - -def prompt_workflow_2fa(params: KeeperParams): - """ - Prompt the user for 2FA verification for a workflow-protected resource. - - Detects the user's configured 2FA methods and handles each type: - - TOTP: Prompts for code directly - - SMS: Sends SMS via router push, then prompts for code - - DUO: Sends DUO push via router, then prompts for code - - Security Key (WebAuthn): Gets challenge from router, authenticates with key - - Args: - params: KeeperParams instance with session info - - Returns: - str: The 2FA value to include in the connect payload, or None if cancelled/failed. - """ - import getpass - import json as _json - from ...proto import APIRequest_pb2, router_pb2 - from ... import api - - # List user's 2FA methods - try: - tfa_list = api.communicate_rest( - params, None, 'authentication/2fa_list', - rs_type=APIRequest_pb2.TwoFactorListResponse - ) - except Exception: - # Fall back to simple TOTP prompt if can't list methods - try: - code = getpass.getpass('2FA required. Enter TOTP code: ').strip() - return code if code else None - except (KeyboardInterrupt, EOFError): - return None - - if not tfa_list.channels: - print(f"\n{bcolors.FAIL}This workflow requires 2FA verification{bcolors.ENDC}") - print(f"Your account does not have any 2FA methods configured. For available methods, run: {bcolors.OKBLUE}2fa add -h{bcolors.ENDC}") - return None - - # Build a list of usable channels - supported_types = { - APIRequest_pb2.TWO_FA_CT_TOTP: 'TOTP (Authenticator App)', - APIRequest_pb2.TWO_FA_CT_SMS: 'SMS Text Message', - APIRequest_pb2.TWO_FA_CT_DUO: 'DUO Security', - APIRequest_pb2.TWO_FA_CT_WEBAUTHN: 'Security Key', - APIRequest_pb2.TWO_FA_CT_DNA: 'Keeper DNA (Watch)', - } - - channels = [] - for ch in tfa_list.channels: - if ch.channelType in supported_types: - channels.append(ch) - - if not channels: - print(f"{bcolors.FAIL}No supported 2FA methods found. Supported: TOTP, SMS, DUO, Security Key.{bcolors.ENDC}") - return None - - # If only one method, use it automatically - if len(channels) == 1: - selected = channels[0] - else: - # Let user pick - print(f"\n{bcolors.OKBLUE}2FA required. Select authentication method:{bcolors.ENDC}") - for idx, ch in enumerate(channels, 1): - name = supported_types.get(ch.channelType, 'Unknown') - extra = f' ({ch.channelName})' if ch.channelName else '' - print(f" {idx}. {name}{extra}") - print(f" q. Cancel") - try: - answer = input('Selection: ').strip() - except (KeyboardInterrupt, EOFError): - return None - if answer.lower() == 'q': - return None - try: - idx = int(answer) - 1 - if 0 <= idx < len(channels): - selected = channels[idx] - else: - print(f"{bcolors.FAIL}Invalid selection.{bcolors.ENDC}") - return None - except ValueError: - print(f"{bcolors.FAIL}Invalid selection.{bcolors.ENDC}") - return None - - # Handle based on channel type - channel_type = selected.channelType - - if channel_type == APIRequest_pb2.TWO_FA_CT_TOTP: - try: - code = getpass.getpass('Enter TOTP code: ').strip() - return code if code else None - except (KeyboardInterrupt, EOFError): - return None - - elif channel_type == APIRequest_pb2.TWO_FA_CT_SMS: - # Send SMS push via router - try: - push_rq = router_pb2.Router2FASendPushRequest() - push_rq.pushType = APIRequest_pb2.TWO_FA_PUSH_SMS - _post_request_to_router(params, '2fa_send_push', rq_proto=push_rq) - print(f"{bcolors.OKGREEN}SMS sent.{bcolors.ENDC}") - except Exception as e: - print(f"{bcolors.FAIL}Failed to send SMS: {e}{bcolors.ENDC}") - return None - try: - code = getpass.getpass('Enter SMS code: ').strip() - return code if code else None - except (KeyboardInterrupt, EOFError): - return None - - elif channel_type == APIRequest_pb2.TWO_FA_CT_DUO: - # Send DUO push via router - try: - push_rq = router_pb2.Router2FASendPushRequest() - push_rq.pushType = APIRequest_pb2.TWO_FA_PUSH_DUO_PUSH - _post_request_to_router(params, '2fa_send_push', rq_proto=push_rq) - print(f"{bcolors.OKGREEN}DUO push sent. Respond on your device, then enter the code.{bcolors.ENDC}") - except Exception as e: - print(f"{bcolors.FAIL}Failed to send DUO push: {e}{bcolors.ENDC}") - return None - try: - code = getpass.getpass('Enter DUO code: ').strip() - return code if code else None - except (KeyboardInterrupt, EOFError): - return None - - elif channel_type == APIRequest_pb2.TWO_FA_CT_DNA: - # Send Keeper DNA push via router - try: - push_rq = router_pb2.Router2FASendPushRequest() - push_rq.pushType = APIRequest_pb2.TWO_FA_PUSH_DNA - _post_request_to_router(params, '2fa_send_push', rq_proto=push_rq) - print(f"{bcolors.OKGREEN}Keeper DNA push sent. Approve on your watch, then enter the code.{bcolors.ENDC}") - except Exception as e: - print(f"{bcolors.FAIL}Failed to send DNA push: {e}{bcolors.ENDC}") - return None - try: - code = getpass.getpass('Enter DNA code: ').strip() - return code if code else None - except (KeyboardInterrupt, EOFError): - return None - - elif channel_type == APIRequest_pb2.TWO_FA_CT_WEBAUTHN: - # Get challenge from router, authenticate with security key - try: - challenge_rq = router_pb2.Router2FAGetWebAuthnChallengeRequest() - challenge_rs = _post_request_to_router( - params, '2fa_get_webauthn_challenge', rq_proto=challenge_rq, - rs_type=router_pb2.Router2FAGetWebAuthnChallengeResponse - ) - if not challenge_rs or not challenge_rs.challenge: - print(f"{bcolors.FAIL}Failed to get WebAuthn challenge from server.{bcolors.ENDC}") - return None - - challenge = _json.loads(challenge_rs.challenge) - - from ...yubikey.yubikey import yubikey_authenticate - response = yubikey_authenticate(challenge) - - if response: - signature = { - "id": response.id, - "rawId": utils.base64_url_encode(response.raw_id), - "response": { - "authenticatorData": utils.base64_url_encode(response.response.authenticator_data), - "clientDataJSON": response.response.client_data.b64, - "signature": utils.base64_url_encode(response.response.signature), - }, - "type": "public-key", - "clientExtensionResults": dict(response.client_extension_results) if response.client_extension_results else {} - } - return _json.dumps(signature) - else: - print(f"{bcolors.FAIL}Security key authentication failed or was cancelled.{bcolors.ENDC}") - return None - - except ImportError: - from ...yubikey import display_fido2_warning - display_fido2_warning() - return None - except Exception as e: - print(f"{bcolors.FAIL}Security key error: {e}{bcolors.ENDC}") - return None - - return None - - -def parse_duration_to_milliseconds(duration_str: str) -> int: - """ - Parse duration string to milliseconds. - - Supports formats: - - "2h" = 2 hours - - "30m" = 30 minutes - - "1d" = 1 day - - "90" = 90 minutes (default unit) - - Args: - duration_str: Duration string (e.g., "2h", "30m", "1d") - - Returns: - int: Duration in milliseconds - - Raises: - CommandError: If duration format is invalid - """ - duration_str = duration_str.lower().strip() - - try: - # Check for unit suffix - if duration_str.endswith('d'): - # Days - days = int(duration_str[:-1]) - return days * 24 * 60 * 60 * 1000 - elif duration_str.endswith('h'): - # Hours - hours = int(duration_str[:-1]) - return hours * 60 * 60 * 1000 - elif duration_str.endswith('m'): - # Minutes - minutes = int(duration_str[:-1]) - return minutes * 60 * 1000 - else: - # Default to minutes if no unit specified - minutes = int(duration_str) - return minutes * 60 * 1000 - except ValueError: - raise CommandError('', f'Invalid duration format: {duration_str}. Use format like "2h", "30m", or "1d"') - - -def format_duration_from_milliseconds(milliseconds: int) -> str: - """ - Format milliseconds to human-readable duration. - - Args: - milliseconds: Duration in milliseconds - - Returns: - str: Formatted duration (e.g., "2 hours", "30 minutes", "1 day") - """ - seconds = milliseconds // 1000 - minutes = seconds // 60 - hours = minutes // 60 - days = hours // 24 - - if days > 0: - return f"{days} day{'s' if days != 1 else ''}" - elif hours > 0: - return f"{hours} hour{'s' if hours != 1 else ''}" - elif minutes > 0: - return f"{minutes} minute{'s' if minutes != 1 else ''}" - else: - return f"{seconds} second{'s' if seconds != 1 else ''}" - - -def format_workflow_stage(stage: int) -> str: - """ - Convert workflow stage enum to readable string. - - Args: - stage: WorkflowStage enum value - - Returns: - str: Human-readable stage name - """ - stage_map = { - workflow_pb2.WS_READY_TO_START: 'Ready to Start', - workflow_pb2.WS_STARTED: 'Started', - workflow_pb2.WS_NEEDS_ACTION: 'Needs Action', - workflow_pb2.WS_WAITING: 'Waiting' - } - return stage_map.get(stage, f'Unknown ({stage})') - - -def format_access_conditions(conditions: List[int]) -> str: - """ - Convert access condition enums to readable string. - - Args: - conditions: List of AccessCondition enum values - - Returns: - str: Human-readable conditions (comma-separated) - """ - condition_map = { - workflow_pb2.AC_APPROVAL: 'Approval Required', - workflow_pb2.AC_CHECKIN: 'Check-in Required', - workflow_pb2.AC_MFA: 'MFA Required', - workflow_pb2.AC_TIME: 'Time Restriction', - workflow_pb2.AC_REASON: 'Reason Required', - workflow_pb2.AC_TICKET: 'Ticket Required' - } - return ', '.join([condition_map.get(c, f'Unknown ({c})') for c in conditions]) - - -# ============================================================================ -# CONFIGURATION COMMANDS -# ============================================================================ - -class WorkflowCreateCommand(Command): - """ - Create a new workflow configuration for a PAM record. - - This enables Just-in-Time PAM features like: - - Approval requirements before access - - Single-user check-in/check-out - - Time-based access controls - - MFA requirements - - Justification requirements - - Example: - pam workflow create --approvals-needed 2 --duration 2h --checkout - """ - parser = argparse.ArgumentParser(prog='pam workflow create', - description='Create workflow configuration for a PAM record', allow_abbrev=False) - parser.add_argument('record', help='Record UID or name to configure workflow for') - parser.add_argument('-n', '--approvals-needed', type=int, default=1, - help='Number of approvals required (default: 1)') - parser.add_argument('-co', '--checkout', action='store_true', - help='Enable single-user check-in/check-out mode') - parser.add_argument('-sa', '--start-on-approval', action='store_true', - help='Start access timer when approved (vs when checked out)') - parser.add_argument('-rr', '--require-reason', action='store_true', - help='Require user to provide reason for access') - parser.add_argument('-rt', '--require-ticket', action='store_true', - help='Require user to provide ticket number') - parser.add_argument('-rm', '--require-mfa', action='store_true', - help='Require MFA verification for access') - parser.add_argument('-d', '--duration', type=str, default='1d', - help='Access duration (e.g., "2h", "30m", "1d"). Default: 1d') - parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], - default='table', help='Output format') - - def get_parser(self): - return WorkflowCreateCommand.parser - - def execute(self, params: KeeperParams, **kwargs): - """Execute workflow creation.""" - record_uid = kwargs.get('record') - - # Resolve record UID if name provided - if record_uid not in params.record_cache: - # Try to search for record by name - records = list(params.record_cache.keys()) - for uid in records: - rec = vault.KeeperRecord.load(params, uid) - if rec and rec.title == record_uid: - record_uid = uid - break - else: - raise CommandError('', f'Record "{record_uid}" not found') - - # Get record details - record = vault.KeeperRecord.load(params, record_uid) - record_uid_bytes = utils.base64_url_decode(record_uid) - - # Create workflow parameters - EXPLICITLY SET ALL FIELDS - # (Protobuf3 defaults can cause issues, so we set everything explicitly) - parameters = workflow_pb2.WorkflowParameters() - parameters.resource.CopyFrom(create_record_ref(record_uid_bytes, record.title)) - - # Set all required fields explicitly - parameters.approvalsNeeded = kwargs.get('approvals_needed', 1) - parameters.checkoutNeeded = kwargs.get('checkout', False) - parameters.startAccessOnApproval = kwargs.get('start_on_approval', False) - parameters.requireReason = kwargs.get('require_reason', False) - parameters.requireTicket = kwargs.get('require_ticket', False) - parameters.requireMFA = kwargs.get('require_mfa', False) - - # Parse duration - duration_str = kwargs.get('duration', '1d') - parameters.accessLength = parse_duration_to_milliseconds(duration_str) - - # IMPORTANT: allowedTimes field (field #9) - leave unset for now - # If workflow requires time-based restrictions, this would be set - # For now, leaving it unset means "no time restrictions" - - # Make API call - try: - response = _post_request_to_router( - params, - 'create_workflow_config', - rq_proto=parameters - ) - - # Auto-add record owner as the first approver (MRD Req #5: - # "By Default: The owner of the record must be added to this list") - owner_email = params.user - owner_added = False - if owner_email: - try: - approver_config = workflow_pb2.WorkflowConfig() - approver_config.parameters.resource.CopyFrom( - create_record_ref(record_uid_bytes, record.title) - ) - approver = workflow_pb2.WorkflowApprover() - approver.user = owner_email - approver_config.approvers.append(approver) - _post_request_to_router( - params, - 'add_workflow_approvers', - rq_proto=approver_config - ) - owner_added = True - except Exception: - # Non-fatal: workflow was created, approver add failed - pass - - # Success output - if kwargs.get('format') == 'json': - result = { - 'status': 'success', - 'record_uid': record_uid, - 'record_name': record.title, - 'workflow_config': { - 'approvals_needed': parameters.approvalsNeeded, - 'checkout_needed': parameters.checkoutNeeded, - 'require_reason': parameters.requireReason, - 'require_ticket': parameters.requireTicket, - 'require_mfa': parameters.requireMFA, - 'access_duration': format_duration_from_milliseconds(parameters.accessLength) - }, - 'owner_approver': owner_email if owner_added else None - } - print(json.dumps(result, indent=2)) - else: - print(f"\n{bcolors.OKGREEN}✓ Workflow created successfully{bcolors.ENDC}\n") - print(f"Record: {record.title} ({record_uid})") - print(f"Approvals needed: {parameters.approvalsNeeded}") - print(f"Check-in/out: {'Yes' if parameters.checkoutNeeded else 'No'}") - print(f"Duration: {format_duration_from_milliseconds(parameters.accessLength)}") - if parameters.requireReason: - print(f"Requires reason: Yes") - if parameters.requireTicket: - print(f"Requires ticket: Yes") - if parameters.requireMFA: - print(f"Requires MFA: Yes") - if owner_added: - print(f"\nApprover added: {owner_email} (record owner)") - else: - print(f"\n{bcolors.WARNING}Note: Add approvers with: " - f"pam workflow add-approver {record_uid} --user {bcolors.ENDC}") - print() - - except Exception as e: - raise CommandError('', f'Failed to create workflow: {str(e)}') - - -class WorkflowUpdateCommand(Command): - """ - Update an existing workflow configuration. - - Reads the current configuration first, then applies only the - specified changes. Unspecified fields retain their current values. - - Example: - pam workflow update --approvals-needed 3 --duration 4h - """ - parser = argparse.ArgumentParser(prog='pam workflow update', - description='Update existing workflow configuration. ' - 'Only specified fields are changed; unspecified fields ' - 'retain their current values.') - parser.add_argument('record', help='Record UID or name with workflow to update') - parser.add_argument('-n', '--approvals-needed', type=int, help='Number of approvals required') - parser.add_argument('-co', '--checkout', type=lambda x: x.lower() == 'true', - help='Enable/disable check-in/check-out (true/false)') - parser.add_argument('-sa', '--start-on-approval', type=lambda x: x.lower() == 'true', - help='Start timer on approval vs check-out (true/false)') - parser.add_argument('-rr', '--require-reason', type=lambda x: x.lower() == 'true', - help='Require reason (true/false)') - parser.add_argument('-rt', '--require-ticket', type=lambda x: x.lower() == 'true', - help='Require ticket (true/false)') - parser.add_argument('-rm', '--require-mfa', type=lambda x: x.lower() == 'true', - help='Require MFA (true/false)') - parser.add_argument('-d', '--duration', type=str, help='Access duration (e.g., "2h", "30m", "1d")') - parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], - default='table', help='Output format') - - def get_parser(self): - return WorkflowUpdateCommand.parser - - def execute(self, params: KeeperParams, **kwargs): - """Execute workflow update.""" - record_uid = kwargs.get('record') - - # Resolve record UID - if record_uid not in params.record_cache: - records = list(params.record_cache.keys()) - for uid in records: - rec = vault.KeeperRecord.load(params, uid) - if rec and rec.title == record_uid: - record_uid = uid - break - else: - raise CommandError('', f'Record "{record_uid}" not found') - - record = vault.KeeperRecord.load(params, record_uid) - record_uid_bytes = utils.base64_url_decode(record_uid) - - try: - # Fetch current workflow config using read_workflow_config - # This ensures we preserve existing values when doing partial updates - ref = create_record_ref(record_uid_bytes, record.title) - current_config = _post_request_to_router( - params, - 'read_workflow_config', - rq_proto=ref, - rs_type=workflow_pb2.WorkflowConfig - ) - - if not current_config: - raise CommandError('', 'No workflow found for record. Create one first with "pam workflow create"') - - # Start with current config values, then override with user-provided values - parameters = workflow_pb2.WorkflowParameters() - parameters.CopyFrom(current_config.parameters) - - # Override with user-provided values - updates_provided = False - if kwargs.get('approvals_needed') is not None: - parameters.approvalsNeeded = kwargs['approvals_needed'] - updates_provided = True - if kwargs.get('checkout') is not None: - parameters.checkoutNeeded = kwargs['checkout'] - updates_provided = True - if kwargs.get('start_on_approval') is not None: - parameters.startAccessOnApproval = kwargs['start_on_approval'] - updates_provided = True - if kwargs.get('require_reason') is not None: - parameters.requireReason = kwargs['require_reason'] - updates_provided = True - if kwargs.get('require_ticket') is not None: - parameters.requireTicket = kwargs['require_ticket'] - updates_provided = True - if kwargs.get('require_mfa') is not None: - parameters.requireMFA = kwargs['require_mfa'] - updates_provided = True - if kwargs.get('duration') is not None: - parameters.accessLength = parse_duration_to_milliseconds(kwargs['duration']) - updates_provided = True - - if not updates_provided: - raise CommandError('', 'No updates provided. Specify at least one option to update (e.g., --approvals-needed, --duration)') - - # Make API call - response = _post_request_to_router( - params, - 'update_workflow_config', - rq_proto=parameters - ) - - if kwargs.get('format') == 'json': - result = {'status': 'success', 'record_uid': record_uid, 'record_name': record.title} - print(json.dumps(result, indent=2)) - else: - print(f"\n{bcolors.OKGREEN}✓ Workflow updated successfully{bcolors.ENDC}\n") - print(f"Record: {record.title} ({record_uid})") - print() - - except CommandError: - raise - except Exception as e: - raise CommandError('', f'Failed to update workflow: {str(e)}') - - -class WorkflowReadCommand(Command): - """ - Read/display workflow configuration. - - Shows the complete workflow configuration including all parameters, - approvers, and metadata. - - Example: - pam workflow read - """ - parser = argparse.ArgumentParser(prog='pam workflow read', - description='Read and display workflow configuration') - parser.add_argument('record', help='Record UID or name') - parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], - default='table', help='Output format') - - def get_parser(self): - return WorkflowReadCommand.parser - - def execute(self, params: KeeperParams, **kwargs): - """Execute workflow read.""" - record_uid = kwargs.get('record') - - # Resolve record UID - if record_uid not in params.record_cache: - records = list(params.record_cache.keys()) - for uid in records: - rec = vault.KeeperRecord.load(params, uid) - if rec and rec.title == record_uid: - record_uid = uid - break - else: - raise CommandError('', f'Record "{record_uid}" not found') - - record = vault.KeeperRecord.load(params, record_uid) - record_uid_bytes = utils.base64_url_decode(record_uid) - - # Create reference to record - ref = create_record_ref(record_uid_bytes, record.title) - - # Make API call - try: - response = _post_request_to_router( - params, - 'read_workflow_config', - rq_proto=ref, - rs_type=workflow_pb2.WorkflowConfig - ) - - if not response: - if kwargs.get('format') == 'json': - print(json.dumps({'status': 'no_workflow', 'message': 'No workflow configured'}, indent=2)) - else: - print(f"\n{bcolors.WARNING}No workflow configured for this record{bcolors.ENDC}\n") - print(f"Record: {record.title} ({record_uid})") - print(f"\nTo create a workflow, run:") - print(f" pam workflow create {record_uid}") - print() - return - - if kwargs.get('format') == 'json': - # JSON output - result = { - 'record_uid': record_uid, - 'record_name': resolve_record_name(params, response.parameters.resource), - 'parameters': { - 'approvals_needed': response.parameters.approvalsNeeded, - 'checkout_needed': response.parameters.checkoutNeeded, - 'start_access_on_approval': response.parameters.startAccessOnApproval, - 'require_reason': response.parameters.requireReason, - 'require_ticket': response.parameters.requireTicket, - 'require_mfa': response.parameters.requireMFA, - 'access_duration': format_duration_from_milliseconds(response.parameters.accessLength) - }, - 'approvers': [] - } - - # Add approvers - for approver in response.approvers: - approver_info = {'escalation': approver.escalation} - if approver.HasField('user'): - approver_info['type'] = 'user' - approver_info['email'] = approver.user - elif approver.HasField('userId'): - approver_info['type'] = 'user_id' - approver_info['user_id'] = approver.userId - elif approver.HasField('teamUid'): - approver_info['type'] = 'team' - approver_info['team_uid'] = utils.base64_url_encode(approver.teamUid) - result['approvers'].append(approver_info) - - print(json.dumps(result, indent=2)) - else: - # Table output - print(f"\n{bcolors.OKBLUE}Workflow Configuration{bcolors.ENDC}\n") - print(f"Record: {resolve_record_name(params, response.parameters.resource)}") - print(f"Record UID: {record_uid}") - - # Display creation date if available - if response.createdOn: - created_date = datetime.fromtimestamp(response.createdOn / 1000) - print(f"Created: {created_date.strftime('%Y-%m-%d %H:%M:%S')}") - - print(f"\n{bcolors.BOLD}Access Parameters:{bcolors.ENDC}") - print(f" Approvals needed: {response.parameters.approvalsNeeded}") - print(f" Check-in/out required: {'Yes' if response.parameters.checkoutNeeded else 'No'}") - print(f" Access duration: {format_duration_from_milliseconds(response.parameters.accessLength)}") - print(f" Timer starts: {'On approval' if response.parameters.startAccessOnApproval else 'On check-out'}") - - print(f"\n{bcolors.BOLD}Requirements:{bcolors.ENDC}") - print(f" Reason required: {'Yes' if response.parameters.requireReason else 'No'}") - print(f" Ticket required: {'Yes' if response.parameters.requireTicket else 'No'}") - print(f" MFA required: {'Yes' if response.parameters.requireMFA else 'No'}") - - # Display approvers - if response.approvers: - print(f"\n{bcolors.BOLD}Approvers ({len(response.approvers)}):{bcolors.ENDC}") - for idx, approver in enumerate(response.approvers, 1): - escalation = ' (Escalation)' if approver.escalation else '' - if approver.HasField('user'): - print(f" {idx}. User: {approver.user}{escalation}") - elif approver.HasField('userId'): - print(f" {idx}. User: {resolve_user_name(params, approver.userId)}{escalation}") - elif approver.HasField('teamUid'): - team_uid = utils.base64_url_encode(approver.teamUid) - team_data = params.team_cache.get(team_uid, {}) - team_name = team_data.get('name', '') - team_display = f"{team_name} ({team_uid})" if team_name else team_uid - print(f" {idx}. Team: {team_display}{escalation}") - else: - print(f"\n{bcolors.WARNING}⚠ No approvers configured!{bcolors.ENDC}") - print(f"Add approvers with: pam workflow add-approver {record_uid} --user ") - - print() - - except Exception as e: - raise CommandError('', f'Failed to read workflow: {str(e)}') - - -class WorkflowDeleteCommand(Command): - """ - Delete a workflow configuration from a record. - - This removes all workflow restrictions and returns the record - to normal access mode. - - Example: - pam workflow delete - """ - parser = argparse.ArgumentParser(prog='pam workflow delete', - description='Delete workflow configuration from a record') - parser.add_argument('record', help='Record UID or name to remove workflow from') - parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], - default='table', help='Output format') - - def get_parser(self): - return WorkflowDeleteCommand.parser - - def execute(self, params: KeeperParams, **kwargs): - """Execute workflow deletion.""" - record_uid = kwargs.get('record') - - # Resolve record UID - if record_uid not in params.record_cache: - records = list(params.record_cache.keys()) - for uid in records: - rec = vault.KeeperRecord.load(params, uid) - if rec and rec.title == record_uid: - record_uid = uid - break - else: - raise CommandError('', f'Record "{record_uid}" not found') - - record = vault.KeeperRecord.load(params, record_uid) - record_uid_bytes = utils.base64_url_decode(record_uid) - - # Create reference to record - ref = create_record_ref(record_uid_bytes, record.title) - - # Make API call - try: - response = _post_request_to_router( - params, - 'delete_workflow_config', - rq_proto=ref - ) - - if kwargs.get('format') == 'json': - result = {'status': 'success', 'record_uid': record_uid, 'record_name': record.title} - print(json.dumps(result, indent=2)) - else: - print(f"\n{bcolors.OKGREEN}✓ Workflow deleted successfully{bcolors.ENDC}\n") - print(f"Record: {record.title} ({record_uid})") - print() - - except Exception as e: - raise CommandError('', f'Failed to delete workflow: {str(e)}') - - -# ============================================================================ -# APPROVER MANAGEMENT COMMANDS -# ============================================================================ - -class WorkflowAddApproversCommand(Command): - """ - Add approvers to a workflow. - - Approvers are users or teams who can approve access requests. - You can mark approvers as "escalated" for handling delayed approvals. - - Example: - pam workflow add-approver --user alice@company.com - pam workflow add-approver --team --escalation - """ - parser = argparse.ArgumentParser(prog='pam workflow add-approver', - description='Add approvers to a workflow') - parser.add_argument('record', help='Record UID or name') - parser.add_argument('-u', '--user', action='append', - help='User email to add as approver (can specify multiple times)') - parser.add_argument('-t', '--team', action='append', - help='Team name or UID to add as approver (can specify multiple times)') - parser.add_argument('-e', '--escalation', action='store_true', help='Mark as escalation approver') - parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], - default='table', help='Output format') - - def get_parser(self): - return WorkflowAddApproversCommand.parser - - def execute(self, params: KeeperParams, **kwargs): - """Execute add approvers.""" - record_uid = kwargs.get('record') - users = kwargs.get('user') or [] - teams = kwargs.get('team') or [] - is_escalation = kwargs.get('escalation', False) - - if not users and not teams: - raise CommandError('', 'Must specify at least one --user or --team') - - # Resolve record UID - if record_uid not in params.record_cache: - records = list(params.record_cache.keys()) - for uid in records: - rec = vault.KeeperRecord.load(params, uid) - if rec and rec.title == record_uid: - record_uid = uid - break - else: - raise CommandError('', f'Record "{record_uid}" not found') - - record = vault.KeeperRecord.load(params, record_uid) - record_uid_bytes = utils.base64_url_decode(record_uid) - - # Create workflow config with approvers - config = workflow_pb2.WorkflowConfig() - config.parameters.resource.CopyFrom(create_record_ref(record_uid_bytes, record.title)) - - # Add user approvers (email validated by backend) - for user_email in users: - approver = workflow_pb2.WorkflowApprover() - approver.user = user_email - approver.escalation = is_escalation - config.approvers.append(approver) - - # Add team approvers (accepts team UID or team name) - for team_input in teams: - resolved_team_uid = validate_team(params, team_input) - approver = workflow_pb2.WorkflowApprover() - approver.teamUid = utils.base64_url_decode(resolved_team_uid) - approver.escalation = is_escalation - config.approvers.append(approver) - - # Make API call - try: - response = _post_request_to_router( - params, - 'add_workflow_approvers', - rq_proto=config - ) - - if kwargs.get('format') == 'json': - result = { - 'status': 'success', - 'record_uid': record_uid, - 'record_name': record.title, - 'approvers_added': len(users) + len(teams), - 'escalation': is_escalation - } - print(json.dumps(result, indent=2)) - else: - print(f"\n{bcolors.OKGREEN}✓ Approvers added successfully{bcolors.ENDC}\n") - print(f"Record: {record.title} ({record_uid})") - print(f"Added {len(users) + len(teams)} approver(s)") - if is_escalation: - print("Type: Escalation approver") - print() - - except Exception as e: - raise CommandError('', f'Failed to add approvers: {str(e)}') - - -class WorkflowDeleteApproversCommand(Command): - """ - Remove approvers from a workflow. - - Example: - pam workflow remove-approver --user alice@company.com - """ - parser = argparse.ArgumentParser(prog='pam workflow remove-approver', - description='Remove approvers from a workflow') - parser.add_argument('record', help='Record UID or name') - parser.add_argument('-u', '--user', action='append', help='User email to remove as approver') - parser.add_argument('-t', '--team', action='append', help='Team name or UID to remove as approver') - parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], - default='table', help='Output format') - - def get_parser(self): - return WorkflowDeleteApproversCommand.parser - - def execute(self, params: KeeperParams, **kwargs): - """Execute delete approvers.""" - record_uid = kwargs.get('record') - users = kwargs.get('user') or [] - teams = kwargs.get('team') or [] - - if not users and not teams: - raise CommandError('', 'Must specify at least one --user or --team') - - # Resolve record UID - if record_uid not in params.record_cache: - records = list(params.record_cache.keys()) - for uid in records: - rec = vault.KeeperRecord.load(params, uid) - if rec and rec.title == record_uid: - record_uid = uid - break - else: - raise CommandError('', f'Record "{record_uid}" not found') - - record = vault.KeeperRecord.load(params, record_uid) - record_uid_bytes = utils.base64_url_decode(record_uid) - - # Create workflow config with approvers to remove - config = workflow_pb2.WorkflowConfig() - config.parameters.resource.CopyFrom(create_record_ref(record_uid_bytes, record.title)) - - # Add user approvers to remove (email validated by backend) - for user_email in users: - approver = workflow_pb2.WorkflowApprover() - approver.user = user_email - config.approvers.append(approver) - - # Add team approvers to remove (accepts team UID or team name) - for team_input in teams: - resolved_team_uid = validate_team(params, team_input) - approver = workflow_pb2.WorkflowApprover() - approver.teamUid = utils.base64_url_decode(resolved_team_uid) - config.approvers.append(approver) - - # Make API call - try: - response = _post_request_to_router( - params, - 'delete_workflow_approvers', - rq_proto=config - ) - - if kwargs.get('format') == 'json': - result = { - 'status': 'success', - 'record_uid': record_uid, - 'record_name': record.title, - 'approvers_removed': len(users) + len(teams) - } - print(json.dumps(result, indent=2)) - else: - print(f"\n{bcolors.OKGREEN}✓ Approvers removed successfully{bcolors.ENDC}\n") - print(f"Record: {record.title} ({record_uid})") - print(f"Removed {len(users) + len(teams)} approver(s)") - print() - - except Exception as e: - raise CommandError('', f'Failed to remove approvers: {str(e)}') - - -# ============================================================================ -# STATE INSPECTION COMMANDS -# ============================================================================ - -class WorkflowGetStateCommand(Command): - """ - Get the current state of a workflow. - - Shows whether a workflow is ready to start, waiting for approval, - in progress, etc. - - Example: - pam workflow state --record - pam workflow state --flow-uid - """ - parser = argparse.ArgumentParser(prog='pam workflow state', - description='Get workflow state for a record or flow') - _state_group = parser.add_mutually_exclusive_group(required=True) - _state_group.add_argument('-r', '--record', help='Record UID or name') - _state_group.add_argument('-f', '--flow-uid', help='Flow UID of active workflow') - parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], - default='table', help='Output format') - - def get_parser(self): - return WorkflowGetStateCommand.parser - - def execute(self, params: KeeperParams, **kwargs): - """Execute get workflow state.""" - record_uid = kwargs.get('record') - flow_uid = kwargs.get('flow_uid') - - # Create state request - state = workflow_pb2.WorkflowState() - - if flow_uid: - # Query by flow UID - state.flowUid = utils.base64_url_decode(flow_uid) - else: - # Query by record UID - if record_uid not in params.record_cache: - records = list(params.record_cache.keys()) - for uid in records: - rec = vault.KeeperRecord.load(params, uid) - if rec and rec.title == record_uid: - record_uid = uid - break - else: - raise CommandError('', f'Record "{record_uid}" not found') - - record = vault.KeeperRecord.load(params, record_uid) - record_uid_bytes = utils.base64_url_decode(record_uid) - state.resource.CopyFrom(create_record_ref(record_uid_bytes, record.title)) - - # Make API call - try: - response = _post_request_to_router( - params, - 'get_workflow_state', - rq_proto=state, - rs_type=workflow_pb2.WorkflowState - ) - - if response is None: - if kwargs.get('format') == 'json': - print(json.dumps({'status': 'no_workflow', 'message': 'No workflow found'}, indent=2)) - else: - print(f"\n{bcolors.WARNING}No workflow found for this record{bcolors.ENDC}\n") - return - - if kwargs.get('format') == 'json': - result = { - 'flow_uid': utils.base64_url_encode(response.flowUid) if response.flowUid else None, - 'record_uid': utils.base64_url_encode(response.resource.value), - 'record_name': resolve_record_name(params, response.resource), - 'stage': format_workflow_stage(response.status.stage), - 'conditions': [format_access_conditions([c]) for c in response.status.conditions], - 'escalated': response.status.escalated, - 'started_on': response.status.startedOn or None, - 'expires_on': response.status.expiresOn or None, - 'approved_by': [ - { - 'user': a.user if a.user else resolve_user_name(params, a.userId), - 'approved_on': a.approvedOn or None - } - for a in response.status.approvedBy - ] - } - print(json.dumps(result, indent=2)) - else: - print(f"\n{bcolors.OKBLUE}Workflow State{bcolors.ENDC}\n") - print(f"Record: {format_record_label(params, response.resource)}") - if response.flowUid: - print(f"Flow UID: {utils.base64_url_encode(response.flowUid)}") - print(f"Stage: {format_workflow_stage(response.status.stage)}") - if response.status.conditions: - print(f"Conditions: {format_access_conditions(response.status.conditions)}") - if response.status.escalated: - print(f"Escalated: Yes") - if response.status.startedOn: - started = datetime.fromtimestamp(response.status.startedOn / 1000) - print(f"Started: {started.strftime('%Y-%m-%d %H:%M:%S')}") - if response.status.expiresOn: - expires = datetime.fromtimestamp(response.status.expiresOn / 1000) - print(f"Expires: {expires.strftime('%Y-%m-%d %H:%M:%S')}") - if response.status.approvedBy: - print(f"Approved by:") - for a in response.status.approvedBy: - name = a.user if a.user else resolve_user_name(params, a.userId) - ts = '' - if a.approvedOn: - ts = f" at {datetime.fromtimestamp(a.approvedOn / 1000).strftime('%Y-%m-%d %H:%M:%S')}" - print(f" - {name}{ts}") - print() - - except Exception as e: - raise CommandError('', f'Failed to get workflow state: {str(e)}') - - -class WorkflowGetUserAccessStateCommand(Command): - """ - Get all workflows for the current user. - - Shows all active workflows, pending approvals, and available workflows - for the logged-in user. - - Example: - pam workflow my-access - """ - parser = argparse.ArgumentParser(prog='pam workflow my-access', - description='Get all workflow states for current user') - parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], - default='table', help='Output format') - - def get_parser(self): - return WorkflowGetUserAccessStateCommand.parser - - def execute(self, params: KeeperParams, **kwargs): - """Execute get user access state.""" - try: - response = _post_request_to_router( - params, - 'get_user_access_state', - rs_type=workflow_pb2.UserAccessState - ) - - if not response or not response.workflows: - if kwargs.get('format') == 'json': - print(json.dumps({'workflows': []}, indent=2)) - else: - print(f"\n{bcolors.WARNING}No active workflows{bcolors.ENDC}\n") - return - - if kwargs.get('format') == 'json': - result = { - 'workflows': [ - { - 'flow_uid': utils.base64_url_encode(wf.flowUid), - 'record_uid': utils.base64_url_encode(wf.resource.value), - 'record_name': resolve_record_name(params, wf.resource), - 'stage': format_workflow_stage(wf.status.stage), - 'conditions': [format_access_conditions([c]) for c in wf.status.conditions], - 'escalated': wf.status.escalated, - 'started_on': wf.status.startedOn or None, - 'expires_on': wf.status.expiresOn or None, - 'approved_by': [ - { - 'user': a.user if a.user else resolve_user_name(params, a.userId), - 'approved_on': a.approvedOn or None - } - for a in wf.status.approvedBy - ] - } - for wf in response.workflows - ] - } - print(json.dumps(result, indent=2)) - else: - rows = [] - for wf in response.workflows: - stage = format_workflow_stage(wf.status.stage) - record_name = resolve_record_name(params, wf.resource) - record_uid = utils.base64_url_encode(wf.resource.value) if wf.resource.value else '' - flow_uid = utils.base64_url_encode(wf.flowUid) if wf.flowUid else '' - conditions = format_access_conditions(wf.status.conditions) if wf.status.conditions else '' - started = datetime.fromtimestamp(wf.status.startedOn / 1000).strftime('%Y-%m-%d %H:%M:%S') if wf.status.startedOn else '' - expires = datetime.fromtimestamp(wf.status.expiresOn / 1000).strftime('%Y-%m-%d %H:%M:%S') if wf.status.expiresOn else '' - approved_by = '' - if wf.status.approvedBy: - approved_names = [a.user if a.user else resolve_user_name(params, a.userId) for a in wf.status.approvedBy] - approved_by = ', '.join(approved_names) - rows.append([stage, record_name, record_uid, flow_uid, approved_by, started, expires, conditions]) - headers = ['Stage', 'Record Name', 'Record UID', 'Flow UID', 'Approved By', 'Started', 'Expires', 'Conditions'] - print() - dump_report_data(rows, headers=headers) - print() - - except Exception as e: - raise CommandError('', f'Failed to get user access state: {str(e)}') - - -class WorkflowGetApprovalRequestsCommand(Command): - """ - Get pending approval requests for the current user. - - Shows all workflows waiting for your approval. - - Example: - pam workflow pending - """ - parser = argparse.ArgumentParser(prog='pam workflow pending', - description='Get pending approval requests') - parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], - default='table', help='Output format') - - def get_parser(self): - return WorkflowGetApprovalRequestsCommand.parser - - def execute(self, params: KeeperParams, **kwargs): - """Execute get approval requests.""" - try: - response = _post_request_to_router( - params, - 'get_approval_requests', - rs_type=workflow_pb2.ApprovalRequests - ) - - if not response or not response.workflows: - if kwargs.get('format') == 'json': - print(json.dumps({'requests': []}, indent=2)) - else: - print(f"\n{bcolors.WARNING}No approval requests{bcolors.ENDC}\n") - return - - # Determine status for each workflow - # Items with startedOn are approved/active - # Items without startedOn need a state check for approved-but-not-started - def _resolve_status(wf): - if wf.startedOn: - return 'Approved' - try: - st = workflow_pb2.WorkflowState() - st.flowUid = wf.flowUid - ws = _post_request_to_router( - params, 'get_workflow_state', rq_proto=st, - rs_type=workflow_pb2.WorkflowState - ) - if ws and ws.status and ws.status.stage in ( - workflow_pb2.WS_READY_TO_START, workflow_pb2.WS_STARTED - ): - return 'Approved' - except Exception: - pass - return 'Pending' - - # Resolve status once per workflow - wf_data = [] - for wf in response.workflows: - status = _resolve_status(wf) - wf_data.append((wf, status)) - - if kwargs.get('format') == 'json': - result = { - 'requests': [ - { - 'flow_uid': utils.base64_url_encode(wf.flowUid), - 'status': status, - 'requested_by': resolve_user_name(params, wf.userId), - 'record_uid': utils.base64_url_encode(wf.resource.value), - 'record_name': resolve_record_name(params, wf.resource), - 'started_on': wf.startedOn or None, - 'expires_on': wf.expiresOn or None, - 'duration': format_duration_from_milliseconds(wf.expiresOn - wf.startedOn) if wf.expiresOn and wf.startedOn else None, - 'reason': wf.reason.decode('utf-8') if wf.reason else None, - 'external_ref': wf.externalRef.decode('utf-8') if wf.externalRef else None, - } - for wf, status in wf_data - ] - } - print(json.dumps(result, indent=2)) - else: - rows = [] - for wf, status in wf_data: - record_uid = utils.base64_url_encode(wf.resource.value) if wf.resource.value else '' - record_name = resolve_record_name(params, wf.resource) - flow_uid = utils.base64_url_encode(wf.flowUid) - requested_by = resolve_user_name(params, wf.userId) - started = datetime.fromtimestamp(wf.startedOn / 1000).strftime('%Y-%m-%d %H:%M:%S') if wf.startedOn else '' - expires = datetime.fromtimestamp(wf.expiresOn / 1000).strftime('%Y-%m-%d %H:%M:%S') if wf.expiresOn else '' - duration = format_duration_from_milliseconds(wf.expiresOn - wf.startedOn) if wf.expiresOn and wf.startedOn else '' - rows.append([status, record_name, record_uid, flow_uid, requested_by, started, expires, duration]) - headers = ['Status', 'Record Name', 'Record UID', 'Flow UID', 'Requested By', 'Started', 'Expires', 'Duration'] - print() - dump_report_data(rows, headers=headers, sort_by=0) - print() - - except Exception as e: - raise CommandError('', f'Failed to get approval requests: {str(e)}') - - -# ============================================================================ -# ACTION COMMANDS -# ============================================================================ - -class WorkflowStartCommand(Command): - """ - Start a workflow (check-out). - - Explicitly starts a workflow and checks out the resource for use. - Can also be started automatically by approval or when attempting - to access a PAM resource. - - Example: - pam workflow start - pam workflow start - """ - parser = argparse.ArgumentParser(prog='pam workflow start', - description='Start a workflow (check-out). ' - 'Can use either record UID/name or flow UID.') - parser.add_argument('uid', help='Record UID, record name, or Flow UID') - parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], - default='table', help='Output format') - - def get_parser(self): - return WorkflowStartCommand.parser - - def execute(self, params: KeeperParams, **kwargs): - """Execute workflow start.""" - uid = kwargs.get('uid') - - # Try as record UID or name first - record_uid = None - record = None - if uid in params.record_cache: - record_uid = uid - else: - for cache_uid in params.record_cache: - rec = vault.KeeperRecord.load(params, cache_uid) - if rec and rec.title == uid: - record_uid = cache_uid - break - - if record_uid: - record = vault.KeeperRecord.load(params, record_uid) - record_uid_bytes = utils.base64_url_decode(record_uid) - state = workflow_pb2.WorkflowState() - state.resource.CopyFrom(create_record_ref(record_uid_bytes, record.title)) - else: - # Treat as flow UID - try: - uid_bytes = utils.base64_url_decode(uid) - except Exception: - raise CommandError('', f'"{uid}" is not a valid record UID/name or flow UID') - state = workflow_pb2.WorkflowState() - state.flowUid = uid_bytes - state.resource.CopyFrom(create_workflow_ref(uid_bytes)) - - # Make API call - try: - _post_request_to_router( - params, - 'start_workflow', - rq_proto=state - ) - - if kwargs.get('format') == 'json': - result = {'status': 'success', 'action': 'checked_out'} - if record: - result['record_uid'] = record_uid - result['record_name'] = record.title - else: - result['flow_uid'] = uid - print(json.dumps(result, indent=2)) - else: - print(f"\n{bcolors.OKGREEN}✓ Workflow started (checked out){bcolors.ENDC}\n") - if record: - print(f"Record: {record.title} ({record_uid})") - else: - print(f"Flow UID: {uid}") - print() - - except Exception as e: - raise CommandError('', f'Failed to start workflow: {str(e)}') - - -class WorkflowRequestAccessCommand(Command): - """ - Request access to a PAM resource with workflow. - - Sends approval request to configured approvers. - - Example: - pam workflow request --reason "Fix bug" --ticket INC-1234 - """ - parser = argparse.ArgumentParser(prog='pam workflow request', - description='Request access to a PAM resource') - parser.add_argument('record', help='Record UID or name') - parser.add_argument('-r', '--reason', help='Reason for access request') - parser.add_argument('-t', '--ticket', help='External ticket/reference number') - parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], - default='table', help='Output format') - - def get_parser(self): - return WorkflowRequestAccessCommand.parser - - def execute(self, params: KeeperParams, **kwargs): - """Execute workflow access request.""" - record_uid = kwargs.get('record') - reason = kwargs.get('reason') or '' - ticket = kwargs.get('ticket') or '' - - # Resolve record UID - if record_uid not in params.record_cache: - records = list(params.record_cache.keys()) - for uid in records: - rec = vault.KeeperRecord.load(params, uid) - if rec and rec.title == record_uid: - record_uid = uid - break - else: - raise CommandError('', f'Record "{record_uid}" not found') - - record = vault.KeeperRecord.load(params, record_uid) - record_uid_bytes = utils.base64_url_decode(record_uid) - - # Use WorkflowAccessRequest which supports reason and ticket - access_request = workflow_pb2.WorkflowAccessRequest() - access_request.resource.CopyFrom(create_record_ref(record_uid_bytes, record.title)) - if reason: - access_request.reason = reason.encode('utf-8') if isinstance(reason, str) else reason - if ticket: - access_request.ticket = ticket.encode('utf-8') if isinstance(ticket, str) else ticket - - # Make API call - try: - response = _post_request_to_router( - params, - 'request_workflow_access', - rq_proto=access_request - ) - - if kwargs.get('format') == 'json': - result = { - 'status': 'success', - 'record_uid': record_uid, - 'record_name': record.title, - 'message': 'Access request sent to approvers' - } - if reason: - result['reason'] = reason - if ticket: - result['ticket'] = ticket - print(json.dumps(result, indent=2)) - else: - print(f"\n{bcolors.OKGREEN}✓ Access request sent{bcolors.ENDC}\n") - print(f"Record: {record.title} ({record_uid})") - if reason: - print(f"Reason: {reason}") - if ticket: - print(f"Ticket: {ticket}") - print("\nApprovers have been notified.") - print() - - except Exception as e: - raise CommandError('', f'Failed to request access: {str(e)}') - - -class WorkflowApproveCommand(Command): - """ - Approve a workflow access request. - - Example: - pam workflow approve - """ - parser = argparse.ArgumentParser(prog='pam workflow approve', - description='Approve a workflow access request') - parser.add_argument('flow_uid', help='Flow UID of the workflow to approve') - parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], - default='table', help='Output format') - - def get_parser(self): - return WorkflowApproveCommand.parser - - def execute(self, params: KeeperParams, **kwargs): - """Execute workflow approval.""" - flow_uid = kwargs.get('flow_uid') - flow_uid_bytes = utils.base64_url_decode(flow_uid) - - # Create WorkflowApprovalOrDenial with deny=False for approval - approval = workflow_pb2.WorkflowApprovalOrDenial() - approval.flowUid = flow_uid_bytes - approval.deny = False - - # Make API call - try: - response = _post_request_to_router( - params, - 'approve_workflow_access', - rq_proto=approval - ) - - if kwargs.get('format') == 'json': - result = {'status': 'success', 'flow_uid': flow_uid, 'action': 'approved'} - print(json.dumps(result, indent=2)) - else: - print(f"\n{bcolors.OKGREEN}✓ Access request approved{bcolors.ENDC}\n") - print(f"Flow UID: {flow_uid}") - print() - - except Exception as e: - raise CommandError('', f'Failed to approve request: {str(e)}') - - -class WorkflowDenyCommand(Command): - """ - Deny a workflow access request. - - Example: - pam workflow deny - """ - parser = argparse.ArgumentParser(prog='pam workflow deny', - description='Deny a workflow access request') - parser.add_argument('flow_uid', help='Flow UID of the workflow to deny') - parser.add_argument('-r', '--reason', help='Reason for denial') - parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], - default='table', help='Output format') - - def get_parser(self): - return WorkflowDenyCommand.parser - - def execute(self, params: KeeperParams, **kwargs): - """Execute workflow denial.""" - flow_uid = kwargs.get('flow_uid') - reason = kwargs.get('reason') or '' - flow_uid_bytes = utils.base64_url_decode(flow_uid) - - # Create WorkflowApprovalOrDenial with deny=True for denial - denial = workflow_pb2.WorkflowApprovalOrDenial() - denial.flowUid = flow_uid_bytes - denial.deny = True - if reason: - denial.denialReason = reason - - # Make API call - try: - response = _post_request_to_router( - params, - 'deny_workflow_access', - rq_proto=denial - ) - - if kwargs.get('format') == 'json': - result = {'status': 'success', 'flow_uid': flow_uid, 'action': 'denied'} - if reason: - result['reason'] = reason - print(json.dumps(result, indent=2)) - else: - print(f"\n{bcolors.WARNING}Access request denied{bcolors.ENDC}\n") - print(f"Flow UID: {flow_uid}") - if reason: - print(f"Reason: {reason}") - print() - - except Exception as e: - raise CommandError('', f'Failed to deny request: {str(e)}') - - -class WorkflowEndCommand(Command): - """ - End a workflow (check-in). - - Explicitly ends the workflow and triggers side effects like - credential rotation. - - Example: - pam workflow end - pam workflow end - pam workflow end "Record Name" - """ - parser = argparse.ArgumentParser(prog='pam workflow end', - description='End a workflow (check-in). ' - 'Can use flow UID, record UID, or record name.') - parser.add_argument('uid', help='Flow UID, Record UID, or record name of the workflow to end') - parser.add_argument('--format', dest='format', action='store', choices=['table', 'json'], - default='table', help='Output format') - - def get_parser(self): - return WorkflowEndCommand.parser - - def execute(self, params: KeeperParams, **kwargs): - """Execute workflow end.""" - uid = kwargs.get('uid') - - # Try as record UID or name first - record_uid = None - record = None - if uid in params.record_cache: - record_uid = uid - else: - for cache_uid in params.record_cache: - rec = vault.KeeperRecord.load(params, cache_uid) - if rec and rec.title == uid: - record_uid = cache_uid - break - - if record_uid: - # Record found — look up active workflow, then end it - record = vault.KeeperRecord.load(params, record_uid) - try: - state_query = workflow_pb2.WorkflowState() - state_query.resource.CopyFrom( - create_record_ref(utils.base64_url_decode(record_uid), record.title if record else '') - ) - workflow_state = _post_request_to_router( - params, 'get_workflow_state', rq_proto=state_query, - rs_type=workflow_pb2.WorkflowState - ) - if not workflow_state or not workflow_state.flowUid: - raise CommandError('', 'No active workflow found for this record. ' - 'The workflow may have already ended or never started.') - - flow_ref = create_workflow_ref(workflow_state.flowUid) - _post_request_to_router(params, 'end_workflow', rq_proto=flow_ref) - - flow_uid_str = utils.base64_url_encode(workflow_state.flowUid) - if kwargs.get('format') == 'json': - result = { - 'status': 'success', - 'flow_uid': flow_uid_str, - 'record_uid': record_uid, - 'record_name': record.title if record else '', - 'action': 'ended' - } - print(json.dumps(result, indent=2)) - else: - print(f"\n{bcolors.OKGREEN}✓ Workflow ended (checked in){bcolors.ENDC}\n") - if record: - print(f"Record: {record.title} ({record_uid})") - else: - print(f"Record: {record_uid}") - print(f"Flow UID: {flow_uid_str}") - print("\nCredentials may have been rotated.") - print() - except CommandError: - raise - except Exception as e: - raise CommandError('', f'Failed to end workflow: {str(e)}') - else: - # Treat as flow UID - try: - uid_bytes = utils.base64_url_decode(uid) - ref = create_workflow_ref(uid_bytes) - _post_request_to_router(params, 'end_workflow', rq_proto=ref) - - if kwargs.get('format') == 'json': - result = {'status': 'success', 'flow_uid': uid, 'action': 'ended'} - print(json.dumps(result, indent=2)) - else: - print(f"\n{bcolors.OKGREEN}✓ Workflow ended (checked in){bcolors.ENDC}\n") - print(f"Flow UID: {uid}") - print("\nCredentials may have been rotated.") - print() - except Exception as e: - raise CommandError('', f'Failed to end workflow: {str(e)}') - - -# ============================================================================ -# GROUP COMMAND (for PAM hierarchy) -# ============================================================================ - -class PAMWorkflowCommand(GroupCommand): - """ - PAM Workflow management commands. - - Groups all workflow-related commands under 'pam workflow' hierarchy. - """ - - def __init__(self): - super(PAMWorkflowCommand, self).__init__() - - # --- Admin / Approver commands --- - self.register_command('create', WorkflowCreateCommand(), 'Create workflow configuration', 'c') - self.register_command('read', WorkflowReadCommand(), 'Read workflow configuration', 'r') - self.register_command('update', WorkflowUpdateCommand(), 'Update workflow configuration', 'u') - self.register_command('delete', WorkflowDeleteCommand(), 'Delete workflow configuration', 'd') - self.register_command('add-approver', WorkflowAddApproversCommand(), 'Add approvers', 'aa') - self.register_command('remove-approver', WorkflowDeleteApproversCommand(), 'Remove approvers', 'ra') - self.register_command('pending', WorkflowGetApprovalRequestsCommand(), 'Get pending approvals', 'p') - self.register_command('approve', WorkflowApproveCommand(), 'Approve access request', 'a') - self.register_command('deny', WorkflowDenyCommand(), 'Deny access request', 'dn') - - # --- User commands --- - self.register_command('request', WorkflowRequestAccessCommand(), 'Request access', 'rq') - self.register_command('start', WorkflowStartCommand(), 'Start workflow (check-out)', 's') - self.register_command('end', WorkflowEndCommand(), 'End workflow (check-in)', 'e') - self.register_command('my-access', WorkflowGetUserAccessStateCommand(), 'Get my access state', 'ma') - self.register_command('state', WorkflowGetStateCommand(), 'Get workflow state', 'st') - - self.default_verb = 'state' -