From b3d5e27e41a83914da3b6a10203035d78ac061e5 Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Mon, 20 Jul 2026 18:33:37 +0530 Subject: [PATCH 01/16] Return JSON 429 for Keeper and local rate limits in service mode (#2226) --- .../commands/pam/gateway_helper.py | 2 + keepercommander/service/README.md | 6 +- keepercommander/service/app.py | 10 +- keepercommander/service/util/command_util.py | 65 +++++--- .../service/util/parse_keeper_response.py | 13 +- keepercommander/service/util/throttle.py | 96 +++++++++++ unit-tests/service/test_throttle_response.py | 149 ++++++++++++++++++ 7 files changed, 315 insertions(+), 26 deletions(-) create mode 100644 keepercommander/service/util/throttle.py create mode 100644 unit-tests/service/test_throttle_response.py diff --git a/keepercommander/commands/pam/gateway_helper.py b/keepercommander/commands/pam/gateway_helper.py index aafed1c6e..d2d206ad9 100644 --- a/keepercommander/commands/pam/gateway_helper.py +++ b/keepercommander/commands/pam/gateway_helper.py @@ -93,6 +93,8 @@ def create_gateway(params, gateway_name, ksm_app, config_init, ott_expire_in_min one_time_token = config_str_and_config_dict.get('config_str') + # New controller may not appear in a warm get_all_gateways() cache. + invalidate_gateway_cache() return one_time_token diff --git a/keepercommander/service/README.md b/keepercommander/service/README.md index 670213241..9d89a2396 100644 --- a/keepercommander/service/README.md +++ b/keepercommander/service/README.md @@ -216,6 +216,7 @@ result_retention: 3600 # Result retention (1 hour) - **Default limits**: 60/minute, 600/hour, 6000/day - **Per-endpoint tracking**: Each API endpoint has independent rate limit counters - **Example**: Setting `"20/minute"` provides 20 requests per minute per endpoint per IP address +- Service rate limits are local (per client IP/endpoint). They are not the same as Keeper’s per-user API throttle; one service HTTP call may trigger many Keeper API calls. #### Error Responses @@ -224,7 +225,10 @@ result_retention: 3600 # Result retention (1 hour) - **401 Unauthorized**: Missing, invalid, or expired API key; no active session - **403 Forbidden**: IP not allowed, access denied, or command not in allowed list - **404 Not Found**: Request ID not found -- **429 Too Many Requests**: Rate limit exceeded +- **429 Too Many Requests**: JSON `{"status":"error","error":"","result_code":"..."}` + - `rate_limited` — service-local Flask limiter + - `throttled` — upstream Keeper API throttle (same as Commander) + - `429` — upstream edge/gateway “Too Many Requests” **Server Errors (5xx):** - **500 Internal Server Error**: Command execution failed or unexpected server error diff --git a/keepercommander/service/app.py b/keepercommander/service/app.py index 774760b6a..ea2b741a0 100644 --- a/keepercommander/service/app.py +++ b/keepercommander/service/app.py @@ -9,13 +9,15 @@ # Contact: ops@keepersecurity.com # -from flask import Flask +from flask import Flask, jsonify import logging from werkzeug.middleware.proxy_fix import ProxyFix +from flask_limiter.errors import RateLimitExceeded from .decorators.security import limiter, is_behind_proxy from .decorators.api_logging import SSLHandshakeFilter from .api.routes import init_routes from .decorators.logging import logger +from .util.throttle import rate_limited_response def create_app(): @@ -35,6 +37,12 @@ def create_app(): logger.debug("Configuring rate limiter") limiter.init_app(app) + @app.errorhandler(RateLimitExceeded) + def handle_rate_limit_exceeded(e): + detail = getattr(e, 'description', None) or str(e) + body, status = rate_limited_response(detail) + return jsonify(body), status + logger.debug("Initializing API routes") init_routes(app) diff --git a/keepercommander/service/util/command_util.py b/keepercommander/service/util/command_util.py index 35434b697..d49839bb7 100644 --- a/keepercommander/service/util/command_util.py +++ b/keepercommander/service/util/command_util.py @@ -17,10 +17,18 @@ from .config_reader import ConfigReader from .exceptions import CommandExecutionError from .parse_keeper_response import parse_keeper_response, ensure_record_add_json_format +from .throttle import ( + RESULT_EDGE_429, + RESULT_THROTTLED, + is_throttle_error, + throttle_error_response, +) from ..core.globals import get_current_params from ..decorators.logging import logger, debug_decorator, sanitize_debug_data from ... import cli, utils from ...crypto import encrypt_aes_v2 +from ...error import KeeperApiError + class CommandExecutor: @staticmethod @@ -114,6 +122,28 @@ def encrypt_response(response: Any) -> bytes: raise return response + @staticmethod + def _status_code_from_response(response: dict) -> int: + if 'status_code' in response: + return response.pop('status_code') + if response.get('status') in ('error', 'warning'): + return 400 + return 200 + + @classmethod + def _finalize_parsed_response(cls, response: Any) -> Tuple[Any, int]: + if not isinstance(response, dict): + return response, 200 + + status_code = cls._status_code_from_response(response) + result_code = response.get('result_code') + if status_code == 429 or result_code in (RESULT_THROTTLED, RESULT_EDGE_429): + body, status_code = throttle_error_response(response.get('error'), result_code) + if 'command' in response: + body['command'] = response['command'] + return body, status_code + return response, status_code + @classmethod def execute(cls, command: str) -> Tuple[Any, int]: logger.debug(f"Executing command: {command}") @@ -142,19 +172,7 @@ def execute(cls, command: str) -> Tuple[Any, int]: # Always let the parser handle the response (including empty responses and logs) response = parse_keeper_response(command, response, log_output) - - if isinstance(response, dict): - # Extract status_code and remove it from response body - if 'status_code' in response: - status_code = response.pop('status_code') - elif response.get("status") == "error": - status_code = 400 - elif response.get("status") == "warning": - status_code = 400 - else: - status_code = 200 - else: - status_code = 200 + response, status_code = cls._finalize_parsed_response(response) response = CommandExecutor.encrypt_response(response) logger.debug(f"Command executed successfully") @@ -162,16 +180,17 @@ def execute(cls, command: str) -> Tuple[Any, int]: except CommandExecutionError as e: # Return the actual command error instead of generic "server busy" logger.error(f"Command execution error: {e}") - error_response = { - "status": "error", - "error": str(e) - } - return error_response, 400 + if is_throttle_error(e): + return throttle_error_response(str(e)) + return {"status": "error", "error": str(e)}, 400 + except KeeperApiError as e: + if is_throttle_error(e): + return throttle_error_response(e.message or str(e), e.result_code) + logger.error(f"Unexpected error during command execution: {e}") + return {"status": "error", "error": f"Unexpected error: {str(e)}"}, 500 except Exception as e: + if is_throttle_error(e): + return throttle_error_response(str(e)) # Log unexpected errors and return a proper error response logger.error(f"Unexpected error during command execution: {e}") - error_response = { - "status": "error", - "error": f"Unexpected error: {str(e)}" - } - return error_response, 500 \ No newline at end of file + return {"status": "error", "error": f"Unexpected error: {str(e)}"}, 500 diff --git a/keepercommander/service/util/parse_keeper_response.py b/keepercommander/service/util/parse_keeper_response.py index bacd347c0..48cbe237f 100644 --- a/keepercommander/service/util/parse_keeper_response.py +++ b/keepercommander/service/util/parse_keeper_response.py @@ -12,6 +12,8 @@ from typing import Any, Dict, List, Optional, Tuple import re, json +from .throttle import RESULT_THROTTLED, clean_throttle_message, is_throttle_text + class KeeperResponseParser: @staticmethod def _clean_ansi_codes(text: str) -> str: @@ -1054,7 +1056,16 @@ def _parse_logging_based_command(command: str, response_str: str) -> Dict[str, A ] has_success_indicator = any(indicator in response_lower for indicator in success_indicators) - + + if is_throttle_text(response_str): + return { + "status": "error", + "status_code": 429, + "command": command.split()[0] if command.split() else command, + "error": clean_throttle_message(response_str), + "result_code": RESULT_THROTTLED, + } + forbidden_patterns = [ "not an msp administrator", "permission denied", diff --git a/keepercommander/service/util/throttle.py b/keepercommander/service/util/throttle.py new file mode 100644 index 000000000..ccb6b7ebb --- /dev/null +++ b/keepercommander/service/util/throttle.py @@ -0,0 +1,96 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bool: + if not text: + return False + lowered = text.lower() + return any(marker in lowered for marker in _THROTTLE_TEXT_MARKERS) + + +def is_throttle_error(exc_or_text: Union[BaseException, str]) -> bool: + if isinstance(exc_or_text, KeeperApiError): + code = exc_or_text.result_code + if code == RESULT_THROTTLED or code == 429 or str(code) == RESULT_EDGE_429: + return True + return is_throttle_text(exc_or_text.message or str(exc_or_text)) + return is_throttle_text(str(exc_or_text or '')) + + +def normalize_throttle_result_code(result_code: Any = None) -> str: + if result_code in (None, ''): + return RESULT_THROTTLED + if result_code == 429 or str(result_code) == RESULT_EDGE_429: + return RESULT_EDGE_429 + return str(result_code) + + +def clean_throttle_message(message: Optional[str]) -> str: + """Prefer Keeper's API message; drop duplicated client retry log noise.""" + if not message: + return 'Request throttled' + + text = message.replace('\\n', '\n') + for line in text.splitlines(): + line = line.strip() + if not line: + continue + lowered = line.lower() + # Skip Commander client retry warnings captured from logging + if 'retrying in' in lowered or ('throttled (' in lowered and 'attempt' in lowered): + continue + if 'too many requests' in lowered: + return 'Too Many Requests' + if 'throttled' in lowered or 'due to repeated attempts' in lowered: + if lowered.startswith('throttled:'): + return line.split(':', 1)[1].strip() or line + return line + + if 'retrying in' in text.lower(): + return 'Request throttled by Keeper API' + + first_line = text.strip().splitlines()[0] + return first_line[:500] + + +def throttle_error_response( + message: Optional[str] = None, + result_code: Any = RESULT_THROTTLED, +) -> Tuple[dict, int]: + return { + 'status': 'error', + 'error': clean_throttle_message(message), + 'result_code': normalize_throttle_result_code(result_code), + }, 429 + + +def rate_limited_response(detail: Optional[str] = None) -> Tuple[dict, int]: + error = 'Service rate limit exceeded' + if detail: + error = f'{error}: {detail}' + return { + 'status': 'error', + 'error': error, + 'result_code': RESULT_RATE_LIMITED, + }, 429 diff --git a/unit-tests/service/test_throttle_response.py b/unit-tests/service/test_throttle_response.py new file mode 100644 index 000000000..361ea5173 --- /dev/null +++ b/unit-tests/service/test_throttle_response.py @@ -0,0 +1,149 @@ +"""Service-mode throttle / rate-limit JSON 429 handling.""" + +import unittest +from unittest import mock + +from flask_limiter.errors import RateLimitExceeded + +from keepercommander.error import KeeperApiError +from keepercommander.service.app import create_app +from keepercommander.service.util.command_util import CommandExecutor +from keepercommander.service.util.parse_keeper_response import KeeperResponseParser +from keepercommander.service.util.throttle import ( + RESULT_EDGE_429, + RESULT_RATE_LIMITED, + RESULT_THROTTLED, + clean_throttle_message, + is_throttle_error, + is_throttle_text, + rate_limited_response, + throttle_error_response, +) + + +class TestThrottleHelpers(unittest.TestCase): + def test_clean_throttle_message_strips_retry_log_noise(self): + noisy = ( + "Throttled (attempt 1/3), retrying in 60 seconds\n" + "Throttled (attempt 1/3), retrying in 60 seconds\n" + "Due to repeated attempts, your request has been throttled. Try again in 1 minute." + ) + self.assertEqual( + clean_throttle_message(noisy), + 'Due to repeated attempts, your request has been throttled. Try again in 1 minute.', + ) + self.assertEqual( + clean_throttle_message('Throttled (attempt 1/3), retrying in 60 seconds\n' * 5), + 'Request throttled by Keeper API', + ) + + def test_clean_throttle_message_strips_result_code_prefix(self): + self.assertEqual( + clean_throttle_message('throttled: Try again in 1 minute.'), + 'Try again in 1 minute.', + ) + + def test_is_throttle_detection(self): + self.assertTrue(is_throttle_error(KeeperApiError('throttled', 'slow down'))) + self.assertTrue(is_throttle_error(KeeperApiError(429, 'Too Many Requests'))) + self.assertTrue(is_throttle_text('too many requests')) + self.assertFalse(is_throttle_text('invalid rate limit config value')) + self.assertFalse(is_throttle_error(KeeperApiError('access_denied', 'nope'))) + + def test_throttle_and_rate_limited_response_shapes(self): + body, status = throttle_error_response('Try again in 1 minute.', RESULT_THROTTLED) + self.assertEqual(status, 429) + self.assertEqual(body['result_code'], RESULT_THROTTLED) + self.assertEqual(body['error'], 'Try again in 1 minute.') + + body, status = throttle_error_response('Too Many Requests', 429) + self.assertEqual(body['result_code'], RESULT_EDGE_429) + + body, status = rate_limited_response('3 per 1 minute') + self.assertEqual(status, 429) + self.assertEqual(body['result_code'], RESULT_RATE_LIMITED) + self.assertIn('3 per 1 minute', body['error']) + + +class TestThrottleResponse(unittest.TestCase): + def test_parser_maps_throttled_text_to_429(self): + result = KeeperResponseParser._parse_logging_based_command( + 'keep-alive', + 'throttled: Due to repeated attempts, your request has been throttled.', + ) + self.assertEqual(result['status_code'], 429) + self.assertEqual(result['result_code'], RESULT_THROTTLED) + self.assertEqual( + result['error'], + 'Due to repeated attempts, your request has been throttled.', + ) + + def test_parser_does_not_treat_rate_limit_config_text_as_throttle(self): + result = KeeperResponseParser._parse_logging_based_command( + 'help', + 'Invalid rate limit config value', + ) + self.assertNotEqual(result.get('status_code'), 429) + self.assertNotEqual(result.get('result_code'), RESULT_THROTTLED) + + def test_execute_maps_keeper_throttle_to_429(self): + err = KeeperApiError('throttled', 'Try again in 1 minute.') + params = mock.Mock(service_mode=False) + params.rest_context = mock.Mock() + with mock.patch( + 'keepercommander.service.core.globals.ensure_params_loaded', + return_value=params, + ), mock.patch.object(CommandExecutor, 'capture_output_and_logs', side_effect=err): + body, status = CommandExecutor.execute('keep-alive') + self.assertEqual(status, 429) + self.assertEqual(body['result_code'], RESULT_THROTTLED) + self.assertEqual(body['error'], 'Try again in 1 minute.') + + def test_execute_maps_edge_429(self): + err = KeeperApiError(429, 'Too Many Requests') + params = mock.Mock(service_mode=False) + params.rest_context = mock.Mock() + with mock.patch( + 'keepercommander.service.core.globals.ensure_params_loaded', + return_value=params, + ), mock.patch.object(CommandExecutor, 'capture_output_and_logs', side_effect=err): + body, status = CommandExecutor.execute('keep-alive') + self.assertEqual(status, 429) + self.assertEqual(body['result_code'], RESULT_EDGE_429) + self.assertEqual(body['error'], 'Too Many Requests') + + def test_execute_preserves_non_throttle_keeper_error_as_500(self): + err = KeeperApiError('access_denied', 'nope') + params = mock.Mock(service_mode=False) + params.rest_context = mock.Mock() + with mock.patch( + 'keepercommander.service.core.globals.ensure_params_loaded', + return_value=params, + ), mock.patch.object(CommandExecutor, 'capture_output_and_logs', side_effect=err): + body, status = CommandExecutor.execute('keep-alive') + self.assertEqual(status, 500) + self.assertIn('Unexpected error', body['error']) + + def test_flask_limiter_returns_json_429(self): + with mock.patch('keepercommander.service.app.init_routes'), \ + mock.patch('keepercommander.service.app.is_behind_proxy', return_value=False), \ + mock.patch('keepercommander.service.app.limiter.init_app'): + app = create_app() + + limit = mock.MagicMock() + limit.error_message = '3 per 1 minute' + + @app.route('/_test_limited') + def _limited(): + raise RateLimitExceeded(limit) + + resp = app.test_client().get('/_test_limited') + self.assertEqual(resp.status_code, 429) + data = resp.get_json() + self.assertEqual(data['status'], 'error') + self.assertEqual(data['result_code'], RESULT_RATE_LIMITED) + self.assertIn('rate limit', data['error'].lower()) + + +if __name__ == '__main__': + unittest.main() From 776338a4d330ba89928946ef286959de1b09de9d Mon Sep 17 00:00:00 2001 From: sshrushanth-ks Date: Tue, 21 Jul 2026 20:09:08 +0530 Subject: [PATCH 02/16] KC-1354: Show team names in nsf-get team_permissions JSON (#2214) (#2236) * fix: Show team names instead of UIDs in nsf-get team_permissions * corrected path for mock in test file --- .../nested_share_folder/folder_api.py | 63 +++++++++++--- unit-tests/test_nested_share_folder.py | 85 +++++++++++++++++++ 2 files changed, 135 insertions(+), 13 deletions(-) diff --git a/keepercommander/nested_share_folder/folder_api.py b/keepercommander/nested_share_folder/folder_api.py index 378c0e181..2f513ba89 100644 --- a/keepercommander/nested_share_folder/folder_api.py +++ b/keepercommander/nested_share_folder/folder_api.py @@ -851,6 +851,40 @@ def _resolve_uid_to_username(params, uid_b64: str) -> Optional[str]: return None +def _resolve_uid_to_team_name(params, uid_b64: str) -> Optional[str]: + """Try to resolve a base64-url team UID to a human-readable team name.""" + team_cache = getattr(params, 'team_cache', None) or {} + team = team_cache.get(uid_b64) + if isinstance(team, dict): + name = team.get('name') + if name: + return name + + enterprise = getattr(params, 'enterprise', None) + if enterprise: + for t in enterprise.get('teams', []): + if t.get('team_uid') == uid_b64 and t.get('name'): + return t['name'] + + try: + teams = api.get_share_objects(params).get('teams', {}) or {} + team_info = teams.get(uid_b64) + if isinstance(team_info, dict): + name = team_info.get('name') + if name: + return name + except Exception: + pass + + for t in (getattr(params, 'available_team_cache', None) or []): + if t.get('team_uid') == uid_b64: + name = t.get('team_name') + if name: + return name + + return None + + # ══════════════════════════════════════════════════════════════════════════ # High-level: get_folder_access_v3 # ══════════════════════════════════════════════════════════════════════════ @@ -895,19 +929,22 @@ def get_folder_access_v3(params, folder_uids, continuation_token=None, at = folder_pb2.AccessType.Name(a.accessType) rt = folder_pb2.AccessRoleType.Name(a.accessRoleType) username = None - if resolve_usernames and at == 'AT_USER': - username = getattr(params, 'user_cache', {}).get(auid) - if not username and hasattr(params, 'enterprise') and params.enterprise: - for u in params.enterprise.get('users', []): - if u.get('user_account_uid') == auid: - username = u.get('username') - break - if not username: - username = _resolve_uid_to_username(params, auid) - if username: - if not hasattr(params, 'user_cache'): - params.user_cache = {} - params.user_cache[auid] = username + if resolve_usernames: + if at == 'AT_USER': + username = getattr(params, 'user_cache', {}).get(auid) + if not username and hasattr(params, 'enterprise') and params.enterprise: + for u in params.enterprise.get('users', []): + if u.get('user_account_uid') == auid: + username = u.get('username') + break + if not username: + username = _resolve_uid_to_username(params, auid) + if username: + if not hasattr(params, 'user_cache'): + params.user_cache = {} + params.user_cache[auid] = username + elif at == 'AT_TEAM': + username = _resolve_uid_to_team_name(params, auid) ai = { 'accessor_uid': auid, 'access_type': at, 'role': rt, 'inherited': bool(a.inherited), 'hidden': bool(a.hidden), diff --git a/unit-tests/test_nested_share_folder.py b/unit-tests/test_nested_share_folder.py index a022fb2a0..db4b6a0a3 100644 --- a/unit-tests/test_nested_share_folder.py +++ b/unit-tests/test_nested_share_folder.py @@ -1288,6 +1288,59 @@ def test_parse_folder_access_result_treats_success_message_as_success(self): self.assertTrue(parsed['success']) self.assertEqual(parsed['status'], 'SUCCESS') + def test_resolve_uid_to_team_name_uses_team_cache(self): + from keepercommander.nested_share_folder.folder_api import _resolve_uid_to_team_name + + team_uid = utils.generate_uid() + params = _make_params(team_cache={ + team_uid: {'name': 'Engineering Team', 'team_uid': team_uid}, + }) + self.assertEqual( + _resolve_uid_to_team_name(params, team_uid), 'Engineering Team') + + @patch('keepercommander.nested_share_folder.folder_api.api.get_share_objects') + def test_resolve_uid_to_team_name_falls_back_to_share_objects(self, mock_share_objects): + from keepercommander.nested_share_folder.folder_api import _resolve_uid_to_team_name + + team_uid = utils.generate_uid() + mock_share_objects.return_value = { + 'teams': {team_uid: {'name': 'Ops Team'}}, + } + params = _make_params() + self.assertEqual(_resolve_uid_to_team_name(params, team_uid), 'Ops Team') + + @patch('keepercommander.nested_share_folder.folder_api.api.communicate_rest') + @patch('keepercommander.nested_share_folder.folder_api.resolve_folder_identifier') + def test_get_folder_access_v3_resolves_team_accessor_name( + self, mock_resolve_folder, mock_communicate): + from keepercommander.nested_share_folder.folder_api import get_folder_access_v3 + from keepercommander.proto import folder_access_pb2 + + folder_uid = utils.generate_uid() + team_uid = utils.generate_uid() + mock_resolve_folder.return_value = folder_uid + + access_result = folder_access_pb2.GetFolderAccessResult() + access_result.folderUid = utils.base64_url_decode(folder_uid) + accessor = access_result.accessors.add() + accessor.accessTypeUid = utils.base64_url_decode(team_uid) + accessor.accessType = folder_pb2.AT_TEAM + accessor.accessRoleType = folder_pb2.VIEWER + + response = folder_access_pb2.GetFolderAccessResponse() + response.folderAccessResults.append(access_result) + mock_communicate.return_value = response + + params = _make_params(team_cache={ + team_uid: {'name': 'Engineering Team', 'team_uid': team_uid}, + }) + result = get_folder_access_v3(params, [folder_uid]) + team_accessor = result['results'][0]['accessors'][0] + + self.assertEqual(team_accessor['username'], 'Engineering Team') + self.assertEqual(team_accessor['accessor_uid'], team_uid) + self.assertEqual(team_accessor['access_type'], 'AT_TEAM') + class TestNestedShareFolderRecordApi(TestCase): @@ -1486,6 +1539,38 @@ def test_get_record_details(self, mock_details): def test_get_record_access(self, mock_accesses): pass + @patch('keepercommander.nested_share_folder.get_folder_access_v3') + def test_folder_json_uses_team_name_in_team_permissions(self, mock_access): + """nsf-get JSON should show team names, not team UIDs, in team_permissions.""" + from keepercommander.commands.nested_share_folder.display_commands import NestedShareGetCommand + + folder_uid, folder_obj = _make_folder(name='Shared Folder') + team_uid = utils.generate_uid() + mock_access.return_value = { + 'results': [{ + 'success': True, + 'accessors': [{ + 'accessor_uid': team_uid, + 'username': 'Engineering Team', + 'access_type': 'AT_TEAM', + 'role': 'VIEWER', + 'inherited': False, + 'permissions': {}, + }], + }], + } + + captured = [] + with mock.patch('builtins.print', side_effect=captured.append): + NestedShareGetCommand._folder_json( + _make_params(nested_share_folders={folder_uid: folder_obj}), + folder_uid, + verbose=False, + ) + + payload = json.loads(captured[-1]) + self.assertEqual(payload['team_permissions'][0]['accessor'], 'Engineering Team') + class TestCommandRegistration(TestCase): From 4d963ae2dfb53606bb32a353a189ebb7efacfc7c Mon Sep 17 00:00:00 2001 From: idimov-keeper <78815270+idimov-keeper@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:55:04 -0500 Subject: [PATCH 03/16] Fix NSF folder titles for team-shared folders. (#2238) Decrypt team-wrapped folderAccesses keys and run NSF decrypt after team keys so sharees see real names instead of Unnamed. Co-authored-by: Cursor --- keepercommander/nested_share_folder/sync.py | 184 ++++++++++++---- keepercommander/sync_down.py | 8 +- unit-tests/test_nsf_folder_key_decrypt.py | 222 ++++++++++++++++++++ 3 files changed, 367 insertions(+), 47 deletions(-) create mode 100644 unit-tests/test_nsf_folder_key_decrypt.py diff --git a/keepercommander/nested_share_folder/sync.py b/keepercommander/nested_share_folder/sync.py index 32992276d..996012fa6 100644 --- a/keepercommander/nested_share_folder/sync.py +++ b/keepercommander/nested_share_folder/sync.py @@ -668,73 +668,169 @@ def _try_decrypt_with_user_keys(enc_key, params): return None +def _try_decrypt_with_typed_key(encrypted_key, key_type, aes_key=None, rsa_key=None, ecc_key=None): + """Decrypt *encrypted_key* using the algorithm indicated by *key_type*. + + Returns plaintext bytes or None. Mirrors Web Vault ``decryptFolderKeyByType``. + """ + try: + if key_type == folder_pb2.encrypted_by_data_key_gcm: + if aes_key is not None: + return crypto.decrypt_aes_v2(encrypted_key, aes_key) + elif key_type == folder_pb2.encrypted_by_data_key: + if aes_key is not None: + return crypto.decrypt_aes_v1(encrypted_key, aes_key) + elif key_type == folder_pb2.encrypted_by_public_key: + if rsa_key is not None: + return crypto.decrypt_rsa(encrypted_key, rsa_key) + elif key_type == folder_pb2.encrypted_by_public_key_ecc: + if ecc_key is not None: + return crypto.decrypt_ec(encrypted_key, ecc_key) + except Exception: + return None + return None + + +def _team_decrypt_material(params, team_uid): + """Return (aes_key, rsa_private_key, ecc_private_key) for a decrypted team, or Nones.""" + team_cache = getattr(params, 'team_cache', None) or {} + team = team_cache.get(team_uid) + if not team or 'team_key_unencrypted' not in team: + return None, None, None + + aes_key = team['team_key_unencrypted'] + rsa_key = None + ecc_key = None + if 'team_private_key_unencrypted' in team: + try: + rsa_key = crypto.load_rsa_private_key(team['team_private_key_unencrypted']) + except Exception: + pass + if 'team_ec_private_key_unencrypted' in team: + try: + ecc_key = crypto.load_ec_private_key(team['team_ec_private_key_unencrypted']) + except Exception: + pass + return aes_key, rsa_key, ecc_key + + +def _try_decrypt_from_folder_access(folder_uid, params): + """Unwrap a folder key from folderAccesses (user or team), mirroring Web Vault. + + Team-shared NSF folders store the sharee's copy of the folder key in + folderAccesses encrypted with the team AES/RSA/ECC key. Direct user shares + use the recipient's data key or public key. + """ + accesses = getattr(params, 'nested_share_folder_accesses', {}).get(folder_uid) or [] + team_cache = getattr(params, 'team_cache', None) or {} + + for fa in accesses: + if 'folder_key' not in fa: + continue + + encrypted_key = fa['folder_key']['encrypted_key'] + key_type = fa['folder_key']['encrypted_key_type'] + access_type = fa.get('access_type') + access_uid = fa.get('access_type_uid') + + use_team = ( + access_type == folder_pb2.AT_TEAM + or (access_uid and access_uid in team_cache) + ) + + try: + folder_key = None + if use_team and access_uid: + team_aes, team_rsa, team_ecc = _team_decrypt_material(params, access_uid) + folder_key = _try_decrypt_with_typed_key( + encrypted_key, key_type, + aes_key=team_aes, rsa_key=team_rsa, ecc_key=team_ecc, + ) + # Algorithm fallbacks (typed decrypt may disagree with wire type) + if not folder_key and team_aes is not None: + folder_key = _try_decrypt_symmetric(encrypted_key, team_aes) + if not folder_key and team_rsa is not None: + try: + folder_key = crypto.decrypt_rsa(encrypted_key, team_rsa) + except Exception: + pass + if not folder_key and team_ecc is not None: + try: + folder_key = crypto.decrypt_ec(encrypted_key, team_ecc) + except Exception: + pass + + if not folder_key: + folder_key = _try_decrypt_with_typed_key( + encrypted_key, key_type, + aes_key=params.data_key, + rsa_key=params.rsa_key2, + ecc_key=params.ecc_key, + ) + if not folder_key: + folder_key = _try_decrypt_with_user_keys(encrypted_key, params) + + if folder_key and len(folder_key) == 32: + return folder_key + except Exception as e: + logging.debug( + 'Failed to decrypt folder key for %s from access data: %s', folder_uid, e + ) + return None + + def _decrypt_nested_share_folder_keys(params): - """Decrypt Nested Share Folder folder and record keys.""" + """Decrypt Nested Share Folder folder and record keys. + + Mirrors Web Vault ``process-keeper-drive-folders``: + - ENCRYPTED_BY_USER_KEY → user keys, then folderAccesses + - ENCRYPTED_BY_PARENT_KEY → parent folder key, then folderAccesses + - ENCRYPTED_BY_TEAM_KEY → folderAccesses (team-wrapped keys) + """ newly_decrypted = True - + while newly_decrypted: newly_decrypted = False - + for folder_uid, folder_obj in params.nested_share_folders.items(): if 'folder_key_unencrypted' in folder_obj: continue folder_key = None + force_accesses = False if folder_uid in params.nested_share_folder_keys: for fk in params.nested_share_folder_keys[folder_uid]: enc_key = fk['encrypted_key'] + key_type = fk['key_type'] try: - if fk['key_type'] == folder_pb2.ENCRYPTED_BY_USER_KEY: - # FolderKeyEncryptionType only tells us the KEY SOURCE (user vs parent), - # not the encryption algorithm. Try all algorithms in likelihood order: - # AES-256-GCM (60 B) — modern default - # AES-256-CBC (48 B) — legacy - # RSA-2048 (256 B) — shared folder re-encrypted for this user - # ECC — EC-based key wrap + if key_type == folder_pb2.ENCRYPTED_BY_TEAM_KEY: + # Key lives in folderAccesses encrypted with the team key. + force_accesses = True + break + if key_type == folder_pb2.ENCRYPTED_BY_USER_KEY: + # FolderKeyEncryptionType is KEY SOURCE, not algorithm. folder_key = _try_decrypt_with_user_keys(enc_key, params) if folder_key: break - elif fk['key_type'] == folder_pb2.ENCRYPTED_BY_PARENT_KEY: - parent_uid = folder_obj.get('parent_uid') + force_accesses = True + elif key_type == folder_pb2.ENCRYPTED_BY_PARENT_KEY: + parent_uid = fk.get('parent_uid') or folder_obj.get('parent_uid') if parent_uid and parent_uid in params.nested_share_folders: parent_folder = params.nested_share_folders[parent_uid] if 'folder_key_unencrypted' in parent_folder: - parent_key = parent_folder['folder_key_unencrypted'] - folder_key = _try_decrypt_symmetric(enc_key, parent_key) + folder_key = _try_decrypt_symmetric( + enc_key, parent_folder['folder_key_unencrypted'] + ) if folder_key: break + # Sharees often get PARENT_KEY metadata without the owner parent. + force_accesses = True except Exception as e: - logging.debug(f"Failed to decrypt folder key for {folder_uid}: {e}") - - # Fallback: try from folder access data (EncryptedDataKey — has explicit algorithm) - if not folder_key and folder_uid in params.nested_share_folder_accesses: - for fa in params.nested_share_folder_accesses[folder_uid]: - if 'folder_key' not in fa: - continue + logging.debug('Failed to decrypt folder key for %s: %s', folder_uid, e) - try: - encrypted_key = fa['folder_key']['encrypted_key'] - key_type = fa['folder_key']['encrypted_key_type'] - - if key_type == folder_pb2.encrypted_by_data_key_gcm: - folder_key = crypto.decrypt_aes_v2(encrypted_key, params.data_key) - elif key_type == folder_pb2.encrypted_by_data_key: - folder_key = crypto.decrypt_aes_v1(encrypted_key, params.data_key) - elif key_type == folder_pb2.encrypted_by_public_key: - if params.rsa_key2: - folder_key = crypto.decrypt_rsa(encrypted_key, params.rsa_key2) - elif key_type == folder_pb2.encrypted_by_public_key_ecc: - if params.ecc_key: - folder_key = crypto.decrypt_ec(encrypted_key, params.ecc_key) - else: - # Unknown type — try all user keys as a last resort - folder_key = _try_decrypt_with_user_keys(encrypted_key, params) - - if folder_key: - break - except Exception as e: - logging.debug(f"Failed to decrypt folder key for {folder_uid} from access data: {e}") + if not folder_key and (force_accesses or folder_uid in params.nested_share_folder_accesses): + folder_key = _try_decrypt_from_folder_access(folder_uid, params) if folder_key: folder_obj['folder_key_unencrypted'] = folder_key @@ -748,7 +844,7 @@ def _decrypt_nested_share_folder_keys(params): if 'color' in data_json: folder_obj['color'] = data_json['color'] except Exception as e: - logging.debug(f"Failed to decrypt folder data for {folder_uid}: {e}") + logging.debug('Failed to decrypt folder data for %s: %s', folder_uid, e) _decrypt_nested_share_record_keys(params) diff --git a/keepercommander/sync_down.py b/keepercommander/sync_down.py index ad6759e1c..45fd3a510 100644 --- a/keepercommander/sync_down.py +++ b/keepercommander/sync_down.py @@ -611,9 +611,6 @@ def convert_user_folder_shared_folder(ufsf): params.revision = revision - if nsf_enabled: - nested_share_folder_sync.process(params, nsf_acc) - for sf in params.shared_folder_cache.values(): owner = sf.get('owner_username') if not owner: @@ -763,6 +760,11 @@ def convert_user_folder_shared_folder(ufsf): del params.team_cache[team_uid] to_delete.clear() + # NSF folder keys may be wrapped with team keys (ENCRYPTED_BY_TEAM_KEY / + # folderAccesses). Decrypt only after team keys are available. + if nsf_enabled: + nested_share_folder_sync.process(params, nsf_acc) + logging.debug('Decrypting shared folder keys') for shared_folder_uid, shared_folder in params.shared_folder_cache.items(): if 'shared_folder_key_unencrypted' not in shared_folder and 'shared_folder_key' in shared_folder: diff --git a/unit-tests/test_nsf_folder_key_decrypt.py b/unit-tests/test_nsf_folder_key_decrypt.py new file mode 100644 index 000000000..3226cf34d --- /dev/null +++ b/unit-tests/test_nsf_folder_key_decrypt.py @@ -0,0 +1,222 @@ +"""Unit tests for NSF folder key / name decrypt (shared + team paths).""" + +import json +from unittest import TestCase +from unittest.mock import Mock + +from keepercommander import crypto, utils +from keepercommander.nested_share_folder import sync as nsf_sync +from keepercommander.proto import folder_pb2 + + +def _make_params(**overrides): + p = Mock() + p.data_key = utils.generate_aes_key() + p.rsa_key2 = None + p.ecc_key = None + p.team_cache = {} + p.nested_share_folders = {} + p.nested_share_folder_keys = {} + p.nested_share_folder_accesses = {} + p.nested_share_records = {} + p.nested_share_record_data = {} + p.nested_share_record_keys = {} + p.nested_share_folder_records = {} + p.nested_share_record_links = {} + for k, v in overrides.items(): + setattr(p, k, v) + return p + + +def _encrypted_folder(name, folder_key): + """Return (folder_uid, folder_obj, folder_key) with AES-GCM encrypted data.""" + folder_uid = utils.generate_uid() + data = crypto.encrypt_aes_v2(json.dumps({'name': name}).encode('utf-8'), folder_key) + folder_obj = { + 'folder_uid': folder_uid, + 'parent_uid': None, + 'data': data, + } + return folder_uid, folder_obj, folder_key + + +class TestNsfFolderKeyDecrypt(TestCase): + + def test_user_key_owner_path_decrypts_name(self): + params = _make_params() + folder_key = utils.generate_aes_key() + folder_uid, folder_obj, _ = _encrypted_folder('Owner Folder', folder_key) + params.nested_share_folders[folder_uid] = folder_obj + params.nested_share_folder_keys[folder_uid] = [{ + 'folder_uid': folder_uid, + 'parent_uid': None, + 'encrypted_key': crypto.encrypt_aes_v2(folder_key, params.data_key), + 'key_type': folder_pb2.ENCRYPTED_BY_USER_KEY, + }] + + nsf_sync._decrypt_nested_share_folder_keys(params) + + self.assertEqual(folder_obj['name'], 'Owner Folder') + self.assertEqual(folder_obj['folder_key_unencrypted'], folder_key) + + def test_team_key_via_folder_access_decrypts_name(self): + """ENCRYPTED_BY_TEAM_KEY + AT_TEAM access wrapped with team AES key.""" + team_uid = utils.generate_uid() + team_aes = utils.generate_aes_key() + folder_key = utils.generate_aes_key() + params = _make_params(team_cache={ + team_uid: { + 'team_uid': team_uid, + 'team_key_unencrypted': team_aes, + }, + }) + folder_uid, folder_obj, _ = _encrypted_folder('Team Shared NSF', folder_key) + params.nested_share_folders[folder_uid] = folder_obj + params.nested_share_folder_keys[folder_uid] = [{ + 'folder_uid': folder_uid, + 'parent_uid': None, + 'encrypted_key': b'', # unused for TEAM_KEY + 'key_type': folder_pb2.ENCRYPTED_BY_TEAM_KEY, + }] + params.nested_share_folder_accesses[folder_uid] = [{ + 'folder_uid': folder_uid, + 'access_type_uid': team_uid, + 'access_type': folder_pb2.AT_TEAM, + 'folder_key': { + 'encrypted_key': crypto.encrypt_aes_v2(folder_key, team_aes), + 'encrypted_key_type': folder_pb2.encrypted_by_data_key_gcm, + }, + }] + + nsf_sync._decrypt_nested_share_folder_keys(params) + + self.assertEqual(folder_obj['name'], 'Team Shared NSF') + self.assertEqual(folder_obj['folder_key_unencrypted'], folder_key) + + def test_team_key_fails_without_decrypted_team_key(self): + """Ordering dependency: encrypted-only team_cache entry cannot unwrap.""" + team_uid = utils.generate_uid() + team_aes = utils.generate_aes_key() + folder_key = utils.generate_aes_key() + params = _make_params(team_cache={ + team_uid: { + 'team_uid': team_uid, + # team_key present but not yet decrypted + 'team_key': utils.base64_url_encode( + crypto.encrypt_aes_v2(team_aes, utils.generate_aes_key()) + ), + 'team_key_type': 1, + }, + }) + folder_uid, folder_obj, _ = _encrypted_folder('Hidden Name', folder_key) + params.nested_share_folders[folder_uid] = folder_obj + params.nested_share_folder_keys[folder_uid] = [{ + 'folder_uid': folder_uid, + 'parent_uid': None, + 'encrypted_key': b'', + 'key_type': folder_pb2.ENCRYPTED_BY_TEAM_KEY, + }] + params.nested_share_folder_accesses[folder_uid] = [{ + 'folder_uid': folder_uid, + 'access_type_uid': team_uid, + 'access_type': folder_pb2.AT_TEAM, + 'folder_key': { + 'encrypted_key': crypto.encrypt_aes_v2(folder_key, team_aes), + 'encrypted_key_type': folder_pb2.encrypted_by_data_key_gcm, + }, + }] + + nsf_sync._decrypt_nested_share_folder_keys(params) + + self.assertNotIn('folder_key_unencrypted', folder_obj) + self.assertNotIn('name', folder_obj) + + def test_parent_key_without_parent_falls_back_to_team_access(self): + """Sharee gets PARENT_KEY metadata but no parent folder; key is in accesses.""" + team_uid = utils.generate_uid() + team_aes = utils.generate_aes_key() + folder_key = utils.generate_aes_key() + parent_uid = utils.generate_uid() + params = _make_params(team_cache={ + team_uid: { + 'team_uid': team_uid, + 'team_key_unencrypted': team_aes, + }, + }) + folder_uid, folder_obj, _ = _encrypted_folder('Child Shared', folder_key) + folder_obj['parent_uid'] = parent_uid + params.nested_share_folders[folder_uid] = folder_obj + # Parent not in nested_share_folders (outside sharee hierarchy) + params.nested_share_folder_keys[folder_uid] = [{ + 'folder_uid': folder_uid, + 'parent_uid': parent_uid, + 'encrypted_key': crypto.encrypt_aes_v2(folder_key, utils.generate_aes_key()), + 'key_type': folder_pb2.ENCRYPTED_BY_PARENT_KEY, + }] + params.nested_share_folder_accesses[folder_uid] = [{ + 'folder_uid': folder_uid, + 'access_type_uid': team_uid, + 'access_type': folder_pb2.AT_TEAM, + 'folder_key': { + 'encrypted_key': crypto.encrypt_aes_v2(folder_key, team_aes), + 'encrypted_key_type': folder_pb2.encrypted_by_data_key_gcm, + }, + }] + + nsf_sync._decrypt_nested_share_folder_keys(params) + + self.assertEqual(folder_obj['name'], 'Child Shared') + + def test_parent_key_with_parent_unwraps_chain(self): + params = _make_params() + parent_key = utils.generate_aes_key() + child_key = utils.generate_aes_key() + parent_uid, parent_obj, _ = _encrypted_folder('Parent', parent_key) + child_uid, child_obj, _ = _encrypted_folder('Child', child_key) + child_obj['parent_uid'] = parent_uid + params.nested_share_folders[parent_uid] = parent_obj + params.nested_share_folders[child_uid] = child_obj + params.nested_share_folder_keys[parent_uid] = [{ + 'folder_uid': parent_uid, + 'parent_uid': None, + 'encrypted_key': crypto.encrypt_aes_v2(parent_key, params.data_key), + 'key_type': folder_pb2.ENCRYPTED_BY_USER_KEY, + }] + params.nested_share_folder_keys[child_uid] = [{ + 'folder_uid': child_uid, + 'parent_uid': parent_uid, + 'encrypted_key': crypto.encrypt_aes_v2(child_key, parent_key), + 'key_type': folder_pb2.ENCRYPTED_BY_PARENT_KEY, + }] + + nsf_sync._decrypt_nested_share_folder_keys(params) + + self.assertEqual(parent_obj['name'], 'Parent') + self.assertEqual(child_obj['name'], 'Child') + + def test_user_access_fallback_when_user_key_fails(self): + """USER_KEY wrap fails; folderAccesses has user-wrapped key.""" + params = _make_params() + folder_key = utils.generate_aes_key() + folder_uid, folder_obj, _ = _encrypted_folder('Access Shared', folder_key) + params.nested_share_folders[folder_uid] = folder_obj + params.nested_share_folder_keys[folder_uid] = [{ + 'folder_uid': folder_uid, + 'parent_uid': None, + # Wrapped with a key the user does not have + 'encrypted_key': crypto.encrypt_aes_v2(folder_key, utils.generate_aes_key()), + 'key_type': folder_pb2.ENCRYPTED_BY_USER_KEY, + }] + params.nested_share_folder_accesses[folder_uid] = [{ + 'folder_uid': folder_uid, + 'access_type_uid': utils.generate_uid(), + 'access_type': folder_pb2.AT_USER, + 'folder_key': { + 'encrypted_key': crypto.encrypt_aes_v2(folder_key, params.data_key), + 'encrypted_key_type': folder_pb2.encrypted_by_data_key_gcm, + }, + }] + + nsf_sync._decrypt_nested_share_folder_keys(params) + + self.assertEqual(folder_obj['name'], 'Access Shared') From f5441313b4563c9c611646b71ac4b691201fe3db Mon Sep 17 00:00:00 2001 From: Matthew Ford Date: Mon, 20 Jul 2026 17:08:33 -0700 Subject: [PATCH 04/16] Make Azure client id and client secret optional. --- keepercommander/commands/discoveryrotation.py | 6 ++++++ keepercommander/commands/pam_import/base.py | 8 ++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index fda7bb82f..3d5b4a6bf 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -2809,6 +2809,9 @@ def parse_properties(self, params, record, **kwargs): # type: (KeeperParams, va if extra_properties: self.assign_typed_fields(record, [RecordEditMixin.parse_field(x) for x in extra_properties]) + # Fields that the backend previously required but now treats as optional for pamAzureConfiguration. + AZURE_OPTIONAL_FIELDS = frozenset({'clientId', 'clientSecret'}) + def verify_required(self, record): # type: (vault.TypedRecord) -> None for field in record.fields: if field.required: @@ -2817,6 +2820,9 @@ def verify_required(self, record): # type: (vault.TypedRecord) -> None field.value = [{ 'type': 'ON_DEMAND' }] + elif (record.record_type == 'pamAzureConfiguration' + and field.label in self.AZURE_OPTIONAL_FIELDS): + pass else: self.warnings.append(f'Empty required field: "{field.get_field_name()}"') for custom in record.custom: diff --git a/keepercommander/commands/pam_import/base.py b/keepercommander/commands/pam_import/base.py index 412833c08..85cc852b4 100644 --- a/keepercommander/commands/pam_import/base.py +++ b/keepercommander/commands/pam_import/base.py @@ -143,8 +143,8 @@ def _initialize(self): # Azure environment: pamAzureConfiguration self.az_entra_id: str = "" # required, text:azureId - self.az_client_id: str = "" # required, secret:clientId - self.az_client_secret: str = "" # required, secret:clientSecret + self.az_client_id: str = "" # optional, secret:clientId + self.az_client_secret: str = "" # optional, secret:clientSecret self.az_subscription_id: str = "" # required, secret:subscriptionId self.az_tenant_id: str = "" # required, secret:tenantId self.az_resource_groups: List[str] = [] # optional, multiline:resourceGroups @@ -277,9 +277,9 @@ def __init__(self, environment_type:str, settings:dict, controller_uid:str, fold elif environment_type == "azure": val = settings.get("az_entra_id", None) # required if isinstance(val, str): self.az_entra_id = val - val = settings.get("az_client_id", None) # required + val = settings.get("az_client_id", None) # optional if isinstance(val, str): self.az_client_id = val - val = settings.get("az_client_secret", None) # required + val = settings.get("az_client_secret", None) # optional if isinstance(val, str): self.az_client_secret = val val = settings.get("az_subscription_id", None) # required if isinstance(val, str): self.az_subscription_id = val From b97101592068ab5570ed4e2e3b6853a31d5c75da Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Thu, 23 Jul 2026 18:32:19 +0530 Subject: [PATCH 05/16] NSF Support to pam action commands --- keepercommander/commands/discover/__init__.py | 97 +++++--- .../commands/discover/result_process.py | 221 ++++++++++++------ keepercommander/commands/discoveryrotation.py | 126 ++++++---- keepercommander/commands/pam/vault_target.py | 12 +- .../commands/pam_debug/__init__.py | 4 +- keepercommander/commands/pam_debug/acl.py | 8 +- keepercommander/commands/pam_debug/dump.py | 125 +++++++--- keepercommander/commands/pam_debug/graph.py | 30 ++- keepercommander/commands/pam_debug/info.py | 26 +-- keepercommander/commands/pam_debug/krouter.py | 1 - keepercommander/commands/pam_debug/link.py | 6 +- .../commands/pam_debug/rotation_setting.py | 22 +- keepercommander/commands/pam_debug/verify.py | 4 +- keepercommander/commands/pam_launch/launch.py | 150 +++++++----- keepercommander/commands/pam_saas/config.py | 137 +++++------ unit-tests/pam/test_pam_debug_nsf.py | 182 +++++++++++++++ 16 files changed, 786 insertions(+), 365 deletions(-) create mode 100644 unit-tests/pam/test_pam_debug_nsf.py diff --git a/keepercommander/commands/discover/__init__.py b/keepercommander/commands/discover/__init__.py index dd07290cb..6ef3c4b10 100644 --- a/keepercommander/commands/discover/__init__.py +++ b/keepercommander/commands/discover/__init__.py @@ -7,6 +7,7 @@ from ..pam.router_helper import get_response_payload from ..pam.gateway_helper import get_all_gateways from ..pam.router_helper import router_send_action_to_gateway +from ..pam.vault_target import pam_folder_exists, get_pam_folder_path from ..ksm import KSMCommand from ... import utils, vault_extensions from ... import vault @@ -21,7 +22,10 @@ import re from packaging import version as packaging_version -from typing import List, Optional, Union, Callable, Tuple, Any, Dict, TYPE_CHECKING +from collections.abc import Callable +from typing import Any, TYPE_CHECKING + +from ..pam_import.record_loader import iter_accessible_record_uids, load_pam_record if TYPE_CHECKING: from ...params import KeeperParams @@ -33,7 +37,7 @@ class MultiConfigurationException(Exception): """ If the gateway has multiple configuration """ - def __init__(self, items: List[Dict]): + def __init__(self, items: list[dict]): super().__init__() self.items = items @@ -66,14 +70,14 @@ def __init__(self, configuration: KeeperRecord, facade: PamConfigurationRecordFa self.gateway = gateway self.application = application self._shared_folders = None - self._info: Optional[Dict] = None + self._info: dict | None = None @staticmethod def all_gateways(params: KeeperParams): return get_all_gateways(params) @staticmethod - def get_configuration_records(params) -> List[KeeperRecord]: + def get_configuration_records(params) -> list[KeeperRecord]: """ Get PAM configuration records. @@ -84,17 +88,37 @@ def get_configuration_records(params) -> List[KeeperRecord]: """ configuration_list = [] - if value_to_boolean(os.environ.get("PAM_RECORD_TYPE_MATCH")): + seen = set() + type_match = value_to_boolean(os.environ.get("PAM_RECORD_TYPE_MATCH")) + if type_match: for record in list(vault_extensions.find_records(params, record_version=iter([3, 6]))): if re.search(r"pam.+Configuration", record.record_type): configuration_list.append(record) + seen.add(record.record_uid) else: - configuration_list = list(vault_extensions.find_records(params, record_version=6)) + for record in list(vault_extensions.find_records(params, record_version=6)): + configuration_list.append(record) + seen.add(record.record_uid) + + # Include Nested Share Folder PAM configs that are not mirrored into record_cache yet. + for uid in iter_accessible_record_uids(params): + if uid in seen: + continue + record = load_pam_record(params, uid) + if not record or not isinstance(record, vault.TypedRecord): + continue + if type_match: + if not re.search(r"pam.+Configuration", record.record_type or ''): + continue + elif record.version != 6: + continue + configuration_list.append(record) + seen.add(uid) return configuration_list @classmethod - def find_gateway(cls, params: KeeperParams, find_func: Callable, gateways: Optional[List] = None) \ - -> Tuple[Optional[GatewayContext], Any]: + def find_gateway(cls, params: KeeperParams, find_func: Callable, gateways: list | None = None) \ + -> tuple[GatewayContext | None, Any]: """ Populate the context from matching using the function passed in. @@ -121,8 +145,8 @@ def find_gateway(cls, params: KeeperParams, find_func: Callable, gateways: Optio return None, None @staticmethod - def from_configuration_uid(params: KeeperParams, configuration_uid: str, gateways: Optional[List] = None) \ - -> Optional[GatewayContext]: + def from_configuration_uid(params: KeeperParams, configuration_uid: str, gateways: list | None = None) \ + -> GatewayContext | None: """ Populate context using the configuration UID. @@ -134,7 +158,7 @@ def from_configuration_uid(params: KeeperParams, configuration_uid: str, gateway if gateways is None: gateways = GatewayContext.all_gateways(params) - configuration_record = vault.KeeperRecord.load(params, configuration_uid) + configuration_record = load_pam_record(params, configuration_uid) if not isinstance(configuration_record, vault.TypedRecord): print(f'{bcolors.FAIL}PAM Configuration [{configuration_uid}] is not available.{bcolors.ENDC}') return None @@ -161,8 +185,8 @@ def from_configuration_uid(params: KeeperParams, configuration_uid: str, gateway ) @staticmethod - def from_gateway(params: KeeperParams, gateway: str, configuration_uid: Optional[str] = None) \ - -> Optional[GatewayContext]: + def from_gateway(params: KeeperParams, gateway: str, configuration_uid: str | None = None) \ + -> GatewayContext | None: """ Populate context use the gateway, and optional configuration UID. @@ -190,7 +214,10 @@ def from_gateway(params: KeeperParams, gateway: str, configuration_uid: Optional logging.debug(f"checking configuration record {configuration_record.title}") # Load the configuration record and get the gateway_uid from the facade. - configuration_record = vault.KeeperRecord.load(params, configuration_record.record_uid) + configuration_record = load_pam_record(params, configuration_record.record_uid) + if not isinstance(configuration_record, vault.TypedRecord): + logging.debug(f" * configuration record could not be loaded, skipping.") + continue configuration_facade = PamConfigurationRecordFacade() configuration_facade.record = configuration_record @@ -276,7 +303,7 @@ def is_gateway(self, request_gateway: str) -> bool: return (request_gateway == utils.base64_url_encode(self.gateway.controllerUid) or request_gateway.lower() == self.gateway_name.lower()) - def get_shared_folders(self, params: KeeperParams) -> List[dict]: + def get_shared_folders(self, params: KeeperParams) -> list[dict]: if self._shared_folders is None: self._shared_folders = [] application_uid = utils.base64_url_encode(self.gateway.applicationUid) @@ -288,17 +315,35 @@ def get_shared_folders(self, params: KeeperParams) -> List[dict]: uid_str = utils.base64_url_encode(shared.secretUid) shared_type = APIRequest_pb2.ApplicationShareType.Name(shared.shareType) if shared_type == 'SHARE_TYPE_FOLDER': - if uid_str not in params.shared_folder_cache: + if uid_str in params.shared_folder_cache: + cached_shared_folder = params.shared_folder_cache[uid_str] + self._shared_folders.append({ + "uid": uid_str, + "name": cached_shared_folder.get('name_unencrypted'), + "folder": cached_shared_folder + }) continue - cached_shared_folder = params.shared_folder_cache[uid_str] - self._shared_folders.append({ - "uid": uid_str, - "name": cached_shared_folder.get('name_unencrypted'), - "folder": cached_shared_folder - }) + nsf = getattr(params, 'nested_share_folders', {}).get(uid_str) + if nsf: + self._shared_folders.append({ + "uid": uid_str, + "name": nsf.get('name', uid_str), + "folder": nsf + }) + # PAM config application folder (often NSF) may not appear in app shares listing. + app_folder_uid = self.default_shared_folder_uid + if app_folder_uid and not any(f.get('uid') == app_folder_uid for f in self._shared_folders): + if pam_folder_exists(params, app_folder_uid): + nsf = getattr(params, 'nested_share_folders', {}).get(app_folder_uid) + name = (nsf.get('name') if nsf else None) or get_pam_folder_path(params, app_folder_uid) or app_folder_uid + self._shared_folders.append({ + "uid": app_folder_uid, + "name": name, + "folder": nsf or params.shared_folder_cache.get(app_folder_uid) or {}, + }) return self._shared_folders - def info(self, params: KeeperParams) -> Optional[Dict]: + def info(self, params: KeeperParams) -> dict | None: if self._info is None: from ..pam.pam_dto import GatewayActionGatewayInfo @@ -333,7 +378,7 @@ def info(self, params: KeeperParams) -> Optional[Dict]: return self._info - def _gateway_version(self, params: KeeperParams) -> Optional[packaging_version.Version]: + def _gateway_version(self, params: KeeperParams) -> packaging_version.Version | None: try: info = self.info(params) @@ -367,7 +412,7 @@ def encrypt(self, data: dict) -> str: ciphertext = encrypt_aes_v2(json_data.encode(), self.configuration.record_key) return base64.b64encode(ciphertext).decode() - def encrypt_str(self, data: Union[bytes, str]) -> str: + def encrypt_str(self, data: bytes | str) -> str: if isinstance(data, str): data = data.encode() ciphertext = encrypt_aes_v2(data, self.configuration.record_key) @@ -419,7 +464,7 @@ class PAMGatewayActionDiscoverCommandBase(Command): } @staticmethod - def get_response_data(router_response: dict) -> Optional[dict]: + def get_response_data(router_response: dict) -> dict | None: if router_response is None: return None diff --git a/keepercommander/commands/discover/result_process.py b/keepercommander/commands/discover/result_process.py index f00a672be..d221aab20 100644 --- a/keepercommander/commands/discover/result_process.py +++ b/keepercommander/commands/discover/result_process.py @@ -8,6 +8,10 @@ from . import PAMGatewayActionDiscoverCommandBase, GatewayContext from ..pam.router_helper import (router_get_connected_gateways, router_set_record_rotation_information, router_configure_resource) +from ..pam.vault_target import records_in_folder, is_nested_share_folder +from ...nested_share_folder.record_api import create_record_data_v3, record_add_v3 +from ...nested_share_folder.common import get_folder_key +from ..pam_import.nsf_helpers import sync_down_preserving_nsf_keys from ... import api, subfolder, utils, crypto, vault, vault_extensions from ...display import bcolors from ...proto import router_pb2, record_pb2, pam_pb2 @@ -23,7 +27,7 @@ from ...discovery_common.constants import PAM_USER from ...discovery_common.constants import VERTICES_SORT_MAP from pydantic import BaseModel -from typing import Optional, List, Any, Tuple, Dict, TYPE_CHECKING +from typing import Any, TYPE_CHECKING from ...api import get_records_add_request if TYPE_CHECKING: @@ -101,21 +105,21 @@ def _is_directory_user(record_type: str) -> bool: record_type == "pamAzureConfiguration") @staticmethod - def _get_shared_folder(params: KeeperParams, pad: str, gateway_context: GatewayContext) -> Optional[str]: + def _get_shared_folder(params: KeeperParams, pad: str, gateway_context: GatewayContext) -> str | None: while True: shared_folders = gateway_context.get_shared_folders(params) index = 0 for folder in shared_folders: print(f"{pad}* {_h(str(index+1))} - {folder.get('uid')} {folder.get('name')}") index += 1 - selected = input(f"{pad}Enter number of the shared folder>") + selected = input(f"{pad}Enter number of the shared folder or Nested Share Folder>") try: return shared_folders[int(selected) - 1].get("uid") except ValueError: print(f"{pad}{_f('Input was not a number.')}") @staticmethod - def get_field_values(record: TypedRecord, field_type: str) -> Optional[List[Any]]: + def get_field_values(record: TypedRecord, field_type: str) -> list[Any] | None: return next( (f.value for f in record.fields @@ -124,7 +128,7 @@ def get_field_values(record: TypedRecord, field_type: str) -> Optional[List[Any] ) def get_keys_by_record(self, params: KeeperParams, gateway_context: GatewayContext, - record: TypedRecord) -> List[str]: + record: TypedRecord) -> list[str]: """ For the record, get the values of fields that are key for this record type. @@ -137,7 +141,7 @@ def get_keys_by_record(self, params: KeeperParams, gateway_context: GatewayConte key_field = Process.get_key_field(record.record_type) keys = [] if key_field == "host_port": - values = self.get_field_values(record, "pamHostname") # type: List[dict] + values = self.get_field_values(record, "pamHostname") # type: list[dict] if len(values) == 0: return [] @@ -182,8 +186,8 @@ def get_keys_by_record(self, params: KeeperParams, gateway_context: GatewayConte @staticmethod def _record_lookup(record_uid: str, - context: Optional[Any] = None, - allow_sm: bool = False) -> Optional[NormalizedRecord]: + context: Any | None = None, + allow_sm: bool = False) -> NormalizedRecord | None: """ Get the record from the Vault, normalize it, and return it. @@ -192,7 +196,7 @@ def _record_lookup(record_uid: str, """ params = context.get("params") - record = vault.TypedRecord.load(params, record_uid) # type: Optional[TypedRecord] + record = vault.TypedRecord.load(params, record_uid) # type: TypedRecord | None if record is None: return None @@ -232,6 +236,7 @@ def _build_record_cache(self, params: KeeperParams, gateway_context: GatewayCont logging.debug(f"building the PAM record cache") # Make a cache of existing record by the criteria per record type + # (includes Nested Share Folder records) cache = { "pamUser": {}, "pamMachine": {}, @@ -241,28 +246,31 @@ def _build_record_cache(self, params: KeeperParams, gateway_context: GatewayCont # Set all the PAM Records records = list(vault_extensions.find_records(params, "pam*", use_regex=True)) + seen_uids = {getattr(r, 'record_uid', None) for r in records} + # Include NSF PAM records that may not appear in find_records edge cases + for record_uid in getattr(params, 'nested_share_records', {}) or {}: + if record_uid not in seen_uids: + rec = vault.TypedRecord.load(params, record_uid) + if rec is not None: + records.append(rec) + seen_uids.add(record_uid) for record in records: - # If the record type is not part of the cache, skip the record - if record.record_type not in cache: - continue - - # Load the full record - record = vault.TypedRecord.load(params, record.record_uid) # type: Optional[TypedRecord] - - cache_keys = self.get_keys_by_record( - params=params, - gateway_context=gateway_context, - record=record - ) - if len(cache_keys) == 0: - continue + if record.record_type in cache: + # Load the full record + record = vault.TypedRecord.load(params, record.record_uid) # type: TypedRecord | None - for cache_key in cache_keys: - cache[record.record_type][cache_key] = record.record_uid + cache_keys = self.get_keys_by_record( + params=params, + gateway_context=gateway_context, + record=record + ) + if cache_keys: + for cache_key in cache_keys: + cache[record.record_type][cache_key] = record.record_uid return cache - def _edit_record(self, content: DiscoveryObject, pad: str, editable: List[str]) -> bool: + def _edit_record(self, content: DiscoveryObject, pad: str, editable: list[str]) -> bool: edit_label = input(f"{pad}Enter 'title' or the name of the {_ok('Label')} to edit, RETURN to cancel> ") @@ -354,7 +362,7 @@ def _edit_record(self, content: DiscoveryObject, pad: str, editable: List[str]) @staticmethod def _add_all_preprocess(vertex: DAGVertex, content: DiscoveryObject, parent_vertex: DAGVertex, - acl: Optional[UserAcl] = None) -> Optional[PromptResult]: + acl: UserAcl | None = None) -> PromptResult | None: """ This is client side check if we should skip prompting the user. @@ -377,7 +385,7 @@ def _add_all_preprocess(vertex: DAGVertex, content: DiscoveryObject, parent_vert return PromptResult(action=PromptActionEnum.SKIP) return None - def _prompt_display_fields(self, content: DiscoveryObject, pad: str) -> List[str]: + def _prompt_display_fields(self, content: DiscoveryObject, pad: str) -> list[str]: editable = [] for section in ["fields", "custom"]: @@ -454,13 +462,13 @@ def _prompt(self, content: DiscoveryObject, acl: UserAcl, parent_vertex: DAGVertex, - vertex: Optional[DAGVertex] = None, + vertex: DAGVertex | None = None, resource_has_admin: bool = True, item_count: int = 0, items_left: int = 0, indent: int = 0, block_auto_add: bool = False, - context: Optional[Any] = None) -> PromptResult: + context: Any | None = None) -> PromptResult: if context is None: raise Exception("Context not set for processing the discovery results") @@ -614,8 +622,8 @@ def _prompt(self, def _find_user_record(self, params: KeeperParams, - bulk_convert_records: List[BulkRecordConvert], - context: Optional[Any] = None) -> Tuple[Optional[TypedRecord], bool]: + bulk_convert_records: list[BulkRecordConvert], + context: Any | None = None) -> tuple[TypedRecord | None, bool]: gateway_context = context.get("gateway_context") # type: GatewayContext record_link = context.get("record_link") # type: RecordLink @@ -623,12 +631,16 @@ def _find_user_record(self, # Get the latest records params.sync_data = True - # Make a list of all records in the shared folders. + # Make a list of all records in the shared folders / Nested Share Folders. # We will use this to check if a selected user is in the shared folders. + shared_record_uids = [] for shared_folder in gateway_context.get_shared_folders(params): - folder = shared_folder.get("folder") - if "records" in folder: + folder = shared_folder.get("folder") or {} + folder_uid = shared_folder.get("uid") + if folder_uid and is_nested_share_folder(params, folder_uid): + shared_record_uids.extend(records_in_folder(params, folder_uid)) + elif isinstance(folder, dict) and "records" in folder: for record in folder["records"]: shared_record_uids.append(record.get("record_uid")) @@ -656,7 +668,7 @@ def _find_user_record(self, return None, False # Find usable admin records. - admin_search_results = [] # type: List[AdminSearchResult] + admin_search_results = [] # type: list[AdminSearchResult] for record in user_record: user_record = vault.KeeperRecord.load(params, record.record_uid) @@ -692,7 +704,7 @@ def _find_user_record(self, is_directory_user = False if record_vertex is not None: parent_record_uid = record_link.get_parent_record_uid(user_record.record_uid) - parent_record = vault.TypedRecord.load(params, parent_record_uid) # type: Optional[TypedRecord] + parent_record = vault.TypedRecord.load(params, parent_record_uid) # type: TypedRecord | None if parent_record is not None: is_directory_user = self._is_directory_user(parent_record.record_type) if not is_directory_user: @@ -801,7 +813,7 @@ def _find_user_record(self, @staticmethod def _handle_admin_record_from_record(record: TypedRecord, content: DiscoveryObject, - context: Optional[Any] = None) -> Optional[PromptResult]: + context: Any | None = None) -> PromptResult | None: params = context.get("param") # type: KeeperParams gateway_context = context.get("gateway_context") # type: GatewayContext @@ -892,9 +904,9 @@ def _prompt_admin(self, parent_vertex: DAGVertex, content: DiscoveryObject, acl: UserAcl, - bulk_convert_records: List[BulkRecordConvert], + bulk_convert_records: list[BulkRecordConvert], indent: int = 0, - context: Optional[Any] = None) -> Optional[PromptResult]: + context: Any | None = None) -> PromptResult | None: if content is None: raise Exception("The admin content was not passed in to prompt the user.") @@ -961,7 +973,7 @@ def _prompt_admin(self, print("") @staticmethod - def _display_auto_add_results(bulk_add_records: List[BulkRecordAdd]): + def _display_auto_add_results(bulk_add_records: list[BulkRecordAdd]): """ Display the number of record created from rule engine ADD results and smart add function. @@ -974,7 +986,7 @@ def _display_auto_add_results(bulk_add_records: List[BulkRecordAdd]): f"record{'' if add_count == 1 else 's'} to be added.{bcolors.ENDC}") @staticmethod - def _prompt_confirm_add(bulk_add_records: List[BulkRecordAdd]): + def _prompt_confirm_add(bulk_add_records: list[BulkRecordAdd]): """ If we quit, we want to ask the user if they want to add record for discovery objects that they selected @@ -1000,7 +1012,7 @@ def _prompt_confirm_add(bulk_add_records: List[BulkRecordAdd]): print(f"{bcolors.FAIL}Did not get 'Y' or 'N'{bcolors.ENDC}") @staticmethod - def _prepare_record(content: DiscoveryObject, context: Optional[Any] = None) -> Tuple[Any, str]: + def _prepare_record(content: DiscoveryObject, context: Any | None = None) -> tuple[Any, str]: """ Prepare the Vault record side. @@ -1009,12 +1021,16 @@ def _prepare_record(content: DiscoveryObject, context: Optional[Any] = None) -> It will be created at the end of the processing run in bulk. We to build a record to get a record UID. + For Nested Share Folders, returns a TypedRecord (created via NSF v3 APIs). + For legacy shared folders, returns a RecordAdd protobuf. + :params content: The discovery object instance. :params context: Optionally, it will contain information set from the run() method. :returns: Returns an unsaved Keeper record instance. """ params = context.get("params") + from ..pam.vault_target import is_nested_share_folder # DEFINE V3 RECORD @@ -1050,8 +1066,12 @@ def _prepare_record(content: DiscoveryObject, context: Optional[Any] = None) -> record_field.required = field.required record.custom.append(record_field) + # Nested Share Folder: keep TypedRecord; created later via NSF v3 APIs. + if content.shared_folder_uid and is_nested_share_folder(params, content.shared_folder_uid): + return record, record.record_uid + folder = params.folder_cache.get(content.shared_folder_uid) - folder_key = None # type: Optional[bytes] + folder_key = None # type: bytes | None if isinstance(folder, subfolder.SharedFolderFolderNode): shared_folder_uid = folder.shared_folder_uid elif isinstance(folder, subfolder.SharedFolderNode): @@ -1092,7 +1112,7 @@ def _prepare_record(content: DiscoveryObject, context: Optional[Any] = None) -> return record_add_protobuf, record.record_uid @classmethod - def _create_records(cls, bulk_add_records: List[BulkRecordAdd], context: Optional[Any] = None) -> ( + def _create_records(cls, bulk_add_records: list[BulkRecordAdd], context: Any | None = None) -> ( BulkProcessResults): """ @@ -1109,31 +1129,88 @@ def _create_records(cls, bulk_add_records: List[BulkRecordAdd], context: Optiona build_process_results = BulkProcessResults() + nsf_bulk = [r for r in bulk_add_records + if r.shared_folder_uid and is_nested_share_folder(params, r.shared_folder_uid)] + legacy_bulk = [r for r in bulk_add_records + if not (r.shared_folder_uid and is_nested_share_folder(params, r.shared_folder_uid))] + ############################################################################################################## # # STEP 1 - Batch add new records - # Generate a list of RecordAdd instance. - # In BulkRecordAdd they will be the record instance. - record_add_list = [r.record for r in bulk_add_records] # type: List[record_pb2.RecordAdd] - records_per_request = 999 + add_results = [] # type: list[record_pb2.RecordModifyResult] + created_nsf = False + skipped_uids = set() + + # NSF path: vault/records/v3/add with folder key encryption + if nsf_bulk: + logging.debug("adding NSF records in batches") + print("batch NSF record create: ", end="") + sys.stdout.flush() + nsf_adds = [] + for bulk_record in nsf_bulk: + record = bulk_record.record + if not isinstance(record, vault.TypedRecord): + build_process_results.failure.append( + BulkRecordFail( + title=bulk_record.title, + error="NSF create expected a TypedRecord preparation payload.", + ) + ) + skipped_uids.add(bulk_record.record_uid) + continue + folder_key = get_folder_key( + params, bulk_record.shared_folder_uid, raise_on_missing=False, + ) + if not folder_key: + build_process_results.failure.append( + BulkRecordFail( + title=bulk_record.title, + error=f"NSF folder key not available for {bulk_record.shared_folder_uid}.", + ) + ) + skipped_uids.add(bulk_record.record_uid) + continue + data = vault_extensions.extract_typed_record_data(record) + nsf_adds.append(create_record_data_v3( + record_uid=bulk_record.record_uid, + record_key=record.record_key or utils.generate_aes_key(), + data=data, + folder_uid=bulk_record.shared_folder_uid, + folder_key=folder_key, + data_key=params.data_key, + client_modified_time=utils.current_milli_time(), + )) + + while nsf_adds: + print(".", end="") + sys.stdout.flush() + chunk = nsf_adds[:records_per_request] + nsf_adds = nsf_adds[records_per_request:] + rs = record_add_v3(params, chunk) + add_results.extend(rs.records) + created_nsf = True + print("") + sys.stdout.flush() - add_results = [] # type: List[record_pb2.RecordModifyResult] - logging.debug("adding record in batches") - print("batch record create: ", end="") - sys.stdout.flush() - while record_add_list: - print(".", end="") + # Legacy path: vault/records_add + if legacy_bulk: + record_add_list = [r.record for r in legacy_bulk] # type: list[record_pb2.RecordAdd] + logging.debug("adding record in batches") + print("batch record create: ", end="") + sys.stdout.flush() + while record_add_list: + print(".", end="") + sys.stdout.flush() + logging.debug(f"* adding batch") + rq = get_records_add_request(params) + rq.records.extend(record_add_list[:records_per_request]) + record_add_list = record_add_list[records_per_request:] + rs = api.communicate_rest(params, rq, 'vault/records_add', rs_type=record_pb2.RecordsModifyResponse) + add_results.extend(rs.records) + print("") sys.stdout.flush() - logging.debug(f"* adding batch") - rq = get_records_add_request(params) - rq.records.extend(record_add_list[:records_per_request]) - record_add_list = record_add_list[records_per_request:] - rs = api.communicate_rest(params, rq, 'vault/records_add', rs_type=record_pb2.RecordsModifyResponse) - add_results.extend(rs.records) - print("") - sys.stdout.flush() logging.debug(f"add_result: {add_results}") @@ -1159,20 +1236,22 @@ def _create_records(cls, bulk_add_records: List[BulkRecordAdd], context: Optiona if bulk_record.record_uid in created_cache: logging.debug(f"found a duplicate of record uid: {bulk_record.record_uid}") continue + if bulk_record.record_uid in skipped_uids: + continue print(".", end="") sys.stdout.flush() # Grab the type Keeper record instance, and title from that record. - pb_add_record = bulk_record.record title = bulk_record.title + rec_uid_bytes = utils.base64_url_decode(bulk_record.record_uid) rotation_disabled = False # Find the result for this record. result = None for x in add_results: - logging.debug(f"{pb_add_record.record_uid} vs {x.record_uid}") - if pb_add_record.record_uid == x.record_uid: + logging.debug(f"{rec_uid_bytes} vs {x.record_uid}") + if rec_uid_bytes == x.record_uid: result = x break @@ -1246,12 +1325,14 @@ def _create_records(cls, bulk_add_records: List[BulkRecordAdd], context: Optiona print("") sys.stdout.flush() + if created_nsf: + sync_down_preserving_nsf_keys(params) params.sync_data = True return build_process_results @classmethod - def _convert_records(cls, bulk_convert_records: List[BulkRecordConvert], context: Optional[Any] = None): + def _convert_records(cls, bulk_convert_records: list[BulkRecordConvert], context: Any | None = None): params = context.get("params") gateway_context = context.get("gateway_context") @@ -1291,7 +1372,7 @@ def _convert_records(cls, bulk_convert_records: List[BulkRecordConvert], context @staticmethod def _get_directory_info(domain: str, skip_users: bool = False, - context: Optional[Any] = None) -> Optional[DirectoryInfo]: + context: Any | None = None) -> DirectoryInfo | None: """ Get information about this record from the vault records. @@ -1305,7 +1386,7 @@ def _get_directory_info(domain: str, # Find the all directory records, in for this gateway, that have a domain that matches what we are looking for. for directory_record in vault_extensions.find_records(params, record_type="pamDirectory"): directory_record = vault.TypedRecord.load(params, - directory_record.record_uid) # type: Optional[TypedRecord] + directory_record.record_uid) # type: TypedRecord | None info = params.record_rotation_cache.get(directory_record.record_uid) if info is None: @@ -1391,7 +1472,7 @@ def _print_resource(rt: str, rule_result: str): "pamDirectory": "Directories", "pamMachine": "Machines", "pamDatabase": "Databases" - } # type: Dict[str, Optional[str]] + } # type: dict[str, str | None] for rv in record_type_to_vertices_map[rt]: # type: DAGVertex if not rv.active or not rv.has_data: diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index 3d5b4a6bf..569c9e0bd 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -18,7 +18,7 @@ import time from datetime import datetime from urllib.parse import urlparse, urlunparse -from typing import Optional, List +from typing import Any import requests from keeper_secrets_manager_core.utils import url_safe_str_to_bytes @@ -29,7 +29,7 @@ from .pam import gateway_helper, router_helper from .pam.config_facades import PamConfigurationRecordFacade from .pam.vault_target import ( - format_pam_folder_display, resolve_pam_folder_uid, + format_pam_folder_display, resolve_pam_folder_uid, is_nested_share_folder, resolve_pam_record, record_exists_in_vault, collect_pam_folder_uids, get_vault_record_title_type, find_pam_records_by_search, resolve_pam_config_folder_info, pam_folder_json_payload, place_record_in_folder, @@ -105,6 +105,7 @@ PAMUniversalSyncRunCommand ) from ..discovery_common.types import UserAcl, UserAclRotationSettings +from ..vault import TypedRecord # These characters are based on the Vault PAM_DEFAULT_SPECIAL_CHAR = '''!@#$%^?();',.=+[]<>{}-_/\\*&:"`~|''' @@ -170,7 +171,7 @@ def parse_schedule_data(kwargs): schedule_json_data = kwargs.get('schedule_json_data') schedule_cron_data = kwargs.get('schedule_cron_data') schedule_on_demand = kwargs.get('on_demand') is True - schedule_data = None # type: Optional[List] + schedule_data = None # type: list | None if isinstance(schedule_json_data, str): schedule_json_data = [schedule_json_data] if isinstance(schedule_json_data, list): @@ -221,7 +222,7 @@ def resolve_record_rotation_revision(params, record_uid): def schedule_from_pam_config(record_pam_config): - # type: (Optional[vault.TypedRecord]) -> Optional[List] + # type: (TypedRecord | None) -> list | None """Return rotation schedule list from a PAM configuration defaultRotationSchedule field.""" if not record_pam_config: return None @@ -233,7 +234,7 @@ def schedule_from_pam_config(record_pam_config): def resolve_record_schedule_data(schedule_data, current_record_rotation, schedule_config, record_pam_config): - # type: (Optional[List], Optional[dict], bool, Optional[vault.TypedRecord]) -> Optional[List] + # type: (list | None, dict | None, bool, TypedRecord | None) -> list | None """Resolve rotation schedule for pam rotation edit (Web Vault use-default-schedule parity).""" if schedule_data is not None: return schedule_data @@ -612,7 +613,7 @@ def config_resource(_dag, target_record, target_config_uid, silent=None): def config_saas_user(_dag, target_record, saas_config_uid: str): - saas_config_record = vault.KeeperRecord.load(params, saas_config_uid) # type: Optional[TypedRecord] + saas_config_record = vault.KeeperRecord.load(params, saas_config_uid) # type: TypedRecord | None if saas_config_record is None: raise CommandError('', 'The SaaS configuration record does not exists.') @@ -1418,7 +1419,7 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None r_requests.append(rq) # Main execute() logic starts here - record_uids = set() # type: Set[str] + record_uids = set() # type: set[str] folder_uids = set() record_pattern = '' @@ -1475,7 +1476,7 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None continue record_uids.add(record_uid) - pam_records = [] # type: List[vault.TypedRecord] + pam_records = [] # type: list[vault.TypedRecord] valid_record_types = ['pamDatabase', 'pamDirectory', 'pamMachine', 'pamUser', 'pamRemoteBrowser'] for record_uid in record_uids: record = vault.KeeperRecord.load(params, record_uid) @@ -1502,7 +1503,7 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None if cfg_rec and cfg_rec.version == 6 and cfg_rec.record_type in PamConfigurationEditMixin.PAM_CONFIG_RECORD_TYPES: config_uid = cfg_rec.record_uid - pam_config = None # type: Optional[vault.TypedRecord] + pam_config = None # type: vault.TypedRecord | None if config_uid: if config_uid in pam_configurations: pam_config = pam_configurations[config_uid] @@ -1519,7 +1520,7 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None schedule_data = parse_schedule_data(kwargs) pwd_complexity = kwargs.get("pwd_complexity") - pwd_complexity_rule_list = None # type: Optional[dict] + pwd_complexity_rule_list = None # type: dict | None if pwd_complexity is not None: if pwd_complexity: pwd_complexity_list = [s.strip() for s in pwd_complexity.split(',', maxsplit=5)] @@ -1560,7 +1561,7 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None 'complexity'] valid_records = [] - r_requests = [] # type: List[router_pb2.RouterRecordRotationRequest] + r_requests = [] # type: list[router_pb2.RouterRecordRotationRequest] # Note: --folder, -fd FOLDER_NAME sets up General rotation # use --schedule-only, -so to preserve individual setups (General, IAM, NOOP) @@ -1618,7 +1619,7 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None config_user(tmp_dag, _record, resource_uid, config_uid, silent=kwargs.get('silent')) elif rotation_profile == 'saas': - saas_config_uid = kwargs.get("saas_config_uid") # type: Optional[str] + saas_config_uid = kwargs.get("saas_config_uid") # type: str | None if saas_config_uid is None: raise CommandError('', 'SaaS rotation profile requires ' '--saas-config-uid to be specified.') @@ -2248,7 +2249,7 @@ def _allowed_settings_dag_to_json(allowed): @staticmethod def _domain_administrative_credential_uid(configuration): - # type: (vault.KeeperRecord) -> Optional[str] + # type: (vault.KeeperRecord) -> str | None if not isinstance(configuration, vault.TypedRecord) or \ configuration.record_type != 'pamDomainConfiguration': return None @@ -2532,7 +2533,7 @@ def get_pam_record_types(params): return PamConfigurationEditMixin.pam_record_types def parse_pam_configuration(self, params, record, **kwargs): - # type: (KeeperParams, vault.TypedRecord, Dict[str, Any]) -> None + # type: (KeeperParams, vault.TypedRecord, dict[str, Any]) -> None field = record.get_typed_field('pamResources') if not field: value = {} @@ -2543,8 +2544,8 @@ def parse_pam_configuration(self, params, record, **kwargs): field.value.append({}) value = field.value[0] - gateway_uid = None # type: Optional[str] - gateway = kwargs.get('gateway_uid') # type: Optional[str] + gateway_uid = None # type: str | None + gateway = kwargs.get('gateway_uid') # type: str | None if gateway: gateways = gateway_helper.get_all_gateways(params) gateway_uid = next((utils.base64_url_encode(x.controllerUid) for x in gateways @@ -2561,8 +2562,8 @@ def parse_pam_configuration(self, params, record, **kwargs): # if len(shares) == 0: # raise Exception(f'Gateway %s has no shared folders', gateway.controllerName) - shared_folder_uid = None # type: Optional[str] - folder_name = kwargs.get('shared_folder_uid') # type: Optional[str] + shared_folder_uid = None # type: str | None + folder_name = kwargs.get('shared_folder_uid') # type: str | None if folder_name: shared_folder_uid = resolve_pam_folder_uid(params, folder_name) if not shared_folder_uid: @@ -2621,7 +2622,7 @@ def parse_pam_configuration(self, params, record, **kwargs): @staticmethod def resolve_single_record(params, record_name, - rec_type=''): # type: (KeeperParams, str, str) -> Optional[vault.KeeperRecord] + rec_type=''): # type: (KeeperParams, str, str) -> vault.KeeperRecord | None return resolve_pam_record(params, record_name, rec_type=rec_type or None) @staticmethod @@ -3125,7 +3126,7 @@ def get_parser(self): @classmethod def _resolve_pam_config_uid(cls, params, identifier): - # type: (KeeperParams, str) -> Optional[str] + # type: (KeeperParams, str) -> str | None if identifier in params.record_cache: rec = vault.KeeperRecord.load(params, identifier) if isinstance(rec, vault.TypedRecord) and rec.version == 6: @@ -3473,7 +3474,7 @@ def execute(self, params, **kwargs): if not record_name: raise CommandError('rotate script', '"record" argument is required') - script_name = kwargs.get('script') # type: Optional[str] + script_name = kwargs.get('script') # type: str | None if not script_name: raise CommandError('rotate script', '"script" argument is required') @@ -3558,7 +3559,7 @@ def execute(self, params, **kwargs): if not record_name: raise CommandError('rotate script', '"record" argument is required') - script_name = kwargs.get('script') # type: Optional[str] + script_name = kwargs.get('script') # type: str | None if not script_name: raise CommandError('rotate script', '"script" argument is required') @@ -3696,9 +3697,10 @@ def _is_rotation_allowed_by_enforcement(params): class PAMGatewayActionRotateCommand(Command): parser = argparse.ArgumentParser(prog='pam action rotate') - parser.add_argument('--record-uid', '-r', dest='record_uid', action='store', help='Record UID to rotate') + parser.add_argument('--record-uid', '-r', dest='record_uid', action='store', + help='Record UID, path, or title to rotate (includes Nested Share Records)') parser.add_argument('--folder', '-f', dest='folder', action='store', - help='Shared folder UID or title pattern to rotate') + help='Shared folder / Nested Share Folder UID or title pattern to rotate') # parser.add_argument('--recursive', '-a', dest='recursive', default=False, action='store', help='Enable recursion to rotate sub-folders too') # parser.add_argument('--record-pattern', '-p', dest='pattern', action='store', help='Record title match pattern') parser.add_argument('--dry-run', '-n', dest='dry_run', default=False, action='store_true', @@ -3716,6 +3718,12 @@ class PAMGatewayActionRotateCommand(Command): parser.add_argument('--email-message', dest='email_message', action='store', help='Custom message to include in email') + _ROTATABLE_FOLDER_TYPES = ( + BaseFolderNode.SharedFolderType, + BaseFolderNode.SharedFolderFolderType, + BaseFolderNode.NestedShareFolderType, + ) + def get_parser(self): return PAMGatewayActionRotateCommand.parser @@ -3768,39 +3776,55 @@ def execute(self, params, **kwargs): f'the following arguments are required: {bcolors.OKBLUE}--record-uid/-r{bcolors.ENDC} or {bcolors.OKBLUE}--folder/-f{bcolors.ENDC}') return - # single record UID - ignore all folder options + # single record - ignore all folder options (NSF-aware UID/path/title resolution) if not folder: - self.record_rotate(params, record_uid) + rec = resolve_pam_record(params, record_uid) + self.record_rotate(params, rec.record_uid if rec else record_uid) return # folder UID or pattern (ignore --record-uid/-r option) folders = [] # root folders matching UID or title pattern records = [] # record UIDs of all v3/pamUser records + rotatable_types = PAMGatewayActionRotateCommand._ROTATABLE_FOLDER_TYPES - # 1. find all shared_folder/shared_folder_folder matching --folder=UID/pattern + # 1. find shared_folder / shared_folder_folder / nested_share_folder matching --folder if folder in params.folder_cache: # folder UID fldr = params.folder_cache.get(folder) - # only shared_folder can be shared to KSM App/Gateway for rotation - # but its children shared_folder_folder can contain rotation records too - if fldr.type in (BaseFolderNode.SharedFolderType, BaseFolderNode.SharedFolderFolderType): + # shared_folder (and NSF) can be shared to KSM App/Gateway for rotation; + # children shared_folder_folder / NSF subfolders can contain rotation records too + if fldr.type in rotatable_types or is_nested_share_folder(params, folder): folders.append(folder) else: - logging.debug(f'Folder skipped (not a shared folder/subfolder) - {folder} {fldr.name}') + logging.debug(f'Folder skipped (not a shared folder/subfolder/NSF) - {folder} {fldr.name}') + elif is_nested_share_folder(params, folder): + folders.append(folder) else: - rx_name = self.str_to_regex(folder) - for fuid in params.folder_cache: - fldr = params.folder_cache.get(fuid) - # requirement - shared folder only (not for user_folder containing shf w/ recursion) - if fldr.type in (BaseFolderNode.SharedFolderType, BaseFolderNode.SharedFolderFolderType): - if fldr.name and rx_name.search(fldr.name): - folders.append(fldr.uid) + resolved = resolve_pam_folder_uid(params, folder) + if resolved: + folders.append(resolved) + else: + rx_name = self.str_to_regex(folder) + for fuid in params.folder_cache: + fldr = params.folder_cache.get(fuid) + # shared folder / NSF only (not user_folder containing shf w/ recursion) + if fldr.type in rotatable_types or is_nested_share_folder(params, fuid): + if fldr.name and rx_name.search(fldr.name): + folders.append(fldr.uid) + for fuid, nsf in getattr(params, 'nested_share_folders', {}).items(): + if fuid in folders: + continue + name = nsf.get('name', '') if isinstance(nsf, dict) else '' + if name and rx_name.search(name): + folders.append(fuid) folders = list(set(folders)) # Remove duplicate UIDs # 2. pattern could match both parent and child - drop all children (w/ a matching parent) if recursive and len(folders) > 1: - roots: dict[str, list] = {} # group by shared_folder_uid + roots: dict[str, list] = {} # group by shared_folder_uid (or NSF root) for fuid in folders: # no shf inside shf yet - roots.setdefault(params.folder_cache.get(fuid).shared_folder_uid, []).append(fuid) + fobj = params.folder_cache.get(fuid) + root_uid = getattr(fobj, 'shared_folder_uid', None) or fuid + roots.setdefault(root_uid, []).append(fuid) uniq = [] for fuid in roots: fldrs = list(set(roots[fuid])) @@ -3813,9 +3837,12 @@ def execute(self, params, **kwargs): for fldr in fldrs: path = [] child = fldr - while params.folder_cache[child].uid != fuid: + while child in params.folder_cache and params.folder_cache[child].uid != fuid: path.append(child) - child = params.folder_cache[child].parent_uid + parent = params.folder_cache[child].parent_uid + if not parent: + break + child = parent path.append(child) # add root shf path = path[1:] if path else [] # skip child uid if not set(path) & fldrset: # no intersect @@ -3827,12 +3854,13 @@ def execute(self, params, **kwargs): if recursive: logging.warning('--recursive/-a option not implemented (ignored)') # params.folder_cache: type=shared_folder_folder, uid=shffUID, shared_folder_uid ='shfUID' - # params.subfolder_cache/subfolder_record_cache + # params.subfolder_cache/subfolder_record_cache / nested_share_folder_records - if fldr not in params.subfolder_record_cache: - logging.debug(f"folder {fldr} empty - not in subfolder_record_cache (skipped)") + folder_records = records_in_folder(params, fldr) + if not folder_records: + logging.debug(f"folder {fldr} empty - no records in folder caches (skipped)") continue - for ruid in params.subfolder_record_cache[fldr]: + for ruid in folder_records: if ruid in params.record_cache: if params.record_cache[ruid].get('version') == 3: data = params.record_cache[ruid].get('data_unencrypted', '') @@ -3849,9 +3877,13 @@ def execute(self, params, **kwargs): for fldr in folders: fobj = params.folder_cache.get(fldr, None) title = fobj.name if isinstance(fobj, BaseFolderNode) else '' + if not title: + nsf = getattr(params, 'nested_share_folders', {}).get(fldr) + if isinstance(nsf, dict): + title = nsf.get('name', '') logging.debug(f'Rotation Folder UID: {fldr} {title}') for rec in records: - title = json.loads(params.record_cache.get(rec, {}).get('data_unencrypted', '')).get('title', '') + title, _ = get_vault_record_title_type(params, rec) logging.debug(f'Rotation Record UID: {rec} {title}') # 6. exit if --dry-run diff --git a/keepercommander/commands/pam/vault_target.py b/keepercommander/commands/pam/vault_target.py index 45d28e589..dae5e96cb 100644 --- a/keepercommander/commands/pam/vault_target.py +++ b/keepercommander/commands/pam/vault_target.py @@ -340,13 +340,11 @@ def resolve_pam_record(params, identifier, rec_type=None): folder, record_title = rs if folder is not None and record_title is not None: folder_uid = folder.uid or '' - subfolder_cache = getattr(params, 'subfolder_record_cache', None) or {} - if folder_uid in subfolder_cache: - for uid in subfolder_cache[folder_uid]: - record = vault.KeeperRecord.load(params, uid) - if record and record.title.casefold() == record_title.casefold(): - if _record_matches_type(record, rec_type): - return record + for uid in records_in_folder(params, folder_uid): + record = vault.KeeperRecord.load(params, uid) + if record and record.title.casefold() == record_title.casefold(): + if _record_matches_type(record, rec_type): + return record l_name = identifier.casefold() matches = [] diff --git a/keepercommander/commands/pam_debug/__init__.py b/keepercommander/commands/pam_debug/__init__.py index eaa0276d3..14e1e17e7 100644 --- a/keepercommander/commands/pam_debug/__init__.py +++ b/keepercommander/commands/pam_debug/__init__.py @@ -1,7 +1,9 @@ from __future__ import annotations from ...utils import value_to_boolean import os -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING + +from ..pam_import.record_loader import load_pam_record # noqa: F401 — re-export for pam_debug modules if TYPE_CHECKING: from ...params import KeeperParams diff --git a/keepercommander/commands/pam_debug/acl.py b/keepercommander/commands/pam_debug/acl.py index a85f49286..3b1430646 100644 --- a/keepercommander/commands/pam_debug/acl.py +++ b/keepercommander/commands/pam_debug/acl.py @@ -4,10 +4,10 @@ from ..discover import (PAMGatewayActionDiscoverCommandBase, GatewayContext, PAM_USER, MultiConfigurationException, multi_conf_msg) from ...display import bcolors -from ... import vault +from . import load_pam_record from ...discovery_common.record_link import RecordLink from ...discovery_common.types import UserAcl -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from ...vault import TypedRecord @@ -59,7 +59,7 @@ def execute(self, params: KeeperParams, **kwargs): logger=logging, debug_level=debug_level, use_per_graph_endpoints=False) - user_record = vault.KeeperRecord.load(params, user_uid) # type: Optional[TypedRecord] + user_record = load_pam_record(params, user_uid) # type: TypedRecord | None if user_record is None: print(f"{bcolors.FAIL}The user record does not exists.{bcolors.ENDC}") return @@ -70,7 +70,7 @@ def execute(self, params: KeeperParams, **kwargs): print(f"{bcolors.FAIL}The user record is not a PAM User record.{bcolors.ENDC}") return - parent_record = vault.KeeperRecord.load(params, parent_uid) # type: Optional[TypedRecord] + parent_record = load_pam_record(params, parent_uid) # type: TypedRecord | None if parent_record is None: print(f"{bcolors.FAIL}The parent record does not exists.{bcolors.ENDC}") return diff --git a/keepercommander/commands/pam_debug/dump.py b/keepercommander/commands/pam_debug/dump.py index 5231a8049..ed6a202de 100644 --- a/keepercommander/commands/pam_debug/dump.py +++ b/keepercommander/commands/pam_debug/dump.py @@ -5,16 +5,19 @@ import json import logging import pathlib -from typing import Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import TYPE_CHECKING from ..base import Command, FolderMixin from ...subfolder import get_folder_uids -from ... import vault, api +from ... import vault from ...keeper_dag import DAG, EdgeType from ...keeper_dag.types import GRAPH_ID_TO_ENDPOINT, PamGraphId +from ...nested_share_folder.common import get_record_from_cache, get_record_key +from ..pam.vault_target import resolve_pam_folder_uid from ..pam_import.keeper_ai_settings import get_resource_settings +from ..pam_import.nsf_helpers import get_folder_record_uids from ...keeper_dag.crypto import decrypt_aes -from . import get_connection +from . import get_connection, load_pam_record if TYPE_CHECKING: from ...params import KeeperParams @@ -58,8 +61,14 @@ def _write_result(data: list) -> None: fh.write(json.dumps(data, indent=2)) logging.info('Saved %d record(s) to %s', len(data), p) - # 1. Resolve folder UID(s) from UID or path + # 1. Resolve folder UID(s) from UID or path (classic + NSF) folder_uids = get_folder_uids(params, folder_uid_arg) + if not folder_uids: + resolved = resolve_pam_folder_uid(params, folder_uid_arg) + if resolved: + folder_uids = {resolved} + elif folder_uid_arg in getattr(params, 'nested_share_folders', {}): + folder_uids = {folder_uid_arg} if not folder_uids: logging.warning('Cannot resolve folder: %r', folder_uid_arg) _write_result([]) @@ -67,27 +76,48 @@ def _write_result(data: list) -> None: # 2. Collect records with folder context # record_uid → (folder_uid, folder_parent_uid) - record_folder_map: Dict[str, Tuple[str, str]] = {} + record_folder_map: dict[str, tuple[str, str]] = {} + + def _folder_parent_uid(f_uid: str) -> str: + if not f_uid: + return '' + folder_node = params.folder_cache.get(f_uid) + if folder_node is not None: + return getattr(folder_node, 'parent_uid', None) or '' + nsf = getattr(params, 'nested_share_folders', {}).get(f_uid) or {} + return nsf.get('parent_uid') or '' if recursive: def _on_folder(f): f_uid = f.uid or '' f_parent_uid = getattr(f, 'parent_uid', None) or '' - for rec_uid in params.subfolder_record_cache.get(f_uid, set()): + for rec_uid in get_folder_record_uids(params, f_uid): if rec_uid not in record_folder_map: record_folder_map[rec_uid] = (f_uid, f_parent_uid) for fuid in folder_uids: FolderMixin.traverse_folder_tree(params, fuid, _on_folder) + # NSF folders may not yet be reconstructed into folder_cache nodes; + # walk nested_share_folders children as a fallback. + nsf_folders = getattr(params, 'nested_share_folders', {}) or {} + if fuid in nsf_folders or any(v.get('parent_uid') == fuid for v in nsf_folders.values()): + stack = [fuid] + seen = set() + while stack: + current = stack.pop() + if not current or current in seen: + continue + seen.add(current) + for rec_uid in get_folder_record_uids(params, current): + if rec_uid not in record_folder_map: + record_folder_map[rec_uid] = (current, _folder_parent_uid(current)) + for child_uid, child in nsf_folders.items(): + if child.get('parent_uid') == current and child_uid not in seen: + stack.append(child_uid) else: for fuid in folder_uids: - if fuid: - folder_node = params.folder_cache.get(fuid) - f_parent_uid = getattr(folder_node, 'parent_uid', None) or '' if folder_node else '' - else: - # root folder has no parent - f_parent_uid = '' - for rec_uid in params.subfolder_record_cache.get(fuid, set()): + f_parent_uid = _folder_parent_uid(fuid) + for rec_uid in get_folder_record_uids(params, fuid): if rec_uid not in record_folder_map: record_folder_map[rec_uid] = (fuid, f_parent_uid) @@ -98,17 +128,22 @@ def _on_folder(f): # 3. Filter by version, then group valid records by config_uid. # Supported versions: 3 (typed), 5 (KSM App/Gateway), 6 (PAM Configuration). # Versions 1–2/4 are legacy/attachment records; skip with a warning. - config_to_records: Dict[str, List[str]] = {} - record_config_map: Dict[str, Optional[str]] = {} - valid_uids: List[str] = [] # passed version filter, in discovery order + config_to_records: dict[str, list[str]] = {} + record_config_map: dict[str, str | None] = {} + valid_uids: list[str] = [] # passed version filter, in discovery order for rec_uid in record_folder_map: - rec = params.record_cache.get(rec_uid) + rec = get_record_from_cache(params, rec_uid) if rec is None: - logging.warning('skipping record %s version unknown - not in record cache', rec_uid) - continue + loaded = load_pam_record(params, rec_uid) + if loaded is None: + logging.warning('skipping record %s version unknown - not in record cache', rec_uid) + continue + version = getattr(loaded, 'version', None) + rec = {'version': version, 'revision': 0, 'shared': False} + else: + version = rec.get('version') - version = rec.get('version') if version is None or version <= 2: logging.warning( 'skipping record %s version %s - PAM records have version >= 3', @@ -157,11 +192,11 @@ def _on_folder(f): # 4. Load all 5 DAGs once per config_uid # keyed by (config_uid, graph_id) - dag_cache: Dict[Tuple[str, int], Optional['DAGType']] = {} + dag_cache: dict[tuple[str, int], 'DAGType' | None] = {} conn = get_connection(params) for config_uid in config_to_records: - config_record = vault.KeeperRecord.load(params, config_uid) + config_record = load_pam_record(params, config_uid) if config_record is None: logging.error('Configuration record %s not found; skipping graph load.', config_uid) for graph_id in ALL_GRAPH_IDS: @@ -185,10 +220,11 @@ def _on_folder(f): for rec_uid in valid_uids: folder_uid, folder_parent_uid = record_folder_map[rec_uid] - rec = params.record_cache[rec_uid] # guaranteed present after step 3 - version = rec.get('version') - shared = rec.get('shared', False) - revision = rec.get('revision', 0) + rec = get_record_from_cache(params, rec_uid) or {} + nsf_meta = getattr(params, 'nested_share_records', {}).get(rec_uid) or {} + version = rec.get('version') or nsf_meta.get('version') + shared = rec.get('shared', nsf_meta.get('shared', False)) + revision = rec.get('revision', nsf_meta.get('revision', 0)) client_modified_time = None cmt = rec.get('client_modified_time') @@ -205,15 +241,29 @@ def _on_folder(f): 'revision': revision, } - # data - same structure as `get --format=json` + # data - same structure as `get --format=json` (classic + NSF) data = {} try: - r = api.get_record(params, rec_uid) - if r: - raw = rec.get('data_unencrypted', b'{}') + raw = rec.get('data_unencrypted') + if raw: data = json.loads(raw.decode() if isinstance(raw, bytes) else raw) - if r.notes: - data['notes'] = r.notes + else: + nsf_data = getattr(params, 'nested_share_record_data', {}).get(rec_uid) or {} + data = dict(nsf_data.get('data_json') or {}) + if not data: + loaded = load_pam_record(params, rec_uid) + if loaded is not None: + from ... import vault_extensions + if isinstance(loaded, vault.TypedRecord): + data = vault_extensions.extract_typed_record_data(loaded) + notes = getattr(loaded, 'notes', None) + if notes: + data['notes'] = notes + else: + loaded = load_pam_record(params, rec_uid) + notes = getattr(loaded, 'notes', None) if loaded else None + if notes: + data['notes'] = notes except Exception as err: logging.warning('Could not build data for record %s: %s', rec_uid, err) @@ -224,7 +274,7 @@ def _on_folder(f): # "vertex_active": bool - present when the record UID is a vertex in that graph # "edges": [...] - present only when there are active, non-deleted edges # Config/graph keys are omitted when the record has no presence there. - graph_sync: Dict[str, Dict[str, dict]] = {} + graph_sync: dict[str, dict[str, dict]] = {} for (c_uid, graph_id), dag in dag_cache.items(): if dag is None: continue @@ -273,7 +323,7 @@ def _collect_graph_entry(dag: 'DAGType', record_uid: str, params: 'KeeperParams' def _collect_edges_for_record(dag: 'DAGType', record_uid: str, params: 'KeeperParams', - config_uid: str) -> List[dict]: + config_uid: str) -> list[dict]: """Return all non-deleted edges that reference record_uid as head or tail. Inactive edges (active=False) are included - they may represent settings @@ -304,8 +354,13 @@ def _collect_edges_for_record(dag: 'DAGType', record_uid: str, params: 'KeeperPa pwd_complexity_enc = rotation_settings.get('pwd_complexity') if pwd_complexity_enc and isinstance(pwd_complexity_enc, str): for uid in (head_uid, tail_uid): - raw_rec = params.record_cache.get(uid) or {} + raw_rec = get_record_from_cache(params, uid) or {} rec_key = raw_rec.get('record_key_unencrypted') + if not rec_key: + try: + rec_key = get_record_key(params, uid, raise_on_missing=False) + except Exception: + rec_key = None if not rec_key: continue try: diff --git a/keepercommander/commands/pam_debug/graph.py b/keepercommander/commands/pam_debug/graph.py index b19003010..9b6e56f33 100644 --- a/keepercommander/commands/pam_debug/graph.py +++ b/keepercommander/commands/pam_debug/graph.py @@ -4,7 +4,7 @@ import logging from ..discover import PAMGatewayActionDiscoverCommandBase, GatewayContext, MultiConfigurationException, multi_conf_msg from ...display import bcolors -from ... import vault +from . import load_pam_record from ...discovery_common.infrastructure import Infrastructure from ...discovery_common.record_link import RecordLink from ...discovery_common.jobs import Jobs @@ -14,16 +14,14 @@ DiscoveryDatabase, JobContent, ServiceAcl) from ...discovery_common.dag_sort import sort_infra_vertices from ...keeper_dag import DAG -from ...keeper_dag.connection.commander import Connection as CommanderConnection -from ...keeper_dag.connection.local import Connection as LocalConnection from ...keeper_dag.types import GRAPH_ID_TO_ENDPOINT, PamEndpoints from ...keeper_dag.vertex import DAGVertex -from ...keeper_dag.edge import DAGEdge, EdgeType -from typing import Optional, Union, TYPE_CHECKING +from ...keeper_dag.edge import EdgeType +from typing import TYPE_CHECKING -Connection = Union[CommanderConnection, LocalConnection] if TYPE_CHECKING: from ...params import KeeperParams + from ...vault import TypedRecord class PAMDebugGraphCommand(PAMGatewayActionDiscoverCommandBase): @@ -100,7 +98,7 @@ def _do_text_list_infra(self, params: KeeperParams, gateway_context: GatewayCont 3: self._b } - def _handle(current_vertex: DAGVertex, indent: int = 0, last_record_type: Optional[str] = None): + def _handle(current_vertex: DAGVertex, indent: int = 0, last_record_type: str | None = None): if not current_vertex.active: return @@ -120,7 +118,7 @@ def _handle(current_vertex: DAGVertex, indent: int = 0, last_record_type: Option if current_content.record_uid is None: text += f"{pad}{ls}{current_vertex.uid}; {current_content.title} does not have a record." else: - record = vault.KeeperRecord.load(params, current_content.record_uid) # type: Optional[TypedRecord] + record = load_pam_record(params, current_content.record_uid) # type: TypedRecord | None if record is not None: text += f"{pad}{ls}" + cf(f"{current_vertex.uid}; {record.title}; {record.record_uid}") else: @@ -165,7 +163,7 @@ def _do_text_list_rl(self, params: KeeperParams, gateway_context: GatewayContext debug_level=debug_level, use_per_graph_endpoints=False) configuration = record_link.dag.get_root - record = vault.KeeperRecord.load(params, configuration.uid) # type: Optional[TypedRecord] + record = load_pam_record(params, configuration.uid) # type: TypedRecord | None if record is None: print(self._f("Configuration record does not exists.")) return @@ -193,7 +191,7 @@ def _group(configuration_vertex: DAGVertex) -> dict: } for vertex in configuration_vertex.has_vertices(): - record = vault.KeeperRecord.load(params, vertex.uid) # type: Optional[TypedRecord] + record = load_pam_record(params, vertex.uid) # type: TypedRecord | None if record is None: group[PAMDebugGraphCommand.NO_RECORD].append({ "v": vertex @@ -263,7 +261,7 @@ def _group(configuration_vertex: DAGVertex) -> dict: if len(children) > 0: bad = [] for child in children: - child_record = vault.KeeperRecord.load(params, child.uid) # type: Optional[TypedRecord] + child_record = load_pam_record(params, child.uid) # type: TypedRecord | None if child_record is None: if child.active: bad.append(self._f(f"- Record UID {child.uid} does not exists.")) @@ -329,7 +327,7 @@ def _do_text_list_service(self, params: KeeperParams, gateway_context: GatewayCo if not resource_vertex.active: continue - machine_record = vault.KeeperRecord.load(params, resource_vertex.uid) # type: Optional[TypedRecord] + machine_record = load_pam_record(params, resource_vertex.uid) # type: TypedRecord | None if machine_record is None or machine_record.record_type != PAM_MACHINE: continue @@ -342,7 +340,7 @@ def _do_text_list_service(self, params: KeeperParams, gateway_context: GatewayCo if not user_vertex.active: continue - user_record = vault.KeeperRecord.load(params, user_vertex.uid) # type: Optional[TypedRecord] + user_record = load_pam_record(params, user_vertex.uid) # type: TypedRecord | None acl = record_link.get_acl(parent_record_uid=resource_vertex.uid, record_uid=user_vertex.uid) if acl is not None and acl.controls_services: if resource_vertex.uid not in machine_dict: @@ -365,7 +363,7 @@ def _do_text_list_service(self, params: KeeperParams, gateway_context: GatewayCo if not us_machine_vertex.active: continue - machine_record = vault.KeeperRecord.load(params, us_machine_vertex.uid) # type: Optional[TypedRecord] + machine_record = load_pam_record(params, us_machine_vertex.uid) # type: TypedRecord | None if machine_record is not None: machine_name = f"{machine_record.title}, {machine_record.record_uid}" else: @@ -381,7 +379,7 @@ def _do_text_list_service(self, params: KeeperParams, gateway_context: GatewayCo if service_acl is None: continue - user_record = vault.KeeperRecord.load(params, us_user_vertex.uid) # type: Optional[TypedRecord] + user_record = load_pam_record(params, us_user_vertex.uid) # type: TypedRecord | None if us_machine_vertex.uid not in machine_dict: machine_dict[us_machine_vertex.uid] = { @@ -613,7 +611,7 @@ def _do_raw_text_list(self, params: KeeperParams, gateway_context: GatewayContex 3: self._p } - def _handle(current_vertex: DAGVertex, last_vertex: Optional[DAGVertex] = None, indent: int = 0): + def _handle(current_vertex: DAGVertex, last_vertex: DAGVertex | None = None, indent: int = 0): pad = "" if indent > 0: diff --git a/keepercommander/commands/pam_debug/info.py b/keepercommander/commands/pam_debug/info.py index 1707a0a9d..a89ddbae7 100644 --- a/keepercommander/commands/pam_debug/info.py +++ b/keepercommander/commands/pam_debug/info.py @@ -2,7 +2,7 @@ import argparse from ..discover import PAMGatewayActionDiscoverCommandBase, GatewayContext from ...display import bcolors -from ... import vault, vault_extensions +from . import load_pam_record from ...discovery_common.infrastructure import Infrastructure from ...discovery_common.record_link import RecordLink from ...discovery_common.types import UserAcl, DiscoveryObject @@ -11,7 +11,7 @@ import time import re import json -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from ...vault import TypedRecord @@ -38,7 +38,7 @@ def get_parser(self): def execute(self, params: KeeperParams, **kwargs): record_uid = kwargs.get("record_uid") - record = vault.KeeperRecord.load(params, record_uid) # type: Optional[TypedRecord] + record = load_pam_record(params, record_uid) # type: TypedRecord | None if record is None: print(f"{bcolors.FAIL}Record does not exists.{bcolors.ENDC}") return @@ -85,7 +85,7 @@ def execute(self, params: KeeperParams, **kwargs): resource_uid = record_rotation.get("resource_uid") - configuration_record = vault.KeeperRecord.load(params, controller_uid) # type: Optional[TypedRecord] + configuration_record = load_pam_record(params, controller_uid) # type: TypedRecord | None if configuration_record is None: print(f"{bcolors.FAIL}The configuration record {controller_uid} does not exist.{bcolors.ENDC}") return @@ -170,8 +170,8 @@ def _print_field(f): if len(record_parent_vertices) > 0: for record_parent_vertex in record_parent_vertices: - parent_record = vault.KeeperRecord.load(params, - record_parent_vertex.uid) # type: Optional[TypedRecord] + parent_record = load_pam_record(params, + record_parent_vertex.uid) # type: TypedRecord | None if parent_record is None: print(f"{bcolors.FAIL} * Parent record {record_parent_vertex.uid} " f"does not exists.{bcolors.ENDC}") @@ -214,9 +214,9 @@ def _print_field(f): if len(acl_content.rotation_settings.saas_record_uid_list) > 0: if acl_content.rotation_settings.noop: saas_config_uid = acl_content.rotation_settings.saas_record_uid_list[0] - saas_config = vault.KeeperRecord.load( + saas_config = load_pam_record( params, - saas_config_uid) # type: Optional[TypedRecord] + saas_config_uid) # type: TypedRecord | None print(f" . SaaS configuration record is {saas_config.title}") else: @@ -239,8 +239,8 @@ def _print_field(f): print(self._b(" Child Records")) if len(record_child_vertices) > 0: for record_child_vertex in record_child_vertices: - child_record = vault.KeeperRecord.load(params, - record_child_vertex.uid) # type: Optional[TypedRecord] + child_record = load_pam_record(params, + record_child_vertex.uid) # type: TypedRecord | None if child_record is None: print(f"{bcolors.FAIL} * Child record {record_child_vertex.uid} " @@ -297,8 +297,8 @@ def _print_field(f): and acl.controls_services): # Get the resource record - machine_record = vault.KeeperRecord.load(params, - machine_vertex.uid) # type: Optional[TypedRecord] + machine_record = load_pam_record(params, + machine_vertex.uid) # type: TypedRecord | None # If the resource record does not exist. if machine_record is None: @@ -333,7 +333,7 @@ def _print_field(f): # Get the users that are used for tasks/services on this machine. for user_vertex in record_vertex.has_vertices(): - user_record = vault.KeeperRecord.load(params, user_vertex.uid) # type: Optional[TypedRecord] + user_record = load_pam_record(params, user_vertex.uid) # type: TypedRecord | None acl = record_link.get_acl(record_vertex.uid, user_vertex.uid) if acl is not None and acl.controls_services: # If the user record does not exist. diff --git a/keepercommander/commands/pam_debug/krouter.py b/keepercommander/commands/pam_debug/krouter.py index a8afaf644..1f4409916 100644 --- a/keepercommander/commands/pam_debug/krouter.py +++ b/keepercommander/commands/pam_debug/krouter.py @@ -1,7 +1,6 @@ from __future__ import annotations import argparse import json -import os from typing import TYPE_CHECKING import requests diff --git a/keepercommander/commands/pam_debug/link.py b/keepercommander/commands/pam_debug/link.py index b8bddba8d..deae2697a 100644 --- a/keepercommander/commands/pam_debug/link.py +++ b/keepercommander/commands/pam_debug/link.py @@ -4,9 +4,9 @@ from ..discover import (PAMGatewayActionDiscoverCommandBase, GatewayContext, PAM_MACHINE, PAM_DATABASE, PAM_DIRECTORY, MultiConfigurationException, multi_conf_msg) from ...display import bcolors -from ... import vault +from . import load_pam_record from ...discovery_common.record_link import RecordLink -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from ...vault import TypedRecord @@ -55,7 +55,7 @@ def execute(self, params: KeeperParams, **kwargs): logger=logging, debug_level=debug_level, use_per_graph_endpoints=False) - resource_record = vault.KeeperRecord.load(params, resource_uid) # type: Optional[TypedRecord] + resource_record = load_pam_record(params, resource_uid) # type: TypedRecord | None if resource_record is None: print(f"{bcolors.FAIL}The parent record does not exists.{bcolors.ENDC}") return diff --git a/keepercommander/commands/pam_debug/rotation_setting.py b/keepercommander/commands/pam_debug/rotation_setting.py index 2557f8a15..0a4c44462 100644 --- a/keepercommander/commands/pam_debug/rotation_setting.py +++ b/keepercommander/commands/pam_debug/rotation_setting.py @@ -2,7 +2,7 @@ import argparse from ..discover import PAMGatewayActionDiscoverCommandBase from ...display import bcolors -from ... import vault +from . import load_pam_record from ...proto import router_pb2 from ...sync_down import sync_down from keeper_secrets_manager_core.utils import url_safe_str_to_bytes @@ -11,7 +11,7 @@ from ...discovery_common.constants import PAM_USER, PAM_MACHINE, PAM_DATABASE, PAM_DIRECTORY from ...discovery_common.types import UserAcl, UserAclRotationSettings import re -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from ...vault import TypedRecord @@ -49,7 +49,7 @@ def execute(self, params: KeeperParams, **kwargs): print("") - user_record = vault.KeeperRecord.load(params, user_record_uid) # type: Optional[TypedRecord] + user_record = load_pam_record(params, user_record_uid) # type: TypedRecord | None if user_record is None: print(f"{bcolors.FAIL}The PAM user record does not exists.{bcolors.ENDC}") return @@ -68,8 +68,8 @@ def execute(self, params: KeeperParams, **kwargs): f"-c, --configuration-record-uid parameter for this command.{bcolors.ENDC}") return - configuration_record = vault.KeeperRecord.load(params, - configuration_record_uid) # type: Optional[TypedRecord] + configuration_record = load_pam_record(params, + configuration_record_uid) # type: TypedRecord | None if configuration_record is None: print(f"{bcolors.FAIL}Configuration record does not exists.{bcolors.ENDC}") return @@ -94,8 +94,8 @@ def execute(self, params: KeeperParams, **kwargs): if resource_record_uid is not None: - resource_record = vault.KeeperRecord.load(params, - resource_record_uid) # type: Optional[TypedRecord] + resource_record = load_pam_record(params, + resource_record_uid) # type: TypedRecord | None if resource_record is None: print(f"{bcolors.FAIL}The resource record does not exists.{bcolors.ENDC}") return @@ -142,8 +142,8 @@ def execute(self, params: KeeperParams, **kwargs): print(f"{bcolors.BOLD}Configuration Record UID{bcolors.ENDC}: {configuration_record_uid}") - configuration_record = vault.KeeperRecord.load(params, - configuration_record_uid) # type: Optional[TypedRecord] + configuration_record = load_pam_record(params, + configuration_record_uid) # type: TypedRecord | None if configuration_record is None: print(f"{bcolors.FAIL}Configuration record does not exists.{bcolors.ENDC}") return @@ -153,8 +153,8 @@ def execute(self, params: KeeperParams, **kwargs): print(f"{bcolors.BOLD}Resource Record UID{bcolors.ENDC}: {resource_record_uid}") - resource_record = vault.KeeperRecord.load(params, - resource_record_uid) # type: Optional[TypedRecord] + resource_record = load_pam_record(params, + resource_record_uid) # type: TypedRecord | None if resource_record is None: print(f"{bcolors.FAIL}The resource record does not exists.{bcolors.ENDC}") return diff --git a/keepercommander/commands/pam_debug/verify.py b/keepercommander/commands/pam_debug/verify.py index cabac24a3..8c1b27c40 100644 --- a/keepercommander/commands/pam_debug/verify.py +++ b/keepercommander/commands/pam_debug/verify.py @@ -3,7 +3,7 @@ import argparse from ..discover import PAMGatewayActionDiscoverCommandBase, GatewayContext, MultiConfigurationException, multi_conf_msg from ...display import bcolors -from ...vault import TypedRecord +from . import load_pam_record from ...discovery_common.verify import Verify import sys from typing import TYPE_CHECKING @@ -53,7 +53,7 @@ def execute(self, params: KeeperParams, **kwargs): return def _record_lookup(record_uid: str) -> KeeperRecord: - return TypedRecord.load(params, record_uid) + return load_pam_record(params, record_uid) colors = { Verify.OK: bcolors.OKGREEN, diff --git a/keepercommander/commands/pam_launch/launch.py b/keepercommander/commands/pam_launch/launch.py index 62ecb9ab4..4736bf61c 100644 --- a/keepercommander/commands/pam_launch/launch.py +++ b/keepercommander/commands/pam_launch/launch.py @@ -11,7 +11,6 @@ from __future__ import annotations import argparse -import os import ipaddress import logging import re @@ -21,7 +20,7 @@ import time from colorama import Fore, Style -from typing import TYPE_CHECKING, Dict, Any, Optional, Tuple +from typing import TYPE_CHECKING, Any from keeper_secrets_manager_core.utils import url_safe_str_to_bytes @@ -72,7 +71,7 @@ from ..pam.gateway_helper import get_all_gateways from ..pam.router_helper import router_get_connected_gateways from ..ssh_agent import try_extract_private_key -from ... import api, vault +from ... import vault from ...subfolder import try_resolve_path from ...error import CommandError from ...utils import value_to_boolean @@ -91,7 +90,7 @@ def _pam_connection_clipboard_bool(v: Any) -> bool: return b is True -def _pam_connection_font_size_int(raw: Any) -> Optional[int]: +def _pam_connection_font_size_int(raw: Any) -> int | None: """Parse pamSettings.connection.fontSize to int, or None if unset or not parseable as an integer size.""" if raw is None: return None @@ -115,7 +114,7 @@ def _pam_connection_font_size_int(raw: Any) -> Optional[int]: return None -def _parse_host_port(value: str) -> Tuple[str, int]: +def _parse_host_port(value: str) -> tuple[str, int]: """ Parse a 'host:port' or '[ipv6]:port' string into (host, port). @@ -177,7 +176,7 @@ def _iter_record_fields(record: Any): yield field -def _get_host_port_from_record(record: Any) -> Tuple[Optional[str], Optional[int]]: +def _get_host_port_from_record(record: Any) -> tuple[str | None, int | None]: """ Extract (hostName, port) from a record's pamHostname or host typed fields. @@ -223,7 +222,7 @@ def _get_host_port_from_record(record: Any) -> Tuple[Optional[str], Optional[int return candidates[0] -def _record_has_credentials(record: Any, params: Optional['KeeperParams'] = None) -> bool: +def _record_has_credentials(record: Any, params: 'KeeperParams' | None = None) -> bool: """ Return True if the record has exactly one non-empty login field and at least one of: - exactly one non-empty password field (fields[] and custom[]), or @@ -283,11 +282,11 @@ def _record_has_host_port(record: Any) -> bool: def _print_close_reason_notice( - reason: Optional[str], + reason: str | None, *, - pending_exit_code: Optional[int], + pending_exit_code: int | None, session_established: bool = False, -) -> Optional[int]: +) -> int | None: """Show a user-facing notice for an involuntary remote close. Stays silent for ``normal`` / ``client`` (user-initiated). Called from the @@ -490,18 +489,21 @@ def _is_valid_pam_record(self, params: KeeperParams, record_uid: str) -> bool: """ try: record = vault.KeeperRecord.load(params, record_uid) - if not isinstance(record, vault.TypedRecord): - return False - if record.version != 3: - return False - return record.record_type in self.VALID_PAM_RECORD_TYPES + if isinstance(record, vault.TypedRecord): + if record.version != 3: + return False + return record.record_type in self.VALID_PAM_RECORD_TYPES + # NSF fallback when the typed record is not in record_cache + from ..pam.vault_target import get_vault_record_title_type + _, rec_type = get_vault_record_title_type(params, record_uid) + return rec_type in self.VALID_PAM_RECORD_TYPES except Exception as e: logging.debug(f"Error checking record type for {record_uid}: {e}") return False - def find_record(self, params: KeeperParams, record_token: str) -> Optional[str]: + def find_record(self, params: KeeperParams, record_token: str) -> str | None: """ - Find a record by UID, path, or title. + Find a record by UID, path, or title (classic + Nested Share Folder records). Args: params: KeeperParams instance @@ -515,10 +517,12 @@ def find_record(self, params: KeeperParams, record_token: str) -> Optional[str]: record_token = record_token.strip() - # Step 1: Try UID lookup + from ..pam.vault_target import record_exists_in_vault + + # Step 1: Try UID lookup (classic + Nested Share Folder) uid_pattern = re.compile(r'^[A-Za-z0-9_-]{22}$') if uid_pattern.match(record_token): - if record_token in params.record_cache: + if record_exists_in_vault(params, record_token): logging.debug(f"Found record by UID: {record_token}") return record_token @@ -541,9 +545,9 @@ def find_record(self, params: KeeperParams, record_token: str) -> Optional[str]: return None - def _find_by_path(self, params: KeeperParams, path: str) -> Optional[str]: + def _find_by_path(self, params: KeeperParams, path: str) -> str | None: """ - Find record by path resolution. + Find record by path resolution (classic folders + Nested Share Folders). If exactly one record matches (any type), returns its UID. If two or more match, filters to PAM types only: returns the single PAM UID if one, @@ -552,24 +556,44 @@ def _find_by_path(self, params: KeeperParams, path: str) -> Optional[str]: Returns: Record UID if found, None otherwise """ - rs = try_resolve_path(params, path) - if rs is None: - return None + from ..pam.vault_target import records_in_folder, resolve_pam_folder_uid - folder, name = rs - if folder is None or name is None: + folder_uid = None + name = None + + rs = try_resolve_path(params, path) + if rs is not None: + folder, name = rs + if folder is not None and name is not None: + folder_uid = folder.uid or '' + + # NSF / unresolved path fallback: "FolderName/RecordTitle" or nested path + if (not folder_uid or name is None or name == '') and '/' in path.strip('/'): + parent, _, title = path.rstrip('/').rpartition('/') + if parent and title: + resolved_folder = resolve_pam_folder_uid(params, parent, allow_legacy_user=True) + if resolved_folder: + folder_uid = resolved_folder + name = title + + if not folder_uid or name is None or name == '': return None - folder_uid = folder.uid or '' - if folder_uid not in params.subfolder_record_cache: + folder_records = records_in_folder(params, folder_uid) + if not folder_records: return None # All records in folder with matching title (any type) all_matched = [] - for uid in params.subfolder_record_cache[folder_uid]: - r = api.get_record(params, uid) - if r and r.title and r.title.lower() == name.lower(): + for uid in folder_records: + record = vault.KeeperRecord.load(params, uid) + if record and record.title and record.title.lower() == name.lower(): all_matched.append(uid) + elif not record: + from ..pam.vault_target import get_vault_record_title_type + title, _ = get_vault_record_title_type(params, uid) + if title and title.lower() == name.lower() and title != '[record inaccessible]': + all_matched.append(uid) if len(all_matched) == 1: logging.debug(f"Found record by path: {path} -> {all_matched[0]}") @@ -594,9 +618,9 @@ def _find_by_path(self, params: KeeperParams, path: str) -> Optional[str]: return None - def _find_by_title(self, params: KeeperParams, title: str) -> Optional[str]: + def _find_by_title(self, params: KeeperParams, title: str) -> str | None: """ - Find record by exact title match. + Find record by exact title match (classic + Nested Share Folder records). If exactly one record matches (any type), returns its UID. If two or more match, filters to PAM types only: returns the single PAM UID if one, @@ -606,10 +630,27 @@ def _find_by_title(self, params: KeeperParams, title: str) -> Optional[str]: Record UID if found, None otherwise """ all_matched = [] + seen = set() + title_lower = title.lower() + for record_uid in params.record_cache: record = vault.KeeperRecord.load(params, record_uid) - if record and record.title and record.title.lower() == title.lower(): + if record and record.title and record.title.lower() == title_lower: all_matched.append(record_uid) + seen.add(record_uid) + + # NSF titles that may only be present in nested_share_record_data + nsf_data = getattr(params, 'nested_share_record_data', {}) or {} + for record_uid, rd in nsf_data.items(): + if record_uid in seen: + continue + data_json = rd.get('data_json', {}) if isinstance(rd, dict) else {} + if not isinstance(data_json, dict): + continue + nsf_title = data_json.get('title') or '' + if isinstance(nsf_title, str) and nsf_title.lower() == title_lower: + all_matched.append(record_uid) + seen.add(record_uid) if len(all_matched) == 1: logging.debug(f"Found record by title: {title} -> {all_matched[0]}") @@ -634,7 +675,7 @@ def _find_by_title(self, params: KeeperParams, title: str) -> Optional[str]: return None - def _find_by_substring(self, params: KeeperParams, token: str) -> Optional[str]: + def _find_by_substring(self, params: KeeperParams, token: str) -> str | None: """Substring fallback for ``find_record`` — case-insensitive contains match across PAM record titles and any ``host`` / ``pamHostname`` field. @@ -650,7 +691,12 @@ def _find_by_substring(self, params: KeeperParams, token: str) -> Optional[str]: token_lower = token.lower() # candidate tuple: (uid, title, [(hostName, port), ...]) candidates: list = [] - for record_uid in params.record_cache: + seen = set() + for record_uid in list(getattr(params, 'record_cache', {})) + list( + getattr(params, 'nested_share_records', {}) or {}): + if record_uid in seen: + continue + seen.add(record_uid) try: record = vault.KeeperRecord.load(params, record_uid) except Exception: @@ -686,7 +732,7 @@ def _find_by_substring(self, params: KeeperParams, token: str) -> Optional[str]: return self._pick_candidate(candidates, token) @staticmethod - def _pick_candidate(candidates: list, token: str) -> Optional[str]: + def _pick_candidate(candidates: list, token: str) -> str | None: """Render a numbered list of candidates and prompt for selection. On non-TTY stdin, prints the list once and returns None — caller @@ -734,8 +780,8 @@ def find_gateway( self, params: KeeperParams, record_uid: str, - tdag: Optional[Any] = None, - ) -> Optional[Dict]: + tdag: Any | None = None, + ) -> dict | None: """ Find the gateway associated with a PAM record. @@ -1002,7 +1048,7 @@ def execute(self, params: KeeperParams, **kwargs): # the cache contract. _cache_entry = launch_cache.get(record_uid) _launch_tdag = None # populated only on cache miss - _cached_gateway_info: Optional[Dict[str, Any]] = None + _cached_gateway_info: dict[str, Any] | None = None if _cache_entry is not None: # CACHE HIT: skip DAG build + find_gateway + online probe @@ -1407,7 +1453,7 @@ def _refresh_fetch(_params=params, _record_uid=record_uid, _self=self): _debug_connect_ui = bool(getattr(params, 'debug', False)) or logging.getLogger().isEnabledFor( logging.DEBUG ) - pre_connect_spinner: Optional[PamLaunchSpinner] = None + pre_connect_spinner: PamLaunchSpinner | None = None _banner_name_connect = (getattr(record, 'title', None) or record_token or record_uid or '').strip() or 'PAM resource' if not _debug_connect_ui: print(f'Launching connection to {_banner_name_connect}...', flush=True) @@ -1498,17 +1544,17 @@ def _refresh_fetch(_params=params, _record_uid=record_uid, _self=self): def _start_cli_session( self, - tunnel_result: Dict[str, Any], + tunnel_result: dict[str, Any], params: KeeperParams, - launch_credential_uid: Optional[str] = None, + launch_credential_uid: str | None = None, use_stdin: bool = False, - cli_scale: Optional[int] = None, - connect_banner_title: Optional[str] = None, - pre_connect_spinner: Optional[PamLaunchSpinner] = None, + cli_scale: int | None = None, + connect_banner_title: str | None = None, + pre_connect_spinner: PamLaunchSpinner | None = None, preserve_crlf: bool = True, - pam_total_tc: Optional[PamConnectTiming] = None, + pam_total_tc: PamConnectTiming | None = None, workflow_expires_on_ms: int = 0, - workflow_flow_uid: Optional[bytes] = None, + workflow_flow_uid: bytes | None = None, workflow_started_by_launch: bool = False, ): """ @@ -1569,7 +1615,7 @@ def _start_cli_session( # Latest close reason from the rust webrtc layer (snake_case name from # PyCloseConnectionReason). Set asynchronously by _on_session_disconnect # below; consumed in the inner finally to print a user-facing notice. - closure_reason: Optional[str] = None + closure_reason: str | None = None # Whether the guac session was live (≥1 sync) at the moment of remote # close. Captured in _on_session_disconnect; lets the notice treat a # guacd_error after an established session as a normal logout. @@ -1577,7 +1623,7 @@ def _start_cli_session( # Distinct exit code for involuntary terminations (KeeperAI, admin). # Raised as SystemExit at the end of the method so the inner/outer # finally cleanup blocks run first. - pending_exit_code: Optional[int] = None + pending_exit_code: int | None = None def signal_handler_fn(signum, frame): nonlocal shutdown_requested @@ -1679,7 +1725,7 @@ def _on_session_disconnect(reason: str) -> None: _debug_connect_ui = bool(getattr(params, 'debug', False)) or logging.getLogger().isEnabledFor( logging.DEBUG ) - _connect_spinner: Optional[PamLaunchSpinner] = pre_connect_spinner + _connect_spinner: PamLaunchSpinner | None = pre_connect_spinner if _connect_spinner is None and not _debug_connect_ui: _banner_name = (connect_banner_title or '').strip() or 'PAM resource' print(f'Launching connection to {_banner_name}...', flush=True) @@ -1954,7 +2000,7 @@ def _remote_key_ctrl_c() -> None: ) # No PasteOrchestrator when PAM disables paste — only Guacamole key chords (no pyperclip). - paste_orch: Optional[PasteOrchestrator] = None + paste_orch: PasteOrchestrator | None = None if not disable_paste: paste_orch = PasteOrchestrator( send_clipboard_fn=python_handler.send_clipboard_stream, diff --git a/keepercommander/commands/pam_saas/config.py b/keepercommander/commands/pam_saas/config.py index 1ecb51d1b..b27eff20e 100644 --- a/keepercommander/commands/pam_saas/config.py +++ b/keepercommander/commands/pam_saas/config.py @@ -4,15 +4,13 @@ from ..discover import PAMGatewayActionDiscoverCommandBase, GatewayContext, MultiConfigurationException, multi_conf_msg from ...display import bcolors from . import get_plugins_map, make_script_signature, SaasCatalog, get_field_input -from ... import api, subfolder, utils, crypto, vault, vault_extensions, attachment, record_management -from ...proto import record_pb2 -from ...api import get_records_add_request, sync_down -from ...error import KeeperApiError +from ... import utils, vault, attachment +from ...api import sync_down from tempfile import TemporaryDirectory import os import json import requests -from typing import Optional, List, TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from ...params import KeeperParams @@ -25,7 +23,7 @@ def __init__(self, configuration_uid: str, plugin_code: str, gateway_context: GatewayContext, - languages: Optional[List[str]] = None, + languages: list[str] | None = None, ): if languages is None: @@ -74,7 +72,8 @@ class PAMActionSaasConfigCommand(PAMGatewayActionDiscoverCommandBase): help='Update an existing SaaS configuration.') parser.add_argument('--shared-folder-uid', '-s', required=False, dest='shared_folder_uid', - action='store', help='Shared folder to store SaaS configuration.') + action='store', + help='Shared folder or Nested Share Folder UID/name to store SaaS configuration.') def get_parser(self): return PAMActionSaasConfigCommand.parser @@ -141,11 +140,49 @@ def _show_plugin_info(plugin: SaasCatalog): print(item) print("") + @staticmethod + def _resolve_target_folder(params: KeeperParams, gateway_context: GatewayContext, + shared_folder_uid: str | None) -> str | None: + from ..pam.vault_target import resolve_pam_folder_uid, pam_folder_exists + + shared_folders = gateway_context.get_shared_folders(params) + allowed_uids = {x.get('uid') for x in shared_folders if x.get('uid')} + + if shared_folder_uid is None: + if len(shared_folders) == 1: + return shared_folders[0].get('uid') + print("") + print(f"{bcolors.FAIL}Multiple shared folders found. " + f"Please use '-s' to select a shared folder.{bcolors.ENDC}") + if shared_folders: + print("Available folders:") + for sf in shared_folders: + print(f" * {sf.get('name') or sf.get('uid')} ({sf.get('uid')})") + return None + + resolved = resolve_pam_folder_uid(params, shared_folder_uid, allow_legacy_user=True) + if not resolved and pam_folder_exists(params, shared_folder_uid): + resolved = shared_folder_uid + if not resolved: + print("") + print(f"{bcolors.FAIL}Folder not found: {shared_folder_uid}{bcolors.ENDC}") + return None + + if resolved not in allowed_uids: + print("") + print(f"{bcolors.FAIL}The shared folder is not part of the gateway application.{bcolors.ENDC}") + return None + return resolved + @staticmethod def _create_config(params: KeeperParams, plugin: SaasCatalog, shared_folder_uid: str, - plugin_code_bytes: Optional[bytes] = None): + plugin_code_bytes: bytes | None = None): + from ..pam.vault_target import ( + create_record_in_folder, update_pam_record, is_nested_share_folder, + ) + from ..pam_import.nsf_helpers import sync_down_preserving_nsf_keys custom_fields = [ vault.TypedField.new_field( @@ -198,59 +235,18 @@ def _create_config(params: KeeperParams, for item in custom_fields: record.custom.append(item) - folder = params.folder_cache.get(shared_folder_uid) - folder_key = None # type: Optional[bytes] - if isinstance(folder, subfolder.SharedFolderFolderNode): - shared_folder_uid = folder.shared_folder_uid - elif isinstance(folder, subfolder.SharedFolderNode): - shared_folder_uid = folder.uid - else: - shared_folder_uid = None - if shared_folder_uid and shared_folder_uid in params.shared_folder_cache: - shared_folder = params.shared_folder_cache.get(shared_folder_uid) - folder_key = shared_folder.get('shared_folder_key_unencrypted') - - add_record = record_pb2.RecordAdd() - add_record.record_uid = utils.base64_url_decode(record.record_uid) - add_record.record_key = crypto.encrypt_aes_v2(record.record_key, params.data_key) - add_record.client_modified_time = utils.current_milli_time() - add_record.folder_type = record_pb2.user_folder - if folder: - add_record.folder_uid = utils.base64_url_decode(folder.uid) - if folder.type == 'shared_folder': - add_record.folder_type = record_pb2.shared_folder - elif folder.type == 'shared_folder_folder': - add_record.folder_type = record_pb2.shared_folder_folder - if folder_key: - add_record.folder_key = crypto.encrypt_aes_v2(record.record_key, folder_key) - - data = vault_extensions.extract_typed_record_data(record) - json_data = api.get_record_data_json_bytes(data) - add_record.data = crypto.encrypt_aes_v2(json_data, record.record_key) - - if params.enterprise_ec_key: - audit_data = vault_extensions.extract_audit_data(record) - if audit_data: - add_record.audit.version = 0 - add_record.audit.data = crypto.encrypt_ec( - json.dumps(audit_data).encode('utf-8'), params.enterprise_ec_key) - - rq = get_records_add_request(params) - rq.records.append(add_record) - rs = api.communicate_rest(params, rq, 'vault/records_add', rs_type=record_pb2.RecordsModifyResponse) - record_rs = next((x for x in rs.records if utils.base64_url_encode(x.record_uid) == record.record_uid), None) - if record_rs: - if record_rs.status != record_pb2.RS_SUCCESS: - raise KeeperApiError(record_rs.status, rs.message) - record.revision = rs.revision - - params.sync_data = True + create_record_in_folder( + params, record, folder_uid=shared_folder_uid, command='pam action saas config', + ) # If this is not a built-in or custom script, we need to attach it to the config record. if plugin_code_bytes is not None and plugin.file_name: with TemporaryDirectory() as temp_dir: - sync_down(params) + if is_nested_share_folder(params, shared_folder_uid): + sync_down_preserving_nsf_keys(params) + else: + sync_down(params) existing_record = vault.TypedRecord.load(params, record.record_uid) # type: TypedRecord if existing_record is None: @@ -272,14 +268,8 @@ def _create_config(params: KeeperParams, attachment.upload_attachments(params, existing_record, [task]) - record.fields = [ - vault.TypedField.new_field( - field_type="fileRef", - field_value=list(existing_record.linked_keys.keys())) - ] - - record_management.update_record(params, existing_record) - params.sync_data = True + # upload_attachments updates fileRef on existing_record via facade + update_pam_record(params, existing_record, command='pam action saas config') print("") print(f"{bcolors.OKGREEN}Created SaaS configuration record with UID of {record.record_uid}{bcolors.ENDC}") @@ -296,9 +286,9 @@ def execute(self, params: KeeperParams, **kwargs): do_update = kwargs.get("do_update", False) # type: bool shared_folder_uid = kwargs.get("shared_folder_uid") # type: str - use_plugin = kwargs.get("plugin") # type: Optional[str] + use_plugin = kwargs.get("plugin") # type: str | None gateway = kwargs.get("gateway") # type: str - configuration_uid = kwargs.get('configuration_uid') # type Optional[str] + configuration_uid = kwargs.get('configuration_uid') # type: str | None try: gateway_context = GatewayContext.from_gateway(params=params, @@ -332,17 +322,10 @@ def execute(self, params: KeeperParams, **kwargs): elif do_create: - shared_folders = gateway_context.get_shared_folders(params) - if shared_folder_uid is None: - if len(shared_folders) == 1: - shared_folder_uid = shared_folders[0].get("uid") - else: - print("") - print(f"{bcolors.FAIL}Multiple shared folders found. " - f"Please use '-s' to select a shared folder.{bcolors.ENDC}") - if next((x for x in shared_folders if x.get("uid") == shared_folder_uid), None) is None: - print("") - print(f"{bcolors.FAIL}The shared folder is not part of the gateway application.{bcolors.ENDC}") + shared_folder_uid = self._resolve_target_folder( + params, gateway_context, shared_folder_uid, + ) + if not shared_folder_uid: return # For catalog plugins, we need to download the python file from GitHub. diff --git a/unit-tests/pam/test_pam_debug_nsf.py b/unit-tests/pam/test_pam_debug_nsf.py new file mode 100644 index 000000000..b68666192 --- /dev/null +++ b/unit-tests/pam/test_pam_debug_nsf.py @@ -0,0 +1,182 @@ +import json +import tempfile +import unittest +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import keepercommander.commands.record # noqa: F401 + +from keepercommander import utils, vault +from keepercommander.commands.discover import GatewayContext +from keepercommander.commands.pam_debug import load_pam_record +from keepercommander.commands.pam_debug.acl import PAMDebugACLCommand +from keepercommander.commands.pam_debug.dump import PAMDebugDumpCommand +from keepercommander.commands.pam_debug.link import PAMDebugLinkCommand +from keepercommander.subfolder import NestedShareFolderNode, RootFolderNode + + +def _typed(uid, title, record_type='pamUser', version=3): + rec = vault.TypedRecord(version=version) + rec.record_uid = uid + rec.title = title + rec.type_name = record_type + return rec + + +def _params(): + folder = NestedShareFolderNode() + folder.uid = 'nsf_folder' + folder.name = 'pamFolder - Resources' + folder.parent_uid = None + folder.subfolders = [] + return SimpleNamespace( + folder_cache={'nsf_folder': folder}, + shared_folder_cache={}, + nested_share_folders={ + 'nsf_folder': {'name': 'pamFolder - Resources', 'parent_uid': None}, + }, + nested_share_folder_records={'nsf_folder': {'machine_uid', 'user_uid'}}, + nested_share_records={ + 'machine_uid': {'version': 3, 'revision': 1, 'shared': False}, + 'user_uid': {'version': 3, 'revision': 1, 'shared': False}, + 'config_uid': {'version': 6, 'revision': 1, 'shared': False}, + }, + nested_share_record_data={ + 'machine_uid': { + 'data_json': {'type': 'pamMachine', 'title': 'NSF Machine', 'fields': []}, + }, + 'user_uid': { + 'data_json': {'type': 'pamUser', 'title': 'NSF User', 'fields': []}, + }, + 'config_uid': { + 'data_json': { + 'type': 'pamNetworkConfiguration', + 'title': 'NSF Config', + 'fields': [{'type': 'pamResources', 'value': [{'folderUid': 'nsf_folder'}]}], + }, + }, + }, + record_cache={}, + subfolder_record_cache={}, + record_rotation_cache={}, + root_folder=RootFolderNode(), + environment_variables={}, + ) + + +class TestPamDebugNsf(unittest.TestCase): + + def test_load_pam_record_resolves_nsf_machine(self): + params = _params() + rec = load_pam_record(params, 'machine_uid') + self.assertIsNotNone(rec) + self.assertEqual(rec.record_uid, 'machine_uid') + self.assertEqual(rec.title, 'NSF Machine') + self.assertEqual(rec.record_type, 'pamMachine') + + def test_acl_uses_load_pam_record_for_nsf_uids(self): + params = _params() + user = _typed('user_uid', 'NSF User', 'pamUser') + parent = _typed('machine_uid', 'NSF Machine', 'pamMachine') + gw = MagicMock() + gw.configuration = _typed('config_uid', 'NSF Config', 'pamNetworkConfiguration', version=6) + gw.configuration_uid = 'config_uid' + + with patch('keepercommander.commands.pam_debug.acl.GatewayContext.from_gateway', return_value=gw), \ + patch('keepercommander.commands.pam_debug.acl.RecordLink') as rl_cls, \ + patch('keepercommander.commands.pam_debug.acl.load_pam_record', + side_effect=[user, parent]) as load, \ + patch('builtins.input', side_effect=['n', 'n']): + rl = rl_cls.return_value + rl.get_acl.return_value = None + rl.get_admin_record_uid.return_value = None + rl.acl_has_belong_to_record_uid.return_value = None + rl.dag.get_vertex.return_value = MagicMock() + PAMDebugACLCommand().execute( + params, gateway='gw', user_uid='user_uid', parent_uid='machine_uid') + + self.assertEqual(load.call_count, 2) + self.assertEqual(load.call_args_list[0].args[1], 'user_uid') + self.assertEqual(load.call_args_list[1].args[1], 'machine_uid') + + def test_link_uses_load_pam_record_for_nsf_resource(self): + params = _params() + parent = _typed('machine_uid', 'NSF Machine', 'pamMachine') + gw = MagicMock() + gw.configuration = _typed('config_uid', 'NSF Config', 'pamNetworkConfiguration', version=6) + gw.configuration_uid = 'config_uid' + + with patch('keepercommander.commands.pam_debug.link.GatewayContext.from_gateway', return_value=gw), \ + patch('keepercommander.commands.pam_debug.link.RecordLink') as rl_cls, \ + patch('keepercommander.commands.pam_debug.link.load_pam_record', return_value=parent) as load: + rl = rl_cls.return_value + PAMDebugLinkCommand().execute(params, gateway='gw', resource_uid='machine_uid') + rl.belongs_to.assert_called_once() + rl.save.assert_called_once() + self.assertEqual(load.call_args.args[1], 'machine_uid') + + def test_dump_collects_nsf_folder_records(self): + params = _params() + with tempfile.TemporaryDirectory() as tmp: + out = f'{tmp}/dump.json' + with patch('keepercommander.commands.pam_debug.dump.get_connection'), \ + patch('keepercommander.commands.pam_debug.dump.DAG'): + PAMDebugDumpCommand().execute( + params, + folder_uid='nsf_folder', + recursive=False, + save_as=out, + ) + with open(out, encoding='utf-8') as fh: + data = json.loads(fh.read()) + + uids = {row['uid'] for row in data} + self.assertEqual(uids, {'machine_uid', 'user_uid'}) + titles = {row['data'].get('title') for row in data} + self.assertEqual(titles, {'NSF Machine', 'NSF User'}) + + def test_gateway_context_includes_nsf_shared_folders(self): + folder_uid = 'zytjEAw5RTUJsF-PPx9wqA' + params = _params() + params.nested_share_folders = { + folder_uid: {'name': 'pamFolder - Resources', 'parent_uid': None}, + } + facade = MagicMock() + facade.folder_uid = folder_uid + gateway = MagicMock() + gateway.applicationUid = b'\x01' * 16 + gateway.controllerUid = b'\x02' * 16 + gateway.controllerName = 'gw' + ctx = GatewayContext( + configuration=_typed('config_uid', 'NSF Config', 'pamNetworkConfiguration', version=6), + facade=facade, + gateway=gateway, + application=MagicMock(), + ) + + share = MagicMock() + share.secretUid = utils.base64_url_decode(folder_uid) + share.shareType = 1 + app_info = MagicMock() + app_info.shares = [share] + + with patch('keepercommander.commands.discover.KSMCommand.get_app_info', return_value=[app_info]), \ + patch('keepercommander.commands.discover.APIRequest_pb2.ApplicationShareType.Name', + return_value='SHARE_TYPE_FOLDER'): + folders = ctx.get_shared_folders(params) + + self.assertEqual(len(folders), 1) + self.assertEqual(folders[0]['uid'], folder_uid) + self.assertEqual(folders[0]['name'], 'pamFolder - Resources') + + def test_gateway_context_loads_nsf_configuration_records(self): + params = _params() + with patch('keepercommander.commands.discover.vault_extensions.find_records', return_value=[]): + configs = GatewayContext.get_configuration_records(params) + + self.assertTrue(any(c.record_uid == 'config_uid' for c in configs)) + self.assertTrue(any(c.record_type == 'pamNetworkConfiguration' for c in configs)) + + +if __name__ == '__main__': + unittest.main() From 51e6a8f20c157467ab77678fd20d7934670c8711 Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Thu, 23 Jul 2026 18:34:00 +0530 Subject: [PATCH 06/16] Return enterprise user IDs in verbose mode and add tree --format=json with share permissions (#2240) * fix: Return IDs for node/teams/roles in enterprise-info --users -v * Implement format json, nsf folder and all records share-perms for tree * Fix share-permissions for classic share-folder --------- Co-authored-by: sshrushanth-ks --- keepercommander/commands/enterprise.py | 21 +- keepercommander/commands/folder.py | 944 ++++++++++++++++-- .../nested_share_folder/__init__.py | 5 + .../nested_share_folder/acl_cache.py | 230 +++++ .../nested_share_folder/record_api.py | 59 +- keepercommander/nested_share_folder/sync.py | 8 + keepercommander/params.py | 5 + .../service/util/parse_keeper_response.py | 61 +- unit-tests/service/test_response_parser.py | 49 +- unit-tests/test_command_enterprise.py | 23 + unit-tests/test_nsf_acl_cache.py | 473 +++++++++ 11 files changed, 1732 insertions(+), 146 deletions(-) create mode 100644 keepercommander/nested_share_folder/acl_cache.py create mode 100644 unit-tests/test_nsf_acl_cache.py diff --git a/keepercommander/commands/enterprise.py b/keepercommander/commands/enterprise.py index 512c4fce1..c1f592b9e 100644 --- a/keepercommander/commands/enterprise.py +++ b/keepercommander/commands/enterprise.py @@ -781,6 +781,7 @@ def tree_node(node): user_teams[enterprise_user_id].add(team_uid) displayed_columns = [x for x in supported_columns if x in columns] + is_verbose = kwargs.get('verbose') or False rows = [] for u in users.values(): user_status_dict = get_user_status_dict(u) @@ -796,12 +797,19 @@ def tree_node(node): elif column == 'transfer_status': row.append(user_status_dict['acct_transfer_status']) elif column == 'node': - row.append(self.get_node_path(params, u['node_id'])) + if is_verbose: + row.append(str(u['node_id'])) + else: + row.append(self.get_node_path(params, u['node_id'])) elif column == 'team_count': row.append(len([1 for t in teams.values() if t['users'] and user_id in t['users']])) elif column == 'teams': - team_names = [t["name"] for t in teams.values() if t['users'] and user_id in t['users']] - row.append(team_names) + user_team_list = [t for t in teams.values() + if t['users'] and user_id in t['users']] + if is_verbose: + row.append([t['id'] for t in user_team_list]) + else: + row.append([t['name'] for t in user_team_list]) elif column == 'role_count' or column == 'roles': role_ids = set() if user_id in user_roles: @@ -813,8 +821,11 @@ def tree_node(node): if column == 'role_count': row.append(len(role_ids)) else: - role_names = [roles[role_id]['name'] for role_id in role_ids if role_id in roles] - row.append(role_names) + if is_verbose: + row.append([str(role_id) for role_id in role_ids if role_id in roles]) + else: + role_names = [roles[role_id]['name'] for role_id in role_ids if role_id in roles] + row.append(role_names) elif column == 'alias': row.append([x['username'] for x in params.enterprise.get('user_aliases', []) if x['enterprise_user_id'] == user_id and x['username'] != email]) diff --git a/keepercommander/commands/folder.py b/keepercommander/commands/folder.py index 84a1ef7d5..180ea64bb 100644 --- a/keepercommander/commands/folder.py +++ b/keepercommander/commands/folder.py @@ -81,12 +81,16 @@ def register_command_info(aliases, command_info): cd_parser.exit = suppress_exit -tree_parser = argparse.ArgumentParser(prog='tree', description='Display the folder structure.') +tree_parser = argparse.ArgumentParser(prog='tree', description='Display the folder structure.', + parents=[base.json_output_parser]) tree_parser.add_argument('-v', '--verbose', dest='verbose', action='store_true', help='print ids') -tree_parser.add_argument('-r', '--records', action='store_true', help='show records within each folder') -show_shares_help = 'show share permissions info (shown in parentheses) for each shared folder' -tree_parser.add_argument('-s', '--shares', action='store_true', help=show_shares_help) -perms_key_help = 'hide share permissions key (valid only when used with --shares flag, which shows key by default)' +tree_parser.add_argument('-r', '--records', action='store_true', + help='show records within each folder (includes record type)') +tree_parser.add_argument('-s', '--shares', action='store_true', + help='show classic shared-folder permissions; with -r also classic record shares') +tree_parser.add_argument('-ns', '--nsf-shares', dest='nsf_shares', action='store_true', + help='show NSF folder permissions (ACL API); with -r also NSF record shares') +perms_key_help = 'hide share permissions key (valid with --shares / --nsf-shares)' tree_parser.add_argument('-hk', '--hide-shares-key', action='store_true', help=perms_key_help) tree_parser.add_argument('-t', '--title', action='store', help='show optional title for folder structure') tree_parser.add_argument('folder', nargs='?', type=str, action='store', help='folder path or UID') @@ -422,18 +426,40 @@ def execute(self, params, **kwargs): verbose = kwargs.get('verbose', False) records = kwargs.get('records') shares = kwargs.get('shares') - hide_key = kwargs.get('hide_shares_key', not shares) + nsf_shares = kwargs.get('nsf_shares') + fmt = kwargs.get('format') or 'table' + show_key = bool(shares or nsf_shares) + hide_key = kwargs.get('hide_shares_key', not show_key) title = kwargs.get('title') + trees = [] if folder_name in params.folder_cache: folder = params.folder_cache.get(folder_name) - formatted_tree(params, folder, verbose=verbose, show_records=records, shares=shares, hide_shares_key=hide_key, title=title) + trees.append(formatted_tree( + params, folder, verbose=verbose, show_records=records, shares=shares, + nsf_shares=nsf_shares, hide_shares_key=hide_key, title=title, fmt=fmt)) else: folders, pattern = try_resolve_path(params, folder_name, find_all_matches=True) if not pattern: for idx, folder in enumerate(folders): - formatted_tree(params, folder, verbose=verbose, show_records=records, shares=shares, hide_shares_key=hide_key or idx > 0, title=title) + trees.append(formatted_tree( + params, folder, verbose=verbose, show_records=records, shares=shares, + nsf_shares=nsf_shares, hide_shares_key=hide_key or idx > 0, title=title, + fmt=fmt)) else: raise CommandError('tree', f'Folder {folder_name} not found') + if fmt == 'json': + payload = trees[0] if len(trees) == 1 else {'trees': [t for t in trees if t]} + text = _tree_json_dumps(payload) + output = kwargs.get('output') + if output: + _, ext = os.path.splitext(output) + path = output if ext else output + '.json' + with open(path, 'w', encoding='utf-8') as fd: + fd.write(text) + fd.write('\n') + logging.info('Report path: %s', os.path.abspath(path)) + return None + return text class FolderRenameCommand(Command): @@ -1779,65 +1805,727 @@ def add_subfolders(folder): # type: (BaseFolderNode) -> None api.sync_down(params) -def formatted_tree(params, folder, verbose=False, show_records=False, shares=False, hide_shares_key=False, title=None): - def print_share_permissions_key(): - perms_key = 'Share Permissions Key:\n' \ - '======================\n' \ - 'RO = Read-Only\n' \ - 'MU = Can Manage Users\n' \ - 'MR = Can Manage Records\n' \ - 'CE = Can Edit\n' \ - 'CS = Can Share\n' \ - '======================\n' - print(perms_key) - - def get_share_info(node): - MU_KEY = 'manage_users' - MR_KEY = 'manage_records' - DMR_KEY = 'default_manage_records' - DMU_KEY = 'default_manage_user' - DCE_KEY = 'default_can_edit' - DCS_KEY = 'default_can_share' - perm_abbrev_lookup = {MU_KEY: 'MU', MR_KEY: 'MR', DMR_KEY: 'MU', DMU_KEY: 'MU', DCE_KEY: 'CE', DCS_KEY: 'CS'} - - def get_users_info(users): - info = [] - for u in users: - email = u.get('username') - if email == params.user: +def _resolve_tree_team_name(params, team_uid): + if not team_uid: + return '' + team = (getattr(params, 'team_cache', None) or {}).get(team_uid) or {} + name = team.get('name') if isinstance(team, dict) else getattr(team, 'name', None) + if name: + return name + if params.enterprise: + for t in params.enterprise.get('teams') or []: + if t.get('team_uid') == team_uid: + name = t.get('name') + if name: + return name + return team_uid + + +def _resolve_sf_member_email(params, user_entry): + """Resolve a shared-folder user row to an email (never a raw account UID).""" + email = (user_entry.get('username') or '').strip() + if email and '@' in email: + return email + account_uid = user_entry.get('account_uid') or '' + if account_uid: + cached = (getattr(params, 'user_cache', None) or {}).get(account_uid) + if cached and '@' in str(cached): + return str(cached) + if params.enterprise: + for u in params.enterprise.get('users') or []: + if u.get('user_account_uid') == account_uid and u.get('username'): + return u.get('username') + return email if email and '@' in email else '' + + +def _is_compact_share_entry(obj): + """True for leaf share objects like {email, permissions} / {name, uid, permissions}.""" + if not isinstance(obj, dict) or not obj: + return False + allowed = {'email', 'name', 'uid', 'permissions'} + if set(obj.keys()) - allowed: + return False + for k, v in obj.items(): + if k == 'permissions': + if not isinstance(v, list) or not all(isinstance(x, str) for x in v): + return False + elif not isinstance(v, str): + return False + return True + + +def _dump_compact_share_entry(obj): + """Dump a share entry on one line; keep field order name/email, uid, permissions.""" + parts = [] + for key in ('name', 'email', 'uid', 'permissions'): + if key not in obj: + continue + val = obj[key] + if key == 'permissions': + inner = ', '.join(json.dumps(x) for x in val) + parts.append(f'"{key}": [{inner}]') + else: + parts.append(f'"{key}": {json.dumps(val)}') + return '{ ' + ', '.join(parts) + ' }' + + +def _tree_json_dumps(obj, level=0, indent=2): + """Pretty JSON with share entries / permission arrays kept on one line.""" + sp = ' ' * (indent * level) + sp1 = ' ' * (indent * (level + 1)) + if isinstance(obj, dict): + if not obj: + return '{}' + if _is_compact_share_entry(obj): + return _dump_compact_share_entry(obj) + lines = ['{'] + items = list(obj.items()) + for i, (k, v) in enumerate(items): + comma = ',' if i < len(items) - 1 else '' + dumped = _tree_json_dumps(v, level + 1, indent) + lines.append(f'{sp1}{json.dumps(k)}: {dumped}{comma}') + lines.append(sp + '}') + return '\n'.join(lines) + if isinstance(obj, list): + if not obj: + return '[]' + if all(isinstance(x, str) for x in obj): + return '[' + ', '.join(json.dumps(x) for x in obj) + ']' + if all(_is_compact_share_entry(x) for x in obj): + lines = ['['] + for i, x in enumerate(obj): + comma = ',' if i < len(obj) - 1 else '' + lines.append(f'{sp1}{_dump_compact_share_entry(x)}{comma}') + lines.append(sp + ']') + return '\n'.join(lines) + lines = ['['] + for i, x in enumerate(obj): + comma = ',' if i < len(obj) - 1 else '' + dumped = _tree_json_dumps(x, level + 1, indent) + lines.append(f'{sp1}{dumped}{comma}') + lines.append(sp + ']') + return '\n'.join(lines) + return json.dumps(obj) + + +_NSF_ROLE_ABBREV = { + 'viewer': 'VW', + 'contributor': 'CT', + 'share-manager': 'SM', + 'content-manager': 'CM', + 'content-share-manager': 'CSM', + 'full-manager': 'FM', + 'unresolved': 'UN', + 'owner': 'OW', +} + + +def _nsf_role_label(accessor): + from .nested_share_folder.helpers import format_role_display, get_access_role_label + role = accessor.get('role') + if role: + return format_role_display(role) + return get_access_role_label(accessor) or 'viewer' + + +def _nsf_role_abbrev(accessor): + label = _nsf_role_label(accessor) + return _NSF_ROLE_ABBREV.get(label, (label[:2].upper() if label else 'VW')) + + +def _looks_like_uid(value): + if not value or not isinstance(value, str) or '@' in value: + return False + # Keeper UIDs are typically 22-char base64url + return bool(re.fullmatch(r'[A-Za-z0-9_-]{16,28}', value)) + + +def _resolve_nsf_user_email(params, accessor): + """Resolve AT_USER accessor to an email; never return a raw UID as email.""" + email = (accessor.get('username') or '').strip() + if email and not _looks_like_uid(email): + return email + auid = accessor.get('accessor_uid') or '' + if not auid: + return email if email and not _looks_like_uid(email) else '' + cached = getattr(params, 'user_cache', {}).get(auid) if hasattr(params, 'user_cache') else None + if cached: + return cached + if params.enterprise: + for u in params.enterprise.get('users') or []: + if u.get('user_account_uid') == auid and u.get('username'): + return u.get('username') + try: + from ..nested_share_folder.folder_api import _resolve_uid_to_username + resolved = _resolve_uid_to_username(params, auid) + if resolved: + if not hasattr(params, 'user_cache') or params.user_cache is None: + params.user_cache = {} + params.user_cache[auid] = resolved + return resolved + except Exception as exc: + logging.debug('NSF user resolve failed for %s: %s', auid, exc) + return '' + + +def _resolve_nsf_app_name(params, app_uid): + if not app_uid: + return '' + try: + from .ksm import KSMCommand + rec = KSMCommand.get_app_record(params, app_uid) + if rec: + data = rec.get('data_unencrypted') + if data: + if isinstance(data, (bytes, bytearray)): + data = data.decode('utf-8') + title = json.loads(data).get('title') + if title: + return title + except Exception as exc: + logging.debug('NSF app resolve via KSM failed for %s: %s', app_uid, exc) + if app_uid in (params.record_cache or {}): + try: + r = api.get_record(params, app_uid) + if r and getattr(r, 'title', None): + return r.title + except Exception as exc: + logging.debug('NSF app resolve via record cache failed for %s: %s', app_uid, exc) + return '' + + +def _nsf_folder_share_data(params, folder_uid, *, include_uids=False): + """Return structured NSF folder share perms and a compact text suffix. + + Owner is listed under ``users`` with permission ``OW`` (not a separate label). + Applications are listed separately (never as fake user emails / UIDs). + """ + from .. import nested_share_folder as _nsf + accessors = _nsf.get_nsf_folder_share_accessors(params, folder_uid) + folder_info = (getattr(params, 'nested_share_folders', {}) or {}).get(folder_uid) or {} + owner = (folder_info.get('owner_username') or '').strip() + if owner and _looks_like_uid(owner): + owner = '' + users = [] + teams = [] + applications = [] + user_parts = [] + team_parts = [] + app_parts = [] + seen_users = set() + + if owner: + users.append({'email': owner, 'permissions': ['OW']}) + user_parts.append(f'[{owner}:OW]') + seen_users.add(owner.lower()) + + for a in accessors: + at = a.get('access_type') or '' + abbrev = _nsf_role_abbrev(a) + auid = a.get('accessor_uid') or '' + + if at == 'AT_OWNER': + if not owner: + email = _resolve_nsf_user_email(params, a) + if email and email.lower() not in seen_users: + users.append({'email': email, 'permissions': ['OW']}) + user_parts.append(f'[{email}:OW]') + seen_users.add(email.lower()) + continue + + if at == 'AT_TEAM': + name = _resolve_tree_team_name(params, auid) + entry = {'name': name, 'permissions': [abbrev]} + if include_uids and auid: + entry['uid'] = auid + teams.append(entry) + team_parts.append(f'[{name}:{abbrev}]') + continue + + if at == 'AT_APPLICATION': + app_name = _resolve_nsf_app_name(params, auid) or (auid if include_uids else 'application') + entry = {'name': app_name, 'permissions': [abbrev]} + if include_uids and auid: + entry['uid'] = auid + applications.append(entry) + app_parts.append(f'[{app_name}:{abbrev}]') + continue + + if at in ('AT_USER', 'AT_UNKNOWN', ''): + email = _resolve_nsf_user_email(params, a) + if not email: + # Unresolved user: only expose UID when -v, never as email. + if include_uids and auid: + entry = {'uid': auid, 'permissions': [abbrev]} + users.append(entry) + user_parts.append(f'[{auid}:{abbrev}]') + continue + if email.lower() in seen_users or email == params.user: + continue + users.append({'email': email, 'permissions': [abbrev]}) + user_parts.append(f'[{email}:{abbrev}]') + seen_users.add(email.lower()) + + data = {} + if users: + data['users'] = users + if teams: + data['teams'] = teams + if applications: + data['applications'] = applications + if not data: + state = (getattr(params, 'nested_share_folder_sharing_states', {}) or {}).get(folder_uid) or {} + if state.get('shared') or state.get('count', 0) > 0: + data['shared'] = True + data['count'] = state.get('count', 0) + + parts = [] + if user_parts: + parts.append('users:' + ','.join(user_parts)) + if team_parts: + parts.append('teams:' + ','.join(team_parts)) + if app_parts: + parts.append('applications:' + ','.join(app_parts)) + if not parts and data.get('shared'): + parts.append(f'shared:count={data.get("count", 0)}') + text = f' ({"; ".join(parts)})' if parts else '' + return data or None, text + + +def _classic_folder_share_data(params, sf, *, include_uids=False): + """Return structured classic SF share perms and compact text suffix. + + Tree text uses a single ``default:`` blob (all default flags). + JSON splits folder-user defaults vs default record rights: + - user_permissions: MU / MR (default manage users / manage records) + - record_permissions: CE / CS (default can-edit / can-share on records) + Named people/teams remain under ``users`` / ``teams``. + """ + DEFAULT_USER_PERM_KEYS = { + 'default_manage_users': 'MU', + 'default_manage_user': 'MU', # legacy alias if present + 'default_manage_records': 'MR', + } + RECORD_PERM_KEYS = { + 'default_can_edit': 'CE', + 'default_can_share': 'CS', + } + MEMBER_PERM_KEYS = { + 'manage_users': 'MU', + 'manage_records': 'MR', + } + + sf = sf or {} + user_permissions = [abbr for key, abbr in DEFAULT_USER_PERM_KEYS.items() if sf.get(key)] + user_permissions = [a for a in ('MU', 'MR') if a in user_permissions] + record_permissions = [abbr for key, abbr in RECORD_PERM_KEYS.items() if sf.get(key)] + record_permissions = [a for a in ('CE', 'CS') if a in record_permissions] + + default_perms = user_permissions + record_permissions + if not default_perms: + default_perms = ['RO'] + + users = [] + user_parts = [] + seen_emails = set() + for u in sf.get('users') or []: + email = _resolve_sf_member_email(params, u) + account_uid = u.get('account_uid') or '' + privs = [abbr for key_name, abbr in MEMBER_PERM_KEYS.items() if u.get(key_name)] + privs = [a for a in ('MU', 'MR') if a in privs] or ['RO'] + if not email: + # Sync often has account_uid only until user_cache fills; still emit membership. + if account_uid: + entry = {'uid': account_uid, 'permissions': privs} + users.append(entry) + user_parts.append(f'[{account_uid}:{",".join(privs)}]') + continue + key = email.lower() + if key in seen_emails: + continue + seen_emails.add(key) + entry = {'email': email, 'permissions': privs} + if include_uids and account_uid: + entry['uid'] = account_uid + users.append(entry) + user_parts.append(f'[{email}:{",".join(privs)}]') + + teams = [] + team_parts = [] + seen_teams = set() + for t in sf.get('teams') or []: + team_uid = t.get('team_uid') or '' + if team_uid and team_uid in seen_teams: + continue + if team_uid: + seen_teams.add(team_uid) + name = (t.get('name') or '').strip() + if not name or _looks_like_uid(name): + name = _resolve_tree_team_name(params, team_uid) if team_uid else name + if not name: + if not team_uid: + continue + name = team_uid + privs = [abbr for key_name, abbr in MEMBER_PERM_KEYS.items() if t.get(key_name)] + privs = [a for a in ('MU', 'MR') if a in privs] or ['RO'] + entry = {'name': name, 'permissions': privs} + if include_uids and team_uid: + entry['uid'] = team_uid + teams.append(entry) + team_parts.append(f'[{name}:{",".join(privs)}]') + + data = { + 'user_permissions': user_permissions, + 'record_permissions': record_permissions, + } + if not user_permissions and not record_permissions: + data['record_permissions'] = ['RO'] + if users: + data['users'] = users + if teams: + data['teams'] = teams + + parts = ['default:' + ','.join(default_perms)] + if team_parts: + parts.append('teams:' + ','.join(team_parts)) + if user_parts: + parts.append('users:' + ','.join(user_parts)) + return data, f' ({"; ".join(parts)})' + + +def _classic_record_share_data(params, record_uid, *, include_uids=False): + """Classic record share perms → structured data + compact text. + + Only direct user shares are listed. Shared-folder inheritance is omitted: + the tree already places the record under its parent shared folder(s). + """ + rec = (params.record_cache or {}).get(record_uid) or {} + shares_data = rec.get('shares') or {} + users = [] + user_parts = [] + for up in shares_data.get('user_permissions') or []: + email = up.get('username') or '' + if not email: + continue + if up.get('owner'): + users.append({'email': email, 'permissions': ['OW']}) + user_parts.append(f'[{email}:OW]') + continue + if email == params.user: + continue + privs = [] + if up.get('editable'): + privs.append('CE') + if up.get('shareable'): + privs.append('CS') + if not privs: + privs = ['RO'] + users.append({'email': email, 'permissions': privs}) + user_parts.append(f'[{email}:{",".join(privs)}]') + if not users: + return None, '' + data = {'users': users} + text = f' (users:{",".join(user_parts)})' + return data, text + + +def _nsf_record_share_data(params, record_uid, *, include_uids=False): + """NSF record share perms → structured data + compact text. + + Uses warmed ``nested_share_record_share_cache``. Direct (non-inherited) + accessors win over folder-inherited rows for the same identity. When the + record ACL cache is empty, falls back to the parent NSF folder ACL. + """ + from .. import nested_share_folder as _nsf + accessors = list(_nsf.get_nsf_record_share_accessors(params, record_uid) or []) + if not accessors: + try: + parent_uids = _nsf.find_nested_share_folders_for_record(params, record_uid) or [] + except Exception as exc: + logging.debug('NSF parent folder lookup failed for %s: %s', record_uid, exc) + parent_uids = [] + for fuid in parent_uids: + data, text = _nsf_folder_share_data(params, fuid, include_uids=include_uids) + if data: + return data, text + return None, '' + + # Direct shares first so inherited folder rows do not hide them. + accessors.sort(key=lambda a: 1 if a.get('inherited') else 0) + + users = [] + teams = [] + applications = [] + user_parts = [] + team_parts = [] + app_parts = [] + seen_users = set() + seen_teams = set() + seen_apps = set() + + for a in accessors: + at = a.get('access_type') or '' + abbrev = _nsf_role_abbrev(a) + auid = a.get('access_type_uid') or a.get('accessor_uid') or '' + + if a.get('owner') or at == 'AT_OWNER': + email = (a.get('accessor_name') or '').strip() + if email and _looks_like_uid(email): + email = '' + if not email: + email = _resolve_nsf_user_email(params, { + 'username': a.get('accessor_name') or a.get('username'), + 'accessor_uid': auid, + }) + if email and email.lower() not in seen_users: + users.append({'email': email, 'permissions': ['OW']}) + user_parts.append(f'[{email}:OW]') + seen_users.add(email.lower()) + elif not email and include_uids and auid and auid not in seen_users: + users.append({'uid': auid, 'permissions': ['OW']}) + user_parts.append(f'[{auid}:OW]') + seen_users.add(auid) + continue + + if at == 'AT_TEAM': + if auid and auid in seen_teams: + continue + name = _resolve_tree_team_name(params, auid) + entry = {'name': name, 'permissions': [abbrev]} + if include_uids and auid: + entry['uid'] = auid + teams.append(entry) + team_parts.append(f'[{name}:{abbrev}]') + if auid: + seen_teams.add(auid) + continue + + if at == 'AT_APPLICATION': + if auid and auid in seen_apps: + continue + app_name = _resolve_nsf_app_name(params, auid) or (auid if include_uids else 'application') + entry = {'name': app_name, 'permissions': [abbrev]} + if include_uids and auid: + entry['uid'] = auid + applications.append(entry) + app_parts.append(f'[{app_name}:{abbrev}]') + if auid: + seen_apps.add(auid) + continue + + email = (a.get('accessor_name') or '').strip() + if email and _looks_like_uid(email): + email = '' + if not email: + email = _resolve_nsf_user_email(params, { + 'username': a.get('accessor_name') or a.get('username'), + 'accessor_uid': auid, + }) + if not email: + if include_uids and auid and auid not in seen_users: + users.append({'uid': auid, 'permissions': [abbrev]}) + user_parts.append(f'[{auid}:{abbrev}]') + seen_users.add(auid) + continue + if email.lower() in seen_users or email == params.user: + continue + users.append({'email': email, 'permissions': [abbrev]}) + user_parts.append(f'[{email}:{abbrev}]') + seen_users.add(email.lower()) + + data = {} + if users: + data['users'] = users + if teams: + data['teams'] = teams + if applications: + data['applications'] = applications + if not data: + try: + parent_uids = _nsf.find_nested_share_folders_for_record(params, record_uid) or [] + except Exception as exc: + logging.debug('NSF parent folder lookup failed for %s: %s', record_uid, exc) + parent_uids = [] + for fuid in parent_uids: + folder_data, folder_text = _nsf_folder_share_data( + params, fuid, include_uids=include_uids) + if folder_data: + return folder_data, folder_text + return None, '' + + parts = [] + if user_parts: + parts.append('users:' + ','.join(user_parts)) + if team_parts: + parts.append('teams:' + ','.join(team_parts)) + if app_parts: + parts.append('applications:' + ','.join(app_parts)) + text = f' ({"; ".join(parts)})' if parts else '' + return data, text + + +def _join_tree_path(parent_path, name): + name = name or '' + if not parent_path or parent_path == '/': + return '/' + name if name else '/' + return parent_path.rstrip('/') + '/' + name + + +def _collect_tree_share_targets(params, folder, show_records): + """Collect NSF folder UIDs and record UIDs under *folder* for ACL warming.""" + nsf_folder_uids = set() + classic_record_uids = set() + nsf_record_uids = set() + nsf_folders = getattr(params, 'nested_share_folders', {}) or {} + nsf_records = getattr(params, 'nested_share_records', {}) or {} + nsf_folder_records = getattr(params, 'nested_share_folder_records', {}) or {} + visited = set() + + def walk(node): + if isinstance(node, Record): + ruid = node.record_uid + if ruid in nsf_records: + nsf_record_uids.add(ruid) + else: + classic_record_uids.add(ruid) + return + + node_uid = node.uid if hasattr(node, 'uid') else '' + walk_key = node_uid or id(node) + if walk_key in visited: + return + visited.add(walk_key) + + is_nsf = ( + (hasattr(node, 'type') and node.type == 'nested_share_folder') + or (node_uid and node_uid in nsf_folders) + ) + if is_nsf and node_uid: + nsf_folder_uids.add(node_uid) + + dir_nodes = [] + if hasattr(node, 'subfolders'): + dir_nodes = [params.folder_cache.get(fuid) for fuid in node.subfolders if params.folder_cache.get(fuid)] + + is_root = (isinstance(node, BaseFolderNode) and (node.type == '/' or node_uid == '')) or ( + hasattr(node, 'type') and node.type == 'nested_share_folder' and not node_uid) + + if is_root and nsf_folders: + for nsf_uid, nsf_folder in nsf_folders.items(): + parent_uid = nsf_folder.get('parent_uid') + is_root_folder = ( + parent_uid is None or parent_uid == '' or parent_uid == 'root' + or parent_uid == 'AAAAAAAAAAAAAAAAAPmtNA' + or (parent_uid and parent_uid not in nsf_folders) + ) + if not is_root_folder: continue - privs = [v for k, v in perm_abbrev_lookup.items() if u.get(k)] or ['RO'] - info.append(f'[{email}:{",".join(privs)}]') - return 'users:' + ','.join(info) if info else '' + nsf_folder_uids.add(nsf_uid) + if nsf_uid in params.folder_cache: + dir_nodes.append(params.folder_cache.get(nsf_uid)) + else: + dir_nodes.append(type('FolderNode', (), { + 'uid': nsf_uid, 'name': nsf_folder.get('name', ''), + 'type': 'nested_share_folder', 'subfolders': [] + })()) + elif node_uid and nsf_folders: + for child_uid, child_folder in nsf_folders.items(): + if child_folder.get('parent_uid', '') == node_uid: + nsf_folder_uids.add(child_uid) + if child_uid in params.folder_cache: + dir_nodes.append(params.folder_cache.get(child_uid)) + else: + dir_nodes.append(type('FolderNode', (), { + 'uid': child_uid, 'name': child_folder.get('name', ''), + 'type': 'nested_share_folder', 'subfolders': [] + })()) - def get_teams_info(teams): - info = [] - for t in teams: - name = t.get('name') - privs = [v for k, v in perm_abbrev_lookup.items() if t.get(k)] or ['RO'] - info.append(f'[{name}:{",".join(privs)}]') - return 'teams:' + ','.join(info) if info else '' - - result = '' - if isinstance(node, SharedFolderNode): - sf = params.shared_folder_cache.get(node.uid) - teams_info = get_teams_info(sf.get('teams', [])) - users_info = get_users_info(sf.get('users', [])) - default_perms = [v for k, v in perm_abbrev_lookup.items() if sf.get(k)] or ['RO'] - default_perms = 'default:' + ','.join(default_perms) - user_perms = [v for k, v in perm_abbrev_lookup.items() if sf.get(k)] or ['RO'] - user_perms = 'user:' + ','.join(user_perms) - perms = [default_perms, user_perms, teams_info, users_info] - perms = [p for p in perms if p] - result = f' ({"; ".join(perms)})' if shares else '' - - return result - - def tree_node(node): + if show_records and isinstance(node, BaseFolderNode): + node_uid_for_recs = '' if node.type == '/' else node.uid + rec_uids = {rec for recs in get_contained_record_uids(params, node_uid_for_recs).values() for rec in recs} + for ruid in rec_uids: + if ruid in nsf_records: + nsf_record_uids.add(ruid) + else: + classic_record_uids.add(ruid) + if is_root: + shown = set(rec_uids) + for folder_uid, nsf_rec_uids in nsf_folder_records.items(): + if folder_uid not in nsf_folders: + for ruid in nsf_rec_uids: + if ruid not in shown: + nsf_record_uids.add(ruid) + shown.add(ruid) + all_filed = set() + for uids in nsf_folder_records.values(): + all_filed.update(uids) + for ruid in nsf_records: + if ruid not in all_filed and ruid not in shown: + nsf_record_uids.add(ruid) + elif node_uid_for_recs in nsf_folder_records: + for ruid in nsf_folder_records[node_uid_for_recs]: + nsf_record_uids.add(ruid) + elif show_records and is_nsf and node_uid and node_uid in nsf_folder_records: + # Temp NSF nodes are not BaseFolderNode; still warm their records. + for ruid in nsf_folder_records[node_uid]: + nsf_record_uids.add(ruid) + + for child in dir_nodes: + if child: + walk(child) + + walk(folder) + return nsf_folder_uids, classic_record_uids, nsf_record_uids + + +def formatted_tree(params, folder, verbose=False, show_records=False, shares=False, + nsf_shares=False, hide_shares_key=False, title=None, fmt='table'): + as_json = (fmt == 'json') + need_nsf_folders = bool(nsf_shares) + need_nsf_records = bool(nsf_shares and show_records) + need_classic_records = bool(shares and show_records) + if need_nsf_folders or need_nsf_records or need_classic_records: + nsf_folder_uids, classic_record_uids, nsf_record_uids = _collect_tree_share_targets( + params, folder, show_records) + from .. import nested_share_folder as _nsf + _nsf.warm_for_tree( + params, + nsf_folder_uids=nsf_folder_uids if need_nsf_folders else None, + classic_record_uids=classic_record_uids if need_classic_records else None, + nsf_record_uids=nsf_record_uids if need_nsf_records else None, + ) + + def print_share_permissions_key(): + lines = [ + 'Share Permissions Key:', + '======================', + ] + if shares: + lines.extend([ + 'RO = Read-Only', + 'MU = Can Manage Users', + 'MR = Can Manage Records', + 'CE = Can Edit', + 'CS = Can Share', + 'OW = Owner', + ]) + if nsf_shares: + lines.extend([ + 'OW = NSF Owner', + 'VW = NSF Viewer', + 'CT = NSF Contributor', + 'SM = NSF Share Manager', + 'CM = NSF Content Manager', + 'CSM = NSF Content + Share Manager', + 'FM = NSF Full Manager', + ]) + lines.append('======================') + print('\n'.join(lines) + '\n') + + def tree_node(node, parent_path=''): node_uid = node.record_uid if isinstance(node, Record) else (node.uid if hasattr(node, 'uid') else '') node_name = node.title if isinstance(node, Record) else (node.name if hasattr(node, 'name') else 'Unknown') - - # Check if it's a Nested Share Folder item and get proper name + is_nested_share = False if isinstance(node, Record): is_nested_share = hasattr(params, 'nested_share_records') and node.record_uid in params.nested_share_records @@ -1845,59 +2533,78 @@ def tree_node(node): is_nested_share = True elif isinstance(node, BaseFolderNode) and not isinstance(node, Record): is_nested_share = hasattr(params, 'nested_share_folders') and node_uid in params.nested_share_folders - # Get folder name from nested_share_folders if available if is_nested_share and node_uid in params.nested_share_folders: nsf_folder_name = params.nested_share_folders[node_uid].get('name', node_name) if nsf_folder_name: node_name = nsf_folder_name - - node_name = f'{node_name} ({node_uid})' if verbose else node_name - share_info = get_share_info(node) if isinstance(node, SharedFolderNode) and shares else '' - - # Format node name based on type + + base_name = node_name + if is_nested_share and not isinstance(node, Record) and node_uid in getattr(params, 'nested_share_folders', {}): + base_name = params.nested_share_folders[node_uid].get('name') or base_name + + is_vault_root = isinstance(node, BaseFolderNode) and (getattr(node, 'type', None) == '/' or not node_uid) + if is_vault_root and not isinstance(node, Record): + node_path = '/' + else: + node_path = _join_tree_path(parent_path, base_name) + + display_name = f'{node_name} ({node_uid})' if verbose else node_name + share_text = '' + share_data = None + kind = 'folder' + record_type = None + if isinstance(node, Record): + kind = 'nested_record' if is_nested_share else 'record' + record_type = getattr(node, 'record_type', None) or '' + type_label = f' [{record_type}]' if record_type else '' nsf_label = ' [Nested Record]' if is_nested_share else ' [Record]' - node_name = f'{Style.DIM}{node_name}{nsf_label}{Style.NORMAL}' + if is_nested_share and nsf_shares: + share_data, share_text = _nsf_record_share_data( + params, node_uid, include_uids=verbose) + elif (not is_nested_share) and shares: + share_data, share_text = _classic_record_share_data( + params, node_uid, include_uids=verbose) + display_name = f'{Style.DIM}{display_name}{type_label}{nsf_label}{share_text}{Style.NORMAL}' elif isinstance(node, SharedFolderNode): - node_name = f'{node_name}{Style.BRIGHT} [SHARED]{Style.NORMAL}{share_info}' + kind = 'shared_folder' + if shares: + share_data, share_text = _classic_folder_share_data( + params, params.shared_folder_cache.get(node.uid), include_uids=verbose) + display_name = f'{display_name}{Style.BRIGHT} [SHARED]{Style.NORMAL}{share_text}' elif is_nested_share: - node_name = f'{node_name}{Style.BRIGHT} [Nested Share Folder]{Style.NORMAL}' + kind = 'nested_share_folder' + if nsf_shares and node_uid: + share_data, share_text = _nsf_folder_share_data( + params, node_uid, include_uids=verbose) + display_name = f'{display_name}{Style.BRIGHT} [Nested Share Folder]{Style.NORMAL}{share_text}' dir_nodes = [] if not isinstance(node, Record): - # Get regular subfolders from folder_cache if hasattr(node, 'subfolders'): dir_nodes = [params.folder_cache.get(fuid) for fuid in node.subfolders if params.folder_cache.get(fuid)] - - # Check if this is root folder and add Nested Share Folder root-level folders + is_root = (isinstance(node, BaseFolderNode) and (node.type == '/' or node_uid == '')) or \ (hasattr(node, 'type') and node.type == 'nested_share_folder' and not node_uid) - + if is_root and hasattr(params, 'nested_share_folders') and params.nested_share_folders: - # Add all Nested Share Folders that are at root level for nsf_uid, nsf_folder in params.nested_share_folders.items(): parent_uid = nsf_folder.get('parent_uid') - # Check if this folder is at root: - # - parent_uid is None, empty string, 'root', or the special root UID - # - Also check if parent doesn't exist in nested_share_folders (orphan = root level) is_root_folder = ( - parent_uid is None or - parent_uid == '' or - parent_uid == 'root' or + parent_uid is None or + parent_uid == '' or + parent_uid == 'root' or parent_uid == 'AAAAAAAAAAAAAAAAAPmtNA' or (parent_uid and parent_uid not in params.nested_share_folders) ) if is_root_folder: - # Check if already in dir_nodes already_added = any(hasattr(n, 'uid') and n.uid == nsf_uid for n in dir_nodes if n) if not already_added: - # Check if in folder_cache first if nsf_uid in params.folder_cache: nsf_node = params.folder_cache.get(nsf_uid) if nsf_node: dir_nodes.append(nsf_node) else: - # Create a temporary folder node for Nested Share Folders not in folder_cache temp_node = type('FolderNode', (), { 'uid': nsf_uid, 'name': nsf_folder.get('name', 'Unnamed'), @@ -1905,14 +2612,11 @@ def tree_node(node): 'subfolders': [] })() dir_nodes.append(temp_node) - - # Add Nested Share Folder subfolders if this is a Nested Share Folder + elif not isinstance(node, Record) and hasattr(params, 'nested_share_folders') and node_uid: - # Find child folders for this Nested Share Folder for child_uid, child_folder in params.nested_share_folders.items(): parent_uid = child_folder.get('parent_uid', '') if parent_uid == node_uid: - # Check if already in dir_nodes already_added = any(hasattr(n, 'uid') and n.uid == child_uid for n in dir_nodes if n) if not already_added: if child_uid in params.folder_cache: @@ -1920,7 +2624,6 @@ def tree_node(node): if child_node: dir_nodes.append(child_node) else: - # Create a temporary folder node temp_node = type('FolderNode', (), { 'uid': child_uid, 'name': child_folder.get('name', 'Unnamed'), @@ -1928,25 +2631,19 @@ def tree_node(node): 'subfolders': [] })() dir_nodes.append(temp_node) - + rec_nodes = [] if show_records and isinstance(node, BaseFolderNode): node_uid_for_recs = '' if node.type == '/' else node.uid - - # Get legacy records rec_uids = {rec for recs in get_contained_record_uids(params, node_uid_for_recs).values() for rec in recs} records = [api.get_record(params, rec_uid) for rec_uid in rec_uids] records = [r for r in records if isinstance(r, Record)] rec_nodes.extend(records) - - # Add Nested Share Records for this folder + if hasattr(params, 'nested_share_folder_records'): - # For root folder, collect Nested Share Folder records that are not inside any known NSF sub-folder if is_root: nsf_folders = getattr(params, 'nested_share_folders', {}) shown_rec_uids = set(rec_uids) - # Records associated with container UIDs that are NOT real NSF sub-folders - # (includes the NSF root UID and any other non-folder containers) for folder_uid, nsf_rec_uids in params.nested_share_folder_records.items(): if folder_uid not in nsf_folders: for rec_uid in nsf_rec_uids: @@ -1955,7 +2652,6 @@ def tree_node(node): if isinstance(rec, Record): rec_nodes.append(rec) shown_rec_uids.add(rec_uid) - # Also show Nested Share Folder records that have NO folder association at all if hasattr(params, 'nested_share_records'): all_filed = set() for uids in params.nested_share_folder_records.values(): @@ -1966,7 +2662,6 @@ def tree_node(node): if isinstance(rec, Record): rec_nodes.append(rec) shown_rec_uids.add(rec_uid) - # For specific folders elif node_uid_for_recs in params.nested_share_folder_records: nsf_rec_uids = params.nested_share_folder_records[node_uid_for_recs] for rec_uid in nsf_rec_uids: @@ -1979,17 +2674,52 @@ def tree_node(node): rec_nodes.sort(key=lambda r: r.title.lower(), reverse=False) child_nodes = dir_nodes + rec_nodes - tns = [tree_node(n) for n in child_nodes] - return node_name, OrderedDict(tns) + child_path = '' if is_vault_root else node_path + child_results = [tree_node(n, child_path) for n in child_nodes] + ascii_children = OrderedDict((disp, br) for disp, br, _ in child_results) + + # Nested JSON node (omit empty children; uid only with -v) + item = {'name': base_name, 'path': node_path} + if verbose and node_uid: + item['uid'] = node_uid + item['kind'] = kind + if record_type: + item['record_type'] = record_type + if share_data: + item['share_permissions'] = share_data + json_children = [jr for _, _, jr in child_results if jr] + if json_children: + item['children'] = json_children + return display_name, ascii_children, item + + root_name, branches, json_root = tree_node(folder, '') + payload = {'tree': json_root} + if title: + payload['title'] = title + if (shares or nsf_shares) and not hide_shares_key: + key = {} + if shares: + key['classic'] = { + 'RO': 'Read-Only', 'MU': 'Can Manage Users', 'MR': 'Can Manage Records', + 'CE': 'Can Edit', 'CS': 'Can Share', 'OW': 'Owner', + } + if nsf_shares: + key['nsf'] = { + 'OW': 'Owner', 'VW': 'Viewer', 'CT': 'Contributor', 'SM': 'Share Manager', + 'CM': 'Content Manager', 'CSM': 'Content + Share Manager', 'FM': 'Full Manager', + } + payload['share_permissions_key'] = key + + if as_json: + return payload - root, branches = tree_node(folder) - tree = {root: branches} tr = LeftAligned(draw=BoxStyle(gfx=drawing.BOX_LIGHT)) - if shares and not hide_shares_key: + if (shares or nsf_shares) and not hide_shares_key: print_share_permissions_key() if title: print(title) - tree_txt = tr(tree) + tree_txt = tr({root_name: branches}) tree_txt = re.sub(r'\s+\(\)', '', tree_txt) print(tree_txt) print('') + return None diff --git a/keepercommander/nested_share_folder/__init__.py b/keepercommander/nested_share_folder/__init__.py index 1feda6a61..f92fac0a8 100644 --- a/keepercommander/nested_share_folder/__init__.py +++ b/keepercommander/nested_share_folder/__init__.py @@ -62,6 +62,11 @@ 'find_nested_share_folders_for_record', 'resolve_nested_share_record_uid', 'resolve_nested_share_folder_uid', ], + 'acl_cache': [ + 'warm_for_tree', 'warm_nsf_folder_share_cache', 'warm_nsf_record_share_cache', + 'warm_classic_record_shares', 'clear_share_caches', 'ensure_share_caches', + 'get_nsf_folder_share_accessors', 'get_nsf_record_share_accessors', + ], } _LAZY_REGISTRY = {} diff --git a/keepercommander/nested_share_folder/acl_cache.py b/keepercommander/nested_share_folder/acl_cache.py new file mode 100644 index 000000000..4d30c3d19 --- /dev/null +++ b/keepercommander/nested_share_folder/acl_cache.py @@ -0,0 +1,230 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' None: + if not hasattr(params, _FOLDER_SHARE_CACHE) or getattr(params, _FOLDER_SHARE_CACHE) is None: + setattr(params, _FOLDER_SHARE_CACHE, {}) + if not hasattr(params, _RECORD_SHARE_CACHE) or getattr(params, _RECORD_SHARE_CACHE) is None: + setattr(params, _RECORD_SHARE_CACHE, {}) + + +def clear_share_caches(params) -> None: + if hasattr(params, _FOLDER_SHARE_CACHE) and isinstance(getattr(params, _FOLDER_SHARE_CACHE), dict): + getattr(params, _FOLDER_SHARE_CACHE).clear() + if hasattr(params, _RECORD_SHARE_CACHE) and isinstance(getattr(params, _RECORD_SHARE_CACHE), dict): + getattr(params, _RECORD_SHARE_CACHE).clear() + + +def _chunked(items: Sequence[str], size: int) -> Iterable[List[str]]: + for i in range(0, len(items), size): + yield list(items[i:i + size]) + + +def _warm_nsf_folder_chunk(params, cache, chunk: List[str]) -> None: + """Fetch one folder-UID chunk, following pagination; leave failures uncached.""" + from .folder_api import get_folder_access_v3 + + accumulated: dict = {} + failed: Set[str] = set() + token = None + for _ in range(50): + info = get_folder_access_v3( + params, chunk, continuation_token=token, resolve_usernames=True) + for fr in info.get('results') or []: + fuid = fr.get('folder_uid') + if not fuid: + continue + if fr.get('success'): + accumulated.setdefault(fuid, []).extend(fr.get('accessors') or []) + else: + failed.add(fuid) + logging.debug('NSF folder ACL warm error for %s: %s', fuid, fr.get('error')) + if not info.get('has_more'): + break + token = info.get('continuation_token') + if token is None: + break + + for fuid, accessors in accumulated.items(): + cache[fuid] = accessors + for fuid in failed: + if fuid not in accumulated: + cache[fuid] = [] + + +def warm_nsf_folder_share_cache(params, folder_uids: Iterable[str], *, force: bool = False) -> None: + """Batch-load full NSF folder accessors into ``nested_share_folder_share_cache``. + + On transport/API exception the UIDs are left uncached so a later warm can + retry (same as record ACL warm). ``success: false`` results cache as []. + """ + ensure_share_caches(params) + cache = getattr(params, _FOLDER_SHARE_CACHE) + needed = [] + seen: Set[str] = set() + for uid in folder_uids: + if not uid or uid in seen: + continue + seen.add(uid) + if force or uid not in cache: + needed.append(uid) + if not needed: + return + + for chunk in _chunked(needed, _FOLDER_BATCH): + try: + _warm_nsf_folder_chunk(params, cache, chunk) + except Exception as exc: + logging.debug('NSF folder ACL warm failed for %s: %s', chunk, exc) + +def _store_nsf_record_accesses(cache, record_uids: Sequence[str], info: dict) -> None: + by_uid: dict = {} + for access in info.get('record_accesses', []) or []: + ruid = access.get('record_uid') + if not ruid: + continue + by_uid.setdefault(ruid, []).append(access) + forbidden = set(info.get('forbidden_records') or []) + for uid in record_uids: + if uid in forbidden: + cache[uid] = [] + elif uid in by_uid: + cache[uid] = by_uid[uid] + else: + # Successful response with no rows for this UID — real empty ACL. + cache[uid] = [] + + +def _is_throttle_error(exc: BaseException) -> bool: + text = str(exc).lower() + return 'throttl' in text or 'too many' in text or 'rate limit' in text + + +def _warm_nsf_record_chunk(params, cache, chunk: List[str], *, force: bool) -> bool: + """Warm one chunk. Returns False if further warming should stop (throttled).""" + from .record_api import get_record_accesses_v3 + + pending = [u for u in chunk if force or u not in cache] + if not pending: + return True + + try: + info = get_record_accesses_v3(params, pending) + _store_nsf_record_accesses(cache, pending, info) + return True + except Exception as exc: + if _is_throttle_error(exc): + logging.warning( + 'NSF record ACL warm throttled — stopping further record ACL fetches. ' + 'Records without a cached ACL will show parent-folder shares. (%s)', + exc) + return False + + # Binary-split once instead of N individual calls (avoids hammering / throttle). + if len(pending) > _RECORD_BATCH_MIN: + mid = len(pending) // 2 + logging.debug( + 'NSF record ACL warm failed for batch of %d; splitting: %s', + len(pending), exc) + if not _warm_nsf_record_chunk(params, cache, pending[:mid], force=force): + return False + return _warm_nsf_record_chunk(params, cache, pending[mid:], force=force) + + logging.warning('NSF record ACL warm failed for %s: %s', pending[0], exc) + # Leave uncached so folder-ACL fallback can still apply. + return True + + +def warm_nsf_record_share_cache(params, record_uids: Iterable[str], *, force: bool = False) -> None: + """Batch-load full NSF record accessors into ``nested_share_record_share_cache``. + + On failure, splits the batch rather than retrying every UID individually + (that pattern caused API floods / throttling). Throttle aborts the rest of + the warm for this call; uncached records fall back to parent folder ACL. + """ + ensure_share_caches(params) + cache = getattr(params, _RECORD_SHARE_CACHE) + needed = [] + seen: Set[str] = set() + for uid in record_uids: + if not uid or uid in seen: + continue + seen.add(uid) + if force or uid not in cache: + needed.append(uid) + if not needed: + return + + for chunk in _chunked(needed, _RECORD_BATCH): + if not _warm_nsf_record_chunk(params, cache, chunk, force=force): + break + + +def warm_classic_record_shares(params, record_uids: Iterable[str]) -> None: + """Ensure classic ``record_cache[uid]['shares']`` is populated (batched API).""" + uids = [u for u in dict.fromkeys(record_uids) if u and u in (params.record_cache or {})] + if not uids: + return + try: + api.get_record_shares(params, uids) + except Exception as exc: + logging.debug('Classic record share warm failed: %s', exc) + + +def warm_for_tree(params, + nsf_folder_uids: Optional[Iterable[str]] = None, + classic_record_uids: Optional[Iterable[str]] = None, + nsf_record_uids: Optional[Iterable[str]] = None) -> None: + """Warm all ACL caches needed for ``tree -s`` / ``tree -s -r``.""" + if nsf_folder_uids: + warm_nsf_folder_share_cache(params, nsf_folder_uids) + if nsf_record_uids: + warm_nsf_record_share_cache(params, nsf_record_uids) + if classic_record_uids: + warm_classic_record_shares(params, classic_record_uids) + + +def get_nsf_folder_share_accessors(params, folder_uid: str) -> List[dict]: + ensure_share_caches(params) + return list(getattr(params, _FOLDER_SHARE_CACHE).get(folder_uid) or []) + + +def get_nsf_record_share_accessors(params, record_uid: str) -> List[dict]: + ensure_share_caches(params) + return list(getattr(params, _RECORD_SHARE_CACHE).get(record_uid) or []) diff --git a/keepercommander/nested_share_folder/record_api.py b/keepercommander/nested_share_folder/record_api.py index f3d13979a..0d6a84832 100644 --- a/keepercommander/nested_share_folder/record_api.py +++ b/keepercommander/nested_share_folder/record_api.py @@ -328,23 +328,21 @@ def _try_decrypt_record_key(params, enc_rk, rk_type, uid): return drk -def get_record_accesses_v3(params, record_uids): - if not record_uids: - raise ValueError("At least one record UID required") - rq = record_details_pb2.RecordAccessRequest() - for uid in record_uids: - rq.recordUids.append(utils.base64_url_decode(uid)) +def _access_type_name(access_type): + try: + return folder_pb2.AccessType.Name(access_type) + except (ValueError, TypeError): + return 'AT_UNKNOWN' - rs = api.communicate_rest(params, rq, 'vault/records/v3/details/access', - rs_type=record_details_pb2.RecordAccessResponse) - result = {'record_accesses': [], 'forbidden_records': []} + +def _parse_record_access_response(rs, result): for ra in rs.recordAccesses: d = ra.data ai = ra.accessorInfo ao = { 'record_uid': utils.base64_url_encode(d.recordUid), 'accessor_name': ai.name, - 'access_type': folder_pb2.AccessType.Name(d.accessType) if hasattr(d, 'accessType') else 'UNKNOWN', + 'access_type': _access_type_name(d.accessType), 'access_type_uid': utils.base64_url_encode(d.accessTypeUid), 'owner': getattr(d, 'owner', False), 'inherited': bool(getattr(d, 'inherited', False)), @@ -361,7 +359,46 @@ def get_record_accesses_v3(params, record_uids): getattr(d.tlaProperties, 'rotateOnExpiration', False)) result['record_accesses'].append(ao) for fu in rs.forbiddenRecords: - result['forbidden_records'].append(utils.base64_url_encode(fu)) + fuid = utils.base64_url_encode(fu) + if fuid not in result['forbidden_records']: + result['forbidden_records'].append(fuid) + + +def get_record_accesses_v3(params, record_uids): + if not record_uids: + raise ValueError("At least one record UID required") + from ..proto import pagination_pb2 + + result = {'record_accesses': [], 'forbidden_records': []} + cursor = '' + page_number = 1 + use_page = False + # Guard against runaway pagination. + for _ in range(50): + rq = record_details_pb2.RecordAccessRequest() + for uid in record_uids: + rq.recordUids.append(utils.base64_url_decode(uid)) + # First request matches historical nsf-get behavior (no page). + # Only send Page on follow-ups; forcing pageSize on page 1 caused + # incomplete accessor lists for some vaults. + if use_page: + page = pagination_pb2.Page() + page.pageNumber = page_number + page.pageSize = 500 # server max + if cursor: + page.cursorToken = cursor + rq.page.CopyFrom(page) + + rs = api.communicate_rest(params, rq, 'vault/records/v3/details/access', + rs_type=record_details_pb2.RecordAccessResponse) + _parse_record_access_response(rs, result) + + page_info = rs.pageInfo if rs.HasField('pageInfo') else None + if not page_info or not page_info.hasMore: + break + cursor = page_info.cursorToken or '' + page_number = (page_info.pageNumber or page_number) + 1 + use_page = True return result diff --git a/keepercommander/nested_share_folder/sync.py b/keepercommander/nested_share_folder/sync.py index 996012fa6..69c884579 100644 --- a/keepercommander/nested_share_folder/sync.py +++ b/keepercommander/nested_share_folder/sync.py @@ -37,6 +37,10 @@ def _ensure_nested_share_folder_attrs(params): params.nested_share_record_links = {} if not hasattr(params, 'nested_share_raw_dag_data'): params.nested_share_raw_dag_data = [] + if not hasattr(params, 'nested_share_folder_share_cache'): + params.nested_share_folder_share_cache = {} + if not hasattr(params, 'nested_share_record_share_cache'): + params.nested_share_record_share_cache = {} def create_accumulator(): @@ -78,6 +82,10 @@ def clear_caches(params): params.nested_share_record_sharing_states.clear() params.nested_share_record_links.clear() params.nested_share_raw_dag_data.clear() + if hasattr(params, 'nested_share_folder_share_cache'): + params.nested_share_folder_share_cache.clear() + if hasattr(params, 'nested_share_record_share_cache'): + params.nested_share_record_share_cache.clear() # nested_share_folder_trashed_folders is intentionally NOT cleared here. # The server keeps sending trashed folders in every sync_down response # (including full/CLEAR syncs), so the trashed-UID filter must survive diff --git a/keepercommander/params.py b/keepercommander/params.py index d74297abe..7a19c6c49 100644 --- a/keepercommander/params.py +++ b/keepercommander/params.py @@ -259,6 +259,9 @@ def __init__(self, config_filename='', config=None, server='keepersecurity.com') self.nested_share_record_sharing_states = {} # record_uid -> sharing state dict self.nested_share_record_links = {} # record_uid -> list of record link dicts self.nested_share_raw_dag_data = [] # list of raw DAG entry dicts + # Full NSF ACL lists after warm (separate from sync self-rows above) + self.nested_share_folder_share_cache = {} # folder_uid -> list of accessors + self.nested_share_record_share_cache = {} # record_uid -> list of accessors self.__proxy = None self.ssh_agent = None self.unmask_all = False @@ -345,6 +348,8 @@ def clear_session(self): self.nested_share_record_sharing_states = {} self.nested_share_record_links = {} self.nested_share_raw_dag_data = [] + self.nested_share_folder_share_cache = {} + self.nested_share_record_share_cache = {} self.ws = None if self.ssh_agent: self.ssh_agent.close() diff --git a/keepercommander/service/util/parse_keeper_response.py b/keepercommander/service/util/parse_keeper_response.py index 48cbe237f..9081b81fc 100644 --- a/keepercommander/service/util/parse_keeper_response.py +++ b/keepercommander/service/util/parse_keeper_response.py @@ -241,26 +241,24 @@ def _parse_share_bracket_list( @staticmethod def _parse_tree_share_permissions(name: str) -> Optional[Dict[str, Any]]: """ - Parse shared-folder permission suffix from a tree line into structured fields. + Parse shared-folder permission suffix from ASCII tree (service ``tree``). - CLI format (from folder.formatted_tree): (default:...; user:...; teams:...; users:...) + Emits ``{"default": "...", "teams"?: ..., "users"?: ...}`` — no ``user`` + field. ``tree --format=json`` uses ``_parse_json_format_command`` instead. """ - # Ordered segments from folder.py: default, user, optional teams, optional users m = re.search( - r'\(default:([^;]+); user:([^;]+)(?:; teams:([^;]+))?(?:; users:([^)]+))?\)', + r'\(default:([^;]+)(?:; user:([^;]+))?(?:; teams:([^;]+))?(?:; users:([^)]+))?\)', name, ) if not m: return None default_val = m.group(1).strip() - user_val = m.group(2).strip() teams_seg = (m.group(3) or "").strip() users_seg = (m.group(4) or "").strip() share_permissions: Dict[str, Any] = { "default": default_val, - "user": user_val, } if teams_seg: share_permissions["teams"] = KeeperResponseParser._parse_share_bracket_list( @@ -272,9 +270,26 @@ def _parse_tree_share_permissions(name: str) -> Optional[Dict[str, Any]]: ) return share_permissions + @staticmethod + def _extract_tree_uid(name: str) -> Optional[str]: + """Return verbose-mode UID paren; ignore share-permission suffixes.""" + share_prefixes = ( + 'default:', 'user:', 'users:', 'teams:', 'folders:', + 'applications:', 'shared:', + ) + for m in re.finditer(r'\(([^)]+)\)', name): + inner = m.group(1).strip() + if any(inner.startswith(p) for p in share_prefixes): + continue + return inner + return None + @staticmethod def _parse_tree_command(response: str) -> Dict[str, Any]: - """Parse 'tree' command output into structured format.""" + """Parse ASCII ``tree`` into the legacy service-mode flat structure. + + Distinct from ``tree --format=json`` (``_parse_json_format_command``). + """ result = { "status": "success", "command": "tree", @@ -349,27 +364,32 @@ def _parse_tree_command(response: str) -> Dict[str, Any]: if not name: continue - is_record = "[Record]" in name - is_shared = "[SHARED]" in name + is_record = '[Record]' in name or '[Nested Record]' in name + is_shared = '[SHARED]' in name - # Extract UID if present (for -v flag) - uid = None - uid_match = re.search(r'\(([^)]+)\)', name) - if uid_match and not any(x in uid_match.group(1) for x in ["default:", "user:"]): - uid = uid_match.group(1) + uid = KeeperResponseParser._extract_tree_uid(name) # Extract share permissions if present (for -s flag) share_permissions = ( KeeperResponseParser._parse_tree_share_permissions(name) if is_shared else None ) - # Clean the name from all indicators + # Clean the name from indicators / share suffixes / record type tags clean_name = name - clean_name = re.sub(r' \([^)]*\) \[SHARED\] \([^)]*\)', '', clean_name) # Remove UID + SHARED + permissions - clean_name = re.sub(r' \([^)]*\) \[Record\]', '', clean_name) # Remove UID + Record - clean_name = re.sub(r' \([^)]*\) \[SHARED\]', '', clean_name) # Remove UID + SHARED - clean_name = re.sub(r' \([^)]*\)', '', clean_name) # Remove just UID - clean_name = clean_name.replace(" [Record]", "").replace(" [SHARED]", "") + clean_name = re.sub( + r' \((?:default|user|users|teams|folders|applications|shared):[^)]*\)', + '', + clean_name, + ) + clean_name = re.sub(r' \([^)]*\) \[SHARED\]', '', clean_name) + clean_name = re.sub(r' \([^)]*\)(?= \[)', '', clean_name) # UID before [tag] + clean_name = re.sub(r' \([^)]+\)$', '', clean_name) # trailing UID + for tag in ( + ' [Nested Share Folder]', ' [Nested Record]', ' [Record]', ' [SHARED]', + ): + clean_name = clean_name.replace(tag, '') + clean_name = re.sub(r' \[[A-Za-z0-9_]+\]', '', clean_name) # [login], etc. + clean_name = clean_name.strip() # Determine type item_type = "record" if is_record else "folder" @@ -1232,6 +1252,7 @@ def _filter_login_messages(response_str: str) -> str: def ensure_record_add_json_format(command: str) -> str: + """Append ``--format=json`` for record-add when missing. Not applied to tree.""" if not command.strip().startswith('record-add'): return command if '--format=json' in command or '--format json' in command: diff --git a/unit-tests/service/test_response_parser.py b/unit-tests/service/test_response_parser.py index 8512c1a98..b98a0e6b3 100644 --- a/unit-tests/service/test_response_parser.py +++ b/unit-tests/service/test_response_parser.py @@ -62,14 +62,14 @@ def test_parse_tree_command(self): self.assertEqual(result['data']['tree'][1]['path'], 'Folder1') def test_parse_tree_command_share_permissions_structured(self): - """tree -s -v: share_permissions splits default/user vs per-user list""" + """tree -s -v: share_permissions uses default only (all abbrevs), plus users list""" sample_output = """Share Permissions Key: ====================== RO = Read-Only MU = Can Manage Users ====================== My Vault - └── Shared Folder (abc123) [SHARED] (default:CE; user:CE; users:[a@x.com:RO],[b@y.com:MU,MR]) + └── Shared Folder (abc123) [SHARED] (default:CE; users:[a@x.com:RO],[b@y.com:MU,MR]) """ result = KeeperResponseParser._parse_tree_command(sample_output) self.assertEqual(result['data']['share_permissions_key'][:2], ['RO = Read-Only', 'MU = Can Manage Users']) @@ -77,13 +77,56 @@ def test_parse_tree_command_share_permissions_structured(self): self.assertTrue(entry['shared']) sp = entry['share_permissions'] self.assertEqual(sp['default'], 'CE') - self.assertEqual(sp['user'], 'CE') + self.assertNotIn('user', sp) self.assertEqual(len(sp['users']), 2) self.assertEqual(sp['users'][0]['username'], 'a@x.com') self.assertEqual(sp['users'][0]['permissions'], 'RO') self.assertEqual(sp['users'][1]['username'], 'b@y.com') self.assertEqual(sp['users'][1]['permissions'], 'MU,MR') + def test_parse_tree_share_permissions_ignores_legacy_user_segment(self): + """Legacy user: segment is ignored; only default is kept.""" + line = 'Shared Folder [SHARED] (default:MU,CE; user:MU,CE; users:[a@x.com:RO])' + sp = KeeperResponseParser._parse_tree_share_permissions(line) + self.assertEqual(sp['default'], 'MU,CE') + self.assertNotIn('user', sp) + self.assertEqual(sp['users'][0]['username'], 'a@x.com') + + def test_parse_tree_does_not_treat_share_suffix_as_uid(self): + sample = ( + 'My Vault\n' + ' └── Folder (abcUID12345678901234) [SHARED] ' + '(default:RO; users:[a@x.com:RO])\n' + ' └── Rec [login] [Nested Record] (users:[me@x.com:OW])\n' + ) + result = KeeperResponseParser._parse_tree_command(sample) + folder = result['data']['tree'][0] + self.assertEqual(folder['name'], 'Folder') + self.assertEqual(folder['uid'], 'abcUID12345678901234') + self.assertEqual(folder['share_permissions']['default'], 'RO') + rec = result['data']['tree'][1] + self.assertEqual(rec['name'], 'Rec') + self.assertEqual(rec['type'], 'record') + self.assertNotIn('uid', rec) + + def test_tree_format_json_uses_native_json_parser(self): + """tree --format=json must not use the ASCII tree parser.""" + self.assertEqual( + KeeperResponseParser._find_parser_method('tree --format=json'), + '_parse_json_format_command', + ) + self.assertEqual( + KeeperResponseParser._find_parser_method('tree'), + '_parse_tree_command', + ) + native = '{"tree": {"name": "My Vault", "kind": "folder", "children": []}}' + result = KeeperResponseParser._parse_json_format_command( + 'tree --format=json', native + ) + self.assertEqual(result['status'], 'success') + self.assertEqual(result['data']['tree']['kind'], 'folder') + self.assertNotIn('level', result['data']['tree']) + def test_parse_mkdir_command(self): """Test parsing of 'mkdir' command output""" diff --git a/unit-tests/test_command_enterprise.py b/unit-tests/test_command_enterprise.py index ac8a0bbde..a7d17b9b0 100644 --- a/unit-tests/test_command_enterprise.py +++ b/unit-tests/test_command_enterprise.py @@ -55,6 +55,29 @@ def test_enterprise_info_command(self): cmd = enterprise.EnterpriseInfoCommand() cmd.execute(params, verbose=True) + def test_enterprise_info_users_verbose_returns_ids(self): + """With -v, node/teams/roles columns should be IDs; without -v, names.""" + params = get_connected_params() + api.query_enterprise(params) + cmd = enterprise.EnterpriseInfoCommand() + columns = 'name,node,teams,roles' + + report = cmd.execute( + params, users=True, format='json', columns=columns, quiet=True) + users = json.loads(report) + user1 = next(u for u in users if u['user_id'] == ent_env.user1_id) + self.assertEqual(user1['node'], 'Enterprise 1') + self.assertEqual(user1['teams'], [ent_env.team1_name]) + self.assertEqual(user1['roles'], [ent_env.role1_name]) + + report = cmd.execute( + params, users=True, format='json', columns=columns, verbose=True, quiet=True) + users = json.loads(report) + user1 = next(u for u in users if u['user_id'] == ent_env.user1_id) + self.assertEqual(user1['node'], str(ent_env.node1_id)) + self.assertEqual(user1['teams'], [ent_env.team1_uid]) + self.assertEqual(user1['roles'], [str(ent_env.role1_id)]) + def test_enterprise_add_user(self): params = get_connected_params() api.query_enterprise(params) diff --git a/unit-tests/test_nsf_acl_cache.py b/unit-tests/test_nsf_acl_cache.py new file mode 100644 index 000000000..31d67354b --- /dev/null +++ b/unit-tests/test_nsf_acl_cache.py @@ -0,0 +1,473 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Date: Thu, 23 Jul 2026 18:48:18 +0530 Subject: [PATCH 07/16] KC-1361: Reject share commands that target a record or folder owner (#2235) * Reject share commands that target a record or folder owner. Fail early with a clear message for share-record, share-folder, nsf-share-record, and nsf-share-folder so owners are not granted, updated, or revoked as if they were share recipients. * resolved review comments * addressed logging and variable naming comments --- .../nested_share_folder/folder_commands.py | 15 +++ .../commands/nested_share_folder/helpers.py | 51 ++++++++++ .../nested_share_folder/sharing_commands.py | 4 + keepercommander/commands/register.py | 96 ++++++++++++++++++- .../nested_share_folder/__init__.py | 3 +- .../nested_share_folder/record_api.py | 11 +++ unit-tests/test_command_register.py | 83 ++++++++++++++++ unit-tests/test_nested_share_folder.py | 40 +++++++- 8 files changed, 299 insertions(+), 4 deletions(-) diff --git a/keepercommander/commands/nested_share_folder/folder_commands.py b/keepercommander/commands/nested_share_folder/folder_commands.py index 52eb39dbb..28f3a677f 100644 --- a/keepercommander/commands/nested_share_folder/folder_commands.py +++ b/keepercommander/commands/nested_share_folder/folder_commands.py @@ -30,6 +30,7 @@ check_folder_edit_permission, check_folder_share_permission, check_folder_delete_permission, classify_share_recipient, ensure_nested_share_folder, is_nested_share_folder, + is_nested_share_folder_owner_email, owner_share_target_message, ) from .parsers import ( nested_share_folder_mkdir_parser, @@ -465,6 +466,11 @@ def _expand_existing(params, folder_uid, folder_arg): elif accessor.get('access_type') == 'AT_USER': username = accessor.get('username') if username and username != params.user: + if is_nested_share_folder_owner_email(params, folder_uid, username): + logging.info( + "nsf-share-folder: skipping owner '%s' for " + "folder '%s'", username, folder_arg) + continue result.append(('user', username)) except Exception as exc: logging.debug( @@ -479,6 +485,11 @@ def _expand_existing(params, folder_uid, folder_arg): if access_type == at_user: username = a.get('username') if username and username != params.user: + if is_nested_share_folder_owner_email(params, folder_uid, username): + logging.info( + "nsf-share-folder: skipping owner '%s' for " + "folder '%s'", username, folder_arg) + continue result.append(('user', username)) elif access_type == at_team: team_uid = a.get('access_type_uid') @@ -493,6 +504,10 @@ def _expand_existing(params, folder_uid, folder_arg): @classmethod def _apply(cls, params, action, folder_uid, recipient, role, expiration, as_team=False, rotate_on_expiration=False): + if not as_team and is_nested_share_folder_owner_email(params, folder_uid, recipient): + raise CommandError( + 'nsf-share-folder', + owner_share_target_message(recipient, entity='folder')) api_name, verb = cls._ACTIONS[action] api_func = getattr(_nsf, api_name) kw = dict(params=params, folder_uid=folder_uid, user_uid=recipient, diff --git a/keepercommander/commands/nested_share_folder/helpers.py b/keepercommander/commands/nested_share_folder/helpers.py index e5dd397ec..855c6c700 100644 --- a/keepercommander/commands/nested_share_folder/helpers.py +++ b/keepercommander/commands/nested_share_folder/helpers.py @@ -174,6 +174,57 @@ def resolve_folder_uid(params, identifier): "only on Nested Share Folders." ) +_OWNER_SHARE_TARGET_MSG = ( + "'{email}' is the owner of this {entity} and already has full access. " + "Share permissions cannot be granted, changed, or revoked for the owner." +) + + +def owner_share_target_message(email, entity='record'): + """User-facing message when a share command targets the owner.""" + return _OWNER_SHARE_TARGET_MSG.format(email=email, entity=entity) + + +def is_nested_share_folder_owner_email(params, folder_uid, email): + """True when *email* matches the NSF folder owner from sync-down.""" + if not folder_uid or not email: + return False + fobj = getattr(params, 'nested_share_folders', {}).get(folder_uid) or {} + owner_username = fobj.get('owner_username') or '' + return bool(owner_username) and owner_username.casefold() == email.casefold() + + +def raise_if_record_share_target_is_owner(params, record_uid, email, cmd_name, *, + is_ownership_transfer=False): + """Raise CommandError when *email* is the owner of *record_uid*. + + When *is_ownership_transfer* is True (``-a owner``), raising means the + target already owns the record so the transfer would be a no-op. When False, + grant/revoke/update against the owner is rejected. + + If owner lookup via ``get_record_accesses_v3`` fails, we log a warning and + return without raising. Blocking the share on a transient access-API error + would reject legitimate grants to non-owners; the server still rejects + invalid owner-targeted shares if we miss the check client-side. + """ + from ...nested_share_folder.record_api import ( + get_record_accesses_v3, find_record_owner_username) + try: + access_result = get_record_accesses_v3(params, [record_uid]) + except Exception as exc: + logging.getLogger(__name__).warning( + "Could not resolve owner for record '%s'; proceeding without " + "client-side owner check: %s", record_uid, exc) + return + owner_username = find_record_owner_username(access_result, record_uid) + if not owner_username or owner_username.casefold() != email.casefold(): + return + if is_ownership_transfer: + raise CommandError( + cmd_name, + f"'{email}' already owns this record. Ownership transfer is a no-op.") + raise CommandError(cmd_name, owner_share_target_message(email, entity='record')) + def is_nested_share_record(params, record_uid): """Return True when *record_uid* is a Nested Share Folder (v3) record.""" diff --git a/keepercommander/commands/nested_share_folder/sharing_commands.py b/keepercommander/commands/nested_share_folder/sharing_commands.py index 36128473c..8872ba78f 100644 --- a/keepercommander/commands/nested_share_folder/sharing_commands.py +++ b/keepercommander/commands/nested_share_folder/sharing_commands.py @@ -34,6 +34,7 @@ check_record_share_permission, collect_records_in_folder, ensure_nested_share_folder, ensure_nested_share_record, + raise_if_record_share_target_is_owner, ) from .parsers import ( nested_share_record_share_parser, @@ -102,6 +103,9 @@ def execute(self, params, **kwargs): with command_error_handler('nsf-share-record'): for email in emails: for record_uid in record_uids: + raise_if_record_share_target_is_owner( + params, record_uid, email, 'nsf-share-record', + is_ownership_transfer=(action == 'owner')) result, effective_action = self._dispatch( params, action, record_uid, email, access_role_type, expiration, rotate_on_expiration) diff --git a/keepercommander/commands/register.py b/keepercommander/commands/register.py index b8de28b37..4c3222e6b 100644 --- a/keepercommander/commands/register.py +++ b/keepercommander/commands/register.py @@ -302,6 +302,65 @@ def _folder_user_lookup(shared_folder, email): return None +def _owner_share_target_message(email, entity='record'): + # type: (str, str) -> str + """User-facing message when a share command targets the owner.""" + return ( + f"'{email}' is the owner of this {entity} and already has full access. " + f"Share permissions cannot be granted, changed, or revoked for the owner." + ) + + +def _is_shared_folder_owner_email(shared_folder, email, params=None): + # type: (dict, str, Optional[KeeperParams]) -> bool + """True when *email* matches the classic shared folder owner from sync-down. + + Resolution order: + 1. ``owner_username`` from sync-down (primary; present for most folders) + 2. ``users[].owner`` flag when sync includes it on the user row + 3. ``owner_account_uid`` matched to the current session user — only used + when username/flag are absent. If sync omits ``owner_account_uid`` or + the session has no ``account_uid_bytes``, this path is skipped and we + return False rather than guessing. + """ + if not shared_folder or not email: + return False + email_cf = email.casefold() + owner_username = shared_folder.get('owner_username') or '' + if owner_username and owner_username.casefold() == email_cf: + return True + # Some sync payloads also put an owner flag on the user row. + users = shared_folder.get('users', []) + if any( + user.get('owner') is True + and (user.get('username') or '').casefold() == email_cf + for user in users + ): + return True + # Last resort: only when the target is the logged-in user and sync exposes + # owner_account_uid. Skipped when either field is missing. + owner_uid = shared_folder.get('owner_account_uid') + account_uid_bytes = getattr(params, 'account_uid_bytes', None) if params else None + if (params and owner_uid and account_uid_bytes + and params.user and params.user.casefold() == email_cf): + if owner_uid == utils.base64_url_encode(account_uid_bytes): + return True + return False + + +def _find_record_owner_username(existing_shares, params=None, record_uid=None): + # type: (dict, Optional[KeeperParams], Optional[str]) -> Optional[str] + """Return the record owner username from share rows or local owner cache.""" + for username, perm in (existing_shares or {}).items(): + if perm and perm.get('owner'): + return username + if params is not None and record_uid and params.user: + owner_info = getattr(params, 'record_owner_cache', {}).get(record_uid) + if owner_info and owner_info.owner: + return params.user + return None + + def format_share_expiration_ms(expiration_ms): # type: (int) -> str """Format a share expiration timestamp (milliseconds) for log output.""" @@ -545,7 +604,18 @@ def prep_rq(recs, users, curr_sf): if all_users or all_records: if all_users: if 'users' in sh_fol: - sf_users.update((x['username'] for x in sh_fol['users'] if x['username'] != params.user)) + for x in sh_fol['users']: + username = x.get('username') + if not username or username == params.user: + continue + if _is_shared_folder_owner_email( + sh_fol, username, params=params): + logging.debug( + "share-folder: skipping owner '%s' in " + "bulk user update for folder '%s'", + username, sf_uid) + continue + sf_users.add(username) if 'teams' in sh_fol: sf_teams.update((x['team_uid'] for x in sh_fol['teams'])) if all_records: @@ -691,6 +761,13 @@ def apply_share_expiration(target): current_user = _folder_user_lookup(curr_sf, email) if current_user: email = current_user['username'] + if _is_shared_folder_owner_email(curr_sf, email, params=params): + # Allow the existing owner self-removal / succession flow in + # _confirm_folder_user_removals; reject grant/update of owner perms. + if action == 'grant': + raise CommandError( + 'share-folder', + _owner_share_target_message(email, entity='shared folder')) uo = folder_pb2.SharedFolderUpdateUser() uo.username = email apply_share_expiration(uo) @@ -1104,7 +1181,24 @@ def apply_share_expiration(ro): # type: (record_pb2.SharedRecord) -> None pass record_path = api.resolve_record_share_path(params, record_uid) + owner_username = _find_record_owner_username( + existing_shares, params=params, record_uid=record_uid) for email in all_users: + # Precedence: owner rejection runs before existing-share add/update + # logic below. Granting/revoking the owner is never valid, even when + # they also appear in existing_shares; "already shared" handling only + # applies to non-owners. + if owner_username and email.casefold() == owner_username.casefold(): + # Ownership transfer (-a owner) is allowed only when the target + # is not already the owner. Grant/revoke must never target owner. + if action == 'owner': + raise CommandError( + 'share-record', + f"'{email}' already owns this record. Ownership transfer is a no-op.") + raise CommandError( + 'share-record', + _owner_share_target_message(email, entity='record')) + ro = record_pb2.SharedRecord() ro.toUsername = email ro.recordUid = utils.base64_url_decode(record_uid) diff --git a/keepercommander/nested_share_folder/__init__.py b/keepercommander/nested_share_folder/__init__.py index f92fac0a8..14c005919 100644 --- a/keepercommander/nested_share_folder/__init__.py +++ b/keepercommander/nested_share_folder/__init__.py @@ -43,7 +43,8 @@ 'create_record_data_v3', 'record_add_v3', 'record_add_pam_configuration_v3', 'record_update_v3', 'create_record_v3', 'update_record_v3', 'create_records_batch_v3', 'get_record_details_v3', 'get_record_accesses_v3', - 'find_direct_user_share_access', 'is_record_share_update_noop', + 'find_direct_user_share_access', 'find_record_owner_username', + 'is_record_share_update_noop', 'share_record_v3', 'update_record_share_v3', 'unshare_record_v3', 'share_record_to_application_v3', 'update_record_share_to_application_v3', 'unshare_record_from_application_v3', diff --git a/keepercommander/nested_share_folder/record_api.py b/keepercommander/nested_share_folder/record_api.py index 0d6a84832..9fc189bc0 100644 --- a/keepercommander/nested_share_folder/record_api.py +++ b/keepercommander/nested_share_folder/record_api.py @@ -417,6 +417,17 @@ def find_direct_user_share_access(access_result, record_uid, email): return None +def find_record_owner_username(access_result, record_uid): + """Return the owner username for *record_uid* from a get_record_accesses_v3 result.""" + return next( + ( + access.get('accessor_name') or '' + for access in access_result.get('record_accesses', []) + if access.get('record_uid') == record_uid and access.get('owner') + ), + '', + ) + _SHARE_EXPIRATION_NOOP_TOLERANCE_MS = 60_000 diff --git a/unit-tests/test_command_register.py b/unit-tests/test_command_register.py index 2f35ea315..c5a8b9d88 100644 --- a/unit-tests/test_command_register.py +++ b/unit-tests/test_command_register.py @@ -68,6 +68,65 @@ def shared(params, record_uids, is_share_admin): cmd.execute(params, email=['user2@keepersecurity.com'], action='revoke', record=record_uid) self.assertEqual(len(TestRegister.expected_commands), 0) + def _shared_with_owner_side_effect(self, owner_username=None, extra_users=None): + """Build a get_record_shares side_effect that marks *owner_username* as owner. + + Defaults to the session user. *extra_users* is an optional list of + non-owner emails included in user_permissions. + """ + extras = list(extra_users or []) + + def shared_with_owner(params_, record_uids, is_share_admin): + owner = owner_username if owner_username is not None else params_.user + for uid in record_uids: + if uid not in params_.record_cache: + continue + perms = [{'username': owner, 'owner': True}] + for email in extras: + perms.append({ + 'username': email, 'owner': False, + 'shareable': False, 'editable': False, + }) + params_.record_cache[uid]['shares'] = {'user_permissions': perms} + + return shared_with_owner + + def test_share_record_rejects_grant_to_owner(self): + """Granting share permissions to the record owner must fail client-side.""" + params = get_synced_params() + record_uid = next(iter([x['record_uid'] for x in params.meta_data_cache.values() if x['can_share']])) + cmd = register.ShareRecordCommand() + + self.record_share_mock = mock.patch('keepercommander.api.get_record_shares').start() + self.record_share_mock.side_effect = self._shared_with_owner_side_effect( + extra_users=['user2@keepersecurity.com']) + + with self.assertRaises(CommandError) as ctx: + cmd.prep_request(params, dict( + email=[params.user], + action='grant', + record=record_uid, + )) + self.assertIn('is the owner', str(ctx.exception)) + self.assertIn('already has full access', str(ctx.exception)) + + def test_share_record_rejects_owner_transfer_to_self(self): + """Ownership transfer to the current owner is a no-op and must be rejected.""" + params = get_synced_params() + record_uid = next(iter([x['record_uid'] for x in params.meta_data_cache.values() if x['can_share']])) + cmd = register.ShareRecordCommand() + + self.record_share_mock = mock.patch('keepercommander.api.get_record_shares').start() + self.record_share_mock.side_effect = self._shared_with_owner_side_effect() + + with self.assertRaises(CommandError) as ctx: + cmd.prep_request(params, dict( + email=[params.user], + action='owner', + record=record_uid, + )) + self.assertIn('already owns', str(ctx.exception)) + @contextmanager def _make_record_rotation_eligible(self, params, target_uid): """Present the record as a pamUser with rotation configured (ROE-eligible).""" @@ -308,6 +367,30 @@ def test_share_folder(self): cmd.execute(params, action='remove', user=['user2@keepersecurity.com'], folder=shared_folder_uid) self.assertEqual(len(TestRegister.expected_commands), 0) + def test_share_folder_rejects_grant_to_owner(self): + """Granting folder permissions to the folder owner must fail client-side.""" + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + owner = params.user + curr_sf = dict(params.shared_folder_cache[shared_folder_uid]) + curr_sf['owner_username'] = owner + curr_sf['users'] = [ + {'username': owner, 'manage_records': True, 'manage_users': True}, + {'username': 'user2@keepersecurity.com', 'manage_records': True, 'manage_users': True}, + ] + + with self.assertRaises(CommandError) as ctx: + register.ShareFolderCommand.prepare_request( + params, + kwargs={'action': 'grant', 'manage_records': 'on', 'manage_users': 'off'}, + curr_sf=curr_sf, + users=[owner], + teams=[], + rec_uids=[], + ) + self.assertIn('is the owner', str(ctx.exception)) + self.assertIn('shared folder', str(ctx.exception)) + def test_share_folder_prepare_request_sets_rotate_on_expiration(self): """Folder-wide expiration/ROE applies to user/team protos, not record protos.""" params = get_synced_params() diff --git a/unit-tests/test_nested_share_folder.py b/unit-tests/test_nested_share_folder.py index db4b6a0a3..aa72a4438 100644 --- a/unit-tests/test_nested_share_folder.py +++ b/unit-tests/test_nested_share_folder.py @@ -881,7 +881,7 @@ def test_share_record(self, mock_share): cmd = NestedShareRecordShareCommand() with mock.patch('builtins.print'): cmd.execute(_make_params(nested_share_records={ruid: robj}), - record=ruid, email='user@example.com', + record=ruid, email=['user@example.com'], action='grant', role='viewer') @patch('keepercommander.nested_share_folder.record_api.unshare_record_v3') @@ -895,9 +895,45 @@ def test_share_record_revoke(self, mock_unshare): cmd = NestedShareRecordShareCommand() with mock.patch('builtins.print'): cmd.execute(_make_params(nested_share_records={ruid: robj}), - record=ruid, email='user@example.com', + record=ruid, email=['user@example.com'], action='revoke') + @patch('keepercommander.nested_share_folder.record_api.get_record_accesses_v3') + @patch('keepercommander.nested_share_folder.record_api.share_record_v3') + def test_share_record_rejects_grant_to_owner(self, mock_share, mock_accesses): + from keepercommander.commands.nested_share_folder import NestedShareRecordShareCommand + ruid, robj = _make_record() + owner = 'owner@example.com' + mock_accesses.return_value = { + 'record_accesses': [{ + 'record_uid': ruid, + 'accessor_name': owner, + 'owner': True, + 'access_type': 'AT_USER', + }], + 'forbidden_records': [], + } + cmd = NestedShareRecordShareCommand() + with self.assertRaises(CommandError) as ctx: + cmd.execute(_make_params(nested_share_records={ruid: robj}), + record=ruid, email=[owner], + action='grant', role='viewer') + self.assertIn('is the owner', str(ctx.exception)) + mock_share.assert_not_called() + + @patch('keepercommander.nested_share_folder.folder_api.grant_folder_access_v3') + def test_share_folder_rejects_grant_to_owner(self, mock_grant): + from keepercommander.commands.nested_share_folder import NestedShareFolderShareCommand + fuid, fobj = _make_folder() + owner = 'owner@example.com' + fobj['owner_username'] = owner + cmd = NestedShareFolderShareCommand() + with self.assertRaises(CommandError) as ctx: + cmd.execute(_make_params(nested_share_folders={fuid: fobj}), + folder=[fuid], user=[owner], action='grant', role='viewer') + self.assertIn('is the owner', str(ctx.exception)) + mock_grant.assert_not_called() + @patch('keepercommander.nested_share_folder.folder_api.grant_folder_access_v3') def test_share_folder_invite_message_uses_command_prefix(self, mock_grant): from keepercommander.commands.nested_share_folder import NestedShareFolderShareCommand From c24d47db976b3757e5a3369693619dc825803714 Mon Sep 17 00:00:00 2001 From: lthievenaz-keeper Date: Fri, 24 Jul 2026 17:02:56 +0100 Subject: [PATCH 08/16] Add support for Key Files in Keepass export (#2244) --- keepercommander/importer/commands.py | 2 ++ keepercommander/importer/imp_exp.py | 2 ++ keepercommander/importer/keepass/keepass.py | 24 ++++++++++----------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/keepercommander/importer/commands.py b/keepercommander/importer/commands.py index 23245f743..5fae4198a 100644 --- a/keepercommander/importer/commands.py +++ b/keepercommander/importer/commands.py @@ -97,6 +97,8 @@ def register_command_info(aliases, command_info): help='Maximum file attachment file. Example: 100K, 50M, 2G. Default: 10M') export_parser.add_argument('-kp', '--keepass-file-password', dest='file_password', action='store', help='Password for the exported file') +export_parser.add_argument('-kkf', '--keepass-key-file', dest='kbdx_key_file', action='store', + help='Keepass key file for the exported file') export_parser.add_argument('--zip', dest='zip_archive', action='store_true', help='Create ZIP archive for file attachments. JSON only') export_parser.add_argument('--save-in-vault', dest='save_in_vault', action='store_true', diff --git a/keepercommander/importer/imp_exp.py b/keepercommander/importer/imp_exp.py index f36c9b068..912ab9ea4 100644 --- a/keepercommander/importer/imp_exp.py +++ b/keepercommander/importer/imp_exp.py @@ -431,6 +431,8 @@ def on_folder(base_folder): # type: (BaseFolderNode) -> None file_password = kwargs.get('file_password') if file_password: args['file_password'] = file_password + if kwargs.get('kbdx_key_file'): + args['kbdx_key_file'] = kwargs.get('kbdx_key_file') zip_archive = kwargs.get('zip_archive') is True if zip_archive: args['zip_archive'] = zip_archive diff --git a/keepercommander/importer/keepass/keepass.py b/keepercommander/importer/keepass/keepass.py index 4268ad67b..a31b9df1c 100644 --- a/keepercommander/importer/keepass/keepass.py +++ b/keepercommander/importer/keepass/keepass.py @@ -284,18 +284,15 @@ def to_keepass_value(keeper_value): # type: (any) -> str else: return XmlUtils.sanitize_xml_text(keeper_value) - def do_export(self, filename, records, file_password=None, **kwargs): - master_password = file_password - confirmed = True - while not master_password or not confirmed: - print('Choose password for your Keepass file') - master_password = getpass.getpass(prompt='...' + 'Keepass Password'.rjust(20) + ': ', stream=None) - print('\nRe-enter password for your Keepass file') - confirmation = getpass.getpass(prompt='...' + 'Keepass Password'.rjust(20) + ': ', stream=None) - confirmed = master_password == confirmation - retry_msg = 'The passwords you entered do not match.\nPlease try again.\n' - fail_msg = bcolors.FAIL + bcolors.BOLD + '\nALERT!\n' + retry_msg + bcolors.ENDC - not confirmed and print(fail_msg) + def do_export(self, filename, records, file_password=None, kbdx_key_file=None, **kwargs): + password = file_password or getpass.getpass(prompt='...' + 'Keepass Password'.rjust(20) + ': ', stream=None) or None + if not kbdx_key_file: + print('Press Enter if your Keepass file is not protected with a key file') + keyfile = kbdx_key_file or input('...' + 'Path to Key file'.rjust(20) + ': ') or None + if keyfile: + keyfile = os.path.expanduser(keyfile.strip("\"'")) + else: + keyfile = None sfs = [] # type: list[SharedFolder] rs = [] # type: list[Record] @@ -308,7 +305,8 @@ def do_export(self, filename, records, file_password=None, **kwargs): template_file = os.path.join(os.path.dirname(__file__), 'template.kdbx') with PyKeePass(template_file, password='111111') as kdb: - kdb.password = master_password + kdb.password = password + kdb.keyfile = keyfile root = kdb.root_group for r in rs: From f4d4ff27e3f3a6c54ff0edfbc0f019016458173f Mon Sep 17 00:00:00 2001 From: lthievenaz-keeper Date: Mon, 27 Jul 2026 17:00:18 +0100 Subject: [PATCH 09/16] Fix TOTP field parsing for Keepass Import / Export (#2245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix issues with blank Keepass password and Keyfile quotations - Keepass import does not support an empty Keepass password (empty string) → fixed - Keepass import breaks if you added the Keyfile wrapped in quotes → fixed * Updated TOTP field importing The import logic was expecting outdated / incomplete field labels for TOTP. - Kept the old logic in case any Keepass integration still uses them - Added new TOTP fields to trigger the TOTP parsing. - Added TOTP algorithms * Fix TOTP field exporting Set new TOTP fields for Keepass export, so that it complies with Keepass TOTP definitions. Added Algorithm field --- keepercommander/importer/keepass/keepass.py | 50 ++++++++++++++++----- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/keepercommander/importer/keepass/keepass.py b/keepercommander/importer/keepass/keepass.py index a31b9df1c..32bd27cef 100644 --- a/keepercommander/importer/keepass/keepass.py +++ b/keepercommander/importer/keepass/keepass.py @@ -79,11 +79,11 @@ def get_folder(group): # type: (Group) -> str return path def do_import(self, filename, **kwargs): - password = getpass.getpass(prompt='...' + 'Keepass Password'.rjust(20) + ': ', stream=None) + password = getpass.getpass(prompt='...' + 'Keepass Password'.rjust(20) + ': ', stream=None) or None print('Press Enter if your Keepass file is not protected with a key file') keyfile = input('...' + 'Path to Key file'.rjust(20) + ': ') if keyfile: - keyfile = os.path.expanduser(keyfile) + keyfile = os.path.expanduser(keyfile.strip("\"'")) else: keyfile = None try: @@ -108,6 +108,7 @@ def do_import(self, filename, **kwargs): totp_issuer = '' totp_period = 0 totp_digits = 0 + totp_algo = None record = Record() fol = Folder() fol.path = folder @@ -145,25 +146,36 @@ def do_import(self, filename, **kwargs): else: field_type = '' field_label = key - if field_label in ('TOTPSecret', 'TOTPPeriod', 'TOTPDigits', 'TOTPIssuer', 'ModifyTOTPSettings', 'ViewTOTPSettings'): + if field_label in ( + 'TimeOtp-Secret-Base32', 'TOTPSecret', + 'TimeOtp-Period', 'TOTPPeriod', + 'TimeOtp-Length', 'TOTPDigits', + 'TimeOtp-Algorithm', + 'TOTPIssuer', + 'ModifyTOTPSettings', 'ViewTOTPSettings' + ): # Ignore TOTP custom fields (set via previous Keeper -> KDBX export implementation) # if the entry's "otp" field contains the corresponding URI if entry.otp: continue - if field_label == 'TOTPSecret': + if field_label in ('TimeOtp-Secret-Base32','TOTPSecret'): totp_secret = value elif field_label == 'TOTPIssuer': totp_issuer = value - elif field_label == 'TOTPPeriod': + elif field_label in ('TimeOtp-Period','TOTPPeriod'): try: totp_period = int(value) except: pass - elif field_label == 'TOTPDigits': + elif field_label in ('TimeOtp-Length','TOTPDigits'): try: totp_digits = int(value) except: pass + elif field_label == 'TimeOtp-Algorithm': + parsed_algo = value.replace('-','')[4:] + if parsed_algo in ('SHA1','SHA256','SHA512'): + totp_algo = parsed_algo else: field = RecordField() field.type = field_type @@ -178,6 +190,8 @@ def do_import(self, filename, **kwargs): value += f'&period={totp_period}' if totp_digits > 0: value += f'&digits={totp_digits}' + if totp_algo: + value += f'&algorithm={totp_algo}' field = RecordField() field.type = 'oneTimeCode' field.value = KeepassImporter.import_field(field_type, value) @@ -354,11 +368,25 @@ def do_export(self, filename, records, file_password=None, kbdx_key_file=None, * entry.otp = otp_value # Set custom fields for Pleasant Password TOTP compatibility totp_props = parse_totp_uri(otp_value) - for key in ['secret', 'period', 'issuer', 'digits']: - val = totp_props.get(key) - val and entry.set_custom_property( - f'TOTP{key.capitalize()}', - self.to_keepass_value(val) + totp_props.get('secret') and entry.set_custom_property( + 'TimeOtp-Secret-Base32', + self.to_keepass_value(totp_props['secret']) + ) + totp_props.get('issuer') and entry.set_custom_property( + 'TOTPIssuer', + self.to_keepass_value(totp_props['issuer']) + ) + totp_props.get('digits') and entry.set_custom_property( + 'TimeOtp-Length', + self.to_keepass_value(totp_props['digits']) + ) + totp_props.get('period') and entry.set_custom_property( + 'TimeOtp-Period', + self.to_keepass_value(totp_props['period']) + ) + totp_props.get('algorithm') and entry.set_custom_property( + 'TimeOtp-Algorithm', + f'HMAC-SHA-{totp_props["algorithm"][3:]}' ) continue From 0447cfca99238087353ccf3fbe8b84fe4b429724 Mon Sep 17 00:00:00 2001 From: pvagare-ks Date: Tue, 28 Jul 2026 17:18:40 +0530 Subject: [PATCH 10/16] nsf-record-update command update --- .../nested_share_folder/record_commands.py | 4 +- .../nested_share_folder/record_api.py | 30 +++++-- unit-tests/test_nested_share_folder.py | 85 +++++++++++++++++++ 3 files changed, 109 insertions(+), 10 deletions(-) diff --git a/keepercommander/commands/nested_share_folder/record_commands.py b/keepercommander/commands/nested_share_folder/record_commands.py index 6a0480646..e5ac2f4fe 100644 --- a/keepercommander/commands/nested_share_folder/record_commands.py +++ b/keepercommander/commands/nested_share_folder/record_commands.py @@ -301,9 +301,7 @@ def execute(self, params, **kwargs): return self.warnings.clear() result = _nsf.update_record_v3( - params=params, record_uid=record_uid, - title=kwargs.get('title'), record_type=record_type, - fields=fields or None, notes=kwargs.get('notes'), + params=params, record_uid=record_uid, data=merged, ) check_result(result, 'nsf-record-update') params.sync_data = True diff --git a/keepercommander/nested_share_folder/record_api.py b/keepercommander/nested_share_folder/record_api.py index 9fc189bc0..1ac17c2fc 100644 --- a/keepercommander/nested_share_folder/record_api.py +++ b/keepercommander/nested_share_folder/record_api.py @@ -151,6 +151,28 @@ def create_record_v3(params, record_type='', title='', fields=None, raise KeeperApiError('no_results', 'No results from record creation') +def _load_existing_record_data(params, record_uid, rec=None): + """Load decrypted record JSON for update""" + candidates = [] + if rec is not None: + candidates.append(rec.get('data_unencrypted')) + cache_rec = getattr(params, 'record_cache', {}).get(record_uid) or {} + candidates.append(cache_rec.get('data_unencrypted')) + nsf_data = getattr(params, 'nested_share_record_data', {}).get(record_uid) or {} + candidates.append(nsf_data.get('data_json')) + + for raw in candidates: + if raw is None: + continue + if isinstance(raw, bytes): + return json.loads(raw.decode('utf-8')) + if isinstance(raw, str): + return json.loads(raw) + if isinstance(raw, dict): + return raw.copy() + return None + + def update_record_v3(params, record_uid, data=None, title=None, record_type=None, fields=None, notes=None, non_shared_data=None, revision=None): @@ -165,13 +187,7 @@ def update_record_v3(params, record_uid, data=None, title=None, rk = rec.get('record_key_unencrypted') or get_record_key(params, record_uid) if data is None: - existing = None - if 'data_unencrypted' in rec: - raw = rec['data_unencrypted'] - if isinstance(raw, bytes): - existing = json.loads(raw.decode('utf-8')) - elif isinstance(raw, str): - existing = json.loads(raw) + existing = _load_existing_record_data(params, record_uid, rec) data = existing.copy() if existing else {'fields': []} if title is not None: data['title'] = title diff --git a/unit-tests/test_nested_share_folder.py b/unit-tests/test_nested_share_folder.py index aa72a4438..d48edcbf0 100644 --- a/unit-tests/test_nested_share_folder.py +++ b/unit-tests/test_nested_share_folder.py @@ -1387,6 +1387,91 @@ def setUp(self): def tearDown(self): mock.patch.stopall() + @patch('keepercommander.nested_share_folder.record_api.record_update_v3') + def test_update_record_v3_preserves_login_fields_from_record_cache(self, mock_update): + """nested_share_records has no data_unencrypted; login fields must come from record_cache.""" + from keepercommander.nested_share_folder.record_api import update_record_v3 + from keepercommander.proto import record_pb2 + + ruid, robj = _make_record() + existing = { + 'type': 'login', + 'title': 'Prod Login', + 'fields': [ + {'type': 'login', 'value': ['alice']}, + {'type': 'password', 'value': ['OldPass123']}, + {'type': 'url', 'value': ['https://example.com']}, + ], + } + params = _make_params( + nested_share_records={ruid: robj}, + record_cache={ruid: { + 'revision': 1, + 'record_key_unencrypted': robj['record_key_unencrypted'], + 'data_unencrypted': json.dumps(existing).encode('utf-8'), + }}, + ) + mock_rs = Mock() + mock_rec = Mock() + mock_rec.status = record_pb2.RS_SUCCESS + mock_rec.message = '' + mock_rs.records = [mock_rec] + mock_rs.revision = 2 + mock_update.return_value = mock_rs + + result = update_record_v3(params, ruid, fields={'password': 'NewPass456'}) + self.assertTrue(result['success']) + + ru = mock_update.call_args[0][1][0] + decrypted = json.loads( + crypto.decrypt_aes_v2(ru.data, robj['record_key_unencrypted']).decode('utf-8').rstrip('\x00') + ) + by_type = {f['type']: f['value'] for f in decrypted['fields']} + self.assertEqual(decrypted['type'], 'login') + self.assertEqual(decrypted['title'], 'Prod Login') + self.assertEqual(by_type['login'], ['alice']) + self.assertEqual(by_type['password'], ['NewPass456']) + self.assertEqual(by_type['url'], ['https://example.com']) + + @patch('keepercommander.nested_share_folder.record_api.record_update_v3') + def test_update_record_v3_preserves_login_fields_from_nsf_record_data(self, mock_update): + """Fallback to nested_share_record_data when record_cache has no decrypted JSON.""" + from keepercommander.nested_share_folder.record_api import update_record_v3 + from keepercommander.proto import record_pb2 + + ruid, robj = _make_record() + existing = { + 'type': 'login', + 'title': 'NSF Login', + 'fields': [ + {'type': 'login', 'value': ['bob']}, + {'type': 'password', 'value': ['KeepMe']}, + ], + } + params = _make_params( + nested_share_records={ruid: robj}, + nested_share_record_data={ruid: {'data_json': existing}}, + ) + mock_rs = Mock() + mock_rec = Mock() + mock_rec.status = record_pb2.RS_SUCCESS + mock_rec.message = '' + mock_rs.records = [mock_rec] + mock_rs.revision = 2 + mock_update.return_value = mock_rs + + result = update_record_v3(params, ruid, fields={'login': 'bob2'}) + self.assertTrue(result['success']) + + ru = mock_update.call_args[0][1][0] + decrypted = json.loads( + crypto.decrypt_aes_v2(ru.data, robj['record_key_unencrypted']).decode('utf-8').rstrip('\x00') + ) + by_type = {f['type']: f['value'] for f in decrypted['fields']} + self.assertEqual(decrypted['title'], 'NSF Login') + self.assertEqual(by_type['login'], ['bob2']) + self.assertEqual(by_type['password'], ['KeepMe']) + @patch('keepercommander.nested_share_folder.record_api.api.communicate_rest') @patch('keepercommander.nested_share_folder.record_api.encrypt_for_recipient') @patch('keepercommander.nested_share_folder.record_api.get_user_public_key') From 857512e977f97249920a39ff24be7f1bb0173bde Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Thu, 30 Jul 2026 20:33:07 +0530 Subject: [PATCH 11/16] Add sailpoint-app-setup command (#2246) (#2253) * Implement Sailpoint App Setup Command for sailpoint integration * Add code enhancements and validations * Improve SailPoint Service Mode: capability gates, better logs, remove Contributor from NSF, and safer pending apply. * Fix sync down timeout --- keepercommander/command_categories.py | 3 +- keepercommander/commands/folder.py | 4 +- .../commands/nested_share_folder/helpers.py | 10 +- keepercommander/commands/start_service.py | 8 +- keepercommander/service/app.py | 5 + .../service/commands/create_service.py | 13 +- .../service/commands/integrations/__init__.py | 2 + .../integrations/integration_setup_base.py | 12 +- .../integrations/sailpoint/__init__.py | 32 + .../sailpoint/apply_entitlements.py | 248 ++++++ .../integrations/sailpoint/command_hook.py | 276 +++++++ .../integrations/sailpoint/command_parse.py | 254 ++++++ .../integrations/sailpoint/command_policy.py | 106 +++ .../integrations/sailpoint/config_fields.py | 89 +++ .../integrations/sailpoint/constants.py | 64 ++ .../integrations/sailpoint/pending_store.py | 192 +++++ .../commands/integrations/sailpoint/poller.py | 118 +++ .../integrations/sailpoint/scim_guard.py | 92 +++ .../integrations/sailpoint/service.py | 144 ++++ .../integrations/sailpoint/share_targets.py | 121 +++ .../integrations/sailpoint_app_setup.py | 191 +++++ keepercommander/service/config/models.py | 2 +- keepercommander/service/docker/__init__.py | 5 +- .../service/docker/compose_builder.py | 8 +- keepercommander/service/docker/models.py | 10 + keepercommander/service/util/command_util.py | 39 +- .../service/util/verified_command.py | 75 +- unit-tests/service/test_sailpoint_pending.py | 742 ++++++++++++++++++ unit-tests/test_nsf_acl_cache.py | 8 +- 29 files changed, 2847 insertions(+), 26 deletions(-) create mode 100644 keepercommander/service/commands/integrations/sailpoint/__init__.py create mode 100644 keepercommander/service/commands/integrations/sailpoint/apply_entitlements.py create mode 100644 keepercommander/service/commands/integrations/sailpoint/command_hook.py create mode 100644 keepercommander/service/commands/integrations/sailpoint/command_parse.py create mode 100644 keepercommander/service/commands/integrations/sailpoint/command_policy.py create mode 100644 keepercommander/service/commands/integrations/sailpoint/config_fields.py create mode 100644 keepercommander/service/commands/integrations/sailpoint/constants.py create mode 100644 keepercommander/service/commands/integrations/sailpoint/pending_store.py create mode 100644 keepercommander/service/commands/integrations/sailpoint/poller.py create mode 100644 keepercommander/service/commands/integrations/sailpoint/scim_guard.py create mode 100644 keepercommander/service/commands/integrations/sailpoint/service.py create mode 100644 keepercommander/service/commands/integrations/sailpoint/share_targets.py create mode 100644 keepercommander/service/commands/integrations/sailpoint_app_setup.py create mode 100644 unit-tests/service/test_sailpoint_pending.py diff --git a/keepercommander/command_categories.py b/keepercommander/command_categories.py index d7c1504f5..f1be4583b 100644 --- a/keepercommander/command_categories.py +++ b/keepercommander/command_categories.py @@ -83,7 +83,8 @@ # Service Mode REST API 'Service Mode REST API': { 'service-create', 'service-add-config', 'service-start', 'service-stop', 'service-status', - 'service-config-add', 'service-docker-setup', 'slack-app-setup', 'teams-app-setup' + 'service-config-add', 'service-docker-setup', 'slack-app-setup', 'teams-app-setup', + 'sailpoint-app-setup' }, # Email Configuration Commands diff --git a/keepercommander/commands/folder.py b/keepercommander/commands/folder.py index 180ea64bb..7da2d0da8 100644 --- a/keepercommander/commands/folder.py +++ b/keepercommander/commands/folder.py @@ -1910,7 +1910,6 @@ def _tree_json_dumps(obj, level=0, indent=2): _NSF_ROLE_ABBREV = { 'viewer': 'VW', - 'contributor': 'CT', 'share-manager': 'SM', 'content-manager': 'CM', 'content-share-manager': 'CSM', @@ -2513,7 +2512,6 @@ def print_share_permissions_key(): lines.extend([ 'OW = NSF Owner', 'VW = NSF Viewer', - 'CT = NSF Contributor', 'SM = NSF Share Manager', 'CM = NSF Content Manager', 'CSM = NSF Content + Share Manager', @@ -2705,7 +2703,7 @@ def tree_node(node, parent_path=''): } if nsf_shares: key['nsf'] = { - 'OW': 'Owner', 'VW': 'Viewer', 'CT': 'Contributor', 'SM': 'Share Manager', + 'OW': 'Owner', 'VW': 'Viewer', 'SM': 'Share Manager', 'CM': 'Content Manager', 'CSM': 'Content + Share Manager', 'FM': 'Full Manager', } payload['share_permissions_key'] = key diff --git a/keepercommander/commands/nested_share_folder/helpers.py b/keepercommander/commands/nested_share_folder/helpers.py index 855c6c700..00ad50152 100644 --- a/keepercommander/commands/nested_share_folder/helpers.py +++ b/keepercommander/commands/nested_share_folder/helpers.py @@ -464,7 +464,7 @@ def infer_role(access): Follows the official permission matrix:: full-manager > content-share-manager > share-manager > - content-manager > viewer > contributor > requestor > navigator + content-manager > viewer > requestor > navigator The distinguishing trait between ``share-manager`` and ``content-share-manager`` is the ability to *edit* records: both roles @@ -485,9 +485,7 @@ def infer_role(access): return 'content-manager' if get('can_view') and get('can_list_access'): return 'viewer' - if get('can_view'): - return 'contributor' - if get('can_view_title'): + if get('can_view') or get('can_view_title'): return 'requestor' return 'navigator' @@ -507,8 +505,8 @@ def role_label(access_role_type): # Map backend AccessRoleType enum names to Nested Share Folder display labels. # Source of truth: folder_pb2.AccessRoleType (NAVIGATOR=0 ... MANAGER=6). _ACCESS_ROLE_DISPLAY_LABELS = { - 'NAVIGATOR': 'contributor', - 'REQUESTOR': 'contributor', + 'NAVIGATOR': 'navigator', + 'REQUESTOR': 'requestor', 'VIEWER': 'viewer', 'SHARED_MANAGER': 'share-manager', 'CONTENT_MANAGER': 'content-manager', diff --git a/keepercommander/commands/start_service.py b/keepercommander/commands/start_service.py index 6524a596f..fafaf1568 100644 --- a/keepercommander/commands/start_service.py +++ b/keepercommander/commands/start_service.py @@ -13,7 +13,9 @@ from ..service.commands.config_operation import AddConfigService from ..service.commands.handle_service import StartService, StopService, ServiceStatus from ..service.commands.service_docker_setup import ServiceDockerSetupCommand -from ..service.commands.integrations import SlackAppSetupCommand, TeamsAppSetupCommand +from ..service.commands.integrations import ( + SlackAppSetupCommand, TeamsAppSetupCommand, SailPointAppSetupCommand, +) def register_commands(commands): commands['service-create'] = CreateService() @@ -24,6 +26,7 @@ def register_commands(commands): commands['service-docker-setup'] = ServiceDockerSetupCommand() commands['slack-app-setup'] = SlackAppSetupCommand() commands['teams-app-setup'] = TeamsAppSetupCommand() + commands['sailpoint-app-setup'] = SailPointAppSetupCommand() def register_command_info(aliases, command_info): service_classes = [ @@ -34,7 +37,8 @@ def register_command_info(aliases, command_info): ServiceStatus, ServiceDockerSetupCommand, SlackAppSetupCommand, - TeamsAppSetupCommand + TeamsAppSetupCommand, + SailPointAppSetupCommand, ] for service_class in service_classes: diff --git a/keepercommander/service/app.py b/keepercommander/service/app.py index ea2b741a0..03a74bcdf 100644 --- a/keepercommander/service/app.py +++ b/keepercommander/service/app.py @@ -11,6 +11,7 @@ from flask import Flask, jsonify import logging +import os from werkzeug.middleware.proxy_fix import ProxyFix from flask_limiter.errors import RateLimitExceeded from .decorators.security import limiter, is_behind_proxy @@ -46,6 +47,10 @@ def handle_rate_limit_exceeded(e): logger.debug("Initializing API routes") init_routes(app) + if (os.environ.get('SAILPOINT_RECORD') or '').strip(): + from .commands.integrations.sailpoint.service import SailPointService + SailPointService.start_background_services() + print("Keeper Commander Service initialization complete") return app diff --git a/keepercommander/service/commands/create_service.py b/keepercommander/service/commands/create_service.py index 6e89f8496..d035fde91 100644 --- a/keepercommander/service/commands/create_service.py +++ b/keepercommander/service/commands/create_service.py @@ -10,6 +10,7 @@ # import argparse +import os from typing import Any, Dict, Optional from ..config.service_config import ServiceConfig from ..config.config_validation import ValidationError @@ -92,9 +93,19 @@ def execute(self, params: KeeperParams, **kwargs) -> None: from ..core.globals import init_globals init_globals(params) - filtered_kwargs = {k: v for k, v in kwargs.items() if k in ['port', 'allowedip', 'deniedip', 'commands', 'ngrok', 'ngrok_custom_domain', 'cloudflare', 'cloudflare_custom_domain', 'certfile', 'certpassword', 'fileformat', 'run_mode', 'queue_enabled', 'update_vault_record', 'ratelimit', 'encryption', 'encryption_key', 'token_expiration']} + filtered_kwargs = {k: v for k, v in kwargs.items() if k in [ + 'port', 'allowedip', 'deniedip', 'commands', 'ngrok', 'ngrok_custom_domain', + 'cloudflare', 'cloudflare_custom_domain', 'certfile', 'certpassword', 'fileformat', + 'run_mode', 'queue_enabled', 'update_vault_record', 'ratelimit', 'encryption', + 'encryption_key', 'token_expiration', + ]} args = StreamlineArgs(**filtered_kwargs) + # Optional SailPoint: enable when SAILPOINT_RECORD points at a marked config record + if (os.environ.get('SAILPOINT_RECORD') or '').strip(): + from .integrations.sailpoint.service import SailPointService + SailPointService.maybe_enable(params, args) + from .integrations.vault_metadata import get_existing_api_key, write_service_metadata existing_api_key = ( get_existing_api_key(params, args.update_vault_record) diff --git a/keepercommander/service/commands/integrations/__init__.py b/keepercommander/service/commands/integrations/__init__.py index e4e4570ae..7375a8cc8 100644 --- a/keepercommander/service/commands/integrations/__init__.py +++ b/keepercommander/service/commands/integrations/__init__.py @@ -14,9 +14,11 @@ from .integration_setup_base import IntegrationSetupCommand from .slack_app_setup import SlackAppSetupCommand from .teams_app_setup import TeamsAppSetupCommand +from .sailpoint_app_setup import SailPointAppSetupCommand __all__ = [ 'IntegrationSetupCommand', 'SlackAppSetupCommand', 'TeamsAppSetupCommand', + 'SailPointAppSetupCommand', ] diff --git a/keepercommander/service/commands/integrations/integration_setup_base.py b/keepercommander/service/commands/integrations/integration_setup_base.py index 55da36f5b..1ff828195 100644 --- a/keepercommander/service/commands/integrations/integration_setup_base.py +++ b/keepercommander/service/commands/integrations/integration_setup_base.py @@ -498,8 +498,16 @@ def _print_integration_resources(self, record_uid: str, config) -> None: name = self.get_integration_name() print(f" • {name} Config Record: {bcolors.OKBLUE}{record_uid}{bcolors.ENDC}") self.print_integration_specific_resources(config) - print(f" • EPM Integration: {bcolors.OKBLUE}{'true' if config.pedm_enabled else 'false'}{bcolors.ENDC}") - print(f" • Device Approval: {bcolors.OKBLUE}{'true' if config.device_approval_enabled else 'false'}{bcolors.ENDC}") + if hasattr(config, 'pedm_enabled'): + print( + f" • EPM Integration: " + f"{bcolors.OKBLUE}{'true' if config.pedm_enabled else 'false'}{bcolors.ENDC}" + ) + if hasattr(config, 'device_approval_enabled'): + print( + f" • Device Approval: " + f"{bcolors.OKBLUE}{'true' if config.device_approval_enabled else 'false'}{bcolors.ENDC}" + ) # -- Optional feature collectors ----------------------------------- diff --git a/keepercommander/service/commands/integrations/sailpoint/__init__.py b/keepercommander/service/commands/integrations/sailpoint/__init__.py new file mode 100644 index 000000000..125f8a046 --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/__init__.py @@ -0,0 +1,32 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bool: + return any( + str(role.get('role_id')) == role_name + or ((role.get('data') or {}).get('displayname') or '').lower() == role_name.lower() + for role in params.enterprise.get('roles') or [] + ) + + @staticmethod + def _team_exists(params: KeeperParams, team_name: str) -> bool: + return any( + team.get('team_uid') == team_name + or (team.get('name') or '').lower() == team_name.lower() + for team in params.enterprise.get('teams') or [] + ) + + @staticmethod + def _run(params: KeeperParams, command: str) -> None: + from ..... import cli + cli.do_command(params, command) + + @staticmethod + def _is_missing_target(error: Exception) -> bool: + msg = str(error).lower() + return any(h in msg for h in _NOT_FOUND_HINTS) + + @staticmethod + def _shell_quote(value: str) -> str: + return shlex.quote(str(value)) + + @classmethod + def user_is_active(cls, params: KeeperParams, email: str) -> bool: + user = SailPointScimGuard.find_user(params, email) + return bool(user and user.get('status') == 'active') + + @classmethod + def _apply_folder(cls, params: KeeperParams, email: str, folder: Dict[str, Any]) -> None: + uid = folder.get('uid') + if not uid: + raise ValueError('Folder entry missing uid') + kind = (folder.get('kind') or 'classic').lower() + email_q = cls._shell_quote(email) + uid_q = cls._shell_quote(uid) + if kind == 'nsf': + role = folder.get('role') or 'viewer' + cls._run( + params, + f'nsf-share-folder -a grant -e {email_q} -r {cls._shell_quote(role)} {uid_q}', + ) + return + + flags = [] + manage_records = folder.get('manage_records') + manage_users = folder.get('manage_users') + if manage_records in ('on', 'off'): + flags.append(f'--manage-records {manage_records}') + if manage_users in ('on', 'off'): + flags.append(f'--manage-users {manage_users}') + flag_str = f" {' '.join(flags)}" if flags else '' + cls._run(params, f'share-folder -a grant --email {email_q}{flag_str} {uid_q}') + + @classmethod + def _apply_record(cls, params: KeeperParams, email: str, record: Dict[str, Any]) -> None: + uid = record.get('uid') + if not uid: + raise ValueError('Record entry missing uid') + kind = (record.get('kind') or 'classic').lower() + email_q = cls._shell_quote(email) + uid_q = cls._shell_quote(uid) + if kind == 'nsf': + role = record.get('role') or 'viewer' + cls._run( + params, + f'nsf-share-record -a grant -e {email_q} -r {cls._shell_quote(role)} {uid_q}', + ) + return + + flags = [] + if record.get('can_edit'): + flags.append('--write') + if record.get('can_share'): + flags.append('--share') + flag_str = f" {' '.join(flags)}" if flags else '' + cls._run(params, f'share-record --email {email_q}{flag_str} {uid_q}') + + @classmethod + def _apply_items( + cls, + params: KeeperParams, + email: str, + items: List[Dict[str, Any]], + *, + label: str, + apply_one: Callable[[KeeperParams, str, Dict[str, Any]], None], + remaining: Dict[str, Any], + dropped: List[str], + ) -> List[Dict[str, Any]]: + still: List[Dict[str, Any]] = [] + for item in items: + uid = item.get('uid') + if not uid: + dropped.append(f'{label} entry missing uid, dropped') + continue + try: + apply_one(params, email, item) + logger.info(f'SailPoint: shared {label.lower()} {uid} with {email}') + except Exception as e: + if cls._is_missing_target(e): + dropped.append(f'{label} not found, dropped: {uid}') + else: + logger.warning(f'Failed to share {label.lower()} {uid} with {email}: {e}') + still.append(item) + remaining['last_error'] = str(e) + return still + + @classmethod + def apply_for_user( + cls, + params: KeeperParams, + email: str, + entry: Dict[str, Any], + *, + allow_roles: bool = True, + allow_teams: bool = True, + allow_folders: bool = True, + allow_records: bool = True, + ) -> Tuple[Dict[str, Any], List[str]]: + remaining = { + 'created_at': entry.get('created_at'), + 'last_error': None, + 'roles': list(entry.get('roles') or []), + 'teams': list(entry.get('teams') or []), + 'folders': [dict(x) for x in (entry.get('folders') or [])], + 'records': [dict(x) for x in (entry.get('records') or [])], + } + dropped: List[str] = [] + scim_user = SailPointScimGuard.is_scim_managed_user(params, email) + email_q = cls._shell_quote(email) + + if not allow_roles and remaining['roles']: + dropped.append('Pending roles skipped by allow_roles=false') + remaining['roles'] = [] + if not allow_teams and remaining['teams']: + dropped.append('Pending teams skipped by allow_teams=false') + remaining['teams'] = [] + + if scim_user: + if remaining['roles'] or remaining['teams']: + dropped.append( + f'SCIM-managed user {email}: skipped pending roles/teams (identity coexistence)' + ) + remaining['roles'] = [] + remaining['teams'] = [] + else: + still_roles = [] + for role in remaining['roles']: + if not cls._role_exists(params, role): + dropped.append(f'Role not found, dropped: {role}') + continue + try: + cls._run( + params, + f'enterprise-user -f {email_q} --add-role {cls._shell_quote(role)}', + ) + logger.info(f"SailPoint: added role '{role}' to {email}") + except Exception as e: + logger.warning(f'Failed to add role {role} for {email}: {e}') + still_roles.append(role) + remaining['last_error'] = str(e) + remaining['roles'] = still_roles + + still_teams = [] + for team in remaining['teams']: + if not cls._team_exists(params, team): + dropped.append(f'Team not found, dropped: {team}') + continue + try: + cls._run( + params, + f'enterprise-user {email_q} --add-team {cls._shell_quote(team)}', + ) + logger.info(f"SailPoint: added team '{team}' to {email}") + except Exception as e: + logger.warning(f'Failed to add team {team} for {email}: {e}') + still_teams.append(team) + remaining['last_error'] = str(e) + remaining['teams'] = still_teams + + if allow_folders: + remaining['folders'] = cls._apply_items( + params, + email, + remaining['folders'], + label='Folder', + apply_one=cls._apply_folder, + remaining=remaining, + dropped=dropped, + ) + elif remaining['folders']: + dropped.append('Pending folders skipped by allow_folders=false') + remaining['folders'] = [] + + if allow_records: + remaining['records'] = cls._apply_items( + params, + email, + remaining['records'], + label='Record', + apply_one=cls._apply_record, + remaining=remaining, + dropped=dropped, + ) + elif remaining['records']: + dropped.append('Pending records skipped by allow_records=false') + remaining['records'] = [] + + if SailPointPendingStore.entry_is_empty(remaining): + remaining = {} + return remaining, dropped diff --git a/keepercommander/service/commands/integrations/sailpoint/command_hook.py b/keepercommander/service/commands/integrations/sailpoint/command_hook.py new file mode 100644 index 000000000..ea2288b51 --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/command_hook.py @@ -0,0 +1,276 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Optional[Tuple[Any, int]]: + """Return (response, status_code) to short-circuit, or None to continue.""" + caps = read_capabilities(params, self.record_uid) + + scope_error = self._check_capability_gates(command, caps) + if scope_error: + return {'status': 'error', 'error': scope_error}, 403 + + er_error = SailPointCommandPolicy.validate_enterprise_role(command) + if er_error: + return {'status': 'error', 'error': er_error}, 403 + + invite = SailPointCommandParser.parse_invite(command) + if invite and invite.emails: + return self._before_invite(params, invite) + + share = SailPointCommandParser.parse_share(command) + if share: + target_error = validate_share_targets(params, share) + if target_error: + return {'status': 'error', 'error': target_error}, 400 + return self._before_share(params, share, caps) + + mutation = SailPointCommandParser.parse_identity_mutation(command) + if mutation: + err = self._first_scim_identity_error(params, mutation.emails) + if err: + return {'status': 'error', 'error': err}, 403 + return None + + @staticmethod + def _first_scim_identity_error(params: KeeperParams, emails: List[str]) -> Optional[str]: + for email in emails: + err = SailPointScimGuard.identity_change_error(params, email) + if err: + return err + return None + + @staticmethod + def _check_capability_gates(command: str, caps: SailPointCapabilities) -> Optional[str]: + tokens = SailPointCommandParser.tokenize(command) + if not tokens: + return None + name = tokens[0].lower() + + if name in _ER_CMDS and not caps.allow_roles: + return ( + 'SailPoint allow_roles is disabled; enterprise-role / er is not allowed.' + ) + + invite = SailPointCommandParser.parse_invite(command) + if invite: + if invite.roles and not caps.allow_roles: + return ( + 'SailPoint allow_roles is disabled; --add-role is not allowed.' + ) + if invite.teams and not caps.allow_teams: + return ( + 'SailPoint allow_teams is disabled; --add-team is not allowed.' + ) + return None + + mutation = SailPointCommandParser.parse_identity_mutation(command) + if mutation: + if mutation.has_role_change and not caps.allow_roles: + return ( + 'SailPoint allow_roles is disabled; ' + '--add-role / --remove-role is not allowed.' + ) + if mutation.has_team_change and not caps.allow_teams: + return ( + 'SailPoint allow_teams is disabled; ' + '--add-team / --remove-team is not allowed.' + ) + return None + + def after_command(self, params: KeeperParams, command: str, success: bool) -> None: + if not success: + return + invite = SailPointCommandParser.parse_invite(command) + if not invite or not invite.emails: + return + + for email in invite.emails: + logger.info(f'SailPoint: user invited {email}') + + if not invite.roles and not invite.teams: + return + + api.query_enterprise(params) + roles = list(invite.roles) if invite.roles else None + teams = list(invite.teams) if invite.teams else None + created: List[str] = [] + updated: List[str] = [] + + eligible: List[str] = [] + for email in invite.emails: + user = SailPointScimGuard.find_user(params, email) + if not user: + logger.warning( + f'SailPoint: skip pending queue; user not found after invite: {email}' + ) + continue + if SailPointScimGuard.identity_change_error(params, email): + continue + eligible.append(email) + if not eligible: + return + + def updater(pending: Dict[str, Any]) -> Dict[str, Any]: + nonlocal created, updated + result = pending + for email in eligible: + key = email.strip().lower() + existed = key in result + result = SailPointPendingStore.merge_entry( + result, email, roles=roles, teams=teams + ) + (updated if existed else created).append(email) + return result + + next_state = SailPointPendingStore.update(params, self.record_uid, updater) + self._log_pending_writes(next_state, created, updated) + + @staticmethod + def _log_pending_writes( + pending: Dict[str, Any], + created: List[str], + updated: List[str], + ) -> None: + for email in created: + entry = pending.get(email.strip().lower()) or {} + summary = SailPointPendingStore.summarize_entry(entry) + detail = f' ({summary})' if summary else '' + logger.info(f'SailPoint: pending entitlements created for {email}{detail}') + for email in updated: + entry = pending.get(email.strip().lower()) or {} + summary = SailPointPendingStore.summarize_entry(entry) + detail = f' ({summary})' if summary else '' + logger.info(f'SailPoint: pending entitlements updated for {email}{detail}') + + def _before_invite(self, params: KeeperParams, invite) -> Optional[Tuple[Any, int]]: + if not (invite.roles or invite.teams): + return None + err = self._first_scim_identity_error(params, invite.emails) + if err: + return {'status': 'error', 'error': err}, 403 + return None + + def _user_status(self, params: KeeperParams, email: str) -> Optional[str]: + user = SailPointScimGuard.find_user(params, email) + return user.get('status') if user else None + + @staticmethod + def _folder_payload(share: ParsedShare, target: str) -> Dict[str, Any]: + item: Dict[str, Any] = {'uid': target} + if share.is_nsf: + item['kind'] = 'nsf' + item['role'] = share.nsf_role or 'viewer' + else: + item['kind'] = 'classic' + item['manage_records'] = share.manage_records + item['manage_users'] = share.manage_users + return item + + @staticmethod + def _record_payload(share: ParsedShare, target: str) -> Dict[str, Any]: + item: Dict[str, Any] = {'uid': target} + if share.is_nsf: + item['kind'] = 'nsf' + item['role'] = share.nsf_role or 'viewer' + else: + item['kind'] = 'classic' + item['can_edit'] = share.can_edit + item['can_share'] = share.can_share + return item + + def _before_share( + self, + params: KeeperParams, + share: ParsedShare, + caps: SailPointCapabilities, + ) -> Optional[Tuple[Any, int]]: + # Revoke/remove/owner must run through Commander so Service Mode returns the + # native error (e.g. User Not Found for Invited users). Only grant is deferred. + if not share.is_grant: + return None + + if share.is_folder and not caps.allow_folders: + return { + 'status': 'error', + 'error': 'SailPoint allow_folders is disabled; share-folder is not allowed.', + }, 403 + if share.is_record and not caps.allow_records: + return { + 'status': 'error', + 'error': 'SailPoint allow_records is disabled; share-record is not allowed.', + }, 403 + + deferred = [e for e in share.emails if self._user_status(params, e) != 'active'] + if not deferred: + return None + + active = [e for e in share.emails if e not in deferred] + if active: + return { + 'status': 'error', + 'error': ( + 'SailPoint cannot mix Active and non-Active users in one share request. ' + f'Share Active users separately ({", ".join(active)}); ' + f'queue non-Active users separately ({", ".join(deferred)}).' + ), + }, 400 + + created: List[str] = [] + updated: List[str] = [] + + def updater(pending: Dict[str, Any]) -> Dict[str, Any]: + nonlocal created, updated + result = pending + for email in deferred: + key = email.strip().lower() + existed = key in result + if share.is_folder: + folders = [self._folder_payload(share, t) for t in share.targets] + result = SailPointPendingStore.merge_entry(result, email, folders=folders) + else: + records = [self._record_payload(share, t) for t in share.targets] + result = SailPointPendingStore.merge_entry(result, email, records=records) + (updated if existed else created).append(email) + return result + + next_state = SailPointPendingStore.update(params, self.record_uid, updater) + self._log_pending_writes(next_state, created, updated) + + return { + 'status': 'success', + 'message': f'User(s) not yet active; queued share for: {", ".join(deferred)}.', + 'queued': deferred, + }, 200 diff --git a/keepercommander/service/commands/integrations/sailpoint/command_parse.py b/keepercommander/service/commands/integrations/sailpoint/command_parse.py new file mode 100644 index 000000000..7286919aa --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/command_parse.py @@ -0,0 +1,254 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Optional[str]: + return self.targets[-1] if self.targets else None + + @property + def is_grant(self) -> bool: + """Only grant (default) is deferred for Invited users; revoke/remove run natively.""" + return (self.action or 'grant').lower() == 'grant' + + +@dataclass +class ParsedIdentityMutation: + """enterprise-user identity change (role/team/node) — used for SCIM coexistence.""" + + emails: List[str] = field(default_factory=list) + has_role_change: bool = False + has_team_change: bool = False + has_node_change: bool = False + + +class SailPointCommandParser: + """Parse enterprise-user invite and share-* command strings.""" + + @staticmethod + def tokenize(command: str) -> List[str]: + try: + return shlex.split(command) + except ValueError: + return command.split() + + @staticmethod + def _matches_flag(token: str, *names: str) -> bool: + """True for ``--flag``, ``-f``, or ``--flag=value`` forms.""" + for name in names: + if token == name: + return True + if name.startswith('--') and token.startswith(f'{name}='): + return True + return False + + @staticmethod + def _one_flag_value(token: str, tokens: List[str], index: int) -> Tuple[Optional[str], int]: + """ + Match Commander argparse append flags (one value per flag): + --add-role R1 + --add-role=R1 + """ + if '=' in token: + return token.split('=', 1)[1], index + 1 + if index + 1 < len(tokens) and not tokens[index + 1].startswith('-'): + return tokens[index + 1], index + 2 + return None, index + 1 + + @staticmethod + def _skip_unknown_flag(tokens: List[str], index: int) -> int: + """Advance past an unrecognized flag and an optional value token.""" + token = tokens[index] + if '=' in token: + return index + 1 + if index + 1 < len(tokens) and not tokens[index + 1].startswith('-'): + return index + 2 + return index + 1 + + @classmethod + def _append_flag_value( + cls, + token: str, + tokens: List[str], + index: int, + dest: List[str], + ) -> int: + value, next_i = cls._one_flag_value(token, tokens, index) + if value is not None: + dest.append(value) + return next_i + + @classmethod + def parse_invite(cls, command: str) -> Optional[ParsedInvite]: + tokens = cls.tokenize(command) + if not tokens or tokens[0] not in _EU_CMDS: + return None + + parsed = ParsedInvite() + emails: List[str] = [] + i = 1 + while i < len(tokens): + t = tokens[i] + if t in _INVITE_FLAGS: + parsed.is_invite = True + i += 1 + elif cls._matches_flag(t, '--node', '-n'): + value, i = cls._one_flag_value(t, tokens, i) + if value is not None: + parsed.node = value + elif cls._matches_flag(t, '--add-role'): + i = cls._append_flag_value(t, tokens, i, parsed.roles) + elif cls._matches_flag(t, '--add-team'): + i = cls._append_flag_value(t, tokens, i, parsed.teams) + elif t.startswith('-'): + i = cls._skip_unknown_flag(tokens, i) + else: + if '@' in t: + emails.append(t) + i += 1 + + parsed.emails = emails + return parsed if parsed.is_invite else None + + @classmethod + def parse_identity_mutation(cls, command: str) -> Optional[ParsedIdentityMutation]: + tokens = cls.tokenize(command) + if not tokens or tokens[0] not in _EU_CMDS: + return None + + emails: List[str] = [] + has_role = False + has_team = False + has_node = False + i = 1 + while i < len(tokens): + t = tokens[i] + if cls._matches_flag(t, '--add-role', '--remove-role'): + has_role = True + _, i = cls._one_flag_value(t, tokens, i) + elif cls._matches_flag(t, '--add-team', '--remove-team'): + has_team = True + _, i = cls._one_flag_value(t, tokens, i) + elif cls._matches_flag(t, '--node', '-n'): + has_node = True + _, i = cls._one_flag_value(t, tokens, i) + elif t.startswith('-'): + i = cls._skip_unknown_flag(tokens, i) + else: + if '@' in t: + emails.append(t) + i += 1 + + if not (has_role or has_team or has_node) or not emails: + return None + return ParsedIdentityMutation( + emails=emails, + has_role_change=has_role, + has_team_change=has_team, + has_node_change=has_node, + ) + + @classmethod + def parse_share(cls, command: str) -> Optional[ParsedShare]: + tokens = cls.tokenize(command) + if not tokens: + return None + name = tokens[0] + if name not in _FOLDER_CMDS and name not in _RECORD_CMDS: + return None + + parsed = ParsedShare( + command=name, + is_folder=name in _FOLDER_CMDS, + is_record=name in _RECORD_CMDS, + is_nsf=name in _NSF_FOLDER or name in _NSF_RECORD, + ) + i = 1 + positional: List[str] = [] + while i < len(tokens): + t = tokens[i] + if cls._matches_flag(t, '-e', '--email'): + value, i = cls._one_flag_value(t, tokens, i) + if value: + parsed.emails.append(value) + elif cls._matches_flag(t, '-a', '--action'): + value, i = cls._one_flag_value(t, tokens, i) + parsed.action = (value or 'grant').strip().lower() or 'grant' + elif t in ('-w', '--write'): + parsed.can_edit = True + i += 1 + elif t in ('-s', '--share') and parsed.is_record: + parsed.can_share = True + i += 1 + elif cls._matches_flag(t, '-p', '--manage-records'): + value, i = cls._one_flag_value(t, tokens, i) + if value is not None: + parsed.manage_records = value + elif cls._matches_flag(t, '-o', '--manage-users'): + value, i = cls._one_flag_value(t, tokens, i) + if value is not None: + parsed.manage_users = value + elif cls._matches_flag(t, '-r', '--role') and parsed.is_nsf: + value, i = cls._one_flag_value(t, tokens, i) + if value is not None: + parsed.nsf_role = value + elif t.startswith('-'): + i = cls._skip_unknown_flag(tokens, i) + else: + positional.append(t) + i += 1 + + if parsed.is_record: + if positional: + parsed.targets = [positional[-1]] + else: + parsed.targets = list(positional) + + return parsed if parsed.emails and parsed.targets else None diff --git a/keepercommander/service/commands/integrations/sailpoint/command_policy.py b/keepercommander/service/commands/integrations/sailpoint/command_policy.py new file mode 100644 index 000000000..7c5148e8e --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/command_policy.py @@ -0,0 +1,106 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + """ + Keep only SailPoint-allowed commands; always drop banned ones. + + Also ensures the full SailPoint allowlist is present so required + commands (e.g. enterprise-role/er) are not dropped when the input + list is a partial or older compose allowlist. + """ + allowed = {c.strip().lower() for c in SAILPOINT_ALLOWED_COMMANDS} + banned = {c.lower() for c in SAILPOINT_BANNED_COMMANDS} + filtered = [ + cmd for raw in (commands or '').split(',') + if (cmd := raw.strip()) + and (key := cmd.lower()) not in banned + and key in allowed + ] + # Input order first, then any missing required allowlist entries. + by_key = {cmd.lower(): cmd for cmd in filtered} + for cmd in SAILPOINT_ALLOWED_COMMANDS: + key = cmd.lower() + if key not in banned and key not in by_key: + by_key[key] = cmd + return ','.join(by_key.values()) + + @classmethod + def default_allowlist(cls) -> str: + return cls.sanitize(','.join(SAILPOINT_ALLOWED_COMMANDS)) + + @classmethod + def validate_enterprise_role(cls, command: str) -> Optional[str]: + """ + Restrict enterprise-role to admin/privilege ops only. + + Returns an error message when blocked, or None when allowed + (including read-only ``er ``). + """ + tokens = SailPointCommandParser.tokenize(command) + if not tokens or tokens[0].lower() not in _ENTERPRISE_ROLE_CMDS: + return None + + for token in tokens[1:]: + lower = token.lower() + if lower in _ER_BLOCKED_FLAGS or any(lower.startswith(p) for p in _ER_BLOCKED_PREFIXES): + flag = token.split('=', 1)[0] + return ( + f'SailPoint mode does not allow enterprise-role {flag}. ' + f'Allowed: {_ER_ALLOWED_HINT}.' + ) + return None diff --git a/keepercommander/service/commands/integrations/sailpoint/config_fields.py b/keepercommander/service/commands/integrations/sailpoint/config_fields.py new file mode 100644 index 000000000..ce2fa7e67 --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/config_fields.py @@ -0,0 +1,89 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bool: + """Parse common truthy/falsey config strings; empty/unknown → default.""" + if raw is None: + return default + text = str(raw).strip().lower() + if not text: + return default + if text in _TRUE_VALUES: + return True + if text in _FALSE_VALUES: + return False + return default + + +def read_capabilities(params: KeeperParams, record_uid: str) -> SailPointCapabilities: + from ..... import vault + + caps = SailPointCapabilities() + record = vault.KeeperRecord.load(params, record_uid) + if not isinstance(record, vault.TypedRecord) or not record.custom: + return caps + + by_label = {field.label: field for field in record.custom if field.label} + + def _bool_field(label: str) -> bool: + field = by_label.get(label) + return parse_bool(field.get_default_value() if field else None, default=True) + + interval = DEFAULT_POLL_INTERVAL_SECONDS + interval_field = by_label.get(POLL_INTERVAL_FIELD) + if interval_field: + try: + interval = max( + MIN_POLL_INTERVAL_SECONDS, + int(interval_field.get_default_value() or interval), + ) + except (TypeError, ValueError): + pass + + return SailPointCapabilities( + allow_folders=_bool_field(ALLOW_FOLDERS_FIELD), + allow_records=_bool_field(ALLOW_RECORDS_FIELD), + allow_roles=_bool_field(ALLOW_ROLES_FIELD), + allow_teams=_bool_field(ALLOW_TEAMS_FIELD), + poll_interval_seconds=interval, + ) diff --git a/keepercommander/service/commands/integrations/sailpoint/constants.py b/keepercommander/service/commands/integrations/sailpoint/constants.py new file mode 100644 index 000000000..7933933ea --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/constants.py @@ -0,0 +1,64 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z') + + @classmethod + def empty_entry(cls) -> Dict[str, Any]: + return { + 'created_at': cls._utc_now(), + 'last_error': None, + 'roles': [], + 'teams': [], + 'folders': [], + 'records': [], + } + + @classmethod + def load(cls, params: KeeperParams, record_uid: str) -> Dict[str, Any]: + record = vault.KeeperRecord.load(params, record_uid) + if not isinstance(record, vault.TypedRecord) or not record.custom: + return {} + field = next( + (f for f in record.custom if f.label == PENDING_ENTITLEMENTS_FIELD), + None, + ) + if not field: + return {} + raw = field.get_default_value() + if not raw: + return {} + try: + data = json.loads(raw) if isinstance(raw, str) else raw + except (TypeError, json.JSONDecodeError): + logger.warning(f'Invalid pending_entitlements JSON on record {record_uid}') + return {} + return data if isinstance(data, dict) else {} + + @classmethod + def _write(cls, params: KeeperParams, record_uid: str, pending: Dict[str, Any]) -> None: + payload = json.dumps(pending, indent=2, sort_keys=True) + record = vault.KeeperRecord.load(params, record_uid) + if not isinstance(record, vault.TypedRecord): + raise RuntimeError(f'SailPoint config record {record_uid} missing or not typed') + + preserved = [ + f for f in (record.custom or []) + if f.label != PENDING_ENTITLEMENTS_FIELD + ] + record.custom = preserved + [ + vault.TypedField.new_field('text', payload, PENDING_ENTITLEMENTS_FIELD), + ] + record_management.update_record(params, record) + params.sync_data = True + api.sync_down(params) + + @classmethod + def update( + cls, + params: KeeperParams, + record_uid: str, + updater: Callable[[Dict[str, Any]], Dict[str, Any]], + ) -> Dict[str, Any]: + """ + Sync, load, apply updater, write. Retries on stale revision so concurrent + writers merge against the latest remote state instead of overwriting it. + """ + last_error: Optional[Exception] = None + for attempt in range(1, _MAX_ATTEMPTS + 1): + try: + params.sync_data = True + api.sync_down(params) + current = cls.load(params, record_uid) + next_state = updater(deepcopy(current)) + if next_state is None: + next_state = {} + if not isinstance(next_state, dict): + raise TypeError('pending entitlements updater must return a dict') + if json.dumps(next_state, sort_keys=True) == json.dumps(current, sort_keys=True): + return current + cls._write(params, record_uid, next_state) + return next_state + except Exception as e: + last_error = e + stale = any(h in str(e).lower() for h in _STALE_HINTS) + if not stale or attempt == _MAX_ATTEMPTS: + raise RuntimeError(f'Failed to save pending entitlements: {last_error}') from e + logger.warning( + f'Stale revision updating pending entitlements; retrying ({attempt}/{_MAX_ATTEMPTS})' + ) + + raise RuntimeError(f'Failed to save pending entitlements: {last_error}') + + @staticmethod + def _merge_share_list( + existing: List[Dict[str, Any]], items: List[Dict[str, Any]], uid_key: str + ) -> List[Dict[str, Any]]: + by_uid = {str(x.get(uid_key)): dict(x) for x in existing if x.get(uid_key)} + for item in (x for x in items if x.get(uid_key)): + key = str(item[uid_key]) + if key in by_uid: + by_uid[key].update(item) + else: + by_uid[key] = dict(item) + return list(by_uid.values()) + + @classmethod + def merge_entry( + cls, + pending: Dict[str, Any], + email: str, + *, + roles: Optional[List[str]] = None, + teams: Optional[List[str]] = None, + folders: Optional[List[Dict[str, Any]]] = None, + records: Optional[List[Dict[str, Any]]] = None, + ) -> Dict[str, Any]: + result = deepcopy(pending) + key = email.strip().lower() + entry = result.get(key) or cls.empty_entry() + if 'created_at' not in entry: + entry['created_at'] = cls._utc_now() + + if roles: + entry['roles'] = sorted(set(entry.get('roles') or []) | set(roles)) + if teams: + entry['teams'] = sorted(set(entry.get('teams') or []) | set(teams)) + if folders: + entry['folders'] = cls._merge_share_list(entry.get('folders') or [], folders, 'uid') + if records: + entry['records'] = cls._merge_share_list(entry.get('records') or [], records, 'uid') + + entry['last_error'] = None + result[key] = entry + return result + + @staticmethod + def entry_is_empty(entry: Dict[str, Any]) -> bool: + return not ( + entry.get('roles') + or entry.get('teams') + or entry.get('folders') + or entry.get('records') + ) + + @staticmethod + def summarize_entry(entry: Dict[str, Any]) -> str: + """Human-readable pending payload for INFO logs (counts only for shares).""" + parts: List[str] = [] + roles = list(entry.get('roles') or []) + teams = list(entry.get('teams') or []) + folders = entry.get('folders') or [] + records = entry.get('records') or [] + if roles: + parts.append(f'roles={roles}') + if teams: + parts.append(f'teams={teams}') + if folders: + parts.append(f'folders={len(folders)}') + if records: + parts.append(f'records={len(records)}') + return ' '.join(parts) diff --git a/keepercommander/service/commands/integrations/sailpoint/poller.py b/keepercommander/service/commands/integrations/sailpoint/poller.py new file mode 100644 index 000000000..9fd9b8061 --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/poller.py @@ -0,0 +1,118 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' None: + """ + Apply entitlements outside the pending-store write path so stale-revision + retries do not re-run share/role commands. + """ + caps = read_capabilities(params, self.record_uid) + pending = SailPointPendingStore.load(params, self.record_uid) + if not pending: + return + + api.query_enterprise(params) + remaining_by_email: Dict[str, Dict[str, Any]] = {} + cleared: List[str] = [] + + for email, entry in list(pending.items()): + if not SailPointEntitlementApplier.user_is_active(params, email): + continue + logger.info( + f'SailPoint: user {email} is Active; applying pending entitlements' + ) + remaining, dropped = SailPointEntitlementApplier.apply_for_user( + params, + email, + entry, + allow_roles=caps.allow_roles, + allow_teams=caps.allow_teams, + allow_folders=caps.allow_folders, + allow_records=caps.allow_records, + ) + for msg in dropped: + logger.warning(f'SailPoint pending: {msg}') + if remaining and not SailPointPendingStore.entry_is_empty(remaining): + remaining_by_email[email] = remaining + else: + cleared.append(email) + logger.info(f'SailPoint: pending entitlements cleared for {email}') + + if not remaining_by_email and not cleared: + return + + def updater(current: Dict[str, Any]) -> Dict[str, Any]: + for email, remaining in remaining_by_email.items(): + if email in current: + current[email] = remaining + for email in cleared: + current.pop(email, None) + return current + + SailPointPendingStore.update(params, self.record_uid, updater) + + def _loop(self) -> None: + from ....core.globals import ensure_params_loaded + + logger.info('SailPoint: entitlement poller started') + while True: + interval = DEFAULT_POLL_INTERVAL_SECONDS + try: + params = ensure_params_loaded() + if params: + params.service_mode = True + from .service import SailPointService + SailPointService.bind_params(params, self.record_uid) + interval = read_capabilities(params, self.record_uid).poll_interval_seconds + self.reconcile(params) + except Exception as e: + logger.error(f'SailPoint poller cycle failed: {e}') + time.sleep(interval) + + @classmethod + def start(cls, record_uid: str) -> None: + if not record_uid: + return + with cls._lock: + if cls._started: + return + poller = cls(record_uid) + thread = threading.Thread( + target=poller._loop, name='sailpoint-entitlement-poller', daemon=True + ) + thread.start() + cls._started = True diff --git a/keepercommander/service/commands/integrations/sailpoint/scim_guard.py b/keepercommander/service/commands/integrations/sailpoint/scim_guard.py new file mode 100644 index 000000000..0e2a42812 --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/scim_guard.py @@ -0,0 +1,92 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' None: + if not params.enterprise: + api.query_enterprise(params) + + @staticmethod + def _scim_node_ids(params: KeeperParams) -> Set[int]: + nodes: Set[int] = set() + for scim in params.enterprise.get('scims') or []: + node_id = scim.get('node_id') + if node_id: + nodes.add(int(node_id)) + for node in params.enterprise.get('nodes') or []: + if node.get('scim_id'): + nodes.add(int(node['node_id'])) + return nodes + + @staticmethod + def _node_ancestors(params: KeeperParams, node_id: int) -> Iterable[int]: + by_id = {int(n['node_id']): n for n in params.enterprise.get('nodes') or []} + current: Optional[int] = node_id + seen = set() + while current and current not in seen: + yield current + seen.add(current) + parent = by_id.get(current, {}).get('parent_id') + current = int(parent) if parent and int(parent) != current else None + + @classmethod + def is_scim_managed_node(cls, params: KeeperParams, node_id: Optional[int]) -> bool: + cls.ensure_enterprise(params) + if not node_id or not params.enterprise: + return False + scim_nodes = cls._scim_node_ids(params) + if not scim_nodes: + return False + return any(n in scim_nodes for n in cls._node_ancestors(params, int(node_id))) + + @classmethod + def find_user(cls, params: KeeperParams, email: str): + cls.ensure_enterprise(params) + if not params.enterprise: + return None + target = email.strip().lower() + return next( + ( + user for user in params.enterprise.get('users') or [] + if (user.get('username') or '').lower() == target + ), + None, + ) + + @classmethod + def is_scim_managed_user(cls, params: KeeperParams, email: str) -> bool: + user = cls.find_user(params, email) + if not user: + return False + return cls.is_scim_managed_node(params, user.get('node_id')) + + @classmethod + def identity_change_error(cls, params: KeeperParams, email: str) -> Optional[str]: + if cls.is_scim_managed_user(params, email): + return ( + f'User {email} is managed by an existing SCIM provider. ' + 'SailPoint may only change folder/record (and admin) entitlements; ' + 'node/team/role identity changes are not allowed.' + ) + return None diff --git a/keepercommander/service/commands/integrations/sailpoint/service.py b/keepercommander/service/commands/integrations/sailpoint/service.py new file mode 100644 index 000000000..ccaf58555 --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/service.py @@ -0,0 +1,144 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bool: + if not record_uid: + return False + from ..... import vault + record = vault.KeeperRecord.load(params, record_uid) + if not isinstance(record, vault.TypedRecord) or not record.custom: + return False + return any( + field.label == SAILPOINT_MARKER_FIELD + and parse_bool(field.get_default_value(), default=False) + for field in record.custom + ) + + @classmethod + def record_uid(cls, params: Optional[KeeperParams] = None) -> Optional[str]: + if params is not None: + uid = getattr(params, cls.PARAMS_ATTR, None) + if uid: + return str(uid).strip() or None + env_uid = (os.environ.get(SAILPOINT_RECORD_ENV) or '').strip() + return env_uid or None + + @classmethod + def bind_params(cls, params: KeeperParams, record_uid: Optional[str] = None) -> KeeperParams: + uid = (record_uid or cls.record_uid(params) or '').strip() + if uid: + setattr(params, cls.PARAMS_ATTR, uid) + return params + + @classmethod + def maybe_enable(cls, params: KeeperParams, args) -> None: + """ + Bind params and sanitize the Service Mode command allowlist when the + SailPoint config record has the integration marker. + + Callers must gate on ``SAILPOINT_RECORD`` before invoking this. + """ + uid = cls.record_uid(params) + try: + if not cls.record_has_marker(params, uid): + logger.warning( + f'{SAILPOINT_RECORD_ENV}={uid} is set but record is missing ' + f'{SAILPOINT_MARKER_FIELD}; SailPoint mode not enabled' + ) + return + except Exception as e: + logger.warning(f'SailPoint marker check failed; mode not enabled: {e}') + return + + cls.bind_params(params, uid) + if args.commands: + cleaned = SailPointCommandPolicy.sanitize(args.commands) + if cleaned != args.commands: + print( + 'SailPoint mode: removed disallowed/sensitive commands from allowlist ' + f'before service-create.\n Was: {args.commands}\n Now: {cleaned}' + ) + args.commands = cleaned + + @classmethod + def start_background_services(cls) -> None: + """ + Start the entitlement poller when SailPoint is enabled. + + Callers must gate on ``SAILPOINT_RECORD`` before invoking this. + """ + from ....core.globals import get_current_params + params = get_current_params() + if not params: + logger.warning('SailPoint poller not started: Keeper params not loaded') + return + uid = cls.record_uid(params) + try: + if not cls.record_has_marker(params, uid): + logger.warning( + f'SailPoint poller not started: record {uid} missing {SAILPOINT_MARKER_FIELD}' + ) + return + except Exception as e: + logger.warning(f'SailPoint poller not started: marker check failed: {e}') + return + cls.bind_params(params, uid) + try: + from .poller import SailPointEntitlementPoller + SailPointEntitlementPoller.start(uid) + except Exception as e: + logger.warning(f'SailPoint poller not started: {e}') + + @classmethod + def handle_command(cls, params: KeeperParams, command: str) -> Optional[Tuple[Any, int]]: + """Callers must gate on ``SAILPOINT_RECORD`` before invoking this.""" + cls.bind_params(params) + uid = cls.record_uid(params) + if not cls.record_has_marker(params, uid): + return None + return SailPointCommandHook(uid).before_command(params, command) + + @classmethod + def after_command(cls, params: KeeperParams, command: str, success: bool = True) -> None: + """Callers must gate on ``SAILPOINT_RECORD`` before invoking this.""" + cls.bind_params(params) + uid = cls.record_uid(params) + if not cls.record_has_marker(params, uid): + return + SailPointCommandHook(uid).after_command(params, command, success) diff --git a/keepercommander/service/commands/integrations/sailpoint/share_targets.py b/keepercommander/service/commands/integrations/sailpoint/share_targets.py new file mode 100644 index 000000000..70418cc46 --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint/share_targets.py @@ -0,0 +1,121 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bool: + return bool(uid) and uid in getattr(params, 'nested_share_folders', {}) + + +def is_nsf_record(params: KeeperParams, uid: str) -> bool: + return bool(uid) and uid in getattr(params, 'nested_share_records', {}) + + +def is_classic_record(params: KeeperParams, uid: str) -> bool: + if not uid or is_nsf_record(params, uid): + return False + return uid in (params.record_cache or {}) + + +def is_classic_shared_folder(params: KeeperParams, uid: str) -> bool: + if not uid or is_nsf_folder(params, uid): + return False + if uid in (params.shared_folder_cache or {}): + return True + folder = (params.folder_cache or {}).get(uid) + if not folder: + return False + return folder.type in ( + BaseFolderNode.SharedFolderType, + BaseFolderNode.SharedFolderFolderType, + ) + + +def validate_share_targets(params: KeeperParams, share: ParsedShare) -> Optional[str]: + """ + Ensure share command family matches target type. + + Classic share-* must not target NSF UIDs, and nsf-share-* must not target + classic shared-folder / record UIDs. + """ + for uid in share.targets: + if share.is_folder and share.is_nsf: + if is_nsf_folder(params, uid): + continue + if is_classic_shared_folder(params, uid): + return ( + f'Target "{uid}" is a classic shared folder; ' + f'use share-folder instead of nsf-share-folder.' + ) + return ( + f'Target "{uid}" is not a Nested Share Folder; ' + f'nsf-share-folder requires an NSF folder UID.' + ) + + if share.is_folder and not share.is_nsf: + if is_classic_shared_folder(params, uid): + continue + if is_nsf_folder(params, uid): + return ( + f'Target "{uid}" is a Nested Share Folder; ' + f'use nsf-share-folder instead of share-folder.' + ) + return ( + f'Target "{uid}" is not a classic shared folder; ' + f'share-folder requires a shared-folder UID.' + ) + + if share.is_record and share.is_nsf: + if is_nsf_record(params, uid): + continue + if is_classic_record(params, uid): + return ( + f'Target "{uid}" is a classic record; ' + f'use share-record instead of nsf-share-record.' + ) + if is_nsf_folder(params, uid) or is_classic_shared_folder(params, uid): + return ( + f'Target "{uid}" is a folder; ' + f'nsf-share-record requires an NSF record UID.' + ) + return ( + f'Target "{uid}" is not an NSF record; ' + f'nsf-share-record requires an NSF record UID.' + ) + + if share.is_record and not share.is_nsf: + if is_classic_record(params, uid): + continue + if is_nsf_record(params, uid): + return ( + f'Target "{uid}" is an NSF record; ' + f'use nsf-share-record instead of share-record.' + ) + if is_nsf_folder(params, uid) or is_classic_shared_folder(params, uid): + return ( + f'Target "{uid}" is a folder; ' + f'share-record requires a classic record UID.' + ) + return ( + f'Target "{uid}" is not a classic record; ' + f'share-record requires a record UID.' + ) + + return None diff --git a/keepercommander/service/commands/integrations/sailpoint_app_setup.py b/keepercommander/service/commands/integrations/sailpoint_app_setup.py new file mode 100644 index 000000000..d52fd9041 --- /dev/null +++ b/keepercommander/service/commands/integrations/sailpoint_app_setup.py @@ -0,0 +1,191 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' str: + return SAILPOINT_RECORD_ENV + + def get_service_commands(self) -> str: + return SailPointCommandPolicy.default_allowlist() + + def collect_integration_config(self, params): + print(f"\n{bcolors.BOLD}SHARE ENTITLEMENTS:{bcolors.ENDC}") + print(f" Control which share entitlements SailPoint may manage via Service Mode") + allow_folders = self._prompt_yes_no('Allow folder shares?', default=True) + allow_records = self._prompt_yes_no('Allow record shares?', default=True) + + print(f"\n{bcolors.BOLD}IDENTITY ENTITLEMENTS:{bcolors.ENDC}") + print(f" Control whether SailPoint may assign roles/teams via enterprise-user") + allow_roles = self._prompt_yes_no('Allow role assignment?', default=True) + allow_teams = self._prompt_yes_no('Allow team assignment?', default=True) + + print(f"\n{bcolors.BOLD}POLL INTERVAL:{bcolors.ENDC}") + print(f" How often (seconds) to check whether invited users have become Active") + while True: + raw = input( + f"{bcolors.OKBLUE}Interval seconds " + f"[Press Enter for {DEFAULT_POLL_INTERVAL_SECONDS}]:{bcolors.ENDC} " + ).strip() + if not raw: + interval = DEFAULT_POLL_INTERVAL_SECONDS + break + try: + interval = max(MIN_POLL_INTERVAL_SECONDS, int(raw)) + break + except ValueError: + print( + f"{bcolors.FAIL}Error: Enter a whole number of seconds " + f"(>= {MIN_POLL_INTERVAL_SECONDS}){bcolors.ENDC}" + ) + + print(f"\n{bcolors.OKGREEN}{bcolors.BOLD}✓ SailPoint Configuration Complete!{bcolors.ENDC}") + return SailPointConfig( + allow_folders=allow_folders, + allow_records=allow_records, + allow_roles=allow_roles, + allow_teams=allow_teams, + poll_interval_seconds=interval, + ) + + def build_record_custom_fields(self, config): + return [ + vault.TypedField.new_field('text', 'true', SAILPOINT_MARKER_FIELD), + vault.TypedField.new_field( + 'text', 'true' if config.allow_folders else 'false', ALLOW_FOLDERS_FIELD + ), + vault.TypedField.new_field( + 'text', 'true' if config.allow_records else 'false', ALLOW_RECORDS_FIELD + ), + vault.TypedField.new_field( + 'text', 'true' if config.allow_roles else 'false', ALLOW_ROLES_FIELD + ), + vault.TypedField.new_field( + 'text', 'true' if config.allow_teams else 'false', ALLOW_TEAMS_FIELD + ), + vault.TypedField.new_field('text', str(config.poll_interval_seconds), POLL_INTERVAL_FIELD), + vault.TypedField.new_field('text', json.dumps({}), PENDING_ENTITLEMENTS_FIELD), + ] + + def _run_integration_setup(self, params, setup_result: SetupResult, + service_config: ServiceConfig, + record_name: str): + """Create/update dedicated SailPoint config record (not the Docker config record).""" + DockerSetupPrinter.print_header('SailPoint Configuration') + config = self.collect_integration_config(params) + + DockerSetupPrinter.print_step(1, 2, f"Creating SailPoint config record '{record_name}'...") + custom_fields = self.build_record_custom_fields(config) + record_uid = self._create_integration_record( + params, record_name, setup_result.folder_uid, custom_fields + ) + + DockerSetupPrinter.print_step(2, 2, 'Updating docker-compose.yml (Commander service only)...') + self._update_docker_compose(setup_result, service_config, record_uid, config) + return record_uid, config + + def _update_record_custom_fields(self, params, record_uid: str, custom_fields) -> None: + """Preserve existing pending_entitlements JSON when re-running setup.""" + existing_pending = SailPointPendingStore.load(params, record_uid) + if existing_pending: + payload = json.dumps(existing_pending, indent=2, sort_keys=True) + custom_fields = [ + f for f in custom_fields if getattr(f, 'label', None) != PENDING_ENTITLEMENTS_FIELD + ] + [vault.TypedField.new_field('text', payload, PENDING_ENTITLEMENTS_FIELD)] + super()._update_record_custom_fields(params, record_uid, custom_fields) + + def _update_docker_compose(self, setup_result, service_config, record_uid, config=None): + """Commander-only compose: COMMANDER_RECORD + SAILPOINT_RECORD (no SailPoint app container).""" + compose_file = os.path.join(os.getcwd(), 'docker-compose.yml') + compose_exists = os.path.exists(compose_file) + if compose_exists: + DockerSetupPrinter.print_warning( + 'Rewriting docker-compose.yml for SailPoint Commander service. Hand edits will be lost.' + ) + + try: + cfg = asdict(service_config) + cfg['commands'] = SailPointCommandPolicy.sanitize(cfg.get('commands') or '') + builder = DockerComposeBuilder( + setup_result, + cfg, + commander_service_name=self.get_commander_service_name(), + commander_container_name=self.get_commander_container_name(), + commander_environment={ + DOCKER_RECORD_ENV: setup_result.record_uid, + self.get_record_env_key(): record_uid, + }, + ) + with open(compose_file, 'w') as f: + f.write(builder.build()) + DockerSetupPrinter.print_success( + 'docker-compose.yml regenerated successfully' + if compose_exists else 'docker-compose.yml created successfully' + ) + except Exception as e: + raise CommandError(self.get_command_name(), f'Failed to update docker-compose.yml: {str(e)}') + + def print_integration_specific_resources(self, config): + print(f" • Allow Folders: {bcolors.OKBLUE}{config.allow_folders}{bcolors.ENDC}") + print(f" • Allow Records: {bcolors.OKBLUE}{config.allow_records}{bcolors.ENDC}") + print(f" • Allow Roles: {bcolors.OKBLUE}{config.allow_roles}{bcolors.ENDC}") + print(f" • Allow Teams: {bcolors.OKBLUE}{config.allow_teams}{bcolors.ENDC}") + print(f" • Poll Interval: {bcolors.OKBLUE}{config.poll_interval_seconds}s{bcolors.ENDC}") + print(f" • Pending JSON field: {bcolors.OKBLUE}{PENDING_ENTITLEMENTS_FIELD}{bcolors.ENDC}") + print(f" • Env key: {bcolors.OKBLUE}{self.get_record_env_key()}{bcolors.ENDC}") + + def print_integration_commands(self): + print(f"\n{bcolors.BOLD}SailPoint / Service Mode usage:{bcolors.ENDC}") + print(f" {bcolors.OKGREEN}• enterprise-user user@co.com --invite --add-role Role --add-team Team{bcolors.ENDC}") + print(f" Invite now; role/team queued until the user is Active") + print(f" {bcolors.OKGREEN}• share-record -e user@co.com RECORD_UID{bcolors.ENDC}") + print(f" {bcolors.OKGREEN}• share-folder -e user@co.com FOLDER_UID{bcolors.ENDC}") + print(f" Queued while invited; applied after activation\n") diff --git a/keepercommander/service/config/models.py b/keepercommander/service/config/models.py index f508a001b..59aa43d65 100644 --- a/keepercommander/service/config/models.py +++ b/keepercommander/service/config/models.py @@ -37,4 +37,4 @@ class ServiceConfigData: cloudflare: str = "n" cloudflare_tunnel_token: str = "" cloudflare_custom_domain: str = "" - cloudflare_public_url: str = "" \ No newline at end of file + cloudflare_public_url: str = "" diff --git a/keepercommander/service/docker/__init__.py b/keepercommander/service/docker/__init__.py index c30315181..a625e5543 100644 --- a/keepercommander/service/docker/__init__.py +++ b/keepercommander/service/docker/__init__.py @@ -20,8 +20,8 @@ """ from .models import ( - DockerSetupConstants, SetupResult, ServiceConfig, SlackConfig, TeamsConfig, SetupStep, - ApproverTeam, ApprovalsConfig, + DockerSetupConstants, SetupResult, ServiceConfig, SlackConfig, TeamsConfig, SailPointConfig, + SetupStep, ApproverTeam, ApprovalsConfig, ) from .printer import DockerSetupPrinter from .setup_base import DockerSetupBase @@ -33,6 +33,7 @@ 'ServiceConfig', 'SlackConfig', 'TeamsConfig', + 'SailPointConfig', 'ApproverTeam', 'ApprovalsConfig', 'SetupStep', diff --git a/keepercommander/service/docker/compose_builder.py b/keepercommander/service/docker/compose_builder.py index be3bf5ffa..f24083c6c 100644 --- a/keepercommander/service/docker/compose_builder.py +++ b/keepercommander/service/docker/compose_builder.py @@ -16,11 +16,14 @@ class DockerComposeBuilder: """Builds docker-compose.yml for Commander + integration services.""" - def __init__(self, setup_result, config: Dict[str, Any], commander_service_name: str = 'commander', commander_container_name: str = 'keeper-service'): + def __init__(self, setup_result, config: Dict[str, Any], commander_service_name: str = 'commander', + commander_container_name: str = 'keeper-service', + commander_environment: Dict[str, str] = None): self.setup_result = setup_result self.config = config self.commander_service_name = commander_service_name self.commander_container_name = commander_container_name + self.commander_environment = commander_environment or {} self._service_cmd_parts: List[str] = [] self._volumes: List[str] = [] self._services: Dict[str, Dict[str, Any]] = {} @@ -59,6 +62,9 @@ def _build_commander_service(self) -> Dict[str, Any]: 'healthcheck': self._build_healthcheck(), 'restart': 'unless-stopped' } + + if self.commander_environment: + service['environment'] = dict(self.commander_environment) if self._volumes: service['volumes'] = self._volumes diff --git a/keepercommander/service/docker/models.py b/keepercommander/service/docker/models.py index d81e26175..f4874d880 100644 --- a/keepercommander/service/docker/models.py +++ b/keepercommander/service/docker/models.py @@ -123,3 +123,13 @@ class TeamsConfig: device_approval_enabled: bool = False device_approval_polling_interval: int = 120 + +@dataclass +class SailPointConfig: + allow_folders: bool = True + allow_records: bool = True + allow_roles: bool = True + allow_teams: bool = True + # Keep in sync with sailpoint.constants.DEFAULT_POLL_INTERVAL_SECONDS + poll_interval_seconds: int = 60 + diff --git a/keepercommander/service/util/command_util.py b/keepercommander/service/util/command_util.py index d49839bb7..a352d6d43 100644 --- a/keepercommander/service/util/command_util.py +++ b/keepercommander/service/util/command_util.py @@ -10,9 +10,11 @@ # import io, html +import os import sys import json import logging +import shlex from typing import Any, Tuple, Optional from .config_reader import ConfigReader from .exceptions import CommandExecutionError @@ -23,6 +25,7 @@ is_throttle_error, throttle_error_response, ) +from .verified_command import Verifycommand from ..core.globals import get_current_params from ..decorators.logging import logger, debug_decorator, sanitize_debug_data from ... import cli, utils @@ -160,6 +163,26 @@ def execute(cls, command: str) -> Tuple[Any, int]: params.service_mode = True command = ensure_record_add_json_format(html.unescape(command)) + + try: + command_tokens = shlex.split(command) + except ValueError: + command_tokens = command.split() + force_error = Verifycommand.validate_enterprise_user_add_role_force( + command_tokens, params + ) + if force_error: + return {"status": "error", "error": force_error}, 400 + + sailpoint_enabled = bool((os.environ.get('SAILPOINT_RECORD') or '').strip()) + if sailpoint_enabled: + from ..commands.integrations.sailpoint.service import SailPointService + sailpoint_response = SailPointService.handle_command(params, command) + if sailpoint_response is not None: + response, status_code = sailpoint_response + response = CommandExecutor.encrypt_response(response) + return response, status_code + return_value, printed_output, log_output = CommandExecutor.capture_output_and_logs(params, command) response = return_value if return_value else printed_output @@ -173,7 +196,21 @@ def execute(cls, command: str) -> Tuple[Any, int]: # Always let the parser handle the response (including empty responses and logs) response = parse_keeper_response(command, response, log_output) response, status_code = cls._finalize_parsed_response(response) - + + if status_code == 200 and sailpoint_enabled: + try: + SailPointService.after_command(params, command, success=True) + except Exception as e: + logger.error(f'SailPoint post-process failed: {e}') + err = { + 'status': 'error', + 'error': ( + 'Command succeeded but SailPoint pending entitlement ' + f'queue failed: {e}' + ), + } + return CommandExecutor.encrypt_response(err), 500 + response = CommandExecutor.encrypt_response(response) logger.debug(f"Command executed successfully") return response, status_code diff --git a/keepercommander/service/util/verified_command.py b/keepercommander/service/util/verified_command.py index e622da0e9..2f62f5f3a 100644 --- a/keepercommander/service/util/verified_command.py +++ b/keepercommander/service/util/verified_command.py @@ -16,7 +16,7 @@ def validate_append_command(command): has_notes = bool(notes_value.strip()) break elif arg == "--notes": - # Check if there's a value after --notes flag + # Check for a value after --notes flag arg_index = command.index(arg) if arg_index + 1 < len(command) and not command[arg_index + 1].startswith("-"): notes_value = command[arg_index + 1] @@ -85,4 +85,75 @@ def validate_transform_folder_command(command): if missing_params: return f"Missing required parameters: {' and '.join(missing_params)}" - return None \ No newline at end of file + return None + + @staticmethod + def _enterprise_user_add_roles(command): + """Collect --add-role values from an enterprise-user command token list.""" + roles = [] + i = 1 + while i < len(command): + arg = command[i] + if arg == '--add-role' and i + 1 < len(command) and not command[i + 1].startswith('-'): + roles.append(command[i + 1]) + i += 2 + continue + if arg.startswith('--add-role='): + roles.append(arg.split('=', 1)[1]) + i += 1 + return roles + + @staticmethod + def _is_managed_admin_role(params, role_name): + """True when the role has administrative (managed node) permissions.""" + if not params or not getattr(params, 'enterprise', None): + return False + role_id = None + for role in params.enterprise.get('roles') or []: + display = ((role.get('data') or {}).get('displayname') or '').strip() + if str(role.get('role_id')) == str(role_name) or display.lower() == str(role_name).lower(): + role_id = role.get('role_id') + break + if role_id is None: + return False + return any( + mn.get('role_id') == role_id + for mn in (params.enterprise.get('managed_nodes') or []) + ) + + @classmethod + def validate_enterprise_user_add_role_force(cls, command, params=None): + """ + Admin roles prompt for confirmation on --add-role. Service Mode cannot + answer that prompt, so require -f/--force (same pattern as transform-folder). + Skips invite/--add flows (roles are deferred, not applied interactively). + """ + if not command or command[0] not in ('enterprise-user', 'eu'): + return None + + # Invite queues roles for later; no interactive admin-role prompt here. + if any(a in ('--invite', '--add') for a in command[1:]): + return None + + roles = cls._enterprise_user_add_roles(command) + if not roles: + return None + + has_force = any(a in ('-f', '--force') for a in command[1:]) + if has_force: + return None + + if params is not None: + try: + from ... import api + if not getattr(params, 'enterprise', None): + api.query_enterprise(params) + except Exception: + pass + if not any(cls._is_managed_admin_role(params, role) for role in roles): + return None + + return ( + 'Missing required parameters: -f/--force flag to bypass ' + 'interactive confirmation' + ) diff --git a/unit-tests/service/test_sailpoint_pending.py b/unit-tests/service/test_sailpoint_pending.py new file mode 100644 index 000000000..f8ee726fb --- /dev/null +++ b/unit-tests/service/test_sailpoint_pending.py @@ -0,0 +1,742 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Date: Mon, 27 Jul 2026 17:06:59 -0700 Subject: [PATCH 12/16] EPM summary reports: decrypt report hash values. --- keepercommander/commands/pedm/pedm_aram.py | 59 ++++++++++++++++++++-- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/keepercommander/commands/pedm/pedm_aram.py b/keepercommander/commands/pedm/pedm_aram.py index 444aa92bf..302d47b44 100644 --- a/keepercommander/commands/pedm/pedm_aram.py +++ b/keepercommander/commands/pedm/pedm_aram.py @@ -656,21 +656,74 @@ def execute(self, context: KeeperParams, **kwargs) -> Any: events = rs.get('audit_event_summary_report_rows') assert isinstance(events, list) + hash_group_cols = [col for col in (group_by or []) + if AuditMixin.field_info.get(col) and AuditMixin.field_info[col].protection == 'hash'] + hash_value_lookup: Dict[str, str] = {} + if hash_group_cols and events: + tree_key = context.enterprise['unencrypted_tree_key'] + ecc_key_data = utils.base64_url_decode(context.enterprise['keys']['ecc_encrypted_private_key']) + ec_private_key = crypto.load_ec_private_key(crypto.decrypt_aes_v2(ecc_key_data, tree_key)) + + uid_set: Set[str] = set() + for event in events: + for col in hash_group_cols: + v = event.get(col) + if isinstance(v, str): + try: + if len(utils.base64_url_decode(v)) == 16: + uid_set.add(v) + except Exception: + pass + + uid_list = [utils.base64_url_decode(x) for x in uid_set] + while uid_list: + chunk, uid_list = uid_list[:1000], uid_list[1000:] + coll_rq = pedm_pb2.AuditCollectionRequest() + coll_rq.valueUid.extend(chunk) + coll_rs = api.execute_router( + context, 'pedm/get_audit_collections', coll_rq, rs_type=pedm_pb2.AuditCollectionResponse) + if coll_rs: + for cv in coll_rs.values: + try: + uid_str = utils.base64_url_encode(cv.valueUid) + decrypted = crypto.decrypt_ec(cv.encryptedData, ec_private_key).decode('utf-8') + hash_value_lookup[uid_str] = decrypted + except Exception: + pass + if kwargs.get('format') == 'json': + if hash_value_lookup: + for event in events: + for col in hash_group_cols: + v = event.get(col) + if isinstance(v, str) and v in hash_value_lookup: + event[f'{col}_value'] = hash_value_lookup[v] return json.dumps(events, indent=2) if not events: return - headers = [] + headers: List[str] = [] if report_type != 'span': headers.append('event_time') headers.extend(aggregate) if group_by: - headers.extend(group_by) + for col in group_by: + headers.append(col) + if col in hash_group_cols: + headers.append(f'{col}_value') + + virtual_col_set: Set[str] = {f'{col}_value' for col in hash_group_cols} rows: List[List[Any]] = [] for event in events: - rows.append([AuditMixin.get_field_value(x, event.get(x), report_type=report_type) for x in headers]) + row: List[Any] = [] + for h in headers: + if h in virtual_col_set: + uid = event.get(h[:-6]) # strip '_value' suffix + row.append(hash_value_lookup.get(uid, '') if isinstance(uid, str) else '') + else: + row.append(AuditMixin.get_field_value(h, event.get(h), report_type=report_type)) + rows.append(row) headers = [report_utils.field_to_title(x) for x in headers] return report_utils.dump_report_data( From 41acfc051e378a24a9114aeaf9e9cd29ca649bdd Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Thu, 30 Jul 2026 12:24:39 -0700 Subject: [PATCH 13/16] Release 18.0.14 --- keepercommander/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keepercommander/__init__.py b/keepercommander/__init__.py index 202bbe42c..3610ed2cf 100644 --- a/keepercommander/__init__.py +++ b/keepercommander/__init__.py @@ -10,4 +10,4 @@ # Contact: commander@keepersecurity.com # -__version__ = '18.0.13' +__version__ = '18.0.14' From c284d59a43b5cda269ce681b1ea772002b3eee51 Mon Sep 17 00:00:00 2001 From: jeremy-london Date: Thu, 9 Jul 2026 17:29:00 -0600 Subject: [PATCH 14/16] feat: adds support for download and list by user downloads # Conflicts: # keepercommander/commands/discoveryrotation.py --- keepercommander/commands/discoveryrotation.py | 15 +- .../commands/pam/recording_commands.py | 440 +++++++++++ keepercommander/commands/pam/router_helper.py | 21 + keepercommander/proto/pam_pb2.py | 98 ++- tests/test_pam_recording_commands.py | 717 ++++++++++++++++++ 5 files changed, 1239 insertions(+), 52 deletions(-) create mode 100644 keepercommander/commands/pam/recording_commands.py create mode 100644 tests/test_pam_recording_commands.py diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index 569c9e0bd..0f93b56b2 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -27,6 +27,7 @@ json_output_parser, field_to_title, toggle_pam_legacy_commands) 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.vault_target import ( format_pam_folder_display, resolve_pam_folder_uid, is_nested_share_folder, @@ -277,7 +278,7 @@ def uses_default_rotation_schedule(params, record_uid, configuration_uid): if not isinstance(record_schedule, list) or len(record_schedule) == 0: return False return record_schedule == default_schedule - + def _valid_record_uids(uids): return [uid for uid in (uids or []) if RecordV3.is_valid_ref_uid(uid)] @@ -324,6 +325,7 @@ def __init__(self): 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('cnapp', PAMCnappCommand(), 'Manage CNAPP integrations', 'cn') + self.register_command('recording', PAMRecordingCommand(), 'Manage PAM Session Recordings', 'rec') class PAMGatewayCommand(GroupCommand): @@ -363,6 +365,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() From 0964925683da551f6995051653a68398f54bcc67 Mon Sep 17 00:00:00 2001 From: lthievenaz-keeper Date: Thu, 30 Jul 2026 20:38:33 +0100 Subject: [PATCH 15/16] Correct supported time definitions for self-destruct flags (#2250) * Correct the help dialog for Record Add --self-destruct Before: `[(m)inutes|(h)ours|(d)ays]` After: `[(mi)nutes|(h)ours|(d)ays|(mo)nths|(y)ears]` * Correct self destruct time definitions Corrected the shorthand for minutes (`mi`, not `m`) Added `mo`nths and `y`ears definitions * Correct the help dialog for PAM Rotate --self-destruct Before: `[(m)inutes|(h)ours|(d)ays]` After: `[(mi)inutes|(h)ours|(d)ays|(mo)nths|(y)ears]` --- RECORD_ADD_DOCUMENTATION.md | 10 ++++++---- keepercommander/commands/discoveryrotation.py | 2 +- keepercommander/commands/record_edit.py | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/RECORD_ADD_DOCUMENTATION.md b/RECORD_ADD_DOCUMENTATION.md index 99372f7c2..69fd803aa 100644 --- a/RECORD_ADD_DOCUMENTATION.md +++ b/RECORD_ADD_DOCUMENTATION.md @@ -403,13 +403,15 @@ The `--self-destruct` option creates temporary records that automatically delete ### Syntax ```bash ---self-destruct [(m)inutes|(h)ours|(d)ays] +--self-destruct [(mi)nutes|(h)ours|(d)ays|(mo)nths|(y)ears] ``` **Time Units:** -- `m` or `minutes` - Minutes (default if no unit specified) +- `mi` or `minutes` - Minutes (default if no unit specified) - `h` or `hours` - Hours - `d` or `days` - Days +- `mo` or `months` - Months +- `y` or `years` - Years ### Examples @@ -428,7 +430,7 @@ record-add -t "Temporary Server Access" -rt login \ record-add -t "Guest WiFi Access" -rt wifiCredentials \ text.ssid="Company-Guest" \ password=TempPass123 \ - --self-destruct 30m \ + --self-destruct 30mi \ --notes "Visitor access for meeting" ``` @@ -647,4 +649,4 @@ record-add --syntax-help # View record-update syntax help record-update --help -``` \ No newline at end of file +``` diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index 0f93b56b2..8e8aaa5af 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -3722,7 +3722,7 @@ class PAMGatewayActionRotateCommand(Command): # Email and share link arguments parser.add_argument('--self-destruct', dest='self_destruct', action='store', - metavar='[(m)inutes|(h)ours|(d)ays]', + metavar='[(mi)inutes|(h)ours|(d)ays|(mo)nths|(y)ears]', help='Create one-time share link that expires after duration') parser.add_argument('--email-config', dest='email_config', action='store', help='Email configuration name to use for sending (required with --send-email)') diff --git a/keepercommander/commands/record_edit.py b/keepercommander/commands/record_edit.py index 2f04e7136..c0750fdb1 100644 --- a/keepercommander/commands/record_edit.py +++ b/keepercommander/commands/record_edit.py @@ -50,7 +50,7 @@ record_add_parser.add_argument('--folder', dest='folder', action='store', help='folder name or UID to store record') record_add_parser.add_argument('--self-destruct', dest='self_destruct', action='store', - metavar='[(m)inutes|(h)ours|(d)ays]', + metavar='[(mi)nutes|(h)ours|(d)ays|(mo)nths|(y)ears]', help='Time period record share URL is valid. The record will be deleted in your vault in 5 minutes since open') record_add_parser.add_argument('--pam-config', dest='pam_config', action='store', help='PAM configuration UID or name to sync password to cloud provider (Azure AD, AWS IAM)') From 55fc5baaa91c8f6207720170c3a1ff32943e77ec Mon Sep 17 00:00:00 2001 From: lthievenaz-keeper Date: Thu, 30 Jul 2026 08:32:30 +0100 Subject: [PATCH 16/16] Change help dialog for enterprise-role --new-user Changed the help message for enterprise-role --new-user argument: > Before: `assign this role to new users. \'add\' only` > After: `set this role as default for nodes and subnodes` There is no limitation for using this arg with or without the --add flag --- keepercommander/commands/enterprise.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keepercommander/commands/enterprise.py b/keepercommander/commands/enterprise.py index c1f592b9e..437a801dc 100644 --- a/keepercommander/commands/enterprise.py +++ b/keepercommander/commands/enterprise.py @@ -194,7 +194,7 @@ def register_command_info(aliases, command_info): enterprise_role_parser.add_argument('--copy', dest='copy', action='store_true', help='copy role with enforcements') enterprise_role_parser.add_argument('--clone', dest='clone', action='store_true', help='copy role with users and enforcements') #enterprise_role_parser.add_argument('--visible-below', dest='visible_below', action='store', choices=['on', 'off'], help='visible to all nodes. \'add\' only') -enterprise_role_parser.add_argument('--new-user', dest='new_user', action='store', choices=['on', 'off'], help='assign this role to new users. \'add\' only') +enterprise_role_parser.add_argument('--new-user', dest='new_user', action='store', choices=['on', 'off'], help='set as default for node and subnodes') enterprise_role_parser.add_argument('--delete', dest='delete', action='store_true', help='delete role') enterprise_role_parser.add_argument('--node', dest='node', action='store', help='node Name or ID') enterprise_role_parser.add_argument('--name', dest='name', action='store', help='role\'s new name')