From 2204862c5819cc82137900c63f600723594565ed Mon Sep 17 00:00:00 2001 From: Ivan Dimov <78815270+idimov-keeper@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:20:41 -0500 Subject: [PATCH 1/9] Fix Windows icacls grant for KC config when COMPUTERNAME equals USERNAME Use DOMAIN\username for icacls /grant instead of bare os.getlogin(), which fails when the machine name and username are the same string. --- keepercommander/utils.py | 17 +++++- unit-tests/test_windows_file_permissions.py | 68 +++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 unit-tests/test_windows_file_permissions.py diff --git a/keepercommander/utils.py b/keepercommander/utils.py index 3a6f84119..41d3f3829 100644 --- a/keepercommander/utils.py +++ b/keepercommander/utils.py @@ -90,6 +90,19 @@ def generate_aes_key(): # type: () -> bytes return crypto.get_random_bytes(32) +def _windows_icacls_principal(): # type: () -> str + """Return a DOMAIN\\username principal suitable for icacls /grant on Windows.""" + username = os.environ.get('USERNAME', '') + if not username: + username = os.getlogin() + if '\\' in username: + return username + domain = os.environ.get('USERDOMAIN') or os.environ.get('COMPUTERNAME', '') + if domain: + return f'{domain}\\{username}' + return username + + def set_file_permissions(file_path): # type: (str) -> None """ Set secure file permissions (600) for configuration files containing sensitive data. @@ -111,10 +124,10 @@ def set_file_permissions(file_path): # type: (str) -> None os.chmod(file_path, stat.S_IRUSR | stat.S_IWUSR) logging.debug(f'Set secure permissions (600) for file: {file_path}') else: - username = os.getlogin() + principal = _windows_icacls_principal() subprocess.run(["icacls", file_path, "/inheritance:r"], check=True, capture_output=True) subprocess.run(["icacls", file_path, "/remove", "NT AUTHORITY\\SYSTEM", "BUILTIN\\Administrators"], check=False, capture_output=True) - subprocess.run(["icacls", file_path, "/grant", f"{username}:RW"], check=True, capture_output=True) + subprocess.run(["icacls", file_path, "/grant", f"{principal}:RW"], check=True, capture_output=True) logging.debug(f'Set secure permissions (owner RW only) for Windows file: {file_path}') except Exception: logging.warning(f'Failed to set file permissions for {file_path}') diff --git a/unit-tests/test_windows_file_permissions.py b/unit-tests/test_windows_file_permissions.py new file mode 100644 index 000000000..c058da26e --- /dev/null +++ b/unit-tests/test_windows_file_permissions.py @@ -0,0 +1,68 @@ +import os +import tempfile +from unittest import TestCase, mock + +from keepercommander import utils + + +class TestWindowsIcaclsPrincipal(TestCase): + def test_userdomain_and_username(self): + with mock.patch.dict(os.environ, {'USERNAME': 'ivan', 'USERDOMAIN': 'IVAN'}, clear=False): + self.assertEqual(utils._windows_icacls_principal(), 'IVAN\\ivan') + + def test_domain_user(self): + with mock.patch.dict(os.environ, {'USERNAME': 'jdoe', 'USERDOMAIN': 'CORP'}, clear=False): + self.assertEqual(utils._windows_icacls_principal(), 'CORP\\jdoe') + + def test_falls_back_to_computername(self): + env = os.environ.copy() + env.pop('USERDOMAIN', None) + with mock.patch.dict(os.environ, env, clear=True): + os.environ['USERNAME'] = 'bob' + os.environ['COMPUTERNAME'] = 'MYPC' + self.assertEqual(utils._windows_icacls_principal(), 'MYPC\\bob') + + def test_already_qualified_username(self): + with mock.patch.dict(os.environ, {'USERNAME': 'CORP\\jdoe', 'USERDOMAIN': 'CORP'}, clear=False): + self.assertEqual(utils._windows_icacls_principal(), 'CORP\\jdoe') + + def test_falls_back_to_getlogin(self): + with mock.patch.dict(os.environ, {}, clear=True): + with mock.patch('os.getlogin', return_value='localuser'): + with mock.patch.dict(os.environ, {'COMPUTERNAME': 'MYPC'}, clear=False): + self.assertEqual(utils._windows_icacls_principal(), 'MYPC\\localuser') + + +class TestSetFilePermissionsWindows(TestCase): + def _grant_principal(self, mock_run): + for call in mock_run.call_args_list: + args = call.args[0] + if '/grant' in args: + return args[args.index('/grant') + 1] + self.fail('icacls /grant was not called') + + @mock.patch('subprocess.run') + @mock.patch('platform.system', return_value='Windows') + @mock.patch('os.path.islink', return_value=False) + def test_grant_uses_qualified_principal_when_names_collide(self, _islink, _system, mock_run): + with tempfile.NamedTemporaryFile(delete=False) as tmp: + path = tmp.name + try: + with mock.patch.dict(os.environ, {'USERNAME': 'ivan', 'USERDOMAIN': 'IVAN'}, clear=False): + utils.set_file_permissions(path) + self.assertEqual(self._grant_principal(mock_run), 'IVAN\\ivan:RW') + finally: + os.unlink(path) + + @mock.patch('subprocess.run') + @mock.patch('platform.system', return_value='Windows') + @mock.patch('os.path.islink', return_value=False) + def test_grant_uses_domain_principal(self, _islink, _system, mock_run): + with tempfile.NamedTemporaryFile(delete=False) as tmp: + path = tmp.name + try: + with mock.patch.dict(os.environ, {'USERNAME': 'jdoe', 'USERDOMAIN': 'CORP'}, clear=False): + utils.set_file_permissions(path) + self.assertEqual(self._grant_principal(mock_run), 'CORP\\jdoe:RW') + finally: + os.unlink(path) From e61ccec090d1afe35002e90033787818e54b286d Mon Sep 17 00:00:00 2001 From: Joao Paulo Oliveira Santos Date: Wed, 8 Jul 2026 18:16:14 -0400 Subject: [PATCH 2/9] Add CNAPP integration commands and helpers (#2102) * KC-1290: CNAPP integration commands and PAM graph migration Re-applied on top of origin/release after sync-branch. Includes CNAPP command helpers, PAM graph endpoint migration, and related unit test updates. * Fix time-dependent enterprise API key list tests for UTC CI Move SIEM Tool mock expiration to 2030 so status detection tests remain deterministic when CI runs in UTC after the token expiry date. --------- Co-authored-by: Ivan Dimov <78815270+idimov-keeper@users.noreply.github.com> --- keepercommander/commands/discoveryrotation.py | 5 +- .../commands/pam/cnapp_commands.py | 650 +++++++++++ keepercommander/commands/pam/cnapp_helper.py | 265 +++++ keepercommander/proto/cnapp_pb2.py | 181 +++ unit-tests/conftest.py | 12 + unit-tests/pam/test_cnapp.py | 1001 +++++++++++++++++ unit-tests/pam/test_dag_layer_b_migration.py | 31 + .../test_command_enterprise_api_keys.py | 4 +- 8 files changed, 2146 insertions(+), 3 deletions(-) create mode 100644 keepercommander/commands/pam/cnapp_commands.py create mode 100644 keepercommander/commands/pam/cnapp_helper.py create mode 100644 keepercommander/proto/cnapp_pb2.py create mode 100644 unit-tests/conftest.py create mode 100644 unit-tests/pam/test_cnapp.py diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index 031665ac2..17fb6fe4d 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -17,8 +17,8 @@ import re import time from datetime import datetime +from typing import Dict, Optional, Any, Set, List from urllib.parse import urlparse, urlunparse -from typing import Optional, List import requests from keeper_secrets_manager_core.utils import url_safe_str_to_bytes @@ -93,6 +93,7 @@ from .pam_saas.config import PAMActionSaasConfigCommand from .pam_saas.update import PAMActionSaasUpdateCommand from .tunnel_and_connections import PAMTunnelCommand, PAMConnectionCommand, PAMRbiCommand, PAMSplitCommand +from .pam.cnapp_commands import PAMCnappCommand from .universalsecretsync import ( PAMUniversalSyncConfigCommand, PAMUniversalSyncRunCommand @@ -287,6 +288,8 @@ def __init__(self): self.register_command('workflow', PAMWorkflowCommand(), 'Manage PAM Workflows', 'w') self.register_command('access', PAMPrivilegedAccessCommand(), 'Manage privileged cloud access operations', 'ac') + self.register_command('cnapp', PAMCnappCommand(), + 'Manage Cloud-Native Application Protection Platform integration', 'cn') self.register_command('universal-sync-config', PAMUniversalSyncConfigCommand(), 'Manage Universal Sync Configurations', 'usc') self.register_command('universal-sync-run', PAMUniversalSyncRunCommand(), 'Run Universal Sync', 'usr') diff --git a/keepercommander/commands/pam/cnapp_commands.py b/keepercommander/commands/pam/cnapp_commands.py new file mode 100644 index 000000000..bce1d5d16 --- /dev/null +++ b/keepercommander/commands/pam/cnapp_commands.py @@ -0,0 +1,650 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bytes|None + """Encrypter keys are typically `openssl rand -base64 32`. Try standard base64 first, + then base64url (legacy notes). 32 bytes only (AES-256) — anything else is rejected so we + don't pass garbage to AES-GCM.""" + if not raw or not isinstance(raw, str): + return None + candidate = raw.strip() + for decoder in (base64.b64decode, base64.urlsafe_b64decode): + try: + padding = '=' * (-len(candidate) % 4) + data = decoder(candidate + padding) + except (binascii.Error, ValueError): + continue + if len(data) == 32: + return data + return None + + +def _load_encrypter_key(params, config_record_uid): + """Resolve the AES key from the CNAPP encrypter vault record. Returns None when the + record can't be loaded or doesn't carry a recognizable key — callers should fall back + to showing the encrypted payload as-is.""" + if not config_record_uid: + return None + try: + record = vault.KeeperRecord.load(params, config_record_uid) + except Exception as e: + logger.debug('CNAPP: failed to load encrypter record %s: %s', config_record_uid, e) + return None + if not isinstance(record, vault.TypedRecord): + return None + # Match vault/cloudSecurityUtils.ts: prefer `secret` then `note` labeled "Encryption Key", + # then the first unlabeled `note` field only when no labeled key field exists. + labeled_raws = [] + secret_field = record.get_typed_field('secret', CNAPP_ENCRYPTION_KEY_LABEL) + if secret_field and secret_field.value: + labeled_raws.append(secret_field.value[0]) + note_labeled = record.get_typed_field('note', CNAPP_ENCRYPTION_KEY_LABEL) + if note_labeled and note_labeled.value: + labeled_raws.append(note_labeled.value[0]) + for raw in labeled_raws: + key = _decode_aes_key(raw) + if key: + return key + if labeled_raws: + logger.warning( + 'CNAPP: "%s" field is present on encrypter record %s but is not a valid AES-256 key; ' + 'not using other note fields.', + CNAPP_ENCRYPTION_KEY_LABEL, config_record_uid, + ) + return None + first_note = record.get_typed_field('note') + if first_note and first_note.value: + key = _decode_aes_key(first_note.value[0]) + if key: + return key + return None + + +def _decrypt_cnapp_payload(payload_bytes, key): + """Decrypt a CNAPP queue payload using the Encrypter's AES-256-GCM key. + + Wire format (matches vault's `decryptCnappQueueItem` in cloudSecurityUtils.ts): + payload_bytes (proto field, base64url-decoded by us) is UTF-8 base64url text + of a JSON envelope `{"encrypted_payload":"","alg":"AES-256-GCM","version":"1"}`. + encrypted_payload base64url-decodes to `nonce(12) || ciphertext || tag(16)` — + the standard layout AESGCM.decrypt expects. + + Returns a dict on success; raises Exception on bad envelope / wrong key / bad alg + so the caller can surface a meaningful warning.""" + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + envelope_b64 = payload_bytes.decode('utf-8') + envelope_json = base64.urlsafe_b64decode(envelope_b64 + '=' * (-len(envelope_b64) % 4)) + envelope = json.loads(envelope_json) + alg = envelope.get('alg') + if alg != 'AES-256-GCM': + raise ValueError(f"Unsupported or missing CNAPP payload algorithm: {alg!r}") + ciphertext_b64 = envelope.get('encrypted_payload') or '' + ciphertext = base64.urlsafe_b64decode(ciphertext_b64 + '=' * (-len(ciphertext_b64) % 4)) + if len(ciphertext) < 12 + 16: + raise ValueError('CNAPP ciphertext shorter than nonce+tag — corrupt payload') + nonce, body = ciphertext[:12], ciphertext[12:] + plaintext = AESGCM(key).decrypt(nonce, body, None) + return json.loads(plaintext.decode('utf-8')) + + +def _resolve_status(value, allow_all=True): # type: (str|int|None, bool) -> int + """Accept either the numeric status id or its case-insensitive name.""" + if value is None or value == '': + status_id = 0 + elif isinstance(value, int): + status_id = value + else: + s = str(value).strip().lower() + if s.lstrip('-').isdigit(): + status_id = int(s) + elif s in QUEUE_STATUS_BY_NAME: + status_id = QUEUE_STATUS_BY_NAME[s] + else: + raise CommandError( + 'pam cnapp', + f"Unknown status '{value}'. Valid: {', '.join(QUEUE_STATUS_BY_NAME)} or 0 for ALL.", + ) + if status_id == 0: + if allow_all: + return 0 + raise CommandError('pam cnapp', 'A specific status is required (cannot be 0/ALL).') + if status_id not in QUEUE_STATUS_BY_ID: + raise CommandError( + 'pam cnapp', + f"Unknown status id {status_id}. Valid ids: {', '.join(str(i) for i in sorted(QUEUE_STATUS_BY_ID))}.", + ) + return status_id + + +def _format_timestamp(epoch_ms): + """krouter emits epoch-millis for received/resolved timestamps; render as UTC ISO.""" + if not epoch_ms: + return '' + try: + return datetime.fromtimestamp(int(epoch_ms) / 1000, tz=timezone.utc).isoformat() + except (ValueError, TypeError, OSError): + return f'' + + +class PAMCnappCommand(GroupCommand): + """Root for the `pam cnapp ...` command tree.""" + + def __init__(self): + super(PAMCnappCommand, self).__init__() + self.register_command('config', PAMCnappConfigCommand(), + 'Manage CNAPP provider configuration', 'c') + self.register_command('queue', PAMCnappQueueCommand(), + 'Manage CNAPP issue queue', 'q') + self.default_verb = 'queue' + + +# --------------------------------------------------------------------------- +# Configuration sub-tree +# --------------------------------------------------------------------------- + +class PAMCnappConfigCommand(GroupCommand): + + def __init__(self): + super(PAMCnappConfigCommand, self).__init__() + self.register_command('set', PAMCnappConfigSetCommand(), + 'Create or update CNAPP provider configuration') + self.register_command('test', PAMCnappConfigTestCommand(), + 'Validate CNAPP provider credentials without saving') + self.register_command('test-encrypter', PAMCnappConfigTestEncrypterCommand(), + 'Health-check the customer Encrypter at /health') + self.register_command('read', PAMCnappConfigReadCommand(), + 'Read the persisted CNAPP configuration for a network') + self.register_command('delete', PAMCnappConfigDeleteCommand(), + 'Delete the CNAPP configuration on a network') + self.default_verb = '' + + +def _add_configuration_args(parser, require_secret=True, optional_secret_on_set=False): + parser.add_argument('--network-uid', '-n', required=True, dest='network_uid', + help='Network record UID (base64url).') + parser.add_argument('--provider', '-p', required=True, dest='provider', + help='CNAPP provider keyword: wiz (case-insensitive).') + parser.add_argument('--client-id', required=True, dest='client_id', + help='Provider API client ID / app ID.') + if optional_secret_on_set: + parser.add_argument('--client-secret', required=False, default=None, dest='client_secret', + help='Provider API client secret. Omit on `config set` to keep the existing secret.') + else: + parser.add_argument('--client-secret', required=require_secret, dest='client_secret', + help='Provider API client secret.') + parser.add_argument('--api-endpoint', required=True, dest='api_endpoint_url', + help='Provider API endpoint URL (e.g. https://api.us1.app.wiz.io/graphql).') + parser.add_argument('--auth-endpoint', required=True, dest='auth_endpoint_url', + help='Provider OAuth2 token endpoint URL (e.g. https://auth.app.wiz.io/oauth/token).') + + +class PAMCnappConfigSetCommand(Command): + parser = argparse.ArgumentParser(prog='pam cnapp config set') + _add_configuration_args(parser, optional_secret_on_set=True) + parser.add_argument('--config-record', required=True, dest='cnapp_config_record_uid', + help='UID of the vault record holding the Encrypter URL + key.') + + def get_parser(self): + return PAMCnappConfigSetCommand.parser + + def execute(self, params, **kwargs): + provider = cnapp_helper.provider_from_name(kwargs.get('provider')) + response = cnapp_helper.set_cnapp_configuration( + params, + network_uid=kwargs.get('network_uid'), + provider=provider, + client_id=kwargs.get('client_id'), + client_secret='' if kwargs.get('client_secret') is None else kwargs.get('client_secret'), + api_endpoint_url=kwargs.get('api_endpoint_url'), + cnapp_config_record_uid=kwargs.get('cnapp_config_record_uid'), + auth_endpoint_url=kwargs.get('auth_endpoint_url'), + ) + print(f"{bcolors.OKGREEN}CNAPP configuration saved.{bcolors.ENDC}") + if response is not None: + _print_configuration(response) + return None + + +class PAMCnappConfigTestCommand(Command): + parser = argparse.ArgumentParser(prog='pam cnapp config test') + _add_configuration_args(parser, require_secret=True) + + def get_parser(self): + return PAMCnappConfigTestCommand.parser + + def execute(self, params, **kwargs): + provider = cnapp_helper.provider_from_name(kwargs.get('provider')) + cnapp_helper.test_cnapp_configuration( + params, + network_uid=kwargs.get('network_uid'), + provider=provider, + client_id=kwargs.get('client_id'), + client_secret=kwargs.get('client_secret'), + api_endpoint_url=kwargs.get('api_endpoint_url'), + auth_endpoint_url=kwargs.get('auth_endpoint_url'), + ) + print(f"{bcolors.OKGREEN}CNAPP credentials validated successfully.{bcolors.ENDC}") + + +class PAMCnappConfigTestEncrypterCommand(Command): + parser = argparse.ArgumentParser(prog='pam cnapp config test-encrypter') + parser.add_argument('--url', '-u', required=True, dest='url', + help='Base URL of the Encrypter. krouter probes /health.') + + def get_parser(self): + return PAMCnappConfigTestEncrypterCommand.parser + + def execute(self, params, **kwargs): + cnapp_helper.test_cnapp_encrypter(params, url_base_encrypter=kwargs.get('url')) + print(f"{bcolors.OKGREEN}Encrypter is reachable.{bcolors.ENDC}") + + +class PAMCnappConfigReadCommand(Command): + parser = argparse.ArgumentParser(prog='pam cnapp config read') + parser.add_argument('--network-uid', '-n', required=True, dest='network_uid', + help='Network record UID (base64url).') + parser.add_argument('--provider', '-p', required=True, dest='provider', + help='CNAPP provider keyword: wiz.') + parser.add_argument('--format', dest='format', choices=['table', 'json'], default='table', + help='Output format.') + + def get_parser(self): + return PAMCnappConfigReadCommand.parser + + def execute(self, params, **kwargs): + provider = cnapp_helper.provider_from_name(kwargs.get('provider')) + response = cnapp_helper.read_cnapp_configuration( + params, + network_uid=kwargs.get('network_uid'), + provider=provider, + ) + if response is None: + logger.warning('No CNAPP configuration returned.') + return None + if kwargs.get('format') == 'json': + print(json.dumps(_configuration_to_dict(response), indent=2)) + return None + _print_configuration(response) + return None + + +class PAMCnappConfigDeleteCommand(Command): + parser = argparse.ArgumentParser(prog='pam cnapp config delete') + parser.add_argument('--network-uid', '-n', required=True, dest='network_uid', + help='Network record UID (base64url).') + + def get_parser(self): + return PAMCnappConfigDeleteCommand.parser + + def execute(self, params, **kwargs): + cnapp_helper.delete_cnapp_configuration(params, network_uid=kwargs.get('network_uid')) + print(f"{bcolors.OKGREEN}CNAPP configuration deleted.{bcolors.ENDC}") + + +# --------------------------------------------------------------------------- +# Queue sub-tree +# --------------------------------------------------------------------------- + +class PAMCnappQueueCommand(GroupCommand): + + def __init__(self): + super(PAMCnappQueueCommand, self).__init__() + self.register_command('list', PAMCnappQueueListCommand(), 'List CNAPP queue items', 'l') + self.register_command('associate', PAMCnappQueueAssociateCommand(), + 'Attach a vault record to a queue item', 'a') + self.register_command('remediate', PAMCnappQueueRemediateCommand(), + 'Trigger a remediation action against the gateway', 'r') + self.register_command('set-status', PAMCnappQueueSetStatusCommand(), + 'Update local queue item status (notifies provider best-effort)', 's') + self.register_command('delete', PAMCnappQueueDeleteCommand(), 'Delete a queue item', 'd') + self.default_verb = 'list' + + +class PAMCnappQueueListCommand(Command): + parser = argparse.ArgumentParser(prog='pam cnapp queue list') + parser.add_argument('--network-uid', '-n', required=True, dest='network_uid', + help='Network record UID (base64url).') + parser.add_argument('--status', '-s', required=False, dest='status', default=0, + help='Filter by status name or id (pending/in_progress/resolved/failed/cancelled). Default: all.') + parser.add_argument('--provider', '-p', required=False, dest='provider', default='wiz', + help='CNAPP provider keyword for the config lookup (default: wiz).') + parser.add_argument('--config-record', required=False, dest='config_record_uid', + help='Explicit encrypter vault record UID. Overrides the lookup via `config read`.') + parser.add_argument('--no-decrypt', dest='no_decrypt', action='store_true', + help="Skip payload decryption — show the raw encrypted envelope's metadata only.") + parser.add_argument('--format', dest='format', choices=['table', 'json'], default='table', + help='Output format. Table and JSON are mutually exclusive.') + + def get_parser(self): + return PAMCnappQueueListCommand.parser + + def _resolve_encrypter_key(self, params, kwargs): + """Resolve the AES key: --config-record wins; otherwise fetch `config read` to get + the cnappConfigRecordUid and load the encrypter record from the local vault.""" + if kwargs.get('no_decrypt'): + return None, None + config_record_uid = kwargs.get('config_record_uid') + if not config_record_uid: + try: + provider = cnapp_helper.provider_from_name(kwargs.get('provider') or 'wiz') + config = cnapp_helper.read_cnapp_configuration( + params, network_uid=kwargs.get('network_uid'), provider=provider) + except Exception as e: + logger.debug('CNAPP: could not read configuration for decryption: %s', e) + return None, None + if config is None or not config.cnappConfigRecordUid: + return None, None + config_record_uid = bytes_to_base64(config.cnappConfigRecordUid) + key = _load_encrypter_key(params, config_record_uid) + return key, config_record_uid + + @staticmethod + def _decrypted_summary(decrypted): + """Compact human-readable summary for the table column. Mirrors the columns the + vault Cloud Security view shows: severity, title, resource.""" + if not isinstance(decrypted, dict): + return '' + issue = decrypted.get('issue') or {} + resource = decrypted.get('resource') or {} + control = decrypted.get('control') or {} + bits = [] + sev = issue.get('severity') + if sev: + bits.append(str(sev).upper()) + title = control.get('name') or issue.get('id') + if title: + bits.append(str(title)) + resource_name = resource.get('name') or resource.get('id') + if resource_name: + bits.append(f"on {resource_name}") + return ' · '.join(bits) + + def execute(self, params, **kwargs): + status_filter = _resolve_status(kwargs.get('status')) + response = cnapp_helper.list_cnapp_queue( + params, + network_uid=kwargs.get('network_uid'), + status_filter=status_filter, + ) + items = list(response.items) if response is not None else [] + has_more = bool(response.hasMore) if response is not None else False + + encrypter_key, encrypter_uid = self._resolve_encrypter_key(params, kwargs) + decrypted_by_id = {} + decrypt_errors = {} # type: dict[int, str] + if encrypter_key: + for item in items: + if not item.payload: + continue + try: + decrypted_by_id[item.cnappQueueId] = _decrypt_cnapp_payload(item.payload, encrypter_key) + except Exception as e: + decrypt_errors[item.cnappQueueId] = str(e) + + if kwargs.get('format') == 'json': + json_items = [] + for item in items: + d = _queue_item_to_dict(item) + d.pop('payload', None) + if item.cnappQueueId in decrypted_by_id: + d['decryptedPayload'] = decrypted_by_id[item.cnappQueueId] + elif item.cnappQueueId in decrypt_errors: + d['decryptError'] = decrypt_errors[item.cnappQueueId] + json_items.append(d) + payload = {'items': json_items, 'hasMore': has_more} + print(json.dumps(payload, indent=2, default=str)) + return None + + if not items: + print('No CNAPP queue items.') + return None + + if encrypter_key is None and not kwargs.get('no_decrypt'): + print(f"{bcolors.WARNING}No encrypter key resolved — payloads will be shown as 'encrypted'. " + f"Pass --config-record or run after `pam cnapp config read` succeeds.{bcolors.ENDC}") + + headers = ['Queue ID', 'Provider', 'Status', 'Received (UTC)', 'Resolved (UTC)', 'Record UID', 'Issue'] + rows = [] + for item in items: + if item.cnappQueueId in decrypted_by_id: + issue_cell = self._decrypted_summary(decrypted_by_id[item.cnappQueueId]) + elif not item.payload: + issue_cell = '' + elif kwargs.get('no_decrypt'): + issue_cell = '' + else: + issue_cell = f"{bcolors.WARNING}{bcolors.ENDC}" + rows.append([ + item.cnappQueueId, + cnapp_helper.CnappProvider.Name(item.cnappProviderId), + QUEUE_STATUS_BY_ID.get(item.cnappQueueStatusId, str(item.cnappQueueStatusId)), + _format_timestamp(item.receivedAt), + _format_timestamp(item.resolvedAt), + bytes_to_base64(item.recordUid) if item.recordUid else '', + issue_cell, + ]) + dump_report_data(rows, headers, fmt='table', filename='', row_number=False) + for queue_id, msg in decrypt_errors.items(): + print(f"{bcolors.WARNING}Queue item {queue_id}: failed to decrypt payload ({msg}).{bcolors.ENDC}") + if has_more: + print(f"{bcolors.WARNING}More queue items exist (hasMore=true). " + f"CLI paging is not available yet — resolve or delete returned items to see more.{bcolors.ENDC}") + return None + + +class PAMCnappQueueAssociateCommand(Command): + parser = argparse.ArgumentParser(prog='pam cnapp queue associate') + parser.add_argument('--queue-id', '-q', required=True, type=int, dest='cnapp_queue_id', + help='Queue item ID (from `pam cnapp queue list`).') + parser.add_argument('--record-uid', '-r', required=True, dest='record_uid', + help='Vault record UID to associate (base64url).') + + def get_parser(self): + return PAMCnappQueueAssociateCommand.parser + + def execute(self, params, **kwargs): + cnapp_helper.associate_cnapp_record( + params, + cnapp_queue_id=kwargs.get('cnapp_queue_id'), + record_uid=kwargs.get('record_uid'), + ) + print(f"{bcolors.OKGREEN}Record associated with queue item {kwargs.get('cnapp_queue_id')}.{bcolors.ENDC}") + + +class PAMCnappQueueRemediateCommand(Command): + parser = argparse.ArgumentParser(prog='pam cnapp queue remediate') + parser.add_argument('--queue-id', '-q', required=True, type=int, dest='cnapp_queue_id', + help='Queue item ID.') + parser.add_argument('--action', '-a', required=True, dest='action_type', + help='Remediation action: rotate_credentials, manage_access, jit_access, remove_standing_privilege.') + parser.add_argument('--provider', '-p', required=False, dest='provider', + help='Provider keyword (wiz). Optional — krouter resolves from queue item if omitted.') + parser.add_argument('--config-record', required=False, dest='cnapp_config_record_uid', + help='Configuration record UID (only required for some action types).') + parser.add_argument('--resource-ref', required=False, dest='resource_ref', + help='Resource reference UID for the action.') + parser.add_argument('--pwd-complexity', required=False, dest='pwd_complexity', + help='Password complexity JSON (rotate_credentials).') + parser.add_argument('--controller-uid', required=False, dest='controller_uid', + help='Override gateway UID.') + parser.add_argument('--message-uid', required=False, dest='message_uid', + help='Client-generated conversation UID for streaming responses.') + parser.add_argument('--group-name', required=False, dest='group_name', + help='Group name (remove_standing_privilege only).') + + def get_parser(self): + return PAMCnappQueueRemediateCommand.parser + + def execute(self, params, **kwargs): + action = cnapp_helper.action_from_name(kwargs.get('action_type')) + provider = None + if kwargs.get('provider'): + provider = cnapp_helper.provider_from_name(kwargs.get('provider')) + response = cnapp_helper.remediate_cnapp_queue_item( + params, + cnapp_queue_id=kwargs.get('cnapp_queue_id'), + action_type=action, + provider=provider, + cnapp_config_record_uid=kwargs.get('cnapp_config_record_uid'), + resource_ref=kwargs.get('resource_ref'), + pwd_complexity=kwargs.get('pwd_complexity'), + controller_uid=kwargs.get('controller_uid'), + message_uid=kwargs.get('message_uid'), + group_name=kwargs.get('group_name'), + ) + if response is None: + print(f"{bcolors.OKGREEN}Remediation dispatched.{bcolors.ENDC}") + return None + action_name = cnapp_helper.CnappRemediationAction.Name(response.actionType) + status_name = QUEUE_STATUS_BY_ID.get(response.cnappQueueStatusId, str(response.cnappQueueStatusId)) + print(f"{bcolors.OKGREEN}Remediation dispatched.{bcolors.ENDC}") + print(f" Action: {action_name}") + print(f" Status: {status_name}") + if response.result: + print(f" Result: {response.result}") + return None + + +class PAMCnappQueueSetStatusCommand(Command): + parser = argparse.ArgumentParser(prog='pam cnapp queue set-status') + parser.add_argument('--queue-id', '-q', required=True, type=int, dest='cnapp_queue_id', + help='Queue item ID.') + parser.add_argument('--status', '-s', required=True, dest='status', + help='New status: pending/in_progress/resolved/failed/cancelled, or its numeric id.') + parser.add_argument('--reason', required=False, dest='reason', + help='Free-form reason (forwarded to provider notification).') + + def get_parser(self): + return PAMCnappQueueSetStatusCommand.parser + + def execute(self, params, **kwargs): + status_id = _resolve_status(kwargs.get('status'), allow_all=False) + response = cnapp_helper.set_cnapp_queue_status( + params, + cnapp_queue_id=kwargs.get('cnapp_queue_id'), + cnapp_queue_status_id=status_id, + reason=kwargs.get('reason'), + ) + applied = response.cnappQueueStatusId if response is not None else status_id + print(f"{bcolors.OKGREEN}Status applied: {QUEUE_STATUS_BY_ID.get(applied, applied)}.{bcolors.ENDC}") + return None + + +class PAMCnappQueueDeleteCommand(Command): + parser = argparse.ArgumentParser(prog='pam cnapp queue delete') + parser.add_argument('--queue-id', '-q', required=True, type=int, dest='cnapp_queue_id', + help='Queue item ID to delete.') + + def get_parser(self): + return PAMCnappQueueDeleteCommand.parser + + def execute(self, params, **kwargs): + cnapp_helper.delete_cnapp_queue_item(params, cnapp_queue_id=kwargs.get('cnapp_queue_id')) + print(f"{bcolors.OKGREEN}Queue item {kwargs.get('cnapp_queue_id')} deleted.{bcolors.ENDC}") + + +# --------------------------------------------------------------------------- +# Formatting helpers +# --------------------------------------------------------------------------- + +def _configuration_to_dict(config): + return { + 'networkUid': bytes_to_base64(config.networkUid) if config.networkUid else '', + 'provider': cnapp_helper.CnappProvider.Name(config.provider), + 'clientId': config.clientId, + 'apiEndpointUrl': config.apiEndpointUrl, + 'authEndpointUrl': config.authEndpointUrl, + 'cnappConfigRecordUid': bytes_to_base64(config.cnappConfigRecordUid) if config.cnappConfigRecordUid else '', + } + + +def _queue_item_to_dict(item): + return { + 'cnappQueueId': item.cnappQueueId, + 'cnappProviderId': cnapp_helper.CnappProvider.Name(item.cnappProviderId), + 'cnappQueueStatusId': item.cnappQueueStatusId, + 'cnappQueueStatusName': QUEUE_STATUS_BY_ID.get(item.cnappQueueStatusId, str(item.cnappQueueStatusId)), + 'receivedAt': item.receivedAt, + 'resolvedAt': item.resolvedAt, + 'networkId': bytes_to_base64(item.networkId) if item.networkId else '', + 'recordUid': bytes_to_base64(item.recordUid) if item.recordUid else '', + } + + +def _uid_display(uid_bytes): + return bytes_to_base64(uid_bytes) if uid_bytes else '(none)' + + +def _print_configuration(config): + print(f"{bcolors.OKBLUE}CNAPP Configuration{bcolors.ENDC}") + print(f" Network UID : {_uid_display(config.networkUid)}") + print(f" Provider : {cnapp_helper.CnappProvider.Name(config.provider)}") + print(f" Client ID : {config.clientId or '(none)'}") + print(f" API Endpoint : {config.apiEndpointUrl or '(none)'}") + print(f" Auth Endpoint : {config.authEndpointUrl or '(none)'}") + print(f" Config Record : {_uid_display(config.cnappConfigRecordUid)}") diff --git a/keepercommander/commands/pam/cnapp_helper.py b/keepercommander/commands/pam/cnapp_helper.py new file mode 100644 index 000000000..c1f0302d6 --- /dev/null +++ b/keepercommander/commands/pam/cnapp_helper.py @@ -0,0 +1,265 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' set_cnapp_configuration + configuration/test -> test_cnapp_configuration + configuration/test-encrypter -> test_cnapp_encrypter + configuration/read -> read_cnapp_configuration + configuration/delete -> delete_cnapp_configuration + + Queue: + queue -> list_cnapp_queue + queue/associate -> associate_cnapp_record + queue/remediate -> remediate_cnapp_queue_item + queue/set-status -> set_cnapp_queue_status + queue/delete -> delete_cnapp_queue_item + +Failures from the helper layer bubble up as Python exceptions raised by the underlying +HTTP/proto plumbing; callers convert them to user-readable output. +""" + +from typing import Optional + +from keeper_secrets_manager_core.utils import url_safe_str_to_bytes + +from ...params import KeeperParams +from ...proto import cnapp_pb2 + + +# NOTE: `router_helper` is imported lazily inside `_post_request_to_router` below. +# Importing it at module top creates this import-time chain: +# cnapp_helper -> router_helper -> gateway_helper +# -> keepercommander.commands.utils -> commands.ksm +# -> commands.record -> commands.ksm (ksm still partially loaded — crash) +# That `record <-> ksm` cycle is pre-existing and only works because production +# code paths load `record` first. Tests that import `cnapp_helper` cold hit the +# cycle directly. TODO(KC-1290): break the record↔ksm cycle so this wrapper can be removed. +def _post_request_to_router(params, endpoint, **kwargs): + """Lazy proxy to `router_helper._post_request_to_router`. + + Defined as a module-level function so callers (and `unittest.mock.patch.object`) + can keep referring to `cnapp_helper._post_request_to_router` as if it were the + original symbol.""" + from .router_helper import _post_request_to_router as _real_post + return _real_post(params, endpoint, **kwargs) + + +# Public re-exports — let commands/tests reach proto types via the helper module so they +# don't need to know the on-disk proto path. +CnappProvider = cnapp_pb2.CnappProvider +CnappRemediationAction = cnapp_pb2.CnappRemediationAction + + +# --------------------------------------------------------------------------- +# Conversion utilities +# --------------------------------------------------------------------------- + +def _to_uid_bytes(uid): # type: (Optional[str]) -> bytes + """Convert a base64url-encoded UID string to bytes; empty/None -> empty bytes.""" + if not uid: + return b'' + if isinstance(uid, bytes): + return uid + return url_safe_str_to_bytes(uid) + + +def provider_from_name(name): # type: (str) -> int + """Resolve a human-typed provider name (e.g. "wiz") to a CnappProvider enum value. + + Accepts the bare provider keyword ("wiz") or the full proto symbol + ("CNAPP_PROVIDER_WIZ"); case-insensitive. Raises ValueError on unknown input.""" + if not name: + return cnapp_pb2.CNAPP_PROVIDER_UNSPECIFIED + normalized = name.strip().upper() + if not normalized.startswith('CNAPP_PROVIDER_'): + normalized = 'CNAPP_PROVIDER_' + normalized + try: + return cnapp_pb2.CnappProvider.Value(normalized) + except ValueError as e: + valid = [n for n in cnapp_pb2.CnappProvider.keys() if n != 'CNAPP_PROVIDER_UNSPECIFIED'] + raise ValueError(f"Unknown CNAPP provider '{name}'. Valid options: {', '.join(valid)}") from e + + +def action_from_name(name): # type: (str) -> int + """Resolve a remediation action name to its enum int. Case-insensitive; accepts the + short keyword (e.g. "rotate_credentials") or the full proto symbol.""" + if not name: + return cnapp_pb2.UNSPECIFIED + normalized = name.strip().upper().replace('-', '_') + try: + return cnapp_pb2.CnappRemediationAction.Value(normalized) + except ValueError as e: + valid = [n for n in cnapp_pb2.CnappRemediationAction.keys() if n != 'UNSPECIFIED'] + raise ValueError(f"Unknown remediation action '{name}'. Valid options: {', '.join(valid)}") from e + + +# --------------------------------------------------------------------------- +# Configuration endpoints +# --------------------------------------------------------------------------- + +def _build_configuration(network_uid, provider, client_id=None, client_secret=None, + api_endpoint_url=None, cnapp_config_record_uid=None, + auth_endpoint_url=None): + # type: (str, int, Optional[str], Optional[str], Optional[str], Optional[str], Optional[str]) -> cnapp_pb2.CnappConfiguration + rq = cnapp_pb2.CnappConfiguration() + rq.networkUid = _to_uid_bytes(network_uid) + rq.provider = provider + if client_id: + rq.clientId = client_id + if client_secret: + rq.clientSecret = client_secret + if api_endpoint_url: + rq.apiEndpointUrl = api_endpoint_url + if cnapp_config_record_uid: + rq.cnappConfigRecordUid = _to_uid_bytes(cnapp_config_record_uid) + if auth_endpoint_url: + rq.authEndpointUrl = auth_endpoint_url + return rq + + +def set_cnapp_configuration(params, network_uid, provider, client_id, client_secret, + api_endpoint_url, cnapp_config_record_uid, auth_endpoint_url=None): + # type: (KeeperParams, str, int, str, str, str, str, Optional[str]) -> cnapp_pb2.CnappConfiguration + """Create or update the CNAPP provider configuration on a network. + + krouter validates the credentials against the provider before persisting; an empty + `client_secret` tells krouter to keep the previously stored value (useful for edits + that only change the endpoint or record UID). + + `auth_endpoint_url` is the provider's OAuth2 token endpoint, letting customers point + at their own tenant/region (e.g. EU vs US Wiz auth host) without a code change.""" + rq = _build_configuration(network_uid, provider, client_id, client_secret, + api_endpoint_url, cnapp_config_record_uid, auth_endpoint_url) + return _post_request_to_router(params, 'cnapp/configuration/set', rq_proto=rq, + rs_type=cnapp_pb2.CnappConfiguration) + + +def test_cnapp_configuration(params, network_uid, provider, client_id, client_secret, + api_endpoint_url, auth_endpoint_url=None): + # type: (KeeperParams, str, int, str, str, str, Optional[str]) -> None + """Probe the provider with the supplied credentials without persisting anything. + + Returns None on success; raises on validation failure (RRC_BAD_REQUEST with the + provider's reason in the message).""" + rq = _build_configuration(network_uid, provider, client_id, client_secret, + api_endpoint_url, cnapp_config_record_uid=None, + auth_endpoint_url=auth_endpoint_url) + return _post_request_to_router(params, 'cnapp/configuration/test', rq_proto=rq) + + +def test_cnapp_encrypter(params, url_base_encrypter): + # type: (KeeperParams, str) -> None + """Issue a `GET /health` against the customer-deployed Encrypter via krouter. + + Used by the UI/CLI to check that the Encrypter URL is reachable before saving a + configuration that references it. Raises on non-200 or transport error.""" + rq = cnapp_pb2.CnappTestEncrypterRequest() + rq.urlBaseEncrypter = url_base_encrypter + return _post_request_to_router(params, 'cnapp/configuration/test-encrypter', rq_proto=rq) + + +def read_cnapp_configuration(params, network_uid, provider): + # type: (KeeperParams, str, int) -> cnapp_pb2.CnappConfiguration + """Read the persisted CNAPP configuration for a network. Note: krouter never returns + the `clientSecret` field — only the endpoint, client id and config record UID.""" + rq = _build_configuration(network_uid, provider) + return _post_request_to_router(params, 'cnapp/configuration/read', rq_proto=rq, + rs_type=cnapp_pb2.CnappConfiguration) + + +def delete_cnapp_configuration(params, network_uid): + # type: (KeeperParams, str) -> None + """Remove the CNAPP configuration on a network. Raises RRC_BAD_STATE if none exists.""" + rq = cnapp_pb2.CnappDeleteConfigurationRequest() + rq.networkUid = _to_uid_bytes(network_uid) + return _post_request_to_router(params, 'cnapp/configuration/delete', rq_proto=rq) + + +# --------------------------------------------------------------------------- +# Queue endpoints +# --------------------------------------------------------------------------- + +def list_cnapp_queue(params, network_uid, status_filter=0): + # type: (KeeperParams, str, int) -> cnapp_pb2.CnappQueueListResponse + """List queued CNAPP issues for a network. `status_filter=0` returns all statuses.""" + rq = cnapp_pb2.CnappQueueListRequest() + rq.networkUid = _to_uid_bytes(network_uid) + rq.statusFilter = int(status_filter) if status_filter is not None else 0 + return _post_request_to_router(params, 'cnapp/queue', rq_proto=rq, + rs_type=cnapp_pb2.CnappQueueListResponse) + + +def associate_cnapp_record(params, cnapp_queue_id, record_uid): + # type: (KeeperParams, int, str) -> None + """Attach a vault record to a queue item — required before remediation.""" + rq = cnapp_pb2.CnappAssociateRequest() + rq.cnappQueueId = int(cnapp_queue_id) + rq.recordUid = _to_uid_bytes(record_uid) + return _post_request_to_router(params, 'cnapp/queue/associate', rq_proto=rq) + + +def remediate_cnapp_queue_item(params, cnapp_queue_id, action_type, provider=None, + cnapp_config_record_uid=None, resource_ref=None, + pwd_complexity=None, controller_uid=None, + message_uid=None, group_name=None): + # type: (KeeperParams, int, int, Optional[int], Optional[str], Optional[str], Optional[str], Optional[str], Optional[str], Optional[str]) -> cnapp_pb2.CnappRemediateResponse + """Trigger a remediation action against the gateway for a queued issue. + + Currently krouter only dispatches `ROTATE_CREDENTIALS`; other actions return + RRC_BAD_REQUEST. The optional fields are forwarded as-is so this helper stays + forward-compatible with new action types.""" + rq = cnapp_pb2.CnappRemediateRequest() + rq.cnappQueueId = int(cnapp_queue_id) + rq.actionType = int(action_type) + if provider is not None: + rq.provider = int(provider) + if cnapp_config_record_uid: + rq.cnappConfigurationRecordUid = _to_uid_bytes(cnapp_config_record_uid) + if resource_ref: + rq.resourceRef = _to_uid_bytes(resource_ref) + if pwd_complexity: + rq.pwdComplexity = pwd_complexity + if controller_uid: + rq.controllerUid = controller_uid + if message_uid: + rq.messageUid = _to_uid_bytes(message_uid) + if group_name: + rq.groupName = group_name + return _post_request_to_router(params, 'cnapp/queue/remediate', rq_proto=rq, + rs_type=cnapp_pb2.CnappRemediateResponse) + + +def set_cnapp_queue_status(params, cnapp_queue_id, cnapp_queue_status_id, reason=None): + # type: (KeeperParams, int, int, Optional[str]) -> cnapp_pb2.CnappSetStatusResponse + """Set the local status on a queue item; krouter best-effort notifies the provider.""" + rq = cnapp_pb2.CnappSetStatusRequest() + rq.cnappQueueId = int(cnapp_queue_id) + rq.cnappQueueStatusId = int(cnapp_queue_status_id) + if reason: + rq.reason = reason + return _post_request_to_router(params, 'cnapp/queue/set-status', rq_proto=rq, + rs_type=cnapp_pb2.CnappSetStatusResponse) + + +def delete_cnapp_queue_item(params, cnapp_queue_id): + # type: (KeeperParams, int) -> None + """Remove a queue item entirely. Raises RRC_BAD_REQUEST if the queue id is unknown.""" + rq = cnapp_pb2.CnappDeleteQueueItemRequest() + rq.cnappQueueId = int(cnapp_queue_id) + return _post_request_to_router(params, 'cnapp/queue/delete', rq_proto=rq) diff --git a/keepercommander/proto/cnapp_pb2.py b/keepercommander/proto/cnapp_pb2.py new file mode 100644 index 000000000..a146a3e0e --- /dev/null +++ b/keepercommander/proto/cnapp_pb2.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: cnapp.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0b\x63napp.proto\x12\x05\x43NAPP\"A\n\x15\x43nappQueueListRequest\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\x12\x14\n\x0cstatusFilter\x18\x02 \x01(\x05\"O\n\x16\x43nappQueueListResponse\x12$\n\x05items\x18\x01 \x03(\x0b\x32\x15.CNAPP.CnappQueueItem\x12\x0f\n\x07hasMore\x18\x02 \x01(\x08\"\xd0\x01\n\x0e\x43nappQueueItem\x12\x14\n\x0c\x63nappQueueId\x18\x01 \x01(\x05\x12-\n\x0f\x63nappProviderId\x18\x02 \x01(\x0e\x32\x14.CNAPP.CnappProvider\x12\x1a\n\x12\x63nappQueueStatusId\x18\x03 \x01(\x05\x12\x12\n\nreceivedAt\x18\x04 \x01(\x03\x12\x12\n\nresolvedAt\x18\x05 \x01(\x03\x12\x11\n\tnetworkId\x18\x06 \x01(\x0c\x12\x0f\n\x07payload\x18\x07 \x01(\x0c\x12\x11\n\trecordUid\x18\x08 \x01(\x0c\"@\n\x15\x43nappAssociateRequest\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x14\n\x0c\x63nappQueueId\x18\x02 \x01(\x05\"4\n\x16\x43nappAssociateResponse\x12\x1a\n\x12\x63nappQueueStatusId\x18\x01 \x01(\x05\"\x97\x02\n\x15\x43nappRemediateRequest\x12\x14\n\x0c\x63nappQueueId\x18\x01 \x01(\x05\x12\x31\n\nactionType\x18\x02 \x01(\x0e\x32\x1d.CNAPP.CnappRemediationAction\x12#\n\x1b\x63nappConfigurationRecordUid\x18\x03 \x01(\x0c\x12\x15\n\rpwdComplexity\x18\x04 \x01(\t\x12\x13\n\x0bresourceRef\x18\x05 \x01(\x0c\x12&\n\x08provider\x18\x06 \x01(\x0e\x32\x14.CNAPP.CnappProvider\x12\x15\n\rcontrollerUid\x18\x07 \x01(\t\x12\x12\n\nmessageUid\x18\x08 \x01(\x0c\x12\x11\n\tgroupName\x18\t \x01(\t\"w\n\x16\x43nappRemediateResponse\x12\x31\n\nactionType\x18\x01 \x01(\x0e\x32\x1d.CNAPP.CnappRemediationAction\x12\x0e\n\x06result\x18\x02 \x01(\t\x12\x1a\n\x12\x63nappQueueStatusId\x18\x03 \x01(\x05\"Y\n\x15\x43nappSetStatusRequest\x12\x14\n\x0c\x63nappQueueId\x18\x01 \x01(\x05\x12\x1a\n\x12\x63nappQueueStatusId\x18\x02 \x01(\x05\x12\x0e\n\x06reason\x18\x03 \x01(\t\"4\n\x16\x43nappSetStatusResponse\x12\x1a\n\x12\x63nappQueueStatusId\x18\x01 \x01(\x05\"3\n\x1b\x43nappDeleteQueueItemRequest\x12\x14\n\x0c\x63nappQueueId\x18\x01 \x01(\x05\"\x1e\n\x1c\x43nappDeleteQueueItemResponse\"\xc7\x01\n\x12\x43nappConfiguration\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\x12&\n\x08provider\x18\x02 \x01(\x0e\x32\x14.CNAPP.CnappProvider\x12\x10\n\x08\x63lientId\x18\x03 \x01(\t\x12\x14\n\x0c\x63lientSecret\x18\x04 \x01(\t\x12\x16\n\x0e\x61piEndpointUrl\x18\x05 \x01(\t\x12\x1c\n\x14\x63nappConfigRecordUid\x18\x06 \x01(\x0c\x12\x17\n\x0f\x61uthEndpointUrl\x18\x07 \x01(\t\"5\n\x1f\x43nappDeleteConfigurationRequest\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\"5\n\x19\x43nappTestEncrypterRequest\x12\x18\n\x10urlBaseEncrypter\x18\x01 \x01(\t*G\n\rCnappProvider\x12\x1e\n\x1a\x43NAPP_PROVIDER_UNSPECIFIED\x10\x00\x12\x16\n\x12\x43NAPP_PROVIDER_WIZ\x10\x01*\x83\x01\n\x16\x43nappRemediationAction\x12\x0f\n\x0bUNSPECIFIED\x10\x00\x12\x16\n\x12ROTATE_CREDENTIALS\x10\x01\x12\x11\n\rMANAGE_ACCESS\x10\x02\x12\x0e\n\nJIT_ACCESS\x10\x03\x12\x1d\n\x19REMOVE_STANDING_PRIVILEGE\x10\x04\x42!\n\x18\x63om.keepersecurity.protoB\x05\x43nappb\x06proto3') + +_CNAPPPROVIDER = DESCRIPTOR.enum_types_by_name['CnappProvider'] +CnappProvider = enum_type_wrapper.EnumTypeWrapper(_CNAPPPROVIDER) +_CNAPPREMEDIATIONACTION = DESCRIPTOR.enum_types_by_name['CnappRemediationAction'] +CnappRemediationAction = enum_type_wrapper.EnumTypeWrapper(_CNAPPREMEDIATIONACTION) +CNAPP_PROVIDER_UNSPECIFIED = 0 +CNAPP_PROVIDER_WIZ = 1 +UNSPECIFIED = 0 +ROTATE_CREDENTIALS = 1 +MANAGE_ACCESS = 2 +JIT_ACCESS = 3 +REMOVE_STANDING_PRIVILEGE = 4 + + +_CNAPPQUEUELISTREQUEST = DESCRIPTOR.message_types_by_name['CnappQueueListRequest'] +_CNAPPQUEUELISTRESPONSE = DESCRIPTOR.message_types_by_name['CnappQueueListResponse'] +_CNAPPQUEUEITEM = DESCRIPTOR.message_types_by_name['CnappQueueItem'] +_CNAPPASSOCIATEREQUEST = DESCRIPTOR.message_types_by_name['CnappAssociateRequest'] +_CNAPPASSOCIATERESPONSE = DESCRIPTOR.message_types_by_name['CnappAssociateResponse'] +_CNAPPREMEDIATEREQUEST = DESCRIPTOR.message_types_by_name['CnappRemediateRequest'] +_CNAPPREMEDIATERESPONSE = DESCRIPTOR.message_types_by_name['CnappRemediateResponse'] +_CNAPPSETSTATUSREQUEST = DESCRIPTOR.message_types_by_name['CnappSetStatusRequest'] +_CNAPPSETSTATUSRESPONSE = DESCRIPTOR.message_types_by_name['CnappSetStatusResponse'] +_CNAPPDELETEQUEUEITEMREQUEST = DESCRIPTOR.message_types_by_name['CnappDeleteQueueItemRequest'] +_CNAPPDELETEQUEUEITEMRESPONSE = DESCRIPTOR.message_types_by_name['CnappDeleteQueueItemResponse'] +_CNAPPCONFIGURATION = DESCRIPTOR.message_types_by_name['CnappConfiguration'] +_CNAPPDELETECONFIGURATIONREQUEST = DESCRIPTOR.message_types_by_name['CnappDeleteConfigurationRequest'] +_CNAPPTESTENCRYPTERREQUEST = DESCRIPTOR.message_types_by_name['CnappTestEncrypterRequest'] +CnappQueueListRequest = _reflection.GeneratedProtocolMessageType('CnappQueueListRequest', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPQUEUELISTREQUEST, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappQueueListRequest) + }) +_sym_db.RegisterMessage(CnappQueueListRequest) + +CnappQueueListResponse = _reflection.GeneratedProtocolMessageType('CnappQueueListResponse', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPQUEUELISTRESPONSE, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappQueueListResponse) + }) +_sym_db.RegisterMessage(CnappQueueListResponse) + +CnappQueueItem = _reflection.GeneratedProtocolMessageType('CnappQueueItem', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPQUEUEITEM, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappQueueItem) + }) +_sym_db.RegisterMessage(CnappQueueItem) + +CnappAssociateRequest = _reflection.GeneratedProtocolMessageType('CnappAssociateRequest', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPASSOCIATEREQUEST, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappAssociateRequest) + }) +_sym_db.RegisterMessage(CnappAssociateRequest) + +CnappAssociateResponse = _reflection.GeneratedProtocolMessageType('CnappAssociateResponse', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPASSOCIATERESPONSE, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappAssociateResponse) + }) +_sym_db.RegisterMessage(CnappAssociateResponse) + +CnappRemediateRequest = _reflection.GeneratedProtocolMessageType('CnappRemediateRequest', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPREMEDIATEREQUEST, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappRemediateRequest) + }) +_sym_db.RegisterMessage(CnappRemediateRequest) + +CnappRemediateResponse = _reflection.GeneratedProtocolMessageType('CnappRemediateResponse', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPREMEDIATERESPONSE, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappRemediateResponse) + }) +_sym_db.RegisterMessage(CnappRemediateResponse) + +CnappSetStatusRequest = _reflection.GeneratedProtocolMessageType('CnappSetStatusRequest', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPSETSTATUSREQUEST, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappSetStatusRequest) + }) +_sym_db.RegisterMessage(CnappSetStatusRequest) + +CnappSetStatusResponse = _reflection.GeneratedProtocolMessageType('CnappSetStatusResponse', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPSETSTATUSRESPONSE, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappSetStatusResponse) + }) +_sym_db.RegisterMessage(CnappSetStatusResponse) + +CnappDeleteQueueItemRequest = _reflection.GeneratedProtocolMessageType('CnappDeleteQueueItemRequest', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPDELETEQUEUEITEMREQUEST, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappDeleteQueueItemRequest) + }) +_sym_db.RegisterMessage(CnappDeleteQueueItemRequest) + +CnappDeleteQueueItemResponse = _reflection.GeneratedProtocolMessageType('CnappDeleteQueueItemResponse', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPDELETEQUEUEITEMRESPONSE, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappDeleteQueueItemResponse) + }) +_sym_db.RegisterMessage(CnappDeleteQueueItemResponse) + +CnappConfiguration = _reflection.GeneratedProtocolMessageType('CnappConfiguration', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPCONFIGURATION, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappConfiguration) + }) +_sym_db.RegisterMessage(CnappConfiguration) + +CnappDeleteConfigurationRequest = _reflection.GeneratedProtocolMessageType('CnappDeleteConfigurationRequest', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPDELETECONFIGURATIONREQUEST, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappDeleteConfigurationRequest) + }) +_sym_db.RegisterMessage(CnappDeleteConfigurationRequest) + +CnappTestEncrypterRequest = _reflection.GeneratedProtocolMessageType('CnappTestEncrypterRequest', (_message.Message,), { + 'DESCRIPTOR' : _CNAPPTESTENCRYPTERREQUEST, + '__module__' : 'cnapp_pb2' + # @@protoc_insertion_point(class_scope:CNAPP.CnappTestEncrypterRequest) + }) +_sym_db.RegisterMessage(CnappTestEncrypterRequest) + +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b'\n\030com.keepersecurity.protoB\005Cnapp' + _CNAPPPROVIDER._serialized_start=1446 + _CNAPPPROVIDER._serialized_end=1517 + _CNAPPREMEDIATIONACTION._serialized_start=1520 + _CNAPPREMEDIATIONACTION._serialized_end=1651 + _CNAPPQUEUELISTREQUEST._serialized_start=22 + _CNAPPQUEUELISTREQUEST._serialized_end=87 + _CNAPPQUEUELISTRESPONSE._serialized_start=89 + _CNAPPQUEUELISTRESPONSE._serialized_end=168 + _CNAPPQUEUEITEM._serialized_start=171 + _CNAPPQUEUEITEM._serialized_end=379 + _CNAPPASSOCIATEREQUEST._serialized_start=381 + _CNAPPASSOCIATEREQUEST._serialized_end=445 + _CNAPPASSOCIATERESPONSE._serialized_start=447 + _CNAPPASSOCIATERESPONSE._serialized_end=499 + _CNAPPREMEDIATEREQUEST._serialized_start=502 + _CNAPPREMEDIATEREQUEST._serialized_end=781 + _CNAPPREMEDIATERESPONSE._serialized_start=783 + _CNAPPREMEDIATERESPONSE._serialized_end=902 + _CNAPPSETSTATUSREQUEST._serialized_start=904 + _CNAPPSETSTATUSREQUEST._serialized_end=993 + _CNAPPSETSTATUSRESPONSE._serialized_start=995 + _CNAPPSETSTATUSRESPONSE._serialized_end=1047 + _CNAPPDELETEQUEUEITEMREQUEST._serialized_start=1049 + _CNAPPDELETEQUEUEITEMREQUEST._serialized_end=1100 + _CNAPPDELETEQUEUEITEMRESPONSE._serialized_start=1102 + _CNAPPDELETEQUEUEITEMRESPONSE._serialized_end=1132 + _CNAPPCONFIGURATION._serialized_start=1135 + _CNAPPCONFIGURATION._serialized_end=1334 + _CNAPPDELETECONFIGURATIONREQUEST._serialized_start=1336 + _CNAPPDELETECONFIGURATIONREQUEST._serialized_end=1389 + _CNAPPTESTENCRYPTERREQUEST._serialized_start=1391 + _CNAPPTESTENCRYPTERREQUEST._serialized_end=1444 +# @@protoc_insertion_point(module_scope) diff --git a/unit-tests/conftest.py b/unit-tests/conftest.py new file mode 100644 index 000000000..5650ef913 --- /dev/null +++ b/unit-tests/conftest.py @@ -0,0 +1,12 @@ +"""Pytest session hooks for unit-tests. + +CNAPP tests import `cnapp_helper` before `keepercommander.commands.record` is loaded, +which triggers a pre-existing record <-> ksm circular import. Loading `record` first +resolves the cycle (same as production startup order). +""" +import pytest + + +@pytest.fixture(scope='session', autouse=True) +def _preload_commands_record_module(): + import keepercommander.commands.record # noqa: F401 diff --git a/unit-tests/pam/test_cnapp.py b/unit-tests/pam/test_cnapp.py new file mode 100644 index 000000000..8d8421710 --- /dev/null +++ b/unit-tests/pam/test_cnapp.py @@ -0,0 +1,1001 @@ +"""Unit tests for the Commander CNAPP helper and command surface. + +Strategy: every test patches `_post_request_to_router` so we can assert on what the +helper sends to krouter and feed deterministic responses back into the commands. We +deliberately stay one layer below the network — no socket calls, no real protobuf +encryption — but we exercise the real proto serializers so wire-format breakage +surfaces here. +""" +import base64 +import io +import json +import os +import unittest +from contextlib import redirect_stdout +from unittest.mock import MagicMock, patch + +# isort: off +# Pre-load `record` before cnapp modules (record↔ksm cycle). Pytest also loads it via +# unit-tests/conftest.py; keep this guard for `python unit-tests/pam/test_cnapp.py`. +import keepercommander.commands.record # noqa: F401 +# isort: on + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM # noqa: E402 +from keeper_secrets_manager_core.utils import bytes_to_base64 # noqa: E402 + +from keepercommander.commands.pam import cnapp_helper # noqa: E402 +from keepercommander.commands.pam import cnapp_commands # noqa: E402 +from keepercommander.error import CommandError # noqa: E402 +from keepercommander.proto import cnapp_pb2 # noqa: E402 + + +# Sample 16-byte UIDs as base64url (the format Commander callers pass in). +NETWORK_UID = 'AAAAAAAAAAAAAAAAAAAAAA' # 16 zero bytes +RECORD_UID = 'AQEBAQEBAQEBAQEBAQEBAQ' # 16 0x01 bytes +CONFIG_RECORD_UID = 'AgICAgICAgICAgICAgICAg' + + +def _mock_params(): + """Minimal KeeperParams stand-in — the helpers only use it to drive the router_helper + transport, which is mocked here, so a MagicMock is enough.""" + return MagicMock() + + +# --------------------------------------------------------------------------- +# cnapp_helper: enum parsing +# --------------------------------------------------------------------------- + +class TestEnumParsing(unittest.TestCase): + """provider_from_name and action_from_name must accept short or full names and + reject unknown values with a helpful error listing valid options.""" + + def test_provider_short_name(self): + self.assertEqual(cnapp_helper.provider_from_name('wiz'), cnapp_pb2.CNAPP_PROVIDER_WIZ) + + def test_provider_full_name_case_insensitive(self): + self.assertEqual( + cnapp_helper.provider_from_name('cnapp_provider_wiz'), + cnapp_pb2.CNAPP_PROVIDER_WIZ, + ) + + def test_provider_empty_returns_unspecified(self): + self.assertEqual(cnapp_helper.provider_from_name(''), cnapp_pb2.CNAPP_PROVIDER_UNSPECIFIED) + + def test_provider_unknown_raises_with_valid_options(self): + with self.assertRaises(ValueError) as ctx: + cnapp_helper.provider_from_name('aws') + self.assertIn('WIZ', str(ctx.exception).upper()) + + def test_action_short_name(self): + self.assertEqual( + cnapp_helper.action_from_name('rotate_credentials'), + cnapp_pb2.ROTATE_CREDENTIALS, + ) + + def test_action_hyphenated(self): + # The CLI accepts hyphens (`--action remove-standing-privilege`) for ergonomics; + # helper must normalize before resolving the enum. + self.assertEqual( + cnapp_helper.action_from_name('remove-standing-privilege'), + cnapp_pb2.REMOVE_STANDING_PRIVILEGE, + ) + + def test_action_unknown_raises(self): + with self.assertRaises(ValueError): + cnapp_helper.action_from_name('teleport') + + def test_action_empty_returns_unspecified(self): + self.assertEqual(cnapp_helper.action_from_name(''), cnapp_pb2.UNSPECIFIED) + + +# --------------------------------------------------------------------------- +# cnapp_helper: configuration endpoints +# --------------------------------------------------------------------------- + +class TestConfigurationHelpers(unittest.TestCase): + """Each helper must dispatch to the right krouter path with a correctly populated + protobuf request and return the typed response.""" + + def setUp(self): + self.params = _mock_params() + + def _patch_post(self, return_value=None): + return patch.object(cnapp_helper, '_post_request_to_router', return_value=return_value) + + def test_set_configuration_dispatches_with_full_payload(self): + expected_response = cnapp_pb2.CnappConfiguration( + clientId='abc', apiEndpointUrl='https://api.wiz.io') + with self._patch_post(return_value=expected_response) as post: + result = cnapp_helper.set_cnapp_configuration( + self.params, + network_uid=NETWORK_UID, + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ, + client_id='abc', + client_secret='secret', + api_endpoint_url='https://api.wiz.io', + cnapp_config_record_uid=CONFIG_RECORD_UID, + auth_endpoint_url='https://auth.wiz.io/oauth/token', + ) + self.assertIs(result, expected_response) + args, kwargs = post.call_args + self.assertEqual(args[1], 'cnapp/configuration/set') + rq = kwargs['rq_proto'] + self.assertEqual(rq.provider, cnapp_pb2.CNAPP_PROVIDER_WIZ) + self.assertEqual(rq.clientId, 'abc') + self.assertEqual(rq.clientSecret, 'secret') + self.assertEqual(rq.apiEndpointUrl, 'https://api.wiz.io') + self.assertEqual(rq.authEndpointUrl, 'https://auth.wiz.io/oauth/token') + self.assertEqual(len(rq.networkUid), 16) + self.assertEqual(len(rq.cnappConfigRecordUid), 16) + self.assertIs(kwargs['rs_type'], cnapp_pb2.CnappConfiguration) + + def test_set_configuration_omits_empty_secret_to_keep_existing(self): + """Edge case: passing '' for client_secret on set must leave the field blank in + the request so krouter can splice in the previously stored secret.""" + with self._patch_post() as post: + cnapp_helper.set_cnapp_configuration( + self.params, + network_uid=NETWORK_UID, + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ, + client_id='abc', + client_secret='', + api_endpoint_url='https://api.wiz.io', + cnapp_config_record_uid=CONFIG_RECORD_UID, + ) + rq = post.call_args.kwargs['rq_proto'] + self.assertEqual(rq.clientSecret, '') + + def test_test_configuration_dispatches_to_test_endpoint(self): + with self._patch_post() as post: + cnapp_helper.test_cnapp_configuration( + self.params, + network_uid=NETWORK_UID, + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ, + client_id='abc', + client_secret='secret', + api_endpoint_url='https://api.wiz.io', + auth_endpoint_url='https://auth.wiz.io/oauth/token', + ) + self.assertEqual(post.call_args.args[1], 'cnapp/configuration/test') + self.assertEqual(post.call_args.kwargs['rq_proto'].authEndpointUrl, 'https://auth.wiz.io/oauth/token') + # test endpoint never persists, so it must not require / send config record UID + self.assertEqual(post.call_args.kwargs['rq_proto'].cnappConfigRecordUid, b'') + + def test_test_encrypter_sets_url(self): + with self._patch_post() as post: + cnapp_helper.test_cnapp_encrypter(self.params, url_base_encrypter='https://encr.local') + rq = post.call_args.kwargs['rq_proto'] + self.assertEqual(post.call_args.args[1], 'cnapp/configuration/test-encrypter') + self.assertEqual(rq.urlBaseEncrypter, 'https://encr.local') + + def test_read_configuration_uses_read_endpoint(self): + with self._patch_post(return_value=cnapp_pb2.CnappConfiguration()) as post: + cnapp_helper.read_cnapp_configuration( + self.params, network_uid=NETWORK_UID, provider=cnapp_pb2.CNAPP_PROVIDER_WIZ) + self.assertEqual(post.call_args.args[1], 'cnapp/configuration/read') + self.assertIs(post.call_args.kwargs['rs_type'], cnapp_pb2.CnappConfiguration) + + def test_delete_configuration_uses_delete_endpoint(self): + with self._patch_post() as post: + cnapp_helper.delete_cnapp_configuration(self.params, network_uid=NETWORK_UID) + self.assertEqual(post.call_args.args[1], 'cnapp/configuration/delete') + self.assertEqual(len(post.call_args.kwargs['rq_proto'].networkUid), 16) + + +# --------------------------------------------------------------------------- +# cnapp_helper: queue endpoints +# --------------------------------------------------------------------------- + +class TestQueueHelpers(unittest.TestCase): + def setUp(self): + self.params = _mock_params() + + def _patch_post(self, return_value=None): + return patch.object(cnapp_helper, '_post_request_to_router', return_value=return_value) + + def test_list_queue_with_status_filter(self): + items = cnapp_pb2.CnappQueueListResponse( + items=[cnapp_pb2.CnappQueueItem(cnappQueueId=42)]) + with self._patch_post(return_value=items) as post: + response = cnapp_helper.list_cnapp_queue( + self.params, network_uid=NETWORK_UID, status_filter=1) + self.assertEqual(post.call_args.args[1], 'cnapp/queue') + self.assertEqual(post.call_args.kwargs['rq_proto'].statusFilter, 1) + self.assertEqual(response.items[0].cnappQueueId, 42) + + def test_list_queue_defaults_to_all_status(self): + with self._patch_post(return_value=cnapp_pb2.CnappQueueListResponse()) as post: + cnapp_helper.list_cnapp_queue(self.params, network_uid=NETWORK_UID) + self.assertEqual(post.call_args.kwargs['rq_proto'].statusFilter, 0) + + def test_associate_record_dispatches(self): + with self._patch_post() as post: + cnapp_helper.associate_cnapp_record( + self.params, cnapp_queue_id=7, record_uid=RECORD_UID) + rq = post.call_args.kwargs['rq_proto'] + self.assertEqual(post.call_args.args[1], 'cnapp/queue/associate') + self.assertEqual(rq.cnappQueueId, 7) + self.assertEqual(len(rq.recordUid), 16) + + def test_remediate_forwards_optional_fields(self): + with self._patch_post(return_value=cnapp_pb2.CnappRemediateResponse()) as post: + cnapp_helper.remediate_cnapp_queue_item( + self.params, + cnapp_queue_id=3, + action_type=cnapp_pb2.ROTATE_CREDENTIALS, + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ, + cnapp_config_record_uid=CONFIG_RECORD_UID, + resource_ref=RECORD_UID, + pwd_complexity='{"len":24}', + controller_uid='gateway-1', + message_uid=RECORD_UID, + group_name='Admins', + ) + rq = post.call_args.kwargs['rq_proto'] + self.assertEqual(post.call_args.args[1], 'cnapp/queue/remediate') + self.assertEqual(rq.cnappQueueId, 3) + self.assertEqual(rq.actionType, cnapp_pb2.ROTATE_CREDENTIALS) + self.assertEqual(rq.provider, cnapp_pb2.CNAPP_PROVIDER_WIZ) + self.assertEqual(rq.pwdComplexity, '{"len":24}') + self.assertEqual(rq.controllerUid, 'gateway-1') + self.assertEqual(rq.groupName, 'Admins') + + def test_remediate_minimal_fields(self): + """No optional fields — only queueId and actionType must be set on the wire.""" + with self._patch_post(return_value=cnapp_pb2.CnappRemediateResponse()) as post: + cnapp_helper.remediate_cnapp_queue_item( + self.params, cnapp_queue_id=9, action_type=cnapp_pb2.ROTATE_CREDENTIALS) + rq = post.call_args.kwargs['rq_proto'] + self.assertEqual(rq.cnappQueueId, 9) + self.assertEqual(rq.provider, 0) + self.assertEqual(rq.pwdComplexity, '') + self.assertEqual(rq.controllerUid, '') + self.assertEqual(rq.groupName, '') + + def test_set_status_with_reason(self): + with self._patch_post(return_value=cnapp_pb2.CnappSetStatusResponse(cnappQueueStatusId=3)) as post: + response = cnapp_helper.set_cnapp_queue_status( + self.params, cnapp_queue_id=11, cnapp_queue_status_id=3, reason='Manually resolved') + self.assertEqual(post.call_args.args[1], 'cnapp/queue/set-status') + self.assertEqual(post.call_args.kwargs['rq_proto'].reason, 'Manually resolved') + self.assertEqual(response.cnappQueueStatusId, 3) + + def test_delete_queue_item_dispatches(self): + with self._patch_post() as post: + cnapp_helper.delete_cnapp_queue_item(self.params, cnapp_queue_id=11) + self.assertEqual(post.call_args.args[1], 'cnapp/queue/delete') + self.assertEqual(post.call_args.kwargs['rq_proto'].cnappQueueId, 11) + + +# --------------------------------------------------------------------------- +# cnapp_helper: error propagation +# --------------------------------------------------------------------------- + +class TestHelperErrorPropagation(unittest.TestCase): + """The router layer raises on RRC_!=OK; helpers must NOT swallow those errors.""" + + def test_set_configuration_propagates_router_error(self): + params = _mock_params() + with patch.object(cnapp_helper, '_post_request_to_router', + side_effect=Exception('Credential validation failed: Unauthorized')): + with self.assertRaises(Exception) as ctx: + cnapp_helper.set_cnapp_configuration( + params, + network_uid=NETWORK_UID, + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ, + client_id='abc', + client_secret='bad', + api_endpoint_url='https://api.wiz.io', + cnapp_config_record_uid=CONFIG_RECORD_UID, + ) + self.assertIn('Credential validation failed', str(ctx.exception)) + + +# --------------------------------------------------------------------------- +# cnapp_commands: status resolver +# --------------------------------------------------------------------------- + +class TestStatusResolver(unittest.TestCase): + + def test_numeric_passes_through(self): + self.assertEqual(cnapp_commands._resolve_status('1'), 1) + self.assertEqual(cnapp_commands._resolve_status(2), 2) + + def test_unknown_numeric_id_raises(self): + with self.assertRaises(CommandError): + cnapp_commands._resolve_status(99) + + def test_zero_is_all(self): + self.assertEqual(cnapp_commands._resolve_status('0'), 0) + self.assertEqual(cnapp_commands._resolve_status(None), 0) + self.assertEqual(cnapp_commands._resolve_status(''), 0) + + def test_named_status_case_insensitive(self): + self.assertEqual(cnapp_commands._resolve_status('PENDING'), 1) + self.assertEqual(cnapp_commands._resolve_status('in_progress'), 2) + self.assertEqual(cnapp_commands._resolve_status('Resolved'), 3) + + def test_unknown_status_raises_command_error(self): + with self.assertRaises(CommandError): + cnapp_commands._resolve_status('flapping') + + +# --------------------------------------------------------------------------- +# cnapp_commands: end-to-end (helpers patched) +# --------------------------------------------------------------------------- + +class TestConfigCommands(unittest.TestCase): + def setUp(self): + self.params = _mock_params() + + def _capture_stdout(self): + buf = io.StringIO() + return buf, redirect_stdout(buf) + + def test_config_set_calls_helper_with_resolved_provider(self): + with patch.object(cnapp_commands.cnapp_helper, 'set_cnapp_configuration', + return_value=cnapp_pb2.CnappConfiguration(clientId='abc', + apiEndpointUrl='https://api.wiz.io', + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ)) as helper: + buf, ctx = self._capture_stdout() + with ctx: + cnapp_commands.PAMCnappConfigSetCommand().execute( + self.params, + network_uid=NETWORK_UID, + provider='wiz', + client_id='abc', + client_secret='secret', + api_endpoint_url='https://api.wiz.io', + cnapp_config_record_uid=CONFIG_RECORD_UID, + auth_endpoint_url='https://auth.wiz.io/oauth/token', + ) + helper.assert_called_once() + kwargs = helper.call_args.kwargs + self.assertEqual(kwargs['provider'], cnapp_pb2.CNAPP_PROVIDER_WIZ) + self.assertEqual(kwargs['client_secret'], 'secret') + self.assertEqual(kwargs['auth_endpoint_url'], 'https://auth.wiz.io/oauth/token') + self.assertIn('saved', buf.getvalue().lower()) + + def test_config_set_blank_secret_passes_through(self): + """Edge case: the CLI must forward an empty secret unchanged so krouter can + keep the existing value.""" + with patch.object(cnapp_commands.cnapp_helper, 'set_cnapp_configuration', + return_value=cnapp_pb2.CnappConfiguration()) as helper: + with redirect_stdout(io.StringIO()): + cnapp_commands.PAMCnappConfigSetCommand().execute( + self.params, + network_uid=NETWORK_UID, + provider='wiz', + client_id='abc', + client_secret='', # explicit + api_endpoint_url='https://api.wiz.io', + cnapp_config_record_uid=CONFIG_RECORD_UID, + ) + self.assertEqual(helper.call_args.kwargs['client_secret'], '') + + def test_config_set_omitted_secret_keeps_existing(self): + with patch.object(cnapp_commands.cnapp_helper, 'set_cnapp_configuration', + return_value=cnapp_pb2.CnappConfiguration()) as helper: + with redirect_stdout(io.StringIO()): + cnapp_commands.PAMCnappConfigSetCommand().execute( + self.params, + network_uid=NETWORK_UID, + provider='wiz', + client_id='abc', + client_secret=None, + api_endpoint_url='https://api.wiz.io', + cnapp_config_record_uid=CONFIG_RECORD_UID, + ) + self.assertEqual(helper.call_args.kwargs['client_secret'], '') + + def test_config_set_invalid_provider_raises(self): + with self.assertRaises(ValueError): + cnapp_commands.PAMCnappConfigSetCommand().execute( + self.params, + network_uid=NETWORK_UID, + provider='bogus', + client_id='abc', + client_secret='secret', + api_endpoint_url='https://api.wiz.io', + cnapp_config_record_uid=CONFIG_RECORD_UID, + ) + + def test_config_test_prints_success(self): + with patch.object(cnapp_commands.cnapp_helper, 'test_cnapp_configuration', return_value=None) as helper: + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappConfigTestCommand().execute( + self.params, + network_uid=NETWORK_UID, + provider='wiz', + client_id='abc', + client_secret='secret', + api_endpoint_url='https://api.wiz.io', + auth_endpoint_url='https://auth.wiz.io/oauth/token', + ) + self.assertEqual(helper.call_args.kwargs['auth_endpoint_url'], 'https://auth.wiz.io/oauth/token') + self.assertIn('validated', buf.getvalue().lower()) + + def test_config_test_propagates_helper_error(self): + with patch.object(cnapp_commands.cnapp_helper, 'test_cnapp_configuration', + side_effect=Exception('Credential validation failed: bad')): + with self.assertRaises(Exception): + cnapp_commands.PAMCnappConfigTestCommand().execute( + self.params, + network_uid=NETWORK_UID, + provider='wiz', + client_id='abc', + client_secret='bad', + api_endpoint_url='https://api.wiz.io', + ) + + def test_config_test_encrypter_success(self): + with patch.object(cnapp_commands.cnapp_helper, 'test_cnapp_encrypter', return_value=None) as helper: + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappConfigTestEncrypterCommand().execute( + self.params, url='https://encr.local') + helper.assert_called_once_with(self.params, url_base_encrypter='https://encr.local') + self.assertIn('reachable', buf.getvalue().lower()) + + def test_config_read_table_format(self): + config = cnapp_pb2.CnappConfiguration( + clientId='abc', + apiEndpointUrl='https://api.wiz.io', + authEndpointUrl='https://auth.wiz.io/oauth/token', + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ, + ) + with patch.object(cnapp_commands.cnapp_helper, 'read_cnapp_configuration', return_value=config): + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappConfigReadCommand().execute( + self.params, network_uid=NETWORK_UID, provider='wiz', format='table') + output = buf.getvalue() + self.assertIn('CNAPP Configuration', output) + self.assertIn('https://api.wiz.io', output) + self.assertIn('https://auth.wiz.io/oauth/token', output) + + def test_config_read_json_format(self): + config = cnapp_pb2.CnappConfiguration( + clientId='abc', + apiEndpointUrl='https://api.wiz.io', + authEndpointUrl='https://auth.wiz.io/oauth/token', + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ, + ) + with patch.object(cnapp_commands.cnapp_helper, 'read_cnapp_configuration', return_value=config): + buf = io.StringIO() + with redirect_stdout(buf): + result = cnapp_commands.PAMCnappConfigReadCommand().execute( + self.params, network_uid=NETWORK_UID, provider='wiz', format='json') + payload = json.loads(buf.getvalue()) + self.assertEqual(payload['clientId'], 'abc') + self.assertEqual(payload['provider'], 'CNAPP_PROVIDER_WIZ') + self.assertEqual(payload['apiEndpointUrl'], 'https://api.wiz.io') + self.assertEqual(payload['authEndpointUrl'], 'https://auth.wiz.io/oauth/token') + self.assertIsNone(result, 'JSON output is the channel — no value returned to the REPL') + + def test_config_read_handles_none_response(self): + with patch.object(cnapp_commands.cnapp_helper, 'read_cnapp_configuration', return_value=None): + self.assertIsNone(cnapp_commands.PAMCnappConfigReadCommand().execute( + self.params, network_uid=NETWORK_UID, provider='wiz', format='table')) + + def test_config_delete_success(self): + with patch.object(cnapp_commands.cnapp_helper, 'delete_cnapp_configuration', return_value=None) as helper: + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappConfigDeleteCommand().execute(self.params, network_uid=NETWORK_UID) + helper.assert_called_once_with(self.params, network_uid=NETWORK_UID) + self.assertIn('deleted', buf.getvalue().lower()) + + +class TestQueueCommands(unittest.TestCase): + def setUp(self): + self.params = _mock_params() + + def _queue_response(self, items=None, has_more=False): + return cnapp_pb2.CnappQueueListResponse(items=items or [], hasMore=has_more) + + def _queue_item(self, queue_id=1, status_id=1, record_uid=b''): + return cnapp_pb2.CnappQueueItem( + cnappQueueId=queue_id, + cnappProviderId=cnapp_pb2.CNAPP_PROVIDER_WIZ, + cnappQueueStatusId=status_id, + receivedAt=1700000000000, + networkId=b'\x00' * 16, + recordUid=record_uid, + ) + + def test_queue_list_empty(self): + with patch.object(cnapp_commands.cnapp_helper, 'list_cnapp_queue', + return_value=self._queue_response()): + buf = io.StringIO() + with redirect_stdout(buf): + result = cnapp_commands.PAMCnappQueueListCommand().execute( + self.params, network_uid=NETWORK_UID, status=0, format='table', + no_decrypt=True) + self.assertIn('No CNAPP queue items', buf.getvalue()) + self.assertIsNone(result, 'queue list must not return the proto so the REPL does not dump bytes') + + def test_queue_list_with_items_table(self): + item = self._queue_item(queue_id=99, status_id=2, record_uid=b'\x01' * 16) + with patch.object(cnapp_commands.cnapp_helper, 'list_cnapp_queue', + return_value=self._queue_response([item])): + buf = io.StringIO() + with redirect_stdout(buf): + result = cnapp_commands.PAMCnappQueueListCommand().execute( + self.params, network_uid=NETWORK_UID, status=0, format='table', + no_decrypt=True) + output = buf.getvalue() + self.assertIn('99', output) + self.assertIn('IN_PROGRESS', output) + self.assertIn('CNAPP_PROVIDER_WIZ', output) + self.assertIsNone(result) + + def test_queue_list_filter_resolves_named_status(self): + with patch.object(cnapp_commands.cnapp_helper, 'list_cnapp_queue', + return_value=self._queue_response()) as helper: + with redirect_stdout(io.StringIO()): + cnapp_commands.PAMCnappQueueListCommand().execute( + self.params, network_uid=NETWORK_UID, status='pending', format='table', + no_decrypt=True) + self.assertEqual(helper.call_args.kwargs['status_filter'], 1) + + def test_queue_list_json_format(self): + item = self._queue_item(queue_id=5, status_id=3, record_uid=b'\x02' * 16) + with patch.object(cnapp_commands.cnapp_helper, 'list_cnapp_queue', + return_value=self._queue_response([item], has_more=True)): + buf = io.StringIO() + with redirect_stdout(buf): + result = cnapp_commands.PAMCnappQueueListCommand().execute( + self.params, network_uid=NETWORK_UID, status=0, format='json', + no_decrypt=True) + payload = json.loads(buf.getvalue()) + self.assertEqual(payload['items'][0]['cnappQueueId'], 5) + self.assertEqual(payload['items'][0]['cnappQueueStatusName'], 'RESOLVED') + self.assertTrue(payload['hasMore']) + self.assertEqual(payload['items'][0]['recordUid'], + bytes_to_base64(b'\x02' * 16)) + self.assertNotIn('payload', payload['items'][0], + 'raw encrypted payload bytes must not leak into JSON output') + self.assertIsNone(result, 'JSON output stream must not also return a value') + + def test_queue_list_warns_when_has_more(self): + item = self._queue_item() + with patch.object(cnapp_commands.cnapp_helper, 'list_cnapp_queue', + return_value=self._queue_response([item], has_more=True)): + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappQueueListCommand().execute( + self.params, network_uid=NETWORK_UID, status=0, format='table', + no_decrypt=True) + self.assertIn('hasMore=true', buf.getvalue()) + + def test_queue_associate_success(self): + with patch.object(cnapp_commands.cnapp_helper, 'associate_cnapp_record', return_value=None) as helper: + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappQueueAssociateCommand().execute( + self.params, cnapp_queue_id=12, record_uid=RECORD_UID) + helper.assert_called_once_with(self.params, cnapp_queue_id=12, record_uid=RECORD_UID) + self.assertIn('12', buf.getvalue()) + + def test_queue_remediate_prints_response(self): + response = cnapp_pb2.CnappRemediateResponse( + actionType=cnapp_pb2.ROTATE_CREDENTIALS, + result='Scheduled', + cnappQueueStatusId=2, + ) + with patch.object(cnapp_commands.cnapp_helper, 'remediate_cnapp_queue_item', + return_value=response): + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappQueueRemediateCommand().execute( + self.params, + cnapp_queue_id=4, + action_type='rotate_credentials', + provider='wiz', + ) + output = buf.getvalue() + self.assertIn('ROTATE_CREDENTIALS', output) + self.assertIn('IN_PROGRESS', output) + self.assertIn('Scheduled', output) + + def test_queue_remediate_unsupported_action_propagates(self): + with patch.object(cnapp_commands.cnapp_helper, 'remediate_cnapp_queue_item', + side_effect=Exception('Unsupported action type response code: RRC_BAD_REQUEST')): + with self.assertRaises(Exception) as ctx: + cnapp_commands.PAMCnappQueueRemediateCommand().execute( + self.params, + cnapp_queue_id=4, + action_type='jit_access', + ) + self.assertIn('Unsupported', str(ctx.exception)) + + def test_queue_remediate_invalid_action_name(self): + with self.assertRaises(ValueError): + cnapp_commands.PAMCnappQueueRemediateCommand().execute( + self.params, cnapp_queue_id=1, action_type='nuke_everything') + + def test_queue_set_status_normalizes_named(self): + response = cnapp_pb2.CnappSetStatusResponse(cnappQueueStatusId=3) + with patch.object(cnapp_commands.cnapp_helper, 'set_cnapp_queue_status', + return_value=response) as helper: + with redirect_stdout(io.StringIO()): + cnapp_commands.PAMCnappQueueSetStatusCommand().execute( + self.params, cnapp_queue_id=8, status='resolved', reason='manual') + kwargs = helper.call_args.kwargs + self.assertEqual(kwargs['cnapp_queue_status_id'], 3) + self.assertEqual(kwargs['reason'], 'manual') + + def test_queue_set_status_rejects_zero(self): + with self.assertRaises(CommandError): + cnapp_commands.PAMCnappQueueSetStatusCommand().execute( + self.params, cnapp_queue_id=8, status=0) + + def test_queue_set_status_rejects_unknown_name(self): + with self.assertRaises(CommandError): + cnapp_commands.PAMCnappQueueSetStatusCommand().execute( + self.params, cnapp_queue_id=8, status='snoozed') + + def test_queue_delete_success(self): + with patch.object(cnapp_commands.cnapp_helper, 'delete_cnapp_queue_item', + return_value=None) as helper: + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappQueueDeleteCommand().execute(self.params, cnapp_queue_id=22) + helper.assert_called_once_with(self.params, cnapp_queue_id=22) + self.assertIn('22', buf.getvalue()) + + def test_queue_delete_unknown_id_propagates_error(self): + with patch.object(cnapp_commands.cnapp_helper, 'delete_cnapp_queue_item', + side_effect=Exception('Queue item not found: 99 Response code: RRC_BAD_REQUEST')): + with self.assertRaises(Exception): + cnapp_commands.PAMCnappQueueDeleteCommand().execute(self.params, cnapp_queue_id=99) + + +# --------------------------------------------------------------------------- +# Command tree wiring +# --------------------------------------------------------------------------- + +class TestCommandTree(unittest.TestCase): + """Sanity check that the cnapp commands are reachable via `pam cnapp ...`.""" + + def test_pam_cnapp_subcommands(self): + from keepercommander.commands.discoveryrotation import PAMControllerCommand + pam = PAMControllerCommand() + self.assertIn('cnapp', pam.subcommands) + config = pam.subcommands['cnapp'].subcommands['config'] + queue = pam.subcommands['cnapp'].subcommands['queue'] + self.assertEqual( + sorted(config.subcommands), + ['delete', 'read', 'set', 'test', 'test-encrypter'], + ) + self.assertEqual( + sorted(queue.subcommands), + ['associate', 'delete', 'list', 'remediate', 'set-status'], + ) + + +# --------------------------------------------------------------------------- +# Payload decryption — round-trip an AES-256-GCM envelope and decrypt it back +# --------------------------------------------------------------------------- + +def _encrypt_cnapp_payload_for_test(plaintext_json, key): + """Produce a CNAPP queue payload byte string the way the Encrypter would so we can + exercise `_decrypt_cnapp_payload` end-to-end without mocking AES-GCM.""" + nonce = os.urandom(12) + ciphertext = AESGCM(key).encrypt(nonce, plaintext_json.encode('utf-8'), None) + enc_b64url = base64.urlsafe_b64encode(nonce + ciphertext).rstrip(b'=').decode('ascii') + envelope = json.dumps({ + 'encrypted_payload': enc_b64url, + 'alg': 'AES-256-GCM', + 'version': '1', + }).encode('utf-8') + envelope_b64url = base64.urlsafe_b64encode(envelope).rstrip(b'=').decode('ascii') + return envelope_b64url.encode('utf-8') + + +class TestPayloadDecryption(unittest.TestCase): + """`_decrypt_cnapp_payload` must round-trip the envelope produced by the customer + Encrypter (UTF-8 base64url envelope wrapping nonce||ciphertext||tag).""" + + def setUp(self): + self.key = os.urandom(32) + self.plaintext = { + 'issue': {'id': 'wiz-001', 'severity': 'HIGH', 'created': '2026-05-01T00:00:00Z'}, + 'resource': {'name': 'i-abc', 'type': 'EC2', 'cloudPlatform': 'AWS'}, + 'control': {'name': 'Public S3', 'risks': ['data-exposure']}, + 'tags': ['team:platform'], + } + + def test_roundtrip(self): + payload = _encrypt_cnapp_payload_for_test(json.dumps(self.plaintext), self.key) + decrypted = cnapp_commands._decrypt_cnapp_payload(payload, self.key) + self.assertEqual(decrypted['issue']['id'], 'wiz-001') + self.assertEqual(decrypted['resource']['name'], 'i-abc') + + def test_wrong_key_raises(self): + payload = _encrypt_cnapp_payload_for_test(json.dumps(self.plaintext), self.key) + with self.assertRaises(Exception): + cnapp_commands._decrypt_cnapp_payload(payload, os.urandom(32)) + + def test_unsupported_alg_raises(self): + envelope = json.dumps({'encrypted_payload': '', 'alg': 'ChaCha20', 'version': '1'}).encode('utf-8') + payload = base64.urlsafe_b64encode(envelope).rstrip(b'=') + with self.assertRaises(ValueError): + cnapp_commands._decrypt_cnapp_payload(payload, self.key) + + def test_missing_alg_raises(self): + envelope = json.dumps({'encrypted_payload': '', 'version': '1'}).encode('utf-8') + payload = base64.urlsafe_b64encode(envelope).rstrip(b'=') + with self.assertRaises(ValueError) as ctx: + cnapp_commands._decrypt_cnapp_payload(payload, self.key) + self.assertIn('missing', str(ctx.exception).lower()) + + def test_short_ciphertext_raises(self): + envelope = json.dumps({ + 'encrypted_payload': base64.urlsafe_b64encode(b'abc').rstrip(b'=').decode('ascii'), + 'alg': 'AES-256-GCM', + }).encode('utf-8') + payload = base64.urlsafe_b64encode(envelope).rstrip(b'=') + with self.assertRaises(ValueError): + cnapp_commands._decrypt_cnapp_payload(payload, self.key) + + +class TestKeyDecode(unittest.TestCase): + """`_decode_aes_key` must accept both standard and url-safe base64, only when the + decoded length is exactly 32 bytes (AES-256). 16-byte keys are rejected.""" + + def test_standard_base64_32(self): + raw = base64.b64encode(b'\x11' * 32).decode('ascii') + self.assertEqual(cnapp_commands._decode_aes_key(raw), b'\x11' * 32) + + def test_urlsafe_base64_32(self): + raw = base64.urlsafe_b64encode(b'\x22' * 32).decode('ascii') + self.assertEqual(cnapp_commands._decode_aes_key(raw), b'\x22' * 32) + + def test_16_bytes_returns_none(self): + raw = base64.b64encode(b'\x44' * 16).decode('ascii') + self.assertIsNone(cnapp_commands._decode_aes_key(raw)) + + def test_wrong_length_returns_none(self): + raw = base64.b64encode(b'\x33' * 24).decode('ascii') + self.assertIsNone(cnapp_commands._decode_aes_key(raw)) + + def test_garbage_returns_none(self): + self.assertIsNone(cnapp_commands._decode_aes_key('not base64 at all!!!')) + self.assertIsNone(cnapp_commands._decode_aes_key('')) + self.assertIsNone(cnapp_commands._decode_aes_key(None)) + + +class TestLoadEncrypterKey(unittest.TestCase): + """_load_encrypter_key must try all labeled candidates before giving up.""" + + VALID_KEY = b'\xAB' * 32 + INVALID_RAW = 'not-a-valid-key!!' + + def _make_typed_field(self, type_ref, label=None, value=None): + field = MagicMock() + field.type = type_ref + field.label = label + field.value = value or [] + return field + + def _make_record(self, secret_labeled=None, note_labeled=None, note_unlabeled=None): + record = MagicMock(spec=cnapp_commands.vault.TypedRecord) + + def get_typed_field(type_ref, label=None): + if type_ref == 'secret' and label == cnapp_commands.CNAPP_ENCRYPTION_KEY_LABEL: + return secret_labeled + if type_ref == 'note' and label == cnapp_commands.CNAPP_ENCRYPTION_KEY_LABEL: + return note_labeled + if type_ref == 'note' and label is None: + return note_unlabeled + return None + + record.get_typed_field.side_effect = get_typed_field + return record + + def _field_with_value(self, raw): + f = MagicMock() + f.value = [raw] + return f + + def test_returns_valid_secret_labeled_key(self): + raw = base64.b64encode(self.VALID_KEY).decode('ascii') + record = self._make_record(secret_labeled=self._field_with_value(raw)) + params = MagicMock() + with patch.object(cnapp_commands.vault.KeeperRecord, 'load', return_value=record): + result = cnapp_commands._load_encrypter_key(params, 'uid123') + self.assertEqual(result, self.VALID_KEY) + + def test_falls_through_to_note_labeled_when_secret_invalid(self): + """When the secret-labeled field is invalid, note-labeled field must still be tried.""" + valid_raw = base64.b64encode(self.VALID_KEY).decode('ascii') + record = self._make_record( + secret_labeled=self._field_with_value(self.INVALID_RAW), + note_labeled=self._field_with_value(valid_raw), + ) + params = MagicMock() + with patch.object(cnapp_commands.vault.KeeperRecord, 'load', return_value=record): + result = cnapp_commands._load_encrypter_key(params, 'uid123') + self.assertEqual(result, self.VALID_KEY) + + def test_warns_and_returns_none_when_all_labeled_invalid(self): + """If labeled fields exist but all are invalid, warn and return None (no unlabeled fallback).""" + unlabeled = self._field_with_value(base64.b64encode(self.VALID_KEY).decode('ascii')) + record = self._make_record( + secret_labeled=self._field_with_value(self.INVALID_RAW), + note_labeled=self._field_with_value(self.INVALID_RAW), + note_unlabeled=unlabeled, + ) + params = MagicMock() + with patch.object(cnapp_commands.vault.KeeperRecord, 'load', return_value=record): + with self.assertLogs(cnapp_commands.__name__, level='WARNING') as cm: + result = cnapp_commands._load_encrypter_key(params, 'uid123') + self.assertIsNone(result) + self.assertTrue(any('not a valid AES-256 key' in msg for msg in cm.output)) + + def test_falls_back_to_unlabeled_note_when_no_labeled_fields(self): + """When no labeled key fields exist at all, the first unlabeled note field is used.""" + valid_raw = base64.b64encode(self.VALID_KEY).decode('ascii') + unlabeled = self._field_with_value(valid_raw) + record = self._make_record(note_unlabeled=unlabeled) + params = MagicMock() + with patch.object(cnapp_commands.vault.KeeperRecord, 'load', return_value=record): + result = cnapp_commands._load_encrypter_key(params, 'uid123') + self.assertEqual(result, self.VALID_KEY) + + def test_returns_none_for_missing_record_uid(self): + params = MagicMock() + self.assertIsNone(cnapp_commands._load_encrypter_key(params, None)) + self.assertIsNone(cnapp_commands._load_encrypter_key(params, '')) + + +class TestQueueListDecryptionIntegration(unittest.TestCase): + """End-to-end: `queue list` resolves the encrypter key via the vault record, decrypts + each payload, and writes the human summary into the table cell.""" + + def setUp(self): + self.params = _mock_params() + self.key = os.urandom(32) + + def _make_item(self, queue_id, plaintext): + return cnapp_pb2.CnappQueueItem( + cnappQueueId=queue_id, + cnappProviderId=cnapp_pb2.CNAPP_PROVIDER_WIZ, + cnappQueueStatusId=1, + receivedAt=1700000000000, + networkId=b'\x00' * 16, + payload=_encrypt_cnapp_payload_for_test(json.dumps(plaintext), self.key), + ) + + def test_table_shows_decrypted_summary_when_key_resolves(self): + items = [self._make_item(101, { + 'issue': {'id': 'wiz-999', 'severity': 'CRITICAL'}, + 'control': {'name': 'Open SSH'}, + 'resource': {'name': 'prod-db-1'}, + })] + response = cnapp_pb2.CnappQueueListResponse(items=items) + config = cnapp_pb2.CnappConfiguration( + cnappConfigRecordUid=b'\xab' * 16, + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ, + ) + with patch.object(cnapp_commands.cnapp_helper, 'list_cnapp_queue', return_value=response), \ + patch.object(cnapp_commands.cnapp_helper, 'read_cnapp_configuration', return_value=config), \ + patch.object(cnapp_commands, '_load_encrypter_key', return_value=self.key): + buf = io.StringIO() + with redirect_stdout(buf): + result = cnapp_commands.PAMCnappQueueListCommand().execute( + self.params, network_uid=NETWORK_UID, status=0, format='table', + provider='wiz', config_record_uid=None, no_decrypt=False) + output = buf.getvalue() + self.assertIn('CRITICAL', output) + self.assertIn('Open SSH', output) + self.assertIn('prod-db-1', output) + self.assertNotIn('', output, 'payload should have been decrypted') + self.assertIsNone(result) + + def test_table_marks_encrypted_when_key_unavailable(self): + items = [self._make_item(7, {'issue': {'id': 'x'}})] + response = cnapp_pb2.CnappQueueListResponse(items=items) + with patch.object(cnapp_commands.cnapp_helper, 'list_cnapp_queue', return_value=response), \ + patch.object(cnapp_commands.cnapp_helper, 'read_cnapp_configuration', + return_value=cnapp_pb2.CnappConfiguration()): + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappQueueListCommand().execute( + self.params, network_uid=NETWORK_UID, status=0, format='table', + provider='wiz', no_decrypt=False) + output = buf.getvalue() + self.assertIn('', output) + self.assertIn('No encrypter key', output) + + def test_json_includes_decrypted_payload_and_no_raw_payload(self): + plaintext = {'issue': {'id': 'wiz-42'}, 'resource': {'name': 'i-xyz'}} + response = cnapp_pb2.CnappQueueListResponse(items=[self._make_item(42, plaintext)]) + config = cnapp_pb2.CnappConfiguration(cnappConfigRecordUid=b'\xcd' * 16, + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ) + with patch.object(cnapp_commands.cnapp_helper, 'list_cnapp_queue', return_value=response), \ + patch.object(cnapp_commands.cnapp_helper, 'read_cnapp_configuration', return_value=config), \ + patch.object(cnapp_commands, '_load_encrypter_key', return_value=self.key): + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappQueueListCommand().execute( + self.params, network_uid=NETWORK_UID, status=0, format='json', + provider='wiz', no_decrypt=False) + payload = json.loads(buf.getvalue()) + self.assertEqual(payload['items'][0]['decryptedPayload']['issue']['id'], 'wiz-42') + self.assertNotIn('payload', payload['items'][0]) + + def test_decrypt_failure_keeps_other_rows_and_reports(self): + good = self._make_item(1, { + 'issue': {'id': 'wiz-good-should-not-show'}, + 'control': {'name': 'Open SSH'}, + 'resource': {'name': 'good-resource'}, + }) + bad = cnapp_pb2.CnappQueueItem( + cnappQueueId=2, + cnappProviderId=cnapp_pb2.CNAPP_PROVIDER_WIZ, + cnappQueueStatusId=1, + payload=b'this-is-not-a-valid-envelope', + ) + response = cnapp_pb2.CnappQueueListResponse(items=[good, bad]) + config = cnapp_pb2.CnappConfiguration(cnappConfigRecordUid=b'\xef' * 16, + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ) + with patch.object(cnapp_commands.cnapp_helper, 'list_cnapp_queue', return_value=response), \ + patch.object(cnapp_commands.cnapp_helper, 'read_cnapp_configuration', return_value=config), \ + patch.object(cnapp_commands, '_load_encrypter_key', return_value=self.key): + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappQueueListCommand().execute( + self.params, network_uid=NETWORK_UID, status=0, format='table', + provider='wiz', no_decrypt=False) + output = buf.getvalue() + self.assertIn('Open SSH', output) + self.assertNotIn('wiz-good-should-not-show', output) + self.assertIn('good-resource', output) + self.assertIn('', output) + self.assertIn('failed to decrypt payload', output) + + def test_json_reports_decrypt_error(self): + good = self._make_item(1, {'issue': {'id': 'wiz-1'}}) + bad = cnapp_pb2.CnappQueueItem( + cnappQueueId=2, + cnappProviderId=cnapp_pb2.CNAPP_PROVIDER_WIZ, + cnappQueueStatusId=1, + payload=b'not-valid', + ) + response = cnapp_pb2.CnappQueueListResponse(items=[good, bad]) + config = cnapp_pb2.CnappConfiguration(cnappConfigRecordUid=b'\xef' * 16, + provider=cnapp_pb2.CNAPP_PROVIDER_WIZ) + with patch.object(cnapp_commands.cnapp_helper, 'list_cnapp_queue', return_value=response), \ + patch.object(cnapp_commands.cnapp_helper, 'read_cnapp_configuration', return_value=config), \ + patch.object(cnapp_commands, '_load_encrypter_key', return_value=self.key): + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappQueueListCommand().execute( + self.params, network_uid=NETWORK_UID, status=0, format='json', + provider='wiz', no_decrypt=False) + items = json.loads(buf.getvalue())['items'] + self.assertIn('decryptedPayload', items[0]) + self.assertIn('decryptError', items[1]) + self.assertNotIn('decryptedPayload', items[1]) + + def test_no_decrypt_flag_skips_key_lookup(self): + items = [self._make_item(11, {'issue': {'id': 'x'}})] + response = cnapp_pb2.CnappQueueListResponse(items=items) + with patch.object(cnapp_commands.cnapp_helper, 'list_cnapp_queue', return_value=response), \ + patch.object(cnapp_commands, '_load_encrypter_key') as key_loader: + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappQueueListCommand().execute( + self.params, network_uid=NETWORK_UID, status=0, format='table', + no_decrypt=True) + key_loader.assert_not_called() + self.assertNotIn('No encrypter key', buf.getvalue()) + self.assertIn('', buf.getvalue()) + + +if __name__ == '__main__': + unittest.main() diff --git a/unit-tests/pam/test_dag_layer_b_migration.py b/unit-tests/pam/test_dag_layer_b_migration.py index ba381cdcb..7f85e10db 100644 --- a/unit-tests/pam/test_dag_layer_b_migration.py +++ b/unit-tests/pam/test_dag_layer_b_migration.py @@ -155,6 +155,37 @@ def _capture(params, rq): }).encode() meta_mock.assert_called_once() + def test_happy_path_bundles_current_meta_so_krouter_persists_ai_edge(self): + """Regression: krouter's configure_resource only writes a settings edge + when it loads loopEdges, which it does only for requests carrying + meta/jit/connection (UserRest.kt:497). A keeperAiSettings-only request + leaves loopEdges null and the ai_settings write is silently dropped. The + Web Vault always sends meta alongside AI settings; Commander must mirror + that by bundling the resource's current meta in the same request.""" + captured = {} + + def _capture(params, rq): + captured['rq'] = rq + return None + + meta_dict = {'version': 1, 'allowedSettings': {'aiEnabled': True}, 'rotateOnTermination': False} + with _patch_inputs(), \ + patch.object(ai_mod, 'encrypt_aes', return_value=b'CIPHER_BYTES'), \ + patch.object(ai_mod, 'get_resource_settings', return_value=meta_dict) as meta_mock, \ + patch('keepercommander.commands.pam.router_helper.router_configure_resource', side_effect=_capture): + ok = ai_mod.set_resource_keeper_ai_settings( + _mock_params(), RESOURCE_UID_STR, {'level': 'critical'}, config_uid=CONFIG_UID_STR + ) + assert ok is True + rq = captured['rq'] + assert rq.keeperAiSettings == b'CIPHER_BYTES' + # The fix: meta must be present so krouter fetches loopEdges and persists + # the ai_settings edge. Without it the write is a silent no-op. + assert rq.meta == json.dumps(meta_dict).encode() + # meta is read from the resource's current 'meta' DATA edge. + meta_mock.assert_called_once() + assert meta_mock.call_args.args[2] == 'meta' + def test_permission_denied_with_fallback_enabled_calls_legacy(self): legacy_called = {'count': 0} diff --git a/unit-tests/test_command_enterprise_api_keys.py b/unit-tests/test_command_enterprise_api_keys.py index e90108927..92d4183f9 100644 --- a/unit-tests/test_command_enterprise_api_keys.py +++ b/unit-tests/test_command_enterprise_api_keys.py @@ -100,7 +100,7 @@ def test_api_key_list_json_format(self): "name": "SIEM Tool", "status": "Active", "issued_date": "2025-07-08 14:16:07", - "expiration_date": "2026-07-08 14:16:07", + "expiration_date": "2030-07-08 14:16:07", "integration": "SIEM:2" }, { @@ -667,7 +667,7 @@ def communicate_rest_success(params, request, path, rs_type=None): token4.name = "SIEM Tool" token4.enterprise_id = 8560 token4.issuedDate = int(datetime.datetime(2025, 7, 8, 14, 16, 7).timestamp() * 1000) - token4.expirationDate = int(datetime.datetime(2026, 7, 8, 14, 16, 7).timestamp() * 1000) + token4.expirationDate = int(datetime.datetime(2030, 7, 8, 14, 16, 7).timestamp() * 1000) integration7 = token4.integrations.add() integration7.roleName = "SIEM" integration7.apiIntegrationTypeName = "SIEM" From 02abf3ddd04895e6235a9a21a2982c01f68b216f Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Thu, 9 Jul 2026 13:18:15 +0530 Subject: [PATCH 3/9] Fix redundant full syncs during Docker Service Mode startup (#2196) * Fix docker startup multiple syncs * Remove sync from 'service-status' and keep it in 'this-device' --- docker-entrypoint.sh | 41 ++++++++----------- .../service/commands/handle_service.py | 5 ++- keepercommander/service/docker/printer.py | 4 +- unit-tests/service/test_service_manager.py | 4 ++ 4 files changed, 28 insertions(+), 26 deletions(-) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 19b265193..91ffad491 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -159,38 +159,31 @@ process_ksm_config() { # DEVICE SETUP AND AUTHENTICATION FUNCTIONS # ============================================================================= -# Setup device registration and persistent login +# Setup device registration and persistent login. +# All three steps run in a single Commander process so login + vault sync +# only happens once instead of three times. setup_device() { local user="$1" local password="$2" local server="$3" - - # Step 1: Register device - log "Registering device..." - if ! python3 keeper.py --user "${user}" --password "${password}" \ - --server "${server}" this-device register; then - log "ERROR: Device registration failed" - exit 1 - fi - - # Step 2: Enable persistent login - log "Enabling persistent login..." - if ! python3 keeper.py --user "${user}" --password "${password}" \ - --server "${server}" this-device persistent-login on; then - log "ERROR: Persistent login setup failed" - exit 1 - fi - # Step 3: Set timeout - log "Setting device logout timeout to 30 Days..." + log "Running device setup (register, persistent login, timeout)..." + local setup_script + setup_script=$(mktemp /tmp/keeper_setup_XXXXXX.cmd) + cat > "${setup_script}" < /dev/null; then - log "ERROR: Timeout setup failed" + --server "${server}" "${setup_script}"; then + log "ERROR: Device setup failed" + rm -f "${setup_script}" exit 1 fi - - log "Device Logout Timeout set successfully" + + rm -f "${setup_script}" log "Device setup completed successfully" } diff --git a/keepercommander/service/commands/handle_service.py b/keepercommander/service/commands/handle_service.py index d18398498..8051ea92f 100644 --- a/keepercommander/service/commands/handle_service.py +++ b/keepercommander/service/commands/handle_service.py @@ -39,11 +39,14 @@ def execute(self, params: KeeperParams, **kwargs) -> None: class ServiceStatus(Command): """Command to get service status.""" + + skip_sync_on_auth = True + @debug_decorator def get_parser(self): parser = argparse.ArgumentParser(prog='service-status', parents=[report_output_parser], description='Displays if the Commander API service is running or stopped') return parser - + def execute(self, params: KeeperParams, **kwargs) -> str: status = ServiceManager.get_status() print(f"Current status: {status}") \ No newline at end of file diff --git a/keepercommander/service/docker/printer.py b/keepercommander/service/docker/printer.py index 28d593729..af1b66dfd 100644 --- a/keepercommander/service/docker/printer.py +++ b/keepercommander/service/docker/printer.py @@ -13,6 +13,8 @@ Output formatting utilities for Docker setup commands. """ +import shlex + from ...display import bcolors from .models import SetupResult @@ -68,7 +70,7 @@ def print_common_deployment_steps(port: str, config_path: str = None) -> None: config_file = config_path if config_path else '~/.keeper/config.json' print(f"\n{bcolors.BOLD}Step 2: Delete the local config.json file{bcolors.ENDC}") - print(f" {bcolors.OKGREEN}rm {config_file}{bcolors.ENDC}") + print(f" {bcolors.OKGREEN}rm {shlex.quote(config_file)}{bcolors.ENDC}") print(f" Why? Prevents device token conflicts - Docker will download its own config.") print(f"\n{bcolors.BOLD}Step 3: Review docker-compose.yml{bcolors.ENDC}") diff --git a/unit-tests/service/test_service_manager.py b/unit-tests/service/test_service_manager.py index f1798ac30..45ac3925b 100644 --- a/unit-tests/service/test_service_manager.py +++ b/unit-tests/service/test_service_manager.py @@ -115,6 +115,10 @@ def test_service_status_when_not_running(self): status_cmd.execute(self.params) mock_print.assert_called_with("Current status: No Commander Service is running currently") + def test_service_status_skips_sync_on_auth(self): + """service-status still requires login but must not trigger a full vault sync.""" + self.assertTrue(ServiceStatus.skip_sync_on_auth) + def test_process_info_save_load(self): """Test ProcessInfo save and load operations""" test_pid = 12345 From c3154e1f16fad3ae93ad4278aef138ffd005ad97 Mon Sep 17 00:00:00 2001 From: sshrushanth-ks Date: Thu, 9 Jul 2026 19:48:36 +0530 Subject: [PATCH 4/9] KC-1315, KC-1329: Fix share-folder record expiration and ROE handling (#2168) * Fix share-folder record expiration removing owner records and breaking re-shares When share-folder was used with -r and --expire-in, expiration was applied to SharedFolderUpdateRecord, which caused the record to be removed from the owner's vault when the timer expired. Route per-record expiration and -roe through the record share API (revoke then re-grant) instead, keep folder user updates for access only, sync before granting, and skip redundant user updates when sharing additional records to the same recipient. * Share-folder: expire folder and record access together; log expiry in output * updated test file * Fix share-folder remove vault deletion; clean up access grant/remove logs * Fix share folder expire in and -r combination * Separate folder and record flag usage to fix multiple remove related issue * Fix -p and -o flags and 1mi expiry * Restrict outside records to be shared via -r * Update help --------- Co-authored-by: amangalampalli-ks --- .../commands/nested_share_folder/helpers.py | 7 +- keepercommander/commands/register.py | 403 +++++++++++++----- unit-tests/test_command_register.py | 334 ++++++++++++++- 3 files changed, 637 insertions(+), 107 deletions(-) diff --git a/keepercommander/commands/nested_share_folder/helpers.py b/keepercommander/commands/nested_share_folder/helpers.py index 5c990d04a..57ab381e5 100644 --- a/keepercommander/commands/nested_share_folder/helpers.py +++ b/keepercommander/commands/nested_share_folder/helpers.py @@ -309,12 +309,13 @@ def walk(fuid): # Expiration parsing # ═══════════════════════════════════════════════════════════════════════════ -def validate_share_expiration_timestamp(expiration_ms, cmd_name): +def validate_share_expiration_timestamp(expiration_ms, cmd_name, *, now_ms=None): """Reject finite expirations that are less than one minute.""" if expiration_ms is None or expiration_ms == -1: return - min_allowed = int(datetime.datetime.now(timezone.utc).timestamp() * 1000) + MIN_SHARE_EXPIRATION_MS - if expiration_ms < min_allowed: + if now_ms is None: + now_ms = int(datetime.datetime.now(timezone.utc).timestamp() * 1000) + if expiration_ms < now_ms + MIN_SHARE_EXPIRATION_MS: raise CommandError( cmd_name, 'Share expiration must be at least 1 minute.', diff --git a/keepercommander/commands/register.py b/keepercommander/commands/register.py index 5aac059db..b8de28b37 100644 --- a/keepercommander/commands/register.py +++ b/keepercommander/commands/register.py @@ -20,7 +20,7 @@ import re import time import urllib.parse -from typing import Optional, Dict, Iterable, Any, Set, List, Union +from typing import Optional, Dict, Iterable, Any, Set, List, Union, Tuple from urllib.parse import urlunparse from tabulate import tabulate @@ -93,37 +93,59 @@ def register_command_info(aliases, command_info): '(not "never") and a pamUser record with rotation configured.') share_record_parser.add_argument('record', nargs='?', type=str, action='store', help='record/shared folder path/UID') -share_folder_parser = argparse.ArgumentParser(prog='share-folder', description='Change the permissions of a shared folder') -share_folder_parser.add_argument('-a', '--action', dest='action', choices=['grant','remove'], - default='grant', action='store', help='shared folder action. \'grant\' if omitted') -share_folder_parser.add_argument('-e', '--email', dest='user', action='append', - help='account email, team, @existing for all users and teams in the folder, ' - 'or \'*\' as default folder permission') -share_folder_parser.add_argument('-r', '--record', dest='record', action='append', - help='record name, record UID, @existing for all records in the folder,' - ' or \'*\' as default folder permission') -share_folder_parser.add_argument('-p', '--manage-records', dest='manage_records', action='store', - choices=['on', 'off'], help='account permission: can manage records.') -share_folder_parser.add_argument('-o', '--manage-users', dest='manage_users', action='store', - choices=['on', 'off'], help='account permission: can manage users.') -share_folder_parser.add_argument('-s', '--can-share', dest='can_share', action='store', - choices=['on', 'off'], help='record permission: can be shared') -share_folder_parser.add_argument('-d', '--can-edit', dest='can_edit', action='store', - choices=['on', 'off'], help='record permission: can be modified.') -share_folder_parser.add_argument('-f', '--force', dest='force', action='store_true', - help='Apply permission changes ignoring default folder permissions. Used on the ' - 'initial sharing action') -expiration = share_folder_parser.add_mutually_exclusive_group() -expiration.add_argument('--expire-at', dest='expire_at', action='store', metavar='TIMESTAMP', - help='share expiration: never or ISO datetime (yyyy-MM-dd[ hh:mm:ss])') -expiration.add_argument('--expire-in', dest='expire_in', action='store', metavar='PERIOD', - help='share expiration: never or period ([(y)ears|(mo)nths|(d)ays|(h)ours(mi)nutes]') -share_folder_parser.add_argument('-roe', '--rotate-on-expiration', dest='rotate_on_expiration', action='store_true', - help='rotate the password when the share access expires. ' - 'Only valid on grant; requires a positive --expire-at/--expire-in ' - '(not "never") and at least one pamUser record with rotation ' - 'configured in the folder.') -share_folder_parser.add_argument('folder', nargs='+', type=str, action='store', help='shared folder path or UID') +share_folder_parser = argparse.ArgumentParser( + prog='share-folder', + description='Manage shared folder access for users and teams, and record permissions in the folder.') + +folder_access = share_folder_parser.add_argument_group( + 'folder access', 'Who can use the folder (grant or remove with -a)') +folder_access.add_argument( + '-a', '--action', dest='action', choices=['grant', 'remove'], + default='grant', action='store', + help='folder access action for -e users/teams: grant (default) or remove') +folder_access.add_argument( + '-e', '--email', dest='user', action='append', + help='account email, team, @existing for all users and teams in the folder, ' + 'or \'*\' as default user permission') +folder_access.add_argument( + '-p', '--manage-records', dest='manage_records', action='store', + choices=['on', 'off'], help='account permission: can manage records. Requires -e.') +folder_access.add_argument( + '-o', '--manage-users', dest='manage_users', action='store', + choices=['on', 'off'], + help='account permission: can manage users. Mutually exclusive with --expire-at/--expire-in. Requires -e.') +expiration = folder_access.add_mutually_exclusive_group() +expiration.add_argument( + '--expire-at', dest='expire_at', action='store', metavar='TIMESTAMP', + help='folder access expiration for -e: never or ISO datetime (yyyy-MM-dd[ hh:mm:ss]). Requires -e.') +expiration.add_argument( + '--expire-in', dest='expire_in', action='store', metavar='PERIOD', + help='folder access expiration for -e: never or period ' + '([(y)ears|(mo)nths|(d)ays|(h)ours|(mi)nutes]). Requires -e.') +folder_access.add_argument( + '-roe', '--rotate-on-expiration', dest='rotate_on_expiration', action='store_true', + help='rotate the password when folder access expires. Grant only; requires a positive ' + '--expire-at/--expire-in (not "never") and a pamUser record with rotation configured. ' + 'Requires -a grant, -e, and --expire-at or --expire-in.') +folder_access.add_argument( + '-f', '--force', dest='force', action='store_true', + help='skip confirmation prompts') + +record_access = share_folder_parser.add_argument_group( + 'record permissions', 'Can edit and can share for records in the folder') +record_access.add_argument( + '-r', '--record', dest='record', action='append', + help='record name or UID already in the folder, @existing for all records in the folder, ' + 'or \'*\' as default record permission') +record_access.add_argument( + '-s', '--can-share', dest='can_share', action='store', + choices=['on', 'off'], help='record permission: can be shared. Requires -r.') +record_access.add_argument( + '-d', '--can-edit', dest='can_edit', action='store', + choices=['on', 'off'], help='record permission: can be modified. Requires -r.') + +share_folder_parser.add_argument( + 'folder', nargs='+', type=str, action='store', help='shared folder path or UID') share_report_parser = argparse.ArgumentParser(prog='share-report', description='Generates a report of shared records', parents=[base.report_output_parser]) @@ -232,6 +254,7 @@ def get_share_expiration(expire_at, expire_in, cmd_name='share-record'): # ( return dt = None # type: Optional[datetime.datetime] + now_ms = None # type: Optional[int] if isinstance(expire_at, str): if expire_at == 'never': return -1 @@ -244,16 +267,108 @@ def get_share_expiration(expire_at, expire_in, cmd_name='share-record'): # ( raise CommandError( cmd_name, 'Share expiration must be at least 1 minute.', - ) - dt = datetime.datetime.now() + td + now_utc = datetime.datetime.now(datetime.timezone.utc) + now_ms = int(now_utc.timestamp() * 1000) + dt = now_utc + td if dt is None: raise ValueError(f'Incorrect expiration: {expire_at or expire_in}') - expiration_seconds = int(dt.timestamp()) + expiration_ms = int(dt.timestamp() * 1000) from .nested_share_folder.helpers import validate_share_expiration_timestamp - validate_share_expiration_timestamp(expiration_seconds * 1000, cmd_name) - return expiration_seconds + validate_share_expiration_timestamp(expiration_ms, cmd_name, now_ms=now_ms) + return expiration_ms // 1000 + + +def _as_append_list(value): + # type: (Any) -> List[Any] + if not value: + return [] + return value if isinstance(value, list) else [value] + + +def _folder_has_record_permission_target(record_uids, default_record, all_records): + # type: (Set[str], bool, bool) -> bool + return bool(record_uids or default_record or all_records) + + +def _folder_user_lookup(shared_folder, email): + # type: (dict, str) -> Optional[dict] + """Find a folder user entry by email (case-insensitive).""" + email_lower = email.lower() + for user in shared_folder.get('users', []): + if user.get('username', '').lower() == email_lower: + return user + return None + + +def format_share_expiration_ms(expiration_ms): + # type: (int) -> str + """Format a share expiration timestamp (milliseconds) for log output.""" + if expiration_ms > 0: + return str(datetime.datetime.fromtimestamp(expiration_ms / 1000)) + if expiration_ms < 0: + return 'never' + return '' + + +def _folder_share_expiration_lookups(rq): + # type: (folder_pb2.SharedFolderUpdateV3Request) -> Tuple[Dict[str, int], Dict[str, int]] + """Map folder user/team names to expiration values from an outgoing folder update request.""" + user_exp = {} + for folder_user in list(rq.sharedFolderAddUser) + list(rq.sharedFolderUpdateUser): + if folder_user.expiration: + user_exp[folder_user.username.lower()] = folder_user.expiration + team_exp = {} + for folder_team in list(rq.sharedFolderAddTeam) + list(rq.sharedFolderUpdateTeam): + if folder_team.expiration: + team_exp[utils.base64_url_encode(folder_team.teamUid)] = folder_team.expiration + return user_exp, team_exp + + +def _record_share_expiration_lookup(rq): + # type: (record_pb2.RecordShareUpdateRequest) -> Dict[Tuple[str, str], int] + """Map (record_uid, username) pairs to expiration values from an outgoing record share request.""" + lookup = {} + for attr in ('addSharedRecord', 'updateSharedRecord'): + for shared_record in getattr(rq, attr): + if shared_record.expiration: + lookup[(utils.base64_url_encode(shared_record.recordUid), + shared_record.toUsername.lower())] = shared_record.expiration + return lookup + + +def _record_share_log_title(params, record_uid): + # type: (KeeperParams, str) -> str + if record_uid in params.record_cache: + return api.get_record(params, record_uid).title + return record_uid + + +def _is_shared_folder_owner(params, shared_folder): + # type: (KeeperParams, dict) -> bool + owner_uid = shared_folder.get('owner_account_uid') + if owner_uid: + return owner_uid == utils.base64_url_encode(params.account_uid_bytes) + owner_username = shared_folder.get('owner_username') + if owner_username and params.user: + return owner_username.lower() == params.user.lower() + return False + + +def _folder_has_full_manager_excluding(shared_folder, exclude_usernames=()): + # type: (dict, Iterable[str]) -> bool + exclude = {x.lower() for x in exclude_usernames if x} + for user in shared_folder.get('users', []): + username = (user.get('username') or '').lower() + if username in exclude: + continue + if user.get('manage_records') and user.get('manage_users'): + return True + for team in shared_folder.get('teams', []): + if team.get('manage_records') and team.get('manage_users'): + return True + return False class ShareFolderCommand(Command): @@ -321,6 +436,8 @@ def get_share_admin_obj_uids(obj_names, obj_type): if action == 'grant': share_expiration = get_share_expiration( kwargs.get('expire_at'), kwargs.get('expire_in'), cmd_name='share-folder') + if isinstance(share_expiration, int) and (kwargs.get('user') or kwargs.get('record')): + SyncDownCommand().execute(params, force=True) rotate_on_expiration = bool(kwargs.get('rotate_on_expiration')) if rotate_on_expiration: @@ -347,7 +464,7 @@ def get_share_admin_obj_uids(obj_names, obj_type): all_users = False default_account = False if 'user' in kwargs: - for u in (kwargs.get('user') or []): + for u in _as_append_list(kwargs.get('user')): if u == '*': default_account = True elif u in ('@existing', '@current'): @@ -378,8 +495,7 @@ def get_share_admin_obj_uids(obj_names, obj_type): default_record = False unresolved_names = [] if 'record' in kwargs: - records = kwargs.get('record') or [] - for r in records: + for r in _as_append_list(kwargs.get('record')): if r == '*': default_record = True elif r in ('@existing', '@current'): @@ -392,12 +508,26 @@ def get_share_admin_obj_uids(obj_names, obj_type): sa_record_uids = get_share_admin_obj_uids(unresolved_names, record_pb2.CHECK_SA_ON_RECORD) record_uids.update(sa_record_uids or {}) + ShareFolderCommand._validate_share_folder_kwargs( + action, kwargs, + record_uids=record_uids, + default_record=default_record, + all_records=all_records) + + if record_uids and not default_record and not all_records: + ShareFolderCommand._validate_records_in_shared_folders( + params, shared_folder_uids, record_uids) + if len(as_users) == 0 and len(as_teams) == 0 and len(record_uids) == 0 and \ not default_record and not default_account and \ not all_users and not all_records: logging.info('Nothing to do') return + if action == 'remove' and as_users: + ShareFolderCommand._confirm_folder_user_removals( + params, shared_folder_uids, as_users, force=kwargs.get('force') is True) + rq_groups = [] def prep_rq(recs, users, curr_sf): @@ -424,19 +554,20 @@ def prep_rq(recs, users, curr_sf): else: sh_fol = { 'shared_folder_uid': sf_uid, - 'users': [{'username': x, 'manage_records': action != 'grant', 'manage_users': action != 'grant'} + 'users': [{'username': x, 'manage_records': False, 'manage_users': False} for x in as_users], - 'teams': [{'team_uid': x, 'manage_records': action != 'grant', 'manage_users': action != 'grant'} + 'teams': [{'team_uid': x, 'manage_records': False, 'manage_users': False} for x in as_teams], - 'records': [{'record_uid': x, 'can_share': action != 'grant', 'can_edit': action != 'grant'} - for x in record_uids] } + if record_uids: + sh_fol['records'] = [ + {'record_uid': x, 'can_share': False, 'can_edit': False} for x in record_uids] chunk_size = 500 rec_list = list(sf_records) user_list = list(sf_users) - num_rec_chunks = math.ceil(len(sf_records) / chunk_size) - num_user_chunks = math.ceil(len(sf_users) / chunk_size) - num_rq_groups = num_user_chunks or 1 * num_rec_chunks or 1 + num_rec_chunks = math.ceil(len(sf_records) / chunk_size) if sf_records else 0 + num_user_chunks = math.ceil(len(sf_users) / chunk_size) if sf_users else 0 + num_rq_groups = (num_rec_chunks or 1) * (num_user_chunks or 1) while len(rq_groups) < num_rq_groups: rq_groups.append([]) rec_chunks = [rec_list[i * chunk_size:(i + 1) * chunk_size] for i in range(num_rec_chunks)] or [[]] @@ -446,11 +577,79 @@ def prep_rq(recs, users, curr_sf): for u_chunk in user_chunks: sf_info = sh_fol.copy() if group_idx: - del sf_info['revision'] + sf_info.pop('revision', None) rq_groups[group_idx].append(prep_rq(r_chunk, u_chunk, sf_info)) group_idx += 1 self.send_requests(params, rq_groups) + @staticmethod + def _validate_share_folder_kwargs(action, kwargs, *, record_uids, default_record, all_records): + has_record_target = _folder_has_record_permission_target(record_uids, default_record, all_records) + has_record_perms = kwargs.get('can_edit') is not None or kwargs.get('can_share') is not None + + if has_record_perms and not has_record_target: + raise CommandError( + 'share-folder', + '-d and -s require a record target: -r , -r *, or -r @existing.') + + @staticmethod + def _validate_records_in_shared_folders(params, shared_folder_uids, record_uids): + # type: (KeeperParams, Set[str], Set[str]) -> None + """Reject -r targets that are not already linked to the shared folder.""" + for sf_uid in shared_folder_uids: + sh_fol = params.shared_folder_cache.get(sf_uid) + if not sh_fol: + raise CommandError( + 'share-folder', + f'Shared folder "{sf_uid}" is not loaded. Sync down and retry.') + folder_record_uids = {x['record_uid'] for x in sh_fol.get('records', [])} + missing = record_uids - folder_record_uids + if not missing: + continue + labels = [] + for uid in sorted(missing): + rec = params.record_cache.get(uid) + title = rec.get('title_unencrypted') if rec else None + labels.append(title or uid) + folder_name = sh_fol.get('name_unencrypted') or sf_uid + raise CommandError( + 'share-folder', + f'Record(s) not in shared folder "{folder_name}": ' + ', '.join(labels)) + + @staticmethod + def _confirm_folder_user_removals(params, shared_folder_uids, users_to_remove, *, force=False): + # type: (KeeperParams, Set[str], Set[str], bool) -> None + current_user = (params.user or '').lower() + if not current_user or current_user not in {u.lower() for u in users_to_remove}: + return + + removing_self_from_shared_folder = False + for sf_uid in shared_folder_uids: + sh_fol = params.shared_folder_cache.get(sf_uid, {}) + if _is_shared_folder_owner(params, sh_fol): + if not _folder_has_full_manager_excluding(sh_fol, [params.user]): + raise CommandError( + 'share-folder', + 'Cannot remove yourself from this shared folder: no other participant has ' + 'manage users and manage records permission.') + if not force: + answer = user_choice( + 'Removing yourself will relinquish folder ownership. ' + 'Another participant can manage users and records. Proceed?', + 'yn', 'n') + if answer.lower() not in ('y', 'yes'): + raise CommandError('share-folder', 'Operation cancelled.') + else: + removing_self_from_shared_folder = True + + if removing_self_from_shared_folder and not force: + answer = user_choice( + 'Are you sure that you want to delete yourself from this shared folder? ' + 'You will not be able to add yourself back into the shared folder after removal.', + 'yn', 'n') + if answer.lower() not in ('y', 'yes'): + raise CommandError('share-folder', 'Operation cancelled.') + @staticmethod def prepare_request(params, kwargs, curr_sf, users, teams, rec_uids, *, default_record=False, default_account=False, @@ -466,7 +665,7 @@ def prepare_request(params, kwargs, curr_sf, users, teams, rec_uids, *, mu = kwargs.get('manage_users') def apply_share_expiration(target): - """Set expiration / timer / rotateOnExpiration on a User/Team/Record update proto.""" + """Set expiration / timer / rotateOnExpiration on a User/Team share update proto.""" if not isinstance(share_expiration, int): return if share_expiration > 0: @@ -488,13 +687,20 @@ def apply_share_expiration(target): rq.defaultManageUsers = folder_pb2.BOOLEAN_NO_CHANGE if len(users) > 0: - existing_users = {x['username'] for x in curr_sf.get('users', [])} for email in users: + current_user = _folder_user_lookup(curr_sf, email) + if current_user: + email = current_user['username'] uo = folder_pb2.SharedFolderUpdateUser() uo.username = email apply_share_expiration(uo) - if email in existing_users: + if current_user: if action == 'grant': + if rec_uids and mr is None and mu is None: + mr_unchanged = (current_user.get('manage_records') is True) + mu_unchanged = (current_user.get('manage_users') is True) + if mr_unchanged and mu_unchanged and not isinstance(share_expiration, int): + continue uo.manageRecords = folder_pb2.BOOLEAN_NO_CHANGE if mr is None else folder_pb2.BOOLEAN_TRUE if mr == 'on' else folder_pb2.BOOLEAN_FALSE uo.manageUsers = folder_pb2.BOOLEAN_NO_CHANGE if mu is None else folder_pb2.BOOLEAN_TRUE if mu == 'on' else folder_pb2.BOOLEAN_FALSE rq.sharedFolderUpdateUser.append(uo) @@ -575,39 +781,24 @@ def apply_share_expiration(target): ce = kwargs.get('can_edit') cs = kwargs.get('can_share') - if default_record and action == 'grant': + if default_record: rq.defaultCanEdit = folder_pb2.BOOLEAN_NO_CHANGE if ce is None else folder_pb2.BOOLEAN_TRUE if ce == 'on' else folder_pb2.BOOLEAN_FALSE rq.defaultCanShare = folder_pb2.BOOLEAN_NO_CHANGE if cs is None else folder_pb2.BOOLEAN_TRUE if cs == 'on' else folder_pb2.BOOLEAN_FALSE if len(rec_uids) > 0: existing_records = {x['record_uid'] for x in curr_sf.get('records', [])} for record_uid in rec_uids: - ro = folder_pb2.SharedFolderUpdateRecord() - ro.recordUid = utils.base64_url_decode(record_uid) - apply_share_expiration(ro) + folder_record_update = folder_pb2.SharedFolderUpdateRecord() + folder_record_update.recordUid = utils.base64_url_decode(record_uid) if record_uid in existing_records: - if action == 'grant': - ro.canEdit = folder_pb2.BOOLEAN_NO_CHANGE if ce is None else folder_pb2.BOOLEAN_TRUE if ce == 'on' else folder_pb2.BOOLEAN_FALSE - ro.canShare = folder_pb2.BOOLEAN_NO_CHANGE if cs is None else folder_pb2.BOOLEAN_TRUE if cs == 'on' else folder_pb2.BOOLEAN_FALSE - rq.sharedFolderUpdateRecord.append(ro) - elif action == 'remove': - rq.sharedFolderRemoveRecord.append(ro.recordUid) + folder_record_update.canEdit = folder_pb2.BOOLEAN_NO_CHANGE if ce is None else folder_pb2.BOOLEAN_TRUE if ce == 'on' else folder_pb2.BOOLEAN_FALSE + folder_record_update.canShare = folder_pb2.BOOLEAN_NO_CHANGE if cs is None else folder_pb2.BOOLEAN_TRUE if cs == 'on' else folder_pb2.BOOLEAN_FALSE + rq.sharedFolderUpdateRecord.append(folder_record_update) else: - if action == 'grant': - default_ce = folder_pb2.BOOLEAN_TRUE if curr_sf.get('default_can_edit') is True else folder_pb2.BOOLEAN_FALSE - default_cs = folder_pb2.BOOLEAN_TRUE if curr_sf.get('default_can_share') is True else folder_pb2.BOOLEAN_FALSE - ro.canEdit = default_ce if ce is None else folder_pb2.BOOLEAN_TRUE if ce == 'on' else folder_pb2.BOOLEAN_FALSE - ro.canShare = default_cs if cs is None else folder_pb2.BOOLEAN_TRUE if cs == 'on' else folder_pb2.BOOLEAN_FALSE - sf_key = curr_sf.get('shared_folder_key_unencrypted') - if sf_key: - rec = params.record_cache[record_uid] - rec_key = rec['record_key_unencrypted'] - if rec.get('version', 0) < 3: - ro.encryptedRecordKey = crypto.encrypt_aes_v1(rec_key, sf_key) - else: - ro.encryptedRecordKey = crypto.encrypt_aes_v2(rec_key, sf_key) - rq.sharedFolderAddRecord.append(ro) + logging.debug( + 'Record %s is not in shared folder %s; skipping record permission update', + record_uid, curr_sf.get('shared_folder_uid')) return rq @staticmethod @@ -622,7 +813,8 @@ def send_requests(params, partitioned_requests): try: rss = api.communicate_rest(params, rqs, 'vault/shared_folder_update_v3', payload_version=1, rs_type=folder_pb2.SharedFolderUpdateV3ResponseV2) - for rs in rss.sharedFoldersUpdateV3Response: + for rq_item, rs in zip(chunk, rss.sharedFoldersUpdateV3Response): + user_exp, team_exp = _folder_share_expiration_lookups(rq_item) team_cache = params.available_team_cache or [] for attr in ( 'sharedFolderAddTeamStatus', 'sharedFolderUpdateTeamStatus', @@ -634,11 +826,13 @@ def send_requests(params, partitioned_requests): team = next((x for x in team_cache if x.get('team_uid') == team_uid), None) if team: status = t.status + exp_text = format_share_expiration_ms(team_exp.get(team_uid, 0)) + exp_suffix = f', folder access expires {exp_text}' if exp_text else '' if status == 'success': - logging.info('Team share \'%s\' %s', team['team_name'], + logging.info('Team share \'%s\' %s%s', team['team_name'], 'added' if attr == 'sharedFolderAddTeamStatus' else 'updated' if attr == 'sharedFolderUpdateTeamStatus' else - 'removed') + 'removed', exp_suffix) else: logging.warning('Team share \'%s\' failed', team['team_name']) @@ -650,15 +844,27 @@ def send_requests(params, partitioned_requests): for s in statuses: username = s.username status = s.status + exp_text = format_share_expiration_ms(user_exp.get(username.lower(), 0)) if status == 'success': - logging.info('User share \'%s\' %s', username, - 'added' if attr == 'sharedFolderAddUserStatus' else - 'updated' if attr == 'sharedFolderUpdateUserStatus' else - 'removed') + if exp_text and attr in ( + 'sharedFolderAddUserStatus', 'sharedFolderUpdateUserStatus'): + logging.info( + 'Folder access granted to user \'%s\', expires %s', + username, exp_text) + elif attr == 'sharedFolderRemoveUserStatus': + logging.info( + 'Folder access removed from user \'%s\'', username) + else: + exp_suffix = f', folder access expires {exp_text}' if exp_text else '' + logging.info('User share \'%s\' %s%s', username, + 'added' if attr == 'sharedFolderAddUserStatus' else + 'updated' if attr == 'sharedFolderUpdateUserStatus' else + 'removed', exp_suffix) elif status == 'invited': logging.info('User \'%s\' invited', username) else: - logging.warning('User share \'%s\' failed', username) + logging.warning( + 'User share \'%s\' failed: %s', username, status) for attr in ('sharedFolderAddRecordStatus', 'sharedFolderUpdateRecordStatus', 'sharedFolderRemoveRecordStatus'): @@ -667,11 +873,7 @@ def send_requests(params, partitioned_requests): for r in statuses: record_uid = utils.base64_url_encode(r.recordUid) status = r.status - if record_uid in params.record_cache: - rec = api.get_record(params, record_uid) - title = rec.title - else: - title = record_uid + title = _record_share_log_title(params, record_uid) if status == 'success': logging.info('Record share \'%s\' %s', title, 'added' if attr == 'sharedFolderAddRecordStatus' else @@ -1077,6 +1279,7 @@ def send_requests(params, requests): left -= added rs = api.communicate_rest(params, rq1, 'vault/records_share_update', rs_type=record_pb2.RecordShareUpdateResponse) + record_exp = _record_share_expiration_lookup(rq1) for attr in ['addSharedRecordStatus', 'updateSharedRecordStatus', 'removeSharedRecordStatus']: if hasattr(rs, attr): statuses = getattr(rs, attr) @@ -1084,13 +1287,23 @@ def send_requests(params, requests): record_uid = utils.base64_url_encode(status_rs.recordUid) status = status_rs.status email = status_rs.username - if status == 'success': - verb = 'granted to' if attr == 'addSharedRecordStatus' else 'changed for' if attr == 'updateSharedRecordStatus' else 'revoked from' - logging.info('Record \"%s\" access permissions has been %s user \'%s\'', record_uid, verb, email) - else: - verb = 'grant' if attr == 'addSharedRecordStatus' else 'change' if attr == 'updateSharedRecordStatus' else 'revoke' - - logging.info('Failed to %s record \"%s\" access permissions for user \'%s\': %s', verb, record_uid, email, status_rs.message) + if status != 'success': + verb = ('grant' if attr == 'addSharedRecordStatus' + else 'change' if attr == 'updateSharedRecordStatus' + else 'revoke') + logging.info( + 'Failed to %s record \"%s\" access permissions for user \'%s\': %s', + verb, record_uid, email, status_rs.message) + continue + verb = ('granted to' if attr == 'addSharedRecordStatus' + else 'changed for' if attr == 'updateSharedRecordStatus' + else 'revoked from') + exp_text = format_share_expiration_ms( + record_exp.get((record_uid, email.lower()), 0)) + exp_suffix = f', record access expires {exp_text}' if exp_text else '' + logging.info( + 'Record \"%s\" access permissions has been %s user \'%s\'%s', + record_uid, verb, email, exp_suffix) rq = next(requests, None) diff --git a/unit-tests/test_command_register.py b/unit-tests/test_command_register.py index 3074af5a7..2f35ea315 100644 --- a/unit-tests/test_command_register.py +++ b/unit-tests/test_command_register.py @@ -6,7 +6,7 @@ from data_vault import get_synced_params, VaultEnvironment from keepercommander.commands import register from keepercommander.error import CommandError -from keepercommander.proto import APIRequest_pb2, record_pb2 +from keepercommander.proto import APIRequest_pb2, folder_pb2, record_pb2 from keepercommander import utils from keepercommander.subfolder import NestedShareFolderNode @@ -305,14 +305,13 @@ def test_share_folder(self): self.assertEqual(len(TestRegister.expected_commands), 0) TestRegister.expected_commands.extend(['shared_folder_update_v3']) - cmd.execute(params, action='revoke', user=['user2@keepersecurity.com'], folder=shared_folder_uid) + cmd.execute(params, action='remove', user=['user2@keepersecurity.com'], folder=shared_folder_uid) self.assertEqual(len(TestRegister.expected_commands), 0) def test_share_folder_prepare_request_sets_rotate_on_expiration(self): - """SharedFolderUpdateUser/Team/Record all carry rotateOnExpiration when -roe is on.""" + """Folder-wide expiration/ROE applies to user/team protos, not record protos.""" params = get_synced_params() shared_folder_uid = next(iter(params.shared_folder_cache.keys())) - record_uid = next(iter([x['record_uid'] for x in params.meta_data_cache.values() if x['can_share']])) team_uid = utils.base64_url_encode(b'a' * 16) curr_sf = dict(params.shared_folder_cache[shared_folder_uid]) @@ -331,7 +330,7 @@ def test_share_folder_prepare_request_sets_rotate_on_expiration(self): curr_sf=curr_sf, users=['user2@keepersecurity.com'], teams=[team_uid], - rec_uids=[record_uid], + rec_uids=[], share_expiration=future_ts, rotate_on_expiration=True, ) @@ -340,7 +339,7 @@ def test_share_folder_prepare_request_sets_rotate_on_expiration(self): team_msgs = list(rq.sharedFolderAddTeam) + list(rq.sharedFolderUpdateTeam) record_msgs = list(rq.sharedFolderAddRecord) + list(rq.sharedFolderUpdateRecord) - for msgs, label in [(user_msgs, 'user'), (team_msgs, 'team'), (record_msgs, 'record')]: + for msgs, label in [(user_msgs, 'user'), (team_msgs, 'team')]: self.assertTrue(msgs, f'expected at least one {label} proto on the wire') for m in msgs: self.assertTrue(m.rotateOnExpiration, @@ -348,13 +347,330 @@ def test_share_folder_prepare_request_sets_rotate_on_expiration(self): self.assertGreater(m.expiration, 0) self.assertEqual(m.timerNotificationType, record_pb2.NOTIFY_OWNER) - def test_share_folder_rotate_on_expiration_rejects_folder_without_pam_user(self): + self.assertFalse(record_msgs, 'record protos must not carry folder-wide expiration') + + def test_share_folder_prepare_request_expire_in_applies_to_folder_user_only(self): + """With -r and --expire-in, only folder user gets a timer; record protos are permissions-only.""" + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + record_uid = next(iter(params.shared_folder_cache[shared_folder_uid]['records']))['record_uid'] + + curr_sf = dict(params.shared_folder_cache[shared_folder_uid]) + curr_sf.setdefault('users', []) + future_ts = int(datetime.datetime.now().timestamp()) + 86_400 + + params.key_cache['user2@keepersecurity.com'] = mock.MagicMock( + rsa=utils.base64_url_decode(vault_env.encoded_public_key), ec=None) + + rq = register.ShareFolderCommand.prepare_request( + params, + kwargs={'action': 'grant', 'can_edit': 'on', 'can_share': 'on'}, + curr_sf=curr_sf, + users=['user2@keepersecurity.com'], + teams=[], + rec_uids=[record_uid], + share_expiration=future_ts, + rotate_on_expiration=True, + ) + + user_msgs = list(rq.sharedFolderAddUser) + list(rq.sharedFolderUpdateUser) + record_msgs = list(rq.sharedFolderAddRecord) + list(rq.sharedFolderUpdateRecord) + + self.assertTrue(user_msgs, 'expected folder user share with expiration') + for m in user_msgs: + self.assertGreater(m.expiration, 0) + self.assertTrue(m.rotateOnExpiration) + self.assertEqual(m.timerNotificationType, record_pb2.NOTIFY_OWNER) + + self.assertTrue(record_msgs, 'expected record permission update') + for m in record_msgs: + self.assertEqual(m.expiration, 0) + self.assertFalse(m.rotateOnExpiration) + + def test_share_folder_remove_with_record_permissions_combined(self): + """-a remove affects -e only; -r/-d/-s update record permissions in the same request.""" + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + record_uid = next(iter(params.shared_folder_cache[shared_folder_uid]['records']))['record_uid'] + + curr_sf = dict(params.shared_folder_cache[shared_folder_uid]) + curr_sf['users'] = [{ + 'username': 'user2@keepersecurity.com', + 'manage_records': True, + 'manage_users': True, + }] + curr_sf['records'] = [{'record_uid': record_uid, 'can_edit': True, 'can_share': True}] + + rq = register.ShareFolderCommand.prepare_request( + params, + kwargs={'action': 'remove', 'can_edit': 'off'}, + curr_sf=curr_sf, + users=['user2@keepersecurity.com'], + teams=[], + rec_uids=[record_uid], + ) + + self.assertEqual(list(rq.sharedFolderRemoveUser), ['user2@keepersecurity.com']) + self.assertTrue(list(rq.sharedFolderUpdateRecord)) + self.assertFalse(list(rq.sharedFolderRemoveRecord)) + self.assertFalse(list(rq.sharedFolderAddRecord)) + + def test_share_folder_remove_rejects_record_permissions_without_record_target(self): + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + cmd = register.ShareFolderCommand() + + with self.assertRaises(CommandError) as ctx: + cmd.execute( + params, + action='remove', + user=['user2@keepersecurity.com'], + folder=shared_folder_uid, + can_edit='on', + force=True, + ) + self.assertIn('-d and -s require a record target', str(ctx.exception)) + + def test_share_folder_grant_rejects_record_permissions_without_record_target(self): + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + cmd = register.ShareFolderCommand() + + with self.assertRaises(CommandError) as ctx: + cmd.execute( + params, + action='grant', + folder=shared_folder_uid, + can_edit='on', + force=True, + ) + self.assertIn('-d and -s require a record target', str(ctx.exception)) + + def test_share_folder_rejects_record_not_in_folder(self): + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + folder_record_uids = {x['record_uid'] for x in params.shared_folder_cache[shared_folder_uid]['records']} + foreign_record_uid = next( + uid for uid in params.record_cache if uid not in folder_record_uids) + cmd = register.ShareFolderCommand() + + with self.assertRaises(CommandError) as ctx: + cmd.execute( + params, + action='grant', + folder=shared_folder_uid, + record=[foreign_record_uid], + can_edit='on', + force=True, + ) + self.assertIn('not in shared folder', str(ctx.exception)) + + def test_share_folder_prepare_request_remove_user_only(self): + """Remove with -e revokes folder access only; no record protos are sent.""" + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + record_uid = next(iter(params.shared_folder_cache[shared_folder_uid]['records']))['record_uid'] + + curr_sf = dict(params.shared_folder_cache[shared_folder_uid]) + curr_sf['users'] = [{ + 'username': 'user2@keepersecurity.com', + 'manage_records': True, + 'manage_users': True, + }] + + rq = register.ShareFolderCommand.prepare_request( + params, + kwargs={'action': 'remove'}, + curr_sf=curr_sf, + users=['user2@keepersecurity.com'], + teams=[], + rec_uids=[], + ) + + self.assertTrue(list(rq.sharedFolderRemoveUser)) + self.assertFalse(list(rq.sharedFolderRemoveRecord)) + self.assertFalse(list(rq.sharedFolderUpdateRecord)) + self.assertFalse(list(rq.sharedFolderAddRecord)) + + def test_share_folder_owner_self_remove_blocked_without_backup_manager(self): params = get_synced_params() shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + params.shared_folder_cache[shared_folder_uid]['owner_account_uid'] = utils.base64_url_encode( + params.account_uid_bytes) + params.shared_folder_cache[shared_folder_uid]['users'] = [{ + 'username': params.user, + 'manage_records': True, + 'manage_users': True, + }] + params.shared_folder_cache[shared_folder_uid]['teams'] = [] cmd = register.ShareFolderCommand() + with self.assertRaises(CommandError) as ctx: - cmd.execute(params, action='grant', user=['user2@keepersecurity.com'], - folder=shared_folder_uid, expire_in='1d', rotate_on_expiration=True) + cmd.execute( + params, + action='remove', + user=[params.user], + folder=shared_folder_uid, + force=True, + ) + self.assertIn('no other participant has', str(ctx.exception)) + + def test_share_folder_participant_self_remove_prompt_declined(self): + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + params.shared_folder_cache[shared_folder_uid]['owner_account_uid'] = utils.generate_uid() + params.shared_folder_cache[shared_folder_uid]['users'] = [ + {'username': params.user, 'manage_records': False, 'manage_users': False}, + {'username': 'user2@keepersecurity.com', 'manage_records': True, 'manage_users': True}, + ] + params.shared_folder_cache[shared_folder_uid]['teams'] = [] + cmd = register.ShareFolderCommand() + + with mock.patch('keepercommander.commands.register.user_choice', return_value='n'): + with self.assertRaises(CommandError) as ctx: + cmd.execute( + params, + action='remove', + user=[params.user], + folder=shared_folder_uid, + ) + self.assertIn('Operation cancelled', str(ctx.exception)) + + def test_share_folder_participant_self_remove_prompt_accepted(self): + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + params.shared_folder_cache[shared_folder_uid]['owner_account_uid'] = utils.generate_uid() + params.shared_folder_cache[shared_folder_uid]['users'] = [ + {'username': params.user, 'manage_records': False, 'manage_users': False}, + {'username': 'user2@keepersecurity.com', 'manage_records': True, 'manage_users': True}, + ] + params.shared_folder_cache[shared_folder_uid]['teams'] = [] + cmd = register.ShareFolderCommand() + TestRegister.expected_commands.extend(['shared_folder_update_v3']) + + with mock.patch('keepercommander.commands.register.user_choice', return_value='y'): + cmd.execute( + params, + action='remove', + user=[params.user], + folder=shared_folder_uid, + ) + self.assertEqual(len(TestRegister.expected_commands), 0) + + def test_share_folder_prepare_request_skips_redundant_user_update_for_record_only(self): + """When sharing another record without expiration, skip redundant folder user update.""" + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + record_uid = next(iter(params.shared_folder_cache[shared_folder_uid]['records']))['record_uid'] + + curr_sf = dict(params.shared_folder_cache[shared_folder_uid]) + curr_sf['users'] = [{ + 'username': 'user2@keepersecurity.com', + 'manage_records': True, + 'manage_users': True, + }] + + rq = register.ShareFolderCommand.prepare_request( + params, + kwargs={'action': 'grant', 'can_edit': 'on', 'can_share': 'on'}, + curr_sf=curr_sf, + users=['user2@keepersecurity.com'], + teams=[], + rec_uids=[record_uid], + share_expiration=None, + ) + + self.assertFalse(list(rq.sharedFolderUpdateUser)) + self.assertTrue(list(rq.sharedFolderUpdateRecord)) + self.assertFalse(list(rq.sharedFolderAddRecord)) + + def test_share_folder_prepare_request_updates_user_when_explicit_perms_with_record(self): + """-p/-o with -r must always update folder user permissions.""" + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + record_uid = next(iter(params.shared_folder_cache[shared_folder_uid]['records']))['record_uid'] + + curr_sf = dict(params.shared_folder_cache[shared_folder_uid]) + curr_sf['users'] = [{ + 'username': 'user2@keepersecurity.com', + 'manage_records': True, + 'manage_users': True, + }] + + rq = register.ShareFolderCommand.prepare_request( + params, + kwargs={'action': 'grant', 'manage_records': 'on', 'manage_users': 'on', + 'can_edit': 'on', 'can_share': 'on'}, + curr_sf=curr_sf, + users=['user2@keepersecurity.com'], + teams=[], + rec_uids=[record_uid], + share_expiration=None, + ) + + user_msgs = list(rq.sharedFolderUpdateUser) + self.assertTrue(user_msgs, 'explicit -p/-o must update folder user even with -r') + self.assertEqual(user_msgs[0].manageRecords, folder_pb2.BOOLEAN_TRUE) + self.assertEqual(user_msgs[0].manageUsers, folder_pb2.BOOLEAN_TRUE) + + def test_share_folder_prepare_request_case_insensitive_user_lookup(self): + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + curr_sf = dict(params.shared_folder_cache[shared_folder_uid]) + curr_sf['users'] = [{ + 'username': 'User2@KeeperSecurity.com', + 'manage_records': False, + 'manage_users': False, + }] + + rq = register.ShareFolderCommand.prepare_request( + params, + kwargs={'action': 'grant', 'manage_records': 'on', 'manage_users': 'on'}, + curr_sf=curr_sf, + users=['user2@keepersecurity.com'], + teams=[], + rec_uids=[], + ) + + user_msgs = list(rq.sharedFolderUpdateUser) + self.assertEqual(len(user_msgs), 1) + self.assertEqual(user_msgs[0].username, 'User2@KeeperSecurity.com') + + def test_share_folder_prepare_request_updates_user_when_folder_wide_expiration(self): + """Folder-wide --expire-in (no -r) sets expiration on the folder user share.""" + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + future_ts = int(datetime.datetime.now().timestamp()) + 86_400 + + curr_sf = dict(params.shared_folder_cache[shared_folder_uid]) + curr_sf['users'] = [{ + 'username': 'user2@keepersecurity.com', + 'manage_records': True, + 'manage_users': True, + }] + + rq = register.ShareFolderCommand.prepare_request( + params, + kwargs={'action': 'grant', 'manage_records': 'on', 'manage_users': 'on'}, + curr_sf=curr_sf, + users=['user2@keepersecurity.com'], + teams=[], + rec_uids=[], + share_expiration=future_ts, + ) + + user_msgs = list(rq.sharedFolderUpdateUser) + self.assertTrue(user_msgs) + self.assertGreater(user_msgs[0].expiration, 0) + + def test_share_folder_rotate_on_expiration_rejects_folder_without_pam_user(self): + params = get_synced_params() + shared_folder_uid = next(iter(params.shared_folder_cache.keys())) + cmd = register.ShareFolderCommand() + with mock.patch('keepercommander.commands.register.SyncDownCommand.execute'): + with self.assertRaises(CommandError) as ctx: + cmd.execute(params, action='grant', user=['user2@keepersecurity.com'], + folder=shared_folder_uid, expire_in='1d', rotate_on_expiration=True) self.assertIn('pamUser', str(ctx.exception)) @staticmethod From c4ad0b6c6f22b90ad5840c6309c8f44efcbef364 Mon Sep 17 00:00:00 2001 From: sshrushanth-ks Date: Fri, 10 Jul 2026 18:07:40 +0530 Subject: [PATCH 5/9] fix: Expose owner flag in classic shared folder get JSON output (#2197) --- keepercommander/commands/record.py | 20 ++++++++++++++++++++ unit-tests/test_command_record.py | 21 +++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/keepercommander/commands/record.py b/keepercommander/commands/record.py index 3f58bb0f8..bc745ebed 100644 --- a/keepercommander/commands/record.py +++ b/keepercommander/commands/record.py @@ -290,6 +290,21 @@ class RecordGetUidCommand(Command): def get_parser(self): return get_info_parser + @staticmethod + def _is_classic_shared_folder_owner(user, owner_username, owner_account_uid): + """Return True if *user* matches the classic shared folder owner from sync-down.""" + if not user: + return False + if owner_username: + username = user.get('username') or '' + if username and username.lower() == owner_username.lower(): + return True + if owner_account_uid: + account_uid = user.get('account_uid') or '' + if account_uid and account_uid == owner_account_uid: + return True + return False + @staticmethod def _build_folder_json(params, f): folder_type = 'nested_share_folder' if f.type == BaseFolderNode.NestedShareFolderType else 'classic_folder' @@ -365,6 +380,9 @@ def execute(self, params, **kwargs): if api.is_shared_folder(params, uid): admins = api.get_share_admins_for_shared_folder(params, uid) sf = api.get_shared_folder(params, uid) + cached_sf = params.shared_folder_cache.get(uid) or {} + owner_username = cached_sf.get('owner_username') + owner_account_uid = cached_sf.get('owner_account_uid') if fmt == 'json': path = get_folder_path(params, sf.shared_folder_uid, delimiter=os.sep) if sf.shared_folder_uid else '' sf_node = params.folder_cache.get(sf.shared_folder_uid) if sf.shared_folder_uid else None @@ -405,6 +423,8 @@ def _format_expiration(expiration_value): sfo['users'] = [{ 'username': u['username'], 'user_id': u.get('account_uid'), + 'owner': RecordGetUidCommand._is_classic_shared_folder_owner( + u, owner_username, owner_account_uid), 'manage_records': u['manage_records'], 'manage_users': u['manage_users'], 'expiration': _format_expiration(u.get('expiration')) diff --git a/unit-tests/test_command_record.py b/unit-tests/test_command_record.py index 03de045fc..e2f6b1684 100644 --- a/unit-tests/test_command_record.py +++ b/unit-tests/test_command_record.py @@ -304,6 +304,27 @@ def test_get_shared_folder_uid(self): cmd.execute(params, uid=shared_folder_uid) cmd.execute(params, format='json', uid=shared_folder_uid) + def test_get_shared_folder_json_includes_owner_flag(self): + params = get_synced_params() + cmd = record.RecordGetUidCommand() + shared_folder_uid = next(iter(params.shared_folder_cache)) + cached_sf = params.shared_folder_cache[shared_folder_uid] + owner_account_uid = cached_sf['owner_account_uid'] + + captured = [] + with mock.patch('builtins.print', side_effect=captured.append), \ + mock.patch('keepercommander.api.get_share_admins_for_shared_folder', return_value=[]): + cmd.execute(params, format='json', uid=shared_folder_uid) + + payload = json.loads(captured[-1]) + self.assertEqual(payload['type'], 'classic_folder') + self.assertIn('users', payload) + self.assertTrue(payload['users']) + owners = [u for u in payload['users'] if u.get('owner')] + self.assertEqual(len(owners), 1) + self.assertEqual(owners[0]['user_id'], owner_account_uid) + self.assertTrue(all('owner' in u for u in payload['users'])) + def test_get_user_folder_json_consistent_by_name_and_uid(self): params = get_synced_params() cmd = record.RecordGetUidCommand() From 6ea4f320f1453a8bd2008733d3f89ab5c1ca0fdf Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Sat, 11 Jul 2026 02:29:02 +0530 Subject: [PATCH 6/9] Support deny inherit from Commander while sharing or changing NSF child folder permissions (#2205) (#2206) --- keepercommander/api.py | 53 +++-- .../nested_share_folder/folder_commands.py | 55 +++-- .../commands/nested_share_folder/helpers.py | 11 +- keepercommander/nested_share_folder/common.py | 70 ++++-- .../nested_share_folder/folder_api.py | 95 +++++++- unit-tests/test_command_record.py | 1 + unit-tests/test_nested_share_folder.py | 202 +++++++++++++++++- 7 files changed, 433 insertions(+), 54 deletions(-) diff --git a/keepercommander/api.py b/keepercommander/api.py index 1ada50da6..1da4a1e32 100644 --- a/keepercommander/api.py +++ b/keepercommander/api.py @@ -377,8 +377,12 @@ def load_team_keys(params, team_uids): # type: (KeeperParams, List[str] encrypted_team_key = t.get('encrypted_team_key') if encrypted_team_key: team_key = crypto.decrypt_aes_v2(utils.base64_url_decode(encrypted_team_key), tree_key) - params.key_cache[team_uid] = PublicKeys(aes=team_key) - s.remove(team_uid) + existing = params.key_cache.get(team_uid) + params.key_cache[team_uid] = PublicKeys( + aes=team_key, + rsa=getattr(existing, 'rsa', b'') or b'', + ec=getattr(existing, 'ec', b'') or b'') + # Still fetch asymmetric public keys via team_get_keys below. except Exception as e: logging.debug('Team UID \"%s\": Decrypt key error: %s', team_uid, str(e)) @@ -397,30 +401,51 @@ def load_team_keys(params, team_uids): # type: (KeeperParams, List[str] } rs = communicate(params, rq) if 'keys' in rs: + merged = {} # team_uid -> PublicKeys fields for tk in rs['keys']: + team_uid = tk.get('team_uid') + if not team_uid: + continue + if team_uid not in merged: + existing = params.key_cache.get(team_uid) + merged[team_uid] = { + 'aes': getattr(existing, 'aes', b'') or b'', + 'rsa': getattr(existing, 'rsa', b'') or b'', + 'ec': getattr(existing, 'ec', b'') or b'', + } + # Read the symmetric/wrapped team key from the 'key' field if 'key' in tk: - team_uid = tk['team_uid'] try: - aes = b'' - rsa = b'' - ec = b'' encrypted_key = utils.base64_url_decode(tk['key']) - key_type = tk['type'] + key_type = tk.get('type') if key_type == 1: - aes = crypto.decrypt_aes_v1(encrypted_key, params.data_key) + merged[team_uid]['aes'] = crypto.decrypt_aes_v1(encrypted_key, params.data_key) elif key_type == 2: - aes = crypto.decrypt_rsa(encrypted_key, params.rsa_key2) + merged[team_uid]['aes'] = crypto.decrypt_rsa(encrypted_key, params.rsa_key2) elif key_type == 3: - aes = crypto.decrypt_aes_v2(encrypted_key, params.data_key) + merged[team_uid]['aes'] = crypto.decrypt_aes_v2(encrypted_key, params.data_key) elif key_type == 4: - aes = crypto.decrypt_ec(encrypted_key, params.ecc_key) + merged[team_uid]['aes'] = crypto.decrypt_ec(encrypted_key, params.ecc_key) elif key_type == -1: - ec = encrypted_key + merged[team_uid]['ec'] = encrypted_key elif key_type == -3: - rsa = encrypted_key - params.key_cache[team_uid] = PublicKeys(rsa=rsa, aes=aes, ec=ec) + merged[team_uid]['rsa'] = encrypted_key + except Exception as e: + logging.debug(e) + # Read the raw team public key from 'team_public_key' field (separate from 'key') + if 'team_public_key' in tk: + try: + pub_key_bytes = utils.base64_url_decode(tk['team_public_key']) + pub_key_type = tk.get('team_public_key_type') + if pub_key_type == -3: + merged[team_uid]['rsa'] = pub_key_bytes + elif pub_key_type == -1: + merged[team_uid]['ec'] = pub_key_bytes except Exception as e: logging.debug(e) + for team_uid, kd in merged.items(): + params.key_cache[team_uid] = PublicKeys( + rsa=kd['rsa'], aes=kd['aes'], ec=kd['ec']) def load_available_teams(params): diff --git a/keepercommander/commands/nested_share_folder/folder_commands.py b/keepercommander/commands/nested_share_folder/folder_commands.py index 57fa7c348..eb8be8341 100644 --- a/keepercommander/commands/nested_share_folder/folder_commands.py +++ b/keepercommander/commands/nested_share_folder/folder_commands.py @@ -362,6 +362,10 @@ def execute(self, params, **kwargs): check_folder_share_permission(params, folder_uid, 'nsf-share-folder') targets = self._collect_targets(params, recipients, folder_uid, folder_arg) + if not targets: + raise CommandError( + 'nsf-share-folder', + f'No valid recipients resolved for folder {folder_arg!r}') for recipient, is_team in targets: self._apply(params, action, folder_uid, recipient, role, expiration, as_team=is_team) @@ -408,24 +412,50 @@ def _expand_existing(params, folder_uid, folder_arg): """Expand ``@existing`` / ``@current`` into all users and teams currently on the folder, excluding the caller. Mirrors legacy behaviour (``shared_folder_cache[...]['users']`` + ``['teams']`` union). + + Uses ``get_folder_access_v3`` so sub-folders (whose sync cache only + carries the current user's own row) still enumerate every accessor + that can be removed, including inherited access. """ from keepercommander.proto import folder_pb2 - accesses = (getattr(params, 'nested_share_folder_accesses', {}) - .get(folder_uid, [])) at_user = int(folder_pb2.AT_USER) at_team = int(folder_pb2.AT_TEAM) result = [] - for a in accesses: - access_type = int(a.get('access_type', 0) or 0) - if access_type == at_user: - username = a.get('username') - if username and username != params.user: - result.append(('user', username)) - elif access_type == at_team: - team_uid = a.get('access_type_uid') - if team_uid: - result.append(('team', team_uid)) + try: + info = _nsf.get_folder_access_v3(params, [folder_uid], resolve_usernames=True) + for fr in info.get('results', []): + if not fr.get('success'): + continue + for accessor in fr.get('accessors', []): + if accessor.get('access_type') == 'AT_OWNER': + continue + if accessor.get('access_type') == 'AT_TEAM': + team_uid = accessor.get('accessor_uid') + if team_uid: + result.append(('team', team_uid)) + elif accessor.get('access_type') == 'AT_USER': + username = accessor.get('username') + if username and username != params.user: + result.append(('user', username)) + except Exception as exc: + logging.debug( + 'nsf-share-folder: get_folder_access_v3 failed for %s: %s', + folder_arg, exc) + + if not result: + accesses = (getattr(params, 'nested_share_folder_accesses', {}) + .get(folder_uid, [])) + for a in accesses: + access_type = int(a.get('access_type', 0) or 0) + if access_type == at_user: + username = a.get('username') + if username and username != params.user: + result.append(('user', username)) + elif access_type == at_team: + team_uid = a.get('access_type_uid') + if team_uid: + result.append(('team', team_uid)) if not result: logging.info("No existing users or teams found in folder '%s'", folder_arg) @@ -447,6 +477,7 @@ def _apply(cls, params, action, folder_uid, recipient, role, expiration, try: result = api_func(**kw) if result['success']: + params.sync_data = True taken = result.get('action_taken', verb) if taken == 'already_had_access': logging.info("%s '%s' already has access", kind, recipient) diff --git a/keepercommander/commands/nested_share_folder/helpers.py b/keepercommander/commands/nested_share_folder/helpers.py index 57ab381e5..b4352a1dd 100644 --- a/keepercommander/commands/nested_share_folder/helpers.py +++ b/keepercommander/commands/nested_share_folder/helpers.py @@ -201,14 +201,16 @@ def classify_share_recipient(params, recipient): Mirrors the legacy ``share-folder`` resolution exactly: 1. If *recipient* matches ``EMAIL_PATTERN`` → ``('user', email_lower)``. 2. Otherwise look it up in ``api.get_share_objects(params)['teams']`` - (cap of 500 entries) and, if needed, ``params.available_team_cache``. - A match by team name *or* team UID returns ``('team', team_uid_b64)``. + (cap of 500 entries), ``params.available_team_cache``, and + ``resolve_team_identifier`` (``team_cache``). A match by team name + *or* team UID returns ``('team', team_uid_b64)``. 3. No match → logs the same warning as legacy and returns ``None``. 4. Multiple matches → logs the same warning and returns ``None``. Returns ``(kind, identifier)`` or ``None``. """ from ... import constants, api + from ...nested_share_folder.common import resolve_team_identifier if re.match(constants.EMAIL_PATTERN, recipient): return 'user', recipient.lower() @@ -228,12 +230,15 @@ def classify_share_recipient(params, recipient): pass matches = [uid for uid, name in teams_map.items() - if recipient in (name, uid)] + if recipient == uid or (name and name.lower() == recipient.lower())] if len(matches) == 1: return 'team', matches[0] if not matches: + resolved_team = resolve_team_identifier(params, recipient) + if resolved_team: + return 'team', resolved_team[0] logger.warning('User "%s" could not be resolved as email or team', recipient) else: diff --git a/keepercommander/nested_share_folder/common.py b/keepercommander/nested_share_folder/common.py index 0a3142a1b..261f47651 100644 --- a/keepercommander/nested_share_folder/common.py +++ b/keepercommander/nested_share_folder/common.py @@ -178,6 +178,11 @@ def get_team_keys(params, team_uid_b64: str): """ from ..params import PublicKeys + cached = params.key_cache.get(team_uid_b64) + has_asym = bool(cached and (getattr(cached, 'rsa', None) or getattr(cached, 'ec', None))) + if cached and has_asym: + return cached + api.load_team_keys(params, [team_uid_b64]) keys = params.key_cache.get(team_uid_b64) @@ -186,21 +191,47 @@ def get_team_keys(params, team_uid_b64: str): try: rq = {'command': 'team_get_keys', 'teams': [team_uid_b64]} rs = api.communicate(params, rq) - existing_aes = getattr(keys, 'aes', None) if keys else None - rsa_pub = b'' - ec_pub = b'' + merged = { + 'aes': getattr(keys, 'aes', b'') or b'' if keys else b'', + 'rsa': getattr(keys, 'rsa', b'') or b'' if keys else b'', + 'ec': getattr(keys, 'ec', b'') or b'' if keys else b'', + } for tk in (rs or {}).get('keys', []): - if tk.get('team_uid') != team_uid_b64 or 'key' not in tk: + if tk.get('team_uid') != team_uid_b64: continue - key_type = tk.get('type') - encrypted_key = utils.base64_url_decode(tk['key']) - if key_type == -1: - ec_pub = encrypted_key - elif key_type == -3: - rsa_pub = encrypted_key - if rsa_pub or ec_pub: + # Symmetric/wrapped key in 'key' field + if 'key' in tk: + try: + key_type = tk.get('type') + encrypted_key = utils.base64_url_decode(tk['key']) + if key_type == 1: + merged['aes'] = crypto.decrypt_aes_v1(encrypted_key, params.data_key) + elif key_type == 2: + merged['aes'] = crypto.decrypt_rsa(encrypted_key, params.rsa_key2) + elif key_type == 3: + merged['aes'] = crypto.decrypt_aes_v2(encrypted_key, params.data_key) + elif key_type == 4: + merged['aes'] = crypto.decrypt_ec(encrypted_key, params.ecc_key) + elif key_type == -1: + merged['ec'] = encrypted_key + elif key_type == -3: + merged['rsa'] = encrypted_key + except Exception as e: + logger.debug('team_get_keys key decode failed: %s', e) + # Raw public key in 'team_public_key' field (separate from 'key') + if 'team_public_key' in tk: + try: + pub_key_bytes = utils.base64_url_decode(tk['team_public_key']) + pub_key_type = tk.get('team_public_key_type') + if pub_key_type == -3: + merged['rsa'] = pub_key_bytes + elif pub_key_type == -1: + merged['ec'] = pub_key_bytes + except Exception as e: + logger.debug('team_get_keys public key decode failed: %s', e) + if any(merged.values()): params.key_cache[team_uid_b64] = PublicKeys( - aes=existing_aes, rsa=rsa_pub, ec=ec_pub) + aes=merged['aes'], rsa=merged['rsa'], ec=merged['ec']) keys = params.key_cache[team_uid_b64] except Exception as exc: logger.debug("team_get_keys fallback failed for %s: %s", @@ -215,6 +246,10 @@ def encrypt_for_team(plaintext_key: bytes, team_keys, prefer_aes: bool = False, forbid_rsa: bool = False) -> Tuple[bytes, int]: """Encrypt *plaintext_key* using the best available team key. + + Mirrors legacy ``share-folder``: prefer the team AES key when + *prefer_aes* is set, otherwise try asymmetric keys first, then fall + back to AES when no public key is available (common for enterprise teams). """ aes = getattr(team_keys, 'aes', None) ec_bytes = getattr(team_keys, 'ec', None) @@ -237,7 +272,9 @@ def encrypt_for_team(plaintext_key: bytes, team_keys, return (crypto.encrypt_ec(plaintext_key, ec_key), folder_pb2.encrypted_by_public_key_ecc) - raise ValueError("No public key found for team") + raise ValueError( + "No public key found for team; NSF folder sharing requires the " + "team's RSA or ECC public key (server requires key type 2)") def resolve_uid_email(params, user_identifier: str) -> Tuple[Optional[bytes], str]: @@ -451,13 +488,12 @@ def parse_folder_access_result(response, folder_uid, user_uid, default_message): if response.folderAccessResults: result = response.folderAccessResults[0] status_value = result.status - is_failure = (status_value != 0) or (result.message and len(result.message) > 0) - status_name = (folder_pb2.FolderModifyStatus.Name(status_value) - if status_value != 0 else 'SUCCESS') + is_failure = status_value != folder_pb2.SUCCESS + status_name = folder_pb2.FolderModifyStatus.Name(status_value) return { 'folder_uid': folder_uid, 'user_uid': user_uid, - 'status': 'ERROR' if is_failure and status_value == 0 else status_name, + 'status': status_name, 'message': result.message if result.message else default_message, 'success': not is_failure, } diff --git a/keepercommander/nested_share_folder/folder_api.py b/keepercommander/nested_share_folder/folder_api.py index 6656f324e..c2555bb21 100644 --- a/keepercommander/nested_share_folder/folder_api.py +++ b/keepercommander/nested_share_folder/folder_api.py @@ -390,8 +390,6 @@ def grant_folder_access_v3(params, folder_uid, user_uid, role='viewer', fk = get_folder_key(params, folder_uid) ek = folder_pb2.EncryptedDataKey() if as_team: - # v3 folder team grants must use the team's *asymmetric* public - # key (server rejects AES with "Key type 2 required"). efk, key_type = encrypt_for_team( fk, team_keys, prefer_aes=False, forbid_rsa=getattr(params, 'forbid_rsa', False)) @@ -464,17 +462,105 @@ def update_folder_access_v3(params, folder_uid, user_uid, role=None, hidden=None return result +def _lookup_folder_accessor(params, folder_uid, accessor_uid_b64, access_type_label): + """Return a folder accessor row from ``get_folder_access_v3``, if present.""" + try: + info = get_folder_access_v3(params, [folder_uid], resolve_usernames=True) + for fr in info.get('results', []): + if not fr.get('success'): + continue + accessors = fr.get('accessors', []) + for accessor in accessors: + if (accessor.get('accessor_uid') == accessor_uid_b64 + and accessor.get('access_type') == access_type_label): + return accessor + for accessor in accessors: + if accessor.get('accessor_uid') == accessor_uid_b64: + return accessor + except Exception as exc: + logger.debug('Folder accessor lookup failed for %s: %s', folder_uid, exc) + return None + + +def _folder_inherits_parent_permissions(params, folder_uid): + """Return True when *folder_uid* has a parent and still inherits its access list.""" + nsf_folders = getattr(params, 'nested_share_folders', {}) + folder_obj = nsf_folders.get(folder_uid) + if not folder_obj: + return False + parent_uid = folder_obj.get('parent_uid') + if not parent_uid: + return False + inherit = folder_obj.get('inherit_user_permissions', 0) or 0 + return inherit != folder_pb2.BOOLEAN_FALSE + + +def _ensure_folder_direct_permissions(params, folder_uid): + """Disable parent permission inheritance so folder access changes apply locally. + """ + if not _folder_inherits_parent_permissions(params, folder_uid): + return False + result = update_folder_v3(params, folder_uid, inherit_permissions=False) + if not result.get('success'): + raise ValueError( + result.get('message') + or 'Failed to disable parent permission inheritance on folder') + nsf_folders = getattr(params, 'nested_share_folders', {}) + if folder_uid in nsf_folders: + nsf_folders[folder_uid]['inherit_user_permissions'] = folder_pb2.BOOLEAN_FALSE + params.sync_data = True + return True + + +def _evict_folder_accessor_cache(params, folder_uid, accessor_uid_b64): + """Drop a cached folder-access row after a successful revoke.""" + accesses = getattr(params, 'nested_share_folder_accesses', {}).get(folder_uid) + if not accesses: + return + params.nested_share_folder_accesses[folder_uid] = [ + fa for fa in accesses + if fa.get('access_type_uid') != accessor_uid_b64 + ] + + def revoke_folder_access_v3(params, folder_uid, user_uid, as_team=False): + """Revoke user or team access to an NSF folder. + + Looks up the accessor via ``get_folder_access_v3`` so sub-folders use the + server-reported UID and access type (including inherited accessors). + On success, evicts the local access cache and sets ``params.sync_data``. + """ resolved = resolve_folder_identifier(params, folder_uid) if not resolved: raise ValueError(f"Folder '{folder_uid}' not found") folder_uid = resolved + _ensure_folder_direct_permissions(params, folder_uid) + actual_uid_bytes, identifier_label, access_type_enum = _resolve_accessor( params, user_uid, as_team) if not actual_uid_bytes: raise ValueError(f"{'Team' if as_team else 'User'} '{user_uid}' not found") + accessor_uid_b64 = utils.base64_url_encode(actual_uid_bytes) + access_type_label = folder_pb2.AccessType.Name(access_type_enum) + accessor = _lookup_folder_accessor( + params, folder_uid, accessor_uid_b64, access_type_label) + if accessor: + server_uid = accessor.get('accessor_uid') + if server_uid: + actual_uid_bytes = utils.base64_url_decode(server_uid) + accessor_uid_b64 = server_uid + server_type = accessor.get('access_type') + if server_type: + access_type_label = server_type + try: + access_type_enum = folder_pb2.AccessType.Value(server_type) + except ValueError: + raise ValueError( + f"Unrecognised access type '{server_type}' returned by server " + f"for folder '{folder_uid}'") + ad = folder_pb2.FolderAccessData() ad.folderUid = utils.base64_url_decode(folder_uid) ad.accessTypeUid = actual_uid_bytes @@ -483,7 +569,10 @@ def revoke_folder_access_v3(params, folder_uid, user_uid, as_team=False): response = folder_access_update_v3(params, folder_access_removes=[ad]) result = parse_folder_access_result(response, folder_uid, identifier_label, 'Access revoked successfully') - result['access_type'] = 'AT_TEAM' if as_team else 'AT_USER' + result['access_type'] = access_type_label + if result.get('success'): + _evict_folder_accessor_cache(params, folder_uid, accessor_uid_b64) + params.sync_data = True return result diff --git a/unit-tests/test_command_record.py b/unit-tests/test_command_record.py index e2f6b1684..834a4bbfb 100644 --- a/unit-tests/test_command_record.py +++ b/unit-tests/test_command_record.py @@ -326,6 +326,7 @@ def test_get_shared_folder_json_includes_owner_flag(self): self.assertTrue(all('owner' in u for u in payload['users'])) def test_get_user_folder_json_consistent_by_name_and_uid(self): + """get --format json returns the same folder payload by name or UID.""" params = get_synced_params() cmd = record.RecordGetUidCommand() user_folder = next( diff --git a/unit-tests/test_nested_share_folder.py b/unit-tests/test_nested_share_folder.py index 301afc3ed..f946d8b07 100644 --- a/unit-tests/test_nested_share_folder.py +++ b/unit-tests/test_nested_share_folder.py @@ -13,7 +13,15 @@ from unittest.mock import Mock, MagicMock, patch from keepercommander import utils, crypto +from keepercommander.commands.nested_share_folder import ( + NestedShareFolderMkdirCommand, + NestedShareFolderShareCommand, +) +from keepercommander.commands.nested_share_folder.helpers import classify_share_recipient from keepercommander.error import CommandError +from keepercommander.nested_share_folder.common import parse_folder_access_result +from keepercommander.nested_share_folder.folder_api import revoke_folder_access_v3 +from keepercommander.proto import folder_pb2 _DATA_KEY = utils.generate_aes_key() @@ -51,13 +59,19 @@ def _make_params(**overrides): return p -def _make_folder(folder_uid=None, name='Test Folder', parent_uid=None): +def _make_folder(folder_uid=None, name='Test Folder', parent_uid=None, + inherit_user_permissions=None): fuid = folder_uid or utils.generate_uid() key = utils.generate_aes_key() - return fuid, { + obj = { 'folder_uid': fuid, 'name': name, 'parent_uid': parent_uid, 'folder_key_unencrypted': key, } + if inherit_user_permissions is not None: + obj['inherit_user_permissions'] = inherit_user_permissions + elif parent_uid: + obj['inherit_user_permissions'] = folder_pb2.BOOLEAN_TRUE + return fuid, obj def _make_record(record_uid=None, title='Test Record'): @@ -245,7 +259,7 @@ def test_mkdir(self, mock_create): @patch('keepercommander.commands.nested_share_folder.folder_commands._nsf.create_folder_v3') def test_mkdir_resolves_parent_uid_in_path(self, mock_create): - from keepercommander.commands.nested_share_folder import NestedShareFolderMkdirCommand + """Parent path segments that are NSF UIDs resolve to the folder, not a name.""" parent_uid = 'tY6D-RanxY252zzBY_xU4A' child_uid = utils.generate_uid() mock_create.return_value = { @@ -270,7 +284,7 @@ def test_mkdir_resolves_parent_uid_in_path(self, mock_create): @patch('keepercommander.commands.nested_share_folder.folder_commands._nsf.create_folder_v3') def test_mkdir_resolves_parent_name_in_path(self, mock_create): - from keepercommander.commands.nested_share_folder import NestedShareFolderMkdirCommand + """Parent path segments that match an existing folder name resolve by name.""" parent_fuid, parent_fobj = _make_folder(name='Engineering') child_uid = utils.generate_uid() mock_create.return_value = { @@ -293,7 +307,7 @@ def test_mkdir_resolves_parent_name_in_path(self, mock_create): @patch('keepercommander.commands.nested_share_folder.folder_commands._nsf.create_folder_v3') def test_mkdir_creates_intermediate_name_segments(self, mock_create): - from keepercommander.commands.nested_share_folder import NestedShareFolderMkdirCommand + """Multi-segment name paths create missing intermediate folders.""" eng_uid = utils.generate_uid() child_uid = utils.generate_uid() mock_create.side_effect = [ @@ -858,6 +872,19 @@ def test_share_folder_invite_message_uses_command_prefix(self, mock_grant): class TestNestedShareFolderFolderApi(TestCase): + def test_encrypt_for_team_uses_rsa_public_key(self): + """Team grants use the RSA public key when available (server requires key type 2).""" + from keepercommander.nested_share_folder.common import encrypt_for_team + from keepercommander.params import PublicKeys + + rsa_priv, rsa_pub = crypto.generate_rsa_key() + rsa_pub_bytes = crypto.unload_rsa_public_key(rsa_pub) + folder_key = utils.generate_aes_key() + team_keys = PublicKeys(aes=utils.generate_aes_key(), rsa=rsa_pub_bytes, ec=b'') + encrypted, key_type = encrypt_for_team(folder_key, team_keys) + self.assertEqual(key_type, folder_pb2.encrypted_by_public_key) + self.assertEqual(crypto.decrypt_rsa(encrypted, rsa_priv), folder_key) + @patch('keepercommander.nested_share_folder.folder_api.folder_access_update_v3') @patch('keepercommander.nested_share_folder.folder_api.handle_share_invite') @patch('keepercommander.nested_share_folder.folder_api.get_user_public_key') @@ -960,6 +987,171 @@ def test_grant_folder_access_same_role_updates_expiration( expiration_timestamp=expiration) + @patch('keepercommander.nested_share_folder.folder_api.folder_access_update_v3') + @patch('keepercommander.nested_share_folder.folder_api.update_folder_v3') + @patch('keepercommander.nested_share_folder.folder_api.get_folder_access_v3') + @patch('keepercommander.nested_share_folder.folder_api.resolve_folder_identifier') + @patch('keepercommander.nested_share_folder.folder_api._resolve_accessor') + def test_revoke_folder_access_subfolder_team( + self, mock_resolve_accessor, mock_resolve_folder, + mock_get_access, mock_update_folder, mock_access_update): + """Revoking team access on an NSF subfolder uses server accessor UIDs.""" + parent_uid, parent_obj = _make_folder(name='Parent') + child_uid, child_obj = _make_folder( + name='Child', parent_uid=parent_uid) + team_uid = utils.generate_uid() + team_uid_bytes = utils.base64_url_decode(team_uid) + params = _make_params(nested_share_folders={ + parent_uid: parent_obj, + child_uid: child_obj, + }) + mock_resolve_folder.return_value = child_uid + mock_resolve_accessor.return_value = ( + team_uid_bytes, team_uid, folder_pb2.AT_TEAM) + mock_update_folder.return_value = {'success': True} + mock_get_access.return_value = { + 'results': [{ + 'folder_uid': child_uid, + 'success': True, + 'accessors': [{ + 'accessor_uid': team_uid, + 'access_type': 'AT_TEAM', + 'role': 'VIEWER', + 'inherited': False, + }], + }], + } + mock_response = Mock() + mock_result = Mock() + mock_result.status = folder_pb2.SUCCESS + mock_result.message = '' + mock_response.folderAccessResults = [mock_result] + mock_access_update.return_value = mock_response + + result = revoke_folder_access_v3( + params, child_uid, team_uid, as_team=True) + + self.assertTrue(result['success']) + self.assertTrue(params.sync_data) + mock_update_folder.assert_called_once_with( + params, child_uid, inherit_permissions=False) + mock_access_update.assert_called_once() + remove_call = mock_access_update.call_args + ad = remove_call.kwargs['folder_access_removes'][0] + self.assertEqual(ad.accessType, folder_pb2.AT_TEAM) + self.assertEqual( + utils.base64_url_encode(ad.accessTypeUid), team_uid) + self.assertEqual( + utils.base64_url_encode(ad.folderUid), child_uid) + + @patch('keepercommander.nested_share_folder.folder_api.folder_access_update_v3') + @patch('keepercommander.nested_share_folder.folder_api.update_folder_v3') + @patch('keepercommander.nested_share_folder.folder_api.get_folder_access_v3') + @patch('keepercommander.nested_share_folder.folder_api.resolve_folder_identifier') + @patch('keepercommander.nested_share_folder.folder_api._resolve_accessor') + def test_revoke_folder_access_subfolder_team_inherited( + self, mock_resolve_accessor, mock_resolve_folder, + mock_get_access, mock_update_folder, mock_access_update): + """Inherited access on a subfolder breaks inheritance then revokes.""" + parent_uid, parent_obj = _make_folder(name='Parent') + child_uid, child_obj = _make_folder( + name='Child', parent_uid=parent_uid) + team_uid = utils.generate_uid() + team_uid_bytes = utils.base64_url_decode(team_uid) + params = _make_params(nested_share_folders={ + parent_uid: parent_obj, + child_uid: child_obj, + }) + mock_resolve_folder.return_value = child_uid + mock_resolve_accessor.return_value = ( + team_uid_bytes, team_uid, folder_pb2.AT_TEAM) + mock_update_folder.return_value = {'success': True} + mock_get_access.return_value = { + 'results': [{ + 'folder_uid': child_uid, + 'success': True, + 'accessors': [{ + 'accessor_uid': team_uid, + 'access_type': 'AT_TEAM', + 'role': 'VIEWER', + 'inherited': True, + }], + }], + } + mock_response = Mock() + mock_result = Mock() + mock_result.status = folder_pb2.SUCCESS + mock_result.message = '' + mock_response.folderAccessResults = [mock_result] + mock_access_update.return_value = mock_response + + result = revoke_folder_access_v3( + params, child_uid, team_uid, as_team=True) + + self.assertTrue(result['success']) + mock_update_folder.assert_called_once_with( + params, child_uid, inherit_permissions=False) + mock_access_update.assert_called_once() + self.assertEqual( + params.nested_share_folders[child_uid]['inherit_user_permissions'], + folder_pb2.BOOLEAN_FALSE) + + @patch('keepercommander.nested_share_folder.folder_api.revoke_folder_access_v3') + @patch('keepercommander.api.get_share_objects') + def test_share_folder_remove_subfolder_team(self, mock_share_objects, mock_revoke): + """nsf-share-folder -a remove resolves team names on NSF subfolders.""" + mock_share_objects.return_value = {'teams': {}} + parent_uid, parent_obj = _make_folder(name='Parent') + child_uid, child_obj = _make_folder( + name='Child', parent_uid=parent_uid) + team_uid = utils.generate_uid() + mock_revoke.return_value = { + 'success': True, 'action_taken': 'removed', + 'status': 'SUCCESS', 'message': '', + } + params = _make_params( + nested_share_folders={parent_uid: parent_obj, child_uid: child_obj}, + team_cache={team_uid: {'name': 'Ops Team', 'team_uid': team_uid}}, + ) + cmd = NestedShareFolderShareCommand() + with mock.patch('builtins.print'): + cmd.execute( + params, + folder=[child_uid], + user=['Ops Team'], + action='remove', + ) + mock_revoke.assert_called_once_with( + params=params, + folder_uid=child_uid, + user_uid=team_uid, + as_team=True, + ) + + def test_classify_share_recipient_resolves_team_from_cache(self): + """Team names fall back to team_cache when share-objects is empty.""" + team_uid = utils.generate_uid() + params = _make_params(team_cache={ + team_uid: {'name': 'Ops Team', 'team_uid': team_uid}, + }) + with mock.patch('keepercommander.api.get_share_objects', return_value={'teams': {}}): + result = classify_share_recipient(params, 'Ops Team') + self.assertEqual(result, ('team', team_uid)) + + def test_parse_folder_access_result_treats_success_message_as_success(self): + """SUCCESS status with a non-empty message is still treated as success.""" + response = Mock() + result = Mock() + result.status = folder_pb2.SUCCESS + result.message = 'Access revoked successfully' + response.folderAccessResults = [result] + + parsed = parse_folder_access_result( + response, 'folder', 'team', 'Access revoked successfully') + self.assertTrue(parsed['success']) + self.assertEqual(parsed['status'], 'SUCCESS') + + class TestNestedShareFolderRecordApi(TestCase): def setUp(self): From a0693cf522867a538b791356e263e56060f08c16 Mon Sep 17 00:00:00 2001 From: Matthew Ford Date: Mon, 13 Jul 2026 13:09:14 -0700 Subject: [PATCH 7/9] Redoing into single commit: DR-1295 Add pamGitHubConfiguration record support Test fixes unrelated to pamGitHubConfiguration changes. --- keepercommander/commands/discoveryrotation.py | 34 +++++++++++++++---- .../test_command_enterprise_api_keys.py | 10 ++++-- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index 17fb6fe4d..221c96da5 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -17,8 +17,8 @@ import re import time from datetime import datetime -from typing import Dict, Optional, Any, Set, List from urllib.parse import urlparse, urlunparse +from typing import Optional, List import requests from keeper_secrets_manager_core.utils import url_safe_str_to_bytes @@ -77,6 +77,7 @@ from .pam_debug.link import PAMDebugLinkCommand from .pam_debug.rotation_setting import PAMDebugRotationSettingsCommand from .pam_debug.vertex import PAMDebugVertexCommand +from .pam.cnapp_commands import PAMCnappCommand from .pam_import.commands import PAMProjectCommand from keepercommander.commands.pam_cloud.pam_privileged_access import PAMPrivilegedAccessCommand from .pam_launch.launch import PAMLaunchCommand @@ -93,7 +94,6 @@ from .pam_saas.config import PAMActionSaasConfigCommand from .pam_saas.update import PAMActionSaasUpdateCommand from .tunnel_and_connections import PAMTunnelCommand, PAMConnectionCommand, PAMRbiCommand, PAMSplitCommand -from .pam.cnapp_commands import PAMCnappCommand from .universalsecretsync import ( PAMUniversalSyncConfigCommand, PAMUniversalSyncRunCommand @@ -288,10 +288,9 @@ def __init__(self): self.register_command('workflow', PAMWorkflowCommand(), 'Manage PAM Workflows', 'w') self.register_command('access', PAMPrivilegedAccessCommand(), 'Manage privileged cloud access operations', 'ac') - self.register_command('cnapp', PAMCnappCommand(), - 'Manage Cloud-Native Application Protection Platform integration', 'cn') self.register_command('universal-sync-config', PAMUniversalSyncConfigCommand(), 'Manage Universal Sync Configurations', 'usc') self.register_command('universal-sync-run', PAMUniversalSyncRunCommand(), 'Run Universal Sync', 'usr') + self.register_command('cnapp', PAMCnappCommand(), 'Manage CNAPP integrations', 'cn') class PAMGatewayCommand(GroupCommand): @@ -2340,7 +2339,8 @@ def print_root_rotation_setting(params, is_verbose=False, format_type='table'): for c in configurations: # type: vault.TypedRecord if c.record_type in ('pamAwsConfiguration', 'pamAzureConfiguration', 'pamGcpConfiguration', - 'pamDomainConfiguration', 'pamNetworkConfiguration', 'pamOciConfiguration'): + 'pamDomainConfiguration', 'pamNetworkConfiguration', 'pamOciConfiguration', + 'pamGitHubConfiguration'): facade.record = c shared_folder_parents = find_parent_top_folder(params, c.record_uid) if shared_folder_parents: @@ -2402,7 +2402,7 @@ def print_root_rotation_setting(params, is_verbose=False, format_type='table'): common_parser = argparse.ArgumentParser(add_help=False) common_parser.add_argument('--environment', '-env', dest='config_type', action='store', - choices=['local', 'aws', 'azure', 'gcp', 'domain', 'oci'], help='PAM Configuration Type') + choices=['local', 'aws', 'azure', 'gcp', 'domain', 'oci', 'github'], help='PAM Configuration Type') common_parser.add_argument('--title', '-t', dest='title', action='store', help='Title of the PAM Configuration') common_parser.add_argument('--gateway', '-g', dest='gateway_uid', action='store', help='Gateway UID or Name') common_parser.add_argument('--shared-folder', '-sf', dest='shared_folder_uid', action='store', @@ -2461,6 +2461,12 @@ def print_root_rotation_setting(params, is_verbose=False, format_type='table'): help='Google Workspace Administrator Email Address') gcp_group.add_argument('--gcp-region', dest='region_names', action='append', help='GCP Region Names') +github_group = common_parser.add_argument_group('github', 'GitHub configuration') +github_group.add_argument('--github-id', dest='github_id', action='store', help='GitHub Id') +github_group.add_argument('--personal-access-token', dest='personal_access_token', action='store', + help='Personal Access Token') +github_group.add_argument('--github-base-url', dest='github_base_url', action='store', + help='GitHub Base URL') class PamConfigurationEditMixin(RecordEditMixin): pam_record_types = None @@ -2642,6 +2648,16 @@ def parse_properties(self, params, record, **kwargs): # type: (KeeperParams, va if gcp_region: regions = '\n'.join(gcp_region) extra_properties.append(f'multiline.pamGcpRegionName={regions}') + elif record.record_type == 'pamGitHubConfiguration': + github_id = kwargs.get('github_id') + if github_id: + extra_properties.append(f'text.pamGitHubId={github_id}') + personal_access_token = kwargs.get('personal_access_token') + if personal_access_token: + extra_properties.append(f'secret.personalAccessToken={personal_access_token}') + github_base_url = kwargs.get('github_base_url') + if github_base_url: + extra_properties.append(f'text.pamGitHubBaseUrl={github_base_url}') elif record.record_type == 'pamAzureConfiguration': azure_id = kwargs.get('azure_id') if azure_id: @@ -2803,13 +2819,15 @@ def execute(self, params, **kwargs): record_type = 'pamNetworkConfiguration' elif config_type == 'gcp': record_type = 'pamGcpConfiguration' + elif config_type == 'github': + record_type = 'pamGitHubConfiguration' elif config_type == 'domain': record_type = 'pamDomainConfiguration' elif config_type == 'oci': record_type = 'pamOciConfiguration' else: raise CommandError('pam-config-new', f'--environment {config_type} is not supported' - ' - supported options: local, aws, azure, gcp, domain, oci') + ' - supported options: local, aws, azure, gcp, domain, oci, github') title = kwargs.get('title') if not title: @@ -2958,6 +2976,8 @@ def execute(self, params, **kwargs): record_type = 'pamNetworkConfiguration' elif config_type == 'gcp': record_type = 'pamGcpConfiguration' + elif config_type == 'github': + record_type = 'pamGitHubConfiguration' elif config_type == 'domain': record_type = 'pamDomainConfiguration' elif config_type == 'oci': diff --git a/unit-tests/test_command_enterprise_api_keys.py b/unit-tests/test_command_enterprise_api_keys.py index 92d4183f9..f43b0fe01 100644 --- a/unit-tests/test_command_enterprise_api_keys.py +++ b/unit-tests/test_command_enterprise_api_keys.py @@ -69,6 +69,9 @@ def test_api_key_list_json_format(self): self.assertEqual(len(TestEnterpriseApiKeys.expected_commands), 0) self.assertIsNotNone(result) + # Compute the expected expiration date for the active token (matches mock: now + 365 days) + token4_expiry = datetime.datetime.now() + datetime.timedelta(days=365) + token4_expiry_str = token4_expiry.strftime('%Y-%m-%d %H:%M:%S') # Assert that the JSON result matches the expected values for all entries expected_json = [ { @@ -100,7 +103,7 @@ def test_api_key_list_json_format(self): "name": "SIEM Tool", "status": "Active", "issued_date": "2025-07-08 14:16:07", - "expiration_date": "2030-07-08 14:16:07", + "expiration_date": token4_expiry_str, "integration": "SIEM:2" }, { @@ -661,13 +664,14 @@ def communicate_rest_success(params, request, path, rs_type=None): integration5.apiIntegrationTypeName = "SIEM" integration5.actionType = 2 - # Token 53 - Active + # Token 53 - Active (expiration is always 1 year from now to keep tests passing over time) token4 = rs.tokens.add() token4.token = "active_token_53" token4.name = "SIEM Tool" token4.enterprise_id = 8560 token4.issuedDate = int(datetime.datetime(2025, 7, 8, 14, 16, 7).timestamp() * 1000) - token4.expirationDate = int(datetime.datetime(2030, 7, 8, 14, 16, 7).timestamp() * 1000) + token4_expiry = datetime.datetime.now() + datetime.timedelta(days=365) + token4.expirationDate = int(token4_expiry.timestamp() * 1000) integration7 = token4.integrations.add() integration7.roleName = "SIEM" integration7.apiIntegrationTypeName = "SIEM" From f6616f69b9b74538b3e4852bde9c05255a7f63b0 Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Mon, 13 Jul 2026 21:45:00 -0700 Subject: [PATCH 8/9] MC transfer command help --- keepercommander/commands/mc_transfer.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/keepercommander/commands/mc_transfer.py b/keepercommander/commands/mc_transfer.py index 692647b05..a15c47aa9 100644 --- a/keepercommander/commands/mc_transfer.py +++ b/keepercommander/commands/mc_transfer.py @@ -27,13 +27,17 @@ def __init__(self): mc_transfer_target_parser.add_argument('--target-email', dest='target_email', help='Target administrator email') mc_transfer_join_msp_parser = argparse.ArgumentParser( - prog='mc-transfer join-msp', description='Initializes Regular/MC/MSP transfer to MSP', parents=[mc_transfer_target_parser]) + prog='mc-transfer join-msp', description='Initializes Regular/MC/MSP transfer to MSP') +mc_transfer_join_msp_parser.add_argument('--target-name', dest='target_name', help='MSP enterprise name') +mc_transfer_join_msp_parser.add_argument('--target-email', dest='target_email', help='MSP administrator email') mc_transfer_leave_msp_parser = argparse.ArgumentParser( - prog='mc-transfer leave-msp', description='Initializes MC leaving MSP', parents=[mc_transfer_target_parser]) + prog='mc-transfer leave-msp', description='Initializes MC leaving MSP') mc_transfer_accept_mc_parser = argparse.ArgumentParser( - prog='mc-transfer accept-mc', description='MSP accepts Regular/MC/MSP transfer', parents=[mc_transfer_target_parser]) + prog='mc-transfer accept-mc', description='MSP accepts Regular/MC/MSP transfer') +mc_transfer_accept_mc_parser.add_argument('--target-name', dest='target_name', help='MC enterprise name') +mc_transfer_accept_mc_parser.add_argument('--target-email', dest='target_email', help='MC administrator email') mc_transfer_status_parser = argparse.ArgumentParser( prog='mc-transfer status', description='Checks MC transfer status', parents=[mc_transfer_target_parser]) From 93a3eaf8959be426316a7ad44d01979fd8188e43 Mon Sep 17 00:00:00 2001 From: "Joao Paulo Oliveira Santos (JP)" Date: Tue, 14 Jul 2026 11:39:46 -0400 Subject: [PATCH 9/9] upgrade to cnapp v4 --- docs/pam-cnapp.md | 402 ++++++++++++++++++ .../commands/pam/cnapp_commands.py | 53 ++- keepercommander/commands/pam/cnapp_helper.py | 53 ++- keepercommander/proto/cnapp_pb2.py | 60 +-- unit-tests/pam/test_cnapp.py | 150 ++++++- 5 files changed, 651 insertions(+), 67 deletions(-) create mode 100644 docs/pam-cnapp.md diff --git a/docs/pam-cnapp.md b/docs/pam-cnapp.md new file mode 100644 index 000000000..cc4f20b25 --- /dev/null +++ b/docs/pam-cnapp.md @@ -0,0 +1,402 @@ +# `pam cnapp` — Manage CNAPP Integrations + +The `pam cnapp` command group connects Keeper PAM to a Cloud-Native Application Protection Platform (CNAPP) provider. It lets you configure the provider connection, pull the queue of security issues the provider has flagged, associate vault records with those issues, and dispatch remediation actions to a PAM Gateway. + +All commands run against a **network** (identified by a network record UID) and talk to the Keeper Router (krouter), which brokers the request to the provider and, where relevant, to the Gateway. + +- **Command root:** `pam cnapp` +- **Aliases:** `pam cnapp config` → `c`, `pam cnapp queue` → `q` +- **Default sub-command:** running `pam cnapp` with no verb defaults to `pam cnapp queue`, which in turn defaults to `pam cnapp queue list`. +- **Supported providers:** `wiz` + +--- + +## Table of Contents + +1. [Concepts](#concepts) +2. [Command Tree](#command-tree) +3. [Configuration Commands](#configuration-commands) + - [`config set`](#config-set) + - [`config test`](#config-test) + - [`config test-encrypter`](#config-test-encrypter) + - [`config read`](#config-read) + - [`config delete`](#config-delete) +4. [Queue Commands](#queue-commands) + - [`queue list`](#queue-list) + - [`queue associate`](#queue-associate) + - [`queue remediate`](#queue-remediate) + - [`queue set-status`](#queue-set-status) + - [`queue delete`](#queue-delete) +5. [Issue Status Values](#issue-status-values) +6. [Payload Decryption](#payload-decryption) +7. [Examples](#examples) + +--- + +## Concepts + +**Network** — A PAM network record. Every `pam cnapp` command targets a single network via its UID (base64url). The network holds the CNAPP configuration and owns the queue of issues. + +**Provider** — The CNAPP vendor Keeper integrates with. The provider keyword is case-insensitive (`wiz` or `WIZ`). Currently only **Wiz** is supported. + +**Configuration** — The stored connection details for a provider on a network: client ID, client secret, API endpoint, OAuth2 auth endpoint, and a reference to an *Encrypter* record. Configuration is validated against the provider before being persisted. + +**Encrypter** — A customer-deployed service that encrypts issue payloads before they are stored in Keeper. Its base URL and AES-256 key live in a vault record referenced by the configuration (`--config-record`). Commander uses that key locally to decrypt payloads when listing the queue. + +**Queue** — The list of security issues the provider has pushed for a network. Each item carries a status, timestamps, an (encrypted) payload describing the issue, and optionally an associated vault record. + +**Gateway** — The PAM Gateway (controller) that executes remediation actions such as credential rotation. + +--- + +## Command Tree + +``` +pam cnapp +├── config (c) +│ ├── set Create or update the provider configuration +│ ├── test Validate credentials without saving +│ ├── test-encrypter Health-check the customer Encrypter +│ ├── read Read the persisted configuration +│ └── delete Delete the configuration +└── queue (q) + ├── list (l) List queued issues + ├── associate (a) Attach a vault record to a queue item + ├── remediate (r) Trigger a remediation action via the Gateway + ├── set-status (s) Update an issue's status + └── delete (d) Delete a queue item +``` + +--- + +## Configuration Commands + +Configuration commands live under `pam cnapp config` (alias `c`). + +### `config set` + +Create or update the CNAPP provider configuration on a network. The credentials are validated against the provider before being persisted. + +``` +pam cnapp config set --network-uid --provider wiz --client-id \ + --api-endpoint --auth-endpoint --config-record \ + [--client-secret ] +``` + +| Flag | Required | Description | +|---|---|---| +| `--network-uid`, `-n` | Yes | Network record UID (base64url). | +| `--provider`, `-p` | Yes | Provider keyword: `wiz` (case-insensitive). | +| `--client-id` | Yes | Provider API client ID / app ID. | +| `--client-secret` | No | Provider API client secret. **Omit to keep the existing stored secret** when editing an existing configuration. | +| `--api-endpoint` | Yes | Provider API endpoint URL (e.g. `https://api.us1.app.wiz.io/graphql`). | +| `--auth-endpoint` | Yes | Provider OAuth2 token endpoint URL (e.g. `https://auth.app.wiz.io/oauth/token`). Lets you point at your own tenant/region. | +| `--config-record` | Yes | UID of the vault record holding the Encrypter URL and encryption key. | + +On success the saved configuration is printed. The `--auth-endpoint` allows customers to target their own tenant or region (for example, an EU vs. US Wiz auth host) without a code change. + +### `config test` + +Validate provider credentials by probing the provider **without persisting anything**. Useful for verifying credentials before running `config set`. + +``` +pam cnapp config test --network-uid --provider wiz --client-id \ + --client-secret --api-endpoint --auth-endpoint +``` + +| Flag | Required | Description | +|---|---|---| +| `--network-uid`, `-n` | Yes | Network record UID (base64url). | +| `--provider`, `-p` | Yes | Provider keyword: `wiz`. | +| `--client-id` | Yes | Provider API client ID. | +| `--client-secret` | Yes | Provider API client secret. | +| `--api-endpoint` | Yes | Provider API endpoint URL. | +| `--auth-endpoint` | Yes | Provider OAuth2 token endpoint URL. | + +Prints `CNAPP credentials validated successfully.` on success; fails with the provider's reason when credentials are rejected. + +### `config test-encrypter` + +Health-check the customer-deployed Encrypter. Keeper Router probes `/health`. + +``` +pam cnapp config test-encrypter --url +``` + +| Flag | Required | Description | +|---|---|---| +| `--url`, `-u` | Yes | Base URL of the Encrypter. `/health` is appended automatically. | + +Use this before `config set` to confirm the Encrypter referenced by your configuration is reachable. Prints `Encrypter is reachable.` on success. + +### `config read` + +Read the persisted CNAPP configuration for a network. Note: the client secret is **never** returned by the server — only the endpoints, client ID, and Encrypter record UID. + +``` +pam cnapp config read --network-uid --provider wiz [--format table|json] +``` + +| Flag | Required | Description | +|---|---|---| +| `--network-uid`, `-n` | Yes | Network record UID (base64url). | +| `--provider`, `-p` | Yes | Provider keyword: `wiz`. | +| `--format` | No | Output format: `table` (default) or `json`. | + +### `config delete` + +Remove the CNAPP configuration from a network. + +``` +pam cnapp config delete --network-uid +``` + +| Flag | Required | Description | +|---|---|---| +| `--network-uid`, `-n` | Yes | Network record UID (base64url). | + +Fails if no configuration currently exists on the network. + +--- + +## Queue Commands + +Queue commands live under `pam cnapp queue` (alias `q`). + +### `queue list` + +List queued CNAPP issues for a network. By default Commander attempts to decrypt each item's payload locally using the Encrypter key so it can show a human-readable issue summary (see [Payload Decryption](#payload-decryption)). + +``` +pam cnapp queue list --network-uid [--status ] [--provider wiz] \ + [--config-record ] [--no-decrypt] [--format table|json] +``` + +| Flag | Required | Description | +|---|---|---| +| `--network-uid`, `-n` | Yes | Network record UID (base64url). | +| `--status`, `-s` | No | Filter by status name or id. Default: all statuses. See [Issue Status Values](#issue-status-values). | +| `--provider`, `-p` | No | Provider keyword used for the config lookup. Default: `wiz`. | +| `--config-record` | No | Explicit Encrypter vault record UID. Overrides the automatic lookup done via `config read`. | +| `--no-decrypt` | No | Skip payload decryption and show only the encrypted envelope's metadata. | +| `--format` | No | Output format: `table` (default) or `json`. | + +The table output includes: Queue ID, Provider, Status, Received (UTC), Resolved (UTC), associated Record UID, Control Hash, and an Issue summary (severity · control/issue · resource). The control hash identifies the provider control that flagged the issue and is what auto-remediation rules are keyed on (see `queue remediate --auto-remediate`); it is also exposed as `controlHash` in `--format json` output. + +**Notes:** +- If no Encrypter key can be resolved, payloads are shown as `` and a warning is printed. Pass `--config-record ` or ensure `config read` succeeds. +- CLI paging is not yet available. If more items exist than were returned (`hasMore`), resolve or delete returned items to surface the rest. + +### `queue associate` + +Attach a vault record to a queue item. Association is **required before remediation** so the Gateway knows which credential to act on. + +``` +pam cnapp queue associate --queue-id --record-uid +``` + +| Flag | Required | Description | +|---|---|---| +| `--queue-id`, `-q` | Yes | Queue item ID (from `queue list`). | +| `--record-uid`, `-r` | Yes | Vault record UID to associate (base64url). | + +### `queue remediate` + +Dispatch a remediation action to the Gateway for a queued issue. + +``` +pam cnapp queue remediate --queue-id --action [options] +``` + +| Flag | Required | Description | +|---|---|---| +| `--queue-id`, `-q` | Yes | Queue item ID. | +| `--action`, `-a` | Yes | Remediation action. See table below. | +| `--resource-ref` | No | Resource reference UID for the action. | +| `--pwd-complexity` | No | Password complexity JSON (for `rotate_credentials`). | +| `--controller-uid` | No | Override the Gateway UID. | +| `--message-uid` | No | Client-generated conversation UID for streaming responses. | +| `--group` | No | Group to remove the user from (`remove_standing_privilege` only). Repeatable. | +| `--role` | No | Role to remove the user from (`remove_standing_privilege` only). Repeatable. | +| `--network-uid`, `-n` | No | PAM configuration (network) record UID whose record key encrypts the `--group`/`--role` values. Required when `--group`/`--role` is given. | +| `--auto-remediate` | No | Register an auto-remediation rule for this item's control hash before rotating (`rotate_credentials` only; the queue item must carry a control hash — see the Control Hash column in `queue list`). | + +**Action types:** + +| Action | Description | +|---|---| +| `rotate_credentials` | Rotate the credential associated with the issue. Supports `--auto-remediate` to also register an auto-remediation rule so future issues with the same control hash rotate automatically. | +| `manage_access` | Manage access to the flagged resource. Frontend-only — rejected by Keeper Router. | +| `jit_access` | Grant just-in-time access. Frontend-only — rejected by Keeper Router. | +| `remove_standing_privilege` | Remove the user's standing privilege. Target groups/roles are supplied with `--group`/`--role` and encrypted client-side with the PAM configuration record key — Keeper Router never sees them in the clear. When omitted, the Gateway resolves the targets from the resource record's JIT settings. | + +> **Note:** Keeper Router dispatches `rotate_credentials` and `remove_standing_privilege`; `manage_access` and `jit_access` return an error. On success the command prints the dispatched action, resulting status, and any result message. + +### `queue set-status` + +Update the local status of a queue item. Keeper Router notifies the provider on a best-effort basis. + +``` +pam cnapp queue set-status --queue-id --status [--reason ] +``` + +| Flag | Required | Description | +|---|---|---| +| `--queue-id`, `-q` | Yes | Queue item ID. | +| `--status`, `-s` | Yes | New status name or numeric id. `0`/`all` is not allowed here — a specific status is required. | +| `--reason` | No | Free-form reason, forwarded to the provider notification. | + +### `queue delete` + +Delete a queue item entirely. + +``` +pam cnapp queue delete --queue-id +``` + +| Flag | Required | Description | +|---|---|---| +| `--queue-id`, `-q` | Yes | Queue item ID to delete. | + +Fails if the queue ID is unknown. + +--- + +## Issue Status Values + +Status flags (`--status`) accept either the case-insensitive name or the numeric id: + +| Name | ID | +|---|---| +| `pending` | 1 | +| `in_progress` | 2 | +| `resolved` | 3 | +| `failed` | 4 | +| `cancelled` | 5 | + +For `queue list`, a status of `0` (or an empty value) means **all statuses** — this is the default. For `queue set-status`, `0`/all is rejected; you must supply a specific status. + +--- + +## Payload Decryption + +Each queue item's payload (the issue detail) is stored encrypted. Commander decrypts it locally so it can show a readable summary, using this flow: + +1. The AES-256-GCM key is taken from the **Encrypter record** — either the one you pass via `--config-record`, or the `cnappConfigRecordUid` resolved automatically from `config read`. +2. The key is read from the Encrypter vault record's `Encryption Key` field (a `secret` or `note` typed field). Keys are expected to be 32 bytes, base64 or base64url-encoded (e.g. the output of `openssl rand -base64 32`). +3. The encrypted payload is unwrapped (`AES-256-GCM`, nonce + ciphertext + tag) and parsed as JSON. + +If the key can't be resolved or a payload fails to decrypt, the item is still listed but its Issue column shows `` (with a warning). Use `--no-decrypt` to skip decryption entirely and show only metadata. + +--- + +## Examples + +### Validate credentials, then save the configuration + +```bash +# 1. Confirm the Encrypter is reachable +pam cnapp config test-encrypter --url https://encrypter.internal.example.com + +# 2. Validate provider credentials without saving +pam cnapp config test \ + --network-uid nB2c3D4e5F6g7H8i9J0kLm \ + --provider wiz \ + --client-id my-wiz-client-id \ + --client-secret "MyWizClientSecret" \ + --api-endpoint https://api.us1.app.wiz.io/graphql \ + --auth-endpoint https://auth.app.wiz.io/oauth/token + +# 3. Persist the configuration +pam cnapp config set \ + --network-uid nB2c3D4e5F6g7H8i9J0kLm \ + --provider wiz \ + --client-id my-wiz-client-id \ + --client-secret "MyWizClientSecret" \ + --api-endpoint https://api.us1.app.wiz.io/graphql \ + --auth-endpoint https://auth.app.wiz.io/oauth/token \ + --config-record eNc2Rypt3rReCoRdUiD01 +``` + +### Update an endpoint without changing the stored secret + +```bash +pam cnapp config set \ + --network-uid nB2c3D4e5F6g7H8i9J0kLm \ + --provider wiz \ + --client-id my-wiz-client-id \ + --api-endpoint https://api.eu1.app.wiz.io/graphql \ + --auth-endpoint https://auth.app.wiz.io/oauth/token \ + --config-record eNc2Rypt3rReCoRdUiD01 +``` + +### Read the current configuration as JSON + +```bash +pam cnapp config read --network-uid nB2c3D4e5F6g7H8i9J0kLm --provider wiz --format json +``` + +### List all queued issues + +```bash +pam cnapp queue list --network-uid nB2c3D4e5F6g7H8i9J0kLm +``` + +### List only pending issues without decrypting payloads + +```bash +pam cnapp queue list --network-uid nB2c3D4e5F6g7H8i9J0kLm --status pending --no-decrypt +``` + +### List issues, decrypting with an explicit Encrypter record + +```bash +pam cnapp queue list \ + --network-uid nB2c3D4e5F6g7H8i9J0kLm \ + --config-record eNc2Rypt3rReCoRdUiD01 \ + --format json +``` + +### Remediate an issue by rotating credentials + +```bash +# 1. Associate the vault record that holds the credential to rotate +pam cnapp queue associate --queue-id 42 --record-uid rEcOrDuIdToRoTaTe123 + +# 2. Dispatch the rotation to the Gateway +pam cnapp queue remediate --queue-id 42 --action rotate_credentials + +# Or also register an auto-remediation rule so future issues with the same +# control hash rotate automatically (item must carry a control hash): +pam cnapp queue remediate --queue-id 42 --action rotate_credentials --auto-remediate +``` + +### Remove standing privilege from specific groups and roles + +```bash +# Group/role names are encrypted client-side with the network record key. +pam cnapp queue remediate --queue-id 42 --action remove_standing_privilege \ + --network-uid nB2c3D4e5F6g7H8i9J0kLm \ + --group Admins --group DBAs --role db-owner + +# Without --group/--role the Gateway uses the resource record's JIT settings: +pam cnapp queue remediate --queue-id 42 --action remove_standing_privilege +``` + +### Mark an issue resolved with a reason + +```bash +pam cnapp queue set-status --queue-id 42 --status resolved --reason "Rotated and verified" +``` + +### Delete a queue item + +```bash +pam cnapp queue delete --queue-id 42 +``` + +### Delete the configuration + +```bash +pam cnapp config delete --network-uid nB2c3D4e5F6g7H8i9J0kLm +``` diff --git a/keepercommander/commands/pam/cnapp_commands.py b/keepercommander/commands/pam/cnapp_commands.py index bce1d5d16..4a2cc0a8f 100644 --- a/keepercommander/commands/pam/cnapp_commands.py +++ b/keepercommander/commands/pam/cnapp_commands.py @@ -468,7 +468,7 @@ def execute(self, params, **kwargs): print(f"{bcolors.WARNING}No encrypter key resolved — payloads will be shown as 'encrypted'. " f"Pass --config-record or run after `pam cnapp config read` succeeds.{bcolors.ENDC}") - headers = ['Queue ID', 'Provider', 'Status', 'Received (UTC)', 'Resolved (UTC)', 'Record UID', 'Issue'] + headers = ['Queue ID', 'Provider', 'Status', 'Received (UTC)', 'Resolved (UTC)', 'Record UID', 'Control Hash', 'Issue'] rows = [] for item in items: if item.cnappQueueId in decrypted_by_id: @@ -486,6 +486,7 @@ def execute(self, params, **kwargs): _format_timestamp(item.receivedAt), _format_timestamp(item.resolvedAt), bytes_to_base64(item.recordUid) if item.recordUid else '', + item.controlHash or '', issue_cell, ]) dump_report_data(rows, headers, fmt='table', filename='', row_number=False) @@ -521,11 +522,8 @@ class PAMCnappQueueRemediateCommand(Command): parser.add_argument('--queue-id', '-q', required=True, type=int, dest='cnapp_queue_id', help='Queue item ID.') parser.add_argument('--action', '-a', required=True, dest='action_type', - help='Remediation action: rotate_credentials, manage_access, jit_access, remove_standing_privilege.') - parser.add_argument('--provider', '-p', required=False, dest='provider', - help='Provider keyword (wiz). Optional — krouter resolves from queue item if omitted.') - parser.add_argument('--config-record', required=False, dest='cnapp_config_record_uid', - help='Configuration record UID (only required for some action types).') + help='Remediation action. rotate_credentials and remove_standing_privilege dispatch ' + 'to the gateway; manage_access/jit_access are frontend-only and rejected by krouter.') parser.add_argument('--resource-ref', required=False, dest='resource_ref', help='Resource reference UID for the action.') parser.add_argument('--pwd-complexity', required=False, dest='pwd_complexity', @@ -534,28 +532,54 @@ class PAMCnappQueueRemediateCommand(Command): help='Override gateway UID.') parser.add_argument('--message-uid', required=False, dest='message_uid', help='Client-generated conversation UID for streaming responses.') - parser.add_argument('--group-name', required=False, dest='group_name', - help='Group name (remove_standing_privilege only).') + parser.add_argument('--group', action='append', dest='group_names', + help='Group to remove the user from (remove_standing_privilege only). Repeatable.') + parser.add_argument('--role', action='append', dest='role_names', + help='Role to remove the user from (remove_standing_privilege only). Repeatable.') + parser.add_argument('--network-uid', '-n', required=False, dest='network_uid', + help='PAM configuration (network) record UID whose record key encrypts --group/--role ' + 'values. Required when --group/--role is given.') + parser.add_argument('--auto-remediate', action='store_true', dest='auto_remediate', + help='Register an auto-remediation rule for this item\'s control hash before rotating ' + '(rotate_credentials only; the queue item must carry a control hash).') def get_parser(self): return PAMCnappQueueRemediateCommand.parser def execute(self, params, **kwargs): action = cnapp_helper.action_from_name(kwargs.get('action_type')) - provider = None - if kwargs.get('provider'): - provider = cnapp_helper.provider_from_name(kwargs.get('provider')) + group_names = kwargs.get('group_names') or [] + role_names = kwargs.get('role_names') or [] + if kwargs.get('auto_remediate') and action != cnapp_helper.CnappRemediationAction.Value('ROTATE_CREDENTIALS'): + raise CommandError('pam cnapp queue remediate', + '--auto-remediate is only supported with rotate_credentials.') + is_rsp = action == cnapp_helper.CnappRemediationAction.Value('REMOVE_STANDING_PRIVILEGE') + if (group_names or role_names) and not is_rsp: + raise CommandError('pam cnapp queue remediate', + '--group/--role are only supported with remove_standing_privilege.') + encrypted_remediations = None + if group_names or role_names: + if not kwargs.get('network_uid'): + raise CommandError('pam cnapp queue remediate', + '--network-uid is required to encrypt --group/--role values.') + try: + encrypted_remediations = cnapp_helper.build_encrypted_remediations( + params, kwargs.get('network_uid'), group_names, role_names) + except ValueError as e: + raise CommandError('pam cnapp queue remediate', str(e)) + elif is_rsp: + print('No --group/--role supplied — the gateway will resolve the targets from ' + "the resource record's JIT settings.") response = cnapp_helper.remediate_cnapp_queue_item( params, cnapp_queue_id=kwargs.get('cnapp_queue_id'), action_type=action, - provider=provider, - cnapp_config_record_uid=kwargs.get('cnapp_config_record_uid'), resource_ref=kwargs.get('resource_ref'), pwd_complexity=kwargs.get('pwd_complexity'), controller_uid=kwargs.get('controller_uid'), message_uid=kwargs.get('message_uid'), - group_name=kwargs.get('group_name'), + encrypted_remediations=encrypted_remediations, + auto_remediate=bool(kwargs.get('auto_remediate')), ) if response is None: print(f"{bcolors.OKGREEN}Remediation dispatched.{bcolors.ENDC}") @@ -633,6 +657,7 @@ def _queue_item_to_dict(item): 'resolvedAt': item.resolvedAt, 'networkId': bytes_to_base64(item.networkId) if item.networkId else '', 'recordUid': bytes_to_base64(item.recordUid) if item.recordUid else '', + 'controlHash': item.controlHash, } diff --git a/keepercommander/commands/pam/cnapp_helper.py b/keepercommander/commands/pam/cnapp_helper.py index c1f0302d6..5b64a292f 100644 --- a/keepercommander/commands/pam/cnapp_helper.py +++ b/keepercommander/commands/pam/cnapp_helper.py @@ -34,10 +34,13 @@ HTTP/proto plumbing; callers convert them to user-readable output. """ -from typing import Optional +import json + +from typing import List, Optional from keeper_secrets_manager_core.utils import url_safe_str_to_bytes +from ... import crypto from ...params import KeeperParams from ...proto import cnapp_pb2 @@ -79,6 +82,24 @@ def _to_uid_bytes(uid): # type: (Optional[str]) -> bytes return url_safe_str_to_bytes(uid) +def build_encrypted_remediations(params, network_uid, group_names, role_names): + # type: (KeeperParams, str, Optional[List[str]], Optional[List[str]]) -> bytes + """Encrypt the REMOVE_STANDING_PRIVILEGE remediation params with the network record key. + + The gateway expects a JSON map `{"groupNames": [...], "roleNames": [...]}` encrypted + AES-256-GCM with the PAM configuration (network) record key; krouter relays the + ciphertext opaquely, so the group/role names never transit in the clear.""" + record = params.record_cache.get(network_uid) if params.record_cache else None + if not record or 'record_key_unencrypted' not in record: + raise ValueError(f'PAM configuration record "{network_uid}" not found in the local cache. ' + f'Run "sync-down" and verify the network record UID.') + plaintext = json.dumps({ + 'groupNames': list(group_names or []), + 'roleNames': list(role_names or []), + }).encode('utf-8') + return crypto.encrypt_aes_v2(plaintext, record['record_key_unencrypted']) + + def provider_from_name(name): # type: (str) -> int """Resolve a human-typed provider name (e.g. "wiz") to a CnappProvider enum value. @@ -214,23 +235,24 @@ def associate_cnapp_record(params, cnapp_queue_id, record_uid): return _post_request_to_router(params, 'cnapp/queue/associate', rq_proto=rq) -def remediate_cnapp_queue_item(params, cnapp_queue_id, action_type, provider=None, - cnapp_config_record_uid=None, resource_ref=None, - pwd_complexity=None, controller_uid=None, - message_uid=None, group_name=None): - # type: (KeeperParams, int, int, Optional[int], Optional[str], Optional[str], Optional[str], Optional[str], Optional[str], Optional[str]) -> cnapp_pb2.CnappRemediateResponse +def remediate_cnapp_queue_item(params, cnapp_queue_id, action_type, resource_ref=None, + pwd_complexity=None, controller_uid=None, message_uid=None, + encrypted_remediations=None, auto_remediate=False): + # type: (KeeperParams, int, int, Optional[str], Optional[str], Optional[str], Optional[str], Optional[bytes], bool) -> cnapp_pb2.CnappRemediateResponse """Trigger a remediation action against the gateway for a queued issue. - Currently krouter only dispatches `ROTATE_CREDENTIALS`; other actions return - RRC_BAD_REQUEST. The optional fields are forwarded as-is so this helper stays - forward-compatible with new action types.""" + krouter dispatches `ROTATE_CREDENTIALS` and `REMOVE_STANDING_PRIVILEGE`; other + actions return RRC_BAD_REQUEST. `encrypted_remediations` carries the + REMOVE_STANDING_PRIVILEGE group/role targets (see `build_encrypted_remediations`); + empty means the gateway falls back to the record's JIT settings. `auto_remediate` + registers an auto-remediation rule for the item's control hash — krouter accepts it + only with ROTATE_CREDENTIALS on items that carry a control hash. + + The proto's `provider` and `cnappConfigurationRecordUid` fields are deprecated + (krouter reads both from its own DB) and deliberately never sent.""" rq = cnapp_pb2.CnappRemediateRequest() rq.cnappQueueId = int(cnapp_queue_id) rq.actionType = int(action_type) - if provider is not None: - rq.provider = int(provider) - if cnapp_config_record_uid: - rq.cnappConfigurationRecordUid = _to_uid_bytes(cnapp_config_record_uid) if resource_ref: rq.resourceRef = _to_uid_bytes(resource_ref) if pwd_complexity: @@ -239,8 +261,9 @@ def remediate_cnapp_queue_item(params, cnapp_queue_id, action_type, provider=Non rq.controllerUid = controller_uid if message_uid: rq.messageUid = _to_uid_bytes(message_uid) - if group_name: - rq.groupName = group_name + if encrypted_remediations: + rq.encryptedRemediations = encrypted_remediations + rq.autoRemediateInFuture = bool(auto_remediate) return _post_request_to_router(params, 'cnapp/queue/remediate', rq_proto=rq, rs_type=cnapp_pb2.CnappRemediateResponse) diff --git a/keepercommander/proto/cnapp_pb2.py b/keepercommander/proto/cnapp_pb2.py index a146a3e0e..b5d7fb0be 100644 --- a/keepercommander/proto/cnapp_pb2.py +++ b/keepercommander/proto/cnapp_pb2.py @@ -15,7 +15,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0b\x63napp.proto\x12\x05\x43NAPP\"A\n\x15\x43nappQueueListRequest\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\x12\x14\n\x0cstatusFilter\x18\x02 \x01(\x05\"O\n\x16\x43nappQueueListResponse\x12$\n\x05items\x18\x01 \x03(\x0b\x32\x15.CNAPP.CnappQueueItem\x12\x0f\n\x07hasMore\x18\x02 \x01(\x08\"\xd0\x01\n\x0e\x43nappQueueItem\x12\x14\n\x0c\x63nappQueueId\x18\x01 \x01(\x05\x12-\n\x0f\x63nappProviderId\x18\x02 \x01(\x0e\x32\x14.CNAPP.CnappProvider\x12\x1a\n\x12\x63nappQueueStatusId\x18\x03 \x01(\x05\x12\x12\n\nreceivedAt\x18\x04 \x01(\x03\x12\x12\n\nresolvedAt\x18\x05 \x01(\x03\x12\x11\n\tnetworkId\x18\x06 \x01(\x0c\x12\x0f\n\x07payload\x18\x07 \x01(\x0c\x12\x11\n\trecordUid\x18\x08 \x01(\x0c\"@\n\x15\x43nappAssociateRequest\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x14\n\x0c\x63nappQueueId\x18\x02 \x01(\x05\"4\n\x16\x43nappAssociateResponse\x12\x1a\n\x12\x63nappQueueStatusId\x18\x01 \x01(\x05\"\x97\x02\n\x15\x43nappRemediateRequest\x12\x14\n\x0c\x63nappQueueId\x18\x01 \x01(\x05\x12\x31\n\nactionType\x18\x02 \x01(\x0e\x32\x1d.CNAPP.CnappRemediationAction\x12#\n\x1b\x63nappConfigurationRecordUid\x18\x03 \x01(\x0c\x12\x15\n\rpwdComplexity\x18\x04 \x01(\t\x12\x13\n\x0bresourceRef\x18\x05 \x01(\x0c\x12&\n\x08provider\x18\x06 \x01(\x0e\x32\x14.CNAPP.CnappProvider\x12\x15\n\rcontrollerUid\x18\x07 \x01(\t\x12\x12\n\nmessageUid\x18\x08 \x01(\x0c\x12\x11\n\tgroupName\x18\t \x01(\t\"w\n\x16\x43nappRemediateResponse\x12\x31\n\nactionType\x18\x01 \x01(\x0e\x32\x1d.CNAPP.CnappRemediationAction\x12\x0e\n\x06result\x18\x02 \x01(\t\x12\x1a\n\x12\x63nappQueueStatusId\x18\x03 \x01(\x05\"Y\n\x15\x43nappSetStatusRequest\x12\x14\n\x0c\x63nappQueueId\x18\x01 \x01(\x05\x12\x1a\n\x12\x63nappQueueStatusId\x18\x02 \x01(\x05\x12\x0e\n\x06reason\x18\x03 \x01(\t\"4\n\x16\x43nappSetStatusResponse\x12\x1a\n\x12\x63nappQueueStatusId\x18\x01 \x01(\x05\"3\n\x1b\x43nappDeleteQueueItemRequest\x12\x14\n\x0c\x63nappQueueId\x18\x01 \x01(\x05\"\x1e\n\x1c\x43nappDeleteQueueItemResponse\"\xc7\x01\n\x12\x43nappConfiguration\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\x12&\n\x08provider\x18\x02 \x01(\x0e\x32\x14.CNAPP.CnappProvider\x12\x10\n\x08\x63lientId\x18\x03 \x01(\t\x12\x14\n\x0c\x63lientSecret\x18\x04 \x01(\t\x12\x16\n\x0e\x61piEndpointUrl\x18\x05 \x01(\t\x12\x1c\n\x14\x63nappConfigRecordUid\x18\x06 \x01(\x0c\x12\x17\n\x0f\x61uthEndpointUrl\x18\x07 \x01(\t\"5\n\x1f\x43nappDeleteConfigurationRequest\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\"5\n\x19\x43nappTestEncrypterRequest\x12\x18\n\x10urlBaseEncrypter\x18\x01 \x01(\t*G\n\rCnappProvider\x12\x1e\n\x1a\x43NAPP_PROVIDER_UNSPECIFIED\x10\x00\x12\x16\n\x12\x43NAPP_PROVIDER_WIZ\x10\x01*\x83\x01\n\x16\x43nappRemediationAction\x12\x0f\n\x0bUNSPECIFIED\x10\x00\x12\x16\n\x12ROTATE_CREDENTIALS\x10\x01\x12\x11\n\rMANAGE_ACCESS\x10\x02\x12\x0e\n\nJIT_ACCESS\x10\x03\x12\x1d\n\x19REMOVE_STANDING_PRIVILEGE\x10\x04\x42!\n\x18\x63om.keepersecurity.protoB\x05\x43nappb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0b\x63napp.proto\x12\x05\x43NAPP\"A\n\x15\x43nappQueueListRequest\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\x12\x14\n\x0cstatusFilter\x18\x02 \x01(\x05\"O\n\x16\x43nappQueueListResponse\x12$\n\x05items\x18\x01 \x03(\x0b\x32\x15.CNAPP.CnappQueueItem\x12\x0f\n\x07hasMore\x18\x02 \x01(\x08\"\xe5\x01\n\x0e\x43nappQueueItem\x12\x14\n\x0c\x63nappQueueId\x18\x01 \x01(\x05\x12-\n\x0f\x63nappProviderId\x18\x02 \x01(\x0e\x32\x14.CNAPP.CnappProvider\x12\x1a\n\x12\x63nappQueueStatusId\x18\x03 \x01(\x05\x12\x12\n\nreceivedAt\x18\x04 \x01(\x03\x12\x12\n\nresolvedAt\x18\x05 \x01(\x03\x12\x11\n\tnetworkId\x18\x06 \x01(\x0c\x12\x0f\n\x07payload\x18\x07 \x01(\x0c\x12\x11\n\trecordUid\x18\x08 \x01(\x0c\x12\x13\n\x0b\x63ontrolHash\x18\t \x01(\t\"@\n\x15\x43nappAssociateRequest\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x14\n\x0c\x63nappQueueId\x18\x02 \x01(\x05\"4\n\x16\x43nappAssociateResponse\x12\x1a\n\x12\x63nappQueueStatusId\x18\x01 \x01(\x05\"\xca\x02\n\x15\x43nappRemediateRequest\x12\x14\n\x0c\x63nappQueueId\x18\x01 \x01(\x05\x12\x31\n\nactionType\x18\x02 \x01(\x0e\x32\x1d.CNAPP.CnappRemediationAction\x12\'\n\x1b\x63nappConfigurationRecordUid\x18\x03 \x01(\x0c\x42\x02\x18\x01\x12\x15\n\rpwdComplexity\x18\x04 \x01(\t\x12\x13\n\x0bresourceRef\x18\x05 \x01(\x0c\x12*\n\x08provider\x18\x06 \x01(\x0e\x32\x14.CNAPP.CnappProviderB\x02\x18\x01\x12\x15\n\rcontrollerUid\x18\x07 \x01(\t\x12\x12\n\nmessageUid\x18\x08 \x01(\x0c\x12\x1d\n\x15\x65ncryptedRemediations\x18\t \x01(\x0c\x12\x1d\n\x15\x61utoRemediateInFuture\x18\n \x01(\x08\"w\n\x16\x43nappRemediateResponse\x12\x31\n\nactionType\x18\x01 \x01(\x0e\x32\x1d.CNAPP.CnappRemediationAction\x12\x0e\n\x06result\x18\x02 \x01(\t\x12\x1a\n\x12\x63nappQueueStatusId\x18\x03 \x01(\x05\"Y\n\x15\x43nappSetStatusRequest\x12\x14\n\x0c\x63nappQueueId\x18\x01 \x01(\x05\x12\x1a\n\x12\x63nappQueueStatusId\x18\x02 \x01(\x05\x12\x0e\n\x06reason\x18\x03 \x01(\t\"4\n\x16\x43nappSetStatusResponse\x12\x1a\n\x12\x63nappQueueStatusId\x18\x01 \x01(\x05\"3\n\x1b\x43nappDeleteQueueItemRequest\x12\x14\n\x0c\x63nappQueueId\x18\x01 \x01(\x05\"\x1e\n\x1c\x43nappDeleteQueueItemResponse\"\xc7\x01\n\x12\x43nappConfiguration\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\x12&\n\x08provider\x18\x02 \x01(\x0e\x32\x14.CNAPP.CnappProvider\x12\x10\n\x08\x63lientId\x18\x03 \x01(\t\x12\x14\n\x0c\x63lientSecret\x18\x04 \x01(\t\x12\x16\n\x0e\x61piEndpointUrl\x18\x05 \x01(\t\x12\x1c\n\x14\x63nappConfigRecordUid\x18\x06 \x01(\x0c\x12\x17\n\x0f\x61uthEndpointUrl\x18\x07 \x01(\t\"5\n\x1f\x43nappDeleteConfigurationRequest\x12\x12\n\nnetworkUid\x18\x01 \x01(\x0c\"5\n\x19\x43nappTestEncrypterRequest\x12\x18\n\x10urlBaseEncrypter\x18\x01 \x01(\t*G\n\rCnappProvider\x12\x1e\n\x1a\x43NAPP_PROVIDER_UNSPECIFIED\x10\x00\x12\x16\n\x12\x43NAPP_PROVIDER_WIZ\x10\x01*\x83\x01\n\x16\x43nappRemediationAction\x12\x0f\n\x0bUNSPECIFIED\x10\x00\x12\x16\n\x12ROTATE_CREDENTIALS\x10\x01\x12\x11\n\rMANAGE_ACCESS\x10\x02\x12\x0e\n\nJIT_ACCESS\x10\x03\x12\x1d\n\x19REMOVE_STANDING_PRIVILEGE\x10\x04\x42!\n\x18\x63om.keepersecurity.protoB\x05\x43nappb\x06proto3') _CNAPPPROVIDER = DESCRIPTOR.enum_types_by_name['CnappProvider'] CnappProvider = enum_type_wrapper.EnumTypeWrapper(_CNAPPPROVIDER) @@ -146,36 +146,40 @@ DESCRIPTOR._options = None DESCRIPTOR._serialized_options = b'\n\030com.keepersecurity.protoB\005Cnapp' - _CNAPPPROVIDER._serialized_start=1446 - _CNAPPPROVIDER._serialized_end=1517 - _CNAPPREMEDIATIONACTION._serialized_start=1520 - _CNAPPREMEDIATIONACTION._serialized_end=1651 + _CNAPPREMEDIATEREQUEST.fields_by_name['cnappConfigurationRecordUid']._options = None + _CNAPPREMEDIATEREQUEST.fields_by_name['cnappConfigurationRecordUid']._serialized_options = b'\030\001' + _CNAPPREMEDIATEREQUEST.fields_by_name['provider']._options = None + _CNAPPREMEDIATEREQUEST.fields_by_name['provider']._serialized_options = b'\030\001' + _CNAPPPROVIDER._serialized_start=1518 + _CNAPPPROVIDER._serialized_end=1589 + _CNAPPREMEDIATIONACTION._serialized_start=1592 + _CNAPPREMEDIATIONACTION._serialized_end=1723 _CNAPPQUEUELISTREQUEST._serialized_start=22 _CNAPPQUEUELISTREQUEST._serialized_end=87 _CNAPPQUEUELISTRESPONSE._serialized_start=89 _CNAPPQUEUELISTRESPONSE._serialized_end=168 _CNAPPQUEUEITEM._serialized_start=171 - _CNAPPQUEUEITEM._serialized_end=379 - _CNAPPASSOCIATEREQUEST._serialized_start=381 - _CNAPPASSOCIATEREQUEST._serialized_end=445 - _CNAPPASSOCIATERESPONSE._serialized_start=447 - _CNAPPASSOCIATERESPONSE._serialized_end=499 - _CNAPPREMEDIATEREQUEST._serialized_start=502 - _CNAPPREMEDIATEREQUEST._serialized_end=781 - _CNAPPREMEDIATERESPONSE._serialized_start=783 - _CNAPPREMEDIATERESPONSE._serialized_end=902 - _CNAPPSETSTATUSREQUEST._serialized_start=904 - _CNAPPSETSTATUSREQUEST._serialized_end=993 - _CNAPPSETSTATUSRESPONSE._serialized_start=995 - _CNAPPSETSTATUSRESPONSE._serialized_end=1047 - _CNAPPDELETEQUEUEITEMREQUEST._serialized_start=1049 - _CNAPPDELETEQUEUEITEMREQUEST._serialized_end=1100 - _CNAPPDELETEQUEUEITEMRESPONSE._serialized_start=1102 - _CNAPPDELETEQUEUEITEMRESPONSE._serialized_end=1132 - _CNAPPCONFIGURATION._serialized_start=1135 - _CNAPPCONFIGURATION._serialized_end=1334 - _CNAPPDELETECONFIGURATIONREQUEST._serialized_start=1336 - _CNAPPDELETECONFIGURATIONREQUEST._serialized_end=1389 - _CNAPPTESTENCRYPTERREQUEST._serialized_start=1391 - _CNAPPTESTENCRYPTERREQUEST._serialized_end=1444 + _CNAPPQUEUEITEM._serialized_end=400 + _CNAPPASSOCIATEREQUEST._serialized_start=402 + _CNAPPASSOCIATEREQUEST._serialized_end=466 + _CNAPPASSOCIATERESPONSE._serialized_start=468 + _CNAPPASSOCIATERESPONSE._serialized_end=520 + _CNAPPREMEDIATEREQUEST._serialized_start=523 + _CNAPPREMEDIATEREQUEST._serialized_end=853 + _CNAPPREMEDIATERESPONSE._serialized_start=855 + _CNAPPREMEDIATERESPONSE._serialized_end=974 + _CNAPPSETSTATUSREQUEST._serialized_start=976 + _CNAPPSETSTATUSREQUEST._serialized_end=1065 + _CNAPPSETSTATUSRESPONSE._serialized_start=1067 + _CNAPPSETSTATUSRESPONSE._serialized_end=1119 + _CNAPPDELETEQUEUEITEMREQUEST._serialized_start=1121 + _CNAPPDELETEQUEUEITEMREQUEST._serialized_end=1172 + _CNAPPDELETEQUEUEITEMRESPONSE._serialized_start=1174 + _CNAPPDELETEQUEUEITEMRESPONSE._serialized_end=1204 + _CNAPPCONFIGURATION._serialized_start=1207 + _CNAPPCONFIGURATION._serialized_end=1406 + _CNAPPDELETECONFIGURATIONREQUEST._serialized_start=1408 + _CNAPPDELETECONFIGURATIONREQUEST._serialized_end=1461 + _CNAPPTESTENCRYPTERREQUEST._serialized_start=1463 + _CNAPPTESTENCRYPTERREQUEST._serialized_end=1516 # @@protoc_insertion_point(module_scope) diff --git a/unit-tests/pam/test_cnapp.py b/unit-tests/pam/test_cnapp.py index 8d8421710..4848ee5c3 100644 --- a/unit-tests/pam/test_cnapp.py +++ b/unit-tests/pam/test_cnapp.py @@ -23,6 +23,7 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM # noqa: E402 from keeper_secrets_manager_core.utils import bytes_to_base64 # noqa: E402 +from keepercommander import crypto # noqa: E402 from keepercommander.commands.pam import cnapp_helper # noqa: E402 from keepercommander.commands.pam import cnapp_commands # noqa: E402 from keepercommander.error import CommandError # noqa: E402 @@ -222,23 +223,25 @@ def test_remediate_forwards_optional_fields(self): cnapp_helper.remediate_cnapp_queue_item( self.params, cnapp_queue_id=3, - action_type=cnapp_pb2.ROTATE_CREDENTIALS, - provider=cnapp_pb2.CNAPP_PROVIDER_WIZ, - cnapp_config_record_uid=CONFIG_RECORD_UID, + action_type=cnapp_pb2.REMOVE_STANDING_PRIVILEGE, resource_ref=RECORD_UID, pwd_complexity='{"len":24}', controller_uid='gateway-1', message_uid=RECORD_UID, - group_name='Admins', + encrypted_remediations=b'\x99' * 40, + auto_remediate=True, ) rq = post.call_args.kwargs['rq_proto'] self.assertEqual(post.call_args.args[1], 'cnapp/queue/remediate') self.assertEqual(rq.cnappQueueId, 3) - self.assertEqual(rq.actionType, cnapp_pb2.ROTATE_CREDENTIALS) - self.assertEqual(rq.provider, cnapp_pb2.CNAPP_PROVIDER_WIZ) + self.assertEqual(rq.actionType, cnapp_pb2.REMOVE_STANDING_PRIVILEGE) self.assertEqual(rq.pwdComplexity, '{"len":24}') self.assertEqual(rq.controllerUid, 'gateway-1') - self.assertEqual(rq.groupName, 'Admins') + self.assertEqual(rq.encryptedRemediations, b'\x99' * 40) + self.assertTrue(rq.autoRemediateInFuture) + # deprecated fields must never go on the wire — krouter reads them from its DB + self.assertEqual(rq.provider, 0) + self.assertEqual(rq.cnappConfigurationRecordUid, b'') def test_remediate_minimal_fields(self): """No optional fields — only queueId and actionType must be set on the wire.""" @@ -250,7 +253,8 @@ def test_remediate_minimal_fields(self): self.assertEqual(rq.provider, 0) self.assertEqual(rq.pwdComplexity, '') self.assertEqual(rq.controllerUid, '') - self.assertEqual(rq.groupName, '') + self.assertEqual(rq.encryptedRemediations, b'') + self.assertFalse(rq.autoRemediateInFuture) def test_set_status_with_reason(self): with self._patch_post(return_value=cnapp_pb2.CnappSetStatusResponse(cnappQueueStatusId=3)) as post: @@ -267,6 +271,41 @@ def test_delete_queue_item_dispatches(self): self.assertEqual(post.call_args.kwargs['rq_proto'].cnappQueueId, 11) +# --------------------------------------------------------------------------- +# cnapp_helper: REMOVE_STANDING_PRIVILEGE remediation encryption +# --------------------------------------------------------------------------- + +class TestBuildEncryptedRemediations(unittest.TestCase): + """The group/role targets must round-trip through AES-256-GCM under the network + record key — the same convention the gateway uses to decrypt them.""" + + def setUp(self): + self.record_key = os.urandom(32) + self.params = MagicMock() + self.params.record_cache = {NETWORK_UID: {'record_key_unencrypted': self.record_key}} + + def test_roundtrip(self): + ciphertext = cnapp_helper.build_encrypted_remediations( + self.params, NETWORK_UID, ['Admins', 'DBAs'], ['db-owner']) + decrypted = json.loads(crypto.decrypt_aes_v2(ciphertext, self.record_key)) + self.assertEqual(decrypted, {'groupNames': ['Admins', 'DBAs'], 'roleNames': ['db-owner']}) + + def test_empty_lists_encode_as_empty_arrays(self): + ciphertext = cnapp_helper.build_encrypted_remediations(self.params, NETWORK_UID, None, None) + decrypted = json.loads(crypto.decrypt_aes_v2(ciphertext, self.record_key)) + self.assertEqual(decrypted, {'groupNames': [], 'roleNames': []}) + + def test_missing_record_raises_with_sync_hint(self): + with self.assertRaises(ValueError) as ctx: + cnapp_helper.build_encrypted_remediations(self.params, 'unknown-uid', ['Admins'], []) + self.assertIn('sync-down', str(ctx.exception)) + + def test_empty_record_cache_raises(self): + self.params.record_cache = {} + with self.assertRaises(ValueError): + cnapp_helper.build_encrypted_remediations(self.params, NETWORK_UID, ['Admins'], []) + + # --------------------------------------------------------------------------- # cnapp_helper: error propagation # --------------------------------------------------------------------------- @@ -495,7 +534,7 @@ def setUp(self): def _queue_response(self, items=None, has_more=False): return cnapp_pb2.CnappQueueListResponse(items=items or [], hasMore=has_more) - def _queue_item(self, queue_id=1, status_id=1, record_uid=b''): + def _queue_item(self, queue_id=1, status_id=1, record_uid=b'', control_hash='ch-abc'): return cnapp_pb2.CnappQueueItem( cnappQueueId=queue_id, cnappProviderId=cnapp_pb2.CNAPP_PROVIDER_WIZ, @@ -503,6 +542,7 @@ def _queue_item(self, queue_id=1, status_id=1, record_uid=b''): receivedAt=1700000000000, networkId=b'\x00' * 16, recordUid=record_uid, + controlHash=control_hash, ) def test_queue_list_empty(self): @@ -529,6 +569,7 @@ def test_queue_list_with_items_table(self): self.assertIn('99', output) self.assertIn('IN_PROGRESS', output) self.assertIn('CNAPP_PROVIDER_WIZ', output) + self.assertIn('ch-abc', output) self.assertIsNone(result) def test_queue_list_filter_resolves_named_status(self): @@ -552,6 +593,7 @@ def test_queue_list_json_format(self): payload = json.loads(buf.getvalue()) self.assertEqual(payload['items'][0]['cnappQueueId'], 5) self.assertEqual(payload['items'][0]['cnappQueueStatusName'], 'RESOLVED') + self.assertEqual(payload['items'][0]['controlHash'], 'ch-abc') self.assertTrue(payload['hasMore']) self.assertEqual(payload['items'][0]['recordUid'], bytes_to_base64(b'\x02' * 16)) @@ -593,13 +635,101 @@ def test_queue_remediate_prints_response(self): self.params, cnapp_queue_id=4, action_type='rotate_credentials', - provider='wiz', ) output = buf.getvalue() self.assertIn('ROTATE_CREDENTIALS', output) self.assertIn('IN_PROGRESS', output) self.assertIn('Scheduled', output) + def test_queue_remediate_auto_remediate_with_rotate(self): + with patch.object(cnapp_commands.cnapp_helper, 'remediate_cnapp_queue_item', + return_value=None) as helper: + with redirect_stdout(io.StringIO()): + cnapp_commands.PAMCnappQueueRemediateCommand().execute( + self.params, + cnapp_queue_id=4, + action_type='rotate_credentials', + auto_remediate=True, + ) + self.assertTrue(helper.call_args.kwargs['auto_remediate']) + + def test_queue_remediate_auto_remediate_rejected_for_other_actions(self): + with patch.object(cnapp_commands.cnapp_helper, 'remediate_cnapp_queue_item') as helper: + with self.assertRaises(CommandError): + cnapp_commands.PAMCnappQueueRemediateCommand().execute( + self.params, + cnapp_queue_id=4, + action_type='remove_standing_privilege', + auto_remediate=True, + ) + helper.assert_not_called() + + def test_queue_remediate_groups_rejected_for_rotate(self): + with patch.object(cnapp_commands.cnapp_helper, 'remediate_cnapp_queue_item') as helper: + with self.assertRaises(CommandError): + cnapp_commands.PAMCnappQueueRemediateCommand().execute( + self.params, + cnapp_queue_id=4, + action_type='rotate_credentials', + group_names=['Admins'], + ) + helper.assert_not_called() + + def test_queue_remediate_groups_require_network_uid(self): + with patch.object(cnapp_commands.cnapp_helper, 'remediate_cnapp_queue_item') as helper: + with self.assertRaises(CommandError): + cnapp_commands.PAMCnappQueueRemediateCommand().execute( + self.params, + cnapp_queue_id=4, + action_type='remove_standing_privilege', + group_names=['Admins'], + ) + helper.assert_not_called() + + def test_queue_remediate_rsp_encrypts_groups_and_roles(self): + sentinel = b'\xaa' * 44 + with patch.object(cnapp_commands.cnapp_helper, 'remediate_cnapp_queue_item', + return_value=None) as helper, \ + patch.object(cnapp_commands.cnapp_helper, 'build_encrypted_remediations', + return_value=sentinel) as builder: + with redirect_stdout(io.StringIO()): + cnapp_commands.PAMCnappQueueRemediateCommand().execute( + self.params, + cnapp_queue_id=4, + action_type='remove_standing_privilege', + network_uid=NETWORK_UID, + group_names=['Admins'], + role_names=['db-owner'], + ) + builder.assert_called_once_with(self.params, NETWORK_UID, ['Admins'], ['db-owner']) + self.assertEqual(helper.call_args.kwargs['encrypted_remediations'], sentinel) + + def test_queue_remediate_rsp_without_groups_notes_jit_fallback(self): + with patch.object(cnapp_commands.cnapp_helper, 'remediate_cnapp_queue_item', + return_value=None) as helper: + buf = io.StringIO() + with redirect_stdout(buf): + cnapp_commands.PAMCnappQueueRemediateCommand().execute( + self.params, + cnapp_queue_id=4, + action_type='remove_standing_privilege', + ) + self.assertIn('JIT settings', buf.getvalue()) + self.assertIsNone(helper.call_args.kwargs['encrypted_remediations']) + + def test_queue_remediate_missing_network_record_surfaces_command_error(self): + with patch.object(cnapp_commands.cnapp_helper, 'build_encrypted_remediations', + side_effect=ValueError('PAM configuration record "x" not found')): + with self.assertRaises(CommandError) as ctx: + cnapp_commands.PAMCnappQueueRemediateCommand().execute( + self.params, + cnapp_queue_id=4, + action_type='remove_standing_privilege', + network_uid=NETWORK_UID, + group_names=['Admins'], + ) + self.assertIn('not found', str(ctx.exception)) + def test_queue_remediate_unsupported_action_propagates(self): with patch.object(cnapp_commands.cnapp_helper, 'remediate_cnapp_queue_item', side_effect=Exception('Unsupported action type response code: RRC_BAD_REQUEST')):