From f0229b9b4771ebeefccfea47551420689adeebf5 Mon Sep 17 00:00:00 2001 From: lthievenaz-keeper Date: Fri, 6 Mar 2026 12:05:40 +0000 Subject: [PATCH 1/5] Create kcm_export.py Add folder and script to convert KCM resources to PAM Project Extend template --- examples/pam-kcm-import/kcm_export.py | 546 ++++++++++++++++++++++++++ 1 file changed, 546 insertions(+) create mode 100644 examples/pam-kcm-import/kcm_export.py diff --git a/examples/pam-kcm-import/kcm_export.py b/examples/pam-kcm-import/kcm_export.py new file mode 100644 index 000000000..37a71eee8 --- /dev/null +++ b/examples/pam-kcm-import/kcm_export.py @@ -0,0 +1,546 @@ +#!/usr/bin/env python3 +""" +Connects to KCM Database (local/remote) and exports connections and connection groups. +Generates JSON file ready to be imported by pam project extend command. + +Can handle the import of Connection Groups in three ways: +1 - Keeps the Connection Group nesting, except if the Group has a KSM configuration set, in which case it will mapped as a root gateway shared folder. + ROOT/ + └ Connection group A (no config)/ + └ Connection group A1 (no config)/ + Connection group B (config)/ + └ Connection group B1 (no config)/ + +2 - Keeps the exact Connection Group nesting + ROOT/ + ├ Connection group A/ + │ └ Connection group A1/ + └ Connection group B/ + └ Connection group B1/ + +3 - Maps all Connection Groups as root gateway shared folder + ROOT/ + Connection group A/ + Connection group A1/ + Connection group B/ + Connection group B1/ +""" + +from json import dump,dumps,loads + +## RICH Console styling - can be removed if rich was not imported ## +from rich.console import Console +from rich.markdown import Markdown +## RICH Console styling ## + +DEBUG = False + +HOSTNAME = '127.0.0.1' + +DB_CONFIG = { + 'host': HOSTNAME, + 'user': 'guacamole_user', + 'password': 'password', + 'database': 'guacamole_db', + 'port': 3306 +} + +TOTP_ACCOUNT = 'kcm-totp%40keepersecurity.com' + +SQL = { + 'groups': """ +SELECT + cg.connection_group_id, + parent_id, + connection_group_name, + cga.attribute_value AS ksm_config +FROM + guacamole_connection_group cg +LEFT JOIN + guacamole_connection_group_attribute cga +ON + cg.connection_group_id = cga.connection_group_id + AND cga.attribute_name = 'ksm-config' +""", + 'connections': """ +SELECT + c.connection_id, + c.connection_name AS name, + c.protocol, + cp.parameter_name, + cp.parameter_value, + e.name AS entity_name, + e.type AS entity_type, + g.connection_group_id, + g.parent_id, + g.connection_group_name AS group_name, + ca.attribute_name, + ca.attribute_value +FROM + guacamole_connection c +LEFT JOIN + guacamole_connection_parameter cp ON c.connection_id = cp.connection_id +LEFT JOIN + guacamole_connection_attribute ca ON c.connection_id = ca.connection_id +LEFT JOIN + guacamole_connection_group g ON c.parent_id = g.connection_group_id +LEFT JOIN + guacamole_connection_permission p ON c.connection_id = p.connection_id +LEFT JOIN + guacamole_entity e ON p.entity_id = e.entity_id; +""" +} + +# Utils and CLI +USE_RICH = False + +try: + console = Console() + USE_RICH = True +except: + pass + +def display(text,style=None): + if USE_RICH: + console.print(Markdown(text),style=style) + else: + print(text) + + +def list_items(items,style='italic yellow'): + for item in items: + display(f'- {item}',style) + + +def handle_prompt(valid_inputs,prompt='Input: '): + response = input(prompt) + if response.lower() in valid_inputs: + return valid_inputs[response] + display('Invalid input') + return handle_prompt(valid_inputs,prompt=prompt) + + +def validate_file_upload(format,filename=None): + if not filename: + filename = input('File path: ') + try: + with open(filename,'r') as file: + if format=='csv': + from csv import DictReader + return list(DictReader(file)) + elif format=='json': + from json import load + return load(file) + elif format=='yaml': + from yaml import safe_load + return safe_load(file) + + except Exception as e: + display(f'Error: Exception {e} raised','bold red') + return validate_file_upload(format) + + +def debug(text,DEBUG): + if DEBUG: + print(f'>>DEBUG: {text}') + + +class KCM_export: + def __init__(self,DEBUG=DEBUG): + self.mappings = validate_file_upload('json','KCM_mappings.json') + self.debug = DEBUG + self.db_config = DB_CONFIG + self.folder_structure = 'ksm_based' + self.separator = '/' + self.dynamic_tokens = [] + self.logged_records = {} + + display('# KCM Import','bold yellow') + # Collect import method + display('What database are you running on KCM?', 'cyan') + list_items(['(1) MySQL','(2) PostgreSQL']) + self.database = handle_prompt({'1':'MYSQL','2':'POSTGRES'}) + + # Collect db credentials + self.collect_db_config() + + # Connect to db + connect = self.connect_to_db() + if not connect: + display('Unable to connect to database, ending program','bold red') + return + + # Generate template + json_template = self.generate_data() + + display('# Data collected and import-ready', 'green') + display('Exporting JSON template...') + with open('pam_import.json','w') as user_file: + dump(json_template,user_file,indent=2) + display('Exported pam_import.json successfully','italic green') + + return + + + def collect_db_config(self): + display('How do you wish to provide your database details?', 'cyan') + list_items([ + '(1) By docker-compose.yml file', + '(2) I have hardcoded them in the Python script' + ]) + if handle_prompt({'1':'file','2':'code'}) == 'file': + display('## Please upload your docker-compose file', 'cyan') + self.docker_compose = validate_file_upload('yaml') + + port={'MYSQL':3306,'POSTGRES':5432} + custom_port = None + + debug('Analysing services',self.debug) + guacamole_env = self.docker_compose['services']['guacamole']['environment'] + db_in_compose = True + host = "127.0.0.1" + if guacamole_env.get(f'{self.database}_HOSTNAME','db') != 'db': + debug('Alternate DB hostname detected',self.debug) + host = guacamole_env[f'{self.database}_HOSTNAME'] + db_in_compose=False + if db_in_compose and 'ports' in guacamole_env: + custom_port = int(self.docker_compose["services"][guacamole_env[f"{self.database}_HOSTNAME"]]["ports"][0].split(':')[0]) + try: + self.db_config = { + 'host': host, + 'user': guacamole_env[f'{self.database}_USERNAME'], + 'password': guacamole_env[f'{self.database}_PASSWORD'], + 'database': guacamole_env[f'{self.database}_DATABASE'], + 'port': custom_port or port[self.database] + } + except: + display('Unable to parse environment variables into suitable DB details. Please check that your docker-compose file has all relevant Guacamole variables, or hardcode them in the script','italic red') + self.collect_db_config() + + + def connect_to_db(self): + if self.database == 'MYSQL': + try: + from mysql.connector import connect + debug('Attempting connection to database',self.debug) + conn = connect(**self.db_config) + cursor = conn.cursor(dictionary=True) + + display('Database connection successful. Extracting data...','italic green') + + debug('Extracting connection group data',self.debug) + cursor.execute(SQL['groups']) + self.group_data = cursor.fetchall() + + debug('Extracting connection data',self.debug) + cursor.execute(SQL['connections']) + self.connection_data = cursor.fetchall() + + display('Done','italic green') + + return True + + except mysql.connector.Error as e: + display(f'MYSQL connector error: {e}','bold red') + return False + + elif self.database == 'POSTGRES': + try: + from psycopg2 import connect, OperationalError + from psycopg2.extras import RealDictCursor + debug('Attempting connection to database',self.debug) + conn = connect(**self.db_config) + cursor = conn.cursor(cursor_factory=RealDictCursor) + + display('Database connection successful. Extracting data...','italic green') + + debug('Extracting connection group data',self.debug) + cursor.execute(SQL['groups']) + group_rows = cursor.fetchall() + self.group_data = [dict(row) for row in group_rows] + + debug('Extracting connection data',self.debug) + cursor.execute(SQL['connections']) + connection_rows = cursor.fetchall() + self.connection_data = [dict(row) for row in connection_rows] + + display('Done','italic green') + + return True + except OperationalError as e: + display(f'POSTGRESQL connector error: {e}','bold red') + return False + + def generate_data(self): + display('By default, this import will keep the Connection Group nesting you have set in KCM, but any Group with a KSM config will be modelled as a root shared folder', 'yellow') + display('What handling do you want to apply to Connection Groups?','cyan') + display('(1) Set Groups with KSM Config as Root Shared Folders (recommended)') + display('''The folder structure will largely follow that of KCM, however any Connection Group with a KSM Service Configuration will be created as a root shared folder: +ROOT/ +. └ Connection group A (no config)/ +. └ Connection group A1 (no config)/ +Connection group B (config)/ +. └ Connection group B1 (no config)/ + ''', 'yellow') + display('(2) Keep exact KCM nesting') + display('''The folder structure will replicate the exact same structure as KCM's: +ROOT/ +. ├ Connection group A/ +. │ └ Connection group A1/ +. └ Connection group B/ +. └ Connection group B1/ + ''', 'yellow') + display('(3) Flat') + display('''All connection groups will be created as root shared folders: +ROOT/ +Connection group A/ +Connection group A1/ +Connection group B/ +Connection group B1/ + ''', 'yellow') + self.folder_structure = handle_prompt({'1':'ksm_based','2':'nested','3':'flat'}) + + self.group_paths = {} + + def resolve_path(group_id): + if group_id is None: + return "ROOT" + if group_id in self.group_paths: + return self.group_paths[group_id] + # Find the group details + group = next(g for g in self.group_data if g['connection_group_id'] == group_id) + if self.folder_structure == 'ksm_based' and group['ksm_config']: + self.group_paths[group_id] = group['connection_group_name'] + return group['connection_group_name'] + parent_path = resolve_path(group['parent_id']) + full_path = f"{parent_path}{self.separator}{group['connection_group_name']}" + self.group_paths[group_id] = full_path + return full_path + + # Resolve paths for all groups + for group in self.group_data: + if self.folder_structure=='flat': + self.group_paths[group['connection_group_id']] = group['connection_group_name'] + else: + resolve_path(group['connection_group_id']) + + self.connections = {} + self.users = {} + self.shared_folders = [] + print(self.group_paths) + + for connection in self.connection_data: + id = connection['connection_id'] + name = connection["name"] + debug(f'Importing Connection {name}',self.debug) + + # Resolving folder path + KCM_folder_path = self.group_paths.get(connection['connection_group_id'],'ROOT') + folder_array = KCM_folder_path.split(self.separator) + # Log Shared folder + if folder_array[0] not in self.shared_folders: + self.shared_folders.append(folder_array[0]) + + # Add users + if id not in self.users: + # Create bespoke user folders + folder_path = f'KCM Users - {folder_array[0]}' + if len(folder_array)>1: + folder_path += self.separator+self.separator.join(folder_array[1:]) + # Create user + user = { + 'folder_path': folder_path, + 'title': f'KCM User - {name}', + 'type': "pamUser", + 'rotation_settings':{} + } + self.users[id] = user + + # Add resources + if id not in self.connections: + # Create bespoke resource folders + folder_path = f'KCM Resources - {folder_array[0]}' + if len(folder_array)>1: + folder_path += self.separator+self.separator.join(folder_array[1:]) + + # Define record-type + types = { + 'http': 'pamRemoteBrowser', + 'mysql': 'pamDatabase', + 'postgres': 'pamDatabase', + 'sql-server': 'pamDatabase', + } + + resource = { + 'folder_path':folder_path, + 'title': f'Resource {name}', + 'type':types.get(connection['protocol'],'pamMachine'), + "host": "", + "pam_settings": { + "options": { + "rotation": "off", + "connections": "on", + "tunneling": "off", + "graphical_session_recording": "off" + }, + "connection": { + "protocol": connection['protocol'] if connection['protocol'] != "postgres" else "postgresql", + "launch_credentials": f'KCM User - {name}' + } + } + } + self.connections[id] = resource + + def handle_arg(id,name,arg,value): + def handle_mapping(mapping,value,dir): + if mapping == 'ignore': + debug(f'Mapping {arg} ignored',self.debug) + return dir + if mapping=='log': + if name not in self.logged_records: + debug(f'Adding record {name} to logged records',self.debug) + self.logged_records[name] = {'name':name, arg:value} + else: + self.logged_records[name][arg] = value + return dir + if mapping is None: + debug(f'Mapping {arg} recognized but not supported',self.debug) + return dir + if '=' in mapping: + value = mapping.split('=')[1] + mapping = mapping.split('=')[0] + if '.' in mapping: + param_array = mapping.split('.') + if len(param_array)>=2: + if param_array[0] not in dir[id]: + dir[id][param_array[0]] = {} + if len(param_array)==2: + dir[id][param_array[0]][param_array[1]] = value + if len(param_array)>=3: + if param_array[1] not in dir[id][param_array[0]]: + dir[id][param_array[0]][param_array[1]] = {} + if len(param_array)==3: + dir[id][param_array[0]][param_array[1]][param_array[2]] = value + if len(param_array)>=4: + if param_array[2] not in dir[id][param_array[0]][param_array[1]]: + dir[id][param_array[0]][param_array[1]][param_array[2]] = {} + dir[id][param_array[0]][param_array[1]][param_array[2]][param_array[3]] = value + else: + dir[id][mapping] = value + return dir + + if value.startswith('${KEEPER_') and id not in self.dynamic_tokens: + debug('Dynamic token detected',self.debug) + self.dynamic_tokens.append(id) + if name not in self.logged_records: + self.logged_records[name] = {'name':name, 'dynamic_token':True} + else: + self.logged_records[name]['dynamic_token'] = True + elif value and arg.startswith('totp-'): + if 'oneTimeCode' not in user: + user['oneTimeCode'] = { + "totp-algorithm": '', + "totp-digits": "", + "totp-period": "", + "totp-secret": "" + } + user['oneTimeCode'][arg] = value + elif value and arg == 'hostname': + resource['host'] = value + elif value and arg == 'port': + resource['pam_settings']['connection']['port'] = value + elif value and arg in self.mappings['users']: + self.users = handle_mapping(self.mappings['users'][arg],value,self.users) + elif arg in self.mappings['resources']: + self.connections = handle_mapping(self.mappings['resources'][arg],value,self.connections) + else: + display(f'Error: Unknown parameter detected: {arg}. Add it to KCM_mappings.json to resolve this error','bold red') + + # Handle args + if connection['parameter_name']: + handle_arg(id,connection['name'],connection['parameter_name'],connection['parameter_value']) + # Handle attributes + if connection['attribute_name']: + handle_arg(id,connection['name'],connection['attribute_name'],connection['attribute_value']) + + + self.user_records = list(user for user in self.users.values()) + self.resource_records = list(conn for conn in self.connections.values()) + + # Sanitize totp + for user in self.user_records: + if 'oneTimeCode' in user: + alg = user['oneTimeCode']["totp-algorithm"] + dig = user['oneTimeCode']["totp-digits"] + period = user['oneTimeCode']["totp-period"] + secret = user['oneTimeCode']["totp-secret"] + stripped_secret = ''.join([x for x in secret if x.isnumeric()]) + user['otp'] = f'otpauth://totp/{TOTP_ACCOUNT}?secret={stripped_secret}&issuer=&algorithm={alg}&digits={dig}&period={period}' + + # Handle SFTP records + for resource in self.resource_records: + if 'sftp' in resource['pam_settings']['connection']: + sftp_settings = resource['pam_settings']['connection']['sftp'] + # Create resource for SFTP + sftp_resource = { + 'folder_path':resource['folder_path']+'/SFTP Resources', + 'title': f'SFTP connection for resource {resource['host']}', + 'type':'pamMachine', + "host": sftp_settings.get("host",""), + "port": sftp_settings.get("port",""), + "pam_settings": { + "options": { + "rotation": "off", + "connections": "off", + "tunneling": "off", + "graphical_session_recording": "off" + }, + "connection": { + "protocol": 'ssh', + "launch_credentials": f'KCM User - {name}' + } + } + } + self.resource_records.append(sftp_resource) + # Create User for SFTP + sftp_user = { + 'folder_path':f'KCM Users - {resource["folder_path"][16:]}/SFTP Users', + 'title': f'SFTP credentials for resource {resource['host']}', + 'type':'pamUsers', + 'login': sftp_settings.get("login",""), + 'password': sftp_settings.get("password",""), + 'private_pem_key': sftp_settings.get("private_key","") + } + self.user_records.append(sftp_user) + # Set correct SFTP settings + resource['pam_settings']['connection']['sftp'].update({ + "sftp_resource": f'SFTP connection for resource {resource['host']}', + "sftp_user_credentials": f'SFTP credentials for resource {resource['host']}' + }) + + if self.dynamic_tokens: + display(f'{len(self.dynamic_tokens)} dynamic tokens detected, they will be added to the JSON file.') + if self.logged_records: + display(f'{len(self.logged_records)-len(self.dynamic_tokens)} records logged, they will be added to the JSON file.') + + logged_records = [] + if self.logged_records: + logged_records = (list(record for record in self.logged_records.values())) + + shared_folders = [] + for folder in self.shared_folders: + shared_folders.extend([f'KCM Users - {folder}',f'KCM Resources - {folder}']) + display('Make sure to add the following Shared Folders to your Gateway Application before importing:') + list_items(shared_folders) + + return { + "pam_data": { + "shared_folders": shared_folders, + "logged_records": logged_records, + "resources": self.resource_records, + "users": [user for user in self.user_records if len(user)>4] + } + } + + +KCM_export() From e267486c32c72acb63c189afcf8fc9aff6968bbb Mon Sep 17 00:00:00 2001 From: lthievenaz-keeper Date: Fri, 6 Mar 2026 12:06:33 +0000 Subject: [PATCH 2/5] Create KCM_mappings.json Add mapping dictionary of KCM parameters, to use in conjunction with the kcm_export.py script --- examples/pam-kcm-import/KCM_mappings.json | 158 ++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 examples/pam-kcm-import/KCM_mappings.json diff --git a/examples/pam-kcm-import/KCM_mappings.json b/examples/pam-kcm-import/KCM_mappings.json new file mode 100644 index 000000000..d027b7271 --- /dev/null +++ b/examples/pam-kcm-import/KCM_mappings.json @@ -0,0 +1,158 @@ +{ + "users":{ + "username":"login", + "password":"password", + "private-key": "private_pem_key", + "public-key": "log", + "passphrase": "log", + "totp-algorithm": "totp-algorithm", + "totp-digits": "totp-digits", + "totp-period": "totp-period", + "totp-secret": "totp-secret" + }, + "resources":{ + "domain": "domain_name", + "create-recording-path": "pam_settings.options.graphical_session_recording=on", + "create-typescript-path": "pam_settings.options.text_session_recording=on", + "recording-include-keys": "pam_settings.connection.recording_include_keys", + "security": "pam_settings.connection.security", + "color-depth": null, + "enable-audio": null, + "disable-copy": "pam_settings.connection.disable_copy", + "disable-paste": "pam_settings.connection.disable_paste", + "force-lossless": null, + "read-only": null, + "backspace": null, + "url": "url", + "allow-url-manipulation": "pam_settings.connection.allow_url_manipulation", + "ignore-initial-ssl-cert": null, + "allowed-resource-url-patterns": "pam_settings.connection.allowed_resource_url_patterns", + "allowed-url-patterns": "pam_settings.connection.allowed_url_patterns", + "autofill-configuration": "pam_settings.connection.autofill_targets", + "disable-audio": "pam_settings.connection.disable_audio", + "audio-bps": null, + "audio-channels": null, + "audio-sample-rate": null, + "ca-cert": "pam_settings.connection.ca_certificate", + "client-cert": "pam_settings.connection.client_certificate", + "client-key": "pam_settings.connection.client_key", + "color-scheme": "pam_settings.connection.color_scheme", + "font-name": null, + "font-size": "pam_settings.connection.font_size", + "scrollback": null, + "ignore-cert": "pam_settings.connection.ignore_server_cert", + "namespace": "pam_settings.connection.namespace", + "pod": "pam_settings.connection.pod_name", + "container": "pam_settings.connection.container", + "use-ssl": "use_ssl", + "database": "pam_settings.connection.default_database", + "disable-csv-export": "pam_settings.connection.disable_csv_export", + "disable-csv-import": "pam_settings.connection.disable_csv_import", + "client-name": null, + "console": null, + "console-audio": null, + "disable-auth": "pam_settings.connection.disable_authentication", + "disable-bitmap-caching": null, + "disable-glyph-caching": null, + "disable-offscreen-caching": null, + "dpi": null, + "enable-audio-input": null, + "disable-display-resize": "pam_settings.connection.disable_dynamic_resizing", + "enable-desktop-composition": "pam_settings.connection.enableDesktopComposition", + "enable-font-smoothing": "pam_settings.connection.enableFontSmooting", + "enable-full-window-drag": "pam_settings.connection.enable_full_window_drag", + "enable-menu-animations": null, + "enable-printing": null, + "enable-theming": null, + "enable-touch": null, + "enable-wallpaper": "pam_settings.connection.enable_wallpaper", + "initial-program": null, + "load-balance-info": "pam_settings.connection.load_balance_info", + "normalize-clipboard": null, + "preconnection-blob": "pam_settings.connection.preconnection_blob", + "preconnection-id": "pam_settings.connection.preconnection_id", + "printer-name": null, + "remote-app": null, + "remote-app-args": null, + "remote-app-dir": null, + "resize-method": null, + "timezone": null, + "width": null, + "height": null, + "locale": null, + "host-key": "pam_settings.connection.public_host_key", + "command": "pam_settings.connection.command", + "server-alive-interval": null, + "terminal-type": null, + "login-failure-regex": "pam_settings.connection.login_failure_regex", + "login-success-regex": "pam_settings.connection.login_success_regex", + "password-regex": "pam_settings.connection.password_regex", + "username-regex": "pam_settings.connection.username_regex", + "audio-servername": null, + "clipboard-buffer-size": null, + "clipboard-encoding": null, + "compress-level": null, + "cursor": null, + "dest-host": null, + "dest-port": null, + "disable-server-input": null, + "encodings": null, + "quality-level": null, + "swap-red-blue": null, + "wol-broadcast-addr": null, + "wol-mac-addr": null, + "wol-send-packet": null, + "wol-udp-port": null, + "wol-wait-time": null, + "create-profile-directory": null, + "profile-storage-directory": null, + "exec-command": null, + "unix-socket": null, + "cert-fingerprints": null, + "cert-tofu": null, + "disable-download": null, + "disable-gfx": null, + "disable-upload": null, + "drive-name": null, + "drive-path": null, + "enable-drive": null, + "create-drive-path": null, + "gateway-domain": null, + "gateway-hostname": null, + "gateway-password": null, + "gateway-port": null, + "gateway-username": null, + "server-layout": null, + "static-channels": null, + "timeout": null, + "ca-certificate": null, + "disable-cert-hostname-verification": null, + "force-encryption": null, + "protocol-version": null, + "ksm-user-config-enabled": "ignore", + "recording-name": "ignore", + "recording-path": "ignore", + "recording-write-existing": "ignore", + "typescript-name": "ignore", + "typescript-path": "ignore", + "typescript-write-existing": "ignore", + "recording-exclude-mouse": null, + "recording-exclude-output": null, + "recording-exclude-touch": null, + "enable-sftp": "pam_settings.connection.sftp.enable_sftp", + "sftp-directory": "pam_settings.connection.sftp.sftp_upload_directory", + "sftp-disable-download": null, + "sftp-disable-upload": null, + "sftp-host-key": null, + "sftp-hostname": "pam_settings.connection.sftp.host", + "sftp-passphrase": null, + "sftp-password": "pam_settings.connection.sftp.password", + "sftp-port": "pam_settings.connection.sftp.port", + "sftp-private-key": "pam_settings.connection.sftp.private_key", + "sftp-public-key": null, + "sftp-root-directory": "pam_settings.connection.sftp.sftp_root_directory", + "sftp-server-alive-interval": "pam_settings.connection.sftp.sftp_keepalive_interval", + "sftp-timeout":null, + "sftp-username": "pam_settings.connection.sftp.login" + } +} From fd1e96353141d11cea44a1182263cf1a7959f405 Mon Sep 17 00:00:00 2001 From: lthievenaz-keeper Date: Fri, 6 Mar 2026 12:07:24 +0000 Subject: [PATCH 3/5] Added comment about KCM_mappings --- examples/pam-kcm-import/kcm_export.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/pam-kcm-import/kcm_export.py b/examples/pam-kcm-import/kcm_export.py index 37a71eee8..3f332f564 100644 --- a/examples/pam-kcm-import/kcm_export.py +++ b/examples/pam-kcm-import/kcm_export.py @@ -3,6 +3,8 @@ Connects to KCM Database (local/remote) and exports connections and connection groups. Generates JSON file ready to be imported by pam project extend command. +Must be run along with a dictionary of KCM parameters named KCM_mappings.json. + Can handle the import of Connection Groups in three ways: 1 - Keeps the Connection Group nesting, except if the Group has a KSM configuration set, in which case it will mapped as a root gateway shared folder. ROOT/ From 7596cdc4c53f737165f06d5681cd27155d004fd7 Mon Sep 17 00:00:00 2001 From: lthievenaz-keeper Date: Fri, 6 Mar 2026 14:00:43 +0000 Subject: [PATCH 4/5] Fixed syntax for f strings with older python version Older versions of python don't support using the same quote characters on f strings - fixed --- examples/pam-kcm-import/kcm_export.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/pam-kcm-import/kcm_export.py b/examples/pam-kcm-import/kcm_export.py index 3f332f564..d8c22e172 100644 --- a/examples/pam-kcm-import/kcm_export.py +++ b/examples/pam-kcm-import/kcm_export.py @@ -486,7 +486,7 @@ def handle_mapping(mapping,value,dir): # Create resource for SFTP sftp_resource = { 'folder_path':resource['folder_path']+'/SFTP Resources', - 'title': f'SFTP connection for resource {resource['host']}', + 'title': f'SFTP connection for resource {resource["host"]}', 'type':'pamMachine', "host": sftp_settings.get("host",""), "port": sftp_settings.get("port",""), @@ -507,7 +507,7 @@ def handle_mapping(mapping,value,dir): # Create User for SFTP sftp_user = { 'folder_path':f'KCM Users - {resource["folder_path"][16:]}/SFTP Users', - 'title': f'SFTP credentials for resource {resource['host']}', + 'title': f'SFTP credentials for resource {resource["host"]}', 'type':'pamUsers', 'login': sftp_settings.get("login",""), 'password': sftp_settings.get("password",""), @@ -516,8 +516,8 @@ def handle_mapping(mapping,value,dir): self.user_records.append(sftp_user) # Set correct SFTP settings resource['pam_settings']['connection']['sftp'].update({ - "sftp_resource": f'SFTP connection for resource {resource['host']}', - "sftp_user_credentials": f'SFTP credentials for resource {resource['host']}' + "sftp_resource": f'SFTP connection for resource {resource["host"]}', + "sftp_user_credentials": f'SFTP credentials for resource {resource["host"]}' }) if self.dynamic_tokens: From cfaddd45d9e4e032a9b62baa6fd3b189db61569a Mon Sep 17 00:00:00 2001 From: lthievenaz-keeper Date: Fri, 6 Mar 2026 15:18:59 +0000 Subject: [PATCH 5/5] Updated naming scheme for resource --- examples/pam-kcm-import/kcm_export.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/pam-kcm-import/kcm_export.py b/examples/pam-kcm-import/kcm_export.py index d8c22e172..b91eab42f 100644 --- a/examples/pam-kcm-import/kcm_export.py +++ b/examples/pam-kcm-import/kcm_export.py @@ -274,7 +274,6 @@ def connect_to_db(self): return False def generate_data(self): - display('By default, this import will keep the Connection Group nesting you have set in KCM, but any Group with a KSM config will be modelled as a root shared folder', 'yellow') display('What handling do you want to apply to Connection Groups?','cyan') display('(1) Set Groups with KSM Config as Root Shared Folders (recommended)') display('''The folder structure will largely follow that of KCM, however any Connection Group with a KSM Service Configuration will be created as a root shared folder: @@ -375,7 +374,7 @@ def resolve_path(group_id): resource = { 'folder_path':folder_path, - 'title': f'Resource {name}', + 'title': f'KCM Resource - {name}', 'type':types.get(connection['protocol'],'pamMachine'), "host": "", "pam_settings": {