diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index 17fb6fe4d..a6eb2c399 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -29,6 +29,7 @@ from .folder import FolderMoveCommand from .ksm import KSMCommand from .pam import gateway_helper, router_helper +from .pam.recording_commands import PAMGetRecordingsForUsersCommand, PAMDownloadRecordingsCommand from .pam.config_facades import PamConfigurationRecordFacade from .pam.config_helper import configuration_controller_get, \ pam_configurations_get_all, pam_configuration_remove, \ @@ -292,6 +293,7 @@ def __init__(self): 'Manage Cloud-Native Application Protection Platform integration', 'cn') self.register_command('universal-sync-config', PAMUniversalSyncConfigCommand(), 'Manage Universal Sync Configurations', 'usc') self.register_command('universal-sync-run', PAMUniversalSyncRunCommand(), 'Run Universal Sync', 'usr') + self.register_command('recording', PAMRecordingCommand(), 'Manage PAM Session Recordings', 'rec') class PAMGatewayCommand(GroupCommand): @@ -331,6 +333,17 @@ def __init__(self): self.default_verb = 'list' +class PAMRecordingCommand(GroupCommand): + + def __init__(self): + super(PAMRecordingCommand, self).__init__() + self.register_command('list-by-user', PAMGetRecordingsForUsersCommand(), + 'List session recordings for one or more users', 'lbu') + self.register_command('download', PAMDownloadRecordingsCommand(), + 'Download recording files to a local directory', 'dl') + self.default_verb = 'list-by-user' + + class PAMDiscoveryCommand(GroupCommand): def __init__(self): diff --git a/keepercommander/commands/pam/recording_commands.py b/keepercommander/commands/pam/recording_commands.py new file mode 100644 index 000000000..0f1ccf553 --- /dev/null +++ b/keepercommander/commands/pam/recording_commands.py @@ -0,0 +1,440 @@ +import argparse +import base64 +import datetime +import json +import logging +import os +import struct +from concurrent.futures import ThreadPoolExecutor, as_completed + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +from ... import crypto, utils +from ...proto import pam_pb2 +from .router_helper import router_get_recordings_for_users, router_download_recording +from ..base import Command, dump_report_data + +RISK_LEVEL_NAMES = { + pam_pb2.PRR_UNSPECIFIED: 'unspecified', + pam_pb2.PRR_LOW: 'low', + pam_pb2.PRR_MEDIUM: 'medium', + pam_pb2.PRR_HIGH: 'high', + pam_pb2.PRR_CRITICAL: 'critical', +} + +RECORDING_TYPE_NAMES = { + pam_pb2.PRT_SESSION: 'session', + pam_pb2.PRT_TYPESCRIPT: 'typescript', + pam_pb2.PRT_TIME: 'time', + pam_pb2.PRT_SUMMARY: 'summary', +} + +RECORDING_TYPE_VALUES = {v: k for k, v in RECORDING_TYPE_NAMES.items()} +RISK_LEVEL_VALUES = {v: k for k, v in RISK_LEVEL_NAMES.items() if v != 'unspecified'} + +# PAMRecordingType → file extension used in the download URL +_TYPE_EXT = { + pam_pb2.PRT_SESSION: 'ses', + pam_pb2.PRT_TYPESCRIPT: 'tys', + pam_pb2.PRT_TIME: 'tim', + pam_pb2.PRT_SUMMARY: 'sum', +} + + +def _get_record_key(params, record_uid_bytes): + """Return the plaintext AES record key for a record UID, or None if not cached.""" + uid_str = utils.base64_url_encode(record_uid_bytes) + rec = params.record_cache.get(uid_str) + if rec is None: + return None + return rec.get('record_key_unencrypted') + + +def _decrypt_recording_file(raw_bytes, record_key): + """Decrypt a PAM recording file. + + Wire format (from vault session-recordings-util.ts): + [4 bytes big-endian: AD length][AD JSON bytes][0x3B ';'][12-byte nonce][AES-GCM ciphertext] + + Key derivation: + salt = AD.resourceKeysSalt (base64) if present, else nonce (legacy) + info = b"{AD.conversationUid}_RECORDING-SESSION_AES-GCM-256" + resourceKey = HKDF-SHA256(ikm=record_key, salt=salt, info=info, length=32) + + Then: + privateMeta = AES-GCM-decrypt(AD.resourceData_base64, resourceKey) → JSON + plaintext = AES-GCM-decrypt(ciphertext, privateMeta.recordingSecret, nonce=nonce, aad=AD_bytes) + """ + # --- parse wire format --- + ad_len = struct.unpack('>I', raw_bytes[:4])[0] + ad_bytes = raw_bytes[4:4 + ad_len] + term_pos = 4 + ad_len + if raw_bytes[term_pos:term_pos + 1] != b';': + raise ValueError('Recording format error: expected ";" terminator after Associated Data') + nonce = raw_bytes[term_pos + 1:term_pos + 13] # 12 bytes + ciphertext = raw_bytes[term_pos + 13:] + + ad = json.loads(ad_bytes.decode('utf-8')) + + # --- derive resource key --- + if ad.get('resourceKeysSalt'): + salt = base64.b64decode(ad['resourceKeysSalt']) + else: + salt = nonce # legacy recordings: nonce was used as salt + + info = f"{ad['conversationUid']}_RECORDING-SESSION_AES-GCM-256".encode('utf-8') + resource_key = HKDF(algorithm=hashes.SHA256(), length=32, salt=salt, info=info).derive(record_key) + + # --- decrypt AD.resourceData → PrivateMeta JSON (contains recordingSecret) --- + resource_data_enc = base64.b64decode(ad['resourceData']) + # stored as nonce(12) + ciphertext — same layout as crypto.decrypt_aes_v2 + private_meta_bytes = AESGCM(resource_key).decrypt( + resource_data_enc[:12], resource_data_enc[12:], None) + private_meta = json.loads(private_meta_bytes.decode('utf-8')) + + # --- decrypt recording data using recordingSecret --- + recording_secret = base64.b64decode(private_meta['recordingSecret']) + plaintext = AESGCM(recording_secret).decrypt(nonce, ciphertext, ad_bytes) + return plaintext + + +def _parse_dt(s): + for fmt in ('%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S', '%Y-%m-%d'): + try: + return datetime.datetime.strptime(s, fmt) + except ValueError: + pass + raise ValueError(f'Cannot parse datetime: {s!r} (use YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)') + + +def _fmt_ts(epoch_ms): + if not epoch_ms: + return '' + return datetime.datetime.utcfromtimestamp(epoch_ms / 1000).strftime('%Y-%m-%d %H:%M:%S UTC') + + +def _build_user_request(kwargs): + """Build a PAMRecordingsForUsersRequest from command kwargs.""" + rq = pam_pb2.PAMRecordingsForUsersRequest() + rq.usernames.extend(kwargs.get('usernames') or []) + rq.maxCount = kwargs.get('max_count') or 0 + + if kwargs.get('range_start'): + rq.rangeStart = int(_parse_dt(kwargs['range_start']).timestamp() * 1000) + if kwargs.get('range_end'): + rq.rangeEnd = int(_parse_dt(kwargs['range_end']).timestamp() * 1000) + + for t in (kwargs.get('types') or []): + rq.types.append(RECORDING_TYPE_VALUES[t]) + for r in (kwargs.get('risks') or []): + rq.risks.append(RISK_LEVEL_VALUES[r]) + rq.protocols.extend(kwargs.get('protocols') or []) + return rq + + +def _add_filter_args(parser): + """Add the shared filter arguments to a parser.""" + parser.add_argument('--types', nargs='+', metavar='TYPE', + choices=list(RECORDING_TYPE_VALUES.keys()), + help='filter by type: session typescript time summary') + parser.add_argument('--risks', nargs='+', metavar='RISK', + choices=list(RISK_LEVEL_VALUES.keys()), + help='filter by AI risk: low medium high critical') + parser.add_argument('--protocols', nargs='+', metavar='PROTOCOL', + help='filter by protocol, e.g. SSH RDP') + parser.add_argument('--range-start', metavar='DATE', + help='recordings on or after this date (YYYY-MM-DD)') + parser.add_argument('--range-end', metavar='DATE', + help='recordings on or before this date (YYYY-MM-DD)') + parser.add_argument('--max-count', type=int, default=0, metavar='N', + help='max results to fetch (default: server max of 1000)') + + +# KRouter rate limit: 50 requests / 5 seconds per user (HTTP.kt). +# We keep workers at 4 so a burst of 4 files lands well inside that budget, +# leaving headroom for any other concurrent API calls in the same session. +_DOWNLOAD_WORKERS = 4 + + +def _download_one(params, conn_uid_bytes, ext, output_dir, record_key): + """Download, decrypt, and save a single recording file. Returns (filename, bytes_written, error).""" + uid_b64 = base64.urlsafe_b64encode(conn_uid_bytes).rstrip(b'=').decode() + filename = f'{uid_b64}.{ext}' + dest = os.path.join(output_dir, filename) + try: + raw = router_download_recording(params, conn_uid_bytes, ext) + plaintext = _decrypt_recording_file(raw, record_key) + with open(dest, 'wb') as f: + f.write(plaintext) + return filename, len(plaintext), None + except Exception as e: + return filename, 0, e + + +def _download_recording_files(params, conn_uid_bytes, exts, output_dir, record_key): + """Download and decrypt one session's recording files (parallel across types).""" + tasks = [(conn_uid_bytes, ext, output_dir, record_key) for ext in exts] + with ThreadPoolExecutor(max_workers=min(len(tasks), _DOWNLOAD_WORKERS)) as ex: + futures = {ex.submit(_download_one, params, *t): t[1] for t in tasks} + for fut in as_completed(futures): + filename, nbytes, err = fut.result() + if err: + logging.warning(' ✗ %s — %s', filename, err) + else: + print(f' ✓ {filename} ({nbytes:,} bytes)') + + +def _download_recordings(params, recordings, type_filter, output_dir): + """Download and decrypt all recordings in parallel, fetching keys from vault cache.""" + os.makedirs(output_dir, exist_ok=True) + print(f'Downloading to {os.path.abspath(output_dir)} ...') + + # Build the full flat task list: (conn_uid_bytes, ext, record_key) + tasks = [] + for rec in recordings: + if not rec.connectionUid: + continue + record_key = _get_record_key(params, rec.recordUid) if rec.recordUid else None + if record_key is None: + logging.warning(' skipping %s — record key not in vault cache (run sync-down first)', + rec.connectionUid.hex()[:12]) + continue + if type_filter: + exts = [_TYPE_EXT[t] for t in type_filter] + else: + ext = _TYPE_EXT.get(rec.recordingType) + exts = [ext] if ext else [] + for ext in exts: + tasks.append((rec.connectionUid, ext, record_key)) + + if not tasks: + print('Nothing to download.') + return + + with ThreadPoolExecutor(max_workers=_DOWNLOAD_WORKERS) as ex: + futures = { + ex.submit(_download_one, params, conn_uid, ext, output_dir, key): (conn_uid, ext) + for conn_uid, ext, key in tasks + } + for fut in as_completed(futures): + filename, nbytes, err = fut.result() + if err: + logging.warning(' ✗ %s — %s', filename, err) + else: + print(f' ✓ {filename} ({nbytes:,} bytes)') + + +class PAMGetRecordingsForUsersCommand(Command): + """List PAM session recordings for one or more users, with optional download.""" + + def get_parser(self): + parser = argparse.ArgumentParser( + prog='pam recording list-by-user', + description='List PAM session recordings for one or more users.') + parser.add_argument('usernames', nargs='+', metavar='USERNAME', + help='one or more Keeper email addresses') + _add_filter_args(parser) + parser.add_argument('--format', dest='output_format', + choices=['table', 'json', 'csv'], default='table', + help='display format (default: table)') + parser.add_argument('--output', metavar='FILE', + help='save listing to a file; format inferred from extension ' + '(.json, .csv, .md/.txt = markdown table)') + parser.add_argument('--download-dir', metavar='DIR', + help='download and decrypt recording files into this directory ' + '(implies download; created if it does not exist)') + return parser + + def execute(self, params, **kwargs): + usernames = kwargs.get('usernames') or [] + if not usernames: + logging.error('At least one username is required.') + return + + rq = _build_user_request(kwargs) + + try: + rs = router_get_recordings_for_users(params, rq) + except Exception as e: + logging.error('get_recordings_for_users failed: %s', e) + return + + if rs is None: + print('No response from router.') + return + + recordings = list(rs.recordings) + has_more = rs.hasMore + + # --- listing output --- + output_file = kwargs.get('output') + output_format = kwargs.get('output_format', 'table') + if output_file: + file_ext = output_file.rsplit('.', 1)[-1].lower() if '.' in output_file else '' + if file_ext == 'json': + output_format = 'json' + elif file_ext == 'csv': + output_format = 'csv' + else: + output_format = 'grid' # markdown table for .md / .txt + + if output_format == 'json': + _print_json(recordings, has_more, output_file) + else: + _print_table(recordings, has_more, output_format, output_file) + + # --- optional download --- + dl_dir = kwargs.get('download_dir') + if dl_dir and recordings: + type_filter = [RECORDING_TYPE_VALUES[t] for t in (kwargs.get('types') or [])] + _download_recordings(params, recordings, type_filter, dl_dir) + + +class PAMDownloadRecordingsCommand(Command): + """Download and decrypt PAM session recording files. + + Two modes: + --usernames List recordings for those users then download all of them. + --connection-uid Download specific session(s) by connection UID + (requires --record-uid so the file can be decrypted). + """ + + def get_parser(self): + parser = argparse.ArgumentParser( + prog='pam recording download', + description='Download and decrypt PAM recording files to a local directory.') + src = parser.add_mutually_exclusive_group(required=True) + src.add_argument('--usernames', nargs='+', metavar='USERNAME', + help='download all recordings for these users') + src.add_argument('--connection-uid', nargs='+', metavar='HEX', + help='one or more connection UIDs (hex) to download directly') + parser.add_argument('--record-uid', metavar='HEX', + help='record UID (hex) — required with --connection-uid for decryption') + parser.add_argument('--output-dir', metavar='DIR', default='recordings', + help='directory to save files into (default: ./recordings)') + _add_filter_args(parser) + return parser + + def execute(self, params, **kwargs): + output_dir = kwargs.get('output_dir') or 'recordings' + type_filter = [RECORDING_TYPE_VALUES[t] for t in (kwargs.get('types') or [])] + + conn_uid_hexes = kwargs.get('connection_uid') or [] + if conn_uid_hexes: + record_uid_hex = kwargs.get('record_uid') + if not record_uid_hex: + logging.error( + '--record-uid is required with --connection-uid so the file can be decrypted.\n' + ' Find the record UID with: pam rec lbu --format json') + return + try: + record_uid_bytes = bytes.fromhex(record_uid_hex) + except ValueError: + logging.error('Invalid --record-uid (expected hex): %s', record_uid_hex) + return + + record_key = _get_record_key(params, record_uid_bytes) + if record_key is None: + logging.error('Record %s not found in vault cache — run sync-down first.', record_uid_hex) + return + + os.makedirs(output_dir, exist_ok=True) + print(f'Downloading to {os.path.abspath(output_dir)} ...') + exts = [_TYPE_EXT[t] for t in (type_filter or list(_TYPE_EXT.keys()))] + for conn_uid_hex in conn_uid_hexes: + try: + conn_uid_bytes = bytes.fromhex(conn_uid_hex) + except ValueError: + logging.error('Invalid --connection-uid (expected hex): %s', conn_uid_hex) + continue + _download_recording_files(params, conn_uid_bytes, exts, output_dir, record_key) + return + + # --- username mode --- + usernames = kwargs.get('usernames') or [] + rq = _build_user_request(kwargs) + + try: + rs = router_get_recordings_for_users(params, rq) + except Exception as e: + logging.error('Failed to list recordings: %s', e) + return + + if rs is None: + print('No response from router.') + return + + recordings = list(rs.recordings) + if not recordings: + print('No recordings found.') + return + + if rs.hasMore: + print(f'Found {len(recordings)} recording(s) (server has more — narrow filters or increase --max-count).') + else: + print(f'Found {len(recordings)} recording(s).') + + _download_recordings(params, recordings, type_filter, output_dir) + + +def _print_table(recordings, has_more, fmt='table', output_file=None): + if not recordings: + print('No recordings found.') + return + + rows = [] + for rec in recordings: + conn_uid = rec.connectionUid.hex() if rec.connectionUid else '' + record_uid = rec.recordUid.hex() if rec.recordUid else '' + rec_type = RECORDING_TYPE_NAMES.get(rec.recordingType, str(rec.recordingType)) + risk = RISK_LEVEL_NAMES.get(rec.aiOverallRiskLevel, '') + rows.append([ + conn_uid[:16] + '…' if len(conn_uid) > 16 else conn_uid, + record_uid[:16] + '…' if len(record_uid) > 16 else record_uid, + rec.userName, + rec_type, + rec.protocol or '', + _fmt_ts(rec.startedOn) if rec.startedOn else '', + str(rec.length) + 's' if rec.length else '', + risk, + ]) + + headers = ['ConnUID', 'RecordUID', 'User', 'Type', 'Protocol', 'Started', 'Duration', 'Risk'] + dump_report_data(rows, headers, fmt=fmt, filename=output_file or '', row_number=False) + if output_file: + print(f'Wrote {len(recordings)} recording(s) to {output_file}') + else: + suffix = ' (more available — narrow filters or increase --max-count)' if has_more else '' + print(f'\nTotal: {len(recordings)}{suffix}') + + +def _print_json(recordings, has_more, output_file=None): + import json + from base64 import b64encode + + out = [] + for rec in recordings: + out.append({ + 'connectionUid': rec.connectionUid.hex() if rec.connectionUid else None, + 'recordUid': rec.recordUid.hex() if rec.recordUid else None, + 'userName': rec.userName, + 'recordingType': RECORDING_TYPE_NAMES.get(rec.recordingType, rec.recordingType), + 'protocol': rec.protocol or None, + 'startedOn': rec.startedOn, + 'createdOn': rec.createdOn, + 'length': rec.length, + 'fileSize': rec.fileSize, + 'closeReason': rec.closeReason or None, + 'recordingDuration': rec.recordingDuration or None, + 'aiOverallRiskLevel': RISK_LEVEL_NAMES.get(rec.aiOverallRiskLevel, None), + 'aiOverallSummary': b64encode(rec.aiOverallSummary).decode() if rec.aiOverallSummary else None, + }) + payload = json.dumps({'recordings': out, 'hasMore': has_more}, indent=2) + if output_file: + with open(output_file, 'w') as f: + f.write(payload) + print(f'Wrote {len(recordings)} recording(s) to {output_file}') + else: + print(payload) diff --git a/keepercommander/commands/pam/router_helper.py b/keepercommander/commands/pam/router_helper.py index cf0594cef..7c40f030e 100644 --- a/keepercommander/commands/pam/router_helper.py +++ b/keepercommander/commands/pam/router_helper.py @@ -122,6 +122,27 @@ def router_get_rotation_schedules(params, proto_request): return _post_request_to_router(params, 'get_rotation_schedules', rq_proto=proto_request, rs_type=pam_pb2.PAMRotationSchedulesResponse) +def router_get_recordings_for_users(params, proto_request): + return _post_request_to_router(params, 'get_recordings_for_users', rq_proto=proto_request, rs_type=pam_pb2.PAMRecordingsResponse) + + +def router_download_recording(params, connection_uid_bytes, recording_type_ext): + # type: (KeeperParams, bytes, str) -> bytes + """Download a single recording file. Returns raw file bytes.""" + import base64 + uid_b64 = base64.urlsafe_b64encode(connection_uid_bytes).rstrip(b'=').decode() + filename = f'{uid_b64}.{recording_type_ext}' + rs = _post_request_to_router(params, f'pam_recordings/{filename}', method='get', + raw_without_status_check_response=True) + if rs.status_code == 404: + raise KeeperApiError(404, f'Recording not found: {filename}') + if rs.status_code == 403: + raise KeeperApiError(403, f'Access denied to recording: {filename}') + if rs.status_code >= 400: + raise KeeperApiError(rs.status_code, f'Download failed ({rs.status_code}): {filename}') + return rs.content + + def router_get_relay_access_creds(params, expire_sec=None): query_params = { 'expire-sec': expire_sec diff --git a/keepercommander/proto/pam_pb2.py b/keepercommander/proto/pam_pb2.py index 9f8a54d8a..05f9d0a0c 100644 --- a/keepercommander/proto/pam_pb2.py +++ b/keepercommander/proto/pam_pb2.py @@ -26,7 +26,7 @@ from . import record_pb2 as record__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\tpam.proto\x12\x03PAM\x1a\x10\x65nterprise.proto\x1a\x0crecord.proto\"\x83\x01\n\x13PAMRotationSchedule\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\x14\n\x0cscheduleData\x18\x04 \x01(\t\x12\x12\n\nnoSchedule\x18\x05 \x01(\x08\"K\n\x1cPAMRotationSchedulesResponse\x12+\n\tschedules\x18\x01 \x03(\x0b\x32\x18.PAM.PAMRotationSchedule\"\x94\x01\n\x13PAMOnlineController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x13\n\x0b\x63onnectedOn\x18\x02 \x01(\x03\x12\x11\n\tipAddress\x18\x03 \x01(\t\x12\x0f\n\x07version\x18\x04 \x01(\t\x12-\n\x0b\x63onnections\x18\x05 \x03(\x0b\x32\x18.PAM.PAMWebRtcConnection\"\xa7\x01\n\x13PAMWebRtcConnection\x12\x15\n\rconnectionUid\x18\x01 \x01(\x0c\x12\'\n\x04type\x18\x02 \x01(\x0e\x32\x19.PAM.WebRtcConnectionType\x12\x11\n\trecordUid\x18\x03 \x01(\x0c\x12\x10\n\x08userName\x18\x04 \x01(\t\x12\x11\n\tstartedOn\x18\x05 \x01(\x03\x12\x18\n\x10\x63onfigurationUid\x18\x06 \x01(\x0c\"Y\n\x14PAMOnlineControllers\x12\x12\n\ndeprecated\x18\x01 \x03(\x0c\x12-\n\x0b\x63ontrollers\x18\x02 \x03(\x0b\x32\x18.PAM.PAMOnlineController\"9\n\x10PAMRotateRequest\x12\x12\n\nrequestUid\x18\x01 \x01(\x0c\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\"A\n\x16PAMControllersResponse\x12\'\n\x0b\x63ontrollers\x18\x01 \x03(\x0b\x32\x12.PAM.PAMController\"=\n\x13PAMRemoveController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x0f\n\x07message\x18\x02 \x01(\t\"L\n\x1bPAMRemoveControllerResponse\x12-\n\x0b\x63ontrollers\x18\x01 \x03(\x0b\x32\x18.PAM.PAMRemoveController\"=\n\x10PAMModifyRequest\x12)\n\noperations\x18\x01 \x03(\x0b\x32\x15.PAM.PAMDataOperation\"\x98\x01\n\x10PAMDataOperation\x12,\n\roperationType\x18\x01 \x01(\x0e\x32\x15.PAM.PAMOperationType\x12\x30\n\rconfiguration\x18\x02 \x01(\x0b\x32\x19.PAM.PAMConfigurationData\x12$\n\x07\x65lement\x18\x03 \x01(\x0b\x32\x13.PAM.PAMElementData\"e\n\x14PAMConfigurationData\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\"E\n\x0ePAMElementData\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12\x11\n\tparentUid\x18\x02 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\"m\n\x19PAMElementOperationResult\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12+\n\x06result\x18\x02 \x01(\x0e\x32\x1b.PAM.PAMOperationResultType\x12\x0f\n\x07message\x18\x03 \x01(\t\"B\n\x0fPAMModifyResult\x12/\n\x07results\x18\x01 \x03(\x0b\x32\x1e.PAM.PAMElementOperationResult\"x\n\nPAMElement\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x03 \x01(\x03\x12\x14\n\x0clastModified\x18\x04 \x01(\x03\x12!\n\x08\x63hildren\x18\x05 \x03(\x0b\x32\x0f.PAM.PAMElement\"#\n\x14PAMGenericUidRequest\x12\x0b\n\x03uid\x18\x01 \x01(\x0c\"%\n\x15PAMGenericUidsRequest\x12\x0c\n\x04uids\x18\x01 \x03(\x0c\"\xab\x01\n\x10PAMConfiguration\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x05 \x01(\x03\x12\x14\n\x0clastModified\x18\x06 \x01(\x03\x12!\n\x08\x63hildren\x18\x07 \x03(\x0b\x32\x0f.PAM.PAMElement\"B\n\x11PAMConfigurations\x12-\n\x0e\x63onfigurations\x18\x01 \x03(\x0b\x32\x15.PAM.PAMConfiguration\"\xff\x01\n\rPAMController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x16\n\x0e\x63ontrollerName\x18\x02 \x01(\t\x12\x13\n\x0b\x64\x65viceToken\x18\x03 \x01(\t\x12\x12\n\ndeviceName\x18\x04 \x01(\t\x12\x0e\n\x06nodeId\x18\x05 \x01(\x03\x12\x0f\n\x07\x63reated\x18\x06 \x01(\x03\x12\x14\n\x0clastModified\x18\x07 \x01(\x03\x12\x16\n\x0e\x61pplicationUid\x18\x08 \x01(\x0c\x12\x30\n\rappClientType\x18\t \x01(\x0e\x32\x19.Enterprise.AppClientType\x12\x15\n\risInitialized\x18\n \x01(\x08\"P\n\x1dPAMSetMaxInstanceCountRequest\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x18\n\x10maxInstanceCount\x18\x02 \x01(\x05\"%\n\x12\x43ontrollerResponse\x12\x0f\n\x07payload\x18\x01 \x01(\t\"M\n\x1aPAMConfigurationController\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x15\n\rcontrollerUid\x18\x02 \x01(\x0c\"\xa3\x01\n\x17\x43onfigurationAddRequest\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x11\n\trecordKey\x18\x02 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12(\n\x0brecordLinks\x18\x04 \x03(\x0b\x32\x13.Records.RecordLink\x12#\n\x05\x61udit\x18\x05 \x01(\x0b\x32\x14.Records.RecordAudit\"J\n\x10RelayAccessCreds\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x10\n\x08password\x18\x02 \x01(\t\x12\x12\n\nserverTime\x18\x03 \x01(\x03\"\x81\x02\n\x14PAMRecordingsRequest\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x10\n\x08maxCount\x18\x02 \x01(\x05\x12\x17\n\nrangeStart\x18\x03 \x01(\x03H\x00\x88\x01\x01\x12\x15\n\x08rangeEnd\x18\x04 \x01(\x03H\x01\x88\x01\x01\x12$\n\x05types\x18\x05 \x03(\x0e\x32\x15.PAM.PAMRecordingType\x12)\n\x05risks\x18\x06 \x03(\x0e\x32\x1a.PAM.PAMRecordingRiskLevel\x12\x11\n\tprotocols\x18\x07 \x03(\t\x12\x14\n\x0c\x63loseReasons\x18\x08 \x03(\x05\x42\r\n\x0b_rangeStartB\x0b\n\t_rangeEnd\"\xd4\x02\n\x0cPAMRecording\x12\x15\n\rconnectionUid\x18\x01 \x01(\x0c\x12,\n\rrecordingType\x18\x02 \x01(\x0e\x32\x15.PAM.PAMRecordingType\x12\x11\n\trecordUid\x18\x03 \x01(\x0c\x12\x10\n\x08userName\x18\x04 \x01(\t\x12\x11\n\tstartedOn\x18\x05 \x01(\x03\x12\x0e\n\x06length\x18\x06 \x01(\x05\x12\x10\n\x08\x66ileSize\x18\x07 \x01(\x03\x12\x11\n\tcreatedOn\x18\x08 \x01(\x03\x12\x10\n\x08protocol\x18\t \x01(\t\x12\x13\n\x0b\x63loseReason\x18\n \x01(\x05\x12\x19\n\x11recordingDuration\x18\x0b \x01(\x05\x12\x36\n\x12\x61iOverallRiskLevel\x18\x0c \x01(\x0e\x32\x1a.PAM.PAMRecordingRiskLevel\x12\x18\n\x10\x61iOverallSummary\x18\r \x01(\x0c\"O\n\x15PAMRecordingsResponse\x12%\n\nrecordings\x18\x01 \x03(\x0b\x32\x11.PAM.PAMRecording\x12\x0f\n\x07hasMore\x18\x02 \x01(\x08\"*\n\x07PAMData\x12\x0e\n\x06vertex\x18\x01 \x01(\x0c\x12\x0f\n\x07\x63ontent\x18\x02 \x01(\x0c\"\x17\n\x07UidList\x12\x0c\n\x04uids\x18\x01 \x03(\x0c\"\xb4\x03\n\x11PAMResourceConfig\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x17\n\nnetworkUid\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12\x15\n\x08\x61\x64minUid\x18\x03 \x01(\x0cH\x01\x88\x01\x01\x12\x11\n\x04meta\x18\x04 \x01(\x0cH\x02\x88\x01\x01\x12\x1f\n\x12\x63onnectionSettings\x18\x05 \x01(\x0cH\x03\x88\x01\x01\x12\'\n\x0c\x63onnectUsers\x18\x06 \x01(\x0b\x32\x0c.PAM.UidListH\x04\x88\x01\x01\x12\x16\n\tdomainUid\x18\x07 \x01(\x0cH\x05\x88\x01\x01\x12\x18\n\x0bjitSettings\x18\x08 \x01(\x0cH\x06\x88\x01\x01\x12\x1d\n\x10keeperAiSettings\x18\t \x01(\x0cH\x07\x88\x01\x01\x12\x1b\n\x0eupdateServices\x18\n \x01(\x08H\x08\x88\x01\x01\x42\r\n\x0b_networkUidB\x0b\n\t_adminUidB\x07\n\x05_metaB\x15\n\x13_connectionSettingsB\x0f\n\r_connectUsersB\x0c\n\n_domainUidB\x0e\n\x0c_jitSettingsB\x13\n\x11_keeperAiSettingsB\x11\n\x0f_updateServices\"%\n\x16PAMUniversalSyncFolder\x12\x0b\n\x03uid\x18\x01 \x01(\x0c\"\xfc\x01\n\x16PAMUniversalSyncConfig\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\x12\x14\n\x07\x65nabled\x18\x02 \x01(\x08H\x00\x88\x01\x01\x12\x1a\n\rdryRunEnabled\x18\x03 \x01(\x08H\x01\x88\x01\x01\x12,\n\x07\x66olders\x18\x04 \x03(\x0b\x32\x1b.PAM.PAMUniversalSyncFolder\x12\x19\n\x0csyncIdentity\x18\x05 \x01(\x0cH\x02\x88\x01\x01\x12\x16\n\tvaultName\x18\x06 \x01(\x0cH\x03\x88\x01\x01\x42\n\n\x08_enabledB\x10\n\x0e_dryRunEnabledB\x0f\n\r_syncIdentityB\x0c\n\n_vaultName\"7\n\x11NhiMetricsRequest\x12\x11\n\tstartTime\x18\x01 \x01(\x03\x12\x0f\n\x07\x65ndTime\x18\x02 \x01(\x03\"\x9c\x02\n\x0ePamUsageByUser\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12!\n\x19recordRotationScheduledOk\x18\x02 \x01(\x05\x12\x1c\n\x14pamConnectionStarted\x18\x03 \x01(\x05\x12\x18\n\x10pamTunnelStarted\x18\x04 \x01(\x05\x12\x1b\n\x13\x64iscoveryJobStarted\x18\x05 \x01(\x05\x12 \n\x18recordRotationOnDemandOk\x18\x06 \x01(\x05\x12\"\n\x1apamSessionRecordingStarted\x18\x07 \x01(\x05\x12\x15\n\rpamRbiStarted\x18\x08 \x01(\x05\x12%\n\x1dpamSessionRbiRecordingStarted\x18\t \x01(\x05\"p\n\x0eNhiUsageByUser\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12\x11\n\trotations\x18\x02 \x01(\x05\x12\x0f\n\x07tunnels\x18\x03 \x01(\x05\x12\x13\n\x0b\x63onnections\x18\x04 \x01(\x05\x12\x15\n\rdiscoveryJobs\x18\x05 \x01(\x05\"\x84\x02\n\x12NhiMetricsResponse\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x03\x12\x11\n\tstartTime\x18\x02 \x01(\x03\x12\x0f\n\x07\x65ndTime\x18\x03 \x01(\x03\x12\x18\n\x10uniqueKsmDevices\x18\x04 \x01(\x05\x12\x18\n\x10pamGatewayOnline\x18\x05 \x01(\x05\x12/\n\x0epamUsageByUser\x18\x06 \x03(\x0b\x32\x13.PAM.PamUsageByUserB\x02\x18\x01\x12\x10\n\x08nhiCount\x18\x07 \x01(\x05\x12\x13\n\x0bksmNhiCount\x18\x08 \x01(\x05\x12(\n\x0busageByUser\x18\t \x03(\x0b\x32\x13.PAM.NhiUsageByUser\"D\n\x16NhiBulkMetricsResponse\x12*\n\tresponses\x18\x01 \x03(\x0b\x32\x17.PAM.NhiMetricsResponse\"N\n\x0bNhiUidEntry\x12\x0b\n\x03uid\x18\x01 \x01(\t\x12\"\n\x08\x63\x61tegory\x18\x02 \x01(\x0e\x32\x10.PAM.NhiCategory\x12\x0e\n\x06ksmNhi\x18\x03 \x01(\x08\"7\n\x11GetNhiUidsRequest\x12\x11\n\tstartTime\x18\x01 \x01(\x03\x12\x0f\n\x07\x65ndTime\x18\x02 \x01(\x03\"4\n\x12GetNhiUidsResponse\x12\x1e\n\x04uids\x18\x01 \x03(\x0b\x32\x10.PAM.NhiUidEntry\"I\n\x1fPAMUniversalSyncPreCheckRequest\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\x12\x12\n\nfolderUids\x18\x02 \x03(\x0c\"C\n\x1ePAMUniversalSyncPreCheckResult\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x0e\n\x06isUsed\x18\x02 \x01(\x08\"X\n PAMUniversalSyncPreCheckResponse\x12\x34\n\x07results\x18\x01 \x03(\x0b\x32#.PAM.PAMUniversalSyncPreCheckResult*\x9e\x01\n\x14WebRtcConnectionType\x12\x0e\n\nCONNECTION\x10\x00\x12\n\n\x06TUNNEL\x10\x01\x12\x07\n\x03SSH\x10\x02\x12\x07\n\x03RDP\x10\x03\x12\x08\n\x04HTTP\x10\x04\x12\x07\n\x03VNC\x10\x05\x12\n\n\x06TELNET\x10\x06\x12\t\n\x05MYSQL\x10\x07\x12\x0e\n\nSQL_SERVER\x10\x08\x12\x0e\n\nPOSTGRESQL\x10\t\x12\x0e\n\nKUBERNETES\x10\n*@\n\x10PAMOperationType\x12\x07\n\x03\x41\x44\x44\x10\x00\x12\n\n\x06UPDATE\x10\x01\x12\x0b\n\x07REPLACE\x10\x02\x12\n\n\x06\x44\x45LETE\x10\x03*p\n\x16PAMOperationResultType\x12\x0f\n\x0bPOT_SUCCESS\x10\x00\x12\x15\n\x11POT_UNKNOWN_ERROR\x10\x01\x12\x16\n\x12POT_ALREADY_EXISTS\x10\x02\x12\x16\n\x12POT_DOES_NOT_EXIST\x10\x03*\xc9\x01\n\x15\x43ontrollerMessageType\x12\x0f\n\x0b\x43MT_GENERAL\x10\x00\x12\x0e\n\nCMT_ROTATE\x10\x01\x12\x11\n\rCMT_DISCOVERY\x10\x02\x12\x0f\n\x0b\x43MT_CONNECT\x10\x03\x12\x19\n\x15\x43MT_ANALYZE_RECORDING\x10\x04\x12!\n\x1d\x43MT_WORKFLOW_ACCESS_ELEVATION\x10\x05\x12\x0b\n\x07\x43MT_USS\x10\x06\x12\x0c\n\x08\x43MT_INFO\x10\x07\x12\x12\n\x0e\x43MT_AUTOMATION\x10\x08*V\n\x10PAMRecordingType\x12\x0f\n\x0bPRT_SESSION\x10\x00\x12\x12\n\x0ePRT_TYPESCRIPT\x10\x01\x12\x0c\n\x08PRT_TIME\x10\x02\x12\x0f\n\x0bPRT_SUMMARY\x10\x03*i\n\x15PAMRecordingRiskLevel\x12\x13\n\x0fPRR_UNSPECIFIED\x10\x00\x12\x0b\n\x07PRR_LOW\x10\x01\x12\x0e\n\nPRR_MEDIUM\x10\x02\x12\x0c\n\x08PRR_HIGH\x10\x03\x12\x10\n\x0cPRR_CRITICAL\x10\x04*`\n\x0bNhiCategory\x12\x18\n\x14NHI_CATEGORY_UNKNOWN\x10\x00\x12\x0c\n\x08PAM_USER\x10\x01\x12\x10\n\x0cPAM_RESOURCE\x10\x02\x12\x0b\n\x07GATEWAY\x10\x03\x12\n\n\x06\x44\x45VICE\x10\x04\x42\x1f\n\x18\x63om.keepersecurity.protoB\x03PAMb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\tpam.proto\x12\x03PAM\x1a\x10\x65nterprise.proto\x1a\x0crecord.proto\"\x83\x01\n\x13PAMRotationSchedule\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\x14\n\x0cscheduleData\x18\x04 \x01(\t\x12\x12\n\nnoSchedule\x18\x05 \x01(\x08\"K\n\x1cPAMRotationSchedulesResponse\x12+\n\tschedules\x18\x01 \x03(\x0b\x32\x18.PAM.PAMRotationSchedule\"\x94\x01\n\x13PAMOnlineController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x13\n\x0b\x63onnectedOn\x18\x02 \x01(\x03\x12\x11\n\tipAddress\x18\x03 \x01(\t\x12\x0f\n\x07version\x18\x04 \x01(\t\x12-\n\x0b\x63onnections\x18\x05 \x03(\x0b\x32\x18.PAM.PAMWebRtcConnection\"\xa7\x01\n\x13PAMWebRtcConnection\x12\x15\n\rconnectionUid\x18\x01 \x01(\x0c\x12\'\n\x04type\x18\x02 \x01(\x0e\x32\x19.PAM.WebRtcConnectionType\x12\x11\n\trecordUid\x18\x03 \x01(\x0c\x12\x10\n\x08userName\x18\x04 \x01(\t\x12\x11\n\tstartedOn\x18\x05 \x01(\x03\x12\x18\n\x10\x63onfigurationUid\x18\x06 \x01(\x0c\"Y\n\x14PAMOnlineControllers\x12\x12\n\ndeprecated\x18\x01 \x03(\x0c\x12-\n\x0b\x63ontrollers\x18\x02 \x03(\x0b\x32\x18.PAM.PAMOnlineController\"9\n\x10PAMRotateRequest\x12\x12\n\nrequestUid\x18\x01 \x01(\x0c\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\"A\n\x16PAMControllersResponse\x12\'\n\x0b\x63ontrollers\x18\x01 \x03(\x0b\x32\x12.PAM.PAMController\"=\n\x13PAMRemoveController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x0f\n\x07message\x18\x02 \x01(\t\"L\n\x1bPAMRemoveControllerResponse\x12-\n\x0b\x63ontrollers\x18\x01 \x03(\x0b\x32\x18.PAM.PAMRemoveController\"=\n\x10PAMModifyRequest\x12)\n\noperations\x18\x01 \x03(\x0b\x32\x15.PAM.PAMDataOperation\"\x98\x01\n\x10PAMDataOperation\x12,\n\roperationType\x18\x01 \x01(\x0e\x32\x15.PAM.PAMOperationType\x12\x30\n\rconfiguration\x18\x02 \x01(\x0b\x32\x19.PAM.PAMConfigurationData\x12$\n\x07\x65lement\x18\x03 \x01(\x0b\x32\x13.PAM.PAMElementData\"e\n\x14PAMConfigurationData\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\"E\n\x0ePAMElementData\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12\x11\n\tparentUid\x18\x02 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\"m\n\x19PAMElementOperationResult\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12+\n\x06result\x18\x02 \x01(\x0e\x32\x1b.PAM.PAMOperationResultType\x12\x0f\n\x07message\x18\x03 \x01(\t\"B\n\x0fPAMModifyResult\x12/\n\x07results\x18\x01 \x03(\x0b\x32\x1e.PAM.PAMElementOperationResult\"x\n\nPAMElement\x12\x12\n\nelementUid\x18\x01 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x03 \x01(\x03\x12\x14\n\x0clastModified\x18\x04 \x01(\x03\x12!\n\x08\x63hildren\x18\x05 \x03(\x0b\x32\x0f.PAM.PAMElement\"#\n\x14PAMGenericUidRequest\x12\x0b\n\x03uid\x18\x01 \x01(\x0c\"%\n\x15PAMGenericUidsRequest\x12\x0c\n\x04uids\x18\x01 \x03(\x0c\"\xab\x01\n\x10PAMConfiguration\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rcontrollerUid\x18\x03 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\x12\x0f\n\x07\x63reated\x18\x05 \x01(\x03\x12\x14\n\x0clastModified\x18\x06 \x01(\x03\x12!\n\x08\x63hildren\x18\x07 \x03(\x0b\x32\x0f.PAM.PAMElement\"B\n\x11PAMConfigurations\x12-\n\x0e\x63onfigurations\x18\x01 \x03(\x0b\x32\x15.PAM.PAMConfiguration\"\xff\x01\n\rPAMController\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x16\n\x0e\x63ontrollerName\x18\x02 \x01(\t\x12\x13\n\x0b\x64\x65viceToken\x18\x03 \x01(\t\x12\x12\n\ndeviceName\x18\x04 \x01(\t\x12\x0e\n\x06nodeId\x18\x05 \x01(\x03\x12\x0f\n\x07\x63reated\x18\x06 \x01(\x03\x12\x14\n\x0clastModified\x18\x07 \x01(\x03\x12\x16\n\x0e\x61pplicationUid\x18\x08 \x01(\x0c\x12\x30\n\rappClientType\x18\t \x01(\x0e\x32\x19.Enterprise.AppClientType\x12\x15\n\risInitialized\x18\n \x01(\x08\"P\n\x1dPAMSetMaxInstanceCountRequest\x12\x15\n\rcontrollerUid\x18\x01 \x01(\x0c\x12\x18\n\x10maxInstanceCount\x18\x02 \x01(\x05\"%\n\x12\x43ontrollerResponse\x12\x0f\n\x07payload\x18\x01 \x01(\t\"M\n\x1aPAMConfigurationController\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x15\n\rcontrollerUid\x18\x02 \x01(\x0c\"\xa3\x01\n\x17\x43onfigurationAddRequest\x12\x18\n\x10\x63onfigurationUid\x18\x01 \x01(\x0c\x12\x11\n\trecordKey\x18\x02 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12(\n\x0brecordLinks\x18\x04 \x03(\x0b\x32\x13.Records.RecordLink\x12#\n\x05\x61udit\x18\x05 \x01(\x0b\x32\x14.Records.RecordAudit\"J\n\x10RelayAccessCreds\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x10\n\x08password\x18\x02 \x01(\t\x12\x12\n\nserverTime\x18\x03 \x01(\x03\"\x81\x02\n\x14PAMRecordingsRequest\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x10\n\x08maxCount\x18\x02 \x01(\x05\x12\x17\n\nrangeStart\x18\x03 \x01(\x03H\x00\x88\x01\x01\x12\x15\n\x08rangeEnd\x18\x04 \x01(\x03H\x01\x88\x01\x01\x12$\n\x05types\x18\x05 \x03(\x0e\x32\x15.PAM.PAMRecordingType\x12)\n\x05risks\x18\x06 \x03(\x0e\x32\x1a.PAM.PAMRecordingRiskLevel\x12\x11\n\tprotocols\x18\x07 \x03(\t\x12\x14\n\x0c\x63loseReasons\x18\x08 \x03(\x05\x42\r\n\x0b_rangeStartB\x0b\n\t_rangeEnd\"\x89\x02\n\x1cPAMRecordingsForUsersRequest\x12\x11\n\tusernames\x18\x01 \x03(\t\x12\x10\n\x08maxCount\x18\x02 \x01(\x05\x12\x17\n\nrangeStart\x18\x03 \x01(\x03H\x00\x88\x01\x01\x12\x15\n\x08rangeEnd\x18\x04 \x01(\x03H\x01\x88\x01\x01\x12$\n\x05types\x18\x05 \x03(\x0e\x32\x15.PAM.PAMRecordingType\x12)\n\x05risks\x18\x06 \x03(\x0e\x32\x1a.PAM.PAMRecordingRiskLevel\x12\x11\n\tprotocols\x18\x07 \x03(\t\x12\x14\n\x0c\x63loseReasons\x18\x08 \x03(\x05\x42\r\n\x0b_rangeStartB\x0b\n\t_rangeEnd\"\xd4\x02\n\x0cPAMRecording\x12\x15\n\rconnectionUid\x18\x01 \x01(\x0c\x12,\n\rrecordingType\x18\x02 \x01(\x0e\x32\x15.PAM.PAMRecordingType\x12\x11\n\trecordUid\x18\x03 \x01(\x0c\x12\x10\n\x08userName\x18\x04 \x01(\t\x12\x11\n\tstartedOn\x18\x05 \x01(\x03\x12\x0e\n\x06length\x18\x06 \x01(\x05\x12\x10\n\x08\x66ileSize\x18\x07 \x01(\x03\x12\x11\n\tcreatedOn\x18\x08 \x01(\x03\x12\x10\n\x08protocol\x18\t \x01(\t\x12\x13\n\x0b\x63loseReason\x18\n \x01(\x05\x12\x19\n\x11recordingDuration\x18\x0b \x01(\x05\x12\x36\n\x12\x61iOverallRiskLevel\x18\x0c \x01(\x0e\x32\x1a.PAM.PAMRecordingRiskLevel\x12\x18\n\x10\x61iOverallSummary\x18\r \x01(\x0c\"O\n\x15PAMRecordingsResponse\x12%\n\nrecordings\x18\x01 \x03(\x0b\x32\x11.PAM.PAMRecording\x12\x0f\n\x07hasMore\x18\x02 \x01(\x08\"*\n\x07PAMData\x12\x0e\n\x06vertex\x18\x01 \x01(\x0c\x12\x0f\n\x07\x63ontent\x18\x02 \x01(\x0c\"\x17\n\x07UidList\x12\x0c\n\x04uids\x18\x01 \x03(\x0c\"\x84\x03\n\x11PAMResourceConfig\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x17\n\nnetworkUid\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12\x15\n\x08\x61\x64minUid\x18\x03 \x01(\x0cH\x01\x88\x01\x01\x12\x11\n\x04meta\x18\x04 \x01(\x0cH\x02\x88\x01\x01\x12\x1f\n\x12\x63onnectionSettings\x18\x05 \x01(\x0cH\x03\x88\x01\x01\x12\'\n\x0c\x63onnectUsers\x18\x06 \x01(\x0b\x32\x0c.PAM.UidListH\x04\x88\x01\x01\x12\x16\n\tdomainUid\x18\x07 \x01(\x0cH\x05\x88\x01\x01\x12\x18\n\x0bjitSettings\x18\x08 \x01(\x0cH\x06\x88\x01\x01\x12\x1d\n\x10keeperAiSettings\x18\t \x01(\x0cH\x07\x88\x01\x01\x42\r\n\x0b_networkUidB\x0b\n\t_adminUidB\x07\n\x05_metaB\x15\n\x13_connectionSettingsB\x0f\n\r_connectUsersB\x0c\n\n_domainUidB\x0e\n\x0c_jitSettingsB\x13\n\x11_keeperAiSettings\"%\n\x16PAMUniversalSyncFolder\x12\x0b\n\x03uid\x18\x01 \x01(\x0c\"\xfc\x01\n\x16PAMUniversalSyncConfig\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\x12\x14\n\x07\x65nabled\x18\x02 \x01(\x08H\x00\x88\x01\x01\x12\x1a\n\rdryRunEnabled\x18\x03 \x01(\x08H\x01\x88\x01\x01\x12,\n\x07\x66olders\x18\x04 \x03(\x0b\x32\x1b.PAM.PAMUniversalSyncFolder\x12\x19\n\x0csyncIdentity\x18\x05 \x01(\x0cH\x02\x88\x01\x01\x12\x16\n\tvaultName\x18\x06 \x01(\x0cH\x03\x88\x01\x01\x42\n\n\x08_enabledB\x10\n\x0e_dryRunEnabledB\x0f\n\r_syncIdentityB\x0c\n\n_vaultName\"7\n\x11NhiMetricsRequest\x12\x11\n\tstartTime\x18\x01 \x01(\x03\x12\x0f\n\x07\x65ndTime\x18\x02 \x01(\x03\"\x9c\x02\n\x0ePamUsageByUser\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12!\n\x19recordRotationScheduledOk\x18\x02 \x01(\x05\x12\x1c\n\x14pamConnectionStarted\x18\x03 \x01(\x05\x12\x18\n\x10pamTunnelStarted\x18\x04 \x01(\x05\x12\x1b\n\x13\x64iscoveryJobStarted\x18\x05 \x01(\x05\x12 \n\x18recordRotationOnDemandOk\x18\x06 \x01(\x05\x12\"\n\x1apamSessionRecordingStarted\x18\x07 \x01(\x05\x12\x15\n\rpamRbiStarted\x18\x08 \x01(\x05\x12%\n\x1dpamSessionRbiRecordingStarted\x18\t \x01(\x05\"p\n\x0eNhiUsageByUser\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12\x11\n\trotations\x18\x02 \x01(\x05\x12\x0f\n\x07tunnels\x18\x03 \x01(\x05\x12\x13\n\x0b\x63onnections\x18\x04 \x01(\x05\x12\x15\n\rdiscoveryJobs\x18\x05 \x01(\x05\"\x84\x02\n\x12NhiMetricsResponse\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x03\x12\x11\n\tstartTime\x18\x02 \x01(\x03\x12\x0f\n\x07\x65ndTime\x18\x03 \x01(\x03\x12\x18\n\x10uniqueKsmDevices\x18\x04 \x01(\x05\x12\x18\n\x10pamGatewayOnline\x18\x05 \x01(\x05\x12/\n\x0epamUsageByUser\x18\x06 \x03(\x0b\x32\x13.PAM.PamUsageByUserB\x02\x18\x01\x12\x10\n\x08nhiCount\x18\x07 \x01(\x05\x12\x13\n\x0bksmNhiCount\x18\x08 \x01(\x05\x12(\n\x0busageByUser\x18\t \x03(\x0b\x32\x13.PAM.NhiUsageByUser\"D\n\x16NhiBulkMetricsResponse\x12*\n\tresponses\x18\x01 \x03(\x0b\x32\x17.PAM.NhiMetricsResponse\"N\n\x0bNhiUidEntry\x12\x0b\n\x03uid\x18\x01 \x01(\t\x12\"\n\x08\x63\x61tegory\x18\x02 \x01(\x0e\x32\x10.PAM.NhiCategory\x12\x0e\n\x06ksmNhi\x18\x03 \x01(\x08\"7\n\x11GetNhiUidsRequest\x12\x11\n\tstartTime\x18\x01 \x01(\x03\x12\x0f\n\x07\x65ndTime\x18\x02 \x01(\x03\"4\n\x12GetNhiUidsResponse\x12\x1e\n\x04uids\x18\x01 \x03(\x0b\x32\x10.PAM.NhiUidEntry*\x9e\x01\n\x14WebRtcConnectionType\x12\x0e\n\nCONNECTION\x10\x00\x12\n\n\x06TUNNEL\x10\x01\x12\x07\n\x03SSH\x10\x02\x12\x07\n\x03RDP\x10\x03\x12\x08\n\x04HTTP\x10\x04\x12\x07\n\x03VNC\x10\x05\x12\n\n\x06TELNET\x10\x06\x12\t\n\x05MYSQL\x10\x07\x12\x0e\n\nSQL_SERVER\x10\x08\x12\x0e\n\nPOSTGRESQL\x10\t\x12\x0e\n\nKUBERNETES\x10\n*@\n\x10PAMOperationType\x12\x07\n\x03\x41\x44\x44\x10\x00\x12\n\n\x06UPDATE\x10\x01\x12\x0b\n\x07REPLACE\x10\x02\x12\n\n\x06\x44\x45LETE\x10\x03*p\n\x16PAMOperationResultType\x12\x0f\n\x0bPOT_SUCCESS\x10\x00\x12\x15\n\x11POT_UNKNOWN_ERROR\x10\x01\x12\x16\n\x12POT_ALREADY_EXISTS\x10\x02\x12\x16\n\x12POT_DOES_NOT_EXIST\x10\x03*\xc9\x01\n\x15\x43ontrollerMessageType\x12\x0f\n\x0b\x43MT_GENERAL\x10\x00\x12\x0e\n\nCMT_ROTATE\x10\x01\x12\x11\n\rCMT_DISCOVERY\x10\x02\x12\x0f\n\x0b\x43MT_CONNECT\x10\x03\x12\x19\n\x15\x43MT_ANALYZE_RECORDING\x10\x04\x12!\n\x1d\x43MT_WORKFLOW_ACCESS_ELEVATION\x10\x05\x12\x0b\n\x07\x43MT_USS\x10\x06\x12\x0c\n\x08\x43MT_INFO\x10\x07\x12\x12\n\x0e\x43MT_AUTOMATION\x10\x08*V\n\x10PAMRecordingType\x12\x0f\n\x0bPRT_SESSION\x10\x00\x12\x12\n\x0ePRT_TYPESCRIPT\x10\x01\x12\x0c\n\x08PRT_TIME\x10\x02\x12\x0f\n\x0bPRT_SUMMARY\x10\x03*i\n\x15PAMRecordingRiskLevel\x12\x13\n\x0fPRR_UNSPECIFIED\x10\x00\x12\x0b\n\x07PRR_LOW\x10\x01\x12\x0e\n\nPRR_MEDIUM\x10\x02\x12\x0c\n\x08PRR_HIGH\x10\x03\x12\x10\n\x0cPRR_CRITICAL\x10\x04*`\n\x0bNhiCategory\x12\x18\n\x14NHI_CATEGORY_UNKNOWN\x10\x00\x12\x0c\n\x08PAM_USER\x10\x01\x12\x10\n\x0cPAM_RESOURCE\x10\x02\x12\x0b\n\x07GATEWAY\x10\x03\x12\n\n\x06\x44\x45VICE\x10\x04\x42\x1f\n\x18\x63om.keepersecurity.protoB\x03PAMb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -36,20 +36,20 @@ _globals['DESCRIPTOR']._serialized_options = b'\n\030com.keepersecurity.protoB\003PAM' _globals['_NHIMETRICSRESPONSE'].fields_by_name['pamUsageByUser']._loaded_options = None _globals['_NHIMETRICSRESPONSE'].fields_by_name['pamUsageByUser']._serialized_options = b'\030\001' - _globals['_WEBRTCCONNECTIONTYPE']._serialized_start=5354 - _globals['_WEBRTCCONNECTIONTYPE']._serialized_end=5512 - _globals['_PAMOPERATIONTYPE']._serialized_start=5514 - _globals['_PAMOPERATIONTYPE']._serialized_end=5578 - _globals['_PAMOPERATIONRESULTTYPE']._serialized_start=5580 - _globals['_PAMOPERATIONRESULTTYPE']._serialized_end=5692 - _globals['_CONTROLLERMESSAGETYPE']._serialized_start=5695 - _globals['_CONTROLLERMESSAGETYPE']._serialized_end=5896 - _globals['_PAMRECORDINGTYPE']._serialized_start=5898 - _globals['_PAMRECORDINGTYPE']._serialized_end=5984 - _globals['_PAMRECORDINGRISKLEVEL']._serialized_start=5986 - _globals['_PAMRECORDINGRISKLEVEL']._serialized_end=6091 - _globals['_NHICATEGORY']._serialized_start=6093 - _globals['_NHICATEGORY']._serialized_end=6189 + _globals['_WEBRTCCONNECTIONTYPE']._serialized_start=5340 + _globals['_WEBRTCCONNECTIONTYPE']._serialized_end=5498 + _globals['_PAMOPERATIONTYPE']._serialized_start=5500 + _globals['_PAMOPERATIONTYPE']._serialized_end=5564 + _globals['_PAMOPERATIONRESULTTYPE']._serialized_start=5566 + _globals['_PAMOPERATIONRESULTTYPE']._serialized_end=5678 + _globals['_CONTROLLERMESSAGETYPE']._serialized_start=5681 + _globals['_CONTROLLERMESSAGETYPE']._serialized_end=5882 + _globals['_PAMRECORDINGTYPE']._serialized_start=5884 + _globals['_PAMRECORDINGTYPE']._serialized_end=5970 + _globals['_PAMRECORDINGRISKLEVEL']._serialized_start=5972 + _globals['_PAMRECORDINGRISKLEVEL']._serialized_end=6077 + _globals['_NHICATEGORY']._serialized_start=6079 + _globals['_NHICATEGORY']._serialized_end=6175 _globals['_PAMROTATIONSCHEDULE']._serialized_start=51 _globals['_PAMROTATIONSCHEDULE']._serialized_end=182 _globals['_PAMROTATIONSCHEDULESRESPONSE']._serialized_start=184 @@ -104,40 +104,36 @@ _globals['_RELAYACCESSCREDS']._serialized_end=2649 _globals['_PAMRECORDINGSREQUEST']._serialized_start=2652 _globals['_PAMRECORDINGSREQUEST']._serialized_end=2909 - _globals['_PAMRECORDING']._serialized_start=2912 - _globals['_PAMRECORDING']._serialized_end=3252 - _globals['_PAMRECORDINGSRESPONSE']._serialized_start=3254 - _globals['_PAMRECORDINGSRESPONSE']._serialized_end=3333 - _globals['_PAMDATA']._serialized_start=3335 - _globals['_PAMDATA']._serialized_end=3377 - _globals['_UIDLIST']._serialized_start=3379 - _globals['_UIDLIST']._serialized_end=3402 - _globals['_PAMRESOURCECONFIG']._serialized_start=3405 - _globals['_PAMRESOURCECONFIG']._serialized_end=3841 - _globals['_PAMUNIVERSALSYNCFOLDER']._serialized_start=3843 - _globals['_PAMUNIVERSALSYNCFOLDER']._serialized_end=3880 - _globals['_PAMUNIVERSALSYNCCONFIG']._serialized_start=3883 - _globals['_PAMUNIVERSALSYNCCONFIG']._serialized_end=4135 - _globals['_NHIMETRICSREQUEST']._serialized_start=4137 - _globals['_NHIMETRICSREQUEST']._serialized_end=4192 - _globals['_PAMUSAGEBYUSER']._serialized_start=4195 - _globals['_PAMUSAGEBYUSER']._serialized_end=4479 - _globals['_NHIUSAGEBYUSER']._serialized_start=4481 - _globals['_NHIUSAGEBYUSER']._serialized_end=4593 - _globals['_NHIMETRICSRESPONSE']._serialized_start=4596 - _globals['_NHIMETRICSRESPONSE']._serialized_end=4856 - _globals['_NHIBULKMETRICSRESPONSE']._serialized_start=4858 - _globals['_NHIBULKMETRICSRESPONSE']._serialized_end=4926 - _globals['_NHIUIDENTRY']._serialized_start=4928 - _globals['_NHIUIDENTRY']._serialized_end=5006 - _globals['_GETNHIUIDSREQUEST']._serialized_start=5008 - _globals['_GETNHIUIDSREQUEST']._serialized_end=5063 - _globals['_GETNHIUIDSRESPONSE']._serialized_start=5065 - _globals['_GETNHIUIDSRESPONSE']._serialized_end=5117 - _globals['_PAMUNIVERSALSYNCPRECHECKREQUEST']._serialized_start=5119 - _globals['_PAMUNIVERSALSYNCPRECHECKREQUEST']._serialized_end=5192 - _globals['_PAMUNIVERSALSYNCPRECHECKRESULT']._serialized_start=5194 - _globals['_PAMUNIVERSALSYNCPRECHECKRESULT']._serialized_end=5261 - _globals['_PAMUNIVERSALSYNCPRECHECKRESPONSE']._serialized_start=5263 - _globals['_PAMUNIVERSALSYNCPRECHECKRESPONSE']._serialized_end=5351 + _globals['_PAMRECORDINGSFORUSERSREQUEST']._serialized_start=2912 + _globals['_PAMRECORDINGSFORUSERSREQUEST']._serialized_end=3177 + _globals['_PAMRECORDING']._serialized_start=3180 + _globals['_PAMRECORDING']._serialized_end=3520 + _globals['_PAMRECORDINGSRESPONSE']._serialized_start=3522 + _globals['_PAMRECORDINGSRESPONSE']._serialized_end=3601 + _globals['_PAMDATA']._serialized_start=3603 + _globals['_PAMDATA']._serialized_end=3645 + _globals['_UIDLIST']._serialized_start=3647 + _globals['_UIDLIST']._serialized_end=3670 + _globals['_PAMRESOURCECONFIG']._serialized_start=3673 + _globals['_PAMRESOURCECONFIG']._serialized_end=4061 + _globals['_PAMUNIVERSALSYNCFOLDER']._serialized_start=4063 + _globals['_PAMUNIVERSALSYNCFOLDER']._serialized_end=4100 + _globals['_PAMUNIVERSALSYNCCONFIG']._serialized_start=4103 + _globals['_PAMUNIVERSALSYNCCONFIG']._serialized_end=4355 + _globals['_NHIMETRICSREQUEST']._serialized_start=4357 + _globals['_NHIMETRICSREQUEST']._serialized_end=4412 + _globals['_PAMUSAGEBYUSER']._serialized_start=4415 + _globals['_PAMUSAGEBYUSER']._serialized_end=4699 + _globals['_NHIUSAGEBYUSER']._serialized_start=4701 + _globals['_NHIUSAGEBYUSER']._serialized_end=4813 + _globals['_NHIMETRICSRESPONSE']._serialized_start=4816 + _globals['_NHIMETRICSRESPONSE']._serialized_end=5076 + _globals['_NHIBULKMETRICSRESPONSE']._serialized_start=5078 + _globals['_NHIBULKMETRICSRESPONSE']._serialized_end=5146 + _globals['_NHIUIDENTRY']._serialized_start=5148 + _globals['_NHIUIDENTRY']._serialized_end=5226 + _globals['_GETNHIUIDSREQUEST']._serialized_start=5228 + _globals['_GETNHIUIDSREQUEST']._serialized_end=5283 + _globals['_GETNHIUIDSRESPONSE']._serialized_start=5285 + _globals['_GETNHIUIDSRESPONSE']._serialized_end=5337 # @@protoc_insertion_point(module_scope) diff --git a/tests/test_pam_recording_commands.py b/tests/test_pam_recording_commands.py new file mode 100644 index 000000000..c129ac010 --- /dev/null +++ b/tests/test_pam_recording_commands.py @@ -0,0 +1,717 @@ +"""Tests for PAM recording commands (PR-212 / get_recordings_for_users).""" + +import json +import os +import tempfile +import time +import unittest +from unittest.mock import MagicMock, patch, call + +from keepercommander.commands.pam.recording_commands import ( + PAMGetRecordingsForUsersCommand, + PAMDownloadRecordingsCommand, + RECORDING_TYPE_NAMES, + RISK_LEVEL_NAMES, + _parse_dt, + _fmt_ts, + _get_record_key, + _download_one, + _DOWNLOAD_WORKERS, +) +from keepercommander.proto import pam_pb2 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_recording(**kwargs): + """Build a PAMRecording proto with sensible defaults.""" + rec = pam_pb2.PAMRecording() + rec.connectionUid = bytes.fromhex(kwargs.get('connectionUid', 'deadbeef' * 4)) + rec.recordUid = bytes.fromhex(kwargs.get('recordUid', 'cafebabe' * 4)) + rec.userName = kwargs.get('userName', 'alice@example.com') + rec.recordingType = kwargs.get('recordingType', pam_pb2.PRT_SESSION) + rec.protocol = kwargs.get('protocol', 'SSH') + rec.startedOn = kwargs.get('startedOn', int(time.time()) * 1000) + rec.length = kwargs.get('length', 120) + rec.fileSize = kwargs.get('fileSize', 4096) + rec.createdOn = kwargs.get('createdOn', int(time.time()) * 1000) + rec.aiOverallRiskLevel = kwargs.get('aiOverallRiskLevel', pam_pb2.PRR_UNSPECIFIED) + return rec + + +def _make_response(recordings, has_more=False): + rs = pam_pb2.PAMRecordingsResponse() + rs.recordings.extend(recordings) + rs.hasMore = has_more + return rs + + +def _make_params(record_key=None, record_uid_hex='cafebabe' * 4): + """Return a mock params object with a record_cache entry.""" + params = MagicMock() + import base64 + uid_bytes = bytes.fromhex(record_uid_hex) + uid_str = base64.urlsafe_b64encode(uid_bytes).rstrip(b'=').decode() + if record_key is not None: + params.record_cache = {uid_str: {'record_key_unencrypted': record_key}} + else: + params.record_cache = {} + return params + + +FAKE_KEY = b'\x00' * 32 +FAKE_PLAINTEXT = b'decrypted session data' + +def _make_fake_recording_bytes(record_key=FAKE_KEY): + """Build a minimal valid recording wire format so _decrypt_recording_file succeeds.""" + import base64, json, struct + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + from cryptography.hazmat.primitives.kdf.hkdf import HKDF + import os + + conv_uid_b64 = base64.b64encode(b'\xde\xad\xbe\xef' * 4).decode() # standard b64 (with +/=) + nonce = os.urandom(12) + salt = os.urandom(12) + salt_b64 = base64.b64encode(salt).decode() + + # derive resource key + info = f"{conv_uid_b64}_RECORDING-SESSION_AES-GCM-256".encode() + resource_key = HKDF(algorithm=hashes.SHA256(), length=32, salt=salt, info=info).derive(record_key) + + # recording secret + recording_secret = os.urandom(32) + private_meta = json.dumps({'recordingSecret': base64.b64encode(recording_secret).decode(), + 'resourceIp': '127.0.0.1', 'resourcePort': '22', + 'startTime': 0, 'userName': 'test'}).encode() + resource_data_nonce = os.urandom(12) + resource_data_enc = resource_data_nonce + AESGCM(resource_key).encrypt(resource_data_nonce, private_meta, None) + resource_data_b64 = base64.b64encode(resource_data_enc).decode() + + ad = {'conversationUid': conv_uid_b64, 'recordingType': 'ses', + 'resourceKeysSalt': salt_b64, 'resourceUid': 'AAAA', + 'resourceData': resource_data_b64, 'userData': ''} + ad_bytes = json.dumps(ad).encode() + ad_len_bytes = struct.pack('>I', len(ad_bytes)) + + # encrypt recording data + recording_ct = AESGCM(recording_secret).encrypt(nonce, FAKE_PLAINTEXT, ad_bytes) + + return ad_len_bytes + ad_bytes + b';' + nonce + recording_ct + + +# --------------------------------------------------------------------------- +# _parse_dt +# --------------------------------------------------------------------------- + +class TestParseDt(unittest.TestCase): + def test_date_only(self): + dt = _parse_dt('2025-03-15') + self.assertEqual((dt.year, dt.month, dt.day), (2025, 3, 15)) + + def test_datetime_T(self): + dt = _parse_dt('2025-03-15T08:30:00') + self.assertEqual(dt.hour, 8) + + def test_datetime_space(self): + dt = _parse_dt('2025-03-15 08:30:00') + self.assertEqual(dt.hour, 8) + + def test_invalid(self): + with self.assertRaises(ValueError): + _parse_dt('not-a-date') + + +# --------------------------------------------------------------------------- +# _fmt_ts +# --------------------------------------------------------------------------- + +class TestFmtTs(unittest.TestCase): + def test_zero(self): + self.assertEqual(_fmt_ts(0), '') + + def test_none(self): + self.assertEqual(_fmt_ts(None), '') + + def test_known_epoch(self): + # 1000 ms = 1 second past epoch → still 1970 + self.assertIn('1970', _fmt_ts(1000)) + + +# --------------------------------------------------------------------------- +# _get_record_key +# --------------------------------------------------------------------------- + +class TestGetRecordKey(unittest.TestCase): + def test_found(self): + params = _make_params(record_key=FAKE_KEY) + key = _get_record_key(params, bytes.fromhex('cafebabe' * 4)) + self.assertEqual(key, FAKE_KEY) + + def test_not_found(self): + params = _make_params(record_key=None) + key = _get_record_key(params, bytes.fromhex('cafebabe' * 4)) + self.assertIsNone(key) + + +# --------------------------------------------------------------------------- +# _download_one +# --------------------------------------------------------------------------- + +class TestDownloadOne(unittest.TestCase): + def test_success(self): + with tempfile.TemporaryDirectory() as tmpdir: + with patch('keepercommander.commands.pam.recording_commands.router_download_recording', + return_value=_make_fake_recording_bytes(FAKE_KEY)): + filename, nbytes, err = _download_one( + MagicMock(), bytes.fromhex('deadbeef' * 4), 'ses', tmpdir, FAKE_KEY) + self.assertIsNone(err) + self.assertEqual(nbytes, len(FAKE_PLAINTEXT)) + self.assertTrue(filename.endswith('.ses')) + + def test_router_error(self): + with tempfile.TemporaryDirectory() as tmpdir: + with patch('keepercommander.commands.pam.recording_commands.router_download_recording', + side_effect=Exception('404')): + filename, nbytes, err = _download_one( + MagicMock(), bytes.fromhex('deadbeef' * 4), 'ses', tmpdir, FAKE_KEY) + self.assertIsNotNone(err) + self.assertEqual(nbytes, 0) + + def test_decrypt_error(self): + with tempfile.TemporaryDirectory() as tmpdir: + with patch('keepercommander.commands.pam.recording_commands.router_download_recording', + return_value=b'invalid-garbage-bytes'): + _, nbytes, err = _download_one( + MagicMock(), bytes.fromhex('deadbeef' * 4), 'ses', tmpdir, FAKE_KEY) + self.assertIsNotNone(err) + + +# --------------------------------------------------------------------------- +# PAMGetRecordingsForUsersCommand (list-by-user) +# --------------------------------------------------------------------------- + +class TestPAMGetRecordingsForUsersCommand(unittest.TestCase): + + def _run(self, router_return, **kwargs): + cmd = PAMGetRecordingsForUsersCommand() + params = _make_params(record_key=FAKE_KEY) + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=router_return) as mock_router: + cmd.execute(params, **kwargs) + return mock_router + + def test_basic_request_shape(self): + rs = _make_response([_make_recording()]) + mock = self._run(rs, usernames=['alice@example.com'], output_format='json') + rq = mock.call_args[0][1] + self.assertIsInstance(rq, pam_pb2.PAMRecordingsForUsersRequest) + self.assertIn('alice@example.com', rq.usernames) + self.assertEqual(rq.maxCount, 0) + + def test_multiple_usernames(self): + rs = _make_response([]) + mock = self._run(rs, usernames=['alice@example.com', 'bob@example.com']) + rq = mock.call_args[0][1] + self.assertIn('alice@example.com', rq.usernames) + self.assertIn('bob@example.com', rq.usernames) + + def test_max_count(self): + rs = _make_response([]) + mock = self._run(rs, usernames=['u@x.com'], max_count=50) + self.assertEqual(mock.call_args[0][1].maxCount, 50) + + def test_range_filters(self): + rs = _make_response([]) + mock = self._run(rs, usernames=['u@x.com'], + range_start='2025-01-01', range_end='2025-12-31') + rq = mock.call_args[0][1] + self.assertTrue(rq.HasField('rangeStart')) + self.assertTrue(rq.HasField('rangeEnd')) + self.assertGreater(rq.rangeEnd, rq.rangeStart) + + def test_type_filter(self): + rs = _make_response([]) + mock = self._run(rs, usernames=['u@x.com'], types=['session', 'typescript']) + rq = mock.call_args[0][1] + self.assertIn(pam_pb2.PRT_SESSION, rq.types) + self.assertIn(pam_pb2.PRT_TYPESCRIPT, rq.types) + + def test_risk_filter(self): + rs = _make_response([]) + mock = self._run(rs, usernames=['u@x.com'], risks=['high', 'critical']) + rq = mock.call_args[0][1] + self.assertIn(pam_pb2.PRR_HIGH, rq.risks) + self.assertIn(pam_pb2.PRR_CRITICAL, rq.risks) + + def test_protocol_filter(self): + rs = _make_response([]) + mock = self._run(rs, usernames=['u@x.com'], protocols=['SSH', 'RDP']) + rq = mock.call_args[0][1] + self.assertIn('SSH', rq.protocols) + self.assertIn('RDP', rq.protocols) + + def test_empty_usernames_aborts(self): + cmd = PAMGetRecordingsForUsersCommand() + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users') as mock: + cmd.execute(MagicMock(), usernames=[]) + mock.assert_not_called() + + def test_none_response(self): + printed = [] + cmd = PAMGetRecordingsForUsersCommand() + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=None), \ + patch('builtins.print', side_effect=printed.append): + cmd.execute(MagicMock(), usernames=['u@x.com']) + self.assertTrue(any('No response' in str(p) for p in printed)) + + def test_router_error(self): + cmd = PAMGetRecordingsForUsersCommand() + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + side_effect=Exception('network error')): + cmd.execute(MagicMock(), usernames=['u@x.com']) # must not raise + + def test_json_output_shape(self): + rec = _make_recording(userName='alice@example.com', protocol='SSH', + aiOverallRiskLevel=pam_pb2.PRR_HIGH) + rs = _make_response([rec], has_more=True) + lines = [] + cmd = PAMGetRecordingsForUsersCommand() + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('builtins.print', side_effect=lambda x: lines.append(x)): + cmd.execute(MagicMock(), usernames=['alice@example.com'], output_format='json') + data = json.loads('\n'.join(lines)) + self.assertIn('recordings', data) + self.assertTrue(data['hasMore']) + row = data['recordings'][0] + self.assertEqual(row['userName'], 'alice@example.com') + self.assertEqual(row['aiOverallRiskLevel'], 'high') + + def test_json_output_to_file(self): + rec = _make_recording() + rs = _make_response([rec]) + cmd = PAMGetRecordingsForUsersCommand() + with tempfile.NamedTemporaryFile(suffix='.json', delete=False) as f: + outpath = f.name + try: + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs): + cmd.execute(MagicMock(), usernames=['u@x.com'], output=outpath) + with open(outpath) as f: + data = json.load(f) + self.assertIn('recordings', data) + finally: + os.unlink(outpath) + + def test_output_format_inferred_from_csv_extension(self): + rs = _make_response([_make_recording()]) + cmd = PAMGetRecordingsForUsersCommand() + with tempfile.NamedTemporaryFile(suffix='.csv', delete=False) as f: + outpath = f.name + try: + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('keepercommander.commands.pam.recording_commands.dump_report_data') as mock_dump: + cmd.execute(MagicMock(), usernames=['u@x.com'], output=outpath) + fmt_used = mock_dump.call_args[1].get('fmt') or mock_dump.call_args[0][2] + self.assertEqual(fmt_used, 'csv') + finally: + os.unlink(outpath) + + def test_download_dir_triggers_download(self): + rec = _make_recording() + rs = _make_response([rec]) + with tempfile.TemporaryDirectory() as tmpdir: + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('keepercommander.commands.pam.recording_commands.router_download_recording', + return_value=_make_fake_recording_bytes(FAKE_KEY)) as mock_dl: + params = _make_params(record_key=FAKE_KEY, record_uid_hex='cafebabe' * 4) + cmd = PAMGetRecordingsForUsersCommand() + cmd.execute(params, usernames=['u@x.com'], download_dir=tmpdir) + mock_dl.assert_called() + + def test_download_dir_skips_missing_record_key(self): + rec = _make_recording() + rs = _make_response([rec]) + with tempfile.TemporaryDirectory() as tmpdir: + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('keepercommander.commands.pam.recording_commands.router_download_recording') as mock_dl: + params = _make_params(record_key=None) # no key in cache + cmd = PAMGetRecordingsForUsersCommand() + cmd.execute(params, usernames=['u@x.com'], download_dir=tmpdir) + mock_dl.assert_not_called() + + +# --------------------------------------------------------------------------- +# PAMDownloadRecordingsCommand +# --------------------------------------------------------------------------- + +class TestPAMDownloadRecordingsCommand(unittest.TestCase): + + def test_connection_uid_requires_record_uid(self): + cmd = PAMDownloadRecordingsCommand() + with patch('keepercommander.commands.pam.recording_commands.router_download_recording') as mock_dl: + cmd.execute(MagicMock(), connection_uid=['deadbeef' * 4]) + mock_dl.assert_not_called() + + def test_invalid_connection_uid_hex(self): + cmd = PAMDownloadRecordingsCommand() + params = _make_params(record_key=FAKE_KEY) + with patch('keepercommander.commands.pam.recording_commands.router_download_recording') as mock_dl: + cmd.execute(params, + connection_uid=['not-hex'], + record_uid='cafebabe' * 4, + output_dir='/tmp') + mock_dl.assert_not_called() + + def test_invalid_record_uid_hex(self): + cmd = PAMDownloadRecordingsCommand() + with patch('keepercommander.commands.pam.recording_commands.router_download_recording') as mock_dl: + cmd.execute(MagicMock(), + connection_uid=['deadbeef' * 4], + record_uid='not-hex', + output_dir='/tmp') + mock_dl.assert_not_called() + + def test_record_key_not_in_cache(self): + cmd = PAMDownloadRecordingsCommand() + params = _make_params(record_key=None) # empty cache + with patch('keepercommander.commands.pam.recording_commands.router_download_recording') as mock_dl: + cmd.execute(params, + connection_uid=['deadbeef' * 4], + record_uid='cafebabe' * 4, + output_dir='/tmp') + mock_dl.assert_not_called() + + def test_single_connection_uid_downloads_and_decrypts(self): + cmd = PAMDownloadRecordingsCommand() + params = _make_params(record_key=FAKE_KEY, record_uid_hex='cafebabe' * 4) + fake_wire = _make_fake_recording_bytes(FAKE_KEY) + with tempfile.TemporaryDirectory() as tmpdir: + with patch('keepercommander.commands.pam.recording_commands.router_download_recording', + return_value=fake_wire): + cmd.execute(params, + connection_uid=['deadbeef' * 4], + record_uid='cafebabe' * 4, + output_dir=tmpdir, + types=['session']) + saved = [f for f in os.listdir(tmpdir) if f.endswith('.ses')] + self.assertEqual(len(saved), 1) + with open(os.path.join(tmpdir, saved[0]), 'rb') as f: + self.assertEqual(f.read(), FAKE_PLAINTEXT) + + def test_multiple_connection_uids(self): + cmd = PAMDownloadRecordingsCommand() + params = _make_params(record_key=FAKE_KEY, record_uid_hex='cafebabe' * 4) + uid1 = 'deadbeef' * 4 + uid2 = 'beefdead' * 4 + fake_wire = _make_fake_recording_bytes(FAKE_KEY) + with tempfile.TemporaryDirectory() as tmpdir: + with patch('keepercommander.commands.pam.recording_commands.router_download_recording', + return_value=fake_wire): + cmd.execute(params, + connection_uid=[uid1, uid2], + record_uid='cafebabe' * 4, + output_dir=tmpdir, + types=['session']) + saved = [f for f in os.listdir(tmpdir) if f.endswith('.ses')] + self.assertEqual(len(saved), 2) + + def test_username_mode_no_results(self): + cmd = PAMDownloadRecordingsCommand() + rs = _make_response([]) + printed = [] + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('builtins.print', side_effect=printed.append): + cmd.execute(MagicMock(), usernames=['u@x.com']) + self.assertTrue(any('No recordings' in str(p) for p in printed)) + + def test_username_mode_downloads_with_decryption(self): + rec = _make_recording() + rs = _make_response([rec]) + params = _make_params(record_key=FAKE_KEY, record_uid_hex='cafebabe' * 4) + with tempfile.TemporaryDirectory() as tmpdir: + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('keepercommander.commands.pam.recording_commands.router_download_recording', + return_value=_make_fake_recording_bytes(FAKE_KEY)): + cmd = PAMDownloadRecordingsCommand() + cmd.execute(params, usernames=['alice@example.com'], output_dir=tmpdir) + saved = os.listdir(tmpdir) + self.assertTrue(len(saved) > 0) + with open(os.path.join(tmpdir, saved[0]), 'rb') as f: + self.assertEqual(f.read(), FAKE_PLAINTEXT) + + def test_username_mode_skips_missing_key(self): + rec = _make_recording() + rs = _make_response([rec]) + params = _make_params(record_key=None) # no key + with tempfile.TemporaryDirectory() as tmpdir: + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('keepercommander.commands.pam.recording_commands.router_download_recording') as mock_dl: + cmd = PAMDownloadRecordingsCommand() + cmd.execute(params, usernames=['alice@example.com'], output_dir=tmpdir) + mock_dl.assert_not_called() + + def test_username_mode_type_filter(self): + rec = _make_recording(recordingType=pam_pb2.PRT_SESSION) + rs = _make_response([rec]) + params = _make_params(record_key=FAKE_KEY, record_uid_hex='cafebabe' * 4) + with tempfile.TemporaryDirectory() as tmpdir: + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('keepercommander.commands.pam.recording_commands.router_download_recording', + return_value=_make_fake_recording_bytes(FAKE_KEY)) as mock_dl: + cmd = PAMDownloadRecordingsCommand() + cmd.execute(params, usernames=['u@x.com'], + types=['session'], output_dir=tmpdir) + # should only request .ses, not all 4 types + calls = mock_dl.call_args_list + exts = [c[0][2] for c in calls] + self.assertEqual(exts, ['ses']) + + def test_router_error_in_username_mode(self): + cmd = PAMDownloadRecordingsCommand() + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + side_effect=Exception('network error')): + cmd.execute(MagicMock(), usernames=['u@x.com']) # must not raise + + def test_none_response_in_username_mode(self): + printed = [] + cmd = PAMDownloadRecordingsCommand() + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=None), \ + patch('builtins.print', side_effect=printed.append): + cmd.execute(MagicMock(), usernames=['u@x.com']) + self.assertTrue(any('No response' in str(p) for p in printed)) + + def test_has_more_warning_printed(self): + rec = _make_recording() + rs = _make_response([rec], has_more=True) + params = _make_params(record_key=FAKE_KEY, record_uid_hex='cafebabe' * 4) + printed = [] + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('keepercommander.commands.pam.recording_commands.router_download_recording', + return_value=_make_fake_recording_bytes(FAKE_KEY)), \ + patch('builtins.print', side_effect=printed.append): + cmd = PAMDownloadRecordingsCommand() + with tempfile.TemporaryDirectory() as tmpdir: + cmd.execute(params, usernames=['u@x.com'], output_dir=tmpdir) + self.assertTrue(any('more' in str(p).lower() for p in printed)) + + +# --------------------------------------------------------------------------- +# get_parser (covers argparse setup lines) +# --------------------------------------------------------------------------- + +class TestGetParser(unittest.TestCase): + def test_lbu_parser_returns_parser(self): + parser = PAMGetRecordingsForUsersCommand().get_parser() + self.assertIsNotNone(parser) + + def test_dl_parser_returns_parser(self): + parser = PAMDownloadRecordingsCommand().get_parser() + self.assertIsNotNone(parser) + + def test_lbu_parser_has_download_dir(self): + parser = PAMGetRecordingsForUsersCommand().get_parser() + args = parser.parse_args(['user@example.com', '--download-dir', '/tmp/recs']) + self.assertEqual(args.download_dir, '/tmp/recs') + + def test_dl_parser_has_output_dir(self): + parser = PAMDownloadRecordingsCommand().get_parser() + args = parser.parse_args(['--usernames', 'user@example.com', '--output-dir', '/tmp/recs']) + self.assertEqual(args.output_dir, '/tmp/recs') + + def test_lbu_parser_filter_args(self): + parser = PAMGetRecordingsForUsersCommand().get_parser() + args = parser.parse_args(['u@x.com', '--types', 'session', '--risks', 'high', + '--protocols', 'SSH', '--max-count', '10']) + self.assertEqual(args.types, ['session']) + self.assertEqual(args.risks, ['high']) + self.assertEqual(args.max_count, 10) + + +# --------------------------------------------------------------------------- +# Legacy salt path (line 85) +# --------------------------------------------------------------------------- + +class TestDecryptLegacySalt(unittest.TestCase): + def test_no_resource_keys_salt_uses_nonce(self): + """When resourceKeysSalt is absent, the nonce is used as the salt.""" + import base64 as b64, struct + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + from cryptography.hazmat.primitives.kdf.hkdf import HKDF + from keepercommander.commands.pam.recording_commands import _decrypt_recording_file + import os + + record_key = FAKE_KEY + conv_uid_b64 = b64.b64encode(b'\xaa\xbb\xcc\xdd' * 4).decode() + nonce = os.urandom(12) + + # Use nonce as salt (legacy path — no resourceKeysSalt key in AD) + info = f"{conv_uid_b64}_RECORDING-SESSION_AES-GCM-256".encode() + resource_key = HKDF(algorithm=hashes.SHA256(), length=32, salt=nonce, info=info).derive(record_key) + + recording_secret = os.urandom(32) + private_meta = json.dumps({'recordingSecret': b64.b64encode(recording_secret).decode()}).encode() + rd_nonce = os.urandom(12) + resource_data_enc = rd_nonce + AESGCM(resource_key).encrypt(rd_nonce, private_meta, None) + + # No 'resourceKeysSalt' key + ad = {'conversationUid': conv_uid_b64, 'resourceData': b64.b64encode(resource_data_enc).decode()} + ad_bytes = json.dumps(ad).encode() + ad_len_bytes = struct.pack('>I', len(ad_bytes)) + plaintext_msg = b'legacy plaintext' + ct = AESGCM(recording_secret).encrypt(nonce, plaintext_msg, ad_bytes) + + wire = ad_len_bytes + ad_bytes + b';' + nonce + ct + result = _decrypt_recording_file(wire, record_key) + self.assertEqual(result, plaintext_msg) + + +# --------------------------------------------------------------------------- +# _download_recording_files error path (line 184) +# --------------------------------------------------------------------------- + +class TestDownloadRecordingFilesError(unittest.TestCase): + def test_error_logged_when_download_fails(self): + from keepercommander.commands.pam.recording_commands import _download_recording_files + with tempfile.TemporaryDirectory() as tmpdir: + with patch('keepercommander.commands.pam.recording_commands.router_download_recording', + side_effect=Exception('server error')), \ + patch('keepercommander.commands.pam.recording_commands.logging') as mock_log: + _download_recording_files(MagicMock(), bytes(16), ['ses'], tmpdir, FAKE_KEY) + mock_log.warning.assert_called() + + +# --------------------------------------------------------------------------- +# _download_recordings edge cases (lines 198, 212-213, 224) +# --------------------------------------------------------------------------- + +class TestDownloadRecordingsEdgeCases(unittest.TestCase): + def test_skips_recording_with_no_connection_uid(self): + from keepercommander.commands.pam.recording_commands import _download_recordings + rec = _make_recording() + rec.ClearField('connectionUid') # empty bytes + printed = [] + with patch('keepercommander.commands.pam.recording_commands.router_download_recording') as mock_dl, \ + patch('builtins.print', side_effect=printed.append): + _download_recordings(_make_params(FAKE_KEY), [rec], [], '/tmp/noop_dir') + mock_dl.assert_not_called() + self.assertTrue(any('Nothing' in str(p) for p in printed)) + + def test_nothing_to_download_when_all_keys_missing(self): + from keepercommander.commands.pam.recording_commands import _download_recordings + rec = _make_recording() + params = _make_params(record_key=None) # no keys + printed = [] + with patch('builtins.print', side_effect=printed.append): + with tempfile.TemporaryDirectory() as tmpdir: + _download_recordings(params, [rec], [], tmpdir) + self.assertTrue(any('Nothing' in str(p) for p in printed)) + + def test_error_in_parallel_loop_logged(self): + from keepercommander.commands.pam.recording_commands import _download_recordings + rec = _make_recording() + params = _make_params(record_key=FAKE_KEY, record_uid_hex='cafebabe' * 4) + with patch('keepercommander.commands.pam.recording_commands.router_download_recording', + side_effect=Exception('download failed')), \ + patch('keepercommander.commands.pam.recording_commands.logging') as mock_log: + with tempfile.TemporaryDirectory() as tmpdir: + _download_recordings(params, [rec], [], tmpdir) + mock_log.warning.assert_called() + + +# --------------------------------------------------------------------------- +# Grid output format (.md / .txt extension → line 281) +# --------------------------------------------------------------------------- + +class TestGridOutputFormat(unittest.TestCase): + def test_md_extension_uses_grid_format(self): + rec = _make_recording() + rs = _make_response([rec]) + cmd = PAMGetRecordingsForUsersCommand() + with tempfile.NamedTemporaryFile(suffix='.md', delete=False) as f: + outpath = f.name + try: + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('keepercommander.commands.pam.recording_commands.dump_report_data') as mock_dump: + cmd.execute(MagicMock(), usernames=['u@x.com'], output=outpath) + fmt_used = mock_dump.call_args[1].get('fmt') or mock_dump.call_args[0][2] + self.assertEqual(fmt_used, 'grid') + finally: + os.unlink(outpath) + + def test_txt_extension_uses_grid_format(self): + rec = _make_recording() + rs = _make_response([rec]) + cmd = PAMGetRecordingsForUsersCommand() + with tempfile.NamedTemporaryFile(suffix='.txt', delete=False) as f: + outpath = f.name + try: + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('keepercommander.commands.pam.recording_commands.dump_report_data') as mock_dump: + cmd.execute(MagicMock(), usernames=['u@x.com'], output=outpath) + fmt_used = mock_dump.call_args[1].get('fmt') or mock_dump.call_args[0][2] + self.assertEqual(fmt_used, 'grid') + finally: + os.unlink(outpath) + + +# --------------------------------------------------------------------------- +# _print_table with output_file (lines 406-407) +# --------------------------------------------------------------------------- + +class TestPrintTableOutputFile(unittest.TestCase): + def test_table_written_to_txt_file(self): + rec = _make_recording() + rs = _make_response([rec]) + cmd = PAMGetRecordingsForUsersCommand() + with tempfile.NamedTemporaryFile(suffix='.txt', delete=False) as f: + outpath = f.name + try: + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs), \ + patch('keepercommander.commands.pam.recording_commands.dump_report_data'): + cmd.execute(MagicMock(), usernames=['u@x.com'], output=outpath) + finally: + os.unlink(outpath) + + +# --------------------------------------------------------------------------- +# _print_json to file (lines 435-438) +# --------------------------------------------------------------------------- + +class TestPrintJsonToFile(unittest.TestCase): + def test_json_written_to_file(self): + rec = _make_recording() + rs = _make_response([rec]) + cmd = PAMGetRecordingsForUsersCommand() + with tempfile.NamedTemporaryFile(suffix='.json', delete=False) as f: + outpath = f.name + try: + with patch('keepercommander.commands.pam.recording_commands.router_get_recordings_for_users', + return_value=rs): + cmd.execute(MagicMock(), usernames=['u@x.com'], output=outpath) + with open(outpath) as f: + data = json.load(f) + self.assertIn('recordings', data) + finally: + os.unlink(outpath) + + +if __name__ == '__main__': + unittest.main()