From 2f1447ac14a750e78b0db96a230f2c4a0715c3a7 Mon Sep 17 00:00:00 2001 From: John Walstra Date: Thu, 20 Aug 2026 17:51:57 -0500 Subject: [PATCH 1/4] Add COM, DCOM, COM+, and SCOM to PAM service management. --- keepercommander/commands/discoveryrotation.py | 7 - keepercommander/commands/pam_debug/acl.py | 156 ------------------ keepercommander/commands/pam_debug/info.py | 60 ++++++- keepercommander/commands/pam_debug/link.py | 75 --------- keepercommander/commands/pam_service/add.py | 10 +- keepercommander/commands/pam_service/list.py | 26 ++- .../commands/pam_service/remove.py | 13 +- .../discovery_common/__version__.py | 2 +- keepercommander/discovery_common/types.py | 26 ++- .../discovery_common/user_service.py | 9 +- 10 files changed, 120 insertions(+), 264 deletions(-) delete mode 100644 keepercommander/commands/pam_debug/acl.py delete mode 100644 keepercommander/commands/pam_debug/link.py diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index c8c3bd13b..f011378da 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -75,13 +75,11 @@ from .discover.rule_list import PAMGatewayActionDiscoverRuleListCommand from .discover.rule_remove import PAMGatewayActionDiscoverRuleRemoveCommand from .discover.rule_update import PAMGatewayActionDiscoverRuleUpdateCommand -from .pam_debug.acl import PAMDebugACLCommand from .pam_debug.dump import PAMDebugDumpCommand from .pam_debug.gateway import PAMDebugGatewayCommand from .pam_debug.graph import PAMDebugGraphCommand from .pam_debug.info import PAMDebugInfoCommand from .pam_debug.krouter import PAMDebugKRouterCommand -from .pam_debug.link import PAMDebugLinkCommand from .pam_debug.rotation_setting import PAMDebugRotationSettingsCommand from .pam_debug.vertex import PAMDebugVertexCommand from .pam.cnapp_commands import PAMCnappCommand @@ -463,11 +461,6 @@ def __init__(self): self.register_command('gateway', PAMDebugGatewayCommand(), 'Debug a gateway', 'g') self.register_command('krouter', PAMDebugKRouterCommand(), 'Show connected krouter version', 'k') self.register_command('graph', PAMDebugGraphCommand(), 'Render graphs', 'r') - - # Disable for now. Needs more work. - # self.register_command('verify', PAMDebugVerifyCommand(), 'Verify graphs') - self.register_command('acl', PAMDebugACLCommand(), 'Control ACL of PAM Users', 'c') - self.register_command('link', PAMDebugLinkCommand(), 'Link resource to configuration', 'l') self.register_command('rs-reset', PAMDebugRotationSettingsCommand(), 'Create/reset rotation settings', 'rs') self.register_command('vertex', PAMDebugVertexCommand(), diff --git a/keepercommander/commands/pam_debug/acl.py b/keepercommander/commands/pam_debug/acl.py deleted file mode 100644 index 3b1430646..000000000 --- a/keepercommander/commands/pam_debug/acl.py +++ /dev/null @@ -1,156 +0,0 @@ -from __future__ import annotations -import argparse -import logging -from ..discover import (PAMGatewayActionDiscoverCommandBase, GatewayContext, PAM_USER, MultiConfigurationException, - multi_conf_msg) -from ...display import bcolors -from . import load_pam_record -from ...discovery_common.record_link import RecordLink -from ...discovery_common.types import UserAcl -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from ...vault import TypedRecord - from ...params import KeeperParams - - -class PAMDebugACLCommand(PAMGatewayActionDiscoverCommandBase): - parser = argparse.ArgumentParser(prog='pam action debug acl') - - # The record to base everything on. - parser.add_argument('--gateway', '-g', required=True, dest='gateway', action='store', - help='Gateway name or UID.') - parser.add_argument('--configuration-uid', "-c", required=False, dest='configuration_uid', - action='store', help='PAM configuration UID, if gateway has multiple.') - - parser.add_argument('--user-uid', '-u', required=True, dest='user_uid', action='store', - help='User UID.') - parser.add_argument('--parent-uid', '-r', required=True, dest='parent_uid', action='store', - help='Resource or Configuration UID.') - parser.add_argument('--debug-gs-level', required=False, dest='debug_level', action='store', - help='GraphSync debug level. Default is 0', type=int, default=0) - - def get_parser(self): - return PAMDebugACLCommand.parser - - def execute(self, params: KeeperParams, **kwargs): - - gateway = kwargs.get("gateway") - user_uid = kwargs.get("user_uid") - parent_uid = kwargs.get("parent_uid") - debug_level = int(kwargs.get("debug_level", 0)) - - print("") - - configuration_uid = kwargs.get('configuration_uid') - try: - gateway_context = GatewayContext.from_gateway(params=params, - gateway=gateway, - configuration_uid=configuration_uid) - if gateway_context is None: - print(f"{bcolors.FAIL}Could not find the gateway configuration for {gateway}.{bcolors.ENDC}") - return - except MultiConfigurationException as err: - multi_conf_msg(gateway, err) - return - - record_link = RecordLink(record=gateway_context.configuration, - params=params, - logger=logging, - debug_level=debug_level, use_per_graph_endpoints=False) - - user_record = load_pam_record(params, user_uid) # type: TypedRecord | None - if user_record is None: - print(f"{bcolors.FAIL}The user record does not exists.{bcolors.ENDC}") - return - - print(f"{bcolors.BOLD}The user record is {user_record.title}{bcolors.ENDC}") - - if user_record.record_type != PAM_USER: - print(f"{bcolors.FAIL}The user record is not a PAM User record.{bcolors.ENDC}") - return - - parent_record = load_pam_record(params, parent_uid) # type: TypedRecord | None - if parent_record is None: - print(f"{bcolors.FAIL}The parent record does not exists.{bcolors.ENDC}") - return - - print(f"{bcolors.BOLD}The parent record is {parent_record.title}{bcolors.ENDC}") - - if parent_record.record_type.startswith("pam") is False: - print(f"{bcolors.FAIL}The parent record is not a PAM record.{bcolors.ENDC}") - return - - if parent_record.record_type == PAM_USER: - print(f"{bcolors.FAIL}The parent record cannot be a PAM User record.{bcolors.ENDC}") - return - - parent_is_config = parent_record.record_type.endswith("Configuration") - - # Get the ACL between the user and the parent. - # It might not exist. - acl_exists = True - acl = record_link.get_acl(user_uid, parent_uid) - if acl is None: - print("No existing ACL, creating an ACL.") - acl = UserAcl() - acl_exists = False - - # Make sure the ACL for cloud user is set. - if parent_is_config is True: - print("Is an IAM user.") - acl.is_iam_user = True - - rl_parent_vertex = record_link.dag.get_vertex(parent_uid) - if rl_parent_vertex is None: - print("Parent record linking vertex did not exists, creating one.") - rl_parent_vertex = record_link.dag.add_vertex(parent_uid) - - rl_user_vertex = record_link.dag.get_vertex(user_uid) - if rl_user_vertex is None: - print("User record linking vertex did not exists, creating one.") - rl_user_vertex = record_link.dag.add_vertex(user_uid) - - has_admin_uid = record_link.get_admin_record_uid(parent_uid) - if has_admin_uid is not None: - print("Parent record already has an admin.") - else: - print("Parent record does not have an admin.") - - belongs_to_vertex = record_link.acl_has_belong_to_record_uid(user_uid) - if belongs_to_vertex is None: - print("User record does not belong to any resource, or provider.") - else: - if not belongs_to_vertex.active: - print("User record belongs to an inactive parent.") - else: - print("User record belongs to another record.") - - print("") - - while True: - res = input(f"Does this user belong to {parent_record.title} Y/N >").lower() - if res == "y": - acl.belongs_to = True - break - elif res == "n": - acl.belongs_to = False - break - - if has_admin_uid is None: - while True: - res = input(f"Is this user the admin of {parent_record.title} Y/N >").lower() - if res == "y": - acl.is_admin = True - break - elif res == "n": - acl.is_admin = False - break - - try: - record_link.belongs_to(user_uid, parent_uid, acl=acl) - record_link.save() - print(f"{bcolors.OKGREEN}Updated/added ACL between {user_record.title} and " - f"{parent_record.title}{bcolors.ENDC}") - except Exception as err: - print(f"{bcolors.FAIL}Could not update ACL: {err}{bcolors.ENDC}") diff --git a/keepercommander/commands/pam_debug/info.py b/keepercommander/commands/pam_debug/info.py index a89ddbae7..74ad5d3db 100644 --- a/keepercommander/commands/pam_debug/info.py +++ b/keepercommander/commands/pam_debug/info.py @@ -5,7 +5,7 @@ from . import load_pam_record from ...discovery_common.infrastructure import Infrastructure from ...discovery_common.record_link import RecordLink -from ...discovery_common.types import UserAcl, DiscoveryObject +from ...discovery_common.types import UserAcl, DiscoveryObject, ServiceEnum from ...discovery_common.constants import PAM_USER, PAM_MACHINE, PAM_DATABASE, PAM_DIRECTORY from ...keeper_dag import EdgeType import time @@ -16,6 +16,7 @@ if TYPE_CHECKING: from ...vault import TypedRecord from ...params import KeeperParams + from ...discovery_common.types import UserAclServiceNames, UserAclServiceNamesItem class PAMDebugInfoCommand(PAMGatewayActionDiscoverCommandBase): @@ -28,8 +29,18 @@ class PAMDebugInfoCommand(PAMGatewayActionDiscoverCommandBase): PAM_DIRECTORY: "PAM Directory", } + TITLES = { + ServiceEnum.service: "Service", + ServiceEnum.task: "Scheduled Task", + ServiceEnum.iis_pool: "IIS Pool", + ServiceEnum.com: "COM (Classic)", + ServiceEnum.dcom: "DCOM", + ServiceEnum.com_plus: "COM Plus", + ServiceEnum.scom: "SCOM", + } + # The record to base everything on. - parser.add_argument('--record-uid', '-i', required=True, dest='record_uid', action='store', + parser.add_argument('--record-uid', '-i', '-r', required=True, dest='record_uid', action='store', help='Keeper PAM record UID.') def get_parser(self): @@ -298,7 +309,7 @@ def _print_field(f): # Get the resource record machine_record = load_pam_record(params, - machine_vertex.uid) # type: TypedRecord | None + machine_vertex.uid) # type: TypedRecord | None # If the resource record does not exist. if machine_record is None: @@ -319,8 +330,18 @@ def _print_field(f): # Record exists; just use information from the record. else: - machines.append(f" * {machine_record.title}, {machine_record.record_uid}, " - f"vertex {machine_vertex.uid}") + text = f" * {machine_record.title}, {machine_record.record_uid}, "\ + f"vertex {machine_vertex.uid}" + if acl.service_names is not None: + for service_name in acl.service_names: # type: UserAclServiceNames + if len(service_name.items) > 0: + text += f"\n + {PAMDebugInfoCommand.TITLES.get(service_name.type)}\n" + for service_item in service_name.items: + text += f"\n . {service_item.name}" + if not service_item.via_discovery: + text += f" (manual entry)" + text += "\n" + machines.append(text) if len(machines) > 0: print(f"{bcolors.HEADER}Controls Services on Machine{bcolors.ENDC}") @@ -465,6 +486,35 @@ def _print_field(f): print(f" * {iis_pool.name} = {iis_pool.user}") else: print(" Machines has no IIS Pools that are using non-builtin users.") + + print(f" {self._b('COM (classic)')} (Non Builtin Users)") + if len(content.item.facts.coms) > 0: + for com in content.item.facts.coms: + print(f" * {com.name} = {com.user}") + else: + print(" Machines has no COM (classic) applications that are using non-builtin users.") + + print(f" {self._b('DCOM')} (Non Builtin Users)") + if len(content.item.facts.dcoms) > 0: + for dcom in content.item.facts.dcoms: + print(f" * {dcom.name} = {dcom.user}") + else: + print(" Machines has no DCOM applications that are using non-builtin users.") + + print(f" {self._b('COM Plus')} (Non Builtin Users)") + if len(content.item.facts.com_pluses) > 0: + for com in content.item.facts.com_pluses: + print(f" * {com.name} = {com.user}") + else: + print(" Machines has no COM Plus applications that are using non-builtin users.") + + print(f" {self._b('SCOM')} (Non Builtin Users)") + if len(content.item.facts.scoms) > 0: + for scom in content.item.facts.scoms: + print(f" * {scom.name} = {scom.user}") + else: + print(" Machines has no SCOM applications that are using non-builtin users.") + else: print(f"{bcolors.FAIL} Machine facts are not set. Discover inside may not have been " f"performed.{bcolors.ENDC}") diff --git a/keepercommander/commands/pam_debug/link.py b/keepercommander/commands/pam_debug/link.py deleted file mode 100644 index deae2697a..000000000 --- a/keepercommander/commands/pam_debug/link.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations -import argparse -import logging -from ..discover import (PAMGatewayActionDiscoverCommandBase, GatewayContext, PAM_MACHINE, PAM_DATABASE, PAM_DIRECTORY, - MultiConfigurationException, multi_conf_msg) -from ...display import bcolors -from . import load_pam_record -from ...discovery_common.record_link import RecordLink -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from ...vault import TypedRecord - from ...params import KeeperParams - - -class PAMDebugLinkCommand(PAMGatewayActionDiscoverCommandBase): - parser = argparse.ArgumentParser(prog='pam action debug link') - - # The record to base everything on. - parser.add_argument('--gateway', '-g', required=True, dest='gateway', action='store', - help='Gateway name or UID.') - parser.add_argument('--configuration-uid', "-c", required=False, dest='configuration_uid', - action='store', help='PAM configuration UID, if gateway has multiple.') - - parser.add_argument('--resource-uid', '-r', required=True, dest='resource_uid', action='store', - help='Resource record UID.') - parser.add_argument('--debug-gs-level', required=False, dest='debug_level', action='store', - help='GraphSync debug level. Default is 0', type=int, default=0) - - def get_parser(self): - return PAMDebugLinkCommand.parser - - def execute(self, params: KeeperParams, **kwargs): - - gateway = kwargs.get("gateway") - resource_uid = kwargs.get("resource_uid") - debug_level = int(kwargs.get("debug_level", 0)) - - print("") - - configuration_uid = kwargs.get('configuration_uid') - try: - gateway_context = GatewayContext.from_gateway(params=params, - gateway=gateway, - configuration_uid=configuration_uid) - if gateway_context is None: - print(f"{bcolors.FAIL}Could not find the gateway configuration for {gateway}.{bcolors.ENDC}") - return - except MultiConfigurationException as err: - multi_conf_msg(gateway, err) - return - - record_link = RecordLink(record=gateway_context.configuration, - params=params, - logger=logging, - debug_level=debug_level, use_per_graph_endpoints=False) - - resource_record = load_pam_record(params, resource_uid) # type: TypedRecord | None - if resource_record is None: - print(f"{bcolors.FAIL}The parent record does not exists.{bcolors.ENDC}") - return - - if resource_record.record_type not in [PAM_MACHINE, PAM_DATABASE, PAM_DIRECTORY]: - print(f"{bcolors.FAIL}The resource record type, {resource_record.record_type} " - f"is not allowed.{bcolors.ENDC}") - return - - try: - record_link.belongs_to(resource_uid, gateway_context.configuration_uid, ) - record_link.save() - print(f"{bcolors.OKGREEN}Added link between '{resource_uid}' and " - f"{gateway_context.configuration_uid}{bcolors.ENDC}") - except Exception as err: - print(f"{bcolors.FAIL}Could not add LINK: {err}{bcolors.ENDC}") - raise err diff --git a/keepercommander/commands/pam_service/add.py b/keepercommander/commands/pam_service/add.py index 05c9ec055..079d15fa4 100644 --- a/keepercommander/commands/pam_service/add.py +++ b/keepercommander/commands/pam_service/add.py @@ -32,7 +32,15 @@ class PAMActionServiceAddCommand(PAMGatewayActionDiscoverCommandBase): parser.add_argument('--user-uid', '-u', required=True, dest='user_uid', action='store', help='The UID of the User record') parser.add_argument('--type', '-t', required=True, dest='service_type', action='store', - choices=["service", "task", "iis_pool"], + choices=[ + "service", + "task", + "iis_pool", + "com", + "dcom", + "com_plus", + "scom" + ], help='Type of service.') parser.add_argument('--name', '-n', required=True, dest='name', action='store', help='Name label for reporting.') diff --git a/keepercommander/commands/pam_service/list.py b/keepercommander/commands/pam_service/list.py index c74cd4c6e..7b61459a6 100644 --- a/keepercommander/commands/pam_service/list.py +++ b/keepercommander/commands/pam_service/list.py @@ -28,6 +28,16 @@ class PAMActionServiceListCommand(PAMGatewayActionDiscoverCommandBase): parser.add_argument('--by-machine', '-m', required=False, dest='do_by_machine', action='store_true', help='List by machine') + TITLES = { + ServiceEnum.service: "Service", + ServiceEnum.task: "Scheduled Task", + ServiceEnum.iis_pool: "IIS Pool", + ServiceEnum.com: "COM (Classic)", + ServiceEnum.dcom: "DCOM", + ServiceEnum.com_plus: "COM Plus", + ServiceEnum.scom: "SCOM", + } + def get_parser(self): return PAMActionServiceListCommand.parser @@ -76,13 +86,7 @@ def _by_user(self, params: KeeperParams, record_link: RecordLink, user_service: items = [] if acl.service_names is not None or acl.service_names != "": for service_name in acl.get_service_names(user_record.record_key): - text = "" - if service_name.type == ServiceEnum.service: - text = "Service" - elif service_name.type == ServiceEnum.task: - text = "Scheduled Task" - elif service_name.type == ServiceEnum.iis_pool: - text = "IIS Pool" + text = PAMActionServiceListCommand.TITLES.get(service_name.type) for item in service_name.items: text += f": {item.name}" if "Unknown" in item.name: @@ -159,13 +163,7 @@ def _by_machine(self, params: KeeperParams, record_link: RecordLink, user_servic items = [] if acl.service_names is not None or acl.service_names != "": for service_name in acl.get_service_names(user_record.record_key): - text = "" - if service_name.type == ServiceEnum.service: - text = "Service" - elif service_name.type == ServiceEnum.task: - text = "Scheduled Task" - elif service_name.type == ServiceEnum.iis_pool: - text = "IIS Pool" + text = PAMActionServiceListCommand.TITLES.get(service_name.type) for item in service_name.items: text += f": {item.name}" if "Unknown" in item.name: diff --git a/keepercommander/commands/pam_service/remove.py b/keepercommander/commands/pam_service/remove.py index f66da90e0..0451eb0cb 100644 --- a/keepercommander/commands/pam_service/remove.py +++ b/keepercommander/commands/pam_service/remove.py @@ -31,8 +31,17 @@ class PAMActionServiceRemoveCommand(PAMGatewayActionDiscoverCommandBase): help='The UID of the User record') parser.add_argument('--type', '-t', required=True, dest='service_type', action='store', - choices=["service", "task", "iis_pool", "all"], - help='Type of service. "all" will clear all') + choices=[ + "all", + "service", + "task", + "iis_pool", + "com", + "dcom", + "com_plus", + "scom" + ], + help='Type of service. "all" will clear all.') parser.add_argument('--name', '-n', required=False, dest='name', action='store', help='Name label for reporting. Exclude will remove all service types.') diff --git a/keepercommander/discovery_common/__version__.py b/keepercommander/discovery_common/__version__.py index b46c29a4b..5eba4ad50 100644 --- a/keepercommander/discovery_common/__version__.py +++ b/keepercommander/discovery_common/__version__.py @@ -1 +1 @@ -__version__ = '1.1.22' +__version__ = '1.1.23' diff --git a/keepercommander/discovery_common/types.py b/keepercommander/discovery_common/types.py index 04ecbae81..1ba1eafc8 100644 --- a/keepercommander/discovery_common/types.py +++ b/keepercommander/discovery_common/types.py @@ -358,6 +358,9 @@ class ServiceEnum(BaseEnum): task = "task" iis_pool = "iis_pool" dcom = "dcom" + com = "com" + com_plus = "com_plus" + scom = "scom" class UserAclServiceNames(BaseModel): @@ -550,7 +553,10 @@ class Facts(BaseModel): services: List[FactsNameUser] = [] tasks: List[FactsNameUser] = [] iis_pools: List[FactsNameUser] = [] + coms: List[FactsNameUser] = [] dcoms: List[FactsNameUser] = [] + com_pluses: List[FactsNameUser] = [] + scoms: List[FactsNameUser] = [] @property def has_services(self): @@ -564,13 +570,31 @@ def has_tasks(self): def has_iis_pools(self): return self.iis_pools is not None and len(self.iis_pools) > 0 + @property + def has_coms(self): + return self.coms is not None and len(self.coms) > 0 + @property def has_dcoms(self): return self.dcoms is not None and len(self.dcoms) > 0 + @property + def has_com_pluses(self): + return self.com_pluses is not None and len(self.com_pluses) > 0 + + @property + def has_scoms(self): + return self.scoms is not None and len(self.scoms) > 0 + @property def has_service_items(self): - return self.has_services or self.has_tasks or self.has_iis_pools or self.has_dcoms + return self.has_services \ + or self.has_tasks \ + or self.has_iis_pools \ + or self.has_coms \ + or self.has_dcoms \ + or self.has_com_pluses \ + or self.has_scoms class DiscoveryMachine(DiscoveryItem): diff --git a/keepercommander/discovery_common/user_service.py b/keepercommander/discovery_common/user_service.py index 328903561..619f49e60 100644 --- a/keepercommander/discovery_common/user_service.py +++ b/keepercommander/discovery_common/user_service.py @@ -858,14 +858,19 @@ def _connect_users_to_machine_services(self, self.debug(f" > {k} = {v}") # Add mapping from user to machine, that control services. - for service_type in [ServiceEnum.service, ServiceEnum.task, ServiceEnum.iis_pool]: + for service_type in [ServiceEnum.service, ServiceEnum.task, ServiceEnum.iis_pool, + ServiceEnum.com, ServiceEnum.dcom, ServiceEnum.com_plus, ServiceEnum.scom]: self.debug("-" * 40) self.debug(f"processing {service_type.value}s for {infra_machine_content.name} " f"({infra_machine_vertex.uid})") # Get the pair of name of the service and the user that controls it. # This is from discovery. - service_pairs = getattr(infra_machine_content.item.facts, f"{service_type.value}s") + if hasattr(infra_machine_content.item.facts, f"{service_type.value}s"): + service_pairs = getattr(infra_machine_content.item.facts, f"{service_type.value}s") + else: + service_pairs = getattr(infra_machine_content.item.facts, f"{service_type.value}es") + if len(service_pairs) == 0: self.debug(" no users control this type of service, skipping") continue From 55cee818526921ced5530cbeae569f3bccb25c1b Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Wed, 26 Aug 2026 13:43:16 -0700 Subject: [PATCH 2/4] SCIM Active Directory: bind is called twice --- keepercommander/scim/data_sources.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/keepercommander/scim/data_sources.py b/keepercommander/scim/data_sources.py index 866b433a4..9f872fdfb 100644 --- a/keepercommander/scim/data_sources.py +++ b/keepercommander/scim/data_sources.py @@ -80,14 +80,17 @@ def get_ldap_connection(self): logging.debug('AD connect: Kerberos auth method. Requires Windows, domain user, and domain computer') auth_method = ldap3.SASL server = ldap3.Server(self.ad_url, tls=tls) - with ldap3.Connection(server, user=self.ad_user, password=self.ad_password, + with ldap3.Connection(server, raise_exceptions=True, + user=self.ad_user, password=self.ad_password, authentication=auth_method, sasl_mechanism=ldap3.KERBEROS if auth_method == ldap3.SASL else None) as connection: - connection.open() - connection.bind() - if not connection.bind(): - raise Exception('Invalid AD username or password') - yield connection + try: + connection.open() + if not connection.bind(): + raise Exception('Invalid AD username or password') + yield connection + finally: + connection.unbind() def _build_domain_lookup(self, connection) -> Dict[str, str]: """Build a mapping of DNS domain names to NetBIOS names. From 1ec04850b3477d95391dcbc3a43c8fd9a7989c0b Mon Sep 17 00:00:00 2001 From: sshrushanth-ks Date: Thu, 27 Aug 2026 21:01:13 +0530 Subject: [PATCH 3/4] KC-1408: Treat first-time share invitations as success in Service Mode (#2325) * KC-1408: Treat first-time share invitations as success in Service Mode (#2303) * Treat first-time share invitations as success in Service Mode * Use ShareInviteSentError so NSF invite success does not mask real share failures * Fix test imports for deleted pam_debug modules Remove imports and tests for PAMDebugACLCommand and PAMDebugLinkCommand, which were deleted in commit 2f1447ac. Update test file to only include tests for modules that still exist. --- .../nested_share_folder/folder_commands.py | 2 +- .../nested_share_folder/sharing_commands.py | 10 ++- keepercommander/commands/register.py | 4 +- .../nested_share_folder/__init__.py | 2 +- keepercommander/nested_share_folder/common.py | 14 +++- .../service/util/parse_keeper_response.py | 13 ++++ unit-tests/pam/test_pam_debug_nsf.py | 43 ------------- unit-tests/service/test_throttle_response.py | 38 +++++++++++ unit-tests/test_nested_share_folder.py | 64 ++++++++++++++++++- 9 files changed, 137 insertions(+), 53 deletions(-) diff --git a/keepercommander/commands/nested_share_folder/folder_commands.py b/keepercommander/commands/nested_share_folder/folder_commands.py index 28f3a677f..2f1a95922 100644 --- a/keepercommander/commands/nested_share_folder/folder_commands.py +++ b/keepercommander/commands/nested_share_folder/folder_commands.py @@ -530,7 +530,7 @@ def _apply(cls, params, action, folder_uid, recipient, role, expiration, logging.info("%s share '%s' %s", kind, recipient, verb) else: logging.warning("%s share '%s' failed", kind, recipient) - except ValueError as e: + except _nsf.ShareInviteSentError as e: logging.warning("nsf-share-folder: %s", e) except Exception as e: raise CommandError('nsf-share-folder', str(e)) diff --git a/keepercommander/commands/nested_share_folder/sharing_commands.py b/keepercommander/commands/nested_share_folder/sharing_commands.py index 8872ba78f..5e66a7545 100644 --- a/keepercommander/commands/nested_share_folder/sharing_commands.py +++ b/keepercommander/commands/nested_share_folder/sharing_commands.py @@ -106,9 +106,13 @@ def execute(self, params, **kwargs): raise_if_record_share_target_is_owner( params, record_uid, email, 'nsf-share-record', is_ownership_transfer=(action == 'owner')) - result, effective_action = self._dispatch( - params, action, record_uid, email, access_role_type, expiration, - rotate_on_expiration) + try: + result, effective_action = self._dispatch( + params, action, record_uid, email, access_role_type, expiration, + rotate_on_expiration) + except _nsf.ShareInviteSentError as e: + logging.warning('nsf-share-record: %s', e) + continue self._log_results(result, effective_action, email) # Strategy dispatch — returns (result, effective_action) diff --git a/keepercommander/commands/register.py b/keepercommander/commands/register.py index 4c3222e6b..73b839576 100644 --- a/keepercommander/commands/register.py +++ b/keepercommander/commands/register.py @@ -807,7 +807,9 @@ def apply_share_expiration(target): uo.typedSharedFolderKey.encryptedKeyType = folder_pb2.encrypted_by_public_key rq.sharedFolderAddUser.append(uo) - else: + elif not invited: + # After a successful invite, keys are unavailable until accepted. + # Do not emit "User not found" — Service Mode treats that as failure. logging.warning('User %s not found', email) if len(teams) > 0: diff --git a/keepercommander/nested_share_folder/__init__.py b/keepercommander/nested_share_folder/__init__.py index b2d656c99..607c53a90 100644 --- a/keepercommander/nested_share_folder/__init__.py +++ b/keepercommander/nested_share_folder/__init__.py @@ -22,7 +22,7 @@ 'get_record_from_cache', 'get_record_revision', 'patch_record_revision', 'parse_sharing_status', 'get_record_key_type', 'encrypt_record_key_for_folder', 'encrypt_for_recipient', - 'handle_share_invite', 'resolve_user_uid_bytes', + 'handle_share_invite', 'ShareInviteSentError', 'resolve_user_uid_bytes', 'load_user_public_key', 'parse_folder_access_result', 'resolve_team_uid_bytes', 'resolve_team_identifier', 'get_team_keys', 'encrypt_for_team', 'is_keeper_uid', diff --git a/keepercommander/nested_share_folder/common.py b/keepercommander/nested_share_folder/common.py index 9b0f4c387..64db3b199 100644 --- a/keepercommander/nested_share_folder/common.py +++ b/keepercommander/nested_share_folder/common.py @@ -514,8 +514,16 @@ def _retry_with_canonical_email(params, recipient_email, _load_pk, # Share invite helper (previously duplicated in share + update_share) # ═══════════════════════════════════════════════════════════════════════════ +class ShareInviteSentError(ValueError): + """Invite was sent; caller should treat as success-with-notice.""" + + def handle_share_invite(params, recipient_email, needs_invite): - """Send a share invite if *needs_invite* is True; raise ValueError.""" + """Send a share invite if *needs_invite* is True. + + Raises ShareInviteSentError after a successful invite (subclass of ValueError). + Raises ValueError when the invite could not be sent. + """ if not needs_invite: return try: @@ -523,10 +531,10 @@ def handle_share_invite(params, recipient_email, needs_invite): rq = APIRequest_pb2.SendShareInviteRequest() rq.email = recipient_email api.communicate_rest(params, rq, 'vault/send_share_invite') - raise ValueError( + raise ShareInviteSentError( f"Share invitation has been sent to '{recipient_email}'. " f"Please repeat this command once the invitation is accepted.") - except ValueError: + except ShareInviteSentError: raise except Exception: raise ValueError( diff --git a/keepercommander/service/util/parse_keeper_response.py b/keepercommander/service/util/parse_keeper_response.py index 9081b81fc..8a923dc59 100644 --- a/keepercommander/service/util/parse_keeper_response.py +++ b/keepercommander/service/util/parse_keeper_response.py @@ -1148,6 +1148,19 @@ def _parse_logging_based_command(command: str, response_str: str) -> Dict[str, A has_bad_request = any(pattern in response_lower for pattern in bad_request_patterns) has_error = any(pattern in response_lower for pattern in error_patterns) has_warning = any(pattern in response_lower for pattern in warning_patterns) + + # First-time share invite is success. Mixed multi-recipient output is + # only partially represented: throttle (above) and forbidden keep + # precedence over this short-circuit. + has_invitation = 'share invitation has been sent to' in response_lower + if has_invitation and not has_forbidden: + formatted_message = KeeperResponseParser._format_multiline_message(response_str) + return { + "status": "success", + "command": command.split()[0] if command.split() else command, + "message": formatted_message, + "data": None, + } if has_success_indicator and (has_not_found or has_bad_request or has_error): return { diff --git a/unit-tests/pam/test_pam_debug_nsf.py b/unit-tests/pam/test_pam_debug_nsf.py index b68666192..855327b0f 100644 --- a/unit-tests/pam/test_pam_debug_nsf.py +++ b/unit-tests/pam/test_pam_debug_nsf.py @@ -9,9 +9,7 @@ from keepercommander import utils, vault from keepercommander.commands.discover import GatewayContext from keepercommander.commands.pam_debug import load_pam_record -from keepercommander.commands.pam_debug.acl import PAMDebugACLCommand from keepercommander.commands.pam_debug.dump import PAMDebugDumpCommand -from keepercommander.commands.pam_debug.link import PAMDebugLinkCommand from keepercommander.subfolder import NestedShareFolderNode, RootFolderNode @@ -74,47 +72,6 @@ def test_load_pam_record_resolves_nsf_machine(self): self.assertEqual(rec.title, 'NSF Machine') self.assertEqual(rec.record_type, 'pamMachine') - def test_acl_uses_load_pam_record_for_nsf_uids(self): - params = _params() - user = _typed('user_uid', 'NSF User', 'pamUser') - parent = _typed('machine_uid', 'NSF Machine', 'pamMachine') - gw = MagicMock() - gw.configuration = _typed('config_uid', 'NSF Config', 'pamNetworkConfiguration', version=6) - gw.configuration_uid = 'config_uid' - - with patch('keepercommander.commands.pam_debug.acl.GatewayContext.from_gateway', return_value=gw), \ - patch('keepercommander.commands.pam_debug.acl.RecordLink') as rl_cls, \ - patch('keepercommander.commands.pam_debug.acl.load_pam_record', - side_effect=[user, parent]) as load, \ - patch('builtins.input', side_effect=['n', 'n']): - rl = rl_cls.return_value - rl.get_acl.return_value = None - rl.get_admin_record_uid.return_value = None - rl.acl_has_belong_to_record_uid.return_value = None - rl.dag.get_vertex.return_value = MagicMock() - PAMDebugACLCommand().execute( - params, gateway='gw', user_uid='user_uid', parent_uid='machine_uid') - - self.assertEqual(load.call_count, 2) - self.assertEqual(load.call_args_list[0].args[1], 'user_uid') - self.assertEqual(load.call_args_list[1].args[1], 'machine_uid') - - def test_link_uses_load_pam_record_for_nsf_resource(self): - params = _params() - parent = _typed('machine_uid', 'NSF Machine', 'pamMachine') - gw = MagicMock() - gw.configuration = _typed('config_uid', 'NSF Config', 'pamNetworkConfiguration', version=6) - gw.configuration_uid = 'config_uid' - - with patch('keepercommander.commands.pam_debug.link.GatewayContext.from_gateway', return_value=gw), \ - patch('keepercommander.commands.pam_debug.link.RecordLink') as rl_cls, \ - patch('keepercommander.commands.pam_debug.link.load_pam_record', return_value=parent) as load: - rl = rl_cls.return_value - PAMDebugLinkCommand().execute(params, gateway='gw', resource_uid='machine_uid') - rl.belongs_to.assert_called_once() - rl.save.assert_called_once() - self.assertEqual(load.call_args.args[1], 'machine_uid') - def test_dump_collects_nsf_folder_records(self): params = _params() with tempfile.TemporaryDirectory() as tmp: diff --git a/unit-tests/service/test_throttle_response.py b/unit-tests/service/test_throttle_response.py index 361ea5173..f0747bf76 100644 --- a/unit-tests/service/test_throttle_response.py +++ b/unit-tests/service/test_throttle_response.py @@ -78,6 +78,44 @@ def test_parser_maps_throttled_text_to_429(self): 'Due to repeated attempts, your request has been throttled.', ) + def test_parser_treats_share_invitation_as_success(self): + result = KeeperResponseParser._parse_logging_based_command( + 'share-folder', + "Share invitation has been sent to 'user@example.com'\n" + "Please repeat this command when invitation is accepted.\n" + "User user@example.com not found", + ) + self.assertEqual(result['status'], 'success') + self.assertNotIn('error', result) + self.assertIn('Share invitation has been sent', str(result['message'])) + + def test_parser_treats_nsf_share_record_invitation_as_success(self): + result = KeeperResponseParser._parse_logging_based_command( + 'nsf-share-record', + "nsf-share-record: Share invitation has been sent to 'user@example.com'. " + "Please repeat this command once the invitation is accepted.", + ) + self.assertEqual(result['status'], 'success') + self.assertIn('Share invitation has been sent', str(result['message'])) + + def test_parser_keeps_throttle_precedence_over_invitation(self): + result = KeeperResponseParser._parse_logging_based_command( + 'share-folder', + "Share invitation has been sent to 'user@example.com'\n" + "throttled: Due to repeated attempts, your request has been throttled.", + ) + self.assertEqual(result['status_code'], 429) + self.assertEqual(result['result_code'], RESULT_THROTTLED) + + def test_parser_keeps_forbidden_precedence_over_invitation(self): + result = KeeperResponseParser._parse_logging_based_command( + 'share-record', + "Share invitation has been sent to 'user@example.com'\n" + "Permission denied", + ) + self.assertEqual(result['status'], 'error') + self.assertEqual(result.get('status_code'), 403) + def test_parser_does_not_treat_rate_limit_config_text_as_throttle(self): result = KeeperResponseParser._parse_logging_based_command( 'help', diff --git a/unit-tests/test_nested_share_folder.py b/unit-tests/test_nested_share_folder.py index 7c912c6bc..7eed51d12 100644 --- a/unit-tests/test_nested_share_folder.py +++ b/unit-tests/test_nested_share_folder.py @@ -1393,11 +1393,14 @@ def test_share_folder_rejects_grant_to_owner(self, mock_grant): @patch('keepercommander.nested_share_folder.folder_api.grant_folder_access_v3') def test_share_folder_invite_message_uses_command_prefix(self, mock_grant): + import keepercommander.nested_share_folder as nsf from keepercommander.commands.nested_share_folder import NestedShareFolderShareCommand + from keepercommander.nested_share_folder.common import ShareInviteSentError + nsf.__dict__.pop('grant_folder_access_v3', None) fuid, fobj = _make_folder() email = 'user@example.com' - mock_grant.side_effect = ValueError( + mock_grant.side_effect = ShareInviteSentError( f"Share invitation has been sent to '{email}'. " "Please repeat this command once the invitation is accepted.") @@ -1410,6 +1413,65 @@ def test_share_folder_invite_message_uses_command_prefix(self, mock_grant): self.assertIn('nsf-share-folder: Share invitation has been sent', output) self.assertNotIn("User '", output) + @patch('keepercommander.nested_share_folder.folder_api.grant_folder_access_v3') + def test_share_folder_no_relationship_still_fails(self, mock_grant): + import keepercommander.nested_share_folder as nsf + from keepercommander.commands.nested_share_folder import NestedShareFolderShareCommand + + nsf.__dict__.pop('grant_folder_access_v3', None) + fuid, fobj = _make_folder() + mock_grant.side_effect = ValueError( + "No sharing relationship with 'user@example.com'. " + "Please invite them to share first, then repeat this command.") + + cmd = NestedShareFolderShareCommand() + with self.assertRaises(CommandError) as ctx: + cmd.execute(_make_params(nested_share_folders={fuid: fobj}), + folder=[fuid], user=['user@example.com'], action='grant', role='viewer') + self.assertIn('No sharing relationship', str(ctx.exception)) + + @patch('keepercommander.nested_share_folder.record_api.share_record_v3') + def test_share_record_invite_message_logged_as_warning(self, mock_share): + import keepercommander.nested_share_folder as nsf + from keepercommander.commands.nested_share_folder import NestedShareRecordShareCommand + from keepercommander.nested_share_folder.common import ShareInviteSentError + + nsf.__dict__.pop('share_record_v3', None) + ruid, robj = _make_record() + email = 'user@example.com' + mock_share.side_effect = ShareInviteSentError( + f"Share invitation has been sent to '{email}'. " + "Please repeat this command once the invitation is accepted.") + + cmd = NestedShareRecordShareCommand() + with self.assertLogs(level='WARNING') as logs, \ + mock.patch.object(NestedShareRecordShareCommand, '_get_direct_user_share', + return_value=None): + cmd.execute(_make_params(nested_share_records={ruid: robj}), + record=ruid, email=[email], action='grant', role='viewer') + + output = '\n'.join(logs.output) + self.assertIn('nsf-share-record: Share invitation has been sent', output) + + @patch('keepercommander.nested_share_folder.record_api.share_record_v3') + def test_share_record_no_relationship_still_fails(self, mock_share): + import keepercommander.nested_share_folder as nsf + from keepercommander.commands.nested_share_folder import NestedShareRecordShareCommand + + nsf.__dict__.pop('share_record_v3', None) + ruid, robj = _make_record() + mock_share.side_effect = ValueError( + "No sharing relationship with 'user@example.com'. " + "Please invite them to share first, then repeat this command.") + + cmd = NestedShareRecordShareCommand() + with mock.patch.object(NestedShareRecordShareCommand, '_get_direct_user_share', + return_value=None): + with self.assertRaises(CommandError) as ctx: + cmd.execute(_make_params(nested_share_records={ruid: robj}), + record=ruid, email=['user@example.com'], action='grant', role='viewer') + self.assertIn('No sharing relationship', str(ctx.exception)) + def test_share_record_roe_rejects_non_grant(self): from keepercommander.commands.nested_share_folder import NestedShareRecordShareCommand ruid, robj = _make_record() From 856916ed07ea5b52a33ec19cae1f03e6237d73cc Mon Sep 17 00:00:00 2001 From: "Joao Paulo Oliveira Santos (JP)" Date: Fri, 7 Aug 2026 14:59:41 -0400 Subject: [PATCH 4/4] KC-1398: Add cspm-report command for CSPM/SSPM/GRC posture snapshots Introduces a new enterprise CLI command callable via Commander Service Mode's existing executecommand endpoint. Exports user status, MFA, roles, enforcements, devices, last login (RMD-sourced), and security scores. Supplemental API calls run concurrently; device fetches are scoped to the current page to avoid full-enterprise scans on paginated requests. --- keepercommander/commands/base.py | 3 + keepercommander/commands/cspm.py | 452 +++++++++++++++++++++++++++++++ 2 files changed, 455 insertions(+) create mode 100644 keepercommander/commands/cspm.py diff --git a/keepercommander/commands/base.py b/keepercommander/commands/base.py index 572e0f8c0..c0b171dbb 100644 --- a/keepercommander/commands/base.py +++ b/keepercommander/commands/base.py @@ -203,6 +203,9 @@ def register_enterprise_commands(commands, aliases, command_info): from . import enterprise_reports enterprise_reports.register_commands(commands) enterprise_reports.register_command_info(aliases, command_info) + from . import cspm + cspm.register_commands(commands) + cspm.register_command_info(aliases, command_info) from .risk_management import RiskManagementReportCommand commands['risk-management'] = RiskManagementReportCommand() command_info['risk-management'] = 'Risk Management Reports' diff --git a/keepercommander/commands/cspm.py b/keepercommander/commands/cspm.py new file mode 100644 index 000000000..7300da553 --- /dev/null +++ b/keepercommander/commands/cspm.py @@ -0,0 +1,452 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' 10_000_000_000 else 1 + return datetime.datetime.fromtimestamp(ts_ms / divisor).isoformat() + except Exception: + return str(ts_ms) + + +def _build_subscription(ent): + licenses = ent.get('licenses', []) + if not licenses: + return {} + lic = licenses[0] + + add_ons = [] + for ao in lic.get('add_ons', []): + add_ons.append({ + 'name': ao.get('name'), + 'enabled': ao.get('enabled'), + 'seats': ao.get('seats'), + 'expiration': _ts_to_iso(ao.get('expiration')), + }) + + return { + 'seats_purchased': lic.get('number_of_seats'), + 'seats_allocated': lic.get('seats_allocated'), + 'seats_pending': lic.get('seats_pending'), + 'expiration': _ts_to_iso(lic.get('expiration')), + 'add_ons': add_ons, + } + + +def _rmd_last_login_from_stats(rmd_map, users): + """Derive {email_lower: str|None} from an already-fetched RMD stats map. + + Uses last_logged_in from RMD as the authoritative last-login source. + The audit-log approach (get_audit_event_reports) misses Commander CLI + and some vault session types, making it unreliable for CSPM staleness checks. + """ + result = {} + for u in users: + key = (u.get('username') or '').lower() + if u.get('status') == 'invited': + result[key] = 'N/A' + continue + rmd = rmd_map.get(key, {}) + ll = rmd.get('last_logged_in') + result[key] = ll # datetime string or None + return result + + +def _fetch_rmd_stats(params): + """Return {email_lower: {has_records, last_logged_in}} from RMD API.""" + from .risk_management import RiskManagementEnterpriseStatDetailsCommand + cmd = RiskManagementEnterpriseStatDetailsCommand() + result = {} + try: + output = cmd.execute(params, format='json') + if not output: + return result + rows = json.loads(output) + for row in rows: + if not isinstance(row, dict) or 'username' not in row: + continue + result[(row['username'] or '').lower()] = { + 'has_records': row.get('has_records'), + 'last_logged_in': row.get('last_logged_in'), + } + except Exception as e: + logging.warning('cspm-report: RMD fetch failed: %s', e) + return result + + +def _fetch_security_scores(params): + """Return {email_lower: score} from enterprise security report.""" + from .security_audit import SecurityAuditReportCommand + cmd = SecurityAuditReportCommand() + result = {} + try: + output = cmd.execute(params, format='json') + if not output: + return result + rows = json.loads(output) + for row in rows: + if not isinstance(row, dict) or 'email' not in row: + continue + result[(row['email'] or '').lower()] = row.get('securityScore') + except Exception as e: + logging.warning('cspm-report: security-audit fetch failed: %s', e) + return result + + +def _fetch_devices(params, user_ids=None): + """Return {enterprise_user_id: [device_dict, ...]} for the given user IDs. + + Active devices come from dm/device_admin_list (batches fired concurrently). + Pending-approval devices come from params.enterprise with no extra API call. + Pass user_ids to scope the fetch to a specific set (e.g. the current page). + """ + from ..proto import DeviceManagement_pb2 + from .device_management import StatusMapper, UICategory + + if user_ids is None: + user_ids = [u['enterprise_user_id'] for u in params.enterprise.get('users', [])] + + uid_set = set(user_ids) + devices_by_uid = {} # enterprise_user_id -> [device_dict] + + # --- Active / approved devices via dm/device_admin_list (concurrent batches) --- + batches = [user_ids[i:i + _DEVICE_BATCH_SIZE] for i in range(0, len(user_ids), _DEVICE_BATCH_SIZE)] + + def _fetch_batch(batch): + rq = DeviceManagement_pb2.DeviceAdminRequest() + rq.enterpriseUserIds.extend(batch) + return api.communicate_rest( + params, rq, 'dm/device_admin_list', + rs_type=DeviceManagement_pb2.DeviceAdminResponse, + ) + + if batches: + with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(batches), 10)) as pool: + future_to_batch = {pool.submit(_fetch_batch, b): b for b in batches} + for future in concurrent.futures.as_completed(future_to_batch): + try: + rs = future.result() + except Exception as e: + logging.warning('cspm-report: dm/device_admin_list failed: %s', e) + continue + for device_user in rs.deviceUserList: + uid = device_user.enterpriseUserId + for device_group in device_user.deviceGroups: + for device in device_group.devices: + devices_by_uid.setdefault(uid, []).append({ + 'device_name': device.deviceName or None, + 'client_version': device.clientVersion or None, + 'device_platform': device.devicePlatform or None, + 'device_category': UICategory.get_ui_category(device), + 'device_status': StatusMapper.get_device_status_display(device.deviceStatus), + 'login_state': StatusMapper.get_login_status_display(device.loginState), + 'last_modified': _ts_to_iso(device.lastModifiedTime), + 'ip_address': None, + 'location': None, + }) + + # --- Pending-approval devices already in params.enterprise (no extra API call) --- + for pending in params.enterprise.get('devices_request_for_admin_approval', []): + uid = pending.get('enterprise_user_id') + if uid is None or uid not in uid_set: + continue + ts_ms = pending.get('date') + devices_by_uid.setdefault(uid, []).append({ + 'device_name': pending.get('device_name'), + 'client_version': pending.get('client_version'), + 'device_platform': None, + 'device_category': pending.get('device_type'), + 'device_status': 'NEEDS_APPROVAL', + 'login_state': None, + 'last_modified': _ts_to_iso(ts_ms), + 'ip_address': pending.get('ip_address'), + 'location': pending.get('location'), + }) + + return devices_by_uid + + +class CspmReportCommand(enterprise_common.EnterpriseCommand): + def get_parser(self): + return cspm_report_parser + + def execute(self, params, **kwargs): + ent = params.enterprise + + # Build lookup indexes from enterprise data (no extra API calls) + role_map = { + r['role_id']: (r.get('data') or {}).get('displayname') or str(r['role_id']) + for r in ent.get('roles', []) + } + team_map = { + t['team_uid']: t.get('name') or t['team_uid'] + for t in ent.get('teams', []) + } + + user_role_map = {} # enterprise_user_id -> {role_id} + for ru in ent.get('role_users', []): + user_role_map.setdefault(ru['enterprise_user_id'], set()).add(ru['role_id']) + + user_team_map = {} # enterprise_user_id -> {team_uid} + for tu in ent.get('team_users', []): + user_team_map.setdefault(tu['enterprise_user_id'], set()).add(tu['team_uid']) + + admin_role_ids = {mn['role_id'] for mn in ent.get('managed_nodes', [])} + + role_privileges_map = {} # role_id -> [privilege] + for rp in ent.get('role_privileges', []): + role_privileges_map.setdefault(rp['role_id'], []).append(rp['privilege']) + + role_enforcements_map = { + re['role_id']: re.get('enforcements', {}) + for re in ent.get('role_enforcements', []) + } + + subscription = _build_subscription(ent) + + users = ent.get('users', []) + total = len(users) + + page = max(1, kwargs.get('page', 1)) + page_size = max(1, min(kwargs.get('page_size', 1000), 5000)) + start = (page - 1) * page_size + end = start + page_size + page_users = users[start:end] + page_user_ids = [u['enterprise_user_id'] for u in page_users] + + need_rmd = kwargs.get('include_last_login') or kwargs.get('include_has_records') + need_security = kwargs.get('include_security_audit') + need_devices = kwargs.get('include_devices') + + rmd_map = {} + security_score_map = {} + devices_map = {} + + # Fire all optional supplemental calls concurrently — they hit independent endpoints. + # Devices are scoped to page_user_ids to avoid fetching the entire enterprise on every page. + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool: + pending = {} + if need_rmd: + pending['rmd'] = pool.submit(_fetch_rmd_stats, params) + if need_security: + pending['security'] = pool.submit(_fetch_security_scores, params) + if need_devices: + pending['devices'] = pool.submit(_fetch_devices, params, page_user_ids) + + for key, fut in pending.items(): + try: + result = fut.result() + if key == 'rmd': + rmd_map = result + elif key == 'security': + security_score_map = result + elif key == 'devices': + devices_map = result + except Exception as e: + logging.warning('cspm-report: %s fetch failed: %s', key, e) + + last_login_map = {} + if kwargs.get('include_last_login'): + last_login_map = _rmd_last_login_from_stats(rmd_map, users) + + user_records = [] + for user in page_users: + uid = user['enterprise_user_id'] + user_role_ids = user_role_map.get(uid, set()) + user_team_uids = user_team_map.get(uid, set()) + + is_admin = bool(user_role_ids & admin_role_ids) + + admin_permissions = [] + if is_admin: + seen = set() + for rid in (user_role_ids & admin_role_ids): + for priv in role_privileges_map.get(rid, []): + if priv not in seen: + seen.add(priv) + admin_permissions.append(priv) + + enforcements = {} + for rid in user_role_ids: + enforcements.update(role_enforcements_map.get(rid, {})) + + record = { + 'email': user.get('username'), + 'user_id': uid, + 'status': _map_status(user), + 'is_admin': is_admin, + 'auth_method': _map_auth_method(user), + 'tfa_enabled': bool(user.get('tfa_enabled', False)), + 'roles': sorted(role_map.get(rid, str(rid)) for rid in user_role_ids), + 'teams': sorted(team_map.get(tuid, str(tuid)) for tuid in user_team_uids), + 'admin_permissions': admin_permissions, + 'role_enforcements': enforcements, + } + + if kwargs.get('include_last_login'): + email_key = (user.get('username') or '').lower() + record['last_login'] = last_login_map.get(email_key) + + if kwargs.get('include_has_records'): + rmd = rmd_map.get((user.get('username') or '').lower(), {}) + record['has_records'] = rmd.get('has_records') + last_logged_in = rmd.get('last_logged_in') + record['last_logged_in'] = ( + last_logged_in.isoformat() + if isinstance(last_logged_in, datetime.datetime) + else last_logged_in + ) + + if kwargs.get('include_security_audit'): + email_key = (user.get('username') or '').lower() + record['security_score'] = security_score_map.get(email_key) + + if kwargs.get('include_devices'): + record['devices'] = devices_map.get(uid, []) + + user_records.append(record) + + fmt = kwargs.get('format') or 'json' + output_file = kwargs.get('output') + + if fmt == 'json': + payload = { + 'total': total, + 'page': page, + 'page_size': page_size, + 'has_more': end < total, + 'subscription': subscription, + 'users': user_records, + } + json_str = json.dumps(payload, indent=2, default=str) + if output_file: + with open(output_file, 'w') as f: + f.write(json_str) + return json_str + + # Table / CSV fallback: one row per user, flat columns (devices omitted in table mode) + headers = [ + 'email', 'user_id', 'status', 'is_admin', 'auth_method', + 'tfa_enabled', 'roles', 'teams', + ] + if kwargs.get('include_last_login'): + headers.append('last_login') + if kwargs.get('include_has_records'): + headers.extend(['has_records', 'last_logged_in']) + if kwargs.get('include_security_audit'): + headers.append('security_score') + if kwargs.get('include_devices'): + headers.append('device_count') + for r in user_records: + r['device_count'] = len(r.get('devices', [])) + + rows = [[r.get(h) for h in headers] for r in user_records] + return base.dump_report_data( + rows, + headers=[base.field_to_title(h) for h in headers], + fmt=fmt, + filename=output_file, + )