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 01/19] 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 02/19] 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 03/19] 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 04/19] 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 05/19] 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 06/19] 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 07/19] 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 08/19] 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 2e6ea6d3bdc4db82356c4d264c207658ac6ec47f Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Tue, 14 Jul 2026 20:33:38 +0530 Subject: [PATCH 09/19] Return record UID from record-add with self-destruct and add --format=json (#2198) --- keepercommander/commands/record_edit.py | 22 +++++++-- keepercommander/service/api/onboarding.py | 7 ++- keepercommander/service/util/command_util.py | 4 +- .../service/util/parse_keeper_response.py | 23 +++++++-- unit-tests/service/test_response_parser.py | 47 ++++++++++++++++++- unit-tests/test_command_record.py | 45 ++++++++++++++++++ 6 files changed, 135 insertions(+), 13 deletions(-) diff --git a/keepercommander/commands/record_edit.py b/keepercommander/commands/record_edit.py index 6836a853a..2f04e7136 100644 --- a/keepercommander/commands/record_edit.py +++ b/keepercommander/commands/record_edit.py @@ -60,6 +60,8 @@ help='Email configuration name to use for sending (required with --send-email)') record_add_parser.add_argument('--email-message', dest='email_message', action='store', help='Custom message to include in onboarding email') +record_add_parser.add_argument('--format', dest='format', action='store', choices=['text', 'json'], + default='text', help='output format') record_add_parser.add_argument('fields', nargs='*', type=str, help='load record type data from strings with dot notation') @@ -846,6 +848,18 @@ class RecordAddCommand(Command, RecordEditMixin): def __init__(self): super(RecordAddCommand, self).__init__() + @staticmethod + def _format_add_result(record_uid: str, share_url: Optional[str] = None, + output_format: str = 'text') -> str: + data = {'record_uid': record_uid} + if share_url: + data['share_url'] = share_url + if output_format == 'json': + return json.dumps(data) + if share_url: + return f'{record_uid}\n{share_url}' + return record_uid + def get_parser(self): return record_add_parser @@ -1035,11 +1049,11 @@ def execute(self, params, **kwargs): logging.warning(f'[EMAIL] Failed to send onboarding email: {e}') # Best-effort: continue and return share URL + output_format = kwargs.get('format') or 'text' if share_url: - return share_url - else: - BreachWatch.scan_and_update_security_data(params, record.record_uid, params.breach_watch) - return record.record_uid + return self._format_add_result(record.record_uid, share_url, output_format) + BreachWatch.scan_and_update_security_data(params, record.record_uid, params.breach_watch) + return self._format_add_result(record.record_uid, None, output_format) def _sync_password_to_pam(self, params: KeeperParams, record: vault.TypedRecord, pam_config_name: str): """ diff --git a/keepercommander/service/api/onboarding.py b/keepercommander/service/api/onboarding.py index aefa88c95..5ad955ba2 100644 --- a/keepercommander/service/api/onboarding.py +++ b/keepercommander/service/api/onboarding.py @@ -17,6 +17,7 @@ from flask import Blueprint, request, jsonify, Response from typing import Tuple, Union +import json import logging from ... import api @@ -433,16 +434,18 @@ def onboard_employee(**kwargs) -> Tuple[Response, int]: 'send_email': send_email, 'email_config': email_config, 'email_message': data.get('email_message'), + 'format': 'json', 'force': True } # Execute record-add command cmd = RecordAddCommand() - share_url = cmd.execute(params, **cmd_kwargs) + result = json.loads(cmd.execute(params, **cmd_kwargs)) return jsonify({ "success": True, - "share_url": share_url, + "record_uid": result.get('record_uid'), + "share_url": result.get('share_url'), "message": f"Employee onboarded successfully" }), 201 diff --git a/keepercommander/service/util/command_util.py b/keepercommander/service/util/command_util.py index 7cac25d3d..35434b697 100644 --- a/keepercommander/service/util/command_util.py +++ b/keepercommander/service/util/command_util.py @@ -16,7 +16,7 @@ from typing import Any, Tuple, Optional from .config_reader import ConfigReader from .exceptions import CommandExecutionError -from .parse_keeper_response import parse_keeper_response +from .parse_keeper_response import parse_keeper_response, ensure_record_add_json_format from ..core.globals import get_current_params from ..decorators.logging import logger, debug_decorator, sanitize_debug_data from ... import cli, utils @@ -129,7 +129,7 @@ def execute(cls, command: str) -> Tuple[Any, int]: if params: params.service_mode = True - command = html.unescape(command) + command = ensure_record_add_json_format(html.unescape(command)) return_value, printed_output, log_output = CommandExecutor.capture_output_and_logs(params, command) response = return_value if return_value else printed_output diff --git a/keepercommander/service/util/parse_keeper_response.py b/keepercommander/service/util/parse_keeper_response.py index aae9159d7..bacd347c0 100644 --- a/keepercommander/service/util/parse_keeper_response.py +++ b/keepercommander/service/util/parse_keeper_response.py @@ -610,13 +610,20 @@ def _parse_record_add_command(response: str) -> Dict[str, Any]: "command": "record-add", "data": None } - - # Check if response is a share URL (starts with https:// and contains /vault/share) - if response_str.startswith('https://') and '/vault/share' in response_str: + + lines = [line.strip() for line in response_str.splitlines() if line.strip()] + if (len(lines) == 2 and lines[1].startswith('https://') + and '/vault/share' in lines[1]): + result["data"] = { + "record_uid": lines[0], + "share_url": lines[1] + } + # Check if response is a share URL only (legacy single-line output) + elif response_str.startswith('https://') and '/vault/share' in response_str: result["data"] = { "share_url": response_str } - elif re.match(r'^[a-zA-Z0-9_-]+$', response_str): + elif re.match(r'^[A-Za-z0-9_+/=-]+$', response_str): result["data"] = { "record_uid": response_str } @@ -1213,6 +1220,14 @@ def _filter_login_messages(response_str: str) -> str: +def ensure_record_add_json_format(command: str) -> str: + if not command.strip().startswith('record-add'): + return command + if '--format=json' in command or '--format json' in command: + return command + return f'{command} --format=json' + + def parse_keeper_response(command: str, response: Any, log_output: str = None) -> Dict[str, Any]: """ Main entry point for parsing Keeper Commander responses. diff --git a/unit-tests/service/test_response_parser.py b/unit-tests/service/test_response_parser.py index 4b389eb9a..8512c1a98 100644 --- a/unit-tests/service/test_response_parser.py +++ b/unit-tests/service/test_response_parser.py @@ -1,7 +1,22 @@ from unittest import TestCase -from keepercommander.service.util.parse_keeper_response import KeeperResponseParser +import json +from keepercommander.service.util.parse_keeper_response import KeeperResponseParser, ensure_record_add_json_format class TestKeeperResponseParser(TestCase): + def test_ensure_record_add_json_format(self): + self.assertEqual( + ensure_record_add_json_format('record-add -t test -rt login'), + 'record-add -t test -rt login --format=json', + ) + self.assertEqual( + ensure_record_add_json_format('record-add -t test --format=json'), + 'record-add -t test --format=json', + ) + self.assertEqual( + ensure_record_add_json_format('ls'), + 'ls', + ) + def test_parse_ls_command(self): """Test parsing of 'ls' command output""" sample_output = """# Folder UID Title Flags @@ -78,6 +93,36 @@ def test_parse_mkdir_command(self): result = KeeperResponseParser._parse_mkdir_command('Created folder with folder_uid=b4pBzT1WowoUXHk_US0SCg') self.assertEqual(result['data']['folder_uid'], 'b4pBzT1WowoUXHk_US0SCg') + def test_parse_record_add_command_two_line_output(self): + """record-add --self-destruct returns record_uid on line 1 and share_url on line 2""" + record_uid = 'riK9X5/XcxGPWRYM2Be1Ow==' + share_url = 'https://keepersecurity.com/vault/share#abc123' + response = f'{record_uid}\n{share_url}' + + result = KeeperResponseParser._parse_record_add_command(response) + self.assertEqual(result['data']['record_uid'], record_uid) + self.assertEqual(result['data']['share_url'], share_url) + + def test_parse_record_add_command_json_format(self): + """record-add --format=json is parsed as structured JSON""" + record_uid = 'riK9X5/XcxGPWRYM2Be1Ow==' + share_url = 'https://keepersecurity.com/vault/share#abc123' + response = json.dumps({'record_uid': record_uid, 'share_url': share_url}) + + result = KeeperResponseParser.parse_response( + 'record-add --self-destruct 2mi --format=json', + response, + ) + self.assertEqual(result['status'], 'success') + self.assertEqual(result['data']['record_uid'], record_uid) + self.assertEqual(result['data']['share_url'], share_url) + + def test_parse_record_add_command_uid_only(self): + """Normal record-add returns bare record_uid""" + record_uid = 'riK9X5/XcxGPWRYM2Be1Ow==' + result = KeeperResponseParser._parse_record_add_command(record_uid) + self.assertEqual(result['data']['record_uid'], record_uid) + def test_parse_get_command(self): """Test parsing of 'get' command output""" sample_output = """Title: Test Record diff --git a/unit-tests/test_command_record.py b/unit-tests/test_command_record.py index 834a4bbfb..d21e3927f 100644 --- a/unit-tests/test_command_record.py +++ b/unit-tests/test_command_record.py @@ -117,6 +117,51 @@ def artf(p, r, f): {"$ref": "script", "label": "rotationScripts"}, # real RT-definition label ] + def test_format_add_result(self): + record_uid = 'riK9X5/XcxGPWRYM2Be1Ow==' + share_url = 'https://keepersecurity.com/vault/share#abc123' + + self.assertEqual( + record_edit.RecordAddCommand._format_add_result(record_uid), + record_uid, + ) + self.assertEqual( + record_edit.RecordAddCommand._format_add_result(record_uid, share_url), + f'{record_uid}\n{share_url}', + ) + parsed = json.loads(record_edit.RecordAddCommand._format_add_result(record_uid, share_url, 'json')) + self.assertEqual(parsed['record_uid'], record_uid) + self.assertEqual(parsed['share_url'], share_url) + parsed = json.loads(record_edit.RecordAddCommand._format_add_result(record_uid, None, 'json')) + self.assertEqual(parsed, {'record_uid': record_uid}) + + def test_add_command_self_destruct_output(self): + params = get_synced_params() + cmd = record_edit.RecordAddCommand() + record_uid = utils.generate_uid() + share_url = 'https://keepersecurity.com/vault/share#abc123' + + with mock.patch('keepercommander.api.sync_down'), \ + mock.patch('keepercommander.record_management.add_record_to_folder') as ar, \ + mock.patch('keepercommander.api.communicate_rest'), \ + mock.patch('keepercommander.commands.record_edit.urlunparse', return_value=share_url): + def artf(p, r, f): + r.record_uid = record_uid + ar.side_effect = artf + + text_result = cmd.execute( + params, force=True, title='Test', record_type='login', + self_destruct='2mi', fields=['login=u', 'password=p'], + ) + self.assertEqual(text_result, f'{record_uid}\n{share_url}') + + json_result = json.loads(cmd.execute( + params, force=True, title='Test', record_type='login', + self_destruct='2mi', fields=['login=u', 'password=p'], format='json', + )) + self.assertEqual(json_result['record_uid'], record_uid) + self.assertEqual(json_result['share_url'], share_url) + def test_add_command_labels_default_is_legacy(self): # No --labels (and explicit --labels=on): fields with no label in the RT definition fall # back to the field type as the label; real definition labels are kept. From 7f9b20923c5a1f65f5dc4ed1f7b297e99e67b126 Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Tue, 14 Jul 2026 11:16:02 -0700 Subject: [PATCH 10/19] whoami command fails for trial enterprise --- keepercommander/commands/utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/keepercommander/commands/utils.py b/keepercommander/commands/utils.py index cf5f2b336..4badcd21e 100644 --- a/keepercommander/commands/utils.py +++ b/keepercommander/commands/utils.py @@ -1554,6 +1554,8 @@ def yes_no(val): expires += f' ({Fore.RED}expired{Fore.RESET})' print(label_value('Expires', expires)) seats_plan = x.get("number_of_seats", "") + if isinstance(seats_plan, int) and seats_plan >= 2147483647: + seats_plan = "Unlimited" seats_active = x.get("seats_allocated", "") seats_invited = x.get("seats_pending", "") print(label_value('User Licenses', f'Plan: {seats_plan} Active: {seats_active} Invited: {seats_invited}')) @@ -1562,7 +1564,7 @@ def yes_no(val): print(label_value('Secure File Storage', file_plan_lookup.get(file_plan, ''))) addons = [] addon_lookup = {a[0]: a[1] for a in constants.MSP_ADDONS} - for ao in x.get('add_ons'): + for ao in x.get('add_ons') or []: if isinstance(ao, dict): enabled = ao.get('enabled') is True if enabled: From b996eb33e650f73b26ae8634972f27609e854ae3 Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Wed, 15 Jul 2026 23:57:35 +0530 Subject: [PATCH 11/19] NSF folder and record support in PAM Commands (#2216) --- keepercommander/commands/base.py | 5 + .../commands/credential_provision.py | 107 +-- keepercommander/commands/discoveryrotation.py | 437 ++++++---- .../commands/discoveryrotation_v1.py | 7 +- keepercommander/commands/folder.py | 10 +- keepercommander/commands/ksm.py | 489 +++++++++--- .../nested_share_folder/folder_commands.py | 38 +- .../commands/nested_share_folder/helpers.py | 29 +- .../commands/nested_share_folder/parsers.py | 16 + .../nested_share_folder/record_commands.py | 7 +- .../nested_share_folder/sharing_commands.py | 38 +- keepercommander/commands/pam/config_helper.py | 118 ++- keepercommander/commands/pam/vault_target.py | 680 ++++++++++++++++ .../pam_cloud/pam_privileged_access.py | 20 +- keepercommander/commands/pam_import/README.md | 3 +- keepercommander/commands/pam_import/base.py | 14 +- keepercommander/commands/pam_import/edit.py | 154 +++- keepercommander/commands/pam_import/export.py | 12 +- keepercommander/commands/pam_import/extend.py | 197 +++-- .../commands/pam_import/nsf_helpers.py | 311 ++++++++ .../commands/pam_import/record_loader.py | 67 ++ .../commands/tunnel_and_connections.py | 18 +- .../nested_share_folder/__init__.py | 7 +- .../nested_share_folder/folder_api.py | 135 +++- .../nested_share_folder/folder_record_api.py | 3 +- .../nested_share_folder/record_api.py | 155 +++- keepercommander/nested_share_folder/sync.py | 9 + keepercommander/proto/AccountSummary_pb2.py | 4 +- keepercommander/proto/AccountSummary_pb2.pyi | 22 +- keepercommander/proto/GraphSync_pb2.pyi | 2 +- keepercommander/proto/SyncDown_pb2.pyi | 32 +- keepercommander/proto/dag_pb2.pyi | 2 +- keepercommander/proto/enterprise_pb2.py | 753 +++++++++--------- keepercommander/proto/enterprise_pb2.pyi | 161 ++-- keepercommander/proto/folder_access_pb2.pyi | 2 +- keepercommander/proto/folder_pb2.py | 104 +-- keepercommander/proto/folder_pb2.pyi | 24 +- keepercommander/proto/pagination_pb2.pyi | 2 +- keepercommander/proto/record_endpoints_pb2.py | 4 +- .../proto/record_endpoints_pb2.pyi | 6 +- keepercommander/proto/record_pb2.py | 312 ++++---- keepercommander/proto/record_pb2.pyi | 32 +- keepercommander/proto/record_sharing_pb2.pyi | 4 +- keepercommander/proto/remove_pb2.py | 42 +- keepercommander/proto/remove_pb2.pyi | 74 ++ keepercommander/proto/tla_pb2.pyi | 2 +- keepercommander/sync_down.py | 16 +- keepercommander/vault_extensions.py | 63 +- tests/test_pam_privileged_cloud.py | 3 +- unit-tests/pam/test_pam_nsf_config.py | 509 ++++++++++++ unit-tests/pam/test_pam_project_export.py | 100 ++- unit-tests/pam/test_pam_project_extend_nsf.py | 112 +++ .../test_pam_project_extend_nsf_helpers.py | 114 +++ unit-tests/pam/test_pam_project_import_nsf.py | 330 ++++++++ unit-tests/pam/test_pam_provision_nsf.py | 137 ++++ unit-tests/pam/test_pam_rotation.py | 242 +++++- unit-tests/pam/test_pam_split_nsf.py | 117 +++ unit-tests/test_command_ksm.py | 165 ++++ unit-tests/test_nested_share_folder.py | 182 ++++- 59 files changed, 5442 insertions(+), 1318 deletions(-) create mode 100644 keepercommander/commands/pam/vault_target.py create mode 100644 keepercommander/commands/pam_import/nsf_helpers.py create mode 100644 keepercommander/commands/pam_import/record_loader.py create mode 100644 unit-tests/pam/test_pam_nsf_config.py create mode 100644 unit-tests/pam/test_pam_project_extend_nsf.py create mode 100644 unit-tests/pam/test_pam_project_extend_nsf_helpers.py create mode 100644 unit-tests/pam/test_pam_project_import_nsf.py create mode 100644 unit-tests/pam/test_pam_provision_nsf.py create mode 100644 unit-tests/pam/test_pam_split_nsf.py diff --git a/keepercommander/commands/base.py b/keepercommander/commands/base.py index cba03f2dc..572e0f8c0 100644 --- a/keepercommander/commands/base.py +++ b/keepercommander/commands/base.py @@ -915,6 +915,11 @@ def resolve_single_record(params, record_name): # type: (KeeperParams, str) -> if record_name in params.record_cache: return vault.KeeperRecord.load(params, record_name) + from .pam_import.record_loader import load_pam_record + rec = load_pam_record(params, record_name) + if rec: + return rec + rs = try_resolve_path(params, record_name) if rs is None: return None diff --git a/keepercommander/commands/credential_provision.py b/keepercommander/commands/credential_provision.py index b1a9008bd..5029b2b6d 100644 --- a/keepercommander/commands/credential_provision.py +++ b/keepercommander/commands/credential_provision.py @@ -47,11 +47,12 @@ from .. import api, vault, vault_extensions, crypto, utils, generator from ..error import CommandError from ..params import KeeperParams -from ..subfolder import try_resolve_path, get_folder_path -from ..record_management import add_record_to_folder +from ..subfolder import try_resolve_path from ..record_facades import LoginRecordFacade from ..proto import APIRequest_pb2 from ..commands.folder import FolderMakeCommand +from ..commands.pam.vault_target import ( + create_record_in_folder, records_in_folder, resolve_provision_target_folder) # RecordLink and discoveryrotation require pydantic (Python 3.8+) try: @@ -1313,35 +1314,24 @@ def _check_duplicate_by_username_in_folder(self, config: Dict[str, Any], params: username = config['account']['username'] pam_config_uid = config['account']['pam_config_uid'] - # Get the same folder path that will be used for PAM User creation try: - gateway_folder_uid, gateway_folder_path = self._get_gateway_application_folder(pam_config_uid, params) + folder_uid = self._resolve_pam_user_folder_uid(config, params) except Exception as e: + logging.warning( + f'Could not resolve target folder for duplicate username check ' + f'(username={username}, PAM config={pam_config_uid}): {e}' + ) return False - # Determine target folder (same logic as _create_pam_user) - user_specified_folder = config.get('vault', {}).get('folder') - - if user_specified_folder: - target_folder_path = f"{gateway_folder_path}/{user_specified_folder.strip('/')}" - else: - department = config['user'].get('department', 'Default') - target_folder_path = f"{gateway_folder_path}/PAM Users/{department}" - - # Get folder UID - folder_uid = self._get_folder_uid(target_folder_path, params) - if not folder_uid: return False - # Get all records in this folder - records_in_folder = params.subfolder_record_cache.get(folder_uid, set()) - - if not records_in_folder: + records_in_folder_set = records_in_folder(params, folder_uid) + if not records_in_folder_set: return False # Search for PAM Users in folder with matching username - for record_uid in records_in_folder: + for record_uid in records_in_folder_set: try: record = vault.KeeperRecord.load(params, record_uid) @@ -1358,7 +1348,7 @@ def _check_duplicate_by_username_in_folder(self, config: Dict[str, Any], params: if facade.login == username: logging.error(f'❌ Duplicate PAM User found (by username in folder):') logging.error(f' Username: {username}') - logging.error(f' Folder: {target_folder_path}') + logging.error(f' Folder UID: {folder_uid}') logging.error(f' Existing UID: {record_uid}') logging.error(f' Title: {record.title}') return True @@ -1481,28 +1471,7 @@ def _create_pam_user( username = config['account']['username'] pam_config_uid = config['account']['pam_config_uid'] - # Get gateway application folder from PAM Config - gateway_folder_uid, gateway_folder_path = self._get_gateway_application_folder(pam_config_uid, params) - - # Determine target folder - user_specified_folder = config.get('vault', {}).get('folder') - - if user_specified_folder: - # Check if the value is a folder UID (exists in folder cache) - if user_specified_folder in params.folder_cache: - folder_uid = user_specified_folder - elif user_specified_folder in params.shared_folder_cache: - folder_uid = user_specified_folder - else: - # Treat as a relative folder path - self._validate_folder_path(user_specified_folder) - target_folder_path = f"{gateway_folder_path}/{user_specified_folder.strip('/')}" - folder_uid = self._ensure_folder_exists(target_folder_path, params) - else: - # Auto-generate subfolder based on department - department = config['user'].get('department', 'Default') - target_folder_path = f"{gateway_folder_path}/PAM Users/{department}" - folder_uid = self._ensure_folder_exists(target_folder_path, params) + folder_uid = self._resolve_pam_user_folder_uid(config, params) # Create PAM User typed record pam_user = vault.TypedRecord() @@ -1576,7 +1545,7 @@ def _create_pam_user( try: # Create record in vault and add to folder - add_record_to_folder(params, pam_user, folder_uid) + create_record_in_folder(params, pam_user, folder_uid, command='credential-provision') # Sync to get the record UID api.sync_down(params) @@ -1619,6 +1588,20 @@ def _validate_folder_path(self, folder_path: str) -> None: f"Example: 'PAM Users/Engineering' (not '/Shared Folders/...')" ) + def _resolve_pam_user_folder_uid(self, config: Dict[str, Any], params: KeeperParams) -> str: + pam_config_uid = config['account']['pam_config_uid'] + user_specified_folder = config.get('vault', {}).get('folder') + if user_specified_folder: + self._validate_folder_path(user_specified_folder) + department = config.get('user', {}).get('department', 'Default') + return resolve_provision_target_folder( + params, + pam_config_uid, + folder_spec=user_specified_folder, + department=department, + command='credential-provision', + ) + def _get_gateway_application_folder(self, pam_config_uid: str, params: KeeperParams) -> Tuple[str, str]: """ Get gateway application folder from PAM Configuration. @@ -1636,35 +1619,11 @@ def _get_gateway_application_folder(self, pam_config_uid: str, params: KeeperPar Raises: ValueError: If PAM Config not found or folder not accessible """ - - # Load PAM Config record - pam_config_record = vault.KeeperRecord.load(params, pam_config_uid) - if not pam_config_record: - raise ValueError(f"PAM Configuration not found: {pam_config_uid}") - - # Use facade to access folder_uid - facade = PamConfigurationRecordFacade() - facade.record = pam_config_record - facade.load_typed_fields() - - folder_uid = facade.folder_uid - - if not folder_uid: - raise ValueError( - f"PAM Configuration '{pam_config_record.title}' has no application folder configured.\n" - f"Please configure the application folder in the PAM Configuration record." - ) - - # Get folder path from folder_uid using existing utility - if folder_uid not in params.folder_cache: - raise ValueError( - f"Application folder (UID: {folder_uid}) not found in vault.\n" - f"Ensure the folder is shared with you." - ) - - folder_path = get_folder_path(params, folder_uid) - - return folder_uid, folder_path + from ..commands.pam.vault_target import resolve_pam_application_folder + try: + return resolve_pam_application_folder(params, pam_config_uid, command='credential-provision') + except CommandError as exc: + raise ValueError(str(exc)) from exc def _ensure_folder_exists(self, folder_path: str, params: KeeperParams) -> Optional[str]: """ diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index 221c96da5..a6fbb9cc9 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -24,15 +24,20 @@ from keeper_secrets_manager_core.utils import url_safe_str_to_bytes from .base import (Command, GroupCommand, user_choice, dump_report_data, report_output_parser, - json_output_parser, field_to_title, - FolderMixin, RecordMixin, toggle_pam_legacy_commands) -from .folder import FolderMoveCommand + json_output_parser, field_to_title, toggle_pam_legacy_commands) from .ksm import KSMCommand from .pam import gateway_helper, router_helper from .pam.config_facades import PamConfigurationRecordFacade +from .pam.vault_target import ( + format_pam_folder_display, resolve_pam_folder_uid, + resolve_pam_record, record_exists_in_vault, collect_pam_folder_uids, + get_vault_record_title_type, find_pam_records_by_search, + resolve_pam_config_folder_info, pam_folder_json_payload, place_record_in_folder, + create_pam_configuration_in_folder, update_pam_record, records_in_folder, +) from .pam.config_helper import configuration_controller_get, \ pam_configurations_get_all, pam_configuration_remove, \ - pam_configuration_create_record_v6, record_rotation_get, \ + record_rotation_get, \ pam_decrypt_configuration_data from .pam.pam_dto import ( @@ -53,11 +58,12 @@ from .tunnel.port_forward.TunnelGraph import TunnelDAG, get_vertex_content from .tunnel.port_forward.tunnel_helpers import get_config_uid, get_keeper_tokens from .. import api, utils, vault_extensions, crypto, vault, record_management, attachment, record_facades +from ..recordv3 import RecordV3 from ..display import bcolors from ..error import CommandError, KeeperApiError from ..params import KeeperParams, LAST_RECORD_UID from ..proto import pam_pb2, router_pb2, record_pb2, APIRequest_pb2 -from ..subfolder import find_parent_top_folder, try_resolve_path, BaseFolderNode +from ..subfolder import try_resolve_path, BaseFolderNode from ..vault import TypedField from ..discovery_common.record_link import RecordLink from .discover.job_start import PAMGatewayActionDiscoverJobStartCommand @@ -260,6 +266,22 @@ def uses_default_rotation_schedule(params, record_uid, configuration_uid): if not isinstance(record_schedule, list) or len(record_schedule) == 0: return False return record_schedule == default_schedule + +def _valid_record_uids(uids): + return [uid for uid in (uids or []) if RecordV3.is_valid_ref_uid(uid)] + + +def _get_pam_config_resource_uids(pam_config_record): + if not pam_config_record: + return [] + resource_field = pam_config_record.get_typed_field('pamResources') + if resource_field and isinstance(resource_field.value, list) and len(resource_field.value) > 0: + resources = resource_field.value[0] + if isinstance(resources, dict): + refs = resources.get('resourceRef') + if isinstance(refs, list): + return _valid_record_uids(refs) + return [] def register_commands(commands): @@ -488,8 +510,8 @@ class PAMCreateRecordRotationCommand(Command): record_group.add_argument('--record', '-r', dest='record_name', action='store', help='Record UID, name, or pattern to be rotated manually or via schedule') record_group.add_argument('--folder', '-fd', dest='folder_name', action='store', - help='Used for bulk rotation setup. The folder UID or name that holds records to be ' - 'configured') + help='Used for bulk rotation setup. The folder UID or name (including Nested Share ' + 'Folders) that holds records to be configured') parser.add_argument('--force', '-f', dest='force', action='store_true', help='Do not ask for confirmation') parser.add_argument('--config', '-c', required=False, dest='config', action='store', help='UID or path of the configuration record.') @@ -543,7 +565,7 @@ def config_resource(_dag, target_record, target_config_uid, silent=None): # Add DAG for resource if target_config_uid: _dag = TunnelDAG(params, encrypted_session_token, encrypted_transmission_key, target_config_uid, - transmission_key=transmission_key) + is_config=True, transmission_key=transmission_key) _dag.edit_tunneling_config(rotation=True) else: raise CommandError('', f'{bcolors.FAIL}Resource "{target_record.record_uid}" is not associated ' @@ -558,7 +580,7 @@ def config_resource(_dag, target_record, target_config_uid, silent=None): _dag.link_resource_to_config(target_record.record_uid) admin = kwargs.get('admin') - adm_rec = RecordMixin.resolve_single_record(params, kwargs.get('admin', None)) + adm_rec = resolve_pam_record(params, kwargs.get('admin', None), rec_type='pamUser') if adm_rec and isinstance(adm_rec, vault.TypedRecord): admin = adm_rec.record_uid @@ -1197,22 +1219,31 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None f"--admin-user ADMIN{bcolors.ENDC}") current_record_rotation = params.record_rotation_cache.get(target_record.record_uid) - if not _dag or not _dag.linking_dag.has_graph: - _dag = TunnelDAG(params, encrypted_session_token, encrypted_transmission_key, target_resource_uid, - transmission_key=transmission_key) + if target_config_uid and (not _dag or not _dag.linking_dag.has_graph + or _dag.record.record_uid != target_config_uid): + _dag = TunnelDAG(params, encrypted_session_token, encrypted_transmission_key, target_config_uid, + is_config=True, transmission_key=transmission_key) if not _dag.linking_dag.has_graph: - raise CommandError('', f'{bcolors.FAIL}Resource "{target_resource_uid}" is not associated ' + _dag.edit_tunneling_config(rotation=True) + elif not _dag or not _dag.linking_dag.has_graph: + if RecordV3.is_valid_ref_uid(target_resource_uid): + _dag = TunnelDAG(params, encrypted_session_token, encrypted_transmission_key, target_resource_uid, + transmission_key=transmission_key) + if not _dag or not _dag.linking_dag.has_graph: + raise CommandError('', f'{bcolors.FAIL}Record "{target_record.record_uid}" is not associated ' f'with any configuration. ' - f'{bcolors.OKBLUE}pam rotation edit -rs {target_resource_uid} ' - f'--config CONFIG{bcolors.ENDC}') + f'{bcolors.OKBLUE}pam rotation edit -r {target_record.record_uid} ' + f'--config CONFIG [--resource RESOURCE]{bcolors.ENDC}') # Noop and resource cannot be both assigned if noop_rotation: target_resource_uid = target_record.record_uid record_resource_uid = None else: - if not target_resource_uid: + if not RecordV3.is_valid_ref_uid(target_resource_uid): # Get the resource configuration from DAG - resource_uids = _dag.get_all_owners(target_record.record_uid) + resource_uids = _valid_record_uids(_dag.get_all_owners(target_record.record_uid)) + if len(resource_uids) == 0 and pam_config: + resource_uids = _get_pam_config_resource_uids(pam_config) if len(resource_uids) > 1: # When processing folders, skip records with multiple resources if folder_name: @@ -1235,17 +1266,23 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None f'it.{bcolors.ENDC}') target_resource_uid = resource_uids[0] - if not _dag.resource_belongs_to_config(target_resource_uid): + if (RecordV3.is_valid_ref_uid(target_resource_uid) + and not _dag.resource_belongs_to_config(target_resource_uid)): # some rotations (iam_user/noop) link straight to pamConfiguration if target_resource_uid != _dag.record.record_uid: - raise CommandError('', - f'{bcolors.FAIL}Resource "{target_resource_uid}" is not associated with the ' - f'configuration of the user "{target_record.record_uid}". To associated the resources ' - f'to this config run {bcolors.OKBLUE}"pam rotation resource {target_resource_uid} ' - f'--config {_dag.record.record_uid}"{bcolors.ENDC}') - if not _dag.user_belongs_to_resource(target_record.record_uid, target_resource_uid): + if target_config_uid: + _dag.link_resource_to_config(target_resource_uid) + else: + raise CommandError('', + f'{bcolors.FAIL}Resource "{target_resource_uid}" is not associated with the ' + f'configuration of the user "{target_record.record_uid}". To associated the resources ' + f'to this config run {bcolors.OKBLUE}"pam rotation resource {target_resource_uid} ' + f'--config {_dag.record.record_uid}"{bcolors.ENDC}') + if (RecordV3.is_valid_ref_uid(target_resource_uid) + and not _dag.user_belongs_to_resource(target_record.record_uid, target_resource_uid)): old_resource_uid = _dag.get_resource_uid(target_record.record_uid) - if old_resource_uid is not None and old_resource_uid != target_resource_uid: + if (RecordV3.is_valid_ref_uid(old_resource_uid) + and old_resource_uid != target_resource_uid): print( f'{bcolors.WARNING}User "{target_record.record_uid}" is associated with another resource: ' f'{old_resource_uid}. ' @@ -1380,7 +1417,11 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) if record_name: - if record_name in params.record_cache: + rec = resolve_pam_record( + params, record_name, rec_type=PamConfigurationEditMixin.ROTATION_TARGET_RECORD_TYPES) + if rec: + record_uids.add(rec.record_uid) + elif record_name in params.record_cache: record_uids.add(record_name) else: rs = try_resolve_path(params, record_name, find_all_matches=True) @@ -1399,24 +1440,11 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None folder_name = kwargs.get('folder_name') if folder_name: - if folder_name in params.folder_cache: - folder_uids.add(folder_name) + collected = collect_pam_folder_uids(params, folder_name) + if collected: + folder_uids.update(collected) else: - rs = try_resolve_path(params, folder_name, find_all_matches=True) - if rs is not None: - folder, record_title = rs - if not record_title: - - def add_folders(sub_folder): # type: (BaseFolderNode) -> None - folder_uids.add(sub_folder.uid or '') - - if isinstance(folder, BaseFolderNode): - folder = [folder] - if isinstance(folder, list): - for f in folder: - FolderMixin.traverse_folder_tree(params, f.uid, add_folders) - else: - logging.warning('Folder \"%s\" not found. Skipping.', folder_name) + logging.warning('Folder \"%s\" not found. Skipping.', folder_name) if record_name and folder_name: raise CommandError('', 'Cannot use both --record and --folder at the same time.') @@ -1424,7 +1452,7 @@ def add_folders(sub_folder): # type: (BaseFolderNode) -> None if folder_uids: regex = re.compile(fnmatch.translate(record_pattern), re.IGNORECASE).match if record_pattern else None for folder_uid in folder_uids: - folder_records = params.subfolder_record_cache.get(folder_uid) + folder_records = records_in_folder(params, folder_uid) if not folder_records: continue if record_pattern and record_pattern in folder_records: @@ -1460,16 +1488,24 @@ def add_folders(sub_folder): # type: (BaseFolderNode) -> None isinstance(x, vault.TypedRecord)} config_uid = kwargs.get('config') - cfg_rec = RecordMixin.resolve_single_record(params, kwargs.get('config', None)) - if cfg_rec and cfg_rec.version == 6 and cfg_rec.record_uid in pam_configurations: + cfg_rec = resolve_pam_record( + params, kwargs.get('config', None), rec_type=PamConfigurationEditMixin.PAM_CONFIG_RECORD_TYPES) + if cfg_rec and cfg_rec.version == 6 and cfg_rec.record_type in PamConfigurationEditMixin.PAM_CONFIG_RECORD_TYPES: config_uid = cfg_rec.record_uid pam_config = None # type: Optional[vault.TypedRecord] if config_uid: if config_uid in pam_configurations: pam_config = pam_configurations[config_uid] + elif cfg_rec and cfg_rec.record_uid == config_uid: + pam_config = cfg_rec else: - raise CommandError('', f'Record uid {config_uid} is not a PAM Configuration record.') + loaded = vault.KeeperRecord.load(params, config_uid) + if (isinstance(loaded, vault.TypedRecord) and loaded.version == 6 + and loaded.record_type in PamConfigurationEditMixin.PAM_CONFIG_RECORD_TYPES): + pam_config = loaded + else: + raise CommandError('', f'Record uid {config_uid} is not a PAM Configuration record.') schedule_data = parse_schedule_data(kwargs) @@ -1504,7 +1540,8 @@ def add_folders(sub_folder): # type: (BaseFolderNode) -> None pwd_complexity_rule_list = {} resource_uid = kwargs.get('resource') - res_rec = RecordMixin.resolve_single_record(params, kwargs.get('resource', None)) + res_rec = resolve_pam_record( + params, kwargs.get('resource', None), rec_type=PamConfigurationEditMixin.PAM_RESOURCE_RECORD_TYPES) if res_rec and isinstance(res_rec, vault.TypedRecord): resource_uid = res_rec.record_uid @@ -1640,8 +1677,10 @@ def execute(self, params, **kwargs): enterprise_all_controllers = list(gateway_helper.get_all_gateways(params)) enterprise_controllers_connected_resp = router_get_connected_gateways(params) - enterprise_controllers_connected_uids_bytes = \ - [x.controllerUid for x in enterprise_controllers_connected_resp.controllers] + enterprise_controllers_connected_uids_bytes = [] + if enterprise_controllers_connected_resp and enterprise_controllers_connected_resp.controllers: + enterprise_controllers_connected_uids_bytes = \ + [x.controllerUid for x in enterprise_controllers_connected_resp.controllers] all_pam_config_records = pam_configurations_get_all(params) table = [] @@ -1676,19 +1715,11 @@ def execute(self, params, **kwargs): (poc for poc in enterprise_controllers_connected_uids_bytes if poc == controller_uid)) row_color = '' - if record_uid in params.record_cache: + if record_exists_in_vault(params, record_uid): row_color = bcolors.HIGHINTENSITYWHITE - rec = params.record_cache[record_uid] - - data_json = rec['data_unencrypted'].decode('utf-8') if isinstance(rec['data_unencrypted'], bytes) else \ - rec['data_unencrypted'] - data = json.loads(data_json) - - record_title = data.get('title') - record_type = data.get('type') or '' + record_title, record_type = get_vault_record_title_type(params, record_uid) else: row_color = bcolors.WHITE - record_title = '[record inaccessible]' record_type = '[record inaccessible]' @@ -1697,8 +1728,8 @@ def execute(self, params, **kwargs): continue row.append(f'{row_color}{record_uid}') - row.append(record_title) - row.append(record_type) + row.append(record_title or '[untitled]') + row.append(record_type or '[unknown]') if s.noSchedule is True: # Per Sergey A: @@ -1753,17 +1784,32 @@ def execute(self, params, **kwargs): f"{bcolors.FAIL}[No config found. Looks like configuration {configuration_uid_str} was removed but rotation schedule was not modified{bcolors.ENDC}") else: - pam_data_decrypted = pam_decrypt_configuration_data(pam_configuration) - pam_config_name = pam_data_decrypted.get('title') - pam_config_type = pam_data_decrypted.get('type') - row.append(f"{pam_config_name} ({pam_config_type})") + pam_config_name, pam_config_type = get_vault_record_title_type(params, configuration_uid_str) + if pam_config_name == '[record inaccessible]': + try: + pam_data_decrypted = pam_decrypt_configuration_data(pam_configuration) + pam_config_name = pam_data_decrypted.get('title') or pam_config_name + pam_config_type = pam_data_decrypted.get('type') or '[unknown]' + except Exception as exc: + logging.warning( + 'Rotation list: PAM configuration %s is not accessible in the vault ' + 'and could not be decrypted from router data: %s', + configuration_uid_str, + exc, + ) + if pam_config_name == '[record inaccessible]': + logging.warning( + 'Rotation list: PAM configuration %s title/type remain inaccessible', + configuration_uid_str, + ) + row.append(f"{pam_config_name or '[untitled]'} ({pam_config_type or '[unknown]'})") if is_verbose: row.append(f'{utils.base64_url_encode(configuration_uid)}{bcolors.ENDC}') table.append(row) - table.sort(key=lambda x: (x[1])) + table.sort(key=lambda x: (x[1] or '')) dump_report_data(table, headers, fmt='table', filename="", row_number=False, column_width=None) @@ -1883,19 +1929,8 @@ def execute(self, params, **kwargs): continue ksm_app_uid_str = utils.base64_url_encode(c.applicationUid) - ksm_app = KSMCommand.get_app_record(params, ksm_app_uid_str) - - if ksm_app: - ksm_app_data_unencrypted_json = ksm_app.get('data_unencrypted') - ksm_app_data_unencrypted_dict = json.loads(ksm_app_data_unencrypted_json) - ksm_app_title = ksm_app_data_unencrypted_dict.get('title') - ksm_app_info_plain = f'{ksm_app_title} ({ksm_app_uid_str})' - ksm_app_name = ksm_app_title - ksm_app_accessible = True - else: - ksm_app_info_plain = f'[APP NOT ACCESSIBLE OR DELETED] ({ksm_app_uid_str})' - ksm_app_name = None - ksm_app_accessible = False + ksm_app_name, ksm_app_accessible, ksm_app_info_plain = KSMCommand.get_ksm_app_display_info( + params, ksm_app_uid_str) # Check if this is gateway pool is_pool = len(connected_instances) > 1 @@ -2256,25 +2291,20 @@ def print_pam_configuration_details(params, config_uid, is_verbose=False, format facade = PamConfigurationRecordFacade() facade.record = configuration - folder_uid = facade.folder_uid - sf = None - if folder_uid in params.shared_folder_cache: - sf = api.get_shared_folder(params, folder_uid) + folder_info = resolve_pam_config_folder_info( + params, facade, configuration.record_uid) if format_type == 'json': config_data = { "uid": configuration.record_uid, "name": configuration.title, "config_type": configuration.record_type, - "shared_folder": { - "name": sf.name if sf else None, - "uid": sf.shared_folder_uid if sf else None - } if sf else None, "gateway_uid": facade.controller_uid, "gateway_name": facade.title, "resource_record_uids": facade.resource_ref, "fields": {} } + config_data.update(pam_folder_json_payload(folder_info)) for field in itertools.chain(configuration.fields, configuration.custom): if field.type in ('pamResources', 'fileRef'): @@ -2303,7 +2333,7 @@ def print_pam_configuration_details(params, config_uid, is_verbose=False, format table.append(['UID', configuration.record_uid]) table.append(['Name', configuration.title]) table.append(['Config Type', configuration.record_type]) - table.append(['Shared Folder', f'{sf.name} ({sf.shared_folder_uid})' if sf else '']) + table.append(['Folder', format_pam_folder_display(folder_info)]) table.append(['Gateway UID', facade.controller_uid]) table.append(['Resource Record UIDs', facade.resource_ref]) @@ -2329,11 +2359,11 @@ def print_root_rotation_setting(params, is_verbose=False, format_type='table'): table = [] if format_type == 'json': - headers = ['uid', 'config_name', 'config_type', 'shared_folder', 'gateway_uid', 'resource_record_uids'] + headers = ['uid', 'config_name', 'config_type', 'folder', 'gateway_uid', 'resource_record_uids'] if is_verbose: headers.append('fields') else: - headers = ['UID', 'Config Name', 'Config Type', 'Shared Folder', 'Gateway UID', 'Resource Record UIDs'] + headers = ['UID', 'Config Name', 'Config Type', 'Folder', 'Gateway UID', 'Resource Record UIDs'] if is_verbose: headers.append('Fields') @@ -2342,22 +2372,20 @@ def print_root_rotation_setting(params, is_verbose=False, format_type='table'): 'pamDomainConfiguration', 'pamNetworkConfiguration', 'pamOciConfiguration', 'pamGitHubConfiguration'): facade.record = c - shared_folder_parents = find_parent_top_folder(params, c.record_uid) - if shared_folder_parents: - sf = shared_folder_parents[0] + folder_info = resolve_pam_config_folder_info( + params, facade, c.record_uid) + if folder_info and folder_info.get('type') != 'unknown': + folder_display = format_pam_folder_display(folder_info) if format_type == 'json': config_data = { "uid": c.record_uid, "config_name": c.title, "config_type": c.record_type, - "shared_folder": { - "name": sf.name, - "uid": sf.uid - }, "gateway_uid": facade.controller_uid, "resource_record_uids": facade.resource_ref } + config_data.update(pam_folder_json_payload(folder_info)) if is_verbose: fields = {} @@ -2371,7 +2399,7 @@ def print_root_rotation_setting(params, is_verbose=False, format_type='table'): configs_data.append(config_data) else: - row = [c.record_uid, c.title, c.record_type, f'{sf.name} ({sf.uid})', + row = [c.record_uid, c.title, c.record_type, folder_display, facade.controller_uid, facade.resource_ref] if is_verbose: @@ -2386,7 +2414,7 @@ def print_root_rotation_setting(params, is_verbose=False, format_type='table'): table.append(row) else: - logging.warning('Following configuration is not in the shared folder: UID: %s, Title: %s', + logging.warning(f'Following configuration folder was not found: UID: %s, Title: %s', c.record_uid, c.title) else: logging.warning('Following configuration has unsupported type: UID: %s, Title: %s', c.record_uid, @@ -2406,8 +2434,8 @@ def print_root_rotation_setting(params, is_verbose=False, format_type='table'): 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', - help='Share Folder where this PAM Configuration is stored. Should be one of the folders to ' - 'which the gateway has access to.') + help='Shared Folder or Nested Share Folder where this PAM Configuration is stored. ' + 'Should be one of the folders to which the gateway has access.') common_parser.add_argument('--schedule', '-sc', dest='default_schedule', action='store', help='Default Schedule: Use CRON syntax') common_parser.add_argument('--port-mapping', '-pm', dest='port_mapping', action='append', help='Port Mapping') @@ -2470,6 +2498,14 @@ def print_root_rotation_setting(params, is_verbose=False, format_type='table'): class PamConfigurationEditMixin(RecordEditMixin): pam_record_types = None + PAM_CONFIG_RECORD_TYPES = frozenset({ + 'pamAwsConfiguration', 'pamAzureConfiguration', 'pamGcpConfiguration', + 'pamDomainConfiguration', 'pamNetworkConfiguration', 'pamOciConfiguration', + }) + PAM_RESOURCE_RECORD_TYPES = frozenset({ + 'pamDatabase', 'pamDirectory', 'pamMachine', 'pamRemoteBrowser', + }) + ROTATION_TARGET_RECORD_TYPES = PAM_RESOURCE_RECORD_TYPES | frozenset({'pamUser'}) def __init__(self): super().__init__() @@ -2521,9 +2557,8 @@ def parse_pam_configuration(self, params, record, **kwargs): shared_folder_uid = None # type: Optional[str] folder_name = kwargs.get('shared_folder_uid') # type: Optional[str] if folder_name: - if folder_name in params.shared_folder_cache: - shared_folder_uid = folder_name - else: + shared_folder_uid = resolve_pam_folder_uid(params, folder_name) + if not shared_folder_uid: for sf_uid in params.shared_folder_cache: sf = api.get_shared_folder(params, sf_uid) if sf and sf.name.casefold() == folder_name.casefold(): @@ -2543,9 +2578,23 @@ def parse_pam_configuration(self, params, record, **kwargs): if rrr: pam_record_lookup = {} rti = PamConfigurationEditMixin.get_pam_record_types(params) + rti_set = set(rti) for r in vault_extensions.find_records(params, record_type=rti): pam_record_lookup[r.record_uid] = r.record_uid pam_record_lookup[r.title.lower()] = r.record_uid + nsf_data = getattr(params, 'nested_share_record_data', {}) + for uid in getattr(params, 'nested_share_records', {}): + rec = vault.KeeperRecord.load(params, uid) + if not rec or rec.record_type not in rti_set: + continue + pam_record_lookup[rec.record_uid] = rec.record_uid + rd = nsf_data.get(uid, {}) + data_json = rd.get('data_json', {}) if isinstance(rd, dict) else {} + title = data_json.get('title', '') if isinstance(data_json, dict) else '' + if title: + pam_record_lookup[title.lower()] = rec.record_uid + elif rec.title: + pam_record_lookup[rec.title.lower()] = rec.record_uid record_uids = set() if 'resourceRef' in value: @@ -2566,17 +2615,7 @@ def parse_pam_configuration(self, params, record, **kwargs): @staticmethod def resolve_single_record(params, record_name, rec_type=''): # type: (KeeperParams, str, str) -> Optional[vault.KeeperRecord] - rec = RecordMixin.resolve_single_record(params, record_name) - if not rec: - recs = [] - for rec in params.record_cache: - vrec = vault.KeeperRecord.load(params, rec) - if vrec and vrec.title == record_name and (not rec_type or rec_type == vrec.record_type): - recs.append(vrec) - if len(recs) > 1: break - if len(recs) == 1: - rec = recs[0] - return rec + return resolve_pam_record(params, record_name, rec_type=rec_type or None) @staticmethod def _domain_kwarg_supplied(kwargs, key, config_edit): @@ -2844,11 +2883,14 @@ def execute(self, params, **kwargs): # resolve folder path to UID sf_name = kwargs.get('shared_folder_uid', '') if sf_name: - fpath = try_resolve_path(params, sf_name) - # [-1] == '' -> existing folder, 'path/' -> non-existing folder - if fpath and len(fpath) >= 2 and fpath[-1] == '': - sfuid = fpath[-2].uid - if sfuid: kwargs['shared_folder_uid'] = sfuid + sfuid = resolve_pam_folder_uid(params, sf_name, allow_legacy_user=True) + if not sfuid: + fpath = try_resolve_path(params, sf_name) + # [-1] == '' -> existing folder, 'path/' -> non-existing folder + if fpath and len(fpath) >= 2 and fpath[-1] == '': + sfuid = fpath[-2].uid + if sfuid: + kwargs['shared_folder_uid'] = sfuid self.parse_properties(params, record, **kwargs) @@ -2875,7 +2917,7 @@ def execute(self, params, **kwargs): self.verify_required(record) - pam_configuration_create_record_v6(params, record, shared_folder_uid) + create_pam_configuration_in_folder(params, record, shared_folder_uid, command='pam-config-new') encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) # Add DAG for configuration @@ -2895,10 +2937,6 @@ def execute(self, params, **kwargs): tmp_dag.link_user_to_config_with_options(admin_cred_ref, is_admin='on') tmp_dag.print_tunneling_config(record.record_uid, None) - # Moving v6 record into the folder - api.sync_down(params) - FolderMoveCommand().execute(params, src=record.record_uid, dst=shared_folder_uid, force=True) - params.environment_variables[LAST_RECORD_UID] = record.record_uid params.sync_data = True @@ -2953,14 +2991,11 @@ def execute(self, params, **kwargs): config_name = kwargs.get('uid') if not config_name: raise CommandError('pam config edit', 'PAM Configuration UID or Title is required') - if config_name in params.record_cache: - configuration = vault.KeeperRecord.load(params, config_name) - else: - l_name = config_name.casefold() - for c in vault_extensions.find_records(params, record_version=6): - if c.title.casefold() == l_name: - configuration = c - break + + config_uid = PAMConfigurationRemoveCommand._resolve_pam_config_uid(params, config_name) + if not config_uid: + raise CommandError('pam-config-edit', f'PAM configuration "{config_name}" not found') + configuration = vault.KeeperRecord.load(params, config_uid) if not configuration: raise CommandError('pam-config-edit', f'PAM configuration "{config_name}" not found') if not isinstance(configuration, vault.TypedRecord) or configuration.version != 6: @@ -3015,7 +3050,7 @@ def execute(self, params, **kwargs): self.parse_properties(params, configuration, config_edit=True, **kwargs) self.verify_required(configuration) - record_management.update_record(params, configuration) + update_pam_record(params, configuration, command='pam-config-edit') admin_cred_ref = '' value = field.get_default_value(dict) @@ -3028,7 +3063,7 @@ def execute(self, params, **kwargs): api.communicate_rest(params, pcc, 'pam/set_configuration_controller') shared_folder_uid = value.get('folderUid') or '' if shared_folder_uid != orig_shared_folder_uid: - FolderMoveCommand().execute(params, src=configuration.record_uid, dst=shared_folder_uid) + place_record_in_folder(params, configuration.record_uid, shared_folder_uid, command='pam-config-edit') if configuration.type_name == 'pamDomainConfiguration' and not kwargs.get('force_domain_admin', False) is True: # pamUser must exist or "403 Insufficient PAM access to perform this operation" @@ -3070,25 +3105,64 @@ class PAMConfigurationRemoveCommand(Command): help='PAM Configuration UID. To view all rotation settings with their UIDs, use command ' '`pam config list`') + _PAM_CONFIG_TYPES = PamConfigurationEditMixin.PAM_CONFIG_RECORD_TYPES + def get_parser(self): return PAMConfigurationRemoveCommand.parser + @classmethod + def _resolve_pam_config_uid(cls, params, identifier): + # type: (KeeperParams, str) -> Optional[str] + if identifier in params.record_cache: + rec = vault.KeeperRecord.load(params, identifier) + if isinstance(rec, vault.TypedRecord) and rec.version == 6: + if rec.record_type in cls._PAM_CONFIG_TYPES: + return rec.record_uid + logging.warning( + 'Record "%s" is v6 but is not a PAM configuration type (found %s)', + identifier, + rec.record_type, + ) + + from ..nested_share_folder import removal_api as _nsf + resolved_uid = _nsf.resolve_nested_share_record_uid(params, identifier) + if resolved_uid: + rec = vault.KeeperRecord.load(params, resolved_uid) + if isinstance(rec, vault.TypedRecord) and rec.version == 6: + if rec.record_type in cls._PAM_CONFIG_TYPES: + return resolved_uid + logging.warning( + 'Nested Share Record "%s" is v6 but is not a PAM configuration type (found %s)', + resolved_uid, + rec.record_type, + ) + + l_name = identifier.casefold() + matches = [] + for config in vault_extensions.find_records(params, record_version=6): + if config.record_type not in cls._PAM_CONFIG_TYPES: + continue + if config.record_uid == identifier: + return config.record_uid + if config.title.casefold() == l_name: + matches.append(config.record_uid) + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + raise CommandError('pam config remove', + f'"{identifier}" matches multiple configurations. Use a UID.') + return None + def execute(self, params, **kwargs): pam_config_name = kwargs.get('uid') if not pam_config_name: - raise CommandError('pam config edit', 'PAM Configuration UID is required') - pam_config_uid = None - for config in vault_extensions.find_records(params, record_version=6): - if config.record_uid == pam_config_name: - pam_config_uid = config.record_uid - break - if config.title.casefold() == pam_config_name.casefold(): - pass - if not pam_config_name: - raise Exception(f'Configuration "{pam_config_name}" not found') + raise CommandError('pam config remove', 'PAM Configuration UID is required') + pam_config_uid = self._resolve_pam_config_uid(params, pam_config_name) + if not pam_config_uid: + raise CommandError('pam config remove', f'Configuration "{pam_config_name}" not found') pam_config = vault.KeeperRecord.load(params, pam_config_uid) if not pam_config: - raise Exception(f'Configuration "{pam_config_uid}" not found') + raise CommandError('pam config remove', f'Configuration "{pam_config_uid}" not found') encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) tmp_dag = TunnelDAG(params, encrypted_session_token, encrypted_transmission_key, pam_config.record_uid, is_config=True, transmission_key=transmission_key) @@ -3122,7 +3196,7 @@ def execute(self, params, **kwargs): gateway_uid = utils.base64_url_encode(rri.controllerUid) if rri.controllerUid else '-' def is_resource_ok(resource_id, params, configuration_uid): - if resource_id not in params.record_cache: + if not record_exists_in_vault(params, resource_id): return False configuration = vault.KeeperRecord.load(params, configuration_uid) if not isinstance(configuration, vault.TypedRecord): @@ -3146,6 +3220,8 @@ def is_resource_ok(resource_id, params, configuration_uid): if pwd_complexity_raw: try: record = params.record_cache.get(record_uid) + if not record: + record = getattr(params, 'nested_share_records', {}).get(record_uid) if record: complexity = crypto.decrypt_aes_v2(utils.base64_url_decode(pwd_complexity_raw), record['record_key_unencrypted']) @@ -3251,8 +3327,8 @@ class PAMRouterScriptCommand(GroupCommand): def __init__(self): super().__init__() self.register_command('list', PAMScriptListCommand(), 'List script fields') - self.register_command('add', PAMScriptAddCommand(), 'List Record Rotation Schedulers') - self.register_command('edit', PAMScriptEditCommand(), 'Add, delete, or edit script field') + self.register_command('add', PAMScriptAddCommand(), 'Add script to record (record owner only)') + self.register_command('edit', PAMScriptEditCommand(), 'Edit script field') self.register_command('delete', PAMScriptDeleteCommand(), 'Delete script field') self.default_verb = 'list' @@ -3270,8 +3346,8 @@ def execute(self, params, **kwargs): table = [] header = ['record_uid', 'title', 'record_type', 'script_uid', 'script_name', 'records', 'command'] - for record in vault_extensions.find_records(params, search_str=pattern, record_version=3, - record_type=('pamUser', 'pamDirectory')): + for record in find_pam_records_by_search(params, pattern, record_version=3, + record_types=('pamUser', 'pamDirectory')): if not isinstance(record, vault.TypedRecord): continue for field in (x for x in record.fields if x.type == 'script'): @@ -3295,7 +3371,8 @@ def execute(self, params, **kwargs): class PAMScriptAddCommand(Command): - parser = argparse.ArgumentParser(prog='pam rotate script add', description='Add script to record') + parser = argparse.ArgumentParser(prog='pam rotate script add', + description='Add script to record (record owner only)') parser.add_argument('--script', required=True, dest='script', action='store', help='Script file name') parser.add_argument('--add-credential', dest='add_credential', action='append', @@ -3311,8 +3388,8 @@ def execute(self, params, **kwargs): record_name = kwargs.get('record') if not record_name: raise CommandError('rotate script', '"record" argument is required') - records = list(vault_extensions.find_records( - params, search_str=record_name, record_version=3, record_type=('pamUser', 'pamDirectory'))) + records = find_pam_records_by_search(params, record_name, record_version=3, + record_types=('pamUser', 'pamDirectory')) if len(records) == 0: raise CommandError('rotate script', f'Record "{record_name}" not found') if len(records) > 1: @@ -3350,7 +3427,10 @@ def execute(self, params, **kwargs): record_refs = kwargs.get('add_credential') if isinstance(record_refs, list): for ref in record_refs: - if ref in params.record_cache: + resolved_ref = resolve_pam_record(params, ref, rec_type='pamUser') + if resolved_ref: + script_value['recordRef'].append(resolved_ref.record_uid) + elif record_exists_in_vault(params, ref): script_value['recordRef'].append(ref) cmd = kwargs.get('script_command') if cmd: @@ -3384,8 +3464,8 @@ def execute(self, params, **kwargs): if not script_name: raise CommandError('rotate script', '"script" argument is required') - records = list(vault_extensions.find_records( - params, search_str=record_name, record_version=3, record_type=('pamUser', 'pamDirectory'))) + records = find_pam_records_by_search(params, record_name, record_version=3, + record_types=('pamUser', 'pamDirectory')) if len(records) == 0: raise CommandError('rotate script', f'Record "{record_name}" not found') if len(records) > 1: @@ -3421,11 +3501,21 @@ def execute(self, params, **kwargs): refs.update(record_refs) remove_credential = kwargs.get('remove_credential') if isinstance(remove_credential, list) and remove_credential: - refs.difference_update(remove_credential) + for ref in remove_credential: + resolved_ref = resolve_pam_record(params, ref, rec_type='pamUser') + if resolved_ref: + refs.discard(resolved_ref.record_uid) + else: + refs.discard(ref) modified = True add_credential = kwargs.get('add_credential') if isinstance(add_credential, list) and add_credential: - refs.update(add_credential) + for ref in add_credential: + resolved_ref = resolve_pam_record(params, ref, rec_type='pamUser') + if resolved_ref: + refs.add(resolved_ref.record_uid) + elif record_exists_in_vault(params, ref): + refs.add(ref) modified = True if modified: script_value['recordRef'] = list(refs) @@ -3459,8 +3549,8 @@ def execute(self, params, **kwargs): if not script_name: raise CommandError('rotate script', '"script" argument is required') - records = list(vault_extensions.find_records( - params, search_str=record_name, record_version=3, record_type=('pamUser', 'pamDirectory'))) + records = find_pam_records_by_search(params, record_name, record_version=3, + record_types=('pamUser', 'pamDirectory')) if len(records) == 0: raise CommandError('rotate script', f'Record "{record_name}" not found') if len(records) > 1: @@ -4224,8 +4314,9 @@ class PAMCreateGatewayCommand(Command): help='Name of the Gateway', action='store') dr_create_controller_parser.add_argument('--application', '-a', required=True, dest='ksm_app', - help='KSM Application name or UID. Use command `sm app list` to view ' - 'available KSM Applications.', action='store') + help='KSM Application name or UID. Use ' + '`secrets-manager app list` to view available applications.', + action='store') dr_create_controller_parser.add_argument('--token-expires-in-min', '-e', type=int, dest='token_expire_in_min', action='store', help='Time for the one time token to expire. Maximum 1440 minutes (24 hrs). Default: 60', @@ -4253,12 +4344,24 @@ def execute(self, params, **kwargs): logging.debug(f'ksm_app =[{ksm_app}]') logging.debug(f'ott_expire_in_min =[{ott_expire_in_min}]') - one_time_token = gateway_helper.create_gateway(params, gateway_name, ksm_app, config_init, ott_expire_in_min) + ksm_app_record = KSMCommand.get_app_record(params, ksm_app) + if not ksm_app_record: + raise CommandError( + 'pam gateway new', + f'KSM Application "{ksm_app}" not found. Run sync-down and verify the application ' + f'exists in your vault or Nested Share Folder.') + + ksm_app_uid = ksm_app_record.get('record_uid', ksm_app) + ksm_app_title, _, _ = KSMCommand.get_ksm_app_display_info(params, ksm_app_uid) + + one_time_token = gateway_helper.create_gateway( + params, gateway_name, ksm_app_uid, config_init, ott_expire_in_min) if is_return_value: return one_time_token else: - print(f'The one time token has been created in application [{bcolors.OKBLUE}{ksm_app}{bcolors.ENDC}].\n\n' + print(f'The one time token has been created in application ' + f'[{bcolors.OKBLUE}{ksm_app_title or ksm_app_uid}{bcolors.ENDC}].\n\n' f'The new Gateway named {bcolors.OKBLUE}{gateway_name}{bcolors.ENDC} will show up in a list ' f'of gateways once it is initialized.\n\n') @@ -4322,4 +4425,4 @@ def execute(self, params: KeeperParams, **kwargs): gateway_name = new_name if new_name else gateway.controllerName gateway_helper.edit_gateway(params, gateway.controllerUid, gateway_name, resolved_node_id) - logging.info('Gateway %s has been edited.', gateway_uid) \ No newline at end of file + logging.info('Gateway %s has been edited.', gateway_uid) diff --git a/keepercommander/commands/discoveryrotation_v1.py b/keepercommander/commands/discoveryrotation_v1.py index d25e0d4b6..cadd5f0c9 100644 --- a/keepercommander/commands/discoveryrotation_v1.py +++ b/keepercommander/commands/discoveryrotation_v1.py @@ -1272,8 +1272,8 @@ class PAMRouterScriptCommand(GroupCommand): def __init__(self): super().__init__() self.register_command('list', PAMScriptListCommand(), 'List script fields') - self.register_command('add', PAMScriptAddCommand(), 'List Record Rotation Schedulers') - self.register_command('edit', PAMScriptEditCommand(), 'Add, delete, or edit script field') + self.register_command('add', PAMScriptAddCommand(), 'Add script to record (record owner only)') + self.register_command('edit', PAMScriptEditCommand(), 'Edit script field') self.register_command('delete', PAMScriptDeleteCommand(), 'Delete script field') self.default_verb = 'list' @@ -1316,7 +1316,8 @@ def execute(self, params, **kwargs): class PAMScriptAddCommand(Command): - parser = argparse.ArgumentParser(prog='pam rotate script add', description='Add script to record') + parser = argparse.ArgumentParser(prog='pam rotate script add', + description='Add script to record (record owner only)') parser.add_argument('--script', required=True, dest='script', action='store', help='Script file name') parser.add_argument('--add-credential', dest='add_credential', action='append', diff --git a/keepercommander/commands/folder.py b/keepercommander/commands/folder.py index f473eb754..84a1ef7d5 100644 --- a/keepercommander/commands/folder.py +++ b/keepercommander/commands/folder.py @@ -899,13 +899,13 @@ def execute(self, params, **kwargs): if src_folder.type == BaseFolderNode.NestedShareFolderType: if dst_folder.type in {BaseFolderNode.SharedFolderType, BaseFolderNode.SharedFolderFolderType}: - raise CommandError('mv', 'Drive folders cannot be moved inside a Shared folder.') - raise CommandError('mv', 'Moving drive folders is currently not supported.') + raise CommandError('mv', 'Nested Share Folders cannot be moved inside a Shared folder.') + raise CommandError('mv', 'Moving Nested Share Folders is currently not supported.') if dst_folder.type == BaseFolderNode.NestedShareFolderType: if src_folder.type in {BaseFolderNode.SharedFolderType, BaseFolderNode.SharedFolderFolderType}: - raise CommandError('mv', 'Shared folders cannot be moved inside a drive folder.') - raise CommandError('mv', 'Folders cannot be moved inside a drive folder.') + raise CommandError('mv', 'Shared folders cannot be moved inside a Nested Share Folder.') + raise CommandError('mv', 'Folders cannot be moved inside a Nested Share Folder.') dp = set() f = dst_folder @@ -949,7 +949,7 @@ def execute(self, params, **kwargs): else: if src_folder.type == BaseFolderNode.NestedShareFolderType: - raise CommandError('mv', 'Moving drive records is currently not supported.') + raise CommandError('mv', 'Moving Nested Share Folder records is currently not supported.') if record_uid in getattr(params, 'nested_share_records', {}) \ and dst_folder.type != BaseFolderNode.NestedShareFolderType: diff --git a/keepercommander/commands/ksm.py b/keepercommander/commands/ksm.py index e55463f19..e6215b531 100644 --- a/keepercommander/commands/ksm.py +++ b/keepercommander/commands/ksm.py @@ -26,6 +26,11 @@ from .base import Command, dump_report_data, user_choice, as_boolean from . import record +from ..nested_share_folder.common import get_folder_key, get_record_key +from .nested_share_folder.helpers import ( + is_nested_share_folder, is_nested_share_record, load_record_metadata, resolve_folder_uid) +from ..nested_share_folder.removal_api import ( + resolve_nested_share_folder_uid, resolve_nested_share_record_uid) from .. import api, utils, crypto, vault from ..params import KeeperParams from ..display import bcolors @@ -90,16 +95,16 @@ --force : Do not prompt for confirmation {bcolors.BOLD}Add Secret to Application:{bcolors.ENDC} - {bcolors.OKGREEN}secrets-manager share add --app {bcolors.OKBLUE}[APP NAME OR UID] {bcolors.OKGREEN}--secret {bcolors.OKBLUE}[RECORD OR SHARED FOLDER UID]{bcolors.ENDC} + {bcolors.OKGREEN}secrets-manager share add --app {bcolors.OKBLUE}[APP NAME OR UID] {bcolors.OKGREEN}--secret {bcolors.OKBLUE}[RECORD, SHARED FOLDER, OR NSF UID/PATH] {bcolors.ENDC} Options: --editable : Allow secrets to be editable by the client {bcolors.BOLD}Update Secret Permissions:{bcolors.ENDC} - {bcolors.OKGREEN}secrets-manager share update --app {bcolors.OKBLUE}[APP NAME OR UID] {bcolors.OKGREEN}--secret {bcolors.OKBLUE}[RECORD OR SHARED FOLDER UID] {bcolors.OKGREEN}--editable{bcolors.ENDC} - {bcolors.OKGREEN}secrets-manager share update --app {bcolors.OKBLUE}[APP NAME OR UID] {bcolors.OKGREEN}--secret {bcolors.OKBLUE}[RECORD OR SHARED FOLDER UID] {bcolors.OKGREEN}--readonly{bcolors.ENDC} + {bcolors.OKGREEN}secrets-manager share update --app {bcolors.OKBLUE}[APP NAME OR UID] {bcolors.OKGREEN}--secret {bcolors.OKBLUE}[RECORD, SHARED FOLDER, OR NSF UID/PATH] {bcolors.OKGREEN}--editable{bcolors.ENDC} + {bcolors.OKGREEN}secrets-manager share update --app {bcolors.OKBLUE}[APP NAME OR UID] {bcolors.OKGREEN}--secret {bcolors.OKBLUE}[RECORD, SHARED FOLDER, OR NSF UID/PATH] {bcolors.OKGREEN}--readonly{bcolors.ENDC} {bcolors.BOLD}Remove Secret from Application:{bcolors.ENDC} - {bcolors.OKGREEN}secrets-manager share remove --app {bcolors.OKBLUE}[APP NAME OR UID] {bcolors.OKGREEN}--secret {bcolors.OKBLUE}[RECORD OR SHARED FOLDER UID]{bcolors.ENDC} + {bcolors.OKGREEN}secrets-manager share remove --app {bcolors.OKBLUE}[APP NAME OR UID] {bcolors.OKGREEN}--secret {bcolors.OKBLUE}[RECORD, SHARED FOLDER, OR NSF UID/PATH] {bcolors.ENDC} {bcolors.BOLD}Add Token to Application:{bcolors.ENDC} {bcolors.OKGREEN}secrets-manager token add {bcolors.OKBLUE}[APP NAME OR UID]{bcolors.ENDC} @@ -130,7 +135,7 @@ help='One of: "app list", "app get", "app create", "app update", "app remove", "app share", ' + '"app unshare", "client add", "client remove", "share add", "share update", "share remove" or "token add"') ksm_parser.add_argument('--secret', '-s', type=str, action='append', required=False, - help='Record UID') + help='Record, shared folder, or Nested Share Folder (NSF) UID or path') ksm_parser.add_argument('--app', '-a', type=str, action='store', required=False, help='Application Name or UID') ksm_parser.add_argument('--client', '-i', type=str, dest='client_names_or_ids', action='append', required=False, @@ -593,11 +598,15 @@ def add_app_share(params, secret_uids, app_name_or_uid, is_editable): app_record_uid = rec_cache_val.get('record_uid') master_key = rec_cache_val.get('record_key_unencrypted') + resolved_uids = KSMCommand.resolve_secret_uids(params, secret_uids) + if not resolved_uids: + return + KSMCommand.share_secret( params=params, app_uid=app_record_uid, master_key=master_key, - secret_uids=secret_uids, + secret_uids=resolved_uids, is_editable=is_editable ) @@ -838,8 +847,6 @@ def shorten_client_id(all_clients, original_id, number_of_characters): print(bcolors.BOLD + "\nApplication Access\n" + bcolors.ENDC) if ai.shares: - recs = params.record_cache - for s in ai.shares: uid_str = utils.base64_url_encode(s.secretUid) sht = APIRequest_pb2.ApplicationShareType.Name(s.shareType) @@ -851,18 +858,17 @@ def shorten_client_id(all_clients, original_id, number_of_characters): } if sht == 'SHARE_TYPE_RECORD': - rec = recs.get(uid_str) - if rec: - record_data_dict = KSMCommand.record_data_as_dict(rec) - share_data["title"] = record_data_dict.get('title') - share_data["type"] = "RECORD" + share_data["title"] = KSMCommand.get_secret_title(params, uid_str, sht) + share_data["type"] = "RECORD" + #ToDo: check if type nsf record is valid here + if is_nested_share_record(params, uid_str): + share_data["type"] = "NSF RECORD" elif sht == 'SHARE_TYPE_FOLDER': - if uid_str in params.shared_folder_cache: - cached_sf = params.shared_folder_cache[uid_str] - share_data["title"] = cached_sf.get('name_unencrypted') - share_data["type"] = "FOLDER" - else: - continue + share_data["title"] = KSMCommand.get_secret_title(params, uid_str, sht) + share_data["type"] = "FOLDER" + #ToDo: check if type nsf folder is valid here + if is_nested_share_folder(params, uid_str): + share_data["type"] = "NSF FOLDER" else: logging.warning("Unknown Share Type %s" % sht) continue @@ -900,33 +906,160 @@ def shorten_client_id(all_clients, original_id, number_of_characters): else: return json.dumps({"applications": result_data}, indent=2) + @staticmethod + def _secret_in_cache(params, uid): + if uid in getattr(params, 'record_cache', {}): + return True + if uid in getattr(params, 'nested_share_records', {}): + return True + if api.is_shared_folder(params, uid): + return True + if is_nested_share_folder(params, uid): + return True + return False + + @staticmethod + def resolve_secret_uid(params, identifier): + if not identifier: + return None + identifier = str(identifier).strip() + if identifier.startswith('[') and identifier.endswith(']') and len(identifier) > 2: + identifier = identifier[1:-1].strip() + if KSMCommand._secret_in_cache(params, identifier): + return identifier + record_uid = resolve_nested_share_record_uid(params, identifier) + if record_uid: + return record_uid + folder_uid = resolve_folder_uid(params, identifier) + if folder_uid and (api.is_shared_folder(params, folder_uid) + or is_nested_share_folder(params, folder_uid)): + return folder_uid + folder_uid = resolve_nested_share_folder_uid(params, identifier) + if folder_uid and is_nested_share_folder(params, folder_uid): + return folder_uid + return None + + @staticmethod + def resolve_secret_uids(params, identifiers): + if not identifiers: + return [] + if isinstance(identifiers, str): + identifiers = [identifiers] + resolved = [] + for ident in identifiers: + uid = KSMCommand.resolve_secret_uid(params, ident) + if uid: + resolved.append(uid) + else: + logging.warning('Could not resolve secret "%s". Run sync-down and try again.', ident) + return resolved + + @staticmethod + def classify_secret(params, uid): + share_key = None + share_type = None + + if uid in getattr(params, 'record_cache', {}) or is_nested_share_record(params, uid): + share_type = 'SHARE_TYPE_RECORD' + if uid in params.record_cache: + share_key = params.record_cache[uid].get('record_key_unencrypted') + if not share_key: + share_key = get_record_key(params, uid, raise_on_missing=False) + elif api.is_shared_folder(params, uid) or is_nested_share_folder(params, uid): + share_type = 'SHARE_TYPE_FOLDER' + cached_sf = getattr(params, 'shared_folder_cache', {}).get(uid, {}) + share_key = cached_sf.get('shared_folder_key_unencrypted') + if not share_key: + share_key = get_folder_key(params, uid, raise_on_missing=False) + + if share_type and share_key: + return {'uid': uid, 'share_type': share_type, 'share_key': share_key} + return None + + @staticmethod + def get_secret_title(params, uid, share_type): + if share_type == 'SHARE_TYPE_RECORD': + rec = getattr(params, 'record_cache', {}).get(uid) + if rec and rec.get('data_unencrypted'): + try: + return KSMCommand.record_data_as_dict(rec).get('title', uid) + except Exception: + pass + if is_nested_share_record(params, uid): + return load_record_metadata(params, uid).get('title', uid) + elif share_type == 'SHARE_TYPE_FOLDER': + cached_sf = getattr(params, 'shared_folder_cache', {}).get(uid, {}) + if cached_sf.get('name_unencrypted'): + return cached_sf.get('name_unencrypted') + nsf = getattr(params, 'nested_share_folders', {}).get(uid, {}) + if nsf.get('name'): + return nsf.get('name') + folder = getattr(params, 'folder_cache', {}).get(uid) + if folder and getattr(folder, 'name', None): + return folder.name + return uid + + @staticmethod + def _share_nsf_secret(params, app_uid, master_key, secret, is_editable=False): + """Share an NSF folder/record with an application using v3 access APIs.""" + from ..nested_share_folder.folder_api import grant_folder_access_to_application_v3 + from ..nested_share_folder.record_api import share_record_to_application_v3 + + uid = secret['uid'] + if secret['share_type'] == 'SHARE_TYPE_FOLDER' and is_nested_share_folder(params, uid): + result = grant_folder_access_to_application_v3( + params, uid, app_uid, master_key, is_editable=is_editable) + if not result.get('success'): + raise KeeperApiError( + result.get('status') or 'access_denied', + result.get('message') or 'Failed to share Nested Share Folder with application') + return True + + if secret['share_type'] == 'SHARE_TYPE_RECORD' and is_nested_share_record(params, uid): + result = share_record_to_application_v3( + params, uid, app_uid, master_key, is_editable=is_editable) + if not result.get('success'): + message = '; '.join( + r.get('message') or r.get('status') or 'share failed' + for r in result.get('results', [])) or 'Failed to share Nested Share Record with application' + raise KeeperApiError('share_failed', message) + return True + + return False + @staticmethod def share_secret(params, app_uid, master_key, secret_uids, is_editable=False): app_shares = [] - added_secret_uids_type_pairs = [] for uid in secret_uids: - is_record = uid in params.record_cache - is_shared_folder = api.is_shared_folder(params, uid) - - if is_record: - rec = params.record_cache[uid] - share_key_decrypted = rec['record_key_unencrypted'] - share_type = 'SHARE_TYPE_RECORD' - elif is_shared_folder: - cached_sf = params.shared_folder_cache[uid] - shared_folder_key_unencrypted = cached_sf.get('shared_folder_key_unencrypted') - share_key_decrypted = shared_folder_key_unencrypted - share_type = 'SHARE_TYPE_FOLDER' - else: - print(f"""{bcolors.WARNING}\tUID="{uid}" is not a Record nor Shared Folder. Only individual records or - Shared Folders can be added to the application.{bcolors.ENDC} Make sure your local cache is up to date by + secret = KSMCommand.classify_secret(params, uid) + if not secret: + print(f"""{bcolors.WARNING}\tUID="{uid}" is not a Record, Shared Folder, or Nested Share Folder record. + Only individual records or folders can be added to the application.{bcolors.ENDC} Make sure your local cache is up to date by running 'sync-down' command and trying again.""") + continue + share_type = secret['share_type'] + uid = secret['uid'] + + # NSF folders/records must use v3 access_update / records share APIs. + if ((share_type == 'SHARE_TYPE_FOLDER' and is_nested_share_folder(params, uid)) + or (share_type == 'SHARE_TYPE_RECORD' and is_nested_share_record(params, uid))): + try: + KSMCommand._share_nsf_secret( + params, app_uid, master_key, secret, is_editable=is_editable) + added_secret_uids_type_pairs.append((uid, share_type)) + except KeeperApiError as kae: + if 'already' in (kae.message or '').lower(): + logging.error("One of the secret UIDs is already shared to this application. " + "Please remove already shared UIDs from your command and try again.") + else: + raise kae continue + share_key_decrypted = secret['share_key'] added_secret_uids_type_pairs.append((uid, share_type)) encrypted_secret_key = crypto.encrypt_aes_v2(share_key_decrypted, master_key) @@ -942,15 +1075,20 @@ def share_secret(params, app_uid, master_key, secret_uids, is_editable=False): if len(added_secret_uids_type_pairs) == 0: return - app_share_add_rq = APIRequest_pb2.AddAppSharesRequest() - app_share_add_rq.appRecordUid = utils.base64_url_decode(app_uid) - app_share_add_rq.shares.extend(app_shares) - try: - api.communicate_rest(params, app_share_add_rq, 'vault/app_share_add') + if app_shares: + app_share_add_rq = APIRequest_pb2.AddAppSharesRequest() + app_share_add_rq.appRecordUid = utils.base64_url_decode(app_uid) + app_share_add_rq.shares.extend(app_shares) + api.communicate_rest(params, app_share_add_rq, 'vault/app_share_add') + print(bcolors.OKGREEN + f'\nSuccessfully added secrets to app uid={app_uid}, ' f'editable=' + bcolors.BOLD + f'{is_editable}:' + bcolors.ENDC) - print('\n'.join(map(lambda x: ('\t' + str(x[0])) + ' ' + ('Record' if ('RECORD' in str(x[1])) else 'Shared Folder'), added_secret_uids_type_pairs))) + print('\n'.join(map(lambda x: ('\t' + str(x[0])) + ' ' + ( + 'Nested Share Folder' if is_nested_share_folder(params, x[0]) else + 'Nested Share Record' if is_nested_share_record(params, x[0]) else + 'Record' if 'RECORD' in str(x[1]) else 'Shared Folder'), + added_secret_uids_type_pairs))) print('\n') return True except KeeperApiError as kae: @@ -961,21 +1099,125 @@ def share_secret(params, app_uid, master_key, secret_uids, is_editable=False): raise kae return False + @staticmethod + def _is_ksm_app_data(data_dict): + return isinstance(data_dict, dict) and data_dict.get('type') == 'app' + + @staticmethod + def _app_record_from_cache_entry(rec_cache_val): + if not rec_cache_val or rec_cache_val.get('version') != 5: + return None + data_unencrypted = rec_cache_val.get('data_unencrypted') + if not data_unencrypted: + return None + try: + data_dict = json.loads(data_unencrypted.decode('utf-8')) + except Exception: + return None + if KSMCommand._is_ksm_app_data(data_dict): + return rec_cache_val + return None + + @staticmethod + def _normalize_nsf_app_record(params, record_uid): + nsf_rec = getattr(params, 'nested_share_records', {}).get(record_uid) + if not nsf_rec: + return None + + nsf_data = getattr(params, 'nested_share_record_data', {}).get(record_uid, {}) + data_json = nsf_data.get('data_json', {}) + if not KSMCommand._is_ksm_app_data(data_json): + return None + + record_key = nsf_rec.get('record_key_unencrypted') + if not record_key: + record_key = get_record_key(params, record_uid, raise_on_missing=False) + if not record_key: + return None + + return { + 'record_uid': record_uid, + 'version': nsf_rec.get('version', 5), + 'revision': nsf_rec.get('revision', 0), + 'record_key_unencrypted': record_key, + 'data_unencrypted': json.dumps(data_json).encode('utf-8'), + 'source': 'nested_share_folder', + } + @staticmethod def get_app_record(params, app_name_or_uid): + if not app_name_or_uid: + return None + + if app_name_or_uid in getattr(params, 'record_cache', {}): + rec = KSMCommand._app_record_from_cache_entry(params.record_cache[app_name_or_uid]) + if rec: + return rec + + nsf_rec = KSMCommand._normalize_nsf_app_record(params, app_name_or_uid) + if nsf_rec: + return nsf_rec for rec_cache_val in params.record_cache.values(): + rec = KSMCommand._app_record_from_cache_entry(rec_cache_val) + if not rec: + continue + r_uid = rec.get('record_uid') + try: + r_unencr_dict = json.loads(rec.get('data_unencrypted').decode('utf-8')) + except Exception: + continue + if r_unencr_dict.get('title') == app_name_or_uid or r_uid == app_name_or_uid: + return rec + + resolved_uid = resolve_nested_share_record_uid(params, app_name_or_uid) + if resolved_uid: + if resolved_uid in getattr(params, 'record_cache', {}): + rec = KSMCommand._app_record_from_cache_entry(params.record_cache[resolved_uid]) + if rec: + return rec + nsf_rec = KSMCommand._normalize_nsf_app_record(params, resolved_uid) + if nsf_rec: + return nsf_rec - if rec_cache_val.get('version') == 5: - r_uid = rec_cache_val.get('record_uid') - r_unencr_json_data = rec_cache_val.get('data_unencrypted').decode('utf-8') - r_unencr_dict = json.loads(r_unencr_json_data) + return None - if r_unencr_dict.get('title') == app_name_or_uid or r_uid == app_name_or_uid: - return rec_cache_val + @staticmethod + def get_app_title(params, app_uid): + rec = KSMCommand.get_app_record(params, app_uid) + if rec and rec.get('data_unencrypted'): + try: + return json.loads(rec.get('data_unencrypted').decode('utf-8')).get('title', app_uid) + except Exception: + pass + + if is_nested_share_record(params, app_uid): + meta = load_record_metadata(params, app_uid) + if meta.get('type') == 'app': + return meta.get('title', app_uid) + nsf_data = getattr(params, 'nested_share_record_data', {}).get(app_uid, {}) + data_json = nsf_data.get('data_json', {}) + if isinstance(data_json, dict) and data_json.get('type') == 'app': + return data_json.get('title', app_uid) return None + @staticmethod + def get_ksm_app_display_info(params, app_uid_str): + ksm_app = KSMCommand.get_app_record(params, app_uid_str) + if ksm_app: + try: + title = json.loads(ksm_app.get('data_unencrypted').decode('utf-8')).get('title', app_uid_str) + except Exception: + title = app_uid_str + return title, True, f'{title} ({app_uid_str})' + + title = KSMCommand.get_app_title(params, app_uid_str) + if title: + return title, False, f'{title} ({app_uid_str})' + + return None, False, f'[APP NOT ACCESSIBLE OR DELETED] ({app_uid_str})' + @staticmethod def search_app_records(params, search_str): @@ -1163,6 +1405,10 @@ def update_app_share(params, secret_uids, app_name_or_uid, is_editable): app_record_uid = rec_cache_val.get('record_uid') master_key = rec_cache_val.get('record_key_unencrypted') + resolved_secret_uids = KSMCommand.resolve_secret_uids(params, secret_uids) + if not resolved_secret_uids: + return + app_info = KSMCommand.get_app_info(params, app_record_uid) existing_shares = { utils.base64_url_encode(s.secretUid): s @@ -1170,7 +1416,18 @@ def update_app_share(params, secret_uids, app_name_or_uid, is_editable): } uids_to_update = [] - for uid in secret_uids: + nsf_uids_to_update = [] + for uid in resolved_secret_uids: + secret = KSMCommand.classify_secret(params, uid) + is_nsf = bool( + secret and ( + (secret['share_type'] == 'SHARE_TYPE_FOLDER' + and is_nested_share_folder(params, secret['uid'])) + or (secret['share_type'] == 'SHARE_TYPE_RECORD' + and is_nested_share_record(params, secret['uid'])))) + if is_nsf: + nsf_uids_to_update.append(secret['uid']) + continue if uid not in existing_shares: logging.warning('Secret "%s" is not currently shared with this application. ' 'Use "share add" to add it first.' % uid) @@ -1182,63 +1439,86 @@ def update_app_share(params, secret_uids, app_name_or_uid, is_editable): continue uids_to_update.append(uid) - if not uids_to_update: + if not uids_to_update and not nsf_uids_to_update: print(bcolors.WARNING + "No share permissions to update." + bcolors.ENDC) return - rq_remove = APIRequest_pb2.RemoveAppSharesRequest() - rq_remove.appRecordUid = utils.base64_url_decode(app_record_uid) - rq_remove.shares.extend(utils.base64_url_decode(uid) for uid in uids_to_update) + from ..nested_share_folder.folder_api import update_folder_access_to_application_v3 + from ..nested_share_folder.record_api import update_record_share_to_application_v3 - try: - api.communicate_rest(params, rq_remove, 'vault/app_share_remove') - except KeeperApiError as kae: - logging.error('Failed to remove shares for update: %s' % kae.message) - return - - app_shares = [] - for uid in uids_to_update: - is_record = uid in params.record_cache - is_shared_folder = api.is_shared_folder(params, uid) - - if is_record: - share_key = params.record_cache[uid]['record_key_unencrypted'] - share_type = 'SHARE_TYPE_RECORD' - elif is_shared_folder: - share_key = params.shared_folder_cache[uid].get('shared_folder_key_unencrypted') - share_type = 'SHARE_TYPE_FOLDER' - else: + updated = [] + for uid in nsf_uids_to_update: + secret = KSMCommand.classify_secret(params, uid) + if not secret: logging.warning('UID "%s" not found in local cache. Run sync-down and try again.' % uid) continue + if secret['share_type'] == 'SHARE_TYPE_FOLDER': + result = update_folder_access_to_application_v3( + params, uid, app_record_uid, is_editable=is_editable) + else: + result = update_record_share_to_application_v3( + params, uid, app_record_uid, master_key, is_editable=is_editable) + if not result.get('success'): + logging.error('Failed to update NSF share "%s": %s', + uid, result.get('message') or result) + continue + updated.append(uid) - encrypted_secret_key = crypto.encrypt_aes_v2(share_key, master_key) + if uids_to_update: + rq_remove = APIRequest_pb2.RemoveAppSharesRequest() + rq_remove.appRecordUid = utils.base64_url_decode(app_record_uid) + rq_remove.shares.extend(utils.base64_url_decode(uid) for uid in uids_to_update) - app_share = APIRequest_pb2.AppShareAdd() - app_share.secretUid = utils.base64_url_decode(uid) - app_share.shareType = APIRequest_pb2.ApplicationShareType.Value(share_type) - app_share.encryptedSecretKey = encrypted_secret_key - app_share.editable = is_editable + try: + api.communicate_rest(params, rq_remove, 'vault/app_share_remove') + except KeeperApiError as kae: + logging.error('Failed to remove shares for update: %s' % kae.message) + return - app_shares.append(app_share) + app_shares = [] + for uid in uids_to_update: + secret = KSMCommand.classify_secret(params, uid) + if not secret: + logging.warning('UID "%s" not found in local cache. Run sync-down and try again.' % uid) + continue - if not app_shares: - return + share_key = secret['share_key'] + share_type = secret['share_type'] + uid = secret['uid'] - rq_add = APIRequest_pb2.AddAppSharesRequest() - rq_add.appRecordUid = utils.base64_url_decode(app_record_uid) - rq_add.shares.extend(app_shares) + encrypted_secret_key = crypto.encrypt_aes_v2(share_key, master_key) - try: - api.communicate_rest(params, rq_add, 'vault/app_share_add') + app_share = APIRequest_pb2.AppShareAdd() + app_share.secretUid = utils.base64_url_decode(uid) + app_share.shareType = APIRequest_pb2.ApplicationShareType.Value(share_type) + app_share.encryptedSecretKey = encrypted_secret_key + app_share.editable = is_editable + + app_shares.append(app_share) + + if not app_shares and not updated: + return + + if app_shares: + rq_add = APIRequest_pb2.AddAppSharesRequest() + rq_add.appRecordUid = utils.base64_url_decode(app_record_uid) + rq_add.shares.extend(app_shares) + + try: + api.communicate_rest(params, rq_add, 'vault/app_share_add') + updated.extend(uids_to_update) + except KeeperApiError as kae: + logging.error('Failed to re-add shares with updated permissions: %s' % kae.message) + return + + if updated: perm = "editable" if is_editable else "read-only" print(bcolors.OKGREEN + f'\nSuccessfully updated share permissions to {perm} for app uid={app_record_uid}:' + bcolors.ENDC) - for uid in uids_to_update: + for uid in updated: print(f'\t{uid}') print() - except KeeperApiError as kae: - logging.error('Failed to re-add shares with updated permissions: %s' % kae.message) @staticmethod def remove_share(params, app_name_or_uid, secret_uids): @@ -1248,11 +1528,38 @@ def remove_share(params, app_name_or_uid, secret_uids): app_uid = app.get('record_uid') - rq = APIRequest_pb2.RemoveAppSharesRequest() + resolved_uids = KSMCommand.resolve_secret_uids(params, secret_uids) + if not resolved_uids: + raise Exception("No valid record or folder secrets were found for removal") + + from ..nested_share_folder.folder_api import revoke_folder_access_from_application_v3 + from ..nested_share_folder.record_api import unshare_record_from_application_v3 + + classic_uids = [] + for uid in resolved_uids: + secret = KSMCommand.classify_secret(params, uid) + if secret and secret['share_type'] == 'SHARE_TYPE_FOLDER' and is_nested_share_folder(params, uid): + result = revoke_folder_access_from_application_v3(params, uid, app_uid) + if not result.get('success'): + raise KeeperApiError( + result.get('status') or 'access_denied', + result.get('message') or f'Failed to remove NSF folder share {uid}') + continue + if secret and secret['share_type'] == 'SHARE_TYPE_RECORD' and is_nested_share_record(params, uid): + result = unshare_record_from_application_v3(params, uid, app_uid) + if not result.get('success'): + message = '; '.join( + r.get('message') or r.get('status') or 'revoke failed' + for r in result.get('results', [])) or f'Failed to remove NSF record share {uid}' + raise KeeperApiError('share_failed', message) + continue + classic_uids.append(uid) - rq.appRecordUid = utils.base64_url_decode(app_uid) - rq.shares.extend((utils.base64_url_decode(x) for x in secret_uids)) - api.communicate_rest(params, rq, 'vault/app_share_remove') + if classic_uids: + rq = APIRequest_pb2.RemoveAppSharesRequest() + rq.appRecordUid = utils.base64_url_decode(app_uid) + rq.shares.extend((utils.base64_url_decode(x) for x in classic_uids)) + api.communicate_rest(params, rq, 'vault/app_share_remove') print(bcolors.OKGREEN + "Secret share was successfully removed from the application\n" + bcolors.ENDC) @staticmethod diff --git a/keepercommander/commands/nested_share_folder/folder_commands.py b/keepercommander/commands/nested_share_folder/folder_commands.py index eb8be8341..52eb39dbb 100644 --- a/keepercommander/commands/nested_share_folder/folder_commands.py +++ b/keepercommander/commands/nested_share_folder/folder_commands.py @@ -21,9 +21,11 @@ from ..base import Command from ...error import CommandError from ... import nested_share_folder as _nsf +from ... import vault_extensions from .helpers import ( ROOT_FOLDER_UID, normalize_parent_uid, resolve_folder_uid, parse_expiration, + validate_rotate_on_expiration, command_error_handler, check_result, check_folder_edit_permission, check_folder_share_permission, check_folder_delete_permission, classify_share_recipient, @@ -238,18 +240,32 @@ def execute(self, params, **kwargs): show_folders = kwargs.get('folders', False) show_records = kwargs.get('records', False) + roe_eligible = bool(kwargs.get('roe_eligible')) fmt = kwargs.get('format', 'table') - if not show_folders and not show_records: + if roe_eligible: + show_folders, show_records = True, False + elif not show_folders and not show_records: show_folders = show_records = True combined = [] if show_folders: - combined.extend(self._collect_folders(params)) + folders = self._collect_folders(params) + if roe_eligible: + folders = [ + row for row in folders + if vault_extensions.nested_share_folder_has_pam_user_with_rotation( + params, row[1]) + ] + combined.extend(folders) if show_records: combined.extend(self._collect_records(params)) if not combined: + if roe_eligible: + logging.info( + "No Nested Share Folders eligible for --rotate-on-expiration.") + return self._print_empty_summary(params, show_folders, show_records) return @@ -341,6 +357,7 @@ def execute(self, params, **kwargs): recipients = kwargs.get('user') or [] folder_args = kwargs.get('folder') or [] role = kwargs.get('role') or 'viewer' + rotate_on_expiration = bool(kwargs.get('rotate_on_expiration')) if not folder_args: raise CommandError('nsf-share-folder', 'Folder path or UID is required') @@ -353,6 +370,9 @@ def execute(self, params, **kwargs): expiration = parse_expiration( kwargs.get('expire_at'), kwargs.get('expire_in'), 'nsf-share-folder') + if rotate_on_expiration: + validate_rotate_on_expiration('nsf-share-folder', action, expiration) + for folder_arg in folder_args: folder_uid = resolve_folder_uid(params, folder_arg) if not folder_uid: @@ -361,6 +381,13 @@ def execute(self, params, **kwargs): identifier=folder_arg) check_folder_share_permission(params, folder_uid, 'nsf-share-folder') + if rotate_on_expiration and \ + not vault_extensions.nested_share_folder_has_pam_user_with_rotation(params, folder_uid): + raise CommandError( + 'nsf-share-folder', + '--rotate-on-expiration requires the folder to contain at least one pamUser ' + f'record with rotation configured. Ineligible folder: {folder_arg!r}') + targets = self._collect_targets(params, recipients, folder_uid, folder_arg) if not targets: raise CommandError( @@ -368,7 +395,8 @@ def execute(self, params, **kwargs): 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) + expiration, as_team=is_team, + rotate_on_expiration=rotate_on_expiration) @classmethod def _collect_targets(cls, params, recipients, folder_uid, folder_arg): @@ -464,7 +492,7 @@ def _expand_existing(params, folder_uid, folder_arg): @classmethod def _apply(cls, params, action, folder_uid, recipient, role, expiration, - as_team=False): + as_team=False, rotate_on_expiration=False): api_name, verb = cls._ACTIONS[action] api_func = getattr(_nsf, api_name) kw = dict(params=params, folder_uid=folder_uid, user_uid=recipient, @@ -473,6 +501,8 @@ def _apply(cls, params, action, folder_uid, recipient, role, expiration, kw['role'] = role if action == 'grant' and expiration is not None: kw['expiration_timestamp'] = expiration + if action == 'grant' and rotate_on_expiration: + kw['rotate_on_expiration'] = True kind = 'Team' if as_team else 'User' try: result = api_func(**kw) diff --git a/keepercommander/commands/nested_share_folder/helpers.py b/keepercommander/commands/nested_share_folder/helpers.py index b4352a1dd..e5dd397ec 100644 --- a/keepercommander/commands/nested_share_folder/helpers.py +++ b/keepercommander/commands/nested_share_folder/helpers.py @@ -103,6 +103,15 @@ def command_error_handler(cmd_name): raise CommandError(cmd_name, str(exc)) +def normalize_nsf_user_message(message): + """Replace legacy server terminology in user-facing error text.""" + if not message: + return message + return (message + .replace('Keeper Drive', 'Nested Share Folder') + .replace('keeper drive', 'nested share folder')) + + def check_result(result, cmd_name): """Raise ``CommandError`` when *result['success']* is falsy. @@ -110,7 +119,8 @@ def check_result(result, cmd_name): appears in almost every command. """ if not result.get('success'): - raise CommandError(cmd_name, result.get('message', 'Unknown error')) + raise CommandError(cmd_name, normalize_nsf_user_message( + result.get('message', 'Unknown error'))) # ═══════════════════════════════════════════════════════════════════════════ @@ -327,6 +337,19 @@ def validate_share_expiration_timestamp(expiration_ms, cmd_name, *, now_ms=None) ) +def validate_rotate_on_expiration(cmd_name, action, expiration): + """Reject ``--rotate-on-expiration`` unless granting with a positive expiry.""" + if action != 'grant': + raise CommandError( + cmd_name, + '--rotate-on-expiration is only valid when granting access') + if not isinstance(expiration, int) or expiration <= 0: + raise CommandError( + cmd_name, + '--rotate-on-expiration requires a positive --expire-at / --expire-in ' + '(cannot be "never").') + + def parse_expiration(expire_at, expire_in, cmd_name): """Parse ``--expire-at`` / ``--expire-in`` into a millisecond timestamp. @@ -364,7 +387,9 @@ def parse_expiration(expire_at, expire_in, cmd_name): cmd_name, 'Share expiration must be at least 1 minute.', ) + # Freeze now for compute + validate (classic share-record / share-folder pattern). now = datetime.datetime.now(timezone.utc) + now_ms = int(now.timestamp() * 1000) delta_map = { 'mi': timedelta(minutes=amount), 'h': timedelta(hours=amount), @@ -374,7 +399,7 @@ def parse_expiration(expire_at, expire_in, cmd_name): } delta = next(v for k, v in delta_map.items() if unit.startswith(k)) expiration_ms = int((now + delta).timestamp() * 1000) - validate_share_expiration_timestamp(expiration_ms, cmd_name) + validate_share_expiration_timestamp(expiration_ms, cmd_name, now_ms=now_ms) return expiration_ms diff --git a/keepercommander/commands/nested_share_folder/parsers.py b/keepercommander/commands/nested_share_folder/parsers.py index ff9b3ac9f..37ad1baf6 100644 --- a/keepercommander/commands/nested_share_folder/parsers.py +++ b/keepercommander/commands/nested_share_folder/parsers.py @@ -71,6 +71,11 @@ def _make_parser(prog, description): '--folders', action='store_true', help='Show only folders') nested_share_folder_list_parser.add_argument( '--records', action='store_true', help='Show only records') +nested_share_folder_list_parser.add_argument( + '--roe-eligible', dest='roe_eligible', action='store_true', + help='only list Nested Share Folders eligible for --rotate-on-expiration ' + '(contain at least one pamUser record with rotation configured; ' + 'implies --folders)') nested_share_folder_list_parser.add_argument( '--format', dest='format', choices=['table', 'csv', 'json'], default='table', help='Output format (default: table)') @@ -104,6 +109,12 @@ def _make_parser(prog, description): _sf_expire.add_argument( '--expire-in', dest='expire_in', action='store', metavar='PERIOD', help='share expiration: never or period (e.g. 30d, 6mo, 1y, 24h, 30mi)') +nested_share_folder_share_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.') nested_share_folder_share_parser.add_argument( 'folder', nargs='+', type=str, help='Nested Share Folder path or UID') @@ -227,6 +238,11 @@ def _make_parser(prog, description): '--expire-in', dest='expire_in', metavar='[(mi)nutes|(h)ours|(d)ays|(mo)nths|(y)ears]', type=str, help='share expiration: never or period (e.g. 30d, 6mo, 1y)') +nested_share_record_share_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 a pamUser record with rotation configured.') nested_share_record_permission_parser = _make_parser( diff --git a/keepercommander/commands/nested_share_folder/record_commands.py b/keepercommander/commands/nested_share_folder/record_commands.py index 6fde8bc25..6a0480646 100644 --- a/keepercommander/commands/nested_share_folder/record_commands.py +++ b/keepercommander/commands/nested_share_folder/record_commands.py @@ -97,7 +97,12 @@ def execute(self, params, **kwargs): return with command_error_handler('nsf-record-add'): - result = _nsf.create_record_v3(params=params, folder_uid=folder_uid, record_data=data) + result = _nsf.create_record_v3( + params=params, + folder_uid=folder_uid, + record_data=data, + record_uid=kwargs.get('record_uid'), + ) check_result(result, 'nsf-record-add') params.sync_data = True return result['record_uid'] diff --git a/keepercommander/commands/nested_share_folder/sharing_commands.py b/keepercommander/commands/nested_share_folder/sharing_commands.py index d4029fac5..36128473c 100644 --- a/keepercommander/commands/nested_share_folder/sharing_commands.py +++ b/keepercommander/commands/nested_share_folder/sharing_commands.py @@ -26,8 +26,10 @@ from ..base import Command from ...error import CommandError from ... import nested_share_folder as _nsf +from ... import vault_extensions from .helpers import ( parse_expiration, get_access_role_label, + validate_rotate_on_expiration, command_error_handler, check_result, check_record_share_permission, collect_records_in_folder, @@ -58,6 +60,7 @@ def execute(self, params, **kwargs): dry_run = kwargs.get('dry_run', False) recursive = kwargs.get('recursive', False) force = kwargs.get('force', False) + rotate_on_expiration = bool(kwargs.get('rotate_on_expiration')) if not record_arg: raise CommandError('nsf-share-record', 'Record path or UID is required') @@ -76,23 +79,38 @@ def execute(self, params, **kwargs): access_role_type = _nsf.resolve_role_name(role) if role else None record_uids = self._resolve_record_uids(params, record_arg, recursive) + if rotate_on_expiration: + validate_rotate_on_expiration('nsf-share-record', action, expiration) + ineligible = [ + uid for uid in record_uids + if not vault_extensions.nested_share_record_is_pam_user_with_rotation(params, uid) + ] + if ineligible: + raise CommandError( + 'nsf-share-record', + '--rotate-on-expiration requires a pamUser record with rotation configured. ' + 'Ineligible record(s): ' + ', '.join(sorted(ineligible))) + for uid in record_uids: check_record_share_permission(params, uid, 'nsf-share-record') if dry_run: - self._print_dry_run(action, record_uids, emails, role, expiration) + self._print_dry_run(action, record_uids, emails, role, expiration, + rotate_on_expiration) return with command_error_handler('nsf-share-record'): for email in emails: for record_uid in record_uids: result, effective_action = self._dispatch( - params, action, record_uid, email, access_role_type, expiration) + params, action, record_uid, email, access_role_type, expiration, + rotate_on_expiration) self._log_results(result, effective_action, email) # Strategy dispatch — returns (result, effective_action) @staticmethod - def _dispatch(params, action, record_uid, email, access_role_type, expiration): + def _dispatch(params, action, record_uid, email, access_role_type, expiration, + rotate_on_expiration=False): if action == 'owner': return (_nsf.transfer_record_ownership_v3( params=params, record_uid=record_uid, new_owner_email=email), 'owner') @@ -102,7 +120,8 @@ def _dispatch(params, action, record_uid, email, access_role_type, expiration): params, record_uid, email) if existing: if _nsf.is_record_share_update_noop( - existing, access_role_type, expiration): + existing, access_role_type, expiration, + rotate_on_expiration=rotate_on_expiration): logging.info( "Record '%s' already shared with '%s' at the requested " "role and expiration; no change needed.", @@ -121,11 +140,13 @@ def _dispatch(params, action, record_uid, email, access_role_type, expiration): return (_nsf.update_record_share_v3( params=params, record_uid=record_uid, recipient_email=email, access_role_type=access_role_type, - expiration_timestamp=expiration), 'update') + expiration_timestamp=expiration, + rotate_on_expiration=rotate_on_expiration), 'update') return (_nsf.share_record_v3( params=params, record_uid=record_uid, recipient_email=email, access_role_type=access_role_type, - expiration_timestamp=expiration), 'grant') + expiration_timestamp=expiration, + rotate_on_expiration=rotate_on_expiration), 'grant') return (_nsf.unshare_record_v3( params=params, record_uid=record_uid, recipient_email=email), 'revoke') @@ -233,7 +254,8 @@ def _resolve_record_uids(params, record_arg, recursive): return [resolved_uid] @staticmethod - def _print_dry_run(action, record_uids, emails, role, expiration): + def _print_dry_run(action, record_uids, emails, role, expiration, + rotate_on_expiration=False): print(f"[dry-run] Action : {action.upper()}") print(f"[dry-run] Records : {', '.join(record_uids)}") if action == 'owner': @@ -245,6 +267,8 @@ def _print_dry_run(action, record_uids, emails, role, expiration): print(f"[dry-run] Role : {role}") if expiration: print(f"[dry-run] Expires : {expiration} ms") + if rotate_on_expiration: + print("[dry-run] Rotate : rotate password on share expiration") # ══════════════════════════════════════════════════════════════════════════ diff --git a/keepercommander/commands/pam/config_helper.py b/keepercommander/commands/pam/config_helper.py index d2aded194..a011fd862 100644 --- a/keepercommander/commands/pam/config_helper.py +++ b/keepercommander/commands/pam/config_helper.py @@ -1,15 +1,16 @@ -import base64 import json import logging import os from keeper_secrets_manager_core.utils import string_to_bytes, bytes_to_string -from ..folder import FolderMoveCommand from ..record import RecordRemoveCommand -from ... import api, crypto, utils, vault, vault_extensions +from ... import api, crypto, utils, vault, vault_extensions, subfolder +from ...error import KeeperApiError +from ...nested_share_folder.common import get_folder_key +from ...nested_share_folder.record_api import create_record_data_v3, record_add_pam_configuration_v3 from ...params import KeeperParams -from ...proto import pam_pb2, router_pb2 +from ...proto import folder_pb2, pam_pb2, record_pb2, router_pb2 def pam_decrypt_configuration_data(pam_config_v6_record): @@ -66,8 +67,27 @@ def pam_configuration_get_field(unencrypted_record, field_identifier): return field +def _resolve_pam_configuration_folder_key(params, folder_uid): + # type: (KeeperParams, str) -> bytes | None + folder_key = get_folder_key(params, folder_uid, raise_on_missing=False) + if folder_key: + return folder_key + + folder = params.folder_cache.get(folder_uid) + shared_folder_uid = None + if isinstance(folder, subfolder.SharedFolderFolderNode): + shared_folder_uid = folder.shared_folder_uid + elif isinstance(folder, subfolder.SharedFolderNode): + shared_folder_uid = folder.uid + if shared_folder_uid and shared_folder_uid in params.shared_folder_cache: + shared_folder = params.shared_folder_cache.get(shared_folder_uid) + return shared_folder.get('shared_folder_key_unencrypted') + return None + + def pam_configuration_create_record_v6(params, record, folder_uid): - # type: (KeeperParams, vault.TypedRecord, str, str) -> None + # type: (KeeperParams, vault.TypedRecord, str) -> None + """Create a classic PAM configuration via pam/add_configuration_record.""" if not record.record_uid: record.record_uid = utils.generate_uid() @@ -85,6 +105,66 @@ def pam_configuration_create_record_v6(params, record, folder_uid): api.communicate_rest(params, car, 'pam/add_configuration_record') +def pam_configuration_create_record_nsf(params, record, folder_uid): + # type: (KeeperParams, vault.TypedRecord, str) -> None + """Create a PAM configuration in an NSF folder via vault/records/v3/add_pam_configuration.""" + if not record.record_uid: + record.record_uid = utils.generate_uid() + + if not record.record_key: + record.record_key = utils.generate_aes_key() + + record_data = vault_extensions.extract_typed_record_data(record) + folder_key = _resolve_pam_configuration_folder_key(params, folder_uid) if folder_uid else None + client_time = utils.current_milli_time() + + audit = None + if params.enterprise_ec_key: + audit_data = vault_extensions.extract_audit_data(record) + if audit_data: + audit = record_pb2.RecordAudit() + audit.version = 0 + audit.data = crypto.encrypt_ec( + json.dumps(audit_data).encode('utf-8'), params.enterprise_ec_key) + + if folder_uid and folder_key: + record_add = create_record_data_v3( + record_uid=record.record_uid, + record_key=record.record_key, + data=record_data, + folder_uid=folder_uid, + folder_key=folder_key, + data_key=params.data_key, + owner_key=params.data_key, + client_modified_time=client_time, + audit=audit, + ) + else: + record_add = create_record_data_v3( + record_uid=record.record_uid, + record_key=record.record_key, + data=record_data, + data_key=params.data_key, + owner_key=params.data_key, + client_modified_time=client_time, + audit=audit, + ) + if folder_uid: + record_add.folderUid = utils.base64_url_decode(folder_uid) + record_add.recordKeyEncryptedBy = folder_pb2.ENCRYPTED_BY_USER_KEY + + response = record_add_pam_configuration_v3(params, [record_add], client_time=client_time) + if not response.records: + raise KeeperApiError('no_results', 'No results from PAM configuration creation') + + record_rs = response.records[0] + if record_rs.status != record_pb2.RS_SUCCESS: + raise KeeperApiError( + record_pb2.RecordModifyResult.Name(record_rs.status), + record_rs.message or 'Failed to create PAM configuration record') + record.revision = response.revision + + def pam_configuration_create(params, gateway_uid_bytes, config_json_str, child_config_json_strings=None, parent_uid_bytes=None): config_operation = pam_pb2.PAMDataOperation() @@ -162,10 +242,36 @@ def pam_configurations_get_all(params): def pam_configuration_remove(params, configuration_uid): # TODO: Check if there are record rotations associated with this config and warn user about that before removing. - RecordRemoveCommand().execute(params, record=configuration_uid, force=True) + from ..nested_share_folder.helpers import is_nested_share_record + from ... import nested_share_folder as _nsf + from ...subfolder import find_folders + from .config_facades import PamConfigurationRecordFacade + + if is_nested_share_record(params, configuration_uid): + folder_uids = list(find_folders(params, configuration_uid)) + folder_uid = folder_uids[0] if folder_uids else None + if not folder_uid: + config = vault.KeeperRecord.load(params, configuration_uid) + if config and isinstance(config, vault.TypedRecord): + facade = PamConfigurationRecordFacade() + facade.record = config + folder_uid = facade.folder_uid + result = _nsf.remove_record_v3(params, [{ + 'record_uid': configuration_uid, + 'folder_uid': folder_uid, + 'operation_type': 'owner-trash', + }], dry_run=False) + if not result.get('confirmed'): + raise Exception( + 'PAM Configuration NSF removal was not confirmed by the server.') + else: + RecordRemoveCommand().execute(params, record=configuration_uid, force=True) if configuration_uid in params.record_cache: del params.record_cache[configuration_uid] + nested_recs = getattr(params, 'nested_share_records', {}) + if configuration_uid in nested_recs: + del nested_recs[configuration_uid] logging.info('PAM Configuration was removed successfully.') diff --git a/keepercommander/commands/pam/vault_target.py b/keepercommander/commands/pam/vault_target.py new file mode 100644 index 000000000..45d28e589 --- /dev/null +++ b/keepercommander/commands/pam/vault_target.py @@ -0,0 +1,680 @@ +from ..folder import FolderMoveCommand +from ... import api, record_management, vault, vault_extensions +from ...error import CommandError +from ...nested_share_folder.folder_record_api import move_record_v3 +from ...subfolder import BaseFolderNode, get_folder_path, try_resolve_path +from .config_facades import PamConfigurationRecordFacade + + +def is_nested_share_folder(params, folder_uid): + if not folder_uid: + return False + + if folder_uid in getattr(params, 'nested_share_folders', {}): + return True + + subfolder = getattr(params, 'subfolder_cache', {}).get(folder_uid) + if isinstance(subfolder, dict) and subfolder.get('source') == 'nested_share_folder': + return True + + folder = getattr(params, 'folder_cache', {}).get(folder_uid) + return bool(folder and getattr(folder, 'type', None) == BaseFolderNode.NestedShareFolderType) + + +def resolve_pam_folder_uid(params, folder_name, allow_legacy_user=False): + if not folder_name: + return None + + if folder_name in getattr(params, 'shared_folder_cache', {}): + return folder_name + + if allow_legacy_user and folder_name in getattr(params, 'folder_cache', {}): + return folder_name + + if is_nested_share_folder(params, folder_name): + return folder_name + + rs = try_resolve_path(params, folder_name) + if rs: + folder, remainder = rs + if folder and not remainder and folder.uid: + if (folder.type == BaseFolderNode.SharedFolderType + or (allow_legacy_user and folder.uid in getattr(params, 'folder_cache', {})) + or is_nested_share_folder(params, folder.uid)): + return folder.uid + + nsf_matches = [ + uid for uid, folder in getattr(params, 'nested_share_folders', {}).items() + if str(folder.get('name', '')).casefold() == str(folder_name).casefold() + ] + if len(nsf_matches) == 1: + return nsf_matches[0] + + return None + + +def pam_folder_exists(params, folder_uid): + if not folder_uid: + return False + if folder_uid in getattr(params, 'folder_cache', {}): + return True + return is_nested_share_folder(params, folder_uid) + + +def get_pam_folder_path(params, folder_uid, delimiter='/'): + if not folder_uid: + return '' + + parts = [] + uid = folder_uid + seen = set() + while uid and uid not in seen: + seen.add(uid) + folder = getattr(params, 'folder_cache', {}).get(uid) + if folder is not None: + name = str(folder.name or '').replace(delimiter, delimiter * 2) + parts.insert(0, name) + uid = folder.parent_uid + continue + + nsf = getattr(params, 'nested_share_folders', {}).get(uid) + if nsf: + name = str(nsf.get('name', '')).replace(delimiter, delimiter * 2) + parts.insert(0, name) + uid = nsf.get('parent_uid') + continue + break + + if parts: + return delimiter.join(parts) + return get_folder_path(params, folder_uid, delimiter=delimiter) + + +def resolve_pam_application_folder(params, pam_config_uid, command='pam'): + pam_config_record = vault.KeeperRecord.load(params, pam_config_uid) + if not pam_config_record: + raise CommandError(command, f'PAM Configuration not found: {pam_config_uid}') + + facade = PamConfigurationRecordFacade() + facade.record = pam_config_record + facade.load_typed_fields() + folder_uid = facade.folder_uid + if not folder_uid: + raise CommandError( + command, + f"PAM Configuration '{pam_config_record.title}' has no application folder configured.\n" + f'Please configure the application folder in the PAM Configuration record.') + + if not pam_folder_exists(params, folder_uid): + raise CommandError( + command, + f'Application folder (UID: {folder_uid}) not found in vault.\n' + f'Ensure the folder is shared with you.') + + return folder_uid, get_pam_folder_path(params, folder_uid) + + +def _find_child_folder(params, parent_uid, name=None, name_suffix=None): + if not parent_uid: + return None + + parent_uid = parent_uid or None + for uid, folder in getattr(params, 'folder_cache', {}).items(): + if folder.parent_uid != parent_uid: + continue + folder_name = str(folder.name or '') + if name is not None and folder_name == name: + return uid + if name_suffix is not None and folder_name.endswith(name_suffix): + return uid + + for uid, folder in getattr(params, 'nested_share_folders', {}).items(): + if folder.get('parent_uid') != parent_uid: + continue + folder_name = str(folder.get('name', '')) + if name is not None and folder_name == name: + return uid + if name_suffix is not None and folder_name.endswith(name_suffix): + return uid + + return None + + +def find_nsf_users_subfolder(params, parent_uid): + return _find_child_folder(params, parent_uid, name_suffix=' - Users') + + +def records_in_folder(params, folder_uid): + records = set((getattr(params, 'subfolder_record_cache', None) or {}).get(folder_uid, set()) or set()) + nsf_records = getattr(params, 'nested_share_folder_records', None) or {} + if folder_uid in nsf_records: + records.update(nsf_records[folder_uid] or set()) + return records + + +def resolve_provision_target_folder(params, pam_config_uid, folder_spec=None, department='Default', + command='pam'): + app_uid, app_path = resolve_pam_application_folder(params, pam_config_uid, command=command) + + if folder_spec: + folder_spec = str(folder_spec).strip() + resolved = resolve_pam_folder_uid(params, folder_spec, allow_legacy_user=True) + if resolved: + return resolved + if folder_spec in getattr(params, 'shared_folder_cache', {}): + return folder_spec + relative = folder_spec.strip('/') + if is_nested_share_folder(params, app_uid): + return ensure_pam_folder_path(params, app_uid, relative, command=command) + if app_path: + return _ensure_legacy_folder_path(params, f'{app_path}/{relative}', command=command) + return _ensure_legacy_folder_path(params, relative, command=command) + + if is_nested_share_folder(params, app_uid): + users_uid = find_nsf_users_subfolder(params, app_uid) + if users_uid: + return users_uid + return ensure_pam_folder_path(params, app_uid, f'PAM Users/{department}', command=command) + + target_path = f'{app_path}/PAM Users/{department}' if app_path else f'PAM Users/{department}' + return _ensure_legacy_folder_path(params, target_path, command=command) + + +def resolve_access_user_save_folder(params, config_uid, folder_spec=None, command='pam-access-user-provision'): + if folder_spec: + folder_uid = resolve_pam_folder_uid(params, folder_spec, allow_legacy_user=True) + if not folder_uid: + raise CommandError(command, f'Folder not found: {folder_spec}') + return folder_uid + + app_uid, _ = resolve_pam_application_folder(params, config_uid, command=command) + if is_nested_share_folder(params, app_uid): + return find_nsf_users_subfolder(params, app_uid) or app_uid + return app_uid + + +def _ensure_legacy_folder_path(params, folder_path, command='pam'): + folder_uid = None + rs = try_resolve_path(params, folder_path) + if rs: + folder_node, remainder = rs + if folder_node and not remainder: + folder_uid = folder_node.uid + + if folder_uid: + return folder_uid + + from ..folder import FolderMakeCommand + components = [x.strip() for x in str(folder_path or '').replace('\\', '/').split('/') if x.strip()] + current_path = '' + for i, component in enumerate(components): + current_path = f'{current_path}/{component}' if current_path else component + rs = try_resolve_path(params, current_path) + if rs and rs[0] and not rs[1]: + continue + FolderMakeCommand().execute( + params, + folder=current_path, + shared_folder=(i == 0), + user_folder=(i != 0), + ) + api.sync_down(params) + + rs = try_resolve_path(params, folder_path) + if rs and rs[0] and not rs[1]: + return rs[0].uid + raise CommandError(command, f'Folder path creation succeeded but final UID not found: {folder_path}') + + +def get_pam_folder_info(params, folder_uid): + # type: (...) -> Optional[dict] + """Return folder name, uid, and type for a PAM configuration target folder.""" + if not folder_uid: + return None + if folder_uid in getattr(params, 'shared_folder_cache', {}): + sf = api.get_shared_folder(params, folder_uid) + return { + 'uid': folder_uid, + 'name': sf.name if sf else folder_uid, + 'type': 'shared_folder', + } + if is_nested_share_folder(params, folder_uid): + nsf = getattr(params, 'nested_share_folders', {}).get(folder_uid, {}) + return { + 'uid': folder_uid, + 'name': nsf.get('name', folder_uid), + 'type': 'nested_share_folder', + } + folder = getattr(params, 'folder_cache', {}).get(folder_uid) + if folder: + return { + 'uid': folder_uid, + 'name': folder.name, + 'type': 'user_folder', + } + return {'uid': folder_uid, 'name': folder_uid, 'type': 'unknown'} + + +def format_pam_folder_display(folder_info): + # type: (Optional[dict]) -> str + if not folder_info: + return '' + suffix = ' [NSF]' if folder_info.get('type') == 'nested_share_folder' else '' + return f'{folder_info["name"]} ({folder_info["uid"]}){suffix}' + + +def resolve_pam_config_folder_info(params, facade, record_uid): + # type: (...) -> Optional[dict] + from ...subfolder import find_parent_top_folder + + folder_info = get_pam_folder_info(params, facade.folder_uid) + if folder_info and folder_info.get('type') != 'unknown': + return folder_info + shared_folder_parents = find_parent_top_folder(params, record_uid) + if shared_folder_parents: + sf = shared_folder_parents[0] + return {'uid': sf.uid, 'name': sf.name, 'type': 'shared_folder'} + return folder_info + + +def pam_folder_json_payload(folder_info): + # type: (Optional[dict]) -> dict + payload = {} + if not folder_info: + return payload + payload['folder'] = folder_info + if folder_info.get('type') == 'shared_folder': + payload['shared_folder'] = { + 'name': folder_info.get('name'), + 'uid': folder_info.get('uid'), + } + return payload + + +def record_exists_in_vault(params, record_uid): + # type: (...) -> bool + from ..nested_share_folder.helpers import is_nested_share_record + + if not record_uid: + return False + return (record_uid in getattr(params, 'record_cache', {}) + or is_nested_share_record(params, record_uid)) + + +def _record_matches_type(record, rec_type): + # type: (...) -> bool + if not rec_type: + return True + record_type = getattr(record, 'record_type', None) + if isinstance(rec_type, str): + return record_type == rec_type + try: + return record_type in rec_type + except TypeError: + return record_type == rec_type + + +def resolve_pam_record(params, identifier, rec_type=None): + # type: (...) -> Optional[vault.KeeperRecord] + """Resolve a record by UID, folder path, or title including Nested Share Records. + + *rec_type* may be a single record type string or a collection of allowed types. + """ + if not identifier: + return None + + if identifier in getattr(params, 'record_cache', {}): + rec = vault.KeeperRecord.load(params, identifier) + if rec and _record_matches_type(rec, rec_type): + return rec + + from ...nested_share_folder import removal_api as _nsf + resolved_uid = _nsf.resolve_nested_share_record_uid(params, identifier) + if resolved_uid: + rec = vault.KeeperRecord.load(params, resolved_uid) + if rec and _record_matches_type(rec, rec_type): + return rec + + rs = try_resolve_path(params, identifier) + if rs is not None: + folder, record_title = rs + if folder is not None and record_title is not None: + folder_uid = folder.uid or '' + subfolder_cache = getattr(params, 'subfolder_record_cache', None) or {} + if folder_uid in subfolder_cache: + for uid in subfolder_cache[folder_uid]: + record = vault.KeeperRecord.load(params, uid) + if record and record.title.casefold() == record_title.casefold(): + if _record_matches_type(record, rec_type): + return record + + l_name = identifier.casefold() + matches = [] + for record_uid in getattr(params, 'record_cache', {}): + record = vault.KeeperRecord.load(params, record_uid) + if record and record.title.casefold() == l_name: + if _record_matches_type(record, rec_type): + matches.append(record) + if len(matches) > 1: + break + if len(matches) == 1: + return matches[0] + return None + + +def get_vault_record_title_type(params, record_uid): + # type: (...) -> tuple + rec = vault.KeeperRecord.load(params, record_uid) + if rec: + return rec.title or '[untitled]', rec.record_type or '[unknown]' + nsf_data = getattr(params, 'nested_share_record_data', {}).get(record_uid, {}) + data_json = nsf_data.get('data_json', {}) if isinstance(nsf_data, dict) else {} + if isinstance(data_json, dict) and data_json: + return (data_json.get('title') or '[record inaccessible]', + data_json.get('type') or '[record inaccessible]') + return '[record inaccessible]', '[record inaccessible]' + + +def traverse_pam_folder_subtree(params, root_folder_uid, callback): + # type: (...) -> None + seen = set() + queue = [root_folder_uid] + while queue: + f_uid = queue.pop(0) + if not f_uid or f_uid in seen: + continue + seen.add(f_uid) + callback(f_uid) + folder = getattr(params, 'folder_cache', {}).get(f_uid) + if folder and folder.subfolders: + for child_uid in folder.subfolders: + if child_uid not in seen: + queue.append(child_uid) + for uid, nsf in getattr(params, 'nested_share_folders', {}).items(): + if nsf.get('parent_uid') == f_uid and uid not in seen: + queue.append(uid) + + +def collect_pam_folder_uids(params, folder_name): + # type: (...) -> set + folder_uids = set() + if not folder_name: + return folder_uids + + resolved = resolve_pam_folder_uid(params, folder_name, allow_legacy_user=True) + if resolved: + traverse_pam_folder_subtree(params, resolved, folder_uids.add) + return folder_uids + + rs = try_resolve_path(params, folder_name, find_all_matches=True) + if rs is not None: + folder, record_title = rs + if not record_title: + folders = folder if isinstance(folder, list) else [folder] + for f in folders: + if isinstance(f, BaseFolderNode) and f.uid: + traverse_pam_folder_subtree(params, f.uid, folder_uids.add) + + return folder_uids + + +def find_pam_records_by_search(params, search_str, record_version=3, record_types=None): + # type: (...) -> list + types = tuple(record_types) if record_types else None + records = list(vault_extensions.find_records( + params, search_str=search_str, record_version=record_version, record_type=types)) + if search_str and len(records) == 0: + rec = resolve_pam_record(params, search_str) + if isinstance(rec, vault.TypedRecord): + if rec.version == record_version and (not types or rec.record_type in types): + records = [rec] + return [r for r in records if isinstance(r, vault.TypedRecord)] + + +def _find_child_folder_uid(params, parent_uid, name): + return _find_child_folder(params, parent_uid, name=name) + + +def ensure_pam_folder_path(params, base_folder_uid, relative_path, command='pam'): + if not base_folder_uid: + raise CommandError(command, 'Base folder UID is required') + + if base_folder_uid not in getattr(params, 'folder_cache', {}) \ + and base_folder_uid not in getattr(params, 'nested_share_folders', {}): + raise CommandError(command, f'Base folder "{base_folder_uid}" not found') + + components = [x.strip() for x in str(relative_path or '').replace('\\', '/').split('/') if x.strip()] + current_uid = base_folder_uid + if not components: + return current_uid + + for component in components: + child_uid = _find_child_folder_uid(params, current_uid, component) + if child_uid: + current_uid = child_uid + continue + + if is_nested_share_folder(params, current_uid): + from ...nested_share_folder.folder_api import create_folder_v3 + result = create_folder_v3(params, component, parent_uid=current_uid) + if isinstance(result, dict) and result.get('success') is False: + raise CommandError(command, result.get('message') or 'Failed to create Nested Share Folder') + current_uid = result.get('folder_uid') if isinstance(result, dict) else None + api.sync_down(params) + else: + from ..folder import FolderMakeCommand + parent_path = get_folder_path(params, current_uid) + folder_path = f'{parent_path}{component}' if parent_path else component + FolderMakeCommand().execute(params, folder=folder_path, user_folder=True) + api.sync_down(params) + current_uid = _find_child_folder_uid(params, current_uid, component) + + if not current_uid: + raise CommandError(command, f'Folder creation succeeded but UID not found: {component}') + + return current_uid + + +def place_record_in_folder(params, record_uid, folder_uid, command='pam'): + from ..nested_share_folder.helpers import normalize_nsf_user_message + + if not folder_uid: + raise CommandError(command, 'Target folder UID is required') + + folder_cache = getattr(params, 'folder_cache', {}) + nsf_cache = getattr(params, 'nested_share_folders', {}) + if folder_uid not in folder_cache and folder_uid not in nsf_cache: + raise CommandError(command, f'Folder "{folder_uid}" not found') + + if is_nested_share_folder(params, folder_uid): + result = move_record_v3(params, record_uid, to_folder_uid=folder_uid) + if isinstance(result, dict) and result.get('success') is False: + message = normalize_nsf_user_message(result.get('message')) or \ + 'Failed to place record in Nested Share Folder' + lowered = message.casefold() + if any(token in lowered for token in ('denied', 'permission', 'forbidden', 'read-only')): + message = ( + f'{message}. Verify you have record-management permission ' + f'on the Nested Share Folder.' + ) + raise CommandError(command, message) + api.sync_down(params) + else: + FolderMoveCommand().execute(params, src=record_uid, dst=folder_uid, force=True) + + +def create_record_in_folder(params, record, folder_uid=None, command='pam'): + if folder_uid and is_nested_share_folder(params, folder_uid): + from ... import vault_extensions + from ...nested_share_folder.record_api import create_record_v3 + from ..nested_share_folder.helpers import normalize_nsf_user_message + + if not isinstance(record, vault.TypedRecord): + raise CommandError(command, 'Nested Share Folder record creation requires a typed record') + + result = create_record_v3( + params, + folder_uid=folder_uid, + record_data=vault_extensions.extract_typed_record_data(record), + ) + if not result.get('success'): + raise CommandError(command, normalize_nsf_user_message(result.get('message')) or + 'Failed to create record in Nested Share Folder') + record.record_uid = result['record_uid'] + from ..pam_import.nsf_helpers import sync_down_preserving_nsf_keys + sync_down_preserving_nsf_keys(params) + else: + record_management.add_record_to_folder(params, record, folder_uid) + + +def create_pam_configuration_in_folder(params, record, folder_uid, command='pam-config-new'): + """Create a v6 PAM configuration in *folder_uid* using NSF or classic placement.""" + from .config_helper import pam_configuration_create_record_nsf, pam_configuration_create_record_v6 + + if is_nested_share_folder(params, folder_uid): + pam_configuration_create_record_nsf(params, record, folder_uid) + from ..pam_import.nsf_helpers import sync_down_preserving_nsf_keys + sync_down_preserving_nsf_keys(params) + return + + pam_configuration_create_record_v6(params, record, folder_uid) + api.sync_down(params) + place_record_in_folder(params, record.record_uid, folder_uid, command=command) + + +def is_pam_nsf_record(params, record_uid): + """Return True when a PAM record lives in NSF caches or an NSF folder.""" + if not record_uid: + return False + if record_uid in (getattr(params, 'nested_share_records', None) or {}): + return True + if record_uid in (getattr(params, 'nested_share_record_data', None) or {}): + return True + try: + from ...subfolder import find_folders + for folder_uid in find_folders(params, record_uid): + if is_nested_share_folder(params, folder_uid): + return True + except Exception: + pass + return False + + +def update_pam_record(params, record, command='pam', force_nsf=False): + """Update a PAM record via NSF v3 API or classic record_management. + """ + from ..nested_share_folder.helpers import normalize_nsf_user_message + from ...nested_share_folder.record_api import update_record_v3 + + use_nsf = force_nsf or is_pam_nsf_record(params, getattr(record, 'record_uid', None)) + if use_nsf: + if not isinstance(record, vault.TypedRecord): + raise CommandError(command, 'Nested Share Folder record update requires a typed record') + + result = update_record_v3( + params, + record.record_uid, + data=vault_extensions.extract_typed_record_data(record), + ) + if not result.get('success'): + raise CommandError(command, normalize_nsf_user_message(result.get('message')) or + 'Failed to update record in Nested Share Folder') + from ..pam_import.nsf_helpers import sync_down_preserving_nsf_keys + sync_down_preserving_nsf_keys(params) + else: + record_management.update_record(params, record) + + +def execute_record_add_in_folder(params, args, folder_uid, command='pam'): + """Add a record in *folder_uid*, using NSF-native creation when needed.""" + from ..record_edit import RecordAddCommand + from ..nested_share_folder.record_commands import NestedShareRecordAddCommand + + record_args = dict(args or {}) + if folder_uid and is_nested_share_folder(params, folder_uid): + nsf_args = dict(record_args) + nsf_args.pop('folder', None) + nsf_args['folder_uid'] = folder_uid + uid = NestedShareRecordAddCommand().execute(params, **nsf_args) + if uid: + from ..pam_import.nsf_helpers import sync_down_preserving_nsf_keys + sync_down_preserving_nsf_keys(params) + return uid + + record_args['folder'] = folder_uid + return RecordAddCommand().execute(params, **record_args) + + +def execute_record_v3_add_in_folder(params, args, folder_uid, command='pam'): + """Add a v3 typed record in *folder_uid*, using NSF-native creation when needed.""" + import json + + from ..recordv3 import RecordAddCommand + from ..nested_share_folder.helpers import normalize_nsf_user_message + from ...nested_share_folder.record_api import create_record_v3 + + record_args = dict(args or {}) + if folder_uid and is_nested_share_folder(params, folder_uid): + data = record_args.get('data') + if not data: + raise CommandError(command, 'Record data is required for Nested Share Folder creation') + if isinstance(data, str): + data = json.loads(data) + result = create_record_v3( + params, + folder_uid=folder_uid, + record_data=data, + record_uid=record_args.get('record_uid') or data.get('uid'), + ) + if not result.get('success'): + raise CommandError(command, normalize_nsf_user_message(result.get('message')) or + 'Failed to create record in Nested Share Folder') + from ..pam_import.nsf_helpers import sync_down_preserving_nsf_keys + sync_down_preserving_nsf_keys(params) + return result['record_uid'] + + record_args['folder'] = folder_uid + return RecordAddCommand().execute(params, **record_args) + + +def grant_pam_folder_permissions(params, folder_uid, permissions, command='pam'): + if not permissions: + return + if not is_nested_share_folder(params, folder_uid): + return + + from ..nested_share_folder.helpers import classify_share_recipient + from ...nested_share_folder.folder_api import grant_folder_access_v3 + + for perm in permissions: + if not any(map(lambda x: x is not None, perm.values())): + continue + recipient = perm.get('name') or perm.get('uid') + if not recipient: + continue + + classified = classify_share_recipient(params, recipient) + if not classified: + continue + + kind, identifier = classified + manage_users = bool(perm.get('manage_users')) + manage_records = bool(perm.get('manage_records')) + if manage_users and manage_records: + role = 'full-manager' + elif manage_records: + role = 'content-manager' + elif manage_users: + role = 'share-manager' + else: + role = 'viewer' + + result = grant_folder_access_v3( + params, + folder_uid, + identifier, + role=role, + as_team=kind == 'team', + ) + if isinstance(result, dict) and result.get('success') is False: + raise CommandError(command, result.get('message') or 'Failed to grant Nested Share Folder access') diff --git a/keepercommander/commands/pam_cloud/pam_privileged_access.py b/keepercommander/commands/pam_cloud/pam_privileged_access.py index 21940efa0..768302c17 100644 --- a/keepercommander/commands/pam_cloud/pam_privileged_access.py +++ b/keepercommander/commands/pam_cloud/pam_privileged_access.py @@ -14,10 +14,11 @@ GatewayActionIdpGroupList, ) from keepercommander.commands.pam.router_helper import router_send_action_to_gateway +from keepercommander.commands.pam.vault_target import ( + create_record_in_folder, resolve_access_user_save_folder) from keepercommander.error import CommandError -from keepercommander import api, crypto, record_management, vault +from keepercommander import api, crypto, vault from keepercommander.proto import pam_pb2 -from keepercommander.subfolder import find_parent_top_folder logger = logging.getLogger(__name__) @@ -202,7 +203,7 @@ class PAMAccessUserProvisionCommand(Command): parser.add_argument('--save-record', '-s', dest='save_record', action='store_true', help='Save provisioned credentials as a pamUser record') parser.add_argument('--folder', '-f', dest='folder_uid', - help='Folder UID to save the record in (used with --save-record)') + help='Folder UID, name, or path to save the record in (used with --save-record)') parser.add_argument('--gateway', '-g', dest='gateway', help='Gateway UID or name') @@ -304,13 +305,10 @@ def execute(self, params, **kwargs): user_id_label = idp_label_map.get(idp_type, 'IdP User ID') record.custom.append(vault.TypedField.new_field('text', user_id, user_id_label)) - folder_uid = kwargs.get('folder_uid') - if not folder_uid: - shared_folders = find_parent_top_folder(params, config_uid) - if shared_folders: - sf = shared_folders[0] - folder_uid = sf.parent_uid if sf.parent_uid else sf.uid - record_management.add_record_to_folder(params, record, folder_uid) + folder_uid = resolve_access_user_save_folder( + params, config_uid, folder_spec=kwargs.get('folder_uid')) + + create_record_in_folder(params, record, folder_uid, command='pam-access-user-provision') params.sync_data = True print(f' Record UID: {record.record_uid}') @@ -570,4 +568,4 @@ def execute(self, params, **kwargs): _dispatch_idp_action(params, action, kwargs.get('gateway')) - logging.info(f'User "{username}" removed from group "{group_id}".') \ No newline at end of file + logging.info(f'User "{username}" removed from group "{group_id}".') diff --git a/keepercommander/commands/pam_import/README.md b/keepercommander/commands/pam_import/README.md index 78d62be8b..de43eec19 100644 --- a/keepercommander/commands/pam_import/README.md +++ b/keepercommander/commands/pam_import/README.md @@ -302,7 +302,8 @@ Each Machine (pamMachine, pamDatabase, pamDirectory) can specify **Administrativ > **Note 1:** `pam_settings` _(options, connection)_ are explained only in pamMachine section below (per protocol) but they are present in all machine types. > **Note 2:** `attachments` and `scripts` examples are in `pam_configuration: local` section. > **Note 3:** Post rotation scripts (a.k.a. `scripts`) are executed in following order: `pamUser` scripts after any **successful** rotation for that user, `pamMachine` scripts after any **successful** rotation on the machine and `pamConfiguration` scripts after any rotation using that configuration. - > **Note 4:** When `allow_supply_user` is false and JIT ephemeral is not used, vault may require a launch credential; import can provide it via `launch_credentials` in the resource's `connection` block. + > **Note 4:** Post-rotation scripts can only be attached by the **record owner**. Non-owners will see an upload error during import or when using `pam rotate script add`. + > **Note 5:** When `allow_supply_user` is false and JIT ephemeral is not used, vault may require a launch credential; import can provide it via `launch_credentials` in the resource's `connection` block. JIT and KeeperAI settings below are shared across all resource types (pamMachine, pamDatabase, pamDirectory) except User and RBI (pamRemoteBrowser) records. **Workflow** (approvals / checkout / temporal restrictions) is supported on all four resource types: pamMachine, pamDatabase, pamDirectory, **and** pamRemoteBrowser. diff --git a/keepercommander/commands/pam_import/base.py b/keepercommander/commands/pam_import/base.py index 3ad394261..f3056f377 100644 --- a/keepercommander/commands/pam_import/base.py +++ b/keepercommander/commands/pam_import/base.py @@ -21,7 +21,7 @@ from pathlib import Path from typing import Any, Dict, Optional, List, Union -from ..record_edit import RecordAddCommand as RecordEditAddCommand +from ..pam.vault_target import execute_record_add_in_folder from ..workflow.helpers import RecordResolver, WorkflowFormatter from ... import api, attachment, utils, vault, vault_extensions, \ record_facades, record_management @@ -1068,7 +1068,7 @@ def create_record(self, params, folder_uid): fields.append(f"file=@{x.file}") if fields: args["fields"] = fields - uid = RecordEditAddCommand().execute(params, **args) + uid = execute_record_add_in_folder(params, args, folder_uid, command='pam-project-import') if uid and isinstance(uid, str): self.uid = uid @@ -1163,7 +1163,7 @@ def create_record(self, params, folder_uid): fields.append(f"file=@{x.file}") if fields: args["fields"] = fields - uid = RecordEditAddCommand().execute(params, **args) + uid = execute_record_add_in_folder(params, args, folder_uid, command='pam-project-import') if uid and isinstance(uid, str): self.uid = uid return uid @@ -1433,7 +1433,7 @@ def create_record(self, params, folder_uid): # switch to f.* once RT definition(s) update w/ pamSettings field if fields: args["fields"] = fields - uid = RecordEditAddCommand().execute(params, **args) + uid = execute_record_add_in_folder(params, args, folder_uid, command='pam-project-import') if uid and isinstance(uid, str): self.uid = uid @@ -1626,7 +1626,7 @@ def create_record(self, params, folder_uid): # switch to f.* once RT definition(s) update w/ pamSettings field if fields: args["fields"] = fields - uid = RecordEditAddCommand().execute(params, **args) + uid = execute_record_add_in_folder(params, args, folder_uid, command='pam-project-import') if uid and isinstance(uid, str): self.uid = uid @@ -1774,7 +1774,7 @@ def create_record(self, params, folder_uid): # switch to f.* once RT definition(s) update w/ pamSettings field if fields: args["fields"] = fields - uid = RecordEditAddCommand().execute(params, **args) + uid = execute_record_add_in_folder(params, args, folder_uid, command='pam-project-import') if uid and isinstance(uid, str): self.uid = uid @@ -1879,7 +1879,7 @@ def create_record(self, params, folder_uid): # switch to f.* once RT definition(s) update w/ pamRemoteBrowserSettings field if fields: args["fields"] = fields - uid = RecordEditAddCommand().execute(params, **args) + uid = execute_record_add_in_folder(params, args, folder_uid, command='pam-project-import') if uid and isinstance(uid, str): self.uid = uid diff --git a/keepercommander/commands/pam_import/edit.py b/keepercommander/commands/pam_import/edit.py index 149938837..8bd69c2cd 100644 --- a/keepercommander/commands/pam_import/edit.py +++ b/keepercommander/commands/pam_import/edit.py @@ -50,7 +50,13 @@ from ..ksm import KSMCommand from ..pam import gateway_helper from ..pam.config_helper import pam_configurations_get_all -from ..recordv3 import RecordAddCommand +from ..pam.vault_target import ( + execute_record_v3_add_in_folder, + grant_pam_folder_permissions, + is_nested_share_folder, + is_pam_nsf_record, + update_pam_record, +) from ..record_edit import RecordUploadAttachmentCommand from ..tunnel.port_forward.TunnelGraph import TunnelDAG from ..tunnel.port_forward.tunnel_helpers import get_keeper_tokens @@ -66,7 +72,7 @@ from ...params import LAST_FOLDER_UID, LAST_SHARED_FOLDER_UID from ...proto import record_pb2, APIRequest_pb2, enterprise_pb2 from ...recordv3 import RecordV3 -from ...subfolder import BaseFolderNode +from ...subfolder import BaseFolderNode, NestedShareFolderNode class PAMProjectImportCommand(Command): @@ -76,6 +82,7 @@ class PAMProjectImportCommand(Command): parser.add_argument("--dry-run", "-d", required=False, dest="dry_run", action="store_true", default=False, help="Test import without modifying vault.") parser.add_argument("--sample-data", "-s", required=False, dest="sample_data", action="store_true", default=False, help="Generate sample data.") parser.add_argument("--show-template", "-t", required=False, dest="show_template", action="store_true", default=False, help="Print JSON template required for manual import.") + parser.add_argument("--nsf", required=False, dest="use_nsf", action="store_true", default=False, help="Create project folders and records in Nested Share Folders.") # parser.add_argument("--force", "-e", required=False, dest="force", action="store_true", default=False, help="Force data import (re/configure later)") parser.add_argument("--output", "-o", required=False, dest="output", action="store", choices=["token", "base64", "json", "k8s"], default="base64", help="Output format (token: one-time token, config: base64/json/k8s)") @@ -112,6 +119,7 @@ def execute(self, params, **kwargs): project["options"]["dry_run"] = kwargs.get("dry_run", False) project["options"]["sample_data"] = kwargs.get("sample_data", False) project["options"]["show_template"] = kwargs.get("show_template", False) + project["options"]["use_nsf"] = kwargs.get("use_nsf", False) project["options"]["force"] = kwargs.get("force", False) project["options"]["output"] = kwargs.get("output", "") or "" @@ -130,7 +138,7 @@ def execute(self, params, **kwargs): project["options"]["project_name"] = "Discovery Playground" project["options"]["file_name"] = "" # project["options"]["dry_run"] = False # dry-run is allowed - extra = self.get_extra_args(["sample_data", "dry_run"], **kwargs) + extra = self.get_extra_args(["sample_data", "dry_run", "use_nsf"], **kwargs) if extra: logging.warning(f"{bcolors.WARNING}Warning: --sample-data|-s overrides other options {extra}{bcolors.ENDC}") @@ -222,6 +230,13 @@ def _has_perms(section_key): PAM_ROOT_FOLDER_NAME = "PAM Environments" + @staticmethod + def _pam_folder_types(use_nsf=False): + types = {BaseFolderNode.UserFolderType} + if use_nsf: + types.add(BaseFolderNode.NestedShareFolderType) + return types + def process_folders(self, params, project: dict) -> dict: res = { "root_folder_target": self.PAM_ROOT_FOLDER_NAME, @@ -243,10 +258,16 @@ def process_folders(self, params, project: dict) -> dict: # if project["data"].get("tool_version", "") != "": # CLI generated export else: # Manually generated import file # FolderListCommand().execute(params, folders_only=True, pattern="/") + use_nsf = project["options"].get("use_nsf", False) is True + allowed_types = self._pam_folder_types(use_nsf) folders = self.find_folders(params, "", res["root_folder_target"], False) if folders: # select first non-root non-shared sub/folder - folders = [x for x in folders if x.type == x.UserFolderType] + folders = [x for x in folders if x.type in allowed_types] + if use_nsf: + folders = [x for x in folders if is_nested_share_folder(params, x.uid)] + else: + folders = [x for x in folders if not is_nested_share_folder(params, x.uid)] if len(folders) > 1: logging.warning(f"""Multiple user folders ({len(folders)}) match folder name "{res["root_folder_target"]}" """ f" using first match with UID: {bcolors.OKGREEN}{folders[0].uid}{bcolors.ENDC}") @@ -256,7 +277,7 @@ def process_folders(self, params, project: dict) -> dict: res["root_folder_uid"] = str(folders[0].uid) elif project["options"].get("dry_run", False) is not True: # FolderMakeCommand().execute(params, user_folder=True, folder=f"""/{res["root_folder_target"]}""") - fuid = self.create_subfolder(params, res["root_folder_target"]) + fuid = self.create_subfolder(params, res["root_folder_target"], use_nsf=use_nsf) res["root_folder_uid"] = fuid # find available project folder - incr. numeric suffix until distinct name found @@ -267,14 +288,14 @@ def process_folders(self, params, project: dict) -> dict: while True: folder_name = res["project_folder_target"] if n <= START_INDEX else f"""{res["project_folder_target"]} #{n}""" folders = self.find_folders(params, res["root_folder_uid"], folder_name, False) - folders = [x for x in folders if x.type == x.UserFolderType] + folders = [x for x in folders if x.type in allowed_types] n += 1 if len(folders) > 0: continue res["project_folder"] = folder_name if project["options"].get("dry_run", False) is not True: # FolderMakeCommand().execute(params, shared_folder=True, folder=f"""/{res["root_folder"]}/{res["project_folder"]}""") - fuid = self.create_subfolder(params, folder_name=res["project_folder"], parent_uid=res["root_folder_uid"]) + fuid = self.create_subfolder(params, folder_name=res["project_folder"], parent_uid=res["root_folder_uid"], use_nsf=use_nsf) res["project_folder_uid"] = fuid puid = res["project_folder_uid"] @@ -282,14 +303,14 @@ def process_folders(self, params, project: dict) -> dict: fname = sfn["folder_name"] if isinstance(sfn, dict) and isinstance(sfn.get("folder_name", None), str) else "" fname = fname.strip() or f"""{res["project_folder"]} - Resources""" fperm, rperm = self.get_folder_permissions(users_folder=False, data=project["data"]) - fuid = self.create_subfolder(params, folder_name=fname, parent_uid=puid, permissions=fperm) + fuid = self.create_subfolder(params, folder_name=fname, parent_uid=puid, permissions=fperm, use_nsf=use_nsf) res["resources_folder_uid"] = fuid sfn = project["data"].get("shared_folder_users", None) fname = sfn["folder_name"] if isinstance(sfn, dict) and isinstance(sfn.get("folder_name", None), str) else "" fname = fname.strip() or f"""{res["project_folder"]} - Users""" fperm, uperm = self.get_folder_permissions(users_folder=True, data=project["data"]) - fuid = self.create_subfolder(params, folder_name=fname, parent_uid=puid, permissions=fperm) + fuid = self.create_subfolder(params, folder_name=fname, parent_uid=puid, permissions=fperm, use_nsf=use_nsf) res["users_folder_uid"] = fuid # add users and teams @@ -302,10 +323,14 @@ def process_folders(self, params, project: dict) -> dict: if res["root_folder_uid"]: print(f"""Will use existing PAM root folder: {res["root_folder_uid"]} {res["root_folder"]}""") else: - print(f"""Will create new PAM root folder: {res["root_folder_target"]}""") - print(f"""Will create new Project folder: {res["project_folder"]}""") + print(f"""Will create new {"NSF " if use_nsf else ""}PAM root folder: {res["root_folder_target"]}""") + print(f"""Will create new {"NSF " if use_nsf else ""}Project folder: {res["project_folder"]}""") else: - api.sync_down(params) + if use_nsf: + from .nsf_helpers import sync_down_preserving_nsf_keys + sync_down_preserving_nsf_keys(params) + else: + api.sync_down(params) return res @@ -342,8 +367,15 @@ def process_ksm_app(self, params, project: dict) -> dict: print(f"""Will create new KSM application: {res["app_name"]}""") return res - # Create KSM App + # Create KSM App and share Resources/Users folders (classic SF or NSF). + # KSMCommand routes NSF folder UIDs through grant_folder_access_to_application_v3. + use_nsf = project["options"].get("use_nsf", False) is True + from .nsf_helpers import restore_nsf_folder_keys, snapshot_nsf_folder_keys, sync_down_preserving_nsf_keys + preserved = snapshot_nsf_folder_keys(params) if use_nsf else None res["app_uid"] = self.create_ksm_app(params, res["app_name"]) + if preserved is not None: + # create_ksm_app sync_down clears NSF caches; restore before NSF secret share. + restore_nsf_folder_keys(params, preserved) for sf_uid in [project["folders"].get("resources_folder_uid", ""), project["folders"].get("users_folder_uid", "")]: if sf_uid.strip(): @@ -352,7 +384,13 @@ def process_ksm_app(self, params, project: dict) -> dict: app=res["app_uid"], secret=[sf_uid], editable=True) - api.sync_down(params) + if use_nsf or any(is_nested_share_folder(params, uid) + for uid in (project["folders"].get("resources_folder_uid", ""), + project["folders"].get("users_folder_uid", "")) + if uid): + sync_down_preserving_nsf_keys(params) + else: + api.sync_down(params) return res def process_gateway(self, params, project: dict) -> dict: @@ -383,6 +421,9 @@ def process_gateway(self, params, project: dict) -> dict: return res # Create new Gateway - PAMCreateGatewayCommand() + use_nsf = project["options"].get("use_nsf", False) is True + from .nsf_helpers import restore_nsf_folder_keys, snapshot_nsf_folder_keys, sync_down_preserving_nsf_keys + preserved = snapshot_nsf_folder_keys(params) if use_nsf else None output_fmt = project["options"]["output"] token_format = None if output_fmt == "token" else ("k8s" if output_fmt == "k8s" else "b64") ksm_app_uid = project["ksm_app"]["app_uid"] @@ -391,6 +432,8 @@ def process_gateway(self, params, project: dict) -> dict: gateway_name=res["gateway_name"], ksm_app=ksm_app_uid, config_init=token_format) + if preserved is not None: + restore_nsf_folder_keys(params, preserved) if token_format is None: res["gateway_token"] = gw[0].get("oneTimeToken", "") if gw else "" # OTT @@ -409,7 +452,10 @@ def process_gateway(self, params, project: dict) -> dict: res["gateway_uid"] = utils.base64_url_encode(gw_names[0]) if gw_names else "" # gateway_helper.remove_gateway(params, utils.base64_url_decode(res["gateway_uid"])) - api.sync_down(params) + if use_nsf: + sync_down_preserving_nsf_keys(params) + else: + api.sync_down(params) return res def process_pam_config(self, params, project: dict) -> dict: @@ -425,10 +471,18 @@ def process_pam_config(self, params, project: dict) -> dict: if not res["pam_config_name_target"]: res["pam_config_name_target"] = project["options"]["project_name"] + " Configuration" - # Find unique PAM Configuration name + # Find unique PAM Configuration name (classic record_cache + NSF caches) n = 1 pams = pam_configurations_get_all(params) - pam_names = [json.loads(x.get("data_unencrypted", "{}")).get("title", "") for x in pams] + pam_names = {json.loads(x.get("data_unencrypted", "{}")).get("title", "") for x in pams} + for uid, nsf_rec in (getattr(params, 'nested_share_records', None) or {}).items(): + if nsf_rec.get('version') != 6: + continue + dj = ((getattr(params, 'nested_share_record_data', None) or {}) + .get(uid, {}).get('data_json') or {}) + title = dj.get('title') or '' + if title: + pam_names.add(title) pam_name = res["pam_config_name_target"] while pam_name in pam_names: n += 1 @@ -530,7 +584,12 @@ def process_pam_config(self, params, project: dict) -> dict: if pce.default_rotation_schedule: args["default_schedule"] = pce.default_rotation_schedule res["pam_config_uid"] = PAMConfigurationNewCommand().execute(params, **args) - api.sync_down(params) + users_folder_uid = project["folders"].get("users_folder_uid", "") + if project["options"].get("use_nsf", False) is True or is_nested_share_folder(params, users_folder_uid): + from .nsf_helpers import sync_down_preserving_nsf_keys + sync_down_preserving_nsf_keys(params) + else: + api.sync_down(params) # add scripts and attachments after record create if pce.attachments: @@ -609,7 +668,17 @@ def generate_discovery_playground_data(self, params, project: dict): # Fix: Rotation is disabled by the PAM configuration. tdag.set_resource_allowed(pam_config_uid, is_config=True, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True) - command = RecordAddCommand() + class PamProjectRecordAddCommand: + @staticmethod + def execute(params, **kwargs): + return execute_record_v3_add_in_folder( + params, + kwargs, + kwargs.get('folder'), + command='pam-project-import' + ) + + command = PamProjectRecordAddCommand() pte = PAMTunnelEditCommand() # when NOOP rotation is added to PAMCreateRecordRotationCommand... @@ -1197,6 +1266,11 @@ def get_folder_permissions(self, users_folder: bool, data: dict): def add_folder_permissions(self, params, folder_uid: str, permissions: list): if permissions: + if is_nested_share_folder(params, folder_uid): + grant_pam_folder_permissions(params, folder_uid, permissions, command='pam-project-import') + api.sync_down(params) + return + shf = SharedFolder() shf.uid = folder_uid # type: ignore shf.path = imp_exp.get_folder_path(params, folder_uid) # type: ignore @@ -1232,7 +1306,8 @@ def add_folder_permissions(self, params, folder_uid: str, permissions: list): # args["manage_users"] = True if manage_users == False else False # ShareFolderCommand().execute(params, **args) - def create_subfolder(self, params, folder_name:str, parent_uid:str="", permissions:Optional[Dict]=None): + def create_subfolder(self, params, folder_name:str, parent_uid:str="", permissions:Optional[Dict]=None, + use_nsf: bool=False): """ Creates subfolder inside parent folder: either `user folder`, `shared folder` or `shared folder folder`. If `parent_uid == ""` then creates subfolder in root folder. @@ -1243,6 +1318,21 @@ def create_subfolder(self, params, folder_name:str, parent_uid:str="", permissio """ name = str(folder_name or "").strip() + if use_nsf or is_nested_share_folder(params, parent_uid): + from .nsf_helpers import seed_nsf_folder_cache, sync_down_preserving_nsf_keys + from ...nested_share_folder.folder_api import create_folder_v3 + result = create_folder_v3(params, name, parent_uid=parent_uid or None) + if isinstance(result, dict) and result.get('success') is False: + raise CommandError("pam", result.get('message') or 'Failed to create Nested Share Folder') + folder_uid = result.get('folder_uid') if isinstance(result, dict) else None + if not folder_uid: + raise CommandError("pam", f'Nested Share Folder creation did not return UID: {name}') + folder_key = result.get('folder_key_unencrypted') if isinstance(result, dict) else None + seed_nsf_folder_cache(params, folder_uid, name, parent_uid or None, folder_key) + sync_down_preserving_nsf_keys(params) + params.environment_variables[LAST_FOLDER_UID] = folder_uid + return folder_uid + base_folder = params.folder_cache.get(parent_uid, None) or params.root_folder shared_folder = True if permissions else False @@ -1314,6 +1404,15 @@ def find_folders(self, params, parent_uid:str, folder:str, is_shared_folder:bool result = [v for k, v in matches.items() if (is_shared_folder and v.type == BaseFolderNode.SharedFolderType) or (not is_shared_folder and v.type == BaseFolderNode.UserFolderType)] + if not is_shared_folder: + for uid, nsf in getattr(params, 'nested_share_folders', {}).items(): + nsf_parent = nsf.get('parent_uid') or None + if nsf_parent == puid and nsf.get('name') == folder: + nsf_folder = NestedShareFolderNode() + nsf_folder.uid = uid + nsf_folder.name = nsf.get('name') + nsf_folder.parent_uid = nsf_parent + result.append(nsf_folder) return result def create_ksm_app(self, params, app_name) -> str: @@ -1814,8 +1913,9 @@ def process_data(self, params, project): # PAM Domain Config - update domain admin creds if pce and pce.environment == "domain": if pce.admin_credential_ref: + from .record_loader import load_pam_record pcuid = project["pam_config"].get("pam_config_uid") - pcrec = vault.KeeperRecord.load(params, pcuid) if pcuid else None + pcrec = load_pam_record(params, pcuid) if pcuid else None if pcrec and isinstance(pcrec, vault.TypedRecord) and pcrec.version == 6: if pcrec.record_type == "pamDomainConfiguration": prf = pcrec.get_typed_field('pamResources') @@ -1825,7 +1925,17 @@ def process_data(self, params, project): prf.value = prf.value or [{}] if isinstance(prf.value[0], dict): prf.value[0]["adminCredentialRef"] = pce.admin_credential_ref - record_management.update_record(params, pcrec) + users_folder_uid = (project.get("folders") or {}).get("users_folder_uid", "") + force_nsf = ( + project["options"].get("use_nsf", False) is True + or is_nested_share_folder(params, users_folder_uid) + or is_pam_nsf_record(params, pcuid) + ) + if force_nsf: + update_pam_record( + params, pcrec, command='pam-project-import', force_nsf=True) + else: + record_management.update_record(params, pcrec) tdag.link_user_to_config_with_options(pce.admin_credential_ref, is_admin='on') else: logging.error(f"Unable to add adminCredentialRef - bad pamResources field in PAM Config {pcuid}") diff --git a/keepercommander/commands/pam_import/export.py b/keepercommander/commands/pam_import/export.py index 1effb4ee3..d847797ee 100644 --- a/keepercommander/commands/pam_import/export.py +++ b/keepercommander/commands/pam_import/export.py @@ -21,6 +21,7 @@ ) from ..base import Command from ..pam.config_facades import PamConfigurationRecordFacade +from .record_loader import iter_accessible_record_uids, load_pam_record from ... import vault from ...display import bcolors @@ -79,7 +80,7 @@ def execute(self, params, **kwargs): return # 1. Load PAM configuration record (v6) - config_record = vault.KeeperRecord.load(params, project_uid) + config_record = load_pam_record(params, project_uid) if not config_record: logging.warning( f"{bcolors.FAIL}PAM configuration '{project_uid}' not found in vault{bcolors.ENDC}" @@ -203,7 +204,7 @@ def _build_resources_and_users(self, params, resource_uids): title_to_uid = self._build_user_title_index(params) for res_uid in resource_uids: - res_record = vault.KeeperRecord.load(params, res_uid) + res_record = load_pam_record(params, res_uid) if not res_record or not isinstance(res_record, vault.TypedRecord): logging.debug("Export: skipping resource UID %s (not found or not TypedRecord)", res_uid) continue @@ -251,10 +252,9 @@ def _build_resources_and_users(self, params, resource_uids): def _build_user_title_index(self, params): """Index every pamUser / login record by lowercased title for title-based linking.""" index = {} - record_cache = getattr(params, "record_cache", {}) or {} - for uid in record_cache: + for uid in iter_accessible_record_uids(params): try: - rec = vault.KeeperRecord.load(params, uid) + rec = load_pam_record(params, uid) except Exception: continue if not rec or not isinstance(rec, vault.TypedRecord): @@ -302,7 +302,7 @@ def _extract_user_uids(self, pam_settings_dict, title_to_uid=None): def _load_user_obj(self, params, usr_uid): """Load a pamUser/login record and return a plain dict, or None on failure.""" - usr_record = vault.KeeperRecord.load(params, usr_uid) + usr_record = load_pam_record(params, usr_uid) if not usr_record or not isinstance(usr_record, vault.TypedRecord): logging.debug("Export: user UID %s not found or not TypedRecord", usr_uid) return None diff --git a/keepercommander/commands/pam_import/extend.py b/keepercommander/commands/pam_import/extend.py index c21a2a6f2..b0a10b175 100644 --- a/keepercommander/commands/pam_import/extend.py +++ b/keepercommander/commands/pam_import/extend.py @@ -54,23 +54,34 @@ refresh_link_to_config_to_latest, ) from .workflow_apply import apply_workflow, validate_workflow_principals +from .nsf_helpers import ( + build_folder_tree, + create_nsf_subfolder, + extend_create_record, + find_pam_configuration, + get_folder_record_uids, + get_ksm_app_folders, + get_records_in_folder, +) +from .record_loader import load_pam_record from ...keeper_dag import EdgeType from ...keeper_dag.types import RefType from ..base import Command from ..ksm import KSMCommand from ..pam import gateway_helper from ..pam.config_helper import configuration_controller_get +from ..pam.vault_target import is_nested_share_folder, is_pam_nsf_record, update_pam_record from ..tunnel.port_forward.TunnelGraph import TunnelDAG from ..tunnel.port_forward.tunnel_helpers import get_keeper_tokens from ..tunnel_and_connections import PAMTunnelEditCommand -from ... import api, crypto, utils, vault, vault_extensions, record_management +from ... import api, crypto, utils, vault, record_management from ... import enterprise as _enterprise_module from ...display import bcolors from ...error import CommandError from ...params import LAST_FOLDER_UID, LAST_SHARED_FOLDER_UID -from ...proto import APIRequest_pb2, enterprise_pb2, pam_pb2, record_pb2 +from ...proto import enterprise_pb2, pam_pb2, record_pb2 from ...recordv3 import RecordV3 -from ...subfolder import BaseFolderNode +from ...subfolder import BaseFolderNode, find_folders as find_record_folders def split_folder_path(path: str) -> list[str]: """Split folder path using path deilmiter / (escape: / -> //)""" @@ -191,23 +202,8 @@ def process_folder_paths(folder_paths, ksm_shared_folders): return good_paths, bad_paths def build_tree_recursive(params, folder_uid: str): - """Recursively build tree for a folder and its subfolders""" - tree = {} - folder = params.folder_cache.get(folder_uid) - if not folder: - return tree - - for subfolder_uid in folder.subfolders: - subfolder = params.folder_cache.get(subfolder_uid) - if subfolder: - folder_name = subfolder.name or '' - tree[folder_name] = { - 'uid': subfolder.uid, - 'name': folder_name, - 'subfolders': build_tree_recursive(params, subfolder.uid) - } - - return tree + """Recursively build tree for a folder and its subfolders (classic or NSF).""" + return build_folder_tree(params, folder_uid) def _collect_path_to_uid_from_tree(path_prefix: str, tree: dict, path_to_uid: dict, only_existing: bool) -> None: @@ -295,35 +291,14 @@ def _get_ksm_app_record_uids(params, ksm_shared_folders: list) -> set: """Return set of all record UIDs in any folder shared to the KSM app.""" folder_uids = _collect_all_folder_uids_under_ksm(ksm_shared_folders) record_uids = set() - subfolder_record_cache = getattr(params, "subfolder_record_cache", None) or {} for fuid in folder_uids: - if fuid in subfolder_record_cache: - record_uids.update(subfolder_record_cache[fuid]) + record_uids.update(get_folder_record_uids(params, fuid)) return record_uids def _get_records_in_folder(params, folder_uid: str): - """Return list of (record_uid, title, record_type, login) for records in folder_uid. - record_type from record for autodetect (e.g. pamUser, pamMachine, login).""" - subfolder_record_cache = getattr(params, "subfolder_record_cache", None) or {} - result = [] - for ruid in subfolder_record_cache.get(folder_uid, []): - try: - rec = vault.KeeperRecord.load(params, ruid) - title = getattr(rec, "title", "") or "" - rtype = "" - if hasattr(rec, "record_type"): - rtype = getattr(rec, "record_type", "") or "" - login = "" - fields = getattr(rec, "fields", None) - if isinstance(fields, list): - field = next((x for x in fields if getattr(x, "type", "") == "login"), None) - if field and hasattr(field, "get_default_value"): - login = (field.get_default_value() or "") or "" - result.append((ruid, title, rtype, login)) - except Exception: - pass - return result + """Return list of (record_uid, title, record_type, login) for records in folder_uid.""" + return get_records_in_folder(params, folder_uid) def _get_all_ksm_app_records(params, ksm_shared_folders: list) -> list: @@ -415,15 +390,7 @@ def execute(self, params, **kwargs): # no need to populate params.enterprise (users/teams caches). # since extend only adds new records to existing folders - configuration = None - if config_name in params.record_cache: - configuration = vault.KeeperRecord.load(params, config_name) - else: - l_name = config_name.casefold() - for c in vault_extensions.find_records(params, record_version=6): - if c.title.casefold() == l_name: - configuration = c - break + configuration = find_pam_configuration(params, config_name) if not (configuration and isinstance(configuration, vault.TypedRecord) and configuration.version == 6): raise CommandError("pam project extend", f"""PAM configuration not found: "{config_name}" """) @@ -478,7 +445,7 @@ def execute(self, params, **kwargs): raise CommandError("pam project extend", f"""PAM KSM Application record not found: "{ksmapp_uid}" """) # Find KSM Application shared folders - ksm_shared_folders = self.get_app_shared_folders(params, ksmapp_uid) + ksm_shared_folders = self.get_app_shared_folders(params, ksmapp_uid, configuration.record_uid) if not ksm_shared_folders: raise CommandError("pam project extend", f""" No shared folders found for KSM Application: "{ksmapp_uid}" """) @@ -559,32 +526,53 @@ def execute(self, params, **kwargs): return self.process_data(params, project) - def get_app_shared_folders(self, params, ksm_app_uid: str) -> list[dict]: - ksm_shared_folders = [] - - try: - app_info_list = KSMCommand.get_app_info(params, ksm_app_uid) - if app_info_list and len(app_info_list) > 0: - app_info = app_info_list[0] - shares = [x for x in app_info.shares if x.shareType == APIRequest_pb2.SHARE_TYPE_FOLDER] # pylint: disable=no-member - for share in shares: - folder_uid = utils.base64_url_encode(share.secretUid) - if folder_uid in params.shared_folder_cache: - cached_sf = params.shared_folder_cache[folder_uid] - folder_name = cached_sf.get('name_unencrypted', 'Unknown') - is_editable = share.editable if hasattr(share, 'editable') else False - - ksm_shared_folders.append({ - 'uid': folder_uid, - 'name': folder_name, - 'editable': is_editable, - 'permissions': "Editable" if is_editable else "Read-Only" - }) - except Exception as e: - logging.error(f"Could not retrieve KSM application shares: {e}") - + def get_app_shared_folders(self, params, ksm_app_uid: str, configuration_uid: Optional[str]=None) -> list[dict]: + ksm_shared_folders = get_ksm_app_folders(params, ksm_app_uid) + if not ksm_shared_folders and configuration_uid: + ksm_shared_folders.extend(self.get_nsf_project_folders(params, configuration_uid)) return ksm_shared_folders + @staticmethod + def get_nsf_project_folders(params, configuration_uid: str) -> list[dict]: + folder_uids = [] + for folder_uid in find_record_folders(params, configuration_uid): + if folder_uid and is_nested_share_folder(params, folder_uid): + folder_uids.append(folder_uid) + + if not folder_uids: + return [] + + project_folder_uids = [] + for folder_uid in folder_uids: + folder = params.folder_cache.get(folder_uid) + if not folder: + continue + parent_uid = folder.parent_uid + if parent_uid: + for uid, candidate in params.folder_cache.items(): + if candidate.parent_uid == parent_uid and is_nested_share_folder(params, uid): + project_folder_uids.append(uid) + else: + project_folder_uids.append(folder_uid) + + result = [] + seen = set() + for folder_uid in project_folder_uids: + if folder_uid in seen: + continue + seen.add(folder_uid) + folder = params.folder_cache.get(folder_uid) + if not folder: + continue + result.append({ + 'uid': folder_uid, + 'name': folder.name or folder_uid, + 'editable': True, + 'permissions': 'Editable', + 'source': 'nested_share_folder', + }) + return result + def process_folders(self, params, project: dict) -> dict: """Step 1: Parse folder_paths from pam_data, build tree, process paths, optionally create new folders. Fills project['folders'] with path_to_folder_uid, good_paths, bad_paths; updates project['error_count'].""" @@ -1103,7 +1091,6 @@ def autodetect_folders(self, params, project: dict) -> list: step3_errors = [] folders_out = project.get("folders") or {} ksm_shared_folders = project.get("ksm_shared_folders") or [] - subfolder_record_cache = getattr(params, "subfolder_record_cache", None) or {} new_no_path = [] for o in chain(project.get("mapped_resources", []), project.get("mapped_users", [])): @@ -1137,7 +1124,7 @@ def autodetect_folders(self, params, project: dict) -> list: non_empty = [] for shf in ksm_shared_folders: uids = _folder_uids_under_shf(shf) - if any(subfolder_record_cache.get(fuid) for fuid in uids): + if any(get_folder_record_uids(params, fuid) for fuid in uids): non_empty.append(shf) if len(non_empty) == 0: step3_errors.append("Autodetect: no folders contain records; cannot assign resources/users folders.") @@ -1189,6 +1176,20 @@ def create_subfolder(self, params, folder_name:str, parent_uid:str="", permissio # folder_uid: if provided, create folder with this UID (same as records with pre-generated uid). name = str(folder_name or "").strip() + if is_nested_share_folder(params, parent_uid): + if folder_uid: + return create_nsf_subfolder(params, name, parent_uid, folder_uid=folder_uid) + from ...nested_share_folder.folder_api import create_folder_v3 + result = create_folder_v3(params, name, parent_uid=parent_uid) + if isinstance(result, dict) and result.get('success') is False: + raise CommandError("pam project extend", result.get('message') or 'Failed to create Nested Share Folder') + new_uid = result.get('folder_uid') if isinstance(result, dict) else None + if not new_uid: + raise CommandError("pam project extend", f'Nested Share Folder creation did not return UID: {name}') + api.sync_down(params) + params.environment_variables[LAST_FOLDER_UID] = new_uid + return new_uid + base_folder = params.folder_cache.get(parent_uid, None) or params.root_folder shared_folder = True if permissions else False @@ -1255,6 +1256,18 @@ def find_folders(self, params, parent_uid:str, folder:str, is_shared_folder:bool result = [v for k, v in matches.items() if (is_shared_folder and v.type == BaseFolderNode.SharedFolderType) or (not is_shared_folder and v.type == BaseFolderNode.UserFolderType)] + if not is_shared_folder: + for uid, nsf in getattr(params, 'nested_share_folders', {}).items(): + nsf_parent = nsf.get('parent_uid') or None + if nsf_parent == puid and nsf.get('name') == folder: + result.append(SimpleNamespace( + uid=uid, + name=nsf.get('name'), + parent_uid=nsf_parent, + type=BaseFolderNode.UserFolderType, + UserFolderType=BaseFolderNode.UserFolderType, + SharedFolderType=BaseFolderNode.SharedFolderType, + )) return result def create_ksm_app(self, params, app_name) -> str: @@ -1382,7 +1395,7 @@ def process_data(self, params, project): logging.warning(f"Processing external users: {len(new_users)}") for n, user in enumerate(new_users): folder_uid = getattr(user, "resolved_folder_uid", None) or shfusr - user.create_record(params, folder_uid) + extend_create_record(params, user, folder_uid) if n % pdelta == 0: print(f"{n}/{len(new_users)}") print(f"{len(new_users)}/{len(new_users)}\n") @@ -1397,7 +1410,7 @@ def process_data(self, params, project): print(f"{n}/{len(new_resources)}") folder_uid = getattr(mach, "resolved_folder_uid", None) or shfres admin_uid = get_admin_credential(mach, True) - mach.create_record(params, folder_uid) + extend_create_record(params, mach, folder_uid) tdag.link_resource_to_config(mach.uid) if isinstance(mach, PamRemoteBrowserObject): args = parse_command_options(mach, True) @@ -1464,7 +1477,7 @@ def process_data(self, params, project): if isinstance(user, PamUserObject) and rs and (getattr(rs, "rotation", "") or "").lower() == "general": rs.resourceUid = mach.uid ufolder = getattr(user, "resolved_folder_uid", None) or shfusr - user.create_record(params, ufolder) + extend_create_record(params, user, ufolder) if isinstance(user, PamUserObject): tdag.link_user_to_resource(user.uid, mach.uid, admin_uid == user.uid, True) if rs: @@ -1499,7 +1512,7 @@ def process_data(self, params, project): if isinstance(user, PamUserObject) and rs and (getattr(rs, "rotation", "") or "").lower() == "general": rs.resourceUid = mach.uid ufolder = getattr(user, "resolved_folder_uid", None) or shfusr - user.create_record(params, ufolder) + extend_create_record(params, user, ufolder) if isinstance(user, PamUserObject): tdag.link_user_to_resource(user.uid, mach.uid, admin_uid == user.uid, True) if rs: @@ -1545,7 +1558,7 @@ def process_data(self, params, project): if pce and getattr(pce, "environment", "") == "domain": if getattr(pce, "admin_credential_ref", None): pcuid = (project.get("pam_config") or {}).get("pam_config_uid") - pcrec = vault.KeeperRecord.load(params, pcuid) if pcuid else None + pcrec = load_pam_record(params, pcuid) if pcuid else None if pcrec and isinstance(pcrec, vault.TypedRecord) and pcrec.version == 6: if pcrec.record_type == "pamDomainConfiguration": prf = pcrec.get_typed_field("pamResources") @@ -1555,7 +1568,18 @@ def process_data(self, params, project): prf.value = prf.value or [{}] if isinstance(prf.value[0], dict): prf.value[0]["adminCredentialRef"] = pce.admin_credential_ref - record_management.update_record(params, pcrec) + # NSF PAM configs live under NSF folders; classic still use record_management. + force_nsf = is_pam_nsf_record(params, pcuid) + if not force_nsf: + for folder_uid in find_record_folders(params, pcuid): + if is_nested_share_folder(params, folder_uid): + force_nsf = True + break + if force_nsf: + update_pam_record( + params, pcrec, command='pam-project-extend', force_nsf=True) + else: + record_management.update_record(params, pcrec) tdag.link_user_to_config_with_options(pce.admin_credential_ref, is_admin="on") else: logging.error(f"Unable to add adminCredentialRef - bad pamResources field in PAM Config {pcuid}") @@ -1569,4 +1593,3 @@ def process_data(self, params, project): add_pam_scripts(params, pam_cfg_uid, refs) logging.debug("Done processing project data.") return - diff --git a/keepercommander/commands/pam_import/nsf_helpers.py b/keepercommander/commands/pam_import/nsf_helpers.py new file mode 100644 index 000000000..48963cf58 --- /dev/null +++ b/keepercommander/commands/pam_import/nsf_helpers.py @@ -0,0 +1,311 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bool: + """Return True when *folder_uid* is a Nested Shared Folder.""" + if not folder_uid: + return False + nsf_folders = getattr(params, 'nested_share_folders', None) or {} + if folder_uid in nsf_folders: + return True + subfolder = (getattr(params, 'subfolder_cache', None) or {}).get(folder_uid) or {} + return subfolder.get('source') == 'nested_share_folder' + + +def find_pam_configuration(params, config_name: str) -> Optional[vault.TypedRecord]: + """Resolve a PAM configuration record by UID or title (classic + NSF).""" + config_name = (config_name or '').strip() + if not config_name: + return None + + rec = load_pam_record(params, config_name) + if rec and isinstance(rec, vault.TypedRecord) and rec.version == 6: + return rec + + l_name = config_name.casefold() + for uid in iter_accessible_record_uids(params): + if uid == config_name: + continue + rec = load_pam_record(params, uid) + if not rec or not isinstance(rec, vault.TypedRecord) or rec.version != 6: + continue + if rec.title and rec.title.casefold() == l_name: + return rec + return None + + +def get_ksm_app_folders(params, ksm_app_uid: str) -> List[dict]: + """Return KSM-application folder roots (classic shared folders and NSF).""" + from ..ksm import KSMCommand + from ... import utils as keeper_utils + from ...proto import APIRequest_pb2 + + folders = [] + try: + app_info_list = KSMCommand.get_app_info(params, ksm_app_uid) + if not app_info_list: + return folders + app_info = app_info_list[0] + shares = [x for x in app_info.shares if x.shareType == APIRequest_pb2.SHARE_TYPE_FOLDER] # pylint: disable=no-member + for share in shares: + folder_uid = keeper_utils.base64_url_encode(share.secretUid) + is_editable = share.editable if hasattr(share, 'editable') else False + entry = _folder_entry_for_uid(params, folder_uid, is_editable) + if entry: + folders.append(entry) + except Exception as exc: + logging.error('Could not retrieve KSM application shares: %s', exc) + return folders + + +def _folder_entry_for_uid(params, folder_uid: str, is_editable: bool) -> Optional[dict]: + if folder_uid in getattr(params, 'shared_folder_cache', {}): + cached_sf = params.shared_folder_cache[folder_uid] + return { + 'uid': folder_uid, + 'name': cached_sf.get('name_unencrypted', 'Unknown'), + 'editable': is_editable, + 'permissions': 'Editable' if is_editable else 'Read-Only', + 'source': 'classic', + } + nsf_folders = getattr(params, 'nested_share_folders', None) or {} + if folder_uid in nsf_folders: + return { + 'uid': folder_uid, + 'name': nsf_folders[folder_uid].get('name', 'Unknown'), + 'editable': is_editable, + 'permissions': 'Editable' if is_editable else 'Read-Only', + 'source': 'nested', + } + return None + + +def build_folder_tree(params, folder_uid: str) -> dict: + """Build a nested folder tree under *folder_uid* (classic or NSF).""" + if is_nsf_folder_uid(params, folder_uid): + return _build_nsf_folder_tree(params, folder_uid) + return _build_classic_folder_tree(params, folder_uid) + + +def _build_classic_folder_tree(params, folder_uid: str) -> dict: + tree = {} + folder = params.folder_cache.get(folder_uid) + if not folder: + return tree + for subfolder_uid in folder.subfolders: + subfolder = params.folder_cache.get(subfolder_uid) + if not subfolder: + continue + folder_name = subfolder.name or '' + tree[folder_name] = { + 'uid': subfolder.uid, + 'name': folder_name, + 'subfolders': _build_classic_folder_tree(params, subfolder.uid), + } + return tree + + +def _build_nsf_folder_tree(params, root_uid: str) -> dict: + nsf_folders = getattr(params, 'nested_share_folders', None) or {} + + def children_of(parent_uid: str) -> dict: + kids = {} + for fuid, finfo in nsf_folders.items(): + raw_parent = finfo.get('parent_uid') or '' + if raw_parent != parent_uid: + continue + name = finfo.get('name') or fuid + kids[name] = { + 'uid': fuid, + 'name': name, + 'subfolders': children_of(fuid), + } + return kids + + return children_of(root_uid) + + +def get_folder_record_uids(params, folder_uid: str) -> set: + """Return record UIDs directly associated with a folder.""" + record_uids = set() + subfolder_record_cache = getattr(params, 'subfolder_record_cache', None) or {} + nsf_folder_records = getattr(params, 'nested_share_folder_records', None) or {} + if folder_uid in subfolder_record_cache: + record_uids.update(subfolder_record_cache[folder_uid]) + if folder_uid in nsf_folder_records: + record_uids.update(nsf_folder_records[folder_uid]) + return record_uids + + +def get_records_in_folder(params, folder_uid: str) -> list: + """Return (uid, title, record_type, login) tuples for records in *folder_uid*.""" + result = [] + for ruid in get_folder_record_uids(params, folder_uid): + try: + rec = load_pam_record(params, ruid) + if not rec: + continue + title = getattr(rec, 'title', '') or '' + rtype = getattr(rec, 'record_type', '') or '' + login = '' + fields = getattr(rec, 'fields', None) + if isinstance(fields, list): + field = next((x for x in fields if getattr(x, 'type', '') == 'login'), None) + if field and hasattr(field, 'get_default_value'): + login = (field.get_default_value() or '') or '' + result.append((ruid, title, rtype, login)) + except Exception: + continue + return result + + +def snapshot_nsf_folder_keys(params) -> dict: + out = {} + for uid, info in (getattr(params, 'nested_share_folders', None) or {}).items(): + key = info.get('folder_key_unencrypted') if isinstance(info, dict) else None + if not key: + continue + out[uid] = { + 'name': info.get('name') or uid, + 'parent_uid': info.get('parent_uid'), + 'folder_key_unencrypted': key, + } + return out + + +def restore_nsf_folder_keys(params, snapshot: Optional[dict]) -> None: + for uid, info in (snapshot or {}).items(): + seed_nsf_folder_cache( + params, + uid, + info.get('name') or uid, + info.get('parent_uid'), + info.get('folder_key_unencrypted'), + ) + + +def sync_down_preserving_nsf_keys(params) -> None: + preserved = snapshot_nsf_folder_keys(params) + api.sync_down(params) + restore_nsf_folder_keys(params, preserved) + + +def seed_nsf_folder_cache(params, folder_uid: str, name: str, + parent_uid: Optional[str] = None, + folder_key: Optional[bytes] = None) -> None: + """Keep local NSF caches consistent right after folder creation. + """ + if not folder_uid: + return + + normalized_parent = parent_uid or None + nsf = getattr(params, 'nested_share_folders', None) + if nsf is None: + params.nested_share_folders = {} + nsf = params.nested_share_folders + entry = dict(nsf.get(folder_uid) or {}) + entry['name'] = name + entry['parent_uid'] = normalized_parent + if folder_key: + entry['folder_key_unencrypted'] = folder_key + nsf[folder_uid] = entry + + subfolder_cache = getattr(params, 'subfolder_cache', None) + if subfolder_cache is None: + params.subfolder_cache = {} + subfolder_cache = params.subfolder_cache + sf_entry = dict(subfolder_cache.get(folder_uid) or {}) + sf_entry.update({ + 'folder_uid': folder_uid, + 'type': 'user_folder', + 'name': name, + 'parent_uid': normalized_parent, + 'source': 'nested_share_folder', + }) + if folder_key: + sf_entry['folder_key_unencrypted'] = folder_key + subfolder_cache[folder_uid] = sf_entry + + # Folder nodes used by find_folders / NSF project discovery. + from ...subfolder import NestedShareFolderNode + folder_cache = getattr(params, 'folder_cache', None) + if isinstance(folder_cache, dict): + node = folder_cache.get(folder_uid) + if not isinstance(node, NestedShareFolderNode): + node = NestedShareFolderNode() + node.uid = folder_uid + node.subfolders = [] + folder_cache[folder_uid] = node + node.name = name + node.parent_uid = normalized_parent + if normalized_parent and normalized_parent in folder_cache: + parent = folder_cache[normalized_parent] + kids = getattr(parent, 'subfolders', None) + if kids is None: + parent.subfolders = [folder_uid] + elif folder_uid not in kids: + kids.append(folder_uid) + + env = getattr(params, 'environment_variables', None) + if isinstance(env, dict): + env['last_folder_uid'] = folder_uid + + +def create_nsf_subfolder(params, folder_name: str, parent_uid: str = '', + folder_uid: Optional[str] = None) -> str: + """Create an NSF subfolder; returns folder UID.""" + from ...nested_share_folder.folder_api import _prepare_folder_for_creation, folder_add_v3 + from ..nested_share_folder.helpers import command_error_handler, check_result + from ...proto import folder_pb2 + + name = str(folder_name or '').strip() + if not name: + raise CommandError('pam project extend', 'NSF subfolder name is required') + if not folder_uid: + folder_uid = api.generate_record_uid() + + parent = parent_uid or None + if parent: + nsf_folders = getattr(params, 'nested_share_folders', None) or {} + folder_cache = getattr(params, 'folder_cache', None) or {} + if parent not in nsf_folders and parent not in folder_cache: + raise CommandError('pam project extend', f'Parent folder "{parent_uid}" not found') + + with command_error_handler('pam project extend'): + fd, folder_key = _prepare_folder_for_creation( + params, folder_uid, name, parent, None, True, + ) + response = folder_add_v3(params, [fd]) + if not response.folderAddResults: + raise CommandError('pam project extend', 'No results from NSF folder creation') + r = response.folderAddResults[0] + check_result({ + 'success': r.status == folder_pb2.SUCCESS, + 'message': r.message, + }, 'pam project extend') + + seed_nsf_folder_cache(params, folder_uid, name, parent_uid, folder_key) + return folder_uid + + +def extend_create_record(params, obj, folder_uid: str) -> Optional[str]: + """Create a PAM import record in a classic or NSF folder.""" + return obj.create_record(params, folder_uid) diff --git a/keepercommander/commands/pam_import/record_loader.py b/keepercommander/commands/pam_import/record_loader.py new file mode 100644 index 000000000..306614798 --- /dev/null +++ b/keepercommander/commands/pam_import/record_loader.py @@ -0,0 +1,67 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' Iterator[str]: + """Yield record UIDs from classic and Nested Shared Folder caches.""" + seen = set() + for attr in ('record_cache', 'nested_share_records'): + cache = getattr(params, attr, None) or {} + for uid in cache: + if uid not in seen: + seen.add(uid) + yield uid + + nsf_record_data = getattr(params, 'nested_share_record_data', None) or {} + for uid in nsf_record_data: + if uid not in seen: + seen.add(uid) + yield uid + + +def load_pam_record(params, record_uid: str) -> Optional[vault.KeeperRecord]: + """Load a vault record from classic cache, NSF cache, or NSF record data.""" + record_uid = (record_uid or '').strip() + if not record_uid: + return None + + cached = get_record_from_cache(params, record_uid) + if cached and cached.get('data_unencrypted'): + rec = vault.KeeperRecord.load(params, cached) + if rec: + return rec + + rec = vault.KeeperRecord.load(params, record_uid) + if rec: + return rec + + nsf_record_data = getattr(params, 'nested_share_record_data', None) or {} + nsf_records = getattr(params, 'nested_share_records', None) or {} + rd = nsf_record_data.get(record_uid) or {} + if 'data_json' not in rd: + return None + + dj = rd['data_json'] + version = nsf_records.get(record_uid, {}).get('version', 3) + if version not in (3, 6): + return None + + keeper_record = vault.TypedRecord(version=version) + keeper_record.record_uid = record_uid + keeper_record.load_record_data(dj, None) + return keeper_record diff --git a/keepercommander/commands/tunnel_and_connections.py b/keepercommander/commands/tunnel_and_connections.py index 65aaecd84..2b478e83e 100644 --- a/keepercommander/commands/tunnel_and_connections.py +++ b/keepercommander/commands/tunnel_and_connections.py @@ -36,6 +36,7 @@ wait_for_tunnel_connection, create_rust_webrtc_settings, \ print_above_keeper_prompt from .pam.router_helper import get_dag_leafs +from .pam.vault_target import update_pam_record from .tunnel_registry import ( PARENT_GRACE_SECONDS, is_pid_alive, @@ -431,8 +432,7 @@ def execute(self, params, **kwargs): record.custom.append(record_seed) dirty = True if dirty: - record_management.update_record(params, record) - api.sync_down(params) + update_pam_record(params, record, command='pam tunnel edit') traffic_encryption_key = record.get_typed_field('trafficEncryptionSeed') if not traffic_encryption_key: @@ -504,8 +504,7 @@ def execute(self, params, **kwargs): dirty = True # Persist the record changes (new pamSettings field or port modifications) if dirty: - record_management.update_record(params, record) - api.sync_down(params) + update_pam_record(params, record, command='pam tunnel edit') dirty = False if not tmp_dag.is_tunneling_config_set_up(record_uid): print(f"{bcolors.FAIL}No PAM Configuration UID set. This must be set for tunneling to work. " @@ -555,8 +554,7 @@ def execute(self, params, **kwargs): if dirty: tmp_dag.set_resource_allowed(resource_uid=record_uid, tunneling=_tunneling, allowed_settings_name=allowed_settings_name) - record_management.update_record(params, record) - api.sync_down(params) + update_pam_record(params, record, command='pam tunnel edit') # Print out the tunnel settings if not kwargs.get('silent'): @@ -4172,7 +4170,10 @@ def execute(self, params, **kwargs): print(f"{bcolors.FAIL}Please provide a valid PAM Configuration.{bcolors.ENDC}") return - folder_uid = resolve_folder(params, folder) + folder_uid = '' + if folder: + from .pam.vault_target import resolve_pam_folder_uid + folder_uid = resolve_pam_folder_uid(params, folder, allow_legacy_user=True) or resolve_folder(params, folder) if folder and not folder_uid: print(f"{bcolors.WARNING}Unable to find destination folder '{folder}' " "(Note: folder names/paths are case sensitive) " @@ -4243,7 +4244,8 @@ def execute(self, params, **kwargs): f"and PAM User record will be created in folder '{folders[0]}'.{bcolors.ENDC}") folder_uid = folders[0] if folders else '' # '' means root folder - record_management.add_record_to_folder(params, user_rec, folder_uid) + from .pam.vault_target import create_record_in_folder + create_record_in_folder(params, user_rec, folder_uid, command='pam-split') pam_user_uid = params.environment_variables.get(LAST_RECORD_UID, '') api.sync_down(params) diff --git a/keepercommander/nested_share_folder/__init__.py b/keepercommander/nested_share_folder/__init__.py index 8436b6ba1..1feda6a61 100644 --- a/keepercommander/nested_share_folder/__init__.py +++ b/keepercommander/nested_share_folder/__init__.py @@ -30,6 +30,9 @@ 'create_folder_data', 'encrypt_folder_key', 'folder_add_v3', 'create_folder_v3', 'create_folders_batch_v3', 'folder_access_update_v3', 'grant_folder_access_v3', + 'grant_folder_access_to_application_v3', + 'update_folder_access_to_application_v3', + 'revoke_folder_access_from_application_v3', 'update_folder_access_v3', 'revoke_folder_access_v3', 'manage_folder_access_batch_v3', 'folder_update_v3', 'resolve_folder_identifier', @@ -37,11 +40,13 @@ 'get_folder_access_v3', ], 'record_api': [ - 'create_record_data_v3', 'record_add_v3', 'record_update_v3', + 'create_record_data_v3', 'record_add_v3', 'record_add_pam_configuration_v3', 'record_update_v3', 'create_record_v3', 'update_record_v3', 'create_records_batch_v3', 'get_record_details_v3', 'get_record_accesses_v3', 'find_direct_user_share_access', 'is_record_share_update_noop', 'share_record_v3', 'update_record_share_v3', 'unshare_record_v3', + 'share_record_to_application_v3', 'update_record_share_to_application_v3', + 'unshare_record_from_application_v3', 'batch_update_record_shares_v3', 'batch_create_record_shares_v3', 'batch_unshare_records_v3', 'transfer_record_ownership_v3', 'transfer_records_ownership_batch_v3', diff --git a/keepercommander/nested_share_folder/folder_api.py b/keepercommander/nested_share_folder/folder_api.py index c2555bb21..378c0e181 100644 --- a/keepercommander/nested_share_folder/folder_api.py +++ b/keepercommander/nested_share_folder/folder_api.py @@ -12,7 +12,7 @@ from .. import utils, crypto, api from ..params import KeeperParams -from ..proto import folder_pb2 +from ..proto import folder_pb2, tla_pb2 from ..error import KeeperApiError from ..subfolder import try_resolve_path @@ -309,9 +309,20 @@ def _resolve_accessor(params, accessor_uid, as_team): return uid_bytes, accessor_uid, folder_pb2.AT_USER +def _apply_folder_expiration_tla(tla_props, expiration_timestamp, + rotate_on_expiration=False): + """Set expiration / ROE fields on folder-access TLAProperties.""" + if expiration_timestamp is None: + return + tla_props.expiration = expiration_timestamp + if rotate_on_expiration and expiration_timestamp > 0: + tla_props.timerNotificationType = tla_pb2.NOTIFY_OWNER + tla_props.rotateOnExpiration = True + + def grant_folder_access_v3(params, folder_uid, user_uid, role='viewer', share_folder_key=True, expiration_timestamp=None, - as_team=False): + as_team=False, rotate_on_expiration=False): """Grant a user *or team* access to a Nested Share Folder. """ resolved = resolve_folder_identifier(params, folder_uid) @@ -372,7 +383,8 @@ def grant_folder_access_v3(params, folder_uid, user_uid, role='viewer', 'success': True, 'action_taken': 'already_had_access'} result = update_folder_access_v3(params, folder_uid, identifier_label, role=role, as_team=as_team, - expiration_timestamp=expiration_timestamp) + expiration_timestamp=expiration_timestamp, + rotate_on_expiration=rotate_on_expiration) result['action_taken'] = 'updated' return result @@ -383,8 +395,8 @@ def grant_folder_access_v3(params, folder_uid, user_uid, role='viewer', ad.accessRoleType = access_role ad.permissions.CopyFrom(get_folder_permissions_for_role(access_role)) - if expiration_timestamp is not None: - ad.tlaProperties.expiration = expiration_timestamp + _apply_folder_expiration_tla( + ad.tlaProperties, expiration_timestamp, rotate_on_expiration) if share_folder_key: fk = get_folder_key(params, folder_uid) @@ -429,7 +441,8 @@ def _check_existing_access(params, folder_uid, uid_bytes, target_role_name, def update_folder_access_v3(params, folder_uid, user_uid, role=None, hidden=None, - expiration_timestamp=None, as_team=False): + expiration_timestamp=None, as_team=False, + rotate_on_expiration=False): if role is None and hidden is None and expiration_timestamp is None: raise ValueError("At least one field (role, hidden, or expiration) required") resolved = resolve_folder_identifier(params, folder_uid) @@ -452,8 +465,8 @@ def update_folder_access_v3(params, folder_uid, user_uid, role=None, hidden=None ad.permissions.CopyFrom(get_folder_permissions_for_role(resolved_role)) if hidden is not None: ad.hidden = hidden - if expiration_timestamp is not None: - ad.tlaProperties.expiration = expiration_timestamp + _apply_folder_expiration_tla( + ad.tlaProperties, expiration_timestamp, rotate_on_expiration) response = folder_access_update_v3(params, folder_access_updates=[ad]) result = parse_folder_access_result(response, folder_uid, identifier_label, @@ -523,6 +536,112 @@ def _evict_folder_accessor_cache(params, folder_uid, accessor_uid_b64): ] +def _application_folder_role(is_editable): + return 'content-manager' if is_editable else 'viewer' + + +def grant_folder_access_to_application_v3(params, folder_uid, app_uid, app_key, + is_editable=False): + """Grant a Secrets Manager application access to an NSF folder. + + Uses ``vault/folders/v3/access_update`` with ``AT_APPLICATION``, matching + the vault UI ``folderAccessAdds`` payload. + """ + resolved = resolve_folder_identifier(params, folder_uid) + if not resolved: + raise ValueError(f"Folder '{folder_uid}' not found") + folder_uid = resolved + if not app_uid or not app_key: + raise ValueError('Application UID and key are required') + + role = _application_folder_role(is_editable) + access_role = resolve_role_name(role) + target_role_name = folder_pb2.AccessRoleType.Name(access_role) + app_uid_bytes = utils.base64_url_decode(app_uid) + + existing = _check_existing_access( + params, folder_uid, app_uid_bytes, target_role_name, 'AT_APPLICATION') + if existing is not None: + if existing == target_role_name: + return { + 'folder_uid': folder_uid, + 'user_uid': app_uid, + 'access_type': 'AT_APPLICATION', + 'status': 'SUCCESS', + 'message': f'Application already has {role} access', + 'success': True, + 'action_taken': 'already_had_access', + } + return update_folder_access_to_application_v3( + params, folder_uid, app_uid, is_editable=is_editable) + + ad = folder_pb2.FolderAccessData() + ad.folderUid = utils.base64_url_decode(folder_uid) + ad.accessTypeUid = app_uid_bytes + ad.accessType = folder_pb2.AT_APPLICATION + ad.accessRoleType = access_role + ad.permissions.CopyFrom(get_folder_permissions_for_role(access_role)) + + folder_key = get_folder_key(params, folder_uid) + ek = folder_pb2.EncryptedDataKey() + ek.encryptedKey = crypto.encrypt_aes_v2(folder_key, app_key) + ek.encryptedKeyType = folder_pb2.encrypted_by_data_key_gcm + ad.folderKey.CopyFrom(ek) + + response = folder_access_update_v3(params, folder_access_adds=[ad]) + result = parse_folder_access_result( + response, folder_uid, app_uid, 'Application access granted successfully') + result['access_type'] = 'AT_APPLICATION' + result.setdefault('action_taken', 'granted' if result['success'] else 'grant_failed') + return result + + +def update_folder_access_to_application_v3(params, folder_uid, app_uid, + is_editable=False): + """Update an application's NSF folder role (viewer / content-manager).""" + resolved = resolve_folder_identifier(params, folder_uid) + if not resolved: + raise ValueError(f"Folder '{folder_uid}' not found") + folder_uid = resolved + + role = _application_folder_role(is_editable) + access_role = resolve_role_name(role) + + ad = folder_pb2.FolderAccessData() + ad.folderUid = utils.base64_url_decode(folder_uid) + ad.accessTypeUid = utils.base64_url_decode(app_uid) + ad.accessType = folder_pb2.AT_APPLICATION + ad.accessRoleType = access_role + ad.permissions.CopyFrom(get_folder_permissions_for_role(access_role)) + + response = folder_access_update_v3(params, folder_access_updates=[ad]) + result = parse_folder_access_result( + response, folder_uid, app_uid, 'Application access updated successfully') + result['access_type'] = 'AT_APPLICATION' + return result + + +def revoke_folder_access_from_application_v3(params, folder_uid, app_uid): + """Revoke a Secrets Manager application's access to an NSF folder.""" + resolved = resolve_folder_identifier(params, folder_uid) + if not resolved: + raise ValueError(f"Folder '{folder_uid}' not found") + folder_uid = resolved + + ad = folder_pb2.FolderAccessData() + ad.folderUid = utils.base64_url_decode(folder_uid) + ad.accessTypeUid = utils.base64_url_decode(app_uid) + ad.accessType = folder_pb2.AT_APPLICATION + + response = folder_access_update_v3(params, folder_access_removes=[ad]) + result = parse_folder_access_result( + response, folder_uid, app_uid, 'Application access revoked successfully') + result['access_type'] = 'AT_APPLICATION' + if result.get('success'): + params.sync_data = True + return result + + def revoke_folder_access_v3(params, folder_uid, user_uid, as_team=False): """Revoke user or team access to an NSF folder. diff --git a/keepercommander/nested_share_folder/folder_record_api.py b/keepercommander/nested_share_folder/folder_record_api.py index 1e64141a5..9070c78fc 100644 --- a/keepercommander/nested_share_folder/folder_record_api.py +++ b/keepercommander/nested_share_folder/folder_record_api.py @@ -13,6 +13,7 @@ encrypt_record_key_for_folder, ) from .folder_api import resolve_folder_identifier +from ..commands.nested_share_folder.helpers import normalize_nsf_user_message logger = logging.getLogger(__name__) @@ -175,7 +176,7 @@ def move_record_v3(params, record_uid, from_folder_uid=None, to_folder_uid=None) r = rs.folderRecordUpdateResult[0] if r.status != folder_pb2.SUCCESS: return _move_failure(record_uid, from_folder_uid, to_folder_uid, - f"Add failed: {r.message}") + f"Add failed: {normalize_nsf_user_message(r.message)}") except Exception as e: return _move_failure(record_uid, from_folder_uid, to_folder_uid, f"Add error: {e}") diff --git a/keepercommander/nested_share_folder/record_api.py b/keepercommander/nested_share_folder/record_api.py index f0bcc9e69..f3d13979a 100644 --- a/keepercommander/nested_share_folder/record_api.py +++ b/keepercommander/nested_share_folder/record_api.py @@ -29,7 +29,8 @@ def create_record_data_v3(record_uid, record_key, data, non_shared_data=None, folder_uid=None, folder_key=None, record_key_type=None, - client_modified_time=None, data_key=None): + client_modified_time=None, data_key=None, + owner_key=None, audit=None): ra = record_endpoints_pb2.RecordAdd() ra.recordUid = utils.base64_url_decode(record_uid) @@ -44,6 +45,10 @@ def create_record_data_v3(record_uid, record_key, data, raise ValueError("data_key required when creating at vault root") ra.recordKey = crypto.encrypt_aes_v2(record_key, data_key) + owner_encryption_key = owner_key or data_key + if owner_encryption_key is not None: + ra.recordKeyEncryptedByOwnerKey = crypto.encrypt_aes_v2(record_key, owner_encryption_key) + ra.recordKeyType = (record_key_type if record_key_type is not None else folder_pb2.encrypted_by_data_key_gcm) @@ -57,6 +62,8 @@ def create_record_data_v3(record_uid, record_key, data, ra.nonSharedData = crypto.encrypt_aes_v2(ns_bytes, record_key) if client_modified_time: ra.clientModifiedTime = client_modified_time + if audit is not None: + ra.audit.CopyFrom(audit) return ra @@ -77,6 +84,18 @@ def record_add_v3(params, records, client_time=None, security_data_key_type=None rs_type=record_pb2.RecordsModifyResponse) +def record_add_pam_configuration_v3(params, records, client_time=None, security_data_key_type=None): + if not records or len(records) > 1000: + raise ValueError("Provide 1..1000 records") + rq = record_endpoints_pb2.RecordsAddRequest() + rq.records.extend(records) + rq.clientTime = client_time if client_time is not None else utils.current_milli_time() + if security_data_key_type: + rq.securityDataKeyType = security_data_key_type + return api.communicate_rest(params, rq, 'vault/records/v3/add_pam_configuration', + rs_type=record_pb2.RecordsModifyResponse) + + def record_update_v3(params, records, client_time=None, security_data_key_type=None): if not records or len(records) > 1000: raise ValueError("Provide 1..1000 records") @@ -96,8 +115,8 @@ def record_update_v3(params, records, client_time=None, security_data_key_type=N def create_record_v3(params, record_type='', title='', fields=None, folder_uid=None, notes=None, custom_fields=None, - record_data=None): - uid = utils.generate_uid() + record_data=None, record_uid=None): + uid = record_uid or utils.generate_uid() rk = os.urandom(32) if record_data is not None: @@ -135,16 +154,15 @@ def create_record_v3(params, record_type='', title='', fields=None, def update_record_v3(params, record_uid, data=None, title=None, record_type=None, fields=None, notes=None, non_shared_data=None, revision=None): - if record_uid not in params.record_cache: + rec = get_record_from_cache(params, record_uid) + if not rec: from .. import sync_down sync_down.sync_down(params) - if record_uid not in params.record_cache: - raise ValueError(f"Record {record_uid} not found") + rec = get_record_from_cache(params, record_uid) + if not rec: + raise ValueError(f"Record {record_uid} not found") - rec = params.record_cache[record_uid] - rk = rec.get('record_key_unencrypted') - if not rk: - raise ValueError(f"Record key not available for {record_uid}") + rk = rec.get('record_key_unencrypted') or get_record_key(params, record_uid) if data is None: existing = None @@ -339,6 +357,8 @@ def get_record_accesses_v3(params, record_uids): # Proto3 scalar fields have no presence; only test the submessage. if d.HasField('tlaProperties'): ao['tla_expiration'] = d.tlaProperties.expiration + ao['tla_rotate_on_expiration'] = bool( + getattr(d.tlaProperties, 'rotateOnExpiration', False)) result['record_accesses'].append(ao) for fu in rs.forbiddenRecords: result['forbidden_records'].append(utils.base64_url_encode(fu)) @@ -363,8 +383,11 @@ def find_direct_user_share_access(access_result, record_uid, email): _SHARE_EXPIRATION_NOOP_TOLERANCE_MS = 60_000 -def is_record_share_update_noop(existing, access_role_type, expiration_timestamp): - """True when an update would leave role and expiration unchanged.""" +def is_record_share_update_noop(existing, access_role_type, expiration_timestamp, + rotate_on_expiration=False): + """True when an update would leave role, expiration, and ROE unchanged.""" + if rotate_on_expiration and not existing.get('tla_rotate_on_expiration'): + return False if existing.get('access_role_type') != access_role_type: return False existing_exp = existing.get('tla_expiration') @@ -383,13 +406,16 @@ def is_record_share_update_noop(existing, access_role_type, expiration_timestamp # Record sharing (Strategy: share / update / revoke) # ══════════════════════════════════════════════════════════════════════════ -def _apply_share_expiration_tla(tla_props, expiration_timestamp): - """Set expiration / notification fields on a TLAProperties proto.""" +def _apply_share_expiration_tla(tla_props, expiration_timestamp, + rotate_on_expiration=False): + """Set expiration / notification / ROE fields on a TLAProperties proto.""" if expiration_timestamp is None: return tla_props.expiration = expiration_timestamp if expiration_timestamp > 0: tla_props.timerNotificationType = tla_pb2.NOTIFY_OWNER + if rotate_on_expiration: + tla_props.rotateOnExpiration = True def _build_revoke_permission(params, record_uid, recipient_email): @@ -409,7 +435,8 @@ def _build_revoke_permission(params, record_uid, recipient_email): def _build_share_permissions(params, record_uid, recipient_email, access_role_type, - expiration_timestamp, include_role, skip_sync=False): + expiration_timestamp, include_role, skip_sync=False, + rotate_on_expiration=False): """Build a Permissions protobuf for share/update — single source of truth.""" if not skip_sync: from .. import sync_down as sd @@ -444,7 +471,9 @@ def _build_share_permissions(params, record_uid, recipient_email, access_role_ty perm.rules.owner = False if include_role and access_role_type is not None: perm.rules.accessRoleType = access_role_type - _apply_share_expiration_tla(perm.rules.tlaProperties, expiration_timestamp) + _apply_share_expiration_tla( + perm.rules.tlaProperties, expiration_timestamp, + rotate_on_expiration=rotate_on_expiration) return perm @@ -459,10 +488,12 @@ def _send_revoke_share_request(params, record_uid, recipient_email): def _send_create_share_request(params, record_uid, recipient_email, access_role_type, - expiration_timestamp, skip_sync=False): + expiration_timestamp, skip_sync=False, + rotate_on_expiration=False): perm = _build_share_permissions(params, record_uid, recipient_email, access_role_type, expiration_timestamp, - include_role=True, skip_sync=skip_sync) + include_role=True, skip_sync=skip_sync, + rotate_on_expiration=rotate_on_expiration) rq = record_sharing_pb2.Request() rq.createSharingPermissions.append(perm) rs = api.communicate_rest(params, rq, 'vault/records/v3/share', @@ -472,10 +503,11 @@ def _send_create_share_request(params, record_uid, recipient_email, access_role_ def share_record_v3(params, record_uid, recipient_email, access_role_type, - expiration_timestamp=None): + expiration_timestamp=None, rotate_on_expiration=False): perm = _build_share_permissions(params, record_uid, recipient_email, access_role_type, expiration_timestamp, - include_role=True) + include_role=True, + rotate_on_expiration=rotate_on_expiration) rq = record_sharing_pb2.Request() rq.createSharingPermissions.append(perm) rs = api.communicate_rest(params, rq, 'vault/records/v3/share', @@ -484,8 +516,82 @@ def share_record_v3(params, record_uid, recipient_email, access_role_type, return {'results': results, 'success': all(r['success'] for r in results)} +def _application_record_role(is_editable): + return folder_pb2.CONTENT_MANAGER if is_editable else folder_pb2.VIEWER + + +def _build_application_share_permission(params, record_uid, app_uid, app_key, + is_editable=False, include_key=True): + rec = get_record_from_cache(params, record_uid) + if not rec: + raise ValueError(f"Record {record_uid} not found in cache") + rk = rec.get('record_key_unencrypted') + if not rk and include_key: + rk = get_record_key(params, record_uid, raise_on_missing=False) + if include_key and not rk: + raise ValueError(f"Record {record_uid} has no decrypted key") + if not app_uid: + raise ValueError('Application UID is required') + + app_uid_bytes = utils.base64_url_decode(app_uid) + record_uid_bytes = utils.base64_url_decode(record_uid) + + perm = record_sharing_pb2.Permissions() + perm.recipientUid = app_uid_bytes + perm.recordUid = record_uid_bytes + if include_key: + perm.recordKey = crypto.encrypt_aes_v2(rk, app_key) + perm.useEccKey = False + + perm.rules.accessTypeUid = app_uid_bytes + perm.rules.accessType = folder_pb2.AT_APPLICATION + perm.rules.recordUid = record_uid_bytes + perm.rules.owner = False + perm.rules.accessRoleType = _application_record_role(is_editable) + return perm + + +def share_record_to_application_v3(params, record_uid, app_uid, app_key, + is_editable=False): + """Share an NSF record with a Secrets Manager application via records/v3/share.""" + perm = _build_application_share_permission( + params, record_uid, app_uid, app_key, is_editable=is_editable, include_key=True) + rq = record_sharing_pb2.Request() + rq.createSharingPermissions.append(perm) + rs = api.communicate_rest(params, rq, 'vault/records/v3/share', + rs_type=record_sharing_pb2.Response) + results = [parse_sharing_status(s) for s in rs.createdSharingStatus] + return {'results': results, 'success': all(r['success'] for r in results)} + + +def update_record_share_to_application_v3(params, record_uid, app_uid, app_key, + is_editable=False): + """Update an NSF record's application share role.""" + perm = _build_application_share_permission( + params, record_uid, app_uid, app_key, is_editable=is_editable, include_key=True) + rq = record_sharing_pb2.Request() + rq.updateSharingPermissions.append(perm) + rs = api.communicate_rest(params, rq, 'vault/records/v3/share', + rs_type=record_sharing_pb2.Response) + results = [parse_sharing_status(s) for s in rs.updatedSharingStatus] + return {'results': results, 'success': all(r['success'] for r in results)} + + +def unshare_record_from_application_v3(params, record_uid, app_uid): + """Revoke a Secrets Manager application's access to an NSF record.""" + perm = _build_application_share_permission( + params, record_uid, app_uid, app_key=None, is_editable=False, include_key=False) + rq = record_sharing_pb2.Request() + rq.revokeSharingPermissions.append(perm) + rs = api.communicate_rest(params, rq, 'vault/records/v3/share', + rs_type=record_sharing_pb2.Response) + results = [parse_sharing_status(s) for s in rs.revokedSharingStatus] + return {'results': results, 'success': all(r['success'] for r in results)} + + def update_record_share_v3(params, record_uid, recipient_email, - access_role_type=None, expiration_timestamp=None): + access_role_type=None, expiration_timestamp=None, + rotate_on_expiration=False): """Update an existing direct share. Role changes use ``updateSharingPermissions``. Positive expirations use @@ -518,11 +624,13 @@ def update_record_share_v3(params, record_uid, recipient_email, recipient_email) return _send_create_share_request( params, record_uid, recipient_email, access_role_type, - expiration_timestamp, skip_sync=True) + expiration_timestamp, skip_sync=True, + rotate_on_expiration=rotate_on_expiration) perm = _build_share_permissions(params, record_uid, recipient_email, access_role_type, expiration_timestamp, - include_role=True, skip_sync=True) + include_role=True, skip_sync=True, + rotate_on_expiration=rotate_on_expiration) rq = record_sharing_pb2.Request() rq.updateSharingPermissions.append(perm) rs = api.communicate_rest(params, rq, 'vault/records/v3/share', @@ -530,7 +638,6 @@ def update_record_share_v3(params, record_uid, recipient_email, results = [parse_sharing_status(s) for s in rs.updatedSharingStatus] return {'results': results, 'success': all(r['success'] for r in results)} - def unshare_record_v3(params, record_uid, recipient_email, skip_sync=False): if not skip_sync: from .. import sync_down as sd diff --git a/keepercommander/nested_share_folder/sync.py b/keepercommander/nested_share_folder/sync.py index 716e96441..32992276d 100644 --- a/keepercommander/nested_share_folder/sync.py +++ b/keepercommander/nested_share_folder/sync.py @@ -189,6 +189,7 @@ def _process_nested_share_folder_sync(params, acc): record_links = acc.get('record_links') or [] removed_record_links = acc.get('removed_record_links') or [] raw_dag_data = acc.get('raw_dag_data') or [] + record_rotations = acc.get('record_rotations') or [] _process_users(params, users) _process_folders(params, folders) @@ -204,6 +205,7 @@ def _process_nested_share_folder_sync(params, acc): _process_record_accesses(params, record_accesses) _process_revoked_record_accesses(params, revoked_record_accesses) _process_record_sharing_states(params, record_sharing_states) + _process_record_rotations(params, record_rotations) _process_record_links(params, record_links) _process_removed_record_links(params, removed_record_links) @@ -227,6 +229,13 @@ def _process_users(params, users): params.user_cache[account_uid] = user.username +def _process_record_rotations(params, record_rotations): + """Fold NSF ``recordRotationData`` into ``params.record_rotation_cache``.""" + from .. import vault_extensions + for rr in record_rotations: + vault_extensions.cache_record_rotation(params, rr) + + def _process_folders(params, folders): """Store base folder objects (encrypted; keys/data decrypted later).""" for folder_data in folders: diff --git a/keepercommander/proto/AccountSummary_pb2.py b/keepercommander/proto/AccountSummary_pb2.py index 353062c78..c6a89b29e 100644 --- a/keepercommander/proto/AccountSummary_pb2.py +++ b/keepercommander/proto/AccountSummary_pb2.py @@ -25,11 +25,11 @@ from . import APIRequest_pb2 as APIRequest__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x14\x41\x63\x63ountSummary.proto\x12\x0e\x41\x63\x63ountSummary\x1a\x10\x41PIRequest.proto\"N\n\x15\x41\x63\x63ountSummaryRequest\x12\x16\n\x0esummaryVersion\x18\x01 \x01(\x05\x12\x1d\n\x15includeRecentActivity\x18\x02 \x01(\x08\"\xcc\x05\n\x16\x41\x63\x63ountSummaryElements\x12\x11\n\tclientKey\x18\x01 \x01(\x0c\x12*\n\x08settings\x18\x02 \x01(\x0b\x32\x18.AccountSummary.Settings\x12*\n\x08keysInfo\x18\x03 \x01(\x0b\x32\x18.AccountSummary.KeysInfo\x12)\n\x08syncLogs\x18\x04 \x03(\x0b\x32\x17.AccountSummary.SyncLog\x12\x19\n\x11isEnterpriseAdmin\x18\x05 \x01(\x08\x12(\n\x07license\x18\x06 \x01(\x0b\x32\x17.AccountSummary.License\x12$\n\x05group\x18\x07 \x01(\x0b\x32\x15.AccountSummary.Group\x12\x32\n\x0c\x45nforcements\x18\x08 \x01(\x0b\x32\x1c.AccountSummary.Enforcements\x12(\n\x06Images\x18\t \x03(\x0b\x32\x18.AccountSummary.KeyValue\x12\x30\n\x0fpersonalLicense\x18\n \x01(\x0b\x32\x17.AccountSummary.License\x12\x1e\n\x16\x66ixSharedFolderRecords\x18\x0b \x01(\x08\x12\x11\n\tusernames\x18\x0c \x03(\t\x12+\n\x07\x64\x65vices\x18\r \x03(\x0b\x32\x1a.AccountSummary.DeviceInfo\x12\x14\n\x0cisShareAdmin\x18\x0e \x01(\x08\x12\x17\n\x0f\x61\x63\x63ountRecovery\x18\x0f \x01(\x08\x12\x1d\n\x15\x61\x63\x63ountRecoveryPrompt\x18\x10 \x01(\x08\x12\'\n\x1fminMasterPasswordLengthNoPrompt\x18\x11 \x01(\x05\x12\x16\n\x0e\x66orbidKeyType2\x18\x12 \x01(\x08\x12\x16\n\x0e\x66orbidKeyType1\x18\x13 \x01(\x08\x12\x1a\n\x12\x64isallowedFeatures\x18\x14 \x03(\t\"\x8b\x03\n\nDeviceInfo\x12\x1c\n\x14\x65ncryptedDeviceToken\x18\x01 \x01(\x0c\x12\x12\n\ndeviceName\x18\x02 \x01(\t\x12\x32\n\x0c\x64\x65viceStatus\x18\x03 \x01(\x0e\x32\x1c.Authentication.DeviceStatus\x12\x17\n\x0f\x64\x65vicePublicKey\x18\x04 \x01(\x0c\x12 \n\x18\x65ncryptedDataKeyDoNotUse\x18\x05 \x01(\x0c\x12\x15\n\rclientVersion\x18\x06 \x01(\t\x12\x10\n\x08username\x18\x07 \x01(\t\x12\x11\n\tipAddress\x18\x08 \x01(\t\x12\x1a\n\x12\x61pproveRequestTime\x18\t \x01(\x03\x12\x1f\n\x17\x65ncryptedDataKeyPresent\x18\n \x01(\x08\x12\x0f\n\x07groupId\x18\x0b \x01(\x03\x12\x16\n\x0e\x64\x65vicePlatform\x18\x0c \x01(\t\x12:\n\x10\x63lientFormFactor\x18\r \x01(\x0e\x32 .Authentication.ClientFormFactor\"\xc1\x01\n\x08KeysInfo\x12\x18\n\x10\x65ncryptionParams\x18\x01 \x01(\x0c\x12\x18\n\x10\x65ncryptedDataKey\x18\x02 \x01(\x0c\x12\x19\n\x11\x64\x61taKeyBackupDate\x18\x03 \x01(\x01\x12\x13\n\x0buserAuthUid\x18\x04 \x01(\x0c\x12\x1b\n\x13\x65ncryptedPrivateKey\x18\x05 \x01(\x0c\x12\x1e\n\x16\x65ncryptedEccPrivateKey\x18\x06 \x01(\x0c\x12\x14\n\x0c\x65\x63\x63PublicKey\x18\x07 \x01(\x0c\"\x81\x01\n\x07SyncLog\x12\x13\n\x0b\x63ountryName\x18\x01 \x01(\t\x12\x12\n\nsecondsAgo\x18\x02 \x01(\x03\x12\x12\n\ndeviceName\x18\x03 \x01(\t\x12\x13\n\x0b\x63ountryCode\x18\x04 \x01(\t\x12\x11\n\tdeviceUID\x18\x05 \x01(\x0c\x12\x11\n\tipAddress\x18\x06 \x01(\t\"\xfd\x06\n\x07License\x12\x18\n\x10subscriptionCode\x18\x01 \x01(\t\x12\x15\n\rproductTypeId\x18\x02 \x01(\x05\x12\x17\n\x0fproductTypeName\x18\x03 \x01(\t\x12\x16\n\x0e\x65xpirationDate\x18\x04 \x01(\t\x12\x1e\n\x16secondsUntilExpiration\x18\x05 \x01(\x03\x12\x12\n\nmaxDevices\x18\x06 \x01(\x05\x12\x14\n\x0c\x66ilePlanType\x18\x07 \x01(\x05\x12\x11\n\tbytesUsed\x18\x08 \x01(\x03\x12\x12\n\nbytesTotal\x18\t \x01(\x03\x12%\n\x1dsecondsUntilStorageExpiration\x18\n \x01(\x03\x12\x1d\n\x15storageExpirationDate\x18\x0b \x01(\t\x12,\n$hasAutoRenewableAppstoreSubscription\x18\x0c \x01(\x08\x12\x13\n\x0b\x61\x63\x63ountType\x18\r \x01(\x05\x12\x18\n\x10uploadsRemaining\x18\x0e \x01(\x05\x12\x14\n\x0c\x65nterpriseId\x18\x0f \x01(\x05\x12\x13\n\x0b\x63hatEnabled\x18\x10 \x01(\x08\x12 \n\x18\x61uditAndReportingEnabled\x18\x11 \x01(\x08\x12!\n\x19\x62reachWatchFeatureDisable\x18\x12 \x01(\x08\x12\x12\n\naccountUid\x18\x13 \x01(\x0c\x12\x1c\n\x14\x61llowPersonalLicense\x18\x14 \x01(\x08\x12\x12\n\nlicensedBy\x18\x15 \x01(\t\x12\r\n\x05\x65mail\x18\x16 \x01(\t\x12\x1a\n\x12\x62reachWatchEnabled\x18\x17 \x01(\x08\x12\x1a\n\x12\x62reachWatchScanned\x18\x18 \x01(\x08\x12\x1d\n\x15\x62reachWatchExpiration\x18\x19 \x01(\x03\x12\x1e\n\x16\x62reachWatchDateCreated\x18\x1a \x01(\x03\x12%\n\x05\x65rror\x18\x1b \x01(\x0b\x32\x16.AccountSummary.Result\x12\x12\n\nexpiration\x18\x1d \x01(\x03\x12\x19\n\x11storageExpiration\x18\x1e \x01(\x03\x12\x14\n\x0cuploadsCount\x18\x1f \x01(\x05\x12\r\n\x05units\x18 \x01(\x05\x12\x19\n\x11pendingEnterprise\x18! \x01(\x08\x12\x14\n\x0cisPamEnabled\x18\" \x01(\x08\x12\x14\n\x0cisKsmEnabled\x18# \x01(\x08\"\xa3\x01\n\x05\x41\x64\x64On\x12\x14\n\x0clicenseKeyId\x18\x01 \x01(\x05\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x16\n\x0e\x65xpirationDate\x18\x03 \x01(\x03\x12\x13\n\x0b\x63reatedDate\x18\x04 \x01(\x03\x12\x0f\n\x07isTrial\x18\x05 \x01(\x08\x12\x0f\n\x07\x65nabled\x18\x06 \x01(\x08\x12\x0f\n\x07scanned\x18\x07 \x01(\x08\x12\x16\n\x0e\x66\x65\x61tureDisable\x18\x08 \x01(\x08\"\xf4\t\n\x08Settings\x12\r\n\x05\x61udit\x18\x01 \x01(\x08\x12!\n\x19mustPerformAccountShareBy\x18\x02 \x01(\x03\x12>\n\x0eshareAccountTo\x18\x03 \x03(\x0b\x32&.AccountSummary.MissingAccountShareKey\x12+\n\x05rules\x18\x04 \x03(\x0b\x32\x1c.AccountSummary.PasswordRule\x12\x1a\n\x12passwordRulesIntro\x18\x05 \x01(\t\x12\x16\n\x0e\x61utoBackupDays\x18\x06 \x01(\x05\x12\r\n\x05theme\x18\x07 \x01(\t\x12\x0f\n\x07\x63hannel\x18\x08 \x01(\t\x12\x14\n\x0c\x63hannelValue\x18\t \x01(\t\x12\x15\n\rrsaConfigured\x18\n \x01(\x08\x12\x15\n\remailVerified\x18\x0b \x01(\x08\x12\"\n\x1amasterPasswordLastModified\x18\x0c \x01(\x01\x12\x18\n\x10\x61\x63\x63ountFolderKey\x18\r \x01(\x0c\x12\x31\n\x0csecurityKeys\x18\x0e \x03(\x0b\x32\x1b.AccountSummary.SecurityKey\x12+\n\tkeyValues\x18\x0f \x03(\x0b\x32\x18.AccountSummary.KeyValue\x12\x0f\n\x07ssoUser\x18\x10 \x01(\x08\x12\x18\n\x10onlineAccessOnly\x18\x11 \x01(\x08\x12\x1c\n\x14masterPasswordExpiry\x18\x12 \x01(\x05\x12\x19\n\x11twoFactorRequired\x18\x13 \x01(\x08\x12\x16\n\x0e\x64isallowExport\x18\x14 \x01(\x08\x12\x15\n\rrestrictFiles\x18\x15 \x01(\x08\x12\x1a\n\x12restrictAllSharing\x18\x16 \x01(\x08\x12\x17\n\x0frestrictSharing\x18\x17 \x01(\x08\x12\"\n\x1arestrictSharingIncomingAll\x18\x18 \x01(\x08\x12)\n!restrictSharingIncomingEnterprise\x18\x19 \x01(\x08\x12\x13\n\x0blogoutTimer\x18\x1a \x01(\x03\x12\x17\n\x0fpersistentLogin\x18\x1b \x01(\x08\x12\x1c\n\x14ipDisableAutoApprove\x18\x1c \x01(\x08\x12$\n\x1cshareDataKeyWithEccPublicKey\x18\x1d \x01(\x08\x12\'\n\x1fshareDataKeyWithDevicePublicKey\x18\x1e \x01(\x08\x12\x1a\n\x12RecordTypesCounter\x18\x1f \x01(\x05\x12$\n\x1cRecordTypesEnterpriseCounter\x18 \x01(\x05\x12\x1a\n\x12recordTypesEnabled\x18! \x01(\x08\x12\x1c\n\x14\x63\x61nManageRecordTypes\x18\" \x01(\x08\x12\x1d\n\x15recordTypesPAMCounter\x18# \x01(\x05\x12\x1a\n\x12logoutTimerMinutes\x18$ \x01(\x05\x12 \n\x18securityKeysNoUserVerify\x18% \x01(\x08\x12\x36\n\x08\x63hannels\x18& \x03(\x0e\x32$.Authentication.TwoFactorChannelType\x12\x19\n\x11personalUsernames\x18\' \x03(\t\x12\x15\n\rmaxIpDistance\x18( \x01(\x05\x12\x1e\n\x16maxIpDistanceEffective\x18) \x01(\x05\"&\n\x08KeyValue\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"-\n\x0fKeyValueBoolean\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x08\"*\n\x0cKeyValueLong\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x03\"=\n\x06Result\x12\x12\n\nresultCode\x18\x01 \x01(\t\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x0e\n\x06result\x18\x03 \x01(\t\"\xc2\x01\n\x0c\x45nforcements\x12)\n\x07strings\x18\x01 \x03(\x0b\x32\x18.AccountSummary.KeyValue\x12\x31\n\x08\x62ooleans\x18\x02 \x03(\x0b\x32\x1f.AccountSummary.KeyValueBoolean\x12+\n\x05longs\x18\x03 \x03(\x0b\x32\x1c.AccountSummary.KeyValueLong\x12\'\n\x05jsons\x18\x04 \x03(\x0b\x32\x18.AccountSummary.KeyValue\"<\n\x16MissingAccountShareKey\x12\x0f\n\x07role_id\x18\x01 \x01(\x03\x12\x11\n\tpublicKey\x18\x02 \x01(\x0c\"u\n\x0cPasswordRule\x12\x10\n\x08ruleType\x18\x01 \x01(\t\x12\x0f\n\x07pattern\x18\x02 \x01(\t\x12\r\n\x05match\x18\x03 \x01(\x08\x12\x0f\n\x07minimum\x18\x04 \x01(\x05\x12\x13\n\x0b\x64\x65scription\x18\x05 \x01(\t\x12\r\n\x05value\x18\x06 \x01(\t\"\x97\x01\n\x0bSecurityKey\x12\x10\n\x08\x64\x65viceId\x18\x01 \x01(\x03\x12\x12\n\ndeviceName\x18\x02 \x01(\t\x12\x11\n\tdateAdded\x18\x03 \x01(\x03\x12\x0f\n\x07isValid\x18\x04 \x01(\x08\x12>\n\x12\x64\x65viceRegistration\x18\x05 \x01(\x0b\x32\".AccountSummary.DeviceRegistration\"y\n\x12\x44\x65viceRegistration\x12\x11\n\tkeyHandle\x18\x01 \x01(\t\x12\x11\n\tpublicKey\x18\x02 \x01(\x0c\x12\x17\n\x0f\x61ttestationCert\x18\x03 \x01(\t\x12\x0f\n\x07\x63ounter\x18\x04 \x01(\x03\x12\x13\n\x0b\x63ompromised\x18\x05 \x01(\x08\"k\n\x05Group\x12\r\n\x05\x61\x64min\x18\x01 \x01(\x08\x12\x1d\n\x15groupVerificationCode\x18\x02 \x01(\t\x12\x34\n\radministrator\x18\x04 \x01(\x0b\x32\x1d.AccountSummary.Administrator\"\xc0\x01\n\rAdministrator\x12\x11\n\tfirstName\x18\x01 \x01(\t\x12\x10\n\x08lastName\x18\x02 \x01(\t\x12\r\n\x05\x65mail\x18\x03 \x01(\t\x12\x1c\n\x14\x63urrentNumberOfUsers\x18\x04 \x01(\x05\x12\x15\n\rnumberOfUsers\x18\x05 \x01(\x05\x12\x18\n\x10subscriptionCode\x18\x07 \x01(\t\x12\x16\n\x0e\x65xpirationDate\x18\x08 \x01(\t\x12\x14\n\x0cpurchaseDate\x18\t \x01(\tB*\n\x18\x63om.keepersecurity.protoB\x0e\x41\x63\x63ountSummaryb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x14\x61\x63\x63ountsummary.proto\x12\x0e\x41\x63\x63ountSummary\x1a\x10\x41PIRequest.proto\"N\n\x15\x41\x63\x63ountSummaryRequest\x12\x16\n\x0esummaryVersion\x18\x01 \x01(\x05\x12\x1d\n\x15includeRecentActivity\x18\x02 \x01(\x08\"\xcc\x05\n\x16\x41\x63\x63ountSummaryElements\x12\x11\n\tclientKey\x18\x01 \x01(\x0c\x12*\n\x08settings\x18\x02 \x01(\x0b\x32\x18.AccountSummary.Settings\x12*\n\x08keysInfo\x18\x03 \x01(\x0b\x32\x18.AccountSummary.KeysInfo\x12)\n\x08syncLogs\x18\x04 \x03(\x0b\x32\x17.AccountSummary.SyncLog\x12\x19\n\x11isEnterpriseAdmin\x18\x05 \x01(\x08\x12(\n\x07license\x18\x06 \x01(\x0b\x32\x17.AccountSummary.License\x12$\n\x05group\x18\x07 \x01(\x0b\x32\x15.AccountSummary.Group\x12\x32\n\x0c\x45nforcements\x18\x08 \x01(\x0b\x32\x1c.AccountSummary.Enforcements\x12(\n\x06Images\x18\t \x03(\x0b\x32\x18.AccountSummary.KeyValue\x12\x30\n\x0fpersonalLicense\x18\n \x01(\x0b\x32\x17.AccountSummary.License\x12\x1e\n\x16\x66ixSharedFolderRecords\x18\x0b \x01(\x08\x12\x11\n\tusernames\x18\x0c \x03(\t\x12+\n\x07\x64\x65vices\x18\r \x03(\x0b\x32\x1a.AccountSummary.DeviceInfo\x12\x14\n\x0cisShareAdmin\x18\x0e \x01(\x08\x12\x17\n\x0f\x61\x63\x63ountRecovery\x18\x0f \x01(\x08\x12\x1d\n\x15\x61\x63\x63ountRecoveryPrompt\x18\x10 \x01(\x08\x12\'\n\x1fminMasterPasswordLengthNoPrompt\x18\x11 \x01(\x05\x12\x16\n\x0e\x66orbidKeyType2\x18\x12 \x01(\x08\x12\x16\n\x0e\x66orbidKeyType1\x18\x13 \x01(\x08\x12\x1a\n\x12\x64isallowedFeatures\x18\x14 \x03(\t\"\x8b\x03\n\nDeviceInfo\x12\x1c\n\x14\x65ncryptedDeviceToken\x18\x01 \x01(\x0c\x12\x12\n\ndeviceName\x18\x02 \x01(\t\x12\x32\n\x0c\x64\x65viceStatus\x18\x03 \x01(\x0e\x32\x1c.Authentication.DeviceStatus\x12\x17\n\x0f\x64\x65vicePublicKey\x18\x04 \x01(\x0c\x12 \n\x18\x65ncryptedDataKeyDoNotUse\x18\x05 \x01(\x0c\x12\x15\n\rclientVersion\x18\x06 \x01(\t\x12\x10\n\x08username\x18\x07 \x01(\t\x12\x11\n\tipAddress\x18\x08 \x01(\t\x12\x1a\n\x12\x61pproveRequestTime\x18\t \x01(\x03\x12\x1f\n\x17\x65ncryptedDataKeyPresent\x18\n \x01(\x08\x12\x0f\n\x07groupId\x18\x0b \x01(\x03\x12\x16\n\x0e\x64\x65vicePlatform\x18\x0c \x01(\t\x12:\n\x10\x63lientFormFactor\x18\r \x01(\x0e\x32 .Authentication.ClientFormFactor\"\xc1\x01\n\x08KeysInfo\x12\x18\n\x10\x65ncryptionParams\x18\x01 \x01(\x0c\x12\x18\n\x10\x65ncryptedDataKey\x18\x02 \x01(\x0c\x12\x19\n\x11\x64\x61taKeyBackupDate\x18\x03 \x01(\x01\x12\x13\n\x0buserAuthUid\x18\x04 \x01(\x0c\x12\x1b\n\x13\x65ncryptedPrivateKey\x18\x05 \x01(\x0c\x12\x1e\n\x16\x65ncryptedEccPrivateKey\x18\x06 \x01(\x0c\x12\x14\n\x0c\x65\x63\x63PublicKey\x18\x07 \x01(\x0c\"\x81\x01\n\x07SyncLog\x12\x13\n\x0b\x63ountryName\x18\x01 \x01(\t\x12\x12\n\nsecondsAgo\x18\x02 \x01(\x03\x12\x12\n\ndeviceName\x18\x03 \x01(\t\x12\x13\n\x0b\x63ountryCode\x18\x04 \x01(\t\x12\x11\n\tdeviceUID\x18\x05 \x01(\x0c\x12\x11\n\tipAddress\x18\x06 \x01(\t\"\xfd\x06\n\x07License\x12\x18\n\x10subscriptionCode\x18\x01 \x01(\t\x12\x15\n\rproductTypeId\x18\x02 \x01(\x05\x12\x17\n\x0fproductTypeName\x18\x03 \x01(\t\x12\x16\n\x0e\x65xpirationDate\x18\x04 \x01(\t\x12\x1e\n\x16secondsUntilExpiration\x18\x05 \x01(\x03\x12\x12\n\nmaxDevices\x18\x06 \x01(\x05\x12\x14\n\x0c\x66ilePlanType\x18\x07 \x01(\x05\x12\x11\n\tbytesUsed\x18\x08 \x01(\x03\x12\x12\n\nbytesTotal\x18\t \x01(\x03\x12%\n\x1dsecondsUntilStorageExpiration\x18\n \x01(\x03\x12\x1d\n\x15storageExpirationDate\x18\x0b \x01(\t\x12,\n$hasAutoRenewableAppstoreSubscription\x18\x0c \x01(\x08\x12\x13\n\x0b\x61\x63\x63ountType\x18\r \x01(\x05\x12\x18\n\x10uploadsRemaining\x18\x0e \x01(\x05\x12\x14\n\x0c\x65nterpriseId\x18\x0f \x01(\x05\x12\x13\n\x0b\x63hatEnabled\x18\x10 \x01(\x08\x12 \n\x18\x61uditAndReportingEnabled\x18\x11 \x01(\x08\x12!\n\x19\x62reachWatchFeatureDisable\x18\x12 \x01(\x08\x12\x12\n\naccountUid\x18\x13 \x01(\x0c\x12\x1c\n\x14\x61llowPersonalLicense\x18\x14 \x01(\x08\x12\x12\n\nlicensedBy\x18\x15 \x01(\t\x12\r\n\x05\x65mail\x18\x16 \x01(\t\x12\x1a\n\x12\x62reachWatchEnabled\x18\x17 \x01(\x08\x12\x1a\n\x12\x62reachWatchScanned\x18\x18 \x01(\x08\x12\x1d\n\x15\x62reachWatchExpiration\x18\x19 \x01(\x03\x12\x1e\n\x16\x62reachWatchDateCreated\x18\x1a \x01(\x03\x12%\n\x05\x65rror\x18\x1b \x01(\x0b\x32\x16.AccountSummary.Result\x12\x12\n\nexpiration\x18\x1d \x01(\x03\x12\x19\n\x11storageExpiration\x18\x1e \x01(\x03\x12\x14\n\x0cuploadsCount\x18\x1f \x01(\x05\x12\r\n\x05units\x18 \x01(\x05\x12\x19\n\x11pendingEnterprise\x18! \x01(\x08\x12\x14\n\x0cisPamEnabled\x18\" \x01(\x08\x12\x14\n\x0cisKsmEnabled\x18# \x01(\x08\"\xa3\x01\n\x05\x41\x64\x64On\x12\x14\n\x0clicenseKeyId\x18\x01 \x01(\x05\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x16\n\x0e\x65xpirationDate\x18\x03 \x01(\x03\x12\x13\n\x0b\x63reatedDate\x18\x04 \x01(\x03\x12\x0f\n\x07isTrial\x18\x05 \x01(\x08\x12\x0f\n\x07\x65nabled\x18\x06 \x01(\x08\x12\x0f\n\x07scanned\x18\x07 \x01(\x08\x12\x16\n\x0e\x66\x65\x61tureDisable\x18\x08 \x01(\x08\"\xf4\t\n\x08Settings\x12\r\n\x05\x61udit\x18\x01 \x01(\x08\x12!\n\x19mustPerformAccountShareBy\x18\x02 \x01(\x03\x12>\n\x0eshareAccountTo\x18\x03 \x03(\x0b\x32&.AccountSummary.MissingAccountShareKey\x12+\n\x05rules\x18\x04 \x03(\x0b\x32\x1c.AccountSummary.PasswordRule\x12\x1a\n\x12passwordRulesIntro\x18\x05 \x01(\t\x12\x16\n\x0e\x61utoBackupDays\x18\x06 \x01(\x05\x12\r\n\x05theme\x18\x07 \x01(\t\x12\x0f\n\x07\x63hannel\x18\x08 \x01(\t\x12\x14\n\x0c\x63hannelValue\x18\t \x01(\t\x12\x15\n\rrsaConfigured\x18\n \x01(\x08\x12\x15\n\remailVerified\x18\x0b \x01(\x08\x12\"\n\x1amasterPasswordLastModified\x18\x0c \x01(\x01\x12\x18\n\x10\x61\x63\x63ountFolderKey\x18\r \x01(\x0c\x12\x31\n\x0csecurityKeys\x18\x0e \x03(\x0b\x32\x1b.AccountSummary.SecurityKey\x12+\n\tkeyValues\x18\x0f \x03(\x0b\x32\x18.AccountSummary.KeyValue\x12\x0f\n\x07ssoUser\x18\x10 \x01(\x08\x12\x18\n\x10onlineAccessOnly\x18\x11 \x01(\x08\x12\x1c\n\x14masterPasswordExpiry\x18\x12 \x01(\x05\x12\x19\n\x11twoFactorRequired\x18\x13 \x01(\x08\x12\x16\n\x0e\x64isallowExport\x18\x14 \x01(\x08\x12\x15\n\rrestrictFiles\x18\x15 \x01(\x08\x12\x1a\n\x12restrictAllSharing\x18\x16 \x01(\x08\x12\x17\n\x0frestrictSharing\x18\x17 \x01(\x08\x12\"\n\x1arestrictSharingIncomingAll\x18\x18 \x01(\x08\x12)\n!restrictSharingIncomingEnterprise\x18\x19 \x01(\x08\x12\x13\n\x0blogoutTimer\x18\x1a \x01(\x03\x12\x17\n\x0fpersistentLogin\x18\x1b \x01(\x08\x12\x1c\n\x14ipDisableAutoApprove\x18\x1c \x01(\x08\x12$\n\x1cshareDataKeyWithEccPublicKey\x18\x1d \x01(\x08\x12\'\n\x1fshareDataKeyWithDevicePublicKey\x18\x1e \x01(\x08\x12\x1a\n\x12RecordTypesCounter\x18\x1f \x01(\x05\x12$\n\x1cRecordTypesEnterpriseCounter\x18 \x01(\x05\x12\x1a\n\x12recordTypesEnabled\x18! \x01(\x08\x12\x1c\n\x14\x63\x61nManageRecordTypes\x18\" \x01(\x08\x12\x1d\n\x15recordTypesPAMCounter\x18# \x01(\x05\x12\x1a\n\x12logoutTimerMinutes\x18$ \x01(\x05\x12 \n\x18securityKeysNoUserVerify\x18% \x01(\x08\x12\x36\n\x08\x63hannels\x18& \x03(\x0e\x32$.Authentication.TwoFactorChannelType\x12\x19\n\x11personalUsernames\x18\' \x03(\t\x12\x15\n\rmaxIpDistance\x18( \x01(\x05\x12\x1e\n\x16maxIpDistanceEffective\x18) \x01(\x05\"&\n\x08KeyValue\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"-\n\x0fKeyValueBoolean\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x08\"*\n\x0cKeyValueLong\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x03\"=\n\x06Result\x12\x12\n\nresultCode\x18\x01 \x01(\t\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x0e\n\x06result\x18\x03 \x01(\t\"\xc2\x01\n\x0c\x45nforcements\x12)\n\x07strings\x18\x01 \x03(\x0b\x32\x18.AccountSummary.KeyValue\x12\x31\n\x08\x62ooleans\x18\x02 \x03(\x0b\x32\x1f.AccountSummary.KeyValueBoolean\x12+\n\x05longs\x18\x03 \x03(\x0b\x32\x1c.AccountSummary.KeyValueLong\x12\'\n\x05jsons\x18\x04 \x03(\x0b\x32\x18.AccountSummary.KeyValue\"<\n\x16MissingAccountShareKey\x12\x0f\n\x07role_id\x18\x01 \x01(\x03\x12\x11\n\tpublicKey\x18\x02 \x01(\x0c\"u\n\x0cPasswordRule\x12\x10\n\x08ruleType\x18\x01 \x01(\t\x12\x0f\n\x07pattern\x18\x02 \x01(\t\x12\r\n\x05match\x18\x03 \x01(\x08\x12\x0f\n\x07minimum\x18\x04 \x01(\x05\x12\x13\n\x0b\x64\x65scription\x18\x05 \x01(\t\x12\r\n\x05value\x18\x06 \x01(\t\"\x97\x01\n\x0bSecurityKey\x12\x10\n\x08\x64\x65viceId\x18\x01 \x01(\x03\x12\x12\n\ndeviceName\x18\x02 \x01(\t\x12\x11\n\tdateAdded\x18\x03 \x01(\x03\x12\x0f\n\x07isValid\x18\x04 \x01(\x08\x12>\n\x12\x64\x65viceRegistration\x18\x05 \x01(\x0b\x32\".AccountSummary.DeviceRegistration\"y\n\x12\x44\x65viceRegistration\x12\x11\n\tkeyHandle\x18\x01 \x01(\t\x12\x11\n\tpublicKey\x18\x02 \x01(\x0c\x12\x17\n\x0f\x61ttestationCert\x18\x03 \x01(\t\x12\x0f\n\x07\x63ounter\x18\x04 \x01(\x03\x12\x13\n\x0b\x63ompromised\x18\x05 \x01(\x08\"k\n\x05Group\x12\r\n\x05\x61\x64min\x18\x01 \x01(\x08\x12\x1d\n\x15groupVerificationCode\x18\x02 \x01(\t\x12\x34\n\radministrator\x18\x04 \x01(\x0b\x32\x1d.AccountSummary.Administrator\"\xc0\x01\n\rAdministrator\x12\x11\n\tfirstName\x18\x01 \x01(\t\x12\x10\n\x08lastName\x18\x02 \x01(\t\x12\r\n\x05\x65mail\x18\x03 \x01(\t\x12\x1c\n\x14\x63urrentNumberOfUsers\x18\x04 \x01(\x05\x12\x15\n\rnumberOfUsers\x18\x05 \x01(\x05\x12\x18\n\x10subscriptionCode\x18\x07 \x01(\t\x12\x16\n\x0e\x65xpirationDate\x18\x08 \x01(\t\x12\x14\n\x0cpurchaseDate\x18\t \x01(\tB*\n\x18\x63om.keepersecurity.protoB\x0e\x41\x63\x63ountSummaryb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'AccountSummary_pb2', _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'accountsummary_pb2', _globals) if not _descriptor._USE_C_DESCRIPTORS: _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'\n\030com.keepersecurity.protoB\016AccountSummary' diff --git a/keepercommander/proto/AccountSummary_pb2.pyi b/keepercommander/proto/AccountSummary_pb2.pyi index 380597751..0121aa9b2 100644 --- a/keepercommander/proto/AccountSummary_pb2.pyi +++ b/keepercommander/proto/AccountSummary_pb2.pyi @@ -12,7 +12,7 @@ class AccountSummaryRequest(_message.Message): INCLUDERECENTACTIVITY_FIELD_NUMBER: _ClassVar[int] summaryVersion: int includeRecentActivity: bool - def __init__(self, summaryVersion: _Optional[int] = ..., includeRecentActivity: bool = ...) -> None: ... + def __init__(self, summaryVersion: _Optional[int] = ..., includeRecentActivity: _Optional[bool] = ...) -> None: ... class AccountSummaryElements(_message.Message): __slots__ = ("clientKey", "settings", "keysInfo", "syncLogs", "isEnterpriseAdmin", "license", "group", "Enforcements", "Images", "personalLicense", "fixSharedFolderRecords", "usernames", "devices", "isShareAdmin", "accountRecovery", "accountRecoveryPrompt", "minMasterPasswordLengthNoPrompt", "forbidKeyType2", "forbidKeyType1", "disallowedFeatures") @@ -56,7 +56,7 @@ class AccountSummaryElements(_message.Message): forbidKeyType2: bool forbidKeyType1: bool disallowedFeatures: _containers.RepeatedScalarFieldContainer[str] - def __init__(self, clientKey: _Optional[bytes] = ..., settings: _Optional[_Union[Settings, _Mapping]] = ..., keysInfo: _Optional[_Union[KeysInfo, _Mapping]] = ..., syncLogs: _Optional[_Iterable[_Union[SyncLog, _Mapping]]] = ..., isEnterpriseAdmin: bool = ..., license: _Optional[_Union[License, _Mapping]] = ..., group: _Optional[_Union[Group, _Mapping]] = ..., Enforcements: _Optional[_Union[Enforcements, _Mapping]] = ..., Images: _Optional[_Iterable[_Union[KeyValue, _Mapping]]] = ..., personalLicense: _Optional[_Union[License, _Mapping]] = ..., fixSharedFolderRecords: bool = ..., usernames: _Optional[_Iterable[str]] = ..., devices: _Optional[_Iterable[_Union[DeviceInfo, _Mapping]]] = ..., isShareAdmin: bool = ..., accountRecovery: bool = ..., accountRecoveryPrompt: bool = ..., minMasterPasswordLengthNoPrompt: _Optional[int] = ..., forbidKeyType2: bool = ..., forbidKeyType1: bool = ..., disallowedFeatures: _Optional[_Iterable[str]] = ...) -> None: ... + def __init__(self, clientKey: _Optional[bytes] = ..., settings: _Optional[_Union[Settings, _Mapping]] = ..., keysInfo: _Optional[_Union[KeysInfo, _Mapping]] = ..., syncLogs: _Optional[_Iterable[_Union[SyncLog, _Mapping]]] = ..., isEnterpriseAdmin: _Optional[bool] = ..., license: _Optional[_Union[License, _Mapping]] = ..., group: _Optional[_Union[Group, _Mapping]] = ..., Enforcements: _Optional[_Union[Enforcements, _Mapping]] = ..., Images: _Optional[_Iterable[_Union[KeyValue, _Mapping]]] = ..., personalLicense: _Optional[_Union[License, _Mapping]] = ..., fixSharedFolderRecords: _Optional[bool] = ..., usernames: _Optional[_Iterable[str]] = ..., devices: _Optional[_Iterable[_Union[DeviceInfo, _Mapping]]] = ..., isShareAdmin: _Optional[bool] = ..., accountRecovery: _Optional[bool] = ..., accountRecoveryPrompt: _Optional[bool] = ..., minMasterPasswordLengthNoPrompt: _Optional[int] = ..., forbidKeyType2: _Optional[bool] = ..., forbidKeyType1: _Optional[bool] = ..., disallowedFeatures: _Optional[_Iterable[str]] = ...) -> None: ... class DeviceInfo(_message.Message): __slots__ = ("encryptedDeviceToken", "deviceName", "deviceStatus", "devicePublicKey", "encryptedDataKeyDoNotUse", "clientVersion", "username", "ipAddress", "approveRequestTime", "encryptedDataKeyPresent", "groupId", "devicePlatform", "clientFormFactor") @@ -86,7 +86,7 @@ class DeviceInfo(_message.Message): groupId: int devicePlatform: str clientFormFactor: _APIRequest_pb2.ClientFormFactor - def __init__(self, encryptedDeviceToken: _Optional[bytes] = ..., deviceName: _Optional[str] = ..., deviceStatus: _Optional[_Union[_APIRequest_pb2.DeviceStatus, str]] = ..., devicePublicKey: _Optional[bytes] = ..., encryptedDataKeyDoNotUse: _Optional[bytes] = ..., clientVersion: _Optional[str] = ..., username: _Optional[str] = ..., ipAddress: _Optional[str] = ..., approveRequestTime: _Optional[int] = ..., encryptedDataKeyPresent: bool = ..., groupId: _Optional[int] = ..., devicePlatform: _Optional[str] = ..., clientFormFactor: _Optional[_Union[_APIRequest_pb2.ClientFormFactor, str]] = ...) -> None: ... + def __init__(self, encryptedDeviceToken: _Optional[bytes] = ..., deviceName: _Optional[str] = ..., deviceStatus: _Optional[_Union[_APIRequest_pb2.DeviceStatus, str]] = ..., devicePublicKey: _Optional[bytes] = ..., encryptedDataKeyDoNotUse: _Optional[bytes] = ..., clientVersion: _Optional[str] = ..., username: _Optional[str] = ..., ipAddress: _Optional[str] = ..., approveRequestTime: _Optional[int] = ..., encryptedDataKeyPresent: _Optional[bool] = ..., groupId: _Optional[int] = ..., devicePlatform: _Optional[str] = ..., clientFormFactor: _Optional[_Union[_APIRequest_pb2.ClientFormFactor, str]] = ...) -> None: ... class KeysInfo(_message.Message): __slots__ = ("encryptionParams", "encryptedDataKey", "dataKeyBackupDate", "userAuthUid", "encryptedPrivateKey", "encryptedEccPrivateKey", "eccPublicKey") @@ -192,7 +192,7 @@ class License(_message.Message): pendingEnterprise: bool isPamEnabled: bool isKsmEnabled: bool - def __init__(self, subscriptionCode: _Optional[str] = ..., productTypeId: _Optional[int] = ..., productTypeName: _Optional[str] = ..., expirationDate: _Optional[str] = ..., secondsUntilExpiration: _Optional[int] = ..., maxDevices: _Optional[int] = ..., filePlanType: _Optional[int] = ..., bytesUsed: _Optional[int] = ..., bytesTotal: _Optional[int] = ..., secondsUntilStorageExpiration: _Optional[int] = ..., storageExpirationDate: _Optional[str] = ..., hasAutoRenewableAppstoreSubscription: bool = ..., accountType: _Optional[int] = ..., uploadsRemaining: _Optional[int] = ..., enterpriseId: _Optional[int] = ..., chatEnabled: bool = ..., auditAndReportingEnabled: bool = ..., breachWatchFeatureDisable: bool = ..., accountUid: _Optional[bytes] = ..., allowPersonalLicense: bool = ..., licensedBy: _Optional[str] = ..., email: _Optional[str] = ..., breachWatchEnabled: bool = ..., breachWatchScanned: bool = ..., breachWatchExpiration: _Optional[int] = ..., breachWatchDateCreated: _Optional[int] = ..., error: _Optional[_Union[Result, _Mapping]] = ..., expiration: _Optional[int] = ..., storageExpiration: _Optional[int] = ..., uploadsCount: _Optional[int] = ..., units: _Optional[int] = ..., pendingEnterprise: bool = ..., isPamEnabled: bool = ..., isKsmEnabled: bool = ...) -> None: ... + def __init__(self, subscriptionCode: _Optional[str] = ..., productTypeId: _Optional[int] = ..., productTypeName: _Optional[str] = ..., expirationDate: _Optional[str] = ..., secondsUntilExpiration: _Optional[int] = ..., maxDevices: _Optional[int] = ..., filePlanType: _Optional[int] = ..., bytesUsed: _Optional[int] = ..., bytesTotal: _Optional[int] = ..., secondsUntilStorageExpiration: _Optional[int] = ..., storageExpirationDate: _Optional[str] = ..., hasAutoRenewableAppstoreSubscription: _Optional[bool] = ..., accountType: _Optional[int] = ..., uploadsRemaining: _Optional[int] = ..., enterpriseId: _Optional[int] = ..., chatEnabled: _Optional[bool] = ..., auditAndReportingEnabled: _Optional[bool] = ..., breachWatchFeatureDisable: _Optional[bool] = ..., accountUid: _Optional[bytes] = ..., allowPersonalLicense: _Optional[bool] = ..., licensedBy: _Optional[str] = ..., email: _Optional[str] = ..., breachWatchEnabled: _Optional[bool] = ..., breachWatchScanned: _Optional[bool] = ..., breachWatchExpiration: _Optional[int] = ..., breachWatchDateCreated: _Optional[int] = ..., error: _Optional[_Union[Result, _Mapping]] = ..., expiration: _Optional[int] = ..., storageExpiration: _Optional[int] = ..., uploadsCount: _Optional[int] = ..., units: _Optional[int] = ..., pendingEnterprise: _Optional[bool] = ..., isPamEnabled: _Optional[bool] = ..., isKsmEnabled: _Optional[bool] = ...) -> None: ... class AddOn(_message.Message): __slots__ = ("licenseKeyId", "name", "expirationDate", "createdDate", "isTrial", "enabled", "scanned", "featureDisable") @@ -212,7 +212,7 @@ class AddOn(_message.Message): enabled: bool scanned: bool featureDisable: bool - def __init__(self, licenseKeyId: _Optional[int] = ..., name: _Optional[str] = ..., expirationDate: _Optional[int] = ..., createdDate: _Optional[int] = ..., isTrial: bool = ..., enabled: bool = ..., scanned: bool = ..., featureDisable: bool = ...) -> None: ... + def __init__(self, licenseKeyId: _Optional[int] = ..., name: _Optional[str] = ..., expirationDate: _Optional[int] = ..., createdDate: _Optional[int] = ..., isTrial: _Optional[bool] = ..., enabled: _Optional[bool] = ..., scanned: _Optional[bool] = ..., featureDisable: _Optional[bool] = ...) -> None: ... class Settings(_message.Message): __slots__ = ("audit", "mustPerformAccountShareBy", "shareAccountTo", "rules", "passwordRulesIntro", "autoBackupDays", "theme", "channel", "channelValue", "rsaConfigured", "emailVerified", "masterPasswordLastModified", "accountFolderKey", "securityKeys", "keyValues", "ssoUser", "onlineAccessOnly", "masterPasswordExpiry", "twoFactorRequired", "disallowExport", "restrictFiles", "restrictAllSharing", "restrictSharing", "restrictSharingIncomingAll", "restrictSharingIncomingEnterprise", "logoutTimer", "persistentLogin", "ipDisableAutoApprove", "shareDataKeyWithEccPublicKey", "shareDataKeyWithDevicePublicKey", "RecordTypesCounter", "RecordTypesEnterpriseCounter", "recordTypesEnabled", "canManageRecordTypes", "recordTypesPAMCounter", "logoutTimerMinutes", "securityKeysNoUserVerify", "channels", "personalUsernames", "maxIpDistance", "maxIpDistanceEffective") @@ -298,7 +298,7 @@ class Settings(_message.Message): personalUsernames: _containers.RepeatedScalarFieldContainer[str] maxIpDistance: int maxIpDistanceEffective: int - def __init__(self, audit: bool = ..., mustPerformAccountShareBy: _Optional[int] = ..., shareAccountTo: _Optional[_Iterable[_Union[MissingAccountShareKey, _Mapping]]] = ..., rules: _Optional[_Iterable[_Union[PasswordRule, _Mapping]]] = ..., passwordRulesIntro: _Optional[str] = ..., autoBackupDays: _Optional[int] = ..., theme: _Optional[str] = ..., channel: _Optional[str] = ..., channelValue: _Optional[str] = ..., rsaConfigured: bool = ..., emailVerified: bool = ..., masterPasswordLastModified: _Optional[float] = ..., accountFolderKey: _Optional[bytes] = ..., securityKeys: _Optional[_Iterable[_Union[SecurityKey, _Mapping]]] = ..., keyValues: _Optional[_Iterable[_Union[KeyValue, _Mapping]]] = ..., ssoUser: bool = ..., onlineAccessOnly: bool = ..., masterPasswordExpiry: _Optional[int] = ..., twoFactorRequired: bool = ..., disallowExport: bool = ..., restrictFiles: bool = ..., restrictAllSharing: bool = ..., restrictSharing: bool = ..., restrictSharingIncomingAll: bool = ..., restrictSharingIncomingEnterprise: bool = ..., logoutTimer: _Optional[int] = ..., persistentLogin: bool = ..., ipDisableAutoApprove: bool = ..., shareDataKeyWithEccPublicKey: bool = ..., shareDataKeyWithDevicePublicKey: bool = ..., RecordTypesCounter: _Optional[int] = ..., RecordTypesEnterpriseCounter: _Optional[int] = ..., recordTypesEnabled: bool = ..., canManageRecordTypes: bool = ..., recordTypesPAMCounter: _Optional[int] = ..., logoutTimerMinutes: _Optional[int] = ..., securityKeysNoUserVerify: bool = ..., channels: _Optional[_Iterable[_Union[_APIRequest_pb2.TwoFactorChannelType, str]]] = ..., personalUsernames: _Optional[_Iterable[str]] = ..., maxIpDistance: _Optional[int] = ..., maxIpDistanceEffective: _Optional[int] = ...) -> None: ... + def __init__(self, audit: _Optional[bool] = ..., mustPerformAccountShareBy: _Optional[int] = ..., shareAccountTo: _Optional[_Iterable[_Union[MissingAccountShareKey, _Mapping]]] = ..., rules: _Optional[_Iterable[_Union[PasswordRule, _Mapping]]] = ..., passwordRulesIntro: _Optional[str] = ..., autoBackupDays: _Optional[int] = ..., theme: _Optional[str] = ..., channel: _Optional[str] = ..., channelValue: _Optional[str] = ..., rsaConfigured: _Optional[bool] = ..., emailVerified: _Optional[bool] = ..., masterPasswordLastModified: _Optional[float] = ..., accountFolderKey: _Optional[bytes] = ..., securityKeys: _Optional[_Iterable[_Union[SecurityKey, _Mapping]]] = ..., keyValues: _Optional[_Iterable[_Union[KeyValue, _Mapping]]] = ..., ssoUser: _Optional[bool] = ..., onlineAccessOnly: _Optional[bool] = ..., masterPasswordExpiry: _Optional[int] = ..., twoFactorRequired: _Optional[bool] = ..., disallowExport: _Optional[bool] = ..., restrictFiles: _Optional[bool] = ..., restrictAllSharing: _Optional[bool] = ..., restrictSharing: _Optional[bool] = ..., restrictSharingIncomingAll: _Optional[bool] = ..., restrictSharingIncomingEnterprise: _Optional[bool] = ..., logoutTimer: _Optional[int] = ..., persistentLogin: _Optional[bool] = ..., ipDisableAutoApprove: _Optional[bool] = ..., shareDataKeyWithEccPublicKey: _Optional[bool] = ..., shareDataKeyWithDevicePublicKey: _Optional[bool] = ..., RecordTypesCounter: _Optional[int] = ..., RecordTypesEnterpriseCounter: _Optional[int] = ..., recordTypesEnabled: _Optional[bool] = ..., canManageRecordTypes: _Optional[bool] = ..., recordTypesPAMCounter: _Optional[int] = ..., logoutTimerMinutes: _Optional[int] = ..., securityKeysNoUserVerify: _Optional[bool] = ..., channels: _Optional[_Iterable[_Union[_APIRequest_pb2.TwoFactorChannelType, str]]] = ..., personalUsernames: _Optional[_Iterable[str]] = ..., maxIpDistance: _Optional[int] = ..., maxIpDistanceEffective: _Optional[int] = ...) -> None: ... class KeyValue(_message.Message): __slots__ = ("key", "value") @@ -314,7 +314,7 @@ class KeyValueBoolean(_message.Message): VALUE_FIELD_NUMBER: _ClassVar[int] key: str value: bool - def __init__(self, key: _Optional[str] = ..., value: bool = ...) -> None: ... + def __init__(self, key: _Optional[str] = ..., value: _Optional[bool] = ...) -> None: ... class KeyValueLong(_message.Message): __slots__ = ("key", "value") @@ -368,7 +368,7 @@ class PasswordRule(_message.Message): minimum: int description: str value: str - def __init__(self, ruleType: _Optional[str] = ..., pattern: _Optional[str] = ..., match: bool = ..., minimum: _Optional[int] = ..., description: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + def __init__(self, ruleType: _Optional[str] = ..., pattern: _Optional[str] = ..., match: _Optional[bool] = ..., minimum: _Optional[int] = ..., description: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... class SecurityKey(_message.Message): __slots__ = ("deviceId", "deviceName", "dateAdded", "isValid", "deviceRegistration") @@ -382,7 +382,7 @@ class SecurityKey(_message.Message): dateAdded: int isValid: bool deviceRegistration: DeviceRegistration - def __init__(self, deviceId: _Optional[int] = ..., deviceName: _Optional[str] = ..., dateAdded: _Optional[int] = ..., isValid: bool = ..., deviceRegistration: _Optional[_Union[DeviceRegistration, _Mapping]] = ...) -> None: ... + def __init__(self, deviceId: _Optional[int] = ..., deviceName: _Optional[str] = ..., dateAdded: _Optional[int] = ..., isValid: _Optional[bool] = ..., deviceRegistration: _Optional[_Union[DeviceRegistration, _Mapping]] = ...) -> None: ... class DeviceRegistration(_message.Message): __slots__ = ("keyHandle", "publicKey", "attestationCert", "counter", "compromised") @@ -396,7 +396,7 @@ class DeviceRegistration(_message.Message): attestationCert: str counter: int compromised: bool - def __init__(self, keyHandle: _Optional[str] = ..., publicKey: _Optional[bytes] = ..., attestationCert: _Optional[str] = ..., counter: _Optional[int] = ..., compromised: bool = ...) -> None: ... + def __init__(self, keyHandle: _Optional[str] = ..., publicKey: _Optional[bytes] = ..., attestationCert: _Optional[str] = ..., counter: _Optional[int] = ..., compromised: _Optional[bool] = ...) -> None: ... class Group(_message.Message): __slots__ = ("admin", "groupVerificationCode", "administrator") @@ -406,7 +406,7 @@ class Group(_message.Message): admin: bool groupVerificationCode: str administrator: Administrator - def __init__(self, admin: bool = ..., groupVerificationCode: _Optional[str] = ..., administrator: _Optional[_Union[Administrator, _Mapping]] = ...) -> None: ... + def __init__(self, admin: _Optional[bool] = ..., groupVerificationCode: _Optional[str] = ..., administrator: _Optional[_Union[Administrator, _Mapping]] = ...) -> None: ... class Administrator(_message.Message): __slots__ = ("firstName", "lastName", "email", "currentNumberOfUsers", "numberOfUsers", "subscriptionCode", "expirationDate", "purchaseDate") diff --git a/keepercommander/proto/GraphSync_pb2.pyi b/keepercommander/proto/GraphSync_pb2.pyi index 594a4eecc..a8e0514cd 100644 --- a/keepercommander/proto/GraphSync_pb2.pyi +++ b/keepercommander/proto/GraphSync_pb2.pyi @@ -137,7 +137,7 @@ class GraphSyncResult(_message.Message): syncPoint: int data: _containers.RepeatedCompositeFieldContainer[GraphSyncDataPlus] hasMore: bool - def __init__(self, streamId: _Optional[bytes] = ..., syncPoint: _Optional[int] = ..., data: _Optional[_Iterable[_Union[GraphSyncDataPlus, _Mapping]]] = ..., hasMore: bool = ...) -> None: ... + def __init__(self, streamId: _Optional[bytes] = ..., syncPoint: _Optional[int] = ..., data: _Optional[_Iterable[_Union[GraphSyncDataPlus, _Mapping]]] = ..., hasMore: _Optional[bool] = ...) -> None: ... class GraphSyncMultiQuery(_message.Message): __slots__ = ("queries",) diff --git a/keepercommander/proto/SyncDown_pb2.pyi b/keepercommander/proto/SyncDown_pb2.pyi index 8c1b62221..ea2677653 100644 --- a/keepercommander/proto/SyncDown_pb2.pyi +++ b/keepercommander/proto/SyncDown_pb2.pyi @@ -40,7 +40,7 @@ class SyncDownRequest(_message.Message): continuationToken: bytes dataVersion: int debug: bool - def __init__(self, continuationToken: _Optional[bytes] = ..., dataVersion: _Optional[int] = ..., debug: bool = ...) -> None: ... + def __init__(self, continuationToken: _Optional[bytes] = ..., dataVersion: _Optional[int] = ..., debug: _Optional[bool] = ...) -> None: ... class SyncDownResponse(_message.Message): __slots__ = ("continuationToken", "hasMore", "cacheStatus", "userFolders", "sharedFolders", "userFolderSharedFolders", "sharedFolderFolders", "records", "recordMetaData", "nonSharedData", "recordLinks", "userFolderRecords", "sharedFolderRecords", "sharedFolderFolderRecords", "sharedFolderUsers", "sharedFolderTeams", "recordAddAuditData", "teams", "sharingChanges", "profile", "profilePic", "pendingTeamMembers", "breachWatchRecords", "userAuths", "breachWatchSecurityData", "reusedPasswords", "removedUserFolders", "removedSharedFolders", "removedUserFolderSharedFolders", "removedSharedFolderFolders", "removedRecords", "removedRecordLinks", "removedUserFolderRecords", "removedSharedFolderRecords", "removedSharedFolderFolderRecords", "removedSharedFolderUsers", "removedSharedFolderTeams", "removedTeams", "ksmAppShares", "ksmAppClients", "shareInvitations", "diagnostics", "recordRotations", "users", "removedUsers", "securityScoreData", "notificationSync", "keeperDriveData") @@ -140,7 +140,7 @@ class SyncDownResponse(_message.Message): securityScoreData: _containers.RepeatedCompositeFieldContainer[SecurityScoreData] notificationSync: _containers.RepeatedCompositeFieldContainer[_NotificationCenter_pb2.NotificationWrapper] keeperDriveData: KeeperDriveData - def __init__(self, continuationToken: _Optional[bytes] = ..., hasMore: bool = ..., cacheStatus: _Optional[_Union[CacheStatus, str]] = ..., userFolders: _Optional[_Iterable[_Union[UserFolder, _Mapping]]] = ..., sharedFolders: _Optional[_Iterable[_Union[SharedFolder, _Mapping]]] = ..., userFolderSharedFolders: _Optional[_Iterable[_Union[UserFolderSharedFolder, _Mapping]]] = ..., sharedFolderFolders: _Optional[_Iterable[_Union[SharedFolderFolder, _Mapping]]] = ..., records: _Optional[_Iterable[_Union[Record, _Mapping]]] = ..., recordMetaData: _Optional[_Iterable[_Union[RecordMetaData, _Mapping]]] = ..., nonSharedData: _Optional[_Iterable[_Union[NonSharedData, _Mapping]]] = ..., recordLinks: _Optional[_Iterable[_Union[RecordLink, _Mapping]]] = ..., userFolderRecords: _Optional[_Iterable[_Union[UserFolderRecord, _Mapping]]] = ..., sharedFolderRecords: _Optional[_Iterable[_Union[SharedFolderRecord, _Mapping]]] = ..., sharedFolderFolderRecords: _Optional[_Iterable[_Union[SharedFolderFolderRecord, _Mapping]]] = ..., sharedFolderUsers: _Optional[_Iterable[_Union[SharedFolderUser, _Mapping]]] = ..., sharedFolderTeams: _Optional[_Iterable[_Union[SharedFolderTeam, _Mapping]]] = ..., recordAddAuditData: _Optional[_Iterable[bytes]] = ..., teams: _Optional[_Iterable[_Union[Team, _Mapping]]] = ..., sharingChanges: _Optional[_Iterable[_Union[SharingChange, _Mapping]]] = ..., profile: _Optional[_Union[Profile, _Mapping]] = ..., profilePic: _Optional[_Union[ProfilePic, _Mapping]] = ..., pendingTeamMembers: _Optional[_Iterable[_Union[PendingTeamMember, _Mapping]]] = ..., breachWatchRecords: _Optional[_Iterable[_Union[BreachWatchRecord, _Mapping]]] = ..., userAuths: _Optional[_Iterable[_Union[UserAuth, _Mapping]]] = ..., breachWatchSecurityData: _Optional[_Iterable[_Union[BreachWatchSecurityData, _Mapping]]] = ..., reusedPasswords: _Optional[_Union[ReusedPasswords, _Mapping]] = ..., removedUserFolders: _Optional[_Iterable[bytes]] = ..., removedSharedFolders: _Optional[_Iterable[bytes]] = ..., removedUserFolderSharedFolders: _Optional[_Iterable[_Union[UserFolderSharedFolder, _Mapping]]] = ..., removedSharedFolderFolders: _Optional[_Iterable[_Union[SharedFolderFolder, _Mapping]]] = ..., removedRecords: _Optional[_Iterable[bytes]] = ..., removedRecordLinks: _Optional[_Iterable[_Union[RecordLink, _Mapping]]] = ..., removedUserFolderRecords: _Optional[_Iterable[_Union[UserFolderRecord, _Mapping]]] = ..., removedSharedFolderRecords: _Optional[_Iterable[_Union[SharedFolderRecord, _Mapping]]] = ..., removedSharedFolderFolderRecords: _Optional[_Iterable[_Union[SharedFolderFolderRecord, _Mapping]]] = ..., removedSharedFolderUsers: _Optional[_Iterable[_Union[SharedFolderUser, _Mapping]]] = ..., removedSharedFolderTeams: _Optional[_Iterable[_Union[SharedFolderTeam, _Mapping]]] = ..., removedTeams: _Optional[_Iterable[bytes]] = ..., ksmAppShares: _Optional[_Iterable[_Union[KsmChange, _Mapping]]] = ..., ksmAppClients: _Optional[_Iterable[_Union[KsmChange, _Mapping]]] = ..., shareInvitations: _Optional[_Iterable[_Union[ShareInvitation, _Mapping]]] = ..., diagnostics: _Optional[_Union[SyncDiagnostics, _Mapping]] = ..., recordRotations: _Optional[_Iterable[_Union[RecordRotation, _Mapping]]] = ..., users: _Optional[_Iterable[_Union[User, _Mapping]]] = ..., removedUsers: _Optional[_Iterable[bytes]] = ..., securityScoreData: _Optional[_Iterable[_Union[SecurityScoreData, _Mapping]]] = ..., notificationSync: _Optional[_Iterable[_Union[_NotificationCenter_pb2.NotificationWrapper, _Mapping]]] = ..., keeperDriveData: _Optional[_Union[KeeperDriveData, _Mapping]] = ...) -> None: ... + def __init__(self, continuationToken: _Optional[bytes] = ..., hasMore: _Optional[bool] = ..., cacheStatus: _Optional[_Union[CacheStatus, str]] = ..., userFolders: _Optional[_Iterable[_Union[UserFolder, _Mapping]]] = ..., sharedFolders: _Optional[_Iterable[_Union[SharedFolder, _Mapping]]] = ..., userFolderSharedFolders: _Optional[_Iterable[_Union[UserFolderSharedFolder, _Mapping]]] = ..., sharedFolderFolders: _Optional[_Iterable[_Union[SharedFolderFolder, _Mapping]]] = ..., records: _Optional[_Iterable[_Union[Record, _Mapping]]] = ..., recordMetaData: _Optional[_Iterable[_Union[RecordMetaData, _Mapping]]] = ..., nonSharedData: _Optional[_Iterable[_Union[NonSharedData, _Mapping]]] = ..., recordLinks: _Optional[_Iterable[_Union[RecordLink, _Mapping]]] = ..., userFolderRecords: _Optional[_Iterable[_Union[UserFolderRecord, _Mapping]]] = ..., sharedFolderRecords: _Optional[_Iterable[_Union[SharedFolderRecord, _Mapping]]] = ..., sharedFolderFolderRecords: _Optional[_Iterable[_Union[SharedFolderFolderRecord, _Mapping]]] = ..., sharedFolderUsers: _Optional[_Iterable[_Union[SharedFolderUser, _Mapping]]] = ..., sharedFolderTeams: _Optional[_Iterable[_Union[SharedFolderTeam, _Mapping]]] = ..., recordAddAuditData: _Optional[_Iterable[bytes]] = ..., teams: _Optional[_Iterable[_Union[Team, _Mapping]]] = ..., sharingChanges: _Optional[_Iterable[_Union[SharingChange, _Mapping]]] = ..., profile: _Optional[_Union[Profile, _Mapping]] = ..., profilePic: _Optional[_Union[ProfilePic, _Mapping]] = ..., pendingTeamMembers: _Optional[_Iterable[_Union[PendingTeamMember, _Mapping]]] = ..., breachWatchRecords: _Optional[_Iterable[_Union[BreachWatchRecord, _Mapping]]] = ..., userAuths: _Optional[_Iterable[_Union[UserAuth, _Mapping]]] = ..., breachWatchSecurityData: _Optional[_Iterable[_Union[BreachWatchSecurityData, _Mapping]]] = ..., reusedPasswords: _Optional[_Union[ReusedPasswords, _Mapping]] = ..., removedUserFolders: _Optional[_Iterable[bytes]] = ..., removedSharedFolders: _Optional[_Iterable[bytes]] = ..., removedUserFolderSharedFolders: _Optional[_Iterable[_Union[UserFolderSharedFolder, _Mapping]]] = ..., removedSharedFolderFolders: _Optional[_Iterable[_Union[SharedFolderFolder, _Mapping]]] = ..., removedRecords: _Optional[_Iterable[bytes]] = ..., removedRecordLinks: _Optional[_Iterable[_Union[RecordLink, _Mapping]]] = ..., removedUserFolderRecords: _Optional[_Iterable[_Union[UserFolderRecord, _Mapping]]] = ..., removedSharedFolderRecords: _Optional[_Iterable[_Union[SharedFolderRecord, _Mapping]]] = ..., removedSharedFolderFolderRecords: _Optional[_Iterable[_Union[SharedFolderFolderRecord, _Mapping]]] = ..., removedSharedFolderUsers: _Optional[_Iterable[_Union[SharedFolderUser, _Mapping]]] = ..., removedSharedFolderTeams: _Optional[_Iterable[_Union[SharedFolderTeam, _Mapping]]] = ..., removedTeams: _Optional[_Iterable[bytes]] = ..., ksmAppShares: _Optional[_Iterable[_Union[KsmChange, _Mapping]]] = ..., ksmAppClients: _Optional[_Iterable[_Union[KsmChange, _Mapping]]] = ..., shareInvitations: _Optional[_Iterable[_Union[ShareInvitation, _Mapping]]] = ..., diagnostics: _Optional[_Union[SyncDiagnostics, _Mapping]] = ..., recordRotations: _Optional[_Iterable[_Union[RecordRotation, _Mapping]]] = ..., users: _Optional[_Iterable[_Union[User, _Mapping]]] = ..., removedUsers: _Optional[_Iterable[bytes]] = ..., securityScoreData: _Optional[_Iterable[_Union[SecurityScoreData, _Mapping]]] = ..., notificationSync: _Optional[_Iterable[_Union[_NotificationCenter_pb2.NotificationWrapper, _Mapping]]] = ..., keeperDriveData: _Optional[_Union[KeeperDriveData, _Mapping]] = ...) -> None: ... class DriveRecord(_message.Message): __slots__ = ("recordUid", "revision", "version", "shared", "clientModifiedTime", "fileSize", "thumbnailSize") @@ -158,7 +158,7 @@ class DriveRecord(_message.Message): clientModifiedTime: int fileSize: int thumbnailSize: int - def __init__(self, recordUid: _Optional[bytes] = ..., revision: _Optional[int] = ..., version: _Optional[int] = ..., shared: bool = ..., clientModifiedTime: _Optional[int] = ..., fileSize: _Optional[int] = ..., thumbnailSize: _Optional[int] = ...) -> None: ... + def __init__(self, recordUid: _Optional[bytes] = ..., revision: _Optional[int] = ..., version: _Optional[int] = ..., shared: _Optional[bool] = ..., clientModifiedTime: _Optional[int] = ..., fileSize: _Optional[int] = ..., thumbnailSize: _Optional[int] = ...) -> None: ... class FolderSharingState(_message.Message): __slots__ = ("folderUid", "shared", "count") @@ -168,7 +168,7 @@ class FolderSharingState(_message.Message): folderUid: bytes shared: bool count: int - def __init__(self, folderUid: _Optional[bytes] = ..., shared: bool = ..., count: _Optional[int] = ...) -> None: ... + def __init__(self, folderUid: _Optional[bytes] = ..., shared: _Optional[bool] = ..., count: _Optional[int] = ...) -> None: ... class KeeperDriveData(_message.Message): __slots__ = ("folders", "folderKeys", "folderAccesses", "revokedFolderAccesses", "recordData", "nonSharedData", "recordAccesses", "revokedRecordAccesses", "recordSharingStates", "recordLinks", "removedRecordLinks", "breachWatchRecords", "securityScoreData", "breachWatchSecurityData", "removedFolders", "removedFolderRecords", "folderRecords", "recordRotationData", "records", "folderSharingState", "rawDagData") @@ -260,7 +260,7 @@ class SharedFolder(_message.Message): owner: str ownerAccountUid: bytes name: bytes - def __init__(self, sharedFolderUid: _Optional[bytes] = ..., revision: _Optional[int] = ..., sharedFolderKey: _Optional[bytes] = ..., keyType: _Optional[_Union[_record_pb2.RecordKeyType, str]] = ..., data: _Optional[bytes] = ..., defaultManageRecords: bool = ..., defaultManageUsers: bool = ..., defaultCanEdit: bool = ..., defaultCanReshare: bool = ..., cacheStatus: _Optional[_Union[CacheStatus, str]] = ..., owner: _Optional[str] = ..., ownerAccountUid: _Optional[bytes] = ..., name: _Optional[bytes] = ...) -> None: ... + def __init__(self, sharedFolderUid: _Optional[bytes] = ..., revision: _Optional[int] = ..., sharedFolderKey: _Optional[bytes] = ..., keyType: _Optional[_Union[_record_pb2.RecordKeyType, str]] = ..., data: _Optional[bytes] = ..., defaultManageRecords: _Optional[bool] = ..., defaultManageUsers: _Optional[bool] = ..., defaultCanEdit: _Optional[bool] = ..., defaultCanReshare: _Optional[bool] = ..., cacheStatus: _Optional[_Union[CacheStatus, str]] = ..., owner: _Optional[str] = ..., ownerAccountUid: _Optional[bytes] = ..., name: _Optional[bytes] = ...) -> None: ... class UserFolderSharedFolder(_message.Message): __slots__ = ("folderUid", "sharedFolderUid", "revision") @@ -326,7 +326,7 @@ class Team(_message.Message): sharedFolderKeys: _containers.RepeatedCompositeFieldContainer[SharedFolderKey] teamEccPrivateKey: bytes teamEccPublicKey: bytes - def __init__(self, teamUid: _Optional[bytes] = ..., name: _Optional[str] = ..., teamKey: _Optional[bytes] = ..., teamKeyType: _Optional[_Union[_record_pb2.RecordKeyType, str]] = ..., teamPrivateKey: _Optional[bytes] = ..., restrictEdit: bool = ..., restrictShare: bool = ..., restrictView: bool = ..., removedSharedFolders: _Optional[_Iterable[bytes]] = ..., sharedFolderKeys: _Optional[_Iterable[_Union[SharedFolderKey, _Mapping]]] = ..., teamEccPrivateKey: _Optional[bytes] = ..., teamEccPublicKey: _Optional[bytes] = ...) -> None: ... + def __init__(self, teamUid: _Optional[bytes] = ..., name: _Optional[str] = ..., teamKey: _Optional[bytes] = ..., teamKeyType: _Optional[_Union[_record_pb2.RecordKeyType, str]] = ..., teamPrivateKey: _Optional[bytes] = ..., restrictEdit: _Optional[bool] = ..., restrictShare: _Optional[bool] = ..., restrictView: _Optional[bool] = ..., removedSharedFolders: _Optional[_Iterable[bytes]] = ..., sharedFolderKeys: _Optional[_Iterable[_Union[SharedFolderKey, _Mapping]]] = ..., teamEccPrivateKey: _Optional[bytes] = ..., teamEccPublicKey: _Optional[bytes] = ...) -> None: ... class Record(_message.Message): __slots__ = ("recordUid", "revision", "version", "shared", "clientModifiedTime", "data", "extra", "udata", "fileSize", "thumbnailSize") @@ -350,7 +350,7 @@ class Record(_message.Message): udata: str fileSize: int thumbnailSize: int - def __init__(self, recordUid: _Optional[bytes] = ..., revision: _Optional[int] = ..., version: _Optional[int] = ..., shared: bool = ..., clientModifiedTime: _Optional[int] = ..., data: _Optional[bytes] = ..., extra: _Optional[bytes] = ..., udata: _Optional[str] = ..., fileSize: _Optional[int] = ..., thumbnailSize: _Optional[int] = ...) -> None: ... + def __init__(self, recordUid: _Optional[bytes] = ..., revision: _Optional[int] = ..., version: _Optional[int] = ..., shared: _Optional[bool] = ..., clientModifiedTime: _Optional[int] = ..., data: _Optional[bytes] = ..., extra: _Optional[bytes] = ..., udata: _Optional[str] = ..., fileSize: _Optional[int] = ..., thumbnailSize: _Optional[int] = ...) -> None: ... class RecordLink(_message.Message): __slots__ = ("parentRecordUid", "childRecordUid", "recordKey", "revision") @@ -416,7 +416,7 @@ class RecordMetaData(_message.Message): expiration: int expirationNotificationType: _record_pb2.TimerNotificationType ownerUsername: str - def __init__(self, recordUid: _Optional[bytes] = ..., owner: bool = ..., recordKey: _Optional[bytes] = ..., recordKeyType: _Optional[_Union[_record_pb2.RecordKeyType, str]] = ..., canShare: bool = ..., canEdit: bool = ..., ownerAccountUid: _Optional[bytes] = ..., expiration: _Optional[int] = ..., expirationNotificationType: _Optional[_Union[_record_pb2.TimerNotificationType, str]] = ..., ownerUsername: _Optional[str] = ...) -> None: ... + def __init__(self, recordUid: _Optional[bytes] = ..., owner: _Optional[bool] = ..., recordKey: _Optional[bytes] = ..., recordKeyType: _Optional[_Union[_record_pb2.RecordKeyType, str]] = ..., canShare: _Optional[bool] = ..., canEdit: _Optional[bool] = ..., ownerAccountUid: _Optional[bytes] = ..., expiration: _Optional[int] = ..., expirationNotificationType: _Optional[_Union[_record_pb2.TimerNotificationType, str]] = ..., ownerUsername: _Optional[str] = ...) -> None: ... class SharingChange(_message.Message): __slots__ = ("recordUid", "shared") @@ -424,7 +424,7 @@ class SharingChange(_message.Message): SHARED_FIELD_NUMBER: _ClassVar[int] recordUid: bytes shared: bool - def __init__(self, recordUid: _Optional[bytes] = ..., shared: bool = ...) -> None: ... + def __init__(self, recordUid: _Optional[bytes] = ..., shared: _Optional[bool] = ...) -> None: ... class Profile(_message.Message): __slots__ = ("data", "profileName", "revision") @@ -490,7 +490,7 @@ class UserAuth(_message.Message): encryptedClientKey: bytes revision: int name: str - def __init__(self, uid: _Optional[bytes] = ..., loginType: _Optional[_Union[_APIRequest_pb2.LoginType, str]] = ..., deleted: bool = ..., iterations: _Optional[int] = ..., salt: _Optional[bytes] = ..., encryptedClientKey: _Optional[bytes] = ..., revision: _Optional[int] = ..., name: _Optional[str] = ...) -> None: ... + def __init__(self, uid: _Optional[bytes] = ..., loginType: _Optional[_Union[_APIRequest_pb2.LoginType, str]] = ..., deleted: _Optional[bool] = ..., iterations: _Optional[int] = ..., salt: _Optional[bytes] = ..., encryptedClientKey: _Optional[bytes] = ..., revision: _Optional[int] = ..., name: _Optional[str] = ...) -> None: ... class BreachWatchSecurityData(_message.Message): __slots__ = ("recordUid", "revision", "removed") @@ -500,7 +500,7 @@ class BreachWatchSecurityData(_message.Message): recordUid: bytes revision: int removed: bool - def __init__(self, recordUid: _Optional[bytes] = ..., revision: _Optional[int] = ..., removed: bool = ...) -> None: ... + def __init__(self, recordUid: _Optional[bytes] = ..., revision: _Optional[int] = ..., removed: _Optional[bool] = ...) -> None: ... class ReusedPasswords(_message.Message): __slots__ = ("count", "revision") @@ -534,7 +534,7 @@ class SharedFolderRecord(_message.Message): expirationNotificationType: _record_pb2.TimerNotificationType ownerUsername: str rotateOnExpiration: bool - def __init__(self, sharedFolderUid: _Optional[bytes] = ..., recordUid: _Optional[bytes] = ..., recordKey: _Optional[bytes] = ..., canShare: bool = ..., canEdit: bool = ..., ownerAccountUid: _Optional[bytes] = ..., expiration: _Optional[int] = ..., owner: bool = ..., expirationNotificationType: _Optional[_Union[_record_pb2.TimerNotificationType, str]] = ..., ownerUsername: _Optional[str] = ..., rotateOnExpiration: bool = ...) -> None: ... + def __init__(self, sharedFolderUid: _Optional[bytes] = ..., recordUid: _Optional[bytes] = ..., recordKey: _Optional[bytes] = ..., canShare: _Optional[bool] = ..., canEdit: _Optional[bool] = ..., ownerAccountUid: _Optional[bytes] = ..., expiration: _Optional[int] = ..., owner: _Optional[bool] = ..., expirationNotificationType: _Optional[_Union[_record_pb2.TimerNotificationType, str]] = ..., ownerUsername: _Optional[str] = ..., rotateOnExpiration: _Optional[bool] = ...) -> None: ... class SharedFolderUser(_message.Message): __slots__ = ("sharedFolderUid", "username", "manageRecords", "manageUsers", "accountUid", "expiration", "expirationNotificationType", "rotateOnExpiration") @@ -554,7 +554,7 @@ class SharedFolderUser(_message.Message): expiration: int expirationNotificationType: _record_pb2.TimerNotificationType rotateOnExpiration: bool - def __init__(self, sharedFolderUid: _Optional[bytes] = ..., username: _Optional[str] = ..., manageRecords: bool = ..., manageUsers: bool = ..., accountUid: _Optional[bytes] = ..., expiration: _Optional[int] = ..., expirationNotificationType: _Optional[_Union[_record_pb2.TimerNotificationType, str]] = ..., rotateOnExpiration: bool = ...) -> None: ... + def __init__(self, sharedFolderUid: _Optional[bytes] = ..., username: _Optional[str] = ..., manageRecords: _Optional[bool] = ..., manageUsers: _Optional[bool] = ..., accountUid: _Optional[bytes] = ..., expiration: _Optional[int] = ..., expirationNotificationType: _Optional[_Union[_record_pb2.TimerNotificationType, str]] = ..., rotateOnExpiration: _Optional[bool] = ...) -> None: ... class SharedFolderTeam(_message.Message): __slots__ = ("sharedFolderUid", "teamUid", "name", "manageRecords", "manageUsers", "expiration", "expirationNotificationType", "rotateOnExpiration") @@ -574,7 +574,7 @@ class SharedFolderTeam(_message.Message): expiration: int expirationNotificationType: _record_pb2.TimerNotificationType rotateOnExpiration: bool - def __init__(self, sharedFolderUid: _Optional[bytes] = ..., teamUid: _Optional[bytes] = ..., name: _Optional[str] = ..., manageRecords: bool = ..., manageUsers: bool = ..., expiration: _Optional[int] = ..., expirationNotificationType: _Optional[_Union[_record_pb2.TimerNotificationType, str]] = ..., rotateOnExpiration: bool = ...) -> None: ... + def __init__(self, sharedFolderUid: _Optional[bytes] = ..., teamUid: _Optional[bytes] = ..., name: _Optional[str] = ..., manageRecords: _Optional[bool] = ..., manageUsers: _Optional[bool] = ..., expiration: _Optional[int] = ..., expirationNotificationType: _Optional[_Union[_record_pb2.TimerNotificationType, str]] = ..., rotateOnExpiration: _Optional[bool] = ...) -> None: ... class KsmChange(_message.Message): __slots__ = ("appRecordUid", "detailId", "removed", "appClientType", "expiration") @@ -588,7 +588,7 @@ class KsmChange(_message.Message): removed: bool appClientType: _enterprise_pb2.AppClientType expiration: int - def __init__(self, appRecordUid: _Optional[bytes] = ..., detailId: _Optional[bytes] = ..., removed: bool = ..., appClientType: _Optional[_Union[_enterprise_pb2.AppClientType, str]] = ..., expiration: _Optional[int] = ...) -> None: ... + def __init__(self, appRecordUid: _Optional[bytes] = ..., detailId: _Optional[bytes] = ..., removed: _Optional[bool] = ..., appClientType: _Optional[_Union[_enterprise_pb2.AppClientType, str]] = ..., expiration: _Optional[int] = ...) -> None: ... class ShareInvitation(_message.Message): __slots__ = ("username",) @@ -638,7 +638,7 @@ class RecordRotation(_message.Message): resourceUid: bytes lastRotation: int lastRotationStatus: RecordRotationStatus - def __init__(self, recordUid: _Optional[bytes] = ..., revision: _Optional[int] = ..., configurationUid: _Optional[bytes] = ..., schedule: _Optional[str] = ..., pwdComplexity: _Optional[bytes] = ..., disabled: bool = ..., resourceUid: _Optional[bytes] = ..., lastRotation: _Optional[int] = ..., lastRotationStatus: _Optional[_Union[RecordRotationStatus, str]] = ...) -> None: ... + def __init__(self, recordUid: _Optional[bytes] = ..., revision: _Optional[int] = ..., configurationUid: _Optional[bytes] = ..., schedule: _Optional[str] = ..., pwdComplexity: _Optional[bytes] = ..., disabled: _Optional[bool] = ..., resourceUid: _Optional[bytes] = ..., lastRotation: _Optional[int] = ..., lastRotationStatus: _Optional[_Union[RecordRotationStatus, str]] = ...) -> None: ... class SecurityScoreData(_message.Message): __slots__ = ("recordUid", "data", "revision") diff --git a/keepercommander/proto/dag_pb2.pyi b/keepercommander/proto/dag_pb2.pyi index 8738ef405..6896c5d7b 100644 --- a/keepercommander/proto/dag_pb2.pyi +++ b/keepercommander/proto/dag_pb2.pyi @@ -78,7 +78,7 @@ class SyncData(_message.Message): data: _containers.RepeatedCompositeFieldContainer[Data] syncPoint: int hasMore: bool - def __init__(self, data: _Optional[_Iterable[_Union[Data, _Mapping]]] = ..., syncPoint: _Optional[int] = ..., hasMore: bool = ...) -> None: ... + def __init__(self, data: _Optional[_Iterable[_Union[Data, _Mapping]]] = ..., syncPoint: _Optional[int] = ..., hasMore: _Optional[bool] = ...) -> None: ... class DebugData(_message.Message): __slots__ = ("dataType", "path", "ref", "parentRef") diff --git a/keepercommander/proto/enterprise_pb2.py b/keepercommander/proto/enterprise_pb2.py index 63c28ba73..b0dc5120f 100644 --- a/keepercommander/proto/enterprise_pb2.py +++ b/keepercommander/proto/enterprise_pb2.py @@ -22,9 +22,10 @@ _sym_db = _symbol_database.Default() +from . import folder_pb2 as folder__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x10\x65nterprise.proto\x12\nEnterprise\"\x84\x01\n\x18\x45nterpriseKeyPairRequest\x12\x1b\n\x13\x65nterprisePublicKey\x18\x01 \x01(\x0c\x12%\n\x1d\x65ncryptedEnterprisePrivateKey\x18\x02 \x01(\x0c\x12$\n\x07keyType\x18\x03 \x01(\x0e\x32\x13.Enterprise.KeyType\"\'\n\x14GetTeamMemberRequest\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\"}\n\x0e\x45nterpriseUser\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\r\n\x05\x65mail\x18\x02 \x01(\t\x12\x1a\n\x12\x65nterpriseUsername\x18\x03 \x01(\t\x12\x14\n\x0cisShareAdmin\x18\x04 \x01(\x08\x12\x10\n\x08username\x18\x05 \x01(\t\"K\n\x15GetTeamMemberResponse\x12\x32\n\x0e\x65nterpriseUser\x18\x01 \x03(\x0b\x32\x1a.Enterprise.EnterpriseUser\"-\n\x11\x45nterpriseUserIds\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x03(\x03\"B\n\x19\x45nterprisePersonalAccount\x12\r\n\x05\x65mail\x18\x01 \x01(\t\x12\x16\n\x0eOBSOLETE_FIELD\x18\x02 \x01(\x0c\"S\n\x17\x45ncryptedTeamKeyRequest\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12\x18\n\x10\x65ncryptedTeamKey\x18\x02 \x01(\x0c\x12\r\n\x05\x66orce\x18\x03 \x01(\x08\"+\n\x0fReEncryptedData\x12\n\n\x02id\x18\x01 \x01(\x03\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\t\"?\n\x12ReEncryptedRoleKey\x12\x0f\n\x07role_id\x18\x01 \x01(\x03\x12\x18\n\x10\x65ncryptedRoleKey\x18\x02 \x01(\x0c\"P\n\x16ReEncryptedUserDataKey\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x1c\n\x14userEncryptedDataKey\x18\x02 \x01(\x0c\"\xd8\x02\n\x1bNodeToManagedCompanyRequest\x12\x11\n\tcompanyId\x18\x01 \x01(\x05\x12*\n\x05nodes\x18\x02 \x03(\x0b\x32\x1b.Enterprise.ReEncryptedData\x12*\n\x05roles\x18\x03 \x03(\x0b\x32\x1b.Enterprise.ReEncryptedData\x12*\n\x05users\x18\x04 \x03(\x0b\x32\x1b.Enterprise.ReEncryptedData\x12\x30\n\x08roleKeys\x18\x05 \x03(\x0b\x32\x1e.Enterprise.ReEncryptedRoleKey\x12\x35\n\x08teamKeys\x18\x06 \x03(\x0b\x32#.Enterprise.EncryptedTeamKeyRequest\x12\x39\n\rusersDataKeys\x18\x07 \x03(\x0b\x32\".Enterprise.ReEncryptedUserDataKey\",\n\x08RoleTeam\x12\x0f\n\x07role_id\x18\x01 \x01(\x03\x12\x0f\n\x07teamUid\x18\x02 \x01(\x0c\"4\n\tRoleTeams\x12\'\n\trole_team\x18\x01 \x03(\x0b\x32\x14.Enterprise.RoleTeam\"/\n\x0bTeamsByRole\x12\x0f\n\x07role_id\x18\x01 \x01(\x03\x12\x0f\n\x07teamUid\x18\x02 \x03(\x0c\"<\n\x12ManagedNodesByRole\x12\x0f\n\x07role_id\x18\x01 \x01(\x03\x12\x15\n\rmanagedNodeId\x18\x02 \x03(\x03\"R\n\x0fRoleUserAddKeys\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x0f\n\x07treeKey\x18\x02 \x01(\t\x12\x14\n\x0croleAdminKey\x18\x03 \x01(\t\"T\n\x0bRoleUserAdd\x12\x0f\n\x07role_id\x18\x01 \x01(\x03\x12\x34\n\x0froleUserAddKeys\x18\x02 \x03(\x0b\x32\x1b.Enterprise.RoleUserAddKeys\"D\n\x13RoleUsersAddRequest\x12-\n\x0croleUserAdds\x18\x01 \x03(\x0b\x32\x17.Enterprise.RoleUserAdd\"\x80\x01\n\x11RoleUserAddResult\x12\x0e\n\x06roleId\x18\x01 \x01(\x03\x12\x18\n\x10\x65nterpriseUserId\x18\x02 \x01(\x03\x12\x30\n\x06status\x18\x03 \x01(\x0e\x32 .Enterprise.RoleUserModifyStatus\x12\x0f\n\x07message\x18\x04 \x01(\t\"F\n\x14RoleUsersAddResponse\x12.\n\x07results\x18\x01 \x03(\x0b\x32\x1d.Enterprise.RoleUserAddResult\"<\n\x0eRoleUserRemove\x12\x0f\n\x07role_id\x18\x01 \x01(\x03\x12\x19\n\x11\x65nterpriseUserIds\x18\x02 \x03(\x03\"M\n\x16RoleUsersRemoveRequest\x12\x33\n\x0froleUserRemoves\x18\x01 \x03(\x0b\x32\x1a.Enterprise.RoleUserRemove\"\x83\x01\n\x14RoleUserRemoveResult\x12\x0e\n\x06roleId\x18\x01 \x01(\x03\x12\x18\n\x10\x65nterpriseUserId\x18\x02 \x01(\x03\x12\x30\n\x06status\x18\x03 \x01(\x0e\x32 .Enterprise.RoleUserModifyStatus\x12\x0f\n\x07message\x18\x04 \x01(\t\"L\n\x17RoleUsersRemoveResponse\x12\x31\n\x07results\x18\x01 \x03(\x0b\x32 .Enterprise.RoleUserRemoveResult\"\xa0\x04\n\x16\x45nterpriseRegistration\x12\x18\n\x10\x65ncryptedTreeKey\x18\x01 \x01(\x0c\x12\x16\n\x0e\x65nterpriseName\x18\x02 \x01(\t\x12\x14\n\x0crootNodeData\x18\x03 \x01(\x0c\x12\x15\n\radminUserData\x18\x04 \x01(\x0c\x12\x11\n\tadminName\x18\x05 \x01(\t\x12\x10\n\x08roleData\x18\x06 \x01(\x0c\x12\x38\n\nrsaKeyPair\x18\x07 \x01(\x0b\x32$.Enterprise.EnterpriseKeyPairRequest\x12\x13\n\x0bnumberSeats\x18\x08 \x01(\x05\x12\x32\n\x0e\x65nterpriseType\x18\t \x01(\x0e\x32\x1a.Enterprise.EnterpriseType\x12\x15\n\rrolePublicKey\x18\n \x01(\x0c\x12*\n\"rolePrivateKeyEncryptedWithRoleKey\x18\x0b \x01(\x0c\x12#\n\x1broleKeyEncryptedWithTreeKey\x18\x0c \x01(\x0c\x12\x38\n\neccKeyPair\x18\r \x01(\x0b\x32$.Enterprise.EnterpriseKeyPairRequest\x12\x18\n\x10\x61llUsersRoleData\x18\x0e \x01(\x0c\x12)\n!roleKeyEncryptedWithUserPublicKey\x18\x0f \x01(\x0c\x12\x18\n\x10\x61pproverRoleData\x18\x10 \x01(\x0c\"H\n\x1a\x44omainPasswordRulesRequest\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x18\n\x10verificationCode\x18\x02 \x01(\t\"\\\n\x19\x44omainPasswordRulesFields\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\x0f\n\x07minimum\x18\x02 \x01(\x05\x12\x0f\n\x07maximum\x18\x03 \x01(\x05\x12\x0f\n\x07\x61llowed\x18\x04 \x01(\x08\"E\n\x10LoginToMcRequest\x12\x16\n\x0emcEnterpriseId\x18\x01 \x01(\x05\x12\x19\n\x11messageSessionUid\x18\x02 \x01(\x0c\"w\n\x11LoginToMcResponse\x12\x1d\n\x15\x65ncryptedSessionToken\x18\x01 \x01(\x0c\x12\x18\n\x10\x65ncryptedTreeKey\x18\x02 \x01(\t\x12\x11\n\tkeyTypeId\x18\x03 \x01(\x05\x12\x16\n\x0e\x66orbidKeyType2\x18\x04 \x01(\x08\"g\n\x1b\x44omainPasswordRulesResponse\x12H\n\x19\x64omainPasswordRulesFields\x18\x01 \x03(\x0b\x32%.Enterprise.DomainPasswordRulesFields\"\x88\x01\n\x18\x41pproveUserDeviceRequest\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x1c\n\x14\x65ncryptedDeviceToken\x18\x02 \x01(\x0c\x12\x1e\n\x16\x65ncryptedDeviceDataKey\x18\x03 \x01(\x0c\x12\x14\n\x0c\x64\x65nyApproval\x18\x04 \x01(\x08\"t\n\x19\x41pproveUserDeviceResponse\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x1c\n\x14\x65ncryptedDeviceToken\x18\x02 \x01(\x0c\x12\x0e\n\x06\x66\x61iled\x18\x03 \x01(\x08\x12\x0f\n\x07message\x18\x04 \x01(\t\"Y\n\x19\x41pproveUserDevicesRequest\x12<\n\x0e\x64\x65viceRequests\x18\x01 \x03(\x0b\x32$.Enterprise.ApproveUserDeviceRequest\"\\\n\x1a\x41pproveUserDevicesResponse\x12>\n\x0f\x64\x65viceResponses\x18\x01 \x03(\x0b\x32%.Enterprise.ApproveUserDeviceResponse\"\x87\x01\n\x15\x45nterpriseUserDataKey\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x1c\n\x14userEncryptedDataKey\x18\x02 \x01(\x0c\x12\x11\n\tkeyTypeId\x18\x03 \x01(\x05\x12\x0f\n\x07roleKey\x18\x04 \x01(\x0c\x12\x12\n\nprivateKey\x18\x05 \x01(\x0c\"I\n\x16\x45nterpriseUserDataKeys\x12/\n\x04keys\x18\x01 \x03(\x0b\x32!.Enterprise.EnterpriseUserDataKey\"g\n\x1a\x45nterpriseUserDataKeyLight\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x1c\n\x14userEncryptedDataKey\x18\x02 \x01(\x0c\x12\x11\n\tkeyTypeId\x18\x03 \x01(\x05\"d\n\x1c\x45nterpriseUserDataKeysByNode\x12\x0e\n\x06nodeId\x18\x01 \x01(\x03\x12\x34\n\x04keys\x18\x02 \x03(\x0b\x32&.Enterprise.EnterpriseUserDataKeyLight\"^\n$EnterpriseUserDataKeysByNodeResponse\x12\x36\n\x04keys\x18\x01 \x03(\x0b\x32(.Enterprise.EnterpriseUserDataKeysByNode\"2\n\x15\x45nterpriseDataRequest\x12\x19\n\x11\x63ontinuationToken\x18\x01 \x01(\x0c\"0\n\x13SpecialProvisioning\x12\x0b\n\x03url\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\"\x84\x02\n\x11GeneralDataEntity\x12\x16\n\x0e\x65nterpriseName\x18\x01 \x01(\t\x12\x1a\n\x12restrictVisibility\x18\x02 \x01(\x08\x12<\n\x13specialProvisioning\x18\x04 \x01(\x0b\x32\x1f.Enterprise.SpecialProvisioning\x12\x30\n\ruserPrivilege\x18\x07 \x01(\x0b\x32\x19.Enterprise.UserPrivilege\x12\x13\n\x0b\x64istributor\x18\x08 \x01(\x08\x12\x1d\n\x15\x66orbidAccountTransfer\x18\t \x01(\x08\x12\x17\n\x0fshowUserOnboard\x18\n \x01(\x08\"\xfd\x01\n\x04Node\x12\x0e\n\x06nodeId\x18\x01 \x01(\x03\x12\x10\n\x08parentId\x18\x02 \x01(\x03\x12\x10\n\x08\x62ridgeId\x18\x03 \x01(\x03\x12\x0e\n\x06scimId\x18\x04 \x01(\x03\x12\x11\n\tlicenseId\x18\x05 \x01(\x03\x12\x15\n\rencryptedData\x18\x06 \x01(\t\x12\x12\n\nduoEnabled\x18\x07 \x01(\x08\x12\x12\n\nrsaEnabled\x18\x08 \x01(\x08\x12 \n\x14ssoServiceProviderId\x18\t \x01(\x03\x42\x02\x18\x01\x12\x1a\n\x12restrictVisibility\x18\n \x01(\x08\x12!\n\x15ssoServiceProviderIds\x18\x0b \x03(\x03\x42\x02\x10\x01\"\x8e\x01\n\x04Role\x12\x0e\n\x06roleId\x18\x01 \x01(\x03\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rencryptedData\x18\x03 \x01(\t\x12\x0f\n\x07keyType\x18\x04 \x01(\t\x12\x14\n\x0cvisibleBelow\x18\x05 \x01(\x08\x12\x16\n\x0enewUserInherit\x18\x06 \x01(\x08\x12\x10\n\x08roleType\x18\x07 \x01(\t\"\xb8\x02\n\x04User\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rencryptedData\x18\x03 \x01(\t\x12\x0f\n\x07keyType\x18\x04 \x01(\t\x12\x10\n\x08username\x18\x05 \x01(\t\x12\x0e\n\x06status\x18\x06 \x01(\t\x12\x0c\n\x04lock\x18\x07 \x01(\x05\x12\x0e\n\x06userId\x18\x08 \x01(\x05\x12\x1e\n\x16\x61\x63\x63ountShareExpiration\x18\t \x01(\x03\x12\x10\n\x08\x66ullName\x18\n \x01(\t\x12\x10\n\x08jobTitle\x18\x0b \x01(\t\x12\x12\n\ntfaEnabled\x18\x0c \x01(\x08\x12\x46\n\x18transferAcceptanceStatus\x18\r \x01(\x0e\x32$.Enterprise.TransferAcceptanceStatus\"7\n\tUserAlias\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x10\n\x08username\x18\x02 \x01(\t\"\xac\x01\n\x18\x43omplianceReportMetaData\x12\x11\n\treportUid\x18\x01 \x01(\x0c\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x12\n\nreportName\x18\x03 \x01(\t\x12\x15\n\rdateGenerated\x18\x04 \x01(\x03\x12\x11\n\trunByName\x18\x05 \x01(\t\x12\x16\n\x0enumberOfOwners\x18\x07 \x01(\x05\x12\x17\n\x0fnumberOfRecords\x18\x08 \x01(\x05\"S\n\x0bManagedNode\x12\x0e\n\x06roleId\x18\x01 \x01(\x03\x12\x15\n\rmanagedNodeId\x18\x02 \x01(\x03\x12\x1d\n\x15\x63\x61scadeNodeManagement\x18\x03 \x01(\x08\"T\n\x0fUserManagedNode\x12\x0e\n\x06nodeId\x18\x01 \x01(\x03\x12\x1d\n\x15\x63\x61scadeNodeManagement\x18\x02 \x01(\x08\x12\x12\n\nprivileges\x18\x03 \x03(\t\"w\n\rUserPrivilege\x12\x35\n\x10userManagedNodes\x18\x01 \x03(\x0b\x32\x1b.Enterprise.UserManagedNode\x12\x18\n\x10\x65nterpriseUserId\x18\x02 \x01(\x03\x12\x15\n\rencryptedData\x18\x03 \x01(\t\"4\n\x08RoleUser\x12\x0e\n\x06roleId\x18\x01 \x01(\x03\x12\x18\n\x10\x65nterpriseUserId\x18\x02 \x01(\x03\"M\n\rRolePrivilege\x12\x15\n\rmanagedNodeId\x18\x01 \x01(\x03\x12\x0e\n\x06roleId\x18\x02 \x01(\x03\x12\x15\n\rprivilegeType\x18\x03 \x01(\t\"T\n\x17PrivilegesByManagedNode\x12\x15\n\rmanagedNodeId\x18\x01 \x01(\x03\x12\x0e\n\x06roleId\x18\x02 \x01(\x03\x12\x12\n\nprivileges\x18\x03 \x03(\t\"I\n\x0fRoleEnforcement\x12\x0e\n\x06roleId\x18\x01 \x01(\x03\x12\x17\n\x0f\x65nforcementType\x18\x02 \x01(\t\x12\r\n\x05value\x18\x03 \x01(\t\"\xa9\x01\n\x04Team\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0e\n\x06nodeId\x18\x03 \x01(\x03\x12\x14\n\x0crestrictEdit\x18\x04 \x01(\x08\x12\x15\n\rrestrictShare\x18\x05 \x01(\x08\x12\x14\n\x0crestrictView\x18\x06 \x01(\x08\x12\x15\n\rencryptedData\x18\x07 \x01(\t\x12\x18\n\x10\x65ncryptedTeamKey\x18\x08 \x01(\t\"G\n\x08TeamUser\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12\x18\n\x10\x65nterpriseUserId\x18\x02 \x01(\x03\x12\x10\n\x08userType\x18\x03 \x01(\t\"K\n\x1aGetDistributorInfoResponse\x12-\n\x0c\x64istributors\x18\x01 \x03(\x0b\x32\x17.Enterprise.Distributor\"B\n\x0b\x44istributor\x12\x0c\n\x04name\x18\x01 \x01(\t\x12%\n\x08mspInfos\x18\x02 \x03(\x0b\x32\x13.Enterprise.MspInfo\"\x9d\x02\n\x07MspInfo\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x05\x12\x16\n\x0e\x65nterpriseName\x18\x02 \x01(\t\x12\x19\n\x11\x61llocatedLicenses\x18\x03 \x01(\x05\x12\x19\n\x11\x61llowedMcProducts\x18\x04 \x03(\t\x12\x15\n\rallowedAddOns\x18\x05 \x03(\t\x12\x17\n\x0fmaxFilePlanType\x18\x06 \x01(\t\x12\x34\n\x10managedCompanies\x18\x07 \x03(\x0b\x32\x1a.Enterprise.ManagedCompany\x12\x1e\n\x16\x61llowUnlimitedLicenses\x18\x08 \x01(\x08\x12(\n\x06\x61\x64\x64Ons\x18\t \x03(\x0b\x32\x18.Enterprise.LicenseAddOn\"\xa8\x02\n\x0eManagedCompany\x12\x16\n\x0emcEnterpriseId\x18\x01 \x01(\x05\x12\x18\n\x10mcEnterpriseName\x18\x02 \x01(\t\x12\x11\n\tmspNodeId\x18\x03 \x01(\x03\x12\x15\n\rnumberOfSeats\x18\x04 \x01(\x05\x12\x15\n\rnumberOfUsers\x18\x05 \x01(\x05\x12\x11\n\tproductId\x18\x06 \x01(\t\x12\x11\n\tisExpired\x18\x07 \x01(\x08\x12\x0f\n\x07treeKey\x18\x08 \x01(\t\x12\x15\n\rtree_key_role\x18\t \x01(\x03\x12\x14\n\x0c\x66ilePlanType\x18\n \x01(\t\x12(\n\x06\x61\x64\x64Ons\x18\x0b \x03(\x0b\x32\x18.Enterprise.LicenseAddOn\x12\x15\n\rtreeKeyTypeId\x18\x0c \x01(\x05\"R\n\x07MSPPool\x12\x11\n\tproductId\x18\x01 \x01(\t\x12\r\n\x05seats\x18\x02 \x01(\x05\x12\x16\n\x0e\x61vailableSeats\x18\x03 \x01(\x05\x12\r\n\x05stash\x18\x04 \x01(\x05\":\n\nMSPContact\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x05\x12\x16\n\x0e\x65nterpriseName\x18\x02 \x01(\t\"\x84\x02\n\x0cLicenseAddOn\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x02 \x01(\x08\x12\x0f\n\x07isTrial\x18\x03 \x01(\x08\x12\x12\n\nexpiration\x18\x04 \x01(\x03\x12\x0f\n\x07\x63reated\x18\x05 \x01(\x03\x12\r\n\x05seats\x18\x06 \x01(\x05\x12\x16\n\x0e\x61\x63tivationTime\x18\x07 \x01(\x03\x12\x19\n\x11includedInProduct\x18\x08 \x01(\x08\x12\x14\n\x0c\x61piCallCount\x18\t \x01(\x05\x12\x17\n\x0ftierDescription\x18\n \x01(\t\x12\x16\n\x0eseatsAllocated\x18\x0b \x01(\x05\x12\x16\n\x0enhiTierAddOnId\x18\x0c \x01(\x05\"s\n\tMCDefault\x12\x11\n\tmcProduct\x18\x01 \x01(\t\x12\x0e\n\x06\x61\x64\x64Ons\x18\x02 \x03(\t\x12\x14\n\x0c\x66ilePlanType\x18\x03 \x01(\t\x12\x13\n\x0bmaxLicenses\x18\x04 \x01(\x05\x12\x18\n\x10\x66ixedMaxLicenses\x18\x05 \x01(\x08\"\xd2\x01\n\nMSPPermits\x12\x12\n\nrestricted\x18\x01 \x01(\x08\x12\x1a\n\x12maxAllowedLicenses\x18\x02 \x01(\x05\x12\x19\n\x11\x61llowedMcProducts\x18\x03 \x03(\t\x12\x15\n\rallowedAddOns\x18\x04 \x03(\t\x12\x17\n\x0fmaxFilePlanType\x18\x05 \x01(\t\x12\x1e\n\x16\x61llowUnlimitedLicenses\x18\x06 \x01(\x08\x12)\n\nmcDefaults\x18\x07 \x03(\x0b\x32\x15.Enterprise.MCDefault\"\xa0\x04\n\x07License\x12\x0c\n\x04paid\x18\x01 \x01(\x08\x12\x15\n\rnumberOfSeats\x18\x02 \x01(\x05\x12\x12\n\nexpiration\x18\x03 \x01(\x03\x12\x14\n\x0clicenseKeyId\x18\x04 \x01(\x05\x12\x15\n\rproductTypeId\x18\x05 \x01(\x05\x12\x0c\n\x04name\x18\x06 \x01(\t\x12\x1b\n\x13\x65nterpriseLicenseId\x18\x07 \x01(\x03\x12\x16\n\x0eseatsAllocated\x18\x08 \x01(\x05\x12\x14\n\x0cseatsPending\x18\t \x01(\x05\x12\x0c\n\x04tier\x18\n \x01(\x05\x12\x16\n\x0e\x66ilePlanTypeId\x18\x0b \x01(\x05\x12\x10\n\x08maxBytes\x18\x0c \x01(\x03\x12\x19\n\x11storageExpiration\x18\r \x01(\x03\x12\x15\n\rlicenseStatus\x18\x0e \x01(\t\x12$\n\x07mspPool\x18\x0f \x03(\x0b\x32\x13.Enterprise.MSPPool\x12)\n\tmanagedBy\x18\x10 \x01(\x0b\x32\x16.Enterprise.MSPContact\x12(\n\x06\x61\x64\x64Ons\x18\x11 \x03(\x0b\x32\x18.Enterprise.LicenseAddOn\x12\x17\n\x0fnextBillingDate\x18\x12 \x01(\x03\x12\x17\n\x0fhasMSPLegacyLog\x18\x13 \x01(\x08\x12*\n\nmspPermits\x18\x14 \x01(\x0b\x32\x16.Enterprise.MSPPermits\x12\x13\n\x0b\x64istributor\x18\x15 \x01(\x08\"n\n\x06\x42ridge\x12\x10\n\x08\x62ridgeId\x18\x01 \x01(\x03\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x18\n\x10wanIpEnforcement\x18\x03 \x01(\t\x12\x18\n\x10lanIpEnforcement\x18\x04 \x01(\t\x12\x0e\n\x06status\x18\x05 \x01(\t\"t\n\x04Scim\x12\x0e\n\x06scimId\x18\x01 \x01(\x03\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x0e\n\x06status\x18\x03 \x01(\t\x12\x12\n\nlastSynced\x18\x04 \x01(\x03\x12\x12\n\nrolePrefix\x18\x05 \x01(\t\x12\x14\n\x0cuniqueGroups\x18\x06 \x01(\x08\"L\n\x0e\x45mailProvision\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x0e\n\x06\x64omain\x18\x03 \x01(\t\x12\x0e\n\x06method\x18\x04 \x01(\t\"R\n\nQueuedTeam\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0e\n\x06nodeId\x18\x03 \x01(\x03\x12\x15\n\rencryptedData\x18\x04 \x01(\t\"0\n\x0eQueuedTeamUser\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12\r\n\x05users\x18\x02 \x03(\x03\"\xa4\x01\n\x0eTeamsAddResult\x12\x34\n\x11successfulTeamAdd\x18\x01 \x03(\x0b\x32\x19.Enterprise.TeamAddResult\x12\x36\n\x13unsuccessfulTeamAdd\x18\x02 \x03(\x0b\x32\x19.Enterprise.TeamAddResult\x12\x0e\n\x06result\x18\x03 \x01(\t\x12\x14\n\x0c\x65rrorMessage\x18\x04 \x01(\t\"U\n\rTeamAddResult\x12\x1e\n\x04team\x18\x01 \x01(\x0b\x32\x10.Enterprise.Team\x12\x0e\n\x06result\x18\x02 \x01(\t\x12\x14\n\x0c\x65rrorMessage\x18\x03 \x01(\t\"\x91\x01\n\nSsoService\x12\x1c\n\x14ssoServiceProviderId\x18\x01 \x01(\x03\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x0c\n\x04name\x18\x03 \x01(\t\x12\x0e\n\x06sp_url\x18\x04 \x01(\t\x12\x16\n\x0einviteNewUsers\x18\x05 \x01(\x08\x12\x0e\n\x06\x61\x63tive\x18\x06 \x01(\x08\x12\x0f\n\x07isCloud\x18\x07 \x01(\x08\"1\n\x10ReportFilterUser\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12\r\n\x05\x65mail\x18\x02 \x01(\t\"\x97\x02\n\x1d\x44\x65viceRequestForAdminApproval\x12\x10\n\x08\x64\x65viceId\x18\x01 \x01(\x03\x12\x18\n\x10\x65nterpriseUserId\x18\x02 \x01(\x03\x12\x1c\n\x14\x65ncryptedDeviceToken\x18\x03 \x01(\x0c\x12\x17\n\x0f\x64\x65vicePublicKey\x18\x04 \x01(\x0c\x12\x12\n\ndeviceName\x18\x05 \x01(\t\x12\x15\n\rclientVersion\x18\x06 \x01(\t\x12\x12\n\ndeviceType\x18\x07 \x01(\t\x12\x0c\n\x04\x64\x61te\x18\x08 \x01(\x03\x12\x11\n\tipAddress\x18\t \x01(\t\x12\x10\n\x08location\x18\n \x01(\t\x12\r\n\x05\x65mail\x18\x0b \x01(\t\x12\x12\n\naccountUid\x18\x0c \x01(\x0c\"`\n\x0e\x45nterpriseData\x12\x30\n\x06\x65ntity\x18\x01 \x01(\x0e\x32 .Enterprise.EnterpriseDataEntity\x12\x0e\n\x06\x64\x65lete\x18\x02 \x01(\x08\x12\x0c\n\x04\x64\x61ta\x18\x03 \x03(\x0c\"\xd0\x01\n\x16\x45nterpriseDataResponse\x12\x19\n\x11\x63ontinuationToken\x18\x01 \x01(\x0c\x12\x0f\n\x07hasMore\x18\x02 \x01(\x08\x12,\n\x0b\x63\x61\x63heStatus\x18\x03 \x01(\x0e\x32\x17.Enterprise.CacheStatus\x12(\n\x04\x64\x61ta\x18\x04 \x03(\x0b\x32\x1a.Enterprise.EnterpriseData\x12\x32\n\x0bgeneralData\x18\x05 \x01(\x0b\x32\x1d.Enterprise.GeneralDataEntity\"*\n\rBackupRequest\x12\x19\n\x11\x63ontinuationToken\x18\x01 \x01(\x0c\"\x98\x01\n\x0c\x42\x61\x63kupRecord\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\x12\x0b\n\x03key\x18\x03 \x01(\x0c\x12*\n\x07keyType\x18\x04 \x01(\x0e\x32\x19.Enterprise.BackupKeyType\x12\x0f\n\x07version\x18\x05 \x01(\x05\x12\x0c\n\x04\x64\x61ta\x18\x06 \x01(\x0c\x12\r\n\x05\x65xtra\x18\x07 \x01(\x0c\".\n\tBackupKey\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12\x11\n\tbackupKey\x18\x02 \x01(\x0c\"\x8d\x02\n\nBackupUser\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12\x10\n\x08userName\x18\x02 \x01(\t\x12\x0f\n\x07\x64\x61taKey\x18\x03 \x01(\x0c\x12\x36\n\x0b\x64\x61taKeyType\x18\x04 \x01(\x0e\x32!.Enterprise.BackupUserDataKeyType\x12\x12\n\nprivateKey\x18\x05 \x01(\x0c\x12\x0f\n\x07treeKey\x18\x06 \x01(\x0c\x12.\n\x0btreeKeyType\x18\x07 \x01(\x0e\x32\x19.Enterprise.BackupKeyType\x12)\n\nbackupKeys\x18\x08 \x03(\x0b\x32\x15.Enterprise.BackupKey\x12\x14\n\x0cprivateECKey\x18\t \x01(\x0c\"\x9e\x01\n\x0e\x42\x61\x63kupResponse\x12\x1f\n\x17\x65nterpriseEccPrivateKey\x18\x01 \x01(\x0c\x12%\n\x05users\x18\x02 \x03(\x0b\x32\x16.Enterprise.BackupUser\x12)\n\x07records\x18\x03 \x03(\x0b\x32\x18.Enterprise.BackupRecord\x12\x19\n\x11\x63ontinuationToken\x18\x04 \x01(\x0c\"e\n\nBackupFile\x12\x0c\n\x04user\x18\x01 \x01(\t\x12\x11\n\tbackupUid\x18\x02 \x01(\x0c\x12\x10\n\x08\x66ileName\x18\x03 \x01(\t\x12\x0f\n\x07\x63reated\x18\x04 \x01(\x03\x12\x13\n\x0b\x64ownloadUrl\x18\x05 \x01(\t\"8\n\x0f\x42\x61\x63kupsResponse\x12%\n\x05\x66iles\x18\x01 \x03(\x0b\x32\x16.Enterprise.BackupFile\".\n\x1cGetEnterpriseDataKeysRequest\x12\x0e\n\x06roleId\x18\x01 \x03(\x03\"\xff\x01\n\x1dGetEnterpriseDataKeysResponse\x12:\n\x12reEncryptedRoleKey\x18\x01 \x03(\x0b\x32\x1e.Enterprise.ReEncryptedRoleKey\x12$\n\x07roleKey\x18\x02 \x03(\x0b\x32\x13.Enterprise.RoleKey\x12\"\n\x06mspKey\x18\x03 \x01(\x0b\x32\x12.Enterprise.MspKey\x12\x32\n\x0e\x65nterpriseKeys\x18\x04 \x01(\x0b\x32\x1a.Enterprise.EnterpriseKeys\x12$\n\x07treeKey\x18\x05 \x01(\x0b\x32\x13.Enterprise.TreeKey\"^\n\x07RoleKey\x12\x0e\n\x06roleId\x18\x01 \x01(\x03\x12\x14\n\x0c\x65ncryptedKey\x18\x02 \x01(\t\x12-\n\x07keyType\x18\x03 \x01(\x0e\x32\x1c.Enterprise.EncryptedKeyType\"d\n\x06MspKey\x12\x1b\n\x13\x65ncryptedMspTreeKey\x18\x01 \x01(\t\x12=\n\x17\x65ncryptedMspTreeKeyType\x18\x02 \x01(\x0e\x32\x1c.Enterprise.EncryptedKeyType\"|\n\x0e\x45nterpriseKeys\x12\x14\n\x0crsaPublicKey\x18\x01 \x01(\x0c\x12\x1e\n\x16rsaEncryptedPrivateKey\x18\x02 \x01(\x0c\x12\x14\n\x0c\x65\x63\x63PublicKey\x18\x03 \x01(\x0c\x12\x1e\n\x16\x65\x63\x63\x45ncryptedPrivateKey\x18\x04 \x01(\x0c\"H\n\x07TreeKey\x12\x0f\n\x07treeKey\x18\x01 \x01(\t\x12,\n\tkeyTypeId\x18\x02 \x01(\x0e\x32\x19.Enterprise.BackupKeyType\"E\n\x14SharedRecordResponse\x12-\n\x06\x65vents\x18\x01 \x03(\x0b\x32\x1d.Enterprise.SharedRecordEvent\"p\n\x11SharedRecordEvent\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x10\n\x08userName\x18\x02 \x01(\t\x12\x0f\n\x07\x63\x61nEdit\x18\x03 \x01(\x08\x12\x12\n\ncanReshare\x18\x04 \x01(\x08\x12\x11\n\tshareFrom\x18\x05 \x01(\x05\".\n\x1cSetRestrictVisibilityRequest\x12\x0e\n\x06nodeId\x18\x01 \x01(\x03\"\xd0\x01\n\x0eUserAddRequest\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\x12-\n\x07keyType\x18\x04 \x01(\x0e\x32\x1c.Enterprise.EncryptedKeyType\x12\x10\n\x08\x66ullName\x18\x05 \x01(\t\x12\x10\n\x08jobTitle\x18\x06 \x01(\t\x12\r\n\x05\x65mail\x18\x07 \x01(\t\x12\x1b\n\x13suppressEmailInvite\x18\x08 \x01(\x08\":\n\x11UserUpdateRequest\x12%\n\x05users\x18\x01 \x03(\x0b\x32\x16.Enterprise.UserUpdate\"\xaf\x01\n\nUserUpdate\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\x12-\n\x07keyType\x18\x04 \x01(\x0e\x32\x1c.Enterprise.EncryptedKeyType\x12\x10\n\x08\x66ullName\x18\x05 \x01(\t\x12\x10\n\x08jobTitle\x18\x06 \x01(\t\x12\r\n\x05\x65mail\x18\x07 \x01(\t\"A\n\x12UserUpdateResponse\x12+\n\x05users\x18\x01 \x03(\x0b\x32\x1c.Enterprise.UserUpdateResult\"Z\n\x10UserUpdateResult\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12,\n\x06status\x18\x02 \x01(\x0e\x32\x1c.Enterprise.UserUpdateStatus\"J\n\x1d\x43omplianceRecordOwnersRequest\x12\x0f\n\x07nodeIds\x18\x01 \x03(\x03\x12\x18\n\x10includeNonShared\x18\x02 \x01(\x08\"O\n\x1e\x43omplianceRecordOwnersResponse\x12-\n\x0crecordOwners\x18\x01 \x03(\x0b\x32\x17.Enterprise.RecordOwner\"7\n\x0bRecordOwner\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x0e\n\x06shared\x18\x02 \x01(\x08\"\xa6\x01\n PreliminaryComplianceDataRequest\x12\x19\n\x11\x65nterpriseUserIds\x18\x01 \x03(\x03\x12\x18\n\x10includeNonShared\x18\x02 \x01(\x08\x12\x19\n\x11\x63ontinuationToken\x18\x03 \x01(\x0c\x12\x32\n*includeTotalMatchingRecordsInFirstResponse\x18\x04 \x01(\x08\"\x9f\x01\n!PreliminaryComplianceDataResponse\x12\x30\n\rauditUserData\x18\x01 \x03(\x0b\x32\x19.Enterprise.AuditUserData\x12\x19\n\x11\x63ontinuationToken\x18\x02 \x01(\x0c\x12\x0f\n\x07hasMore\x18\x03 \x01(\x08\x12\x1c\n\x14totalMatchingRecords\x18\x04 \x01(\x05\"K\n\x0f\x41uditUserRecord\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x15\n\rencryptedData\x18\x02 \x01(\x0c\x12\x0e\n\x06shared\x18\x03 \x01(\x08\"\x8d\x01\n\rAuditUserData\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x35\n\x10\x61uditUserRecords\x18\x02 \x03(\x0b\x32\x1b.Enterprise.AuditUserRecord\x12+\n\x06status\x18\x03 \x01(\x0e\x32\x1b.Enterprise.AuditUserStatus\"\x7f\n\x17\x43omplianceReportFilters\x12\x14\n\x0crecordTitles\x18\x01 \x03(\t\x12\x12\n\nrecordUids\x18\x02 \x03(\x0c\x12\x11\n\tjobTitles\x18\x03 \x03(\x03\x12\x0c\n\x04urls\x18\x04 \x03(\t\x12\x19\n\x11\x65nterpriseUserIds\x18\x05 \x03(\x03\"\x7f\n\x17\x43omplianceReportRequest\x12<\n\x13\x63omplianceReportRun\x18\x01 \x01(\x0b\x32\x1f.Enterprise.ComplianceReportRun\x12\x12\n\nreportName\x18\x02 \x01(\t\x12\x12\n\nsaveReport\x18\x03 \x01(\x08\"\x85\x01\n\x13\x43omplianceReportRun\x12N\n\x17reportCriteriaAndFilter\x18\x01 \x01(\x0b\x32-.Enterprise.ComplianceReportCriteriaAndFilter\x12\r\n\x05users\x18\x02 \x03(\x03\x12\x0f\n\x07records\x18\x03 \x03(\x0c\"\xfc\x01\n!ComplianceReportCriteriaAndFilter\x12\x0e\n\x06nodeId\x18\x01 \x01(\x03\x12\x13\n\x0b\x63riteriaUid\x18\x02 \x01(\x0c\x12\x14\n\x0c\x63riteriaName\x18\x03 \x01(\t\x12\x36\n\x08\x63riteria\x18\x04 \x01(\x0b\x32$.Enterprise.ComplianceReportCriteria\x12\x33\n\x07\x66ilters\x18\x05 \x03(\x0b\x32\".Enterprise.ComplianceReportFilter\x12\x14\n\x0clastModified\x18\x06 \x01(\x03\x12\x19\n\x11nodeEncryptedData\x18\x07 \x01(\x0c\"b\n\x18\x43omplianceReportCriteria\x12\x11\n\tjobTitles\x18\x01 \x03(\t\x12\x19\n\x11\x65nterpriseUserIds\x18\x02 \x03(\x03\x12\x18\n\x10includeNonShared\x18\x03 \x01(\x08\"x\n\x16\x43omplianceReportFilter\x12\x14\n\x0crecordTitles\x18\x01 \x03(\t\x12\x12\n\nrecordUids\x18\x02 \x03(\x0c\x12\x11\n\tjobTitles\x18\x03 \x03(\t\x12\x0c\n\x04urls\x18\x04 \x03(\t\x12\x13\n\x0brecordTypes\x18\x05 \x03(\t\"\xa1\x05\n\x18\x43omplianceReportResponse\x12\x15\n\rdateGenerated\x18\x01 \x01(\x03\x12\x15\n\rrunByUserName\x18\x02 \x01(\t\x12\x12\n\nreportName\x18\x03 \x01(\t\x12\x11\n\treportUid\x18\x04 \x01(\x0c\x12<\n\x13\x63omplianceReportRun\x18\x05 \x01(\x0b\x32\x1f.Enterprise.ComplianceReportRun\x12-\n\x0cuserProfiles\x18\x06 \x03(\x0b\x32\x17.Enterprise.UserProfile\x12)\n\nauditTeams\x18\x07 \x03(\x0b\x32\x15.Enterprise.AuditTeam\x12-\n\x0c\x61uditRecords\x18\x08 \x03(\x0b\x32\x17.Enterprise.AuditRecord\x12+\n\x0buserRecords\x18\t \x03(\x0b\x32\x16.Enterprise.UserRecord\x12;\n\x13sharedFolderRecords\x18\n \x03(\x0b\x32\x1e.Enterprise.SharedFolderRecord\x12\x37\n\x11sharedFolderUsers\x18\x0b \x03(\x0b\x32\x1c.Enterprise.SharedFolderUser\x12\x37\n\x11sharedFolderTeams\x18\x0c \x03(\x0b\x32\x1c.Enterprise.SharedFolderTeam\x12\x31\n\x0e\x61uditTeamUsers\x18\r \x03(\x0b\x32\x19.Enterprise.AuditTeamUser\x12)\n\nauditRoles\x18\x0e \x03(\x0b\x32\x15.Enterprise.AuditRole\x12/\n\rlinkedRecords\x18\x0f \x03(\x0b\x32\x18.Enterprise.LinkedRecord\"\x81\x01\n\x0b\x41uditRecord\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x11\n\tauditData\x18\x02 \x01(\x0c\x12\x16\n\x0ehasAttachments\x18\x03 \x01(\x08\x12\x0f\n\x07inTrash\x18\x04 \x01(\x08\x12\x10\n\x08treeLeft\x18\x05 \x01(\x05\x12\x11\n\ttreeRight\x18\x06 \x01(\x05\"\x80\x02\n\tAuditRole\x12\x0e\n\x06roleId\x18\x01 \x01(\x03\x12\x15\n\rencryptedData\x18\x02 \x01(\x0c\x12&\n\x1erestrictShareOutsideEnterprise\x18\x03 \x01(\x08\x12\x18\n\x10restrictShareAll\x18\x04 \x01(\x08\x12\"\n\x1arestrictShareOfAttachments\x18\x05 \x01(\x08\x12)\n!restrictMaskPasswordsWhileEditing\x18\x06 \x01(\x08\x12;\n\x13roleNodeManagements\x18\x07 \x03(\x0b\x32\x1e.Enterprise.RoleNodeManagement\"^\n\x12RoleNodeManagement\x12\x10\n\x08treeLeft\x18\x01 \x01(\x05\x12\x11\n\ttreeRight\x18\x02 \x01(\x05\x12\x0f\n\x07\x63\x61scade\x18\x03 \x01(\x08\x12\x12\n\nprivileges\x18\x04 \x01(\x05\"k\n\x0bUserProfile\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x10\n\x08\x66ullName\x18\x02 \x01(\t\x12\x10\n\x08jobTitle\x18\x03 \x01(\t\x12\r\n\x05\x65mail\x18\x04 \x01(\t\x12\x0f\n\x07roleIds\x18\x05 \x03(\x03\"=\n\x10RecordPermission\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x16\n\x0epermissionBits\x18\x02 \x01(\x05\"_\n\nUserRecord\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x37\n\x11recordPermissions\x18\x02 \x03(\x0b\x32\x1c.Enterprise.RecordPermission\"[\n\tAuditTeam\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12\x10\n\x08teamName\x18\x02 \x01(\t\x12\x14\n\x0crestrictEdit\x18\x03 \x01(\x08\x12\x15\n\rrestrictShare\x18\x04 \x01(\x08\";\n\rAuditTeamUser\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12\x19\n\x11\x65nterpriseUserIds\x18\x02 \x03(\x03\"\x9f\x01\n\x12SharedFolderRecord\x12\x17\n\x0fsharedFolderUid\x18\x01 \x01(\x0c\x12\x37\n\x11recordPermissions\x18\x02 \x03(\x0b\x32\x1c.Enterprise.RecordPermission\x12\x37\n\x11shareAdminRecords\x18\x03 \x03(\x0b\x32\x1c.Enterprise.ShareAdminRecord\"M\n\x10ShareAdminRecord\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x1f\n\x17recordPermissionIndexes\x18\x02 \x03(\x05\"F\n\x10SharedFolderUser\x12\x17\n\x0fsharedFolderUid\x18\x01 \x01(\x0c\x12\x19\n\x11\x65nterpriseUserIds\x18\x02 \x03(\x03\"=\n\x10SharedFolderTeam\x12\x17\n\x0fsharedFolderUid\x18\x01 \x01(\x0c\x12\x10\n\x08teamUids\x18\x02 \x03(\x0c\"/\n\x1aGetComplianceReportRequest\x12\x11\n\treportUid\x18\x01 \x01(\x0c\"2\n\x1bGetComplianceReportResponse\x12\x13\n\x0b\x64ownloadUrl\x18\x01 \x01(\t\"6\n\x1f\x43omplianceReportCriteriaRequest\x12\x13\n\x0b\x63riteriaUid\x18\x01 \x01(\x0c\";\n$SaveComplianceReportCriteriaResponse\x12\x13\n\x0b\x63riteriaUid\x18\x01 \x01(\x0c\"4\n\x0cLinkedRecord\x12\x10\n\x08ownerUid\x18\x01 \x01(\x0c\x12\x12\n\nrecordUids\x18\x02 \x03(\x0c\"W\n\x17GetSharingAdminsRequest\x12\x17\n\x0fsharedFolderUid\x18\x01 \x01(\x0c\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\x12\x10\n\x08username\x18\x03 \x01(\t\"\xe0\x01\n\x0eUserProfileExt\x12\r\n\x05\x65mail\x18\x01 \x01(\t\x12\x10\n\x08\x66ullName\x18\x02 \x01(\t\x12\x10\n\x08jobTitle\x18\x03 \x01(\t\x12\x14\n\x0cisMSPMCAdmin\x18\x04 \x01(\x08\x12\x18\n\x10isInSharedFolder\x18\x05 \x01(\x08\x12&\n\x1eisShareAdminForRequestedObject\x18\x06 \x01(\x08\x12(\n isShareAdminForSharedFolderOwner\x18\x07 \x01(\x08\x12\x19\n\x11hasAccessToObject\x18\x08 \x01(\x08\"O\n\x18GetSharingAdminsResponse\x12\x33\n\x0fuserProfileExts\x18\x01 \x03(\x0b\x32\x1a.Enterprise.UserProfileExt\"_\n\x1eTeamsEnterpriseUsersAddRequest\x12=\n\x05teams\x18\x01 \x03(\x0b\x32..Enterprise.TeamsEnterpriseUsersAddTeamRequest\"t\n\"TeamsEnterpriseUsersAddTeamRequest\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12=\n\x05users\x18\x02 \x03(\x0b\x32..Enterprise.TeamsEnterpriseUsersAddUserRequest\"\xab\x01\n\"TeamsEnterpriseUsersAddUserRequest\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12*\n\x08userType\x18\x02 \x01(\x0e\x32\x18.Enterprise.TeamUserType\x12\x13\n\x07teamKey\x18\x03 \x01(\tB\x02\x18\x01\x12*\n\x0ctypedTeamKey\x18\x04 \x01(\x0b\x32\x14.Enterprise.TypedKey\"F\n\x08TypedKey\x12\x0b\n\x03key\x18\x01 \x01(\x0c\x12-\n\x07keyType\x18\x02 \x01(\x0e\x32\x1c.Enterprise.EncryptedKeyType\"s\n\x1fTeamsEnterpriseUsersAddResponse\x12>\n\x05teams\x18\x01 \x03(\x0b\x32/.Enterprise.TeamsEnterpriseUsersAddTeamResponse\x12\x10\n\x08revision\x18\x02 \x01(\x03\"\xc4\x01\n#TeamsEnterpriseUsersAddTeamResponse\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12>\n\x05users\x18\x02 \x03(\x0b\x32/.Enterprise.TeamsEnterpriseUsersAddUserResponse\x12\x0f\n\x07success\x18\x03 \x01(\x08\x12\x0f\n\x07message\x18\x04 \x01(\t\x12\x12\n\nresultCode\x18\x05 \x01(\t\x12\x16\n\x0e\x61\x64\x64itionalInfo\x18\x06 \x01(\t\"\x9f\x01\n#TeamsEnterpriseUsersAddUserResponse\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x10\n\x08revision\x18\x02 \x01(\x03\x12\x0f\n\x07success\x18\x03 \x01(\x08\x12\x0f\n\x07message\x18\x04 \x01(\t\x12\x12\n\nresultCode\x18\x05 \x01(\t\x12\x16\n\x0e\x61\x64\x64itionalInfo\x18\x06 \x01(\t\"E\n\x18TeamEnterpriseUserRemove\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12\x18\n\x10\x65nterpriseUserId\x18\x02 \x01(\x03\"j\n TeamEnterpriseUserRemovesRequest\x12\x46\n\x18teamEnterpriseUserRemove\x18\x01 \x03(\x0b\x32$.Enterprise.TeamEnterpriseUserRemove\"{\n!TeamEnterpriseUserRemovesResponse\x12V\n teamEnterpriseUserRemoveResponse\x18\x01 \x03(\x0b\x32,.Enterprise.TeamEnterpriseUserRemoveResponse\"\xb8\x01\n TeamEnterpriseUserRemoveResponse\x12\x46\n\x18teamEnterpriseUserRemove\x18\x01 \x01(\x0b\x32$.Enterprise.TeamEnterpriseUserRemove\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x12\n\nresultCode\x18\x03 \x01(\t\x12\x0f\n\x07message\x18\x04 \x01(\t\x12\x16\n\x0e\x61\x64\x64itionalInfo\x18\x05 \x01(\t\"M\n\x0b\x44omainAlias\x12\x0e\n\x06\x64omain\x18\x01 \x01(\t\x12\r\n\x05\x61lias\x18\x02 \x01(\t\x12\x0e\n\x06status\x18\x03 \x01(\x05\x12\x0f\n\x07message\x18\x04 \x01(\t\"B\n\x12\x44omainAliasRequest\x12,\n\x0b\x64omainAlias\x18\x01 \x03(\x0b\x32\x17.Enterprise.DomainAlias\"C\n\x13\x44omainAliasResponse\x12,\n\x0b\x64omainAlias\x18\x01 \x03(\x0b\x32\x17.Enterprise.DomainAlias\"m\n\x1f\x45nterpriseUsersProvisionRequest\x12\x33\n\x05users\x18\x01 \x03(\x0b\x32$.Enterprise.EnterpriseUsersProvision\x12\x15\n\rclientVersion\x18\x02 \x01(\t\"\xb6\x03\n\x18\x45nterpriseUsersProvision\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x10\n\x08username\x18\x02 \x01(\t\x12\x0e\n\x06nodeId\x18\x03 \x01(\x03\x12\x15\n\rencryptedData\x18\x04 \x01(\t\x12-\n\x07keyType\x18\x05 \x01(\x0e\x32\x1c.Enterprise.EncryptedKeyType\x12\x10\n\x08\x66ullName\x18\x06 \x01(\t\x12\x10\n\x08jobTitle\x18\x07 \x01(\t\x12\x1e\n\x16\x65nterpriseUsersDataKey\x18\x08 \x01(\x0c\x12\x14\n\x0c\x61uthVerifier\x18\t \x01(\x0c\x12\x18\n\x10\x65ncryptionParams\x18\n \x01(\x0c\x12\x14\n\x0crsaPublicKey\x18\x0b \x01(\x0c\x12\x1e\n\x16rsaEncryptedPrivateKey\x18\x0c \x01(\x0c\x12\x14\n\x0c\x65\x63\x63PublicKey\x18\r \x01(\x0c\x12\x1e\n\x16\x65\x63\x63\x45ncryptedPrivateKey\x18\x0e \x01(\x0c\x12\x1c\n\x14\x65ncryptedDeviceToken\x18\x0f \x01(\x0c\x12\x1a\n\x12\x65ncryptedClientKey\x18\x10 \x01(\x0c\"_\n EnterpriseUsersProvisionResponse\x12;\n\x07results\x18\x01 \x03(\x0b\x32*.Enterprise.EnterpriseUsersProvisionResult\"q\n\x1e\x45nterpriseUsersProvisionResult\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x0c\n\x04\x63ode\x18\x02 \x01(\t\x12\x0f\n\x07message\x18\x03 \x01(\t\x12\x16\n\x0e\x61\x64\x64itionalInfo\x18\x04 \x01(\t\"a\n\x19\x45nterpriseUsersAddRequest\x12-\n\x05users\x18\x01 \x03(\x0b\x32\x1e.Enterprise.EnterpriseUsersAdd\x12\x15\n\rclientVersion\x18\x02 \x01(\t\"\x8c\x02\n\x12\x45nterpriseUsersAdd\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x10\n\x08username\x18\x02 \x01(\t\x12\x0e\n\x06nodeId\x18\x03 \x01(\x03\x12\x15\n\rencryptedData\x18\x04 \x01(\t\x12-\n\x07keyType\x18\x05 \x01(\x0e\x32\x1c.Enterprise.EncryptedKeyType\x12\x10\n\x08\x66ullName\x18\x06 \x01(\t\x12\x10\n\x08jobTitle\x18\x07 \x01(\t\x12\x1b\n\x13suppressEmailInvite\x18\x08 \x01(\x08\x12\x15\n\rinviteeLocale\x18\t \x01(\t\x12\x0c\n\x04move\x18\n \x01(\x08\x12\x0e\n\x06roleId\x18\x0b \x01(\x03\"\x9b\x01\n\x1a\x45nterpriseUsersAddResponse\x12\x35\n\x07results\x18\x01 \x03(\x0b\x32$.Enterprise.EnterpriseUsersAddResult\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x0c\n\x04\x63ode\x18\x03 \x01(\t\x12\x0f\n\x07message\x18\x04 \x01(\t\x12\x16\n\x0e\x61\x64\x64itionalInfo\x18\x05 \x01(\t\"\x96\x01\n\x18\x45nterpriseUsersAddResult\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x18\n\x10verificationCode\x18\x03 \x01(\t\x12\x0c\n\x04\x63ode\x18\x04 \x01(\t\x12\x0f\n\x07message\x18\x05 \x01(\t\x12\x16\n\x0e\x61\x64\x64itionalInfo\x18\x06 \x01(\t\"\xb9\x01\n\x17UpdateMSPPermitsRequest\x12\x17\n\x0fmspEnterpriseId\x18\x01 \x01(\x05\x12\x1a\n\x12maxAllowedLicenses\x18\x02 \x01(\x05\x12\x19\n\x11\x61llowedMcProducts\x18\x03 \x03(\t\x12\x15\n\rallowedAddOns\x18\x04 \x03(\t\x12\x17\n\x0fmaxFilePlanType\x18\x05 \x01(\t\x12\x1e\n\x16\x61llowUnlimitedLicenses\x18\x06 \x01(\x08\"9\n\x1c\x44\x65leteEnterpriseUsersRequest\x12\x19\n\x11\x65nterpriseUserIds\x18\x01 \x03(\x03\"o\n\x1a\x44\x65leteEnterpriseUserStatus\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x37\n\x06status\x18\x02 \x01(\x0e\x32\'.Enterprise.DeleteEnterpriseUsersResult\"]\n\x1d\x44\x65leteEnterpriseUsersResponse\x12<\n\x0c\x64\x65leteStatus\x18\x01 \x03(\x0b\x32&.Enterprise.DeleteEnterpriseUserStatus\"w\n\x18\x43learSecurityDataRequest\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x03(\x03\x12\x10\n\x08\x61llUsers\x18\x02 \x01(\x08\x12/\n\x04type\x18\x03 \x01(\x0e\x32!.Enterprise.ClearSecurityDataType\"%\n\x13ListDomainsResponse\x12\x0e\n\x06\x64omain\x18\x01 \x03(\t\"d\n\x14ReserveDomainRequest\x12<\n\x13reserveDomainAction\x18\x01 \x01(\x0e\x32\x1f.Enterprise.ReserveDomainAction\x12\x0e\n\x06\x64omain\x18\x02 \x01(\t\"&\n\x15ReserveDomainResponse\x12\r\n\x05token\x18\x01 \x01(\t\".\n\x0bRolesByTeam\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12\x0e\n\x06roleId\x18\x02 \x03(\x03\"\x8d\x01\n\x10LockUsersRequest\x12\x1d\n\x15lockEnterpriseUserIds\x18\x01 \x03(\x03\x12 \n\x18\x64isableEnterpriseUserIds\x18\x02 \x03(\x03\x12\x1f\n\x17unlockEnterpriseUserIds\x18\x03 \x03(\x03\x12\x17\n\x0f\x64\x65leteIfPending\x18\x04 \x01(\x08\"C\n\x11LockUsersResponse\x12.\n\x08response\x18\x01 \x03(\x0b\x32\x1c.Enterprise.LockUserResponse\"n\n\x10LockUserResponse\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12*\n\x06status\x18\x02 \x01(\x0e\x32\x1a.Enterprise.UserLockStatus\x12\x14\n\x0c\x65rrorMessage\x18\x03 \x01(\t*\x1b\n\x07KeyType\x12\x07\n\x03RSA\x10\x00\x12\x07\n\x03\x45\x43\x43\x10\x01*\x9a\x02\n\x14RoleUserModifyStatus\x12\x0f\n\x0bROLE_EXISTS\x10\x00\x12\x14\n\x10MISSING_TREE_KEY\x10\x01\x12\x14\n\x10MISSING_ROLE_KEY\x10\x02\x12\x1e\n\x1aINVALID_ENTERPRISE_USER_ID\x10\x03\x12\x1b\n\x17PENDING_ENTERPRISE_USER\x10\x04\x12\x13\n\x0fINVALID_NODE_ID\x10\x05\x12!\n\x1dMAY_NOT_REMOVE_SELF_FROM_ROLE\x10\x06\x12\x1c\n\x18MUST_HAVE_ONE_USER_ADMIN\x10\x07\x12\x13\n\x0fINVALID_ROLE_ID\x10\x08\x12\x1d\n\x19PAM_LICENSE_SEAT_EXCEEDED\x10\t*=\n\x0e\x45nterpriseType\x12\x17\n\x13\x45NTERPRISE_STANDARD\x10\x00\x12\x12\n\x0e\x45NTERPRISE_MSP\x10\x01*s\n\x18TransferAcceptanceStatus\x12\r\n\tUNDEFINED\x10\x00\x12\x10\n\x0cNOT_REQUIRED\x10\x01\x12\x10\n\x0cNOT_ACCEPTED\x10\x02\x12\x16\n\x12PARTIALLY_ACCEPTED\x10\x03\x12\x0c\n\x08\x41\x43\x43\x45PTED\x10\x04*\xe1\x03\n\x14\x45nterpriseDataEntity\x12\x0b\n\x07UNKNOWN\x10\x00\x12\t\n\x05NODES\x10\x01\x12\t\n\x05ROLES\x10\x02\x12\t\n\x05USERS\x10\x03\x12\t\n\x05TEAMS\x10\x04\x12\x0e\n\nTEAM_USERS\x10\x05\x12\x0e\n\nROLE_USERS\x10\x06\x12\x13\n\x0fROLE_PRIVILEGES\x10\x07\x12\x15\n\x11ROLE_ENFORCEMENTS\x10\x08\x12\x0e\n\nROLE_TEAMS\x10\t\x12\x0c\n\x08LICENSES\x10\n\x12\x11\n\rMANAGED_NODES\x10\x0b\x12\x15\n\x11MANAGED_COMPANIES\x10\x0c\x12\x0b\n\x07\x42RIDGES\x10\r\x12\t\n\x05SCIMS\x10\x0e\x12\x13\n\x0f\x45MAIL_PROVISION\x10\x0f\x12\x10\n\x0cQUEUED_TEAMS\x10\x10\x12\x15\n\x11QUEUED_TEAM_USERS\x10\x11\x12\x10\n\x0cSSO_SERVICES\x10\x12\x12\x17\n\x13REPORT_FILTER_USERS\x10\x13\x12&\n\"DEVICES_REQUEST_FOR_ADMIN_APPROVAL\x10\x14\x12\x10\n\x0cUSER_ALIASES\x10\x15\x12)\n%COMPLIANCE_REPORT_CRITERIA_AND_FILTER\x10\x16\x12\x16\n\x12\x43OMPLIANCE_REPORTS\x10\x17*\"\n\x0b\x43\x61\x63heStatus\x12\x08\n\x04KEEP\x10\x00\x12\t\n\x05\x43LEAR\x10\x01*\x93\x01\n\rBackupKeyType\x12\n\n\x06NO_KEY\x10\x00\x12\x19\n\x15\x45NCRYPTED_BY_DATA_KEY\x10\x01\x12\x1b\n\x17\x45NCRYPTED_BY_PUBLIC_KEY\x10\x02\x12\x1d\n\x19\x45NCRYPTED_BY_DATA_KEY_GCM\x10\x03\x12\x1f\n\x1b\x45NCRYPTED_BY_PUBLIC_KEY_ECC\x10\x04*:\n\x15\x42\x61\x63kupUserDataKeyType\x12\x07\n\x03OWN\x10\x00\x12\x18\n\x14SHARED_TO_ENTERPRISE\x10\x01*\xa5\x01\n\x10\x45ncryptedKeyType\x12\r\n\tKT_NO_KEY\x10\x00\x12\x1c\n\x18KT_ENCRYPTED_BY_DATA_KEY\x10\x01\x12\x1e\n\x1aKT_ENCRYPTED_BY_PUBLIC_KEY\x10\x02\x12 \n\x1cKT_ENCRYPTED_BY_DATA_KEY_GCM\x10\x03\x12\"\n\x1eKT_ENCRYPTED_BY_PUBLIC_KEY_ECC\x10\x04*\xb7\x02\n\x12\x45nterpriseFlagType\x12\x0b\n\x07INVALID\x10\x00\x12\x1a\n\x16\x41LLOW_PERSONAL_LICENSE\x10\x01\x12\x18\n\x14SPECIAL_PROVISIONING\x10\x02\x12\x10\n\x0cRECORD_TYPES\x10\x03\x12\x13\n\x0fSECRETS_MANAGER\x10\x04\x12\x15\n\x11\x45NTERPRISE_LOCKED\x10\x05\x12\x15\n\x11\x46ORBID_KEY_TYPE_2\x10\x06\x12\x15\n\x11\x43ONSOLE_ONBOARDED\x10\x07\x12\x1b\n\x17\x46ORBID_ACCOUNT_TRANSFER\x10\x08\x12\x15\n\x11NPS_POPUP_OPT_OUT\x10\t\x12\x15\n\x11SHOW_USER_ONBOARD\x10\n\x12\x15\n\x11\x46ORBID_KEY_TYPE_1\x10\x0b\x12\x10\n\x0cKEEPER_DRIVE\x10\x0c*E\n\x10UserUpdateStatus\x12\x12\n\x0eUSER_UPDATE_OK\x10\x00\x12\x1d\n\x19USER_UPDATE_ACCESS_DENIED\x10\x01*I\n\x0f\x41uditUserStatus\x12\x06\n\x02OK\x10\x00\x12\x11\n\rACCESS_DENIED\x10\x01\x12\x1b\n\x17NO_LONGER_IN_ENTERPRISE\x10\x02*3\n\x0cTeamUserType\x12\x08\n\x04USER\x10\x00\x12\t\n\x05\x41\x44MIN\x10\x01\x12\x0e\n\nADMIN_ONLY\x10\x02*x\n\rAppClientType\x12\x0c\n\x08NOT_USED\x10\x00\x12\x0b\n\x07GENERAL\x10\x01\x12%\n!DISCOVERY_AND_ROTATION_CONTROLLER\x10\x02\x12\x12\n\x0eKCM_CONTROLLER\x10\x03\x12\x11\n\rSELF_DESTRUCT\x10\x04*\x8f\x01\n\x1b\x44\x65leteEnterpriseUsersResult\x12\x0b\n\x07SUCCESS\x10\x00\x12\x1a\n\x16NOT_AN_ENTERPRISE_USER\x10\x01\x12\x16\n\x12\x43\x41NNOT_DELETE_SELF\x10\x02\x12$\n BRIDGE_CANNOT_DELETE_ACTIVE_USER\x10\x03\x12\t\n\x05\x45RROR\x10\x04*\x87\x01\n\x15\x43learSecurityDataType\x12\x1e\n\x1aRECALCULATE_SUMMARY_REPORT\x10\x00\x12\'\n#FORCE_CLIENT_CHECK_FOR_MISSING_DATA\x10\x01\x12%\n!FORCE_CLIENT_RESEND_SECURITY_DATA\x10\x02*J\n\x13ReserveDomainAction\x12\x10\n\x0c\x44OMAIN_TOKEN\x10\x00\x12\x0e\n\nDOMAIN_ADD\x10\x01\x12\x11\n\rDOMAIN_DELETE\x10\x02*s\n\x0eUserLockStatus\x12\x17\n\x13UNKNOWN_LOCK_STATUS\x10\x00\x12\n\n\x06LOCKED\x10\x01\x12\x0c\n\x08\x44ISABLED\x10\x02\x12\x0c\n\x08UNLOCKED\x10\x03\x12\x0b\n\x07\x44\x45LETED\x10\x04\x12\x13\n\x0f\x43\x41NT_BE_PENDING\x10\x05*\x80\x01\n\x1d\x45xternalCloudSecretsStoreType\x12\x16\n\x12UNKNOWN_STORE_TYPE\x10\x00\x12\x17\n\x13\x41WS_SECRETS_MANAGER\x10\x01\x12\x13\n\x0f\x41ZURE_KEY_VAULT\x10\x02\x12\x19\n\x15GOOGLE_SECRET_MANAGER\x10\x03\x42&\n\x18\x63om.keepersecurity.protoB\nEnterpriseb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x10\x65nterprise.proto\x12\nEnterprise\x1a\x0c\x66older.proto\"\x84\x01\n\x18\x45nterpriseKeyPairRequest\x12\x1b\n\x13\x65nterprisePublicKey\x18\x01 \x01(\x0c\x12%\n\x1d\x65ncryptedEnterprisePrivateKey\x18\x02 \x01(\x0c\x12$\n\x07keyType\x18\x03 \x01(\x0e\x32\x13.Enterprise.KeyType\"\'\n\x14GetTeamMemberRequest\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\"}\n\x0e\x45nterpriseUser\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\r\n\x05\x65mail\x18\x02 \x01(\t\x12\x1a\n\x12\x65nterpriseUsername\x18\x03 \x01(\t\x12\x14\n\x0cisShareAdmin\x18\x04 \x01(\x08\x12\x10\n\x08username\x18\x05 \x01(\t\"K\n\x15GetTeamMemberResponse\x12\x32\n\x0e\x65nterpriseUser\x18\x01 \x03(\x0b\x32\x1a.Enterprise.EnterpriseUser\"-\n\x11\x45nterpriseUserIds\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x03(\x03\"B\n\x19\x45nterprisePersonalAccount\x12\r\n\x05\x65mail\x18\x01 \x01(\t\x12\x16\n\x0eOBSOLETE_FIELD\x18\x02 \x01(\x0c\"S\n\x17\x45ncryptedTeamKeyRequest\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12\x18\n\x10\x65ncryptedTeamKey\x18\x02 \x01(\x0c\x12\r\n\x05\x66orce\x18\x03 \x01(\x08\"+\n\x0fReEncryptedData\x12\n\n\x02id\x18\x01 \x01(\x03\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\t\"?\n\x12ReEncryptedRoleKey\x12\x0f\n\x07role_id\x18\x01 \x01(\x03\x12\x18\n\x10\x65ncryptedRoleKey\x18\x02 \x01(\x0c\"P\n\x16ReEncryptedUserDataKey\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x1c\n\x14userEncryptedDataKey\x18\x02 \x01(\x0c\"\xd8\x02\n\x1bNodeToManagedCompanyRequest\x12\x11\n\tcompanyId\x18\x01 \x01(\x05\x12*\n\x05nodes\x18\x02 \x03(\x0b\x32\x1b.Enterprise.ReEncryptedData\x12*\n\x05roles\x18\x03 \x03(\x0b\x32\x1b.Enterprise.ReEncryptedData\x12*\n\x05users\x18\x04 \x03(\x0b\x32\x1b.Enterprise.ReEncryptedData\x12\x30\n\x08roleKeys\x18\x05 \x03(\x0b\x32\x1e.Enterprise.ReEncryptedRoleKey\x12\x35\n\x08teamKeys\x18\x06 \x03(\x0b\x32#.Enterprise.EncryptedTeamKeyRequest\x12\x39\n\rusersDataKeys\x18\x07 \x03(\x0b\x32\".Enterprise.ReEncryptedUserDataKey\",\n\x08RoleTeam\x12\x0f\n\x07role_id\x18\x01 \x01(\x03\x12\x0f\n\x07teamUid\x18\x02 \x01(\x0c\"4\n\tRoleTeams\x12\'\n\trole_team\x18\x01 \x03(\x0b\x32\x14.Enterprise.RoleTeam\"/\n\x0bTeamsByRole\x12\x0f\n\x07role_id\x18\x01 \x01(\x03\x12\x0f\n\x07teamUid\x18\x02 \x03(\x0c\"<\n\x12ManagedNodesByRole\x12\x0f\n\x07role_id\x18\x01 \x01(\x03\x12\x15\n\rmanagedNodeId\x18\x02 \x03(\x03\"\x82\x01\n\x0fRoleUserAddKeys\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x13\n\x07treeKey\x18\x02 \x01(\tB\x02\x18\x01\x12\x14\n\x0croleAdminKey\x18\x03 \x01(\t\x12*\n\x0ctypedTreeKey\x18\x04 \x01(\x0b\x32\x14.Enterprise.TypedKey\"T\n\x0bRoleUserAdd\x12\x0f\n\x07role_id\x18\x01 \x01(\x03\x12\x34\n\x0froleUserAddKeys\x18\x02 \x03(\x0b\x32\x1b.Enterprise.RoleUserAddKeys\"D\n\x13RoleUsersAddRequest\x12-\n\x0croleUserAdds\x18\x01 \x03(\x0b\x32\x17.Enterprise.RoleUserAdd\"\x80\x01\n\x11RoleUserAddResult\x12\x0e\n\x06roleId\x18\x01 \x01(\x03\x12\x18\n\x10\x65nterpriseUserId\x18\x02 \x01(\x03\x12\x30\n\x06status\x18\x03 \x01(\x0e\x32 .Enterprise.RoleUserModifyStatus\x12\x0f\n\x07message\x18\x04 \x01(\t\"F\n\x14RoleUsersAddResponse\x12.\n\x07results\x18\x01 \x03(\x0b\x32\x1d.Enterprise.RoleUserAddResult\"<\n\x0eRoleUserRemove\x12\x0f\n\x07role_id\x18\x01 \x01(\x03\x12\x19\n\x11\x65nterpriseUserIds\x18\x02 \x03(\x03\"M\n\x16RoleUsersRemoveRequest\x12\x33\n\x0froleUserRemoves\x18\x01 \x03(\x0b\x32\x1a.Enterprise.RoleUserRemove\"\x83\x01\n\x14RoleUserRemoveResult\x12\x0e\n\x06roleId\x18\x01 \x01(\x03\x12\x18\n\x10\x65nterpriseUserId\x18\x02 \x01(\x03\x12\x30\n\x06status\x18\x03 \x01(\x0e\x32 .Enterprise.RoleUserModifyStatus\x12\x0f\n\x07message\x18\x04 \x01(\t\"L\n\x17RoleUsersRemoveResponse\x12\x31\n\x07results\x18\x01 \x03(\x0b\x32 .Enterprise.RoleUserRemoveResult\"\xa0\x04\n\x16\x45nterpriseRegistration\x12\x18\n\x10\x65ncryptedTreeKey\x18\x01 \x01(\x0c\x12\x16\n\x0e\x65nterpriseName\x18\x02 \x01(\t\x12\x14\n\x0crootNodeData\x18\x03 \x01(\x0c\x12\x15\n\radminUserData\x18\x04 \x01(\x0c\x12\x11\n\tadminName\x18\x05 \x01(\t\x12\x10\n\x08roleData\x18\x06 \x01(\x0c\x12\x38\n\nrsaKeyPair\x18\x07 \x01(\x0b\x32$.Enterprise.EnterpriseKeyPairRequest\x12\x13\n\x0bnumberSeats\x18\x08 \x01(\x05\x12\x32\n\x0e\x65nterpriseType\x18\t \x01(\x0e\x32\x1a.Enterprise.EnterpriseType\x12\x15\n\rrolePublicKey\x18\n \x01(\x0c\x12*\n\"rolePrivateKeyEncryptedWithRoleKey\x18\x0b \x01(\x0c\x12#\n\x1broleKeyEncryptedWithTreeKey\x18\x0c \x01(\x0c\x12\x38\n\neccKeyPair\x18\r \x01(\x0b\x32$.Enterprise.EnterpriseKeyPairRequest\x12\x18\n\x10\x61llUsersRoleData\x18\x0e \x01(\x0c\x12)\n!roleKeyEncryptedWithUserPublicKey\x18\x0f \x01(\x0c\x12\x18\n\x10\x61pproverRoleData\x18\x10 \x01(\x0c\"H\n\x1a\x44omainPasswordRulesRequest\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x18\n\x10verificationCode\x18\x02 \x01(\t\"\\\n\x19\x44omainPasswordRulesFields\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\x0f\n\x07minimum\x18\x02 \x01(\x05\x12\x0f\n\x07maximum\x18\x03 \x01(\x05\x12\x0f\n\x07\x61llowed\x18\x04 \x01(\x08\"E\n\x10LoginToMcRequest\x12\x16\n\x0emcEnterpriseId\x18\x01 \x01(\x05\x12\x19\n\x11messageSessionUid\x18\x02 \x01(\x0c\"w\n\x11LoginToMcResponse\x12\x1d\n\x15\x65ncryptedSessionToken\x18\x01 \x01(\x0c\x12\x18\n\x10\x65ncryptedTreeKey\x18\x02 \x01(\t\x12\x11\n\tkeyTypeId\x18\x03 \x01(\x05\x12\x16\n\x0e\x66orbidKeyType2\x18\x04 \x01(\x08\"g\n\x1b\x44omainPasswordRulesResponse\x12H\n\x19\x64omainPasswordRulesFields\x18\x01 \x03(\x0b\x32%.Enterprise.DomainPasswordRulesFields\"\x88\x01\n\x18\x41pproveUserDeviceRequest\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x1c\n\x14\x65ncryptedDeviceToken\x18\x02 \x01(\x0c\x12\x1e\n\x16\x65ncryptedDeviceDataKey\x18\x03 \x01(\x0c\x12\x14\n\x0c\x64\x65nyApproval\x18\x04 \x01(\x08\"t\n\x19\x41pproveUserDeviceResponse\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x1c\n\x14\x65ncryptedDeviceToken\x18\x02 \x01(\x0c\x12\x0e\n\x06\x66\x61iled\x18\x03 \x01(\x08\x12\x0f\n\x07message\x18\x04 \x01(\t\"Y\n\x19\x41pproveUserDevicesRequest\x12<\n\x0e\x64\x65viceRequests\x18\x01 \x03(\x0b\x32$.Enterprise.ApproveUserDeviceRequest\"\\\n\x1a\x41pproveUserDevicesResponse\x12>\n\x0f\x64\x65viceResponses\x18\x01 \x03(\x0b\x32%.Enterprise.ApproveUserDeviceResponse\"\x87\x01\n\x15\x45nterpriseUserDataKey\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x1c\n\x14userEncryptedDataKey\x18\x02 \x01(\x0c\x12\x11\n\tkeyTypeId\x18\x03 \x01(\x05\x12\x0f\n\x07roleKey\x18\x04 \x01(\x0c\x12\x12\n\nprivateKey\x18\x05 \x01(\x0c\"I\n\x16\x45nterpriseUserDataKeys\x12/\n\x04keys\x18\x01 \x03(\x0b\x32!.Enterprise.EnterpriseUserDataKey\"g\n\x1a\x45nterpriseUserDataKeyLight\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x1c\n\x14userEncryptedDataKey\x18\x02 \x01(\x0c\x12\x11\n\tkeyTypeId\x18\x03 \x01(\x05\"d\n\x1c\x45nterpriseUserDataKeysByNode\x12\x0e\n\x06nodeId\x18\x01 \x01(\x03\x12\x34\n\x04keys\x18\x02 \x03(\x0b\x32&.Enterprise.EnterpriseUserDataKeyLight\"^\n$EnterpriseUserDataKeysByNodeResponse\x12\x36\n\x04keys\x18\x01 \x03(\x0b\x32(.Enterprise.EnterpriseUserDataKeysByNode\"2\n\x15\x45nterpriseDataRequest\x12\x19\n\x11\x63ontinuationToken\x18\x01 \x01(\x0c\"0\n\x13SpecialProvisioning\x12\x0b\n\x03url\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\"\x84\x02\n\x11GeneralDataEntity\x12\x16\n\x0e\x65nterpriseName\x18\x01 \x01(\t\x12\x1a\n\x12restrictVisibility\x18\x02 \x01(\x08\x12<\n\x13specialProvisioning\x18\x04 \x01(\x0b\x32\x1f.Enterprise.SpecialProvisioning\x12\x30\n\ruserPrivilege\x18\x07 \x01(\x0b\x32\x19.Enterprise.UserPrivilege\x12\x13\n\x0b\x64istributor\x18\x08 \x01(\x08\x12\x1d\n\x15\x66orbidAccountTransfer\x18\t \x01(\x08\x12\x17\n\x0fshowUserOnboard\x18\n \x01(\x08\"\xfd\x01\n\x04Node\x12\x0e\n\x06nodeId\x18\x01 \x01(\x03\x12\x10\n\x08parentId\x18\x02 \x01(\x03\x12\x10\n\x08\x62ridgeId\x18\x03 \x01(\x03\x12\x0e\n\x06scimId\x18\x04 \x01(\x03\x12\x11\n\tlicenseId\x18\x05 \x01(\x03\x12\x15\n\rencryptedData\x18\x06 \x01(\t\x12\x12\n\nduoEnabled\x18\x07 \x01(\x08\x12\x12\n\nrsaEnabled\x18\x08 \x01(\x08\x12 \n\x14ssoServiceProviderId\x18\t \x01(\x03\x42\x02\x18\x01\x12\x1a\n\x12restrictVisibility\x18\n \x01(\x08\x12!\n\x15ssoServiceProviderIds\x18\x0b \x03(\x03\x42\x02\x10\x01\"\x8e\x01\n\x04Role\x12\x0e\n\x06roleId\x18\x01 \x01(\x03\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rencryptedData\x18\x03 \x01(\t\x12\x0f\n\x07keyType\x18\x04 \x01(\t\x12\x14\n\x0cvisibleBelow\x18\x05 \x01(\x08\x12\x16\n\x0enewUserInherit\x18\x06 \x01(\x08\x12\x10\n\x08roleType\x18\x07 \x01(\t\"\xb8\x02\n\x04User\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rencryptedData\x18\x03 \x01(\t\x12\x0f\n\x07keyType\x18\x04 \x01(\t\x12\x10\n\x08username\x18\x05 \x01(\t\x12\x0e\n\x06status\x18\x06 \x01(\t\x12\x0c\n\x04lock\x18\x07 \x01(\x05\x12\x0e\n\x06userId\x18\x08 \x01(\x05\x12\x1e\n\x16\x61\x63\x63ountShareExpiration\x18\t \x01(\x03\x12\x10\n\x08\x66ullName\x18\n \x01(\t\x12\x10\n\x08jobTitle\x18\x0b \x01(\t\x12\x12\n\ntfaEnabled\x18\x0c \x01(\x08\x12\x46\n\x18transferAcceptanceStatus\x18\r \x01(\x0e\x32$.Enterprise.TransferAcceptanceStatus\"7\n\tUserAlias\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x10\n\x08username\x18\x02 \x01(\t\"\xac\x01\n\x18\x43omplianceReportMetaData\x12\x11\n\treportUid\x18\x01 \x01(\x0c\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x12\n\nreportName\x18\x03 \x01(\t\x12\x15\n\rdateGenerated\x18\x04 \x01(\x03\x12\x11\n\trunByName\x18\x05 \x01(\t\x12\x16\n\x0enumberOfOwners\x18\x07 \x01(\x05\x12\x17\n\x0fnumberOfRecords\x18\x08 \x01(\x05\"S\n\x0bManagedNode\x12\x0e\n\x06roleId\x18\x01 \x01(\x03\x12\x15\n\rmanagedNodeId\x18\x02 \x01(\x03\x12\x1d\n\x15\x63\x61scadeNodeManagement\x18\x03 \x01(\x08\"T\n\x0fUserManagedNode\x12\x0e\n\x06nodeId\x18\x01 \x01(\x03\x12\x1d\n\x15\x63\x61scadeNodeManagement\x18\x02 \x01(\x08\x12\x12\n\nprivileges\x18\x03 \x03(\t\"w\n\rUserPrivilege\x12\x35\n\x10userManagedNodes\x18\x01 \x03(\x0b\x32\x1b.Enterprise.UserManagedNode\x12\x18\n\x10\x65nterpriseUserId\x18\x02 \x01(\x03\x12\x15\n\rencryptedData\x18\x03 \x01(\t\"4\n\x08RoleUser\x12\x0e\n\x06roleId\x18\x01 \x01(\x03\x12\x18\n\x10\x65nterpriseUserId\x18\x02 \x01(\x03\"M\n\rRolePrivilege\x12\x15\n\rmanagedNodeId\x18\x01 \x01(\x03\x12\x0e\n\x06roleId\x18\x02 \x01(\x03\x12\x15\n\rprivilegeType\x18\x03 \x01(\t\"T\n\x17PrivilegesByManagedNode\x12\x15\n\rmanagedNodeId\x18\x01 \x01(\x03\x12\x0e\n\x06roleId\x18\x02 \x01(\x03\x12\x12\n\nprivileges\x18\x03 \x03(\t\"I\n\x0fRoleEnforcement\x12\x0e\n\x06roleId\x18\x01 \x01(\x03\x12\x17\n\x0f\x65nforcementType\x18\x02 \x01(\t\x12\r\n\x05value\x18\x03 \x01(\t\"\xa9\x01\n\x04Team\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0e\n\x06nodeId\x18\x03 \x01(\x03\x12\x14\n\x0crestrictEdit\x18\x04 \x01(\x08\x12\x15\n\rrestrictShare\x18\x05 \x01(\x08\x12\x14\n\x0crestrictView\x18\x06 \x01(\x08\x12\x15\n\rencryptedData\x18\x07 \x01(\t\x12\x18\n\x10\x65ncryptedTeamKey\x18\x08 \x01(\t\"G\n\x08TeamUser\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12\x18\n\x10\x65nterpriseUserId\x18\x02 \x01(\x03\x12\x10\n\x08userType\x18\x03 \x01(\t\"K\n\x1aGetDistributorInfoResponse\x12-\n\x0c\x64istributors\x18\x01 \x03(\x0b\x32\x17.Enterprise.Distributor\"B\n\x0b\x44istributor\x12\x0c\n\x04name\x18\x01 \x01(\t\x12%\n\x08mspInfos\x18\x02 \x03(\x0b\x32\x13.Enterprise.MspInfo\"\x9d\x02\n\x07MspInfo\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x05\x12\x16\n\x0e\x65nterpriseName\x18\x02 \x01(\t\x12\x19\n\x11\x61llocatedLicenses\x18\x03 \x01(\x05\x12\x19\n\x11\x61llowedMcProducts\x18\x04 \x03(\t\x12\x15\n\rallowedAddOns\x18\x05 \x03(\t\x12\x17\n\x0fmaxFilePlanType\x18\x06 \x01(\t\x12\x34\n\x10managedCompanies\x18\x07 \x03(\x0b\x32\x1a.Enterprise.ManagedCompany\x12\x1e\n\x16\x61llowUnlimitedLicenses\x18\x08 \x01(\x08\x12(\n\x06\x61\x64\x64Ons\x18\t \x03(\x0b\x32\x18.Enterprise.LicenseAddOn\"\xa8\x02\n\x0eManagedCompany\x12\x16\n\x0emcEnterpriseId\x18\x01 \x01(\x05\x12\x18\n\x10mcEnterpriseName\x18\x02 \x01(\t\x12\x11\n\tmspNodeId\x18\x03 \x01(\x03\x12\x15\n\rnumberOfSeats\x18\x04 \x01(\x05\x12\x15\n\rnumberOfUsers\x18\x05 \x01(\x05\x12\x11\n\tproductId\x18\x06 \x01(\t\x12\x11\n\tisExpired\x18\x07 \x01(\x08\x12\x0f\n\x07treeKey\x18\x08 \x01(\t\x12\x15\n\rtree_key_role\x18\t \x01(\x03\x12\x14\n\x0c\x66ilePlanType\x18\n \x01(\t\x12(\n\x06\x61\x64\x64Ons\x18\x0b \x03(\x0b\x32\x18.Enterprise.LicenseAddOn\x12\x15\n\rtreeKeyTypeId\x18\x0c \x01(\x05\"R\n\x07MSPPool\x12\x11\n\tproductId\x18\x01 \x01(\t\x12\r\n\x05seats\x18\x02 \x01(\x05\x12\x16\n\x0e\x61vailableSeats\x18\x03 \x01(\x05\x12\r\n\x05stash\x18\x04 \x01(\x05\":\n\nMSPContact\x12\x14\n\x0c\x65nterpriseId\x18\x01 \x01(\x05\x12\x16\n\x0e\x65nterpriseName\x18\x02 \x01(\t\"\x84\x02\n\x0cLicenseAddOn\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x02 \x01(\x08\x12\x0f\n\x07isTrial\x18\x03 \x01(\x08\x12\x12\n\nexpiration\x18\x04 \x01(\x03\x12\x0f\n\x07\x63reated\x18\x05 \x01(\x03\x12\r\n\x05seats\x18\x06 \x01(\x05\x12\x16\n\x0e\x61\x63tivationTime\x18\x07 \x01(\x03\x12\x19\n\x11includedInProduct\x18\x08 \x01(\x08\x12\x14\n\x0c\x61piCallCount\x18\t \x01(\x05\x12\x17\n\x0ftierDescription\x18\n \x01(\t\x12\x16\n\x0eseatsAllocated\x18\x0b \x01(\x05\x12\x16\n\x0enhiTierAddOnId\x18\x0c \x01(\x05\"s\n\tMCDefault\x12\x11\n\tmcProduct\x18\x01 \x01(\t\x12\x0e\n\x06\x61\x64\x64Ons\x18\x02 \x03(\t\x12\x14\n\x0c\x66ilePlanType\x18\x03 \x01(\t\x12\x13\n\x0bmaxLicenses\x18\x04 \x01(\x05\x12\x18\n\x10\x66ixedMaxLicenses\x18\x05 \x01(\x08\"\xd2\x01\n\nMSPPermits\x12\x12\n\nrestricted\x18\x01 \x01(\x08\x12\x1a\n\x12maxAllowedLicenses\x18\x02 \x01(\x05\x12\x19\n\x11\x61llowedMcProducts\x18\x03 \x03(\t\x12\x15\n\rallowedAddOns\x18\x04 \x03(\t\x12\x17\n\x0fmaxFilePlanType\x18\x05 \x01(\t\x12\x1e\n\x16\x61llowUnlimitedLicenses\x18\x06 \x01(\x08\x12)\n\nmcDefaults\x18\x07 \x03(\x0b\x32\x15.Enterprise.MCDefault\"\xa0\x04\n\x07License\x12\x0c\n\x04paid\x18\x01 \x01(\x08\x12\x15\n\rnumberOfSeats\x18\x02 \x01(\x05\x12\x12\n\nexpiration\x18\x03 \x01(\x03\x12\x14\n\x0clicenseKeyId\x18\x04 \x01(\x05\x12\x15\n\rproductTypeId\x18\x05 \x01(\x05\x12\x0c\n\x04name\x18\x06 \x01(\t\x12\x1b\n\x13\x65nterpriseLicenseId\x18\x07 \x01(\x03\x12\x16\n\x0eseatsAllocated\x18\x08 \x01(\x05\x12\x14\n\x0cseatsPending\x18\t \x01(\x05\x12\x0c\n\x04tier\x18\n \x01(\x05\x12\x16\n\x0e\x66ilePlanTypeId\x18\x0b \x01(\x05\x12\x10\n\x08maxBytes\x18\x0c \x01(\x03\x12\x19\n\x11storageExpiration\x18\r \x01(\x03\x12\x15\n\rlicenseStatus\x18\x0e \x01(\t\x12$\n\x07mspPool\x18\x0f \x03(\x0b\x32\x13.Enterprise.MSPPool\x12)\n\tmanagedBy\x18\x10 \x01(\x0b\x32\x16.Enterprise.MSPContact\x12(\n\x06\x61\x64\x64Ons\x18\x11 \x03(\x0b\x32\x18.Enterprise.LicenseAddOn\x12\x17\n\x0fnextBillingDate\x18\x12 \x01(\x03\x12\x17\n\x0fhasMSPLegacyLog\x18\x13 \x01(\x08\x12*\n\nmspPermits\x18\x14 \x01(\x0b\x32\x16.Enterprise.MSPPermits\x12\x13\n\x0b\x64istributor\x18\x15 \x01(\x08\"n\n\x06\x42ridge\x12\x10\n\x08\x62ridgeId\x18\x01 \x01(\x03\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x18\n\x10wanIpEnforcement\x18\x03 \x01(\t\x12\x18\n\x10lanIpEnforcement\x18\x04 \x01(\t\x12\x0e\n\x06status\x18\x05 \x01(\t\"t\n\x04Scim\x12\x0e\n\x06scimId\x18\x01 \x01(\x03\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x0e\n\x06status\x18\x03 \x01(\t\x12\x12\n\nlastSynced\x18\x04 \x01(\x03\x12\x12\n\nrolePrefix\x18\x05 \x01(\t\x12\x14\n\x0cuniqueGroups\x18\x06 \x01(\x08\"L\n\x0e\x45mailProvision\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x0e\n\x06\x64omain\x18\x03 \x01(\t\x12\x0e\n\x06method\x18\x04 \x01(\t\"R\n\nQueuedTeam\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0e\n\x06nodeId\x18\x03 \x01(\x03\x12\x15\n\rencryptedData\x18\x04 \x01(\t\"0\n\x0eQueuedTeamUser\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12\r\n\x05users\x18\x02 \x03(\x03\"\xa4\x01\n\x0eTeamsAddResult\x12\x34\n\x11successfulTeamAdd\x18\x01 \x03(\x0b\x32\x19.Enterprise.TeamAddResult\x12\x36\n\x13unsuccessfulTeamAdd\x18\x02 \x03(\x0b\x32\x19.Enterprise.TeamAddResult\x12\x0e\n\x06result\x18\x03 \x01(\t\x12\x14\n\x0c\x65rrorMessage\x18\x04 \x01(\t\"U\n\rTeamAddResult\x12\x1e\n\x04team\x18\x01 \x01(\x0b\x32\x10.Enterprise.Team\x12\x0e\n\x06result\x18\x02 \x01(\t\x12\x14\n\x0c\x65rrorMessage\x18\x03 \x01(\t\"\x91\x01\n\nSsoService\x12\x1c\n\x14ssoServiceProviderId\x18\x01 \x01(\x03\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x0c\n\x04name\x18\x03 \x01(\t\x12\x0e\n\x06sp_url\x18\x04 \x01(\t\x12\x16\n\x0einviteNewUsers\x18\x05 \x01(\x08\x12\x0e\n\x06\x61\x63tive\x18\x06 \x01(\x08\x12\x0f\n\x07isCloud\x18\x07 \x01(\x08\"1\n\x10ReportFilterUser\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12\r\n\x05\x65mail\x18\x02 \x01(\t\"\x97\x02\n\x1d\x44\x65viceRequestForAdminApproval\x12\x10\n\x08\x64\x65viceId\x18\x01 \x01(\x03\x12\x18\n\x10\x65nterpriseUserId\x18\x02 \x01(\x03\x12\x1c\n\x14\x65ncryptedDeviceToken\x18\x03 \x01(\x0c\x12\x17\n\x0f\x64\x65vicePublicKey\x18\x04 \x01(\x0c\x12\x12\n\ndeviceName\x18\x05 \x01(\t\x12\x15\n\rclientVersion\x18\x06 \x01(\t\x12\x12\n\ndeviceType\x18\x07 \x01(\t\x12\x0c\n\x04\x64\x61te\x18\x08 \x01(\x03\x12\x11\n\tipAddress\x18\t \x01(\t\x12\x10\n\x08location\x18\n \x01(\t\x12\r\n\x05\x65mail\x18\x0b \x01(\t\x12\x12\n\naccountUid\x18\x0c \x01(\x0c\"`\n\x0e\x45nterpriseData\x12\x30\n\x06\x65ntity\x18\x01 \x01(\x0e\x32 .Enterprise.EnterpriseDataEntity\x12\x0e\n\x06\x64\x65lete\x18\x02 \x01(\x08\x12\x0c\n\x04\x64\x61ta\x18\x03 \x03(\x0c\"\xd0\x01\n\x16\x45nterpriseDataResponse\x12\x19\n\x11\x63ontinuationToken\x18\x01 \x01(\x0c\x12\x0f\n\x07hasMore\x18\x02 \x01(\x08\x12,\n\x0b\x63\x61\x63heStatus\x18\x03 \x01(\x0e\x32\x17.Enterprise.CacheStatus\x12(\n\x04\x64\x61ta\x18\x04 \x03(\x0b\x32\x1a.Enterprise.EnterpriseData\x12\x32\n\x0bgeneralData\x18\x05 \x01(\x0b\x32\x1d.Enterprise.GeneralDataEntity\"*\n\rBackupRequest\x12\x19\n\x11\x63ontinuationToken\x18\x01 \x01(\x0c\"\x98\x01\n\x0c\x42\x61\x63kupRecord\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\x12\x0b\n\x03key\x18\x03 \x01(\x0c\x12*\n\x07keyType\x18\x04 \x01(\x0e\x32\x19.Enterprise.BackupKeyType\x12\x0f\n\x07version\x18\x05 \x01(\x05\x12\x0c\n\x04\x64\x61ta\x18\x06 \x01(\x0c\x12\r\n\x05\x65xtra\x18\x07 \x01(\x0c\".\n\tBackupKey\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12\x11\n\tbackupKey\x18\x02 \x01(\x0c\"\x8d\x02\n\nBackupUser\x12\x0e\n\x06userId\x18\x01 \x01(\x05\x12\x10\n\x08userName\x18\x02 \x01(\t\x12\x0f\n\x07\x64\x61taKey\x18\x03 \x01(\x0c\x12\x36\n\x0b\x64\x61taKeyType\x18\x04 \x01(\x0e\x32!.Enterprise.BackupUserDataKeyType\x12\x12\n\nprivateKey\x18\x05 \x01(\x0c\x12\x0f\n\x07treeKey\x18\x06 \x01(\x0c\x12.\n\x0btreeKeyType\x18\x07 \x01(\x0e\x32\x19.Enterprise.BackupKeyType\x12)\n\nbackupKeys\x18\x08 \x03(\x0b\x32\x15.Enterprise.BackupKey\x12\x14\n\x0cprivateECKey\x18\t \x01(\x0c\"\x9e\x01\n\x0e\x42\x61\x63kupResponse\x12\x1f\n\x17\x65nterpriseEccPrivateKey\x18\x01 \x01(\x0c\x12%\n\x05users\x18\x02 \x03(\x0b\x32\x16.Enterprise.BackupUser\x12)\n\x07records\x18\x03 \x03(\x0b\x32\x18.Enterprise.BackupRecord\x12\x19\n\x11\x63ontinuationToken\x18\x04 \x01(\x0c\"e\n\nBackupFile\x12\x0c\n\x04user\x18\x01 \x01(\t\x12\x11\n\tbackupUid\x18\x02 \x01(\x0c\x12\x10\n\x08\x66ileName\x18\x03 \x01(\t\x12\x0f\n\x07\x63reated\x18\x04 \x01(\x03\x12\x13\n\x0b\x64ownloadUrl\x18\x05 \x01(\t\"8\n\x0f\x42\x61\x63kupsResponse\x12%\n\x05\x66iles\x18\x01 \x03(\x0b\x32\x16.Enterprise.BackupFile\".\n\x1cGetEnterpriseDataKeysRequest\x12\x0e\n\x06roleId\x18\x01 \x03(\x03\"\xff\x01\n\x1dGetEnterpriseDataKeysResponse\x12:\n\x12reEncryptedRoleKey\x18\x01 \x03(\x0b\x32\x1e.Enterprise.ReEncryptedRoleKey\x12$\n\x07roleKey\x18\x02 \x03(\x0b\x32\x13.Enterprise.RoleKey\x12\"\n\x06mspKey\x18\x03 \x01(\x0b\x32\x12.Enterprise.MspKey\x12\x32\n\x0e\x65nterpriseKeys\x18\x04 \x01(\x0b\x32\x1a.Enterprise.EnterpriseKeys\x12$\n\x07treeKey\x18\x05 \x01(\x0b\x32\x13.Enterprise.TreeKey\"^\n\x07RoleKey\x12\x0e\n\x06roleId\x18\x01 \x01(\x03\x12\x14\n\x0c\x65ncryptedKey\x18\x02 \x01(\t\x12-\n\x07keyType\x18\x03 \x01(\x0e\x32\x1c.Enterprise.EncryptedKeyType\"d\n\x06MspKey\x12\x1b\n\x13\x65ncryptedMspTreeKey\x18\x01 \x01(\t\x12=\n\x17\x65ncryptedMspTreeKeyType\x18\x02 \x01(\x0e\x32\x1c.Enterprise.EncryptedKeyType\"|\n\x0e\x45nterpriseKeys\x12\x14\n\x0crsaPublicKey\x18\x01 \x01(\x0c\x12\x1e\n\x16rsaEncryptedPrivateKey\x18\x02 \x01(\x0c\x12\x14\n\x0c\x65\x63\x63PublicKey\x18\x03 \x01(\x0c\x12\x1e\n\x16\x65\x63\x63\x45ncryptedPrivateKey\x18\x04 \x01(\x0c\"H\n\x07TreeKey\x12\x0f\n\x07treeKey\x18\x01 \x01(\t\x12,\n\tkeyTypeId\x18\x02 \x01(\x0e\x32\x19.Enterprise.BackupKeyType\"E\n\x14SharedRecordResponse\x12-\n\x06\x65vents\x18\x01 \x03(\x0b\x32\x1d.Enterprise.SharedRecordEvent\"p\n\x11SharedRecordEvent\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x10\n\x08userName\x18\x02 \x01(\t\x12\x0f\n\x07\x63\x61nEdit\x18\x03 \x01(\x08\x12\x12\n\ncanReshare\x18\x04 \x01(\x08\x12\x11\n\tshareFrom\x18\x05 \x01(\x05\".\n\x1cSetRestrictVisibilityRequest\x12\x0e\n\x06nodeId\x18\x01 \x01(\x03\"\xd0\x01\n\x0eUserAddRequest\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x15\n\rencryptedData\x18\x03 \x01(\x0c\x12-\n\x07keyType\x18\x04 \x01(\x0e\x32\x1c.Enterprise.EncryptedKeyType\x12\x10\n\x08\x66ullName\x18\x05 \x01(\t\x12\x10\n\x08jobTitle\x18\x06 \x01(\t\x12\r\n\x05\x65mail\x18\x07 \x01(\t\x12\x1b\n\x13suppressEmailInvite\x18\x08 \x01(\x08\":\n\x11UserUpdateRequest\x12%\n\x05users\x18\x01 \x03(\x0b\x32\x16.Enterprise.UserUpdate\"\xe7\x01\n\nUserUpdate\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x0e\n\x06nodeId\x18\x02 \x01(\x03\x12\x19\n\rencryptedData\x18\x03 \x01(\x0c\x42\x02\x18\x01\x12-\n\x07keyType\x18\x04 \x01(\x0e\x32\x1c.Enterprise.EncryptedKeyType\x12\x10\n\x08\x66ullName\x18\x05 \x01(\t\x12\x10\n\x08jobTitle\x18\x06 \x01(\t\x12\r\n\x05\x65mail\x18\x07 \x01(\t\x12\x15\n\rinviteeLocale\x18\x08 \x01(\t\x12\x1b\n\x13\x65ncryptedDataString\x18\t \x01(\t\"A\n\x12UserUpdateResponse\x12+\n\x05users\x18\x01 \x03(\x0b\x32\x1c.Enterprise.UserUpdateResult\"\x88\x01\n\x10UserUpdateResult\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12,\n\x06status\x18\x02 \x01(\x0e\x32\x1c.Enterprise.UserUpdateStatus\x12\x14\n\x0c\x65rrorMessage\x18\x03 \x01(\t\x12\x16\n\x0e\x61\x64\x64itionalInfo\x18\x04 \x01(\t\"J\n\x1d\x43omplianceRecordOwnersRequest\x12\x0f\n\x07nodeIds\x18\x01 \x03(\x03\x12\x18\n\x10includeNonShared\x18\x02 \x01(\x08\"O\n\x1e\x43omplianceRecordOwnersResponse\x12-\n\x0crecordOwners\x18\x01 \x03(\x0b\x32\x17.Enterprise.RecordOwner\"7\n\x0bRecordOwner\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x0e\n\x06shared\x18\x02 \x01(\x08\"\xa6\x01\n PreliminaryComplianceDataRequest\x12\x19\n\x11\x65nterpriseUserIds\x18\x01 \x03(\x03\x12\x18\n\x10includeNonShared\x18\x02 \x01(\x08\x12\x19\n\x11\x63ontinuationToken\x18\x03 \x01(\x0c\x12\x32\n*includeTotalMatchingRecordsInFirstResponse\x18\x04 \x01(\x08\"\x9f\x01\n!PreliminaryComplianceDataResponse\x12\x30\n\rauditUserData\x18\x01 \x03(\x0b\x32\x19.Enterprise.AuditUserData\x12\x19\n\x11\x63ontinuationToken\x18\x02 \x01(\x0c\x12\x0f\n\x07hasMore\x18\x03 \x01(\x08\x12\x1c\n\x14totalMatchingRecords\x18\x04 \x01(\x05\"b\n\x0f\x41uditUserRecord\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x15\n\rencryptedData\x18\x02 \x01(\x0c\x12\x0e\n\x06shared\x18\x03 \x01(\x08\x12\x15\n\risDriveRecord\x18\x04 \x01(\x08\"\x8d\x01\n\rAuditUserData\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x35\n\x10\x61uditUserRecords\x18\x02 \x03(\x0b\x32\x1b.Enterprise.AuditUserRecord\x12+\n\x06status\x18\x03 \x01(\x0e\x32\x1b.Enterprise.AuditUserStatus\"\x7f\n\x17\x43omplianceReportFilters\x12\x14\n\x0crecordTitles\x18\x01 \x03(\t\x12\x12\n\nrecordUids\x18\x02 \x03(\x0c\x12\x11\n\tjobTitles\x18\x03 \x03(\x03\x12\x0c\n\x04urls\x18\x04 \x03(\t\x12\x19\n\x11\x65nterpriseUserIds\x18\x05 \x03(\x03\"\x7f\n\x17\x43omplianceReportRequest\x12<\n\x13\x63omplianceReportRun\x18\x01 \x01(\x0b\x32\x1f.Enterprise.ComplianceReportRun\x12\x12\n\nreportName\x18\x02 \x01(\t\x12\x12\n\nsaveReport\x18\x03 \x01(\x08\"\x85\x01\n\x13\x43omplianceReportRun\x12N\n\x17reportCriteriaAndFilter\x18\x01 \x01(\x0b\x32-.Enterprise.ComplianceReportCriteriaAndFilter\x12\r\n\x05users\x18\x02 \x03(\x03\x12\x0f\n\x07records\x18\x03 \x03(\x0c\"\xfc\x01\n!ComplianceReportCriteriaAndFilter\x12\x0e\n\x06nodeId\x18\x01 \x01(\x03\x12\x13\n\x0b\x63riteriaUid\x18\x02 \x01(\x0c\x12\x14\n\x0c\x63riteriaName\x18\x03 \x01(\t\x12\x36\n\x08\x63riteria\x18\x04 \x01(\x0b\x32$.Enterprise.ComplianceReportCriteria\x12\x33\n\x07\x66ilters\x18\x05 \x03(\x0b\x32\".Enterprise.ComplianceReportFilter\x12\x14\n\x0clastModified\x18\x06 \x01(\x03\x12\x19\n\x11nodeEncryptedData\x18\x07 \x01(\x0c\"b\n\x18\x43omplianceReportCriteria\x12\x11\n\tjobTitles\x18\x01 \x03(\t\x12\x19\n\x11\x65nterpriseUserIds\x18\x02 \x03(\x03\x12\x18\n\x10includeNonShared\x18\x03 \x01(\x08\"x\n\x16\x43omplianceReportFilter\x12\x14\n\x0crecordTitles\x18\x01 \x03(\t\x12\x12\n\nrecordUids\x18\x02 \x03(\x0c\x12\x11\n\tjobTitles\x18\x03 \x03(\t\x12\x0c\n\x04urls\x18\x04 \x03(\t\x12\x13\n\x0brecordTypes\x18\x05 \x03(\t\"\xa1\x05\n\x18\x43omplianceReportResponse\x12\x15\n\rdateGenerated\x18\x01 \x01(\x03\x12\x15\n\rrunByUserName\x18\x02 \x01(\t\x12\x12\n\nreportName\x18\x03 \x01(\t\x12\x11\n\treportUid\x18\x04 \x01(\x0c\x12<\n\x13\x63omplianceReportRun\x18\x05 \x01(\x0b\x32\x1f.Enterprise.ComplianceReportRun\x12-\n\x0cuserProfiles\x18\x06 \x03(\x0b\x32\x17.Enterprise.UserProfile\x12)\n\nauditTeams\x18\x07 \x03(\x0b\x32\x15.Enterprise.AuditTeam\x12-\n\x0c\x61uditRecords\x18\x08 \x03(\x0b\x32\x17.Enterprise.AuditRecord\x12+\n\x0buserRecords\x18\t \x03(\x0b\x32\x16.Enterprise.UserRecord\x12;\n\x13sharedFolderRecords\x18\n \x03(\x0b\x32\x1e.Enterprise.SharedFolderRecord\x12\x37\n\x11sharedFolderUsers\x18\x0b \x03(\x0b\x32\x1c.Enterprise.SharedFolderUser\x12\x37\n\x11sharedFolderTeams\x18\x0c \x03(\x0b\x32\x1c.Enterprise.SharedFolderTeam\x12\x31\n\x0e\x61uditTeamUsers\x18\r \x03(\x0b\x32\x19.Enterprise.AuditTeamUser\x12)\n\nauditRoles\x18\x0e \x03(\x0b\x32\x15.Enterprise.AuditRole\x12/\n\rlinkedRecords\x18\x0f \x03(\x0b\x32\x18.Enterprise.LinkedRecord\"\x98\x01\n\x0b\x41uditRecord\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x11\n\tauditData\x18\x02 \x01(\x0c\x12\x16\n\x0ehasAttachments\x18\x03 \x01(\x08\x12\x0f\n\x07inTrash\x18\x04 \x01(\x08\x12\x10\n\x08treeLeft\x18\x05 \x01(\x05\x12\x11\n\ttreeRight\x18\x06 \x01(\x05\x12\x15\n\risDriveRecord\x18\x07 \x01(\x08\"\x80\x02\n\tAuditRole\x12\x0e\n\x06roleId\x18\x01 \x01(\x03\x12\x15\n\rencryptedData\x18\x02 \x01(\x0c\x12&\n\x1erestrictShareOutsideEnterprise\x18\x03 \x01(\x08\x12\x18\n\x10restrictShareAll\x18\x04 \x01(\x08\x12\"\n\x1arestrictShareOfAttachments\x18\x05 \x01(\x08\x12)\n!restrictMaskPasswordsWhileEditing\x18\x06 \x01(\x08\x12;\n\x13roleNodeManagements\x18\x07 \x03(\x0b\x32\x1e.Enterprise.RoleNodeManagement\"^\n\x12RoleNodeManagement\x12\x10\n\x08treeLeft\x18\x01 \x01(\x05\x12\x11\n\ttreeRight\x18\x02 \x01(\x05\x12\x0f\n\x07\x63\x61scade\x18\x03 \x01(\x08\x12\x12\n\nprivileges\x18\x04 \x01(\x05\"k\n\x0bUserProfile\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x10\n\x08\x66ullName\x18\x02 \x01(\t\x12\x10\n\x08jobTitle\x18\x03 \x01(\t\x12\r\n\x05\x65mail\x18\x04 \x01(\t\x12\x0f\n\x07roleIds\x18\x05 \x03(\x03\"{\n\x10RecordPermission\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x18\n\x0epermissionBits\x18\x02 \x01(\x05H\x00\x12,\n\x05\x64rive\x18\x03 \x01(\x0b\x32\x1b.Enterprise.DrivePermissionH\x00\x42\x0c\n\npermission\"\xc7\x01\n\x0f\x44rivePermission\x12\r\n\x05owner\x18\x01 \x01(\x08\x12\x0e\n\x06\x64\x65nied\x18\x02 \x01(\x08\x12\x0f\n\x07\x63\x61nEdit\x18\x03 \x01(\x08\x12\x10\n\x08\x63\x61nShare\x18\x04 \x01(\x08\x12\x14\n\x0cisShareAdmin\x18\x05 \x01(\x08\x12&\n\naccessType\x18\x06 \x01(\x0e\x32\x12.Folder.AccessType\x12\x34\n\x11\x66olderPermissions\x18\x07 \x01(\x0b\x32\x19.Folder.FolderPermissions\"_\n\nUserRecord\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x37\n\x11recordPermissions\x18\x02 \x03(\x0b\x32\x1c.Enterprise.RecordPermission\"[\n\tAuditTeam\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12\x10\n\x08teamName\x18\x02 \x01(\t\x12\x14\n\x0crestrictEdit\x18\x03 \x01(\x08\x12\x15\n\rrestrictShare\x18\x04 \x01(\x08\";\n\rAuditTeamUser\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12\x19\n\x11\x65nterpriseUserIds\x18\x02 \x03(\x03\"\x9f\x01\n\x12SharedFolderRecord\x12\x17\n\x0fsharedFolderUid\x18\x01 \x01(\x0c\x12\x37\n\x11recordPermissions\x18\x02 \x03(\x0b\x32\x1c.Enterprise.RecordPermission\x12\x37\n\x11shareAdminRecords\x18\x03 \x03(\x0b\x32\x1c.Enterprise.ShareAdminRecord\"M\n\x10ShareAdminRecord\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x1f\n\x17recordPermissionIndexes\x18\x02 \x03(\x05\"F\n\x10SharedFolderUser\x12\x17\n\x0fsharedFolderUid\x18\x01 \x01(\x0c\x12\x19\n\x11\x65nterpriseUserIds\x18\x02 \x03(\x03\"=\n\x10SharedFolderTeam\x12\x17\n\x0fsharedFolderUid\x18\x01 \x01(\x0c\x12\x10\n\x08teamUids\x18\x02 \x03(\x0c\"/\n\x1aGetComplianceReportRequest\x12\x11\n\treportUid\x18\x01 \x01(\x0c\"2\n\x1bGetComplianceReportResponse\x12\x13\n\x0b\x64ownloadUrl\x18\x01 \x01(\t\"6\n\x1f\x43omplianceReportCriteriaRequest\x12\x13\n\x0b\x63riteriaUid\x18\x01 \x01(\x0c\";\n$SaveComplianceReportCriteriaResponse\x12\x13\n\x0b\x63riteriaUid\x18\x01 \x01(\x0c\"4\n\x0cLinkedRecord\x12\x10\n\x08ownerUid\x18\x01 \x01(\x0c\x12\x12\n\nrecordUids\x18\x02 \x03(\x0c\"W\n\x17GetSharingAdminsRequest\x12\x17\n\x0fsharedFolderUid\x18\x01 \x01(\x0c\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\x12\x10\n\x08username\x18\x03 \x01(\t\"\xe0\x01\n\x0eUserProfileExt\x12\r\n\x05\x65mail\x18\x01 \x01(\t\x12\x10\n\x08\x66ullName\x18\x02 \x01(\t\x12\x10\n\x08jobTitle\x18\x03 \x01(\t\x12\x14\n\x0cisMSPMCAdmin\x18\x04 \x01(\x08\x12\x18\n\x10isInSharedFolder\x18\x05 \x01(\x08\x12&\n\x1eisShareAdminForRequestedObject\x18\x06 \x01(\x08\x12(\n isShareAdminForSharedFolderOwner\x18\x07 \x01(\x08\x12\x19\n\x11hasAccessToObject\x18\x08 \x01(\x08\"O\n\x18GetSharingAdminsResponse\x12\x33\n\x0fuserProfileExts\x18\x01 \x03(\x0b\x32\x1a.Enterprise.UserProfileExt\"_\n\x1eTeamsEnterpriseUsersAddRequest\x12=\n\x05teams\x18\x01 \x03(\x0b\x32..Enterprise.TeamsEnterpriseUsersAddTeamRequest\"t\n\"TeamsEnterpriseUsersAddTeamRequest\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12=\n\x05users\x18\x02 \x03(\x0b\x32..Enterprise.TeamsEnterpriseUsersAddUserRequest\"\xab\x01\n\"TeamsEnterpriseUsersAddUserRequest\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12*\n\x08userType\x18\x02 \x01(\x0e\x32\x18.Enterprise.TeamUserType\x12\x13\n\x07teamKey\x18\x03 \x01(\tB\x02\x18\x01\x12*\n\x0ctypedTeamKey\x18\x04 \x01(\x0b\x32\x14.Enterprise.TypedKey\"F\n\x08TypedKey\x12\x0b\n\x03key\x18\x01 \x01(\x0c\x12-\n\x07keyType\x18\x02 \x01(\x0e\x32\x1c.Enterprise.EncryptedKeyType\"s\n\x1fTeamsEnterpriseUsersAddResponse\x12>\n\x05teams\x18\x01 \x03(\x0b\x32/.Enterprise.TeamsEnterpriseUsersAddTeamResponse\x12\x10\n\x08revision\x18\x02 \x01(\x03\"\xc4\x01\n#TeamsEnterpriseUsersAddTeamResponse\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12>\n\x05users\x18\x02 \x03(\x0b\x32/.Enterprise.TeamsEnterpriseUsersAddUserResponse\x12\x0f\n\x07success\x18\x03 \x01(\x08\x12\x0f\n\x07message\x18\x04 \x01(\t\x12\x12\n\nresultCode\x18\x05 \x01(\t\x12\x16\n\x0e\x61\x64\x64itionalInfo\x18\x06 \x01(\t\"\x9f\x01\n#TeamsEnterpriseUsersAddUserResponse\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x10\n\x08revision\x18\x02 \x01(\x03\x12\x0f\n\x07success\x18\x03 \x01(\x08\x12\x0f\n\x07message\x18\x04 \x01(\t\x12\x12\n\nresultCode\x18\x05 \x01(\t\x12\x16\n\x0e\x61\x64\x64itionalInfo\x18\x06 \x01(\t\"E\n\x18TeamEnterpriseUserRemove\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12\x18\n\x10\x65nterpriseUserId\x18\x02 \x01(\x03\"j\n TeamEnterpriseUserRemovesRequest\x12\x46\n\x18teamEnterpriseUserRemove\x18\x01 \x03(\x0b\x32$.Enterprise.TeamEnterpriseUserRemove\"{\n!TeamEnterpriseUserRemovesResponse\x12V\n teamEnterpriseUserRemoveResponse\x18\x01 \x03(\x0b\x32,.Enterprise.TeamEnterpriseUserRemoveResponse\"\xb8\x01\n TeamEnterpriseUserRemoveResponse\x12\x46\n\x18teamEnterpriseUserRemove\x18\x01 \x01(\x0b\x32$.Enterprise.TeamEnterpriseUserRemove\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x12\n\nresultCode\x18\x03 \x01(\t\x12\x0f\n\x07message\x18\x04 \x01(\t\x12\x16\n\x0e\x61\x64\x64itionalInfo\x18\x05 \x01(\t\"M\n\x0b\x44omainAlias\x12\x0e\n\x06\x64omain\x18\x01 \x01(\t\x12\r\n\x05\x61lias\x18\x02 \x01(\t\x12\x0e\n\x06status\x18\x03 \x01(\x05\x12\x0f\n\x07message\x18\x04 \x01(\t\"B\n\x12\x44omainAliasRequest\x12,\n\x0b\x64omainAlias\x18\x01 \x03(\x0b\x32\x17.Enterprise.DomainAlias\"C\n\x13\x44omainAliasResponse\x12,\n\x0b\x64omainAlias\x18\x01 \x03(\x0b\x32\x17.Enterprise.DomainAlias\"m\n\x1f\x45nterpriseUsersProvisionRequest\x12\x33\n\x05users\x18\x01 \x03(\x0b\x32$.Enterprise.EnterpriseUsersProvision\x12\x15\n\rclientVersion\x18\x02 \x01(\t\"\xb6\x03\n\x18\x45nterpriseUsersProvision\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x10\n\x08username\x18\x02 \x01(\t\x12\x0e\n\x06nodeId\x18\x03 \x01(\x03\x12\x15\n\rencryptedData\x18\x04 \x01(\t\x12-\n\x07keyType\x18\x05 \x01(\x0e\x32\x1c.Enterprise.EncryptedKeyType\x12\x10\n\x08\x66ullName\x18\x06 \x01(\t\x12\x10\n\x08jobTitle\x18\x07 \x01(\t\x12\x1e\n\x16\x65nterpriseUsersDataKey\x18\x08 \x01(\x0c\x12\x14\n\x0c\x61uthVerifier\x18\t \x01(\x0c\x12\x18\n\x10\x65ncryptionParams\x18\n \x01(\x0c\x12\x14\n\x0crsaPublicKey\x18\x0b \x01(\x0c\x12\x1e\n\x16rsaEncryptedPrivateKey\x18\x0c \x01(\x0c\x12\x14\n\x0c\x65\x63\x63PublicKey\x18\r \x01(\x0c\x12\x1e\n\x16\x65\x63\x63\x45ncryptedPrivateKey\x18\x0e \x01(\x0c\x12\x1c\n\x14\x65ncryptedDeviceToken\x18\x0f \x01(\x0c\x12\x1a\n\x12\x65ncryptedClientKey\x18\x10 \x01(\x0c\"_\n EnterpriseUsersProvisionResponse\x12;\n\x07results\x18\x01 \x03(\x0b\x32*.Enterprise.EnterpriseUsersProvisionResult\"q\n\x1e\x45nterpriseUsersProvisionResult\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x0c\n\x04\x63ode\x18\x02 \x01(\t\x12\x0f\n\x07message\x18\x03 \x01(\t\x12\x16\n\x0e\x61\x64\x64itionalInfo\x18\x04 \x01(\t\"a\n\x19\x45nterpriseUsersAddRequest\x12-\n\x05users\x18\x01 \x03(\x0b\x32\x1e.Enterprise.EnterpriseUsersAdd\x12\x15\n\rclientVersion\x18\x02 \x01(\t\"\x8c\x02\n\x12\x45nterpriseUsersAdd\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x10\n\x08username\x18\x02 \x01(\t\x12\x0e\n\x06nodeId\x18\x03 \x01(\x03\x12\x15\n\rencryptedData\x18\x04 \x01(\t\x12-\n\x07keyType\x18\x05 \x01(\x0e\x32\x1c.Enterprise.EncryptedKeyType\x12\x10\n\x08\x66ullName\x18\x06 \x01(\t\x12\x10\n\x08jobTitle\x18\x07 \x01(\t\x12\x1b\n\x13suppressEmailInvite\x18\x08 \x01(\x08\x12\x15\n\rinviteeLocale\x18\t \x01(\t\x12\x0c\n\x04move\x18\n \x01(\x08\x12\x0e\n\x06roleId\x18\x0b \x01(\x03\"\x9b\x01\n\x1a\x45nterpriseUsersAddResponse\x12\x35\n\x07results\x18\x01 \x03(\x0b\x32$.Enterprise.EnterpriseUsersAddResult\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x0c\n\x04\x63ode\x18\x03 \x01(\t\x12\x0f\n\x07message\x18\x04 \x01(\t\x12\x16\n\x0e\x61\x64\x64itionalInfo\x18\x05 \x01(\t\"\x96\x01\n\x18\x45nterpriseUsersAddResult\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x18\n\x10verificationCode\x18\x03 \x01(\t\x12\x0c\n\x04\x63ode\x18\x04 \x01(\t\x12\x0f\n\x07message\x18\x05 \x01(\t\x12\x16\n\x0e\x61\x64\x64itionalInfo\x18\x06 \x01(\t\"\xb9\x01\n\x17UpdateMSPPermitsRequest\x12\x17\n\x0fmspEnterpriseId\x18\x01 \x01(\x05\x12\x1a\n\x12maxAllowedLicenses\x18\x02 \x01(\x05\x12\x19\n\x11\x61llowedMcProducts\x18\x03 \x03(\t\x12\x15\n\rallowedAddOns\x18\x04 \x03(\t\x12\x17\n\x0fmaxFilePlanType\x18\x05 \x01(\t\x12\x1e\n\x16\x61llowUnlimitedLicenses\x18\x06 \x01(\x08\"9\n\x1c\x44\x65leteEnterpriseUsersRequest\x12\x19\n\x11\x65nterpriseUserIds\x18\x01 \x03(\x03\"o\n\x1a\x44\x65leteEnterpriseUserStatus\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12\x37\n\x06status\x18\x02 \x01(\x0e\x32\'.Enterprise.DeleteEnterpriseUsersResult\"]\n\x1d\x44\x65leteEnterpriseUsersResponse\x12<\n\x0c\x64\x65leteStatus\x18\x01 \x03(\x0b\x32&.Enterprise.DeleteEnterpriseUserStatus\"w\n\x18\x43learSecurityDataRequest\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x03(\x03\x12\x10\n\x08\x61llUsers\x18\x02 \x01(\x08\x12/\n\x04type\x18\x03 \x01(\x0e\x32!.Enterprise.ClearSecurityDataType\"%\n\x13ListDomainsResponse\x12\x0e\n\x06\x64omain\x18\x01 \x03(\t\"d\n\x14ReserveDomainRequest\x12<\n\x13reserveDomainAction\x18\x01 \x01(\x0e\x32\x1f.Enterprise.ReserveDomainAction\x12\x0e\n\x06\x64omain\x18\x02 \x01(\t\"&\n\x15ReserveDomainResponse\x12\r\n\x05token\x18\x01 \x01(\t\".\n\x0bRolesByTeam\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12\x0e\n\x06roleId\x18\x02 \x03(\x03\"\x8d\x01\n\x10LockUsersRequest\x12\x1d\n\x15lockEnterpriseUserIds\x18\x01 \x03(\x03\x12 \n\x18\x64isableEnterpriseUserIds\x18\x02 \x03(\x03\x12\x1f\n\x17unlockEnterpriseUserIds\x18\x03 \x03(\x03\x12\x17\n\x0f\x64\x65leteIfPending\x18\x04 \x01(\x08\"C\n\x11LockUsersResponse\x12.\n\x08response\x18\x01 \x03(\x0b\x32\x1c.Enterprise.LockUserResponse\"n\n\x10LockUserResponse\x12\x18\n\x10\x65nterpriseUserId\x18\x01 \x01(\x03\x12*\n\x06status\x18\x02 \x01(\x0e\x32\x1a.Enterprise.UserLockStatus\x12\x14\n\x0c\x65rrorMessage\x18\x03 \x01(\t*\x1b\n\x07KeyType\x12\x07\n\x03RSA\x10\x00\x12\x07\n\x03\x45\x43\x43\x10\x01*\xaf\x02\n\x14RoleUserModifyStatus\x12\x0f\n\x0bROLE_EXISTS\x10\x00\x12\x14\n\x10MISSING_TREE_KEY\x10\x01\x12\x14\n\x10MISSING_ROLE_KEY\x10\x02\x12\x1e\n\x1aINVALID_ENTERPRISE_USER_ID\x10\x03\x12\x1b\n\x17PENDING_ENTERPRISE_USER\x10\x04\x12\x13\n\x0fINVALID_NODE_ID\x10\x05\x12!\n\x1dMAY_NOT_REMOVE_SELF_FROM_ROLE\x10\x06\x12\x1c\n\x18MUST_HAVE_ONE_USER_ADMIN\x10\x07\x12\x13\n\x0fINVALID_ROLE_ID\x10\x08\x12\x1d\n\x19PAM_LICENSE_SEAT_EXCEEDED\x10\t\x12\x13\n\x0fWOULD_LOCK_SELF\x10\n*=\n\x0e\x45nterpriseType\x12\x17\n\x13\x45NTERPRISE_STANDARD\x10\x00\x12\x12\n\x0e\x45NTERPRISE_MSP\x10\x01*s\n\x18TransferAcceptanceStatus\x12\r\n\tUNDEFINED\x10\x00\x12\x10\n\x0cNOT_REQUIRED\x10\x01\x12\x10\n\x0cNOT_ACCEPTED\x10\x02\x12\x16\n\x12PARTIALLY_ACCEPTED\x10\x03\x12\x0c\n\x08\x41\x43\x43\x45PTED\x10\x04*\xe1\x03\n\x14\x45nterpriseDataEntity\x12\x0b\n\x07UNKNOWN\x10\x00\x12\t\n\x05NODES\x10\x01\x12\t\n\x05ROLES\x10\x02\x12\t\n\x05USERS\x10\x03\x12\t\n\x05TEAMS\x10\x04\x12\x0e\n\nTEAM_USERS\x10\x05\x12\x0e\n\nROLE_USERS\x10\x06\x12\x13\n\x0fROLE_PRIVILEGES\x10\x07\x12\x15\n\x11ROLE_ENFORCEMENTS\x10\x08\x12\x0e\n\nROLE_TEAMS\x10\t\x12\x0c\n\x08LICENSES\x10\n\x12\x11\n\rMANAGED_NODES\x10\x0b\x12\x15\n\x11MANAGED_COMPANIES\x10\x0c\x12\x0b\n\x07\x42RIDGES\x10\r\x12\t\n\x05SCIMS\x10\x0e\x12\x13\n\x0f\x45MAIL_PROVISION\x10\x0f\x12\x10\n\x0cQUEUED_TEAMS\x10\x10\x12\x15\n\x11QUEUED_TEAM_USERS\x10\x11\x12\x10\n\x0cSSO_SERVICES\x10\x12\x12\x17\n\x13REPORT_FILTER_USERS\x10\x13\x12&\n\"DEVICES_REQUEST_FOR_ADMIN_APPROVAL\x10\x14\x12\x10\n\x0cUSER_ALIASES\x10\x15\x12)\n%COMPLIANCE_REPORT_CRITERIA_AND_FILTER\x10\x16\x12\x16\n\x12\x43OMPLIANCE_REPORTS\x10\x17*\"\n\x0b\x43\x61\x63heStatus\x12\x08\n\x04KEEP\x10\x00\x12\t\n\x05\x43LEAR\x10\x01*\x93\x01\n\rBackupKeyType\x12\n\n\x06NO_KEY\x10\x00\x12\x19\n\x15\x45NCRYPTED_BY_DATA_KEY\x10\x01\x12\x1b\n\x17\x45NCRYPTED_BY_PUBLIC_KEY\x10\x02\x12\x1d\n\x19\x45NCRYPTED_BY_DATA_KEY_GCM\x10\x03\x12\x1f\n\x1b\x45NCRYPTED_BY_PUBLIC_KEY_ECC\x10\x04*:\n\x15\x42\x61\x63kupUserDataKeyType\x12\x07\n\x03OWN\x10\x00\x12\x18\n\x14SHARED_TO_ENTERPRISE\x10\x01*\xa5\x01\n\x10\x45ncryptedKeyType\x12\r\n\tKT_NO_KEY\x10\x00\x12\x1c\n\x18KT_ENCRYPTED_BY_DATA_KEY\x10\x01\x12\x1e\n\x1aKT_ENCRYPTED_BY_PUBLIC_KEY\x10\x02\x12 \n\x1cKT_ENCRYPTED_BY_DATA_KEY_GCM\x10\x03\x12\"\n\x1eKT_ENCRYPTED_BY_PUBLIC_KEY_ECC\x10\x04*\xb7\x02\n\x12\x45nterpriseFlagType\x12\x0b\n\x07INVALID\x10\x00\x12\x1a\n\x16\x41LLOW_PERSONAL_LICENSE\x10\x01\x12\x18\n\x14SPECIAL_PROVISIONING\x10\x02\x12\x10\n\x0cRECORD_TYPES\x10\x03\x12\x13\n\x0fSECRETS_MANAGER\x10\x04\x12\x15\n\x11\x45NTERPRISE_LOCKED\x10\x05\x12\x15\n\x11\x46ORBID_KEY_TYPE_2\x10\x06\x12\x15\n\x11\x43ONSOLE_ONBOARDED\x10\x07\x12\x1b\n\x17\x46ORBID_ACCOUNT_TRANSFER\x10\x08\x12\x15\n\x11NPS_POPUP_OPT_OUT\x10\t\x12\x15\n\x11SHOW_USER_ONBOARD\x10\n\x12\x15\n\x11\x46ORBID_KEY_TYPE_1\x10\x0b\x12\x10\n\x0cKEEPER_DRIVE\x10\x0c*\xf3\x01\n\x10UserUpdateStatus\x12\x12\n\x0eUSER_UPDATE_OK\x10\x00\x12\x1d\n\x19USER_UPDATE_ACCESS_DENIED\x10\x01\x12&\n\"USER_UPDATE_EXCEEDED_LICENSE_SEATS\x10\x02\x12\x1b\n\x17USER_UPDATE_BAD_REQUEST\x10\x03\x12\x19\n\x15USER_UPDATE_DUPLICATE\x10\x04\x12\x1d\n\x19USER_UPDATE_INVALID_STATE\x10\x05\x12\x16\n\x12USER_UPDATE_FAILED\x10\x06\x12\x15\n\x11USER_UPDATE_ERROR\x10\x07*I\n\x0f\x41uditUserStatus\x12\x06\n\x02OK\x10\x00\x12\x11\n\rACCESS_DENIED\x10\x01\x12\x1b\n\x17NO_LONGER_IN_ENTERPRISE\x10\x02*3\n\x0cTeamUserType\x12\x08\n\x04USER\x10\x00\x12\t\n\x05\x41\x44MIN\x10\x01\x12\x0e\n\nADMIN_ONLY\x10\x02*x\n\rAppClientType\x12\x0c\n\x08NOT_USED\x10\x00\x12\x0b\n\x07GENERAL\x10\x01\x12%\n!DISCOVERY_AND_ROTATION_CONTROLLER\x10\x02\x12\x12\n\x0eKCM_CONTROLLER\x10\x03\x12\x11\n\rSELF_DESTRUCT\x10\x04*\x8f\x01\n\x1b\x44\x65leteEnterpriseUsersResult\x12\x0b\n\x07SUCCESS\x10\x00\x12\x1a\n\x16NOT_AN_ENTERPRISE_USER\x10\x01\x12\x16\n\x12\x43\x41NNOT_DELETE_SELF\x10\x02\x12$\n BRIDGE_CANNOT_DELETE_ACTIVE_USER\x10\x03\x12\t\n\x05\x45RROR\x10\x04*\x87\x01\n\x15\x43learSecurityDataType\x12\x1e\n\x1aRECALCULATE_SUMMARY_REPORT\x10\x00\x12\'\n#FORCE_CLIENT_CHECK_FOR_MISSING_DATA\x10\x01\x12%\n!FORCE_CLIENT_RESEND_SECURITY_DATA\x10\x02*J\n\x13ReserveDomainAction\x12\x10\n\x0c\x44OMAIN_TOKEN\x10\x00\x12\x0e\n\nDOMAIN_ADD\x10\x01\x12\x11\n\rDOMAIN_DELETE\x10\x02*s\n\x0eUserLockStatus\x12\x17\n\x13UNKNOWN_LOCK_STATUS\x10\x00\x12\n\n\x06LOCKED\x10\x01\x12\x0c\n\x08\x44ISABLED\x10\x02\x12\x0c\n\x08UNLOCKED\x10\x03\x12\x0b\n\x07\x44\x45LETED\x10\x04\x12\x13\n\x0f\x43\x41NT_BE_PENDING\x10\x05*\x80\x01\n\x1d\x45xternalCloudSecretsStoreType\x12\x16\n\x12UNKNOWN_STORE_TYPE\x10\x00\x12\x17\n\x13\x41WS_SECRETS_MANAGER\x10\x01\x12\x13\n\x0f\x41ZURE_KEY_VAULT\x10\x02\x12\x19\n\x15GOOGLE_SECRET_MANAGER\x10\x03\x42&\n\x18\x63om.keepersecurity.protoB\nEnterpriseb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -32,382 +33,388 @@ if not _descriptor._USE_C_DESCRIPTORS: _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'\n\030com.keepersecurity.protoB\nEnterprise' + _globals['_ROLEUSERADDKEYS'].fields_by_name['treeKey']._loaded_options = None + _globals['_ROLEUSERADDKEYS'].fields_by_name['treeKey']._serialized_options = b'\030\001' _globals['_NODE'].fields_by_name['ssoServiceProviderId']._loaded_options = None _globals['_NODE'].fields_by_name['ssoServiceProviderId']._serialized_options = b'\030\001' _globals['_NODE'].fields_by_name['ssoServiceProviderIds']._loaded_options = None _globals['_NODE'].fields_by_name['ssoServiceProviderIds']._serialized_options = b'\020\001' + _globals['_USERUPDATE'].fields_by_name['encryptedData']._loaded_options = None + _globals['_USERUPDATE'].fields_by_name['encryptedData']._serialized_options = b'\030\001' _globals['_TEAMSENTERPRISEUSERSADDUSERREQUEST'].fields_by_name['teamKey']._loaded_options = None _globals['_TEAMSENTERPRISEUSERSADDUSERREQUEST'].fields_by_name['teamKey']._serialized_options = b'\030\001' - _globals['_KEYTYPE']._serialized_start=20649 - _globals['_KEYTYPE']._serialized_end=20676 - _globals['_ROLEUSERMODIFYSTATUS']._serialized_start=20679 - _globals['_ROLEUSERMODIFYSTATUS']._serialized_end=20961 - _globals['_ENTERPRISETYPE']._serialized_start=20963 - _globals['_ENTERPRISETYPE']._serialized_end=21024 - _globals['_TRANSFERACCEPTANCESTATUS']._serialized_start=21026 - _globals['_TRANSFERACCEPTANCESTATUS']._serialized_end=21141 - _globals['_ENTERPRISEDATAENTITY']._serialized_start=21144 - _globals['_ENTERPRISEDATAENTITY']._serialized_end=21625 - _globals['_CACHESTATUS']._serialized_start=21627 - _globals['_CACHESTATUS']._serialized_end=21661 - _globals['_BACKUPKEYTYPE']._serialized_start=21664 - _globals['_BACKUPKEYTYPE']._serialized_end=21811 - _globals['_BACKUPUSERDATAKEYTYPE']._serialized_start=21813 - _globals['_BACKUPUSERDATAKEYTYPE']._serialized_end=21871 - _globals['_ENCRYPTEDKEYTYPE']._serialized_start=21874 - _globals['_ENCRYPTEDKEYTYPE']._serialized_end=22039 - _globals['_ENTERPRISEFLAGTYPE']._serialized_start=22042 - _globals['_ENTERPRISEFLAGTYPE']._serialized_end=22353 - _globals['_USERUPDATESTATUS']._serialized_start=22355 - _globals['_USERUPDATESTATUS']._serialized_end=22424 - _globals['_AUDITUSERSTATUS']._serialized_start=22426 - _globals['_AUDITUSERSTATUS']._serialized_end=22499 - _globals['_TEAMUSERTYPE']._serialized_start=22501 - _globals['_TEAMUSERTYPE']._serialized_end=22552 - _globals['_APPCLIENTTYPE']._serialized_start=22554 - _globals['_APPCLIENTTYPE']._serialized_end=22674 - _globals['_DELETEENTERPRISEUSERSRESULT']._serialized_start=22677 - _globals['_DELETEENTERPRISEUSERSRESULT']._serialized_end=22820 - _globals['_CLEARSECURITYDATATYPE']._serialized_start=22823 - _globals['_CLEARSECURITYDATATYPE']._serialized_end=22958 - _globals['_RESERVEDOMAINACTION']._serialized_start=22960 - _globals['_RESERVEDOMAINACTION']._serialized_end=23034 - _globals['_USERLOCKSTATUS']._serialized_start=23036 - _globals['_USERLOCKSTATUS']._serialized_end=23151 - _globals['_EXTERNALCLOUDSECRETSSTORETYPE']._serialized_start=23154 - _globals['_EXTERNALCLOUDSECRETSSTORETYPE']._serialized_end=23282 - _globals['_ENTERPRISEKEYPAIRREQUEST']._serialized_start=33 - _globals['_ENTERPRISEKEYPAIRREQUEST']._serialized_end=165 - _globals['_GETTEAMMEMBERREQUEST']._serialized_start=167 - _globals['_GETTEAMMEMBERREQUEST']._serialized_end=206 - _globals['_ENTERPRISEUSER']._serialized_start=208 - _globals['_ENTERPRISEUSER']._serialized_end=333 - _globals['_GETTEAMMEMBERRESPONSE']._serialized_start=335 - _globals['_GETTEAMMEMBERRESPONSE']._serialized_end=410 - _globals['_ENTERPRISEUSERIDS']._serialized_start=412 - _globals['_ENTERPRISEUSERIDS']._serialized_end=457 - _globals['_ENTERPRISEPERSONALACCOUNT']._serialized_start=459 - _globals['_ENTERPRISEPERSONALACCOUNT']._serialized_end=525 - _globals['_ENCRYPTEDTEAMKEYREQUEST']._serialized_start=527 - _globals['_ENCRYPTEDTEAMKEYREQUEST']._serialized_end=610 - _globals['_REENCRYPTEDDATA']._serialized_start=612 - _globals['_REENCRYPTEDDATA']._serialized_end=655 - _globals['_REENCRYPTEDROLEKEY']._serialized_start=657 - _globals['_REENCRYPTEDROLEKEY']._serialized_end=720 - _globals['_REENCRYPTEDUSERDATAKEY']._serialized_start=722 - _globals['_REENCRYPTEDUSERDATAKEY']._serialized_end=802 - _globals['_NODETOMANAGEDCOMPANYREQUEST']._serialized_start=805 - _globals['_NODETOMANAGEDCOMPANYREQUEST']._serialized_end=1149 - _globals['_ROLETEAM']._serialized_start=1151 - _globals['_ROLETEAM']._serialized_end=1195 - _globals['_ROLETEAMS']._serialized_start=1197 - _globals['_ROLETEAMS']._serialized_end=1249 - _globals['_TEAMSBYROLE']._serialized_start=1251 - _globals['_TEAMSBYROLE']._serialized_end=1298 - _globals['_MANAGEDNODESBYROLE']._serialized_start=1300 - _globals['_MANAGEDNODESBYROLE']._serialized_end=1360 - _globals['_ROLEUSERADDKEYS']._serialized_start=1362 - _globals['_ROLEUSERADDKEYS']._serialized_end=1444 - _globals['_ROLEUSERADD']._serialized_start=1446 - _globals['_ROLEUSERADD']._serialized_end=1530 - _globals['_ROLEUSERSADDREQUEST']._serialized_start=1532 - _globals['_ROLEUSERSADDREQUEST']._serialized_end=1600 - _globals['_ROLEUSERADDRESULT']._serialized_start=1603 - _globals['_ROLEUSERADDRESULT']._serialized_end=1731 - _globals['_ROLEUSERSADDRESPONSE']._serialized_start=1733 - _globals['_ROLEUSERSADDRESPONSE']._serialized_end=1803 - _globals['_ROLEUSERREMOVE']._serialized_start=1805 - _globals['_ROLEUSERREMOVE']._serialized_end=1865 - _globals['_ROLEUSERSREMOVEREQUEST']._serialized_start=1867 - _globals['_ROLEUSERSREMOVEREQUEST']._serialized_end=1944 - _globals['_ROLEUSERREMOVERESULT']._serialized_start=1947 - _globals['_ROLEUSERREMOVERESULT']._serialized_end=2078 - _globals['_ROLEUSERSREMOVERESPONSE']._serialized_start=2080 - _globals['_ROLEUSERSREMOVERESPONSE']._serialized_end=2156 - _globals['_ENTERPRISEREGISTRATION']._serialized_start=2159 - _globals['_ENTERPRISEREGISTRATION']._serialized_end=2703 - _globals['_DOMAINPASSWORDRULESREQUEST']._serialized_start=2705 - _globals['_DOMAINPASSWORDRULESREQUEST']._serialized_end=2777 - _globals['_DOMAINPASSWORDRULESFIELDS']._serialized_start=2779 - _globals['_DOMAINPASSWORDRULESFIELDS']._serialized_end=2871 - _globals['_LOGINTOMCREQUEST']._serialized_start=2873 - _globals['_LOGINTOMCREQUEST']._serialized_end=2942 - _globals['_LOGINTOMCRESPONSE']._serialized_start=2944 - _globals['_LOGINTOMCRESPONSE']._serialized_end=3063 - _globals['_DOMAINPASSWORDRULESRESPONSE']._serialized_start=3065 - _globals['_DOMAINPASSWORDRULESRESPONSE']._serialized_end=3168 - _globals['_APPROVEUSERDEVICEREQUEST']._serialized_start=3171 - _globals['_APPROVEUSERDEVICEREQUEST']._serialized_end=3307 - _globals['_APPROVEUSERDEVICERESPONSE']._serialized_start=3309 - _globals['_APPROVEUSERDEVICERESPONSE']._serialized_end=3425 - _globals['_APPROVEUSERDEVICESREQUEST']._serialized_start=3427 - _globals['_APPROVEUSERDEVICESREQUEST']._serialized_end=3516 - _globals['_APPROVEUSERDEVICESRESPONSE']._serialized_start=3518 - _globals['_APPROVEUSERDEVICESRESPONSE']._serialized_end=3610 - _globals['_ENTERPRISEUSERDATAKEY']._serialized_start=3613 - _globals['_ENTERPRISEUSERDATAKEY']._serialized_end=3748 - _globals['_ENTERPRISEUSERDATAKEYS']._serialized_start=3750 - _globals['_ENTERPRISEUSERDATAKEYS']._serialized_end=3823 - _globals['_ENTERPRISEUSERDATAKEYLIGHT']._serialized_start=3825 - _globals['_ENTERPRISEUSERDATAKEYLIGHT']._serialized_end=3928 - _globals['_ENTERPRISEUSERDATAKEYSBYNODE']._serialized_start=3930 - _globals['_ENTERPRISEUSERDATAKEYSBYNODE']._serialized_end=4030 - _globals['_ENTERPRISEUSERDATAKEYSBYNODERESPONSE']._serialized_start=4032 - _globals['_ENTERPRISEUSERDATAKEYSBYNODERESPONSE']._serialized_end=4126 - _globals['_ENTERPRISEDATAREQUEST']._serialized_start=4128 - _globals['_ENTERPRISEDATAREQUEST']._serialized_end=4178 - _globals['_SPECIALPROVISIONING']._serialized_start=4180 - _globals['_SPECIALPROVISIONING']._serialized_end=4228 - _globals['_GENERALDATAENTITY']._serialized_start=4231 - _globals['_GENERALDATAENTITY']._serialized_end=4491 - _globals['_NODE']._serialized_start=4494 - _globals['_NODE']._serialized_end=4747 - _globals['_ROLE']._serialized_start=4750 - _globals['_ROLE']._serialized_end=4892 - _globals['_USER']._serialized_start=4895 - _globals['_USER']._serialized_end=5207 - _globals['_USERALIAS']._serialized_start=5209 - _globals['_USERALIAS']._serialized_end=5264 - _globals['_COMPLIANCEREPORTMETADATA']._serialized_start=5267 - _globals['_COMPLIANCEREPORTMETADATA']._serialized_end=5439 - _globals['_MANAGEDNODE']._serialized_start=5441 - _globals['_MANAGEDNODE']._serialized_end=5524 - _globals['_USERMANAGEDNODE']._serialized_start=5526 - _globals['_USERMANAGEDNODE']._serialized_end=5610 - _globals['_USERPRIVILEGE']._serialized_start=5612 - _globals['_USERPRIVILEGE']._serialized_end=5731 - _globals['_ROLEUSER']._serialized_start=5733 - _globals['_ROLEUSER']._serialized_end=5785 - _globals['_ROLEPRIVILEGE']._serialized_start=5787 - _globals['_ROLEPRIVILEGE']._serialized_end=5864 - _globals['_PRIVILEGESBYMANAGEDNODE']._serialized_start=5866 - _globals['_PRIVILEGESBYMANAGEDNODE']._serialized_end=5950 - _globals['_ROLEENFORCEMENT']._serialized_start=5952 - _globals['_ROLEENFORCEMENT']._serialized_end=6025 - _globals['_TEAM']._serialized_start=6028 - _globals['_TEAM']._serialized_end=6197 - _globals['_TEAMUSER']._serialized_start=6199 - _globals['_TEAMUSER']._serialized_end=6270 - _globals['_GETDISTRIBUTORINFORESPONSE']._serialized_start=6272 - _globals['_GETDISTRIBUTORINFORESPONSE']._serialized_end=6347 - _globals['_DISTRIBUTOR']._serialized_start=6349 - _globals['_DISTRIBUTOR']._serialized_end=6415 - _globals['_MSPINFO']._serialized_start=6418 - _globals['_MSPINFO']._serialized_end=6703 - _globals['_MANAGEDCOMPANY']._serialized_start=6706 - _globals['_MANAGEDCOMPANY']._serialized_end=7002 - _globals['_MSPPOOL']._serialized_start=7004 - _globals['_MSPPOOL']._serialized_end=7086 - _globals['_MSPCONTACT']._serialized_start=7088 - _globals['_MSPCONTACT']._serialized_end=7146 - _globals['_LICENSEADDON']._serialized_start=7149 - _globals['_LICENSEADDON']._serialized_end=7409 - _globals['_MCDEFAULT']._serialized_start=7411 - _globals['_MCDEFAULT']._serialized_end=7526 - _globals['_MSPPERMITS']._serialized_start=7529 - _globals['_MSPPERMITS']._serialized_end=7739 - _globals['_LICENSE']._serialized_start=7742 - _globals['_LICENSE']._serialized_end=8286 - _globals['_BRIDGE']._serialized_start=8288 - _globals['_BRIDGE']._serialized_end=8398 - _globals['_SCIM']._serialized_start=8400 - _globals['_SCIM']._serialized_end=8516 - _globals['_EMAILPROVISION']._serialized_start=8518 - _globals['_EMAILPROVISION']._serialized_end=8594 - _globals['_QUEUEDTEAM']._serialized_start=8596 - _globals['_QUEUEDTEAM']._serialized_end=8678 - _globals['_QUEUEDTEAMUSER']._serialized_start=8680 - _globals['_QUEUEDTEAMUSER']._serialized_end=8728 - _globals['_TEAMSADDRESULT']._serialized_start=8731 - _globals['_TEAMSADDRESULT']._serialized_end=8895 - _globals['_TEAMADDRESULT']._serialized_start=8897 - _globals['_TEAMADDRESULT']._serialized_end=8982 - _globals['_SSOSERVICE']._serialized_start=8985 - _globals['_SSOSERVICE']._serialized_end=9130 - _globals['_REPORTFILTERUSER']._serialized_start=9132 - _globals['_REPORTFILTERUSER']._serialized_end=9181 - _globals['_DEVICEREQUESTFORADMINAPPROVAL']._serialized_start=9184 - _globals['_DEVICEREQUESTFORADMINAPPROVAL']._serialized_end=9463 - _globals['_ENTERPRISEDATA']._serialized_start=9465 - _globals['_ENTERPRISEDATA']._serialized_end=9561 - _globals['_ENTERPRISEDATARESPONSE']._serialized_start=9564 - _globals['_ENTERPRISEDATARESPONSE']._serialized_end=9772 - _globals['_BACKUPREQUEST']._serialized_start=9774 - _globals['_BACKUPREQUEST']._serialized_end=9816 - _globals['_BACKUPRECORD']._serialized_start=9819 - _globals['_BACKUPRECORD']._serialized_end=9971 - _globals['_BACKUPKEY']._serialized_start=9973 - _globals['_BACKUPKEY']._serialized_end=10019 - _globals['_BACKUPUSER']._serialized_start=10022 - _globals['_BACKUPUSER']._serialized_end=10291 - _globals['_BACKUPRESPONSE']._serialized_start=10294 - _globals['_BACKUPRESPONSE']._serialized_end=10452 - _globals['_BACKUPFILE']._serialized_start=10454 - _globals['_BACKUPFILE']._serialized_end=10555 - _globals['_BACKUPSRESPONSE']._serialized_start=10557 - _globals['_BACKUPSRESPONSE']._serialized_end=10613 - _globals['_GETENTERPRISEDATAKEYSREQUEST']._serialized_start=10615 - _globals['_GETENTERPRISEDATAKEYSREQUEST']._serialized_end=10661 - _globals['_GETENTERPRISEDATAKEYSRESPONSE']._serialized_start=10664 - _globals['_GETENTERPRISEDATAKEYSRESPONSE']._serialized_end=10919 - _globals['_ROLEKEY']._serialized_start=10921 - _globals['_ROLEKEY']._serialized_end=11015 - _globals['_MSPKEY']._serialized_start=11017 - _globals['_MSPKEY']._serialized_end=11117 - _globals['_ENTERPRISEKEYS']._serialized_start=11119 - _globals['_ENTERPRISEKEYS']._serialized_end=11243 - _globals['_TREEKEY']._serialized_start=11245 - _globals['_TREEKEY']._serialized_end=11317 - _globals['_SHAREDRECORDRESPONSE']._serialized_start=11319 - _globals['_SHAREDRECORDRESPONSE']._serialized_end=11388 - _globals['_SHAREDRECORDEVENT']._serialized_start=11390 - _globals['_SHAREDRECORDEVENT']._serialized_end=11502 - _globals['_SETRESTRICTVISIBILITYREQUEST']._serialized_start=11504 - _globals['_SETRESTRICTVISIBILITYREQUEST']._serialized_end=11550 - _globals['_USERADDREQUEST']._serialized_start=11553 - _globals['_USERADDREQUEST']._serialized_end=11761 - _globals['_USERUPDATEREQUEST']._serialized_start=11763 - _globals['_USERUPDATEREQUEST']._serialized_end=11821 - _globals['_USERUPDATE']._serialized_start=11824 - _globals['_USERUPDATE']._serialized_end=11999 - _globals['_USERUPDATERESPONSE']._serialized_start=12001 - _globals['_USERUPDATERESPONSE']._serialized_end=12066 - _globals['_USERUPDATERESULT']._serialized_start=12068 - _globals['_USERUPDATERESULT']._serialized_end=12158 - _globals['_COMPLIANCERECORDOWNERSREQUEST']._serialized_start=12160 - _globals['_COMPLIANCERECORDOWNERSREQUEST']._serialized_end=12234 - _globals['_COMPLIANCERECORDOWNERSRESPONSE']._serialized_start=12236 - _globals['_COMPLIANCERECORDOWNERSRESPONSE']._serialized_end=12315 - _globals['_RECORDOWNER']._serialized_start=12317 - _globals['_RECORDOWNER']._serialized_end=12372 - _globals['_PRELIMINARYCOMPLIANCEDATAREQUEST']._serialized_start=12375 - _globals['_PRELIMINARYCOMPLIANCEDATAREQUEST']._serialized_end=12541 - _globals['_PRELIMINARYCOMPLIANCEDATARESPONSE']._serialized_start=12544 - _globals['_PRELIMINARYCOMPLIANCEDATARESPONSE']._serialized_end=12703 - _globals['_AUDITUSERRECORD']._serialized_start=12705 - _globals['_AUDITUSERRECORD']._serialized_end=12780 - _globals['_AUDITUSERDATA']._serialized_start=12783 - _globals['_AUDITUSERDATA']._serialized_end=12924 - _globals['_COMPLIANCEREPORTFILTERS']._serialized_start=12926 - _globals['_COMPLIANCEREPORTFILTERS']._serialized_end=13053 - _globals['_COMPLIANCEREPORTREQUEST']._serialized_start=13055 - _globals['_COMPLIANCEREPORTREQUEST']._serialized_end=13182 - _globals['_COMPLIANCEREPORTRUN']._serialized_start=13185 - _globals['_COMPLIANCEREPORTRUN']._serialized_end=13318 - _globals['_COMPLIANCEREPORTCRITERIAANDFILTER']._serialized_start=13321 - _globals['_COMPLIANCEREPORTCRITERIAANDFILTER']._serialized_end=13573 - _globals['_COMPLIANCEREPORTCRITERIA']._serialized_start=13575 - _globals['_COMPLIANCEREPORTCRITERIA']._serialized_end=13673 - _globals['_COMPLIANCEREPORTFILTER']._serialized_start=13675 - _globals['_COMPLIANCEREPORTFILTER']._serialized_end=13795 - _globals['_COMPLIANCEREPORTRESPONSE']._serialized_start=13798 - _globals['_COMPLIANCEREPORTRESPONSE']._serialized_end=14471 - _globals['_AUDITRECORD']._serialized_start=14474 - _globals['_AUDITRECORD']._serialized_end=14603 - _globals['_AUDITROLE']._serialized_start=14606 - _globals['_AUDITROLE']._serialized_end=14862 - _globals['_ROLENODEMANAGEMENT']._serialized_start=14864 - _globals['_ROLENODEMANAGEMENT']._serialized_end=14958 - _globals['_USERPROFILE']._serialized_start=14960 - _globals['_USERPROFILE']._serialized_end=15067 - _globals['_RECORDPERMISSION']._serialized_start=15069 - _globals['_RECORDPERMISSION']._serialized_end=15130 - _globals['_USERRECORD']._serialized_start=15132 - _globals['_USERRECORD']._serialized_end=15227 - _globals['_AUDITTEAM']._serialized_start=15229 - _globals['_AUDITTEAM']._serialized_end=15320 - _globals['_AUDITTEAMUSER']._serialized_start=15322 - _globals['_AUDITTEAMUSER']._serialized_end=15381 - _globals['_SHAREDFOLDERRECORD']._serialized_start=15384 - _globals['_SHAREDFOLDERRECORD']._serialized_end=15543 - _globals['_SHAREADMINRECORD']._serialized_start=15545 - _globals['_SHAREADMINRECORD']._serialized_end=15622 - _globals['_SHAREDFOLDERUSER']._serialized_start=15624 - _globals['_SHAREDFOLDERUSER']._serialized_end=15694 - _globals['_SHAREDFOLDERTEAM']._serialized_start=15696 - _globals['_SHAREDFOLDERTEAM']._serialized_end=15757 - _globals['_GETCOMPLIANCEREPORTREQUEST']._serialized_start=15759 - _globals['_GETCOMPLIANCEREPORTREQUEST']._serialized_end=15806 - _globals['_GETCOMPLIANCEREPORTRESPONSE']._serialized_start=15808 - _globals['_GETCOMPLIANCEREPORTRESPONSE']._serialized_end=15858 - _globals['_COMPLIANCEREPORTCRITERIAREQUEST']._serialized_start=15860 - _globals['_COMPLIANCEREPORTCRITERIAREQUEST']._serialized_end=15914 - _globals['_SAVECOMPLIANCEREPORTCRITERIARESPONSE']._serialized_start=15916 - _globals['_SAVECOMPLIANCEREPORTCRITERIARESPONSE']._serialized_end=15975 - _globals['_LINKEDRECORD']._serialized_start=15977 - _globals['_LINKEDRECORD']._serialized_end=16029 - _globals['_GETSHARINGADMINSREQUEST']._serialized_start=16031 - _globals['_GETSHARINGADMINSREQUEST']._serialized_end=16118 - _globals['_USERPROFILEEXT']._serialized_start=16121 - _globals['_USERPROFILEEXT']._serialized_end=16345 - _globals['_GETSHARINGADMINSRESPONSE']._serialized_start=16347 - _globals['_GETSHARINGADMINSRESPONSE']._serialized_end=16426 - _globals['_TEAMSENTERPRISEUSERSADDREQUEST']._serialized_start=16428 - _globals['_TEAMSENTERPRISEUSERSADDREQUEST']._serialized_end=16523 - _globals['_TEAMSENTERPRISEUSERSADDTEAMREQUEST']._serialized_start=16525 - _globals['_TEAMSENTERPRISEUSERSADDTEAMREQUEST']._serialized_end=16641 - _globals['_TEAMSENTERPRISEUSERSADDUSERREQUEST']._serialized_start=16644 - _globals['_TEAMSENTERPRISEUSERSADDUSERREQUEST']._serialized_end=16815 - _globals['_TYPEDKEY']._serialized_start=16817 - _globals['_TYPEDKEY']._serialized_end=16887 - _globals['_TEAMSENTERPRISEUSERSADDRESPONSE']._serialized_start=16889 - _globals['_TEAMSENTERPRISEUSERSADDRESPONSE']._serialized_end=17004 - _globals['_TEAMSENTERPRISEUSERSADDTEAMRESPONSE']._serialized_start=17007 - _globals['_TEAMSENTERPRISEUSERSADDTEAMRESPONSE']._serialized_end=17203 - _globals['_TEAMSENTERPRISEUSERSADDUSERRESPONSE']._serialized_start=17206 - _globals['_TEAMSENTERPRISEUSERSADDUSERRESPONSE']._serialized_end=17365 - _globals['_TEAMENTERPRISEUSERREMOVE']._serialized_start=17367 - _globals['_TEAMENTERPRISEUSERREMOVE']._serialized_end=17436 - _globals['_TEAMENTERPRISEUSERREMOVESREQUEST']._serialized_start=17438 - _globals['_TEAMENTERPRISEUSERREMOVESREQUEST']._serialized_end=17544 - _globals['_TEAMENTERPRISEUSERREMOVESRESPONSE']._serialized_start=17546 - _globals['_TEAMENTERPRISEUSERREMOVESRESPONSE']._serialized_end=17669 - _globals['_TEAMENTERPRISEUSERREMOVERESPONSE']._serialized_start=17672 - _globals['_TEAMENTERPRISEUSERREMOVERESPONSE']._serialized_end=17856 - _globals['_DOMAINALIAS']._serialized_start=17858 - _globals['_DOMAINALIAS']._serialized_end=17935 - _globals['_DOMAINALIASREQUEST']._serialized_start=17937 - _globals['_DOMAINALIASREQUEST']._serialized_end=18003 - _globals['_DOMAINALIASRESPONSE']._serialized_start=18005 - _globals['_DOMAINALIASRESPONSE']._serialized_end=18072 - _globals['_ENTERPRISEUSERSPROVISIONREQUEST']._serialized_start=18074 - _globals['_ENTERPRISEUSERSPROVISIONREQUEST']._serialized_end=18183 - _globals['_ENTERPRISEUSERSPROVISION']._serialized_start=18186 - _globals['_ENTERPRISEUSERSPROVISION']._serialized_end=18624 - _globals['_ENTERPRISEUSERSPROVISIONRESPONSE']._serialized_start=18626 - _globals['_ENTERPRISEUSERSPROVISIONRESPONSE']._serialized_end=18721 - _globals['_ENTERPRISEUSERSPROVISIONRESULT']._serialized_start=18723 - _globals['_ENTERPRISEUSERSPROVISIONRESULT']._serialized_end=18836 - _globals['_ENTERPRISEUSERSADDREQUEST']._serialized_start=18838 - _globals['_ENTERPRISEUSERSADDREQUEST']._serialized_end=18935 - _globals['_ENTERPRISEUSERSADD']._serialized_start=18938 - _globals['_ENTERPRISEUSERSADD']._serialized_end=19206 - _globals['_ENTERPRISEUSERSADDRESPONSE']._serialized_start=19209 - _globals['_ENTERPRISEUSERSADDRESPONSE']._serialized_end=19364 - _globals['_ENTERPRISEUSERSADDRESULT']._serialized_start=19367 - _globals['_ENTERPRISEUSERSADDRESULT']._serialized_end=19517 - _globals['_UPDATEMSPPERMITSREQUEST']._serialized_start=19520 - _globals['_UPDATEMSPPERMITSREQUEST']._serialized_end=19705 - _globals['_DELETEENTERPRISEUSERSREQUEST']._serialized_start=19707 - _globals['_DELETEENTERPRISEUSERSREQUEST']._serialized_end=19764 - _globals['_DELETEENTERPRISEUSERSTATUS']._serialized_start=19766 - _globals['_DELETEENTERPRISEUSERSTATUS']._serialized_end=19877 - _globals['_DELETEENTERPRISEUSERSRESPONSE']._serialized_start=19879 - _globals['_DELETEENTERPRISEUSERSRESPONSE']._serialized_end=19972 - _globals['_CLEARSECURITYDATAREQUEST']._serialized_start=19974 - _globals['_CLEARSECURITYDATAREQUEST']._serialized_end=20093 - _globals['_LISTDOMAINSRESPONSE']._serialized_start=20095 - _globals['_LISTDOMAINSRESPONSE']._serialized_end=20132 - _globals['_RESERVEDOMAINREQUEST']._serialized_start=20134 - _globals['_RESERVEDOMAINREQUEST']._serialized_end=20234 - _globals['_RESERVEDOMAINRESPONSE']._serialized_start=20236 - _globals['_RESERVEDOMAINRESPONSE']._serialized_end=20274 - _globals['_ROLESBYTEAM']._serialized_start=20276 - _globals['_ROLESBYTEAM']._serialized_end=20322 - _globals['_LOCKUSERSREQUEST']._serialized_start=20325 - _globals['_LOCKUSERSREQUEST']._serialized_end=20466 - _globals['_LOCKUSERSRESPONSE']._serialized_start=20468 - _globals['_LOCKUSERSRESPONSE']._serialized_end=20535 - _globals['_LOCKUSERRESPONSE']._serialized_start=20537 - _globals['_LOCKUSERRESPONSE']._serialized_end=20647 + _globals['_KEYTYPE']._serialized_start=21125 + _globals['_KEYTYPE']._serialized_end=21152 + _globals['_ROLEUSERMODIFYSTATUS']._serialized_start=21155 + _globals['_ROLEUSERMODIFYSTATUS']._serialized_end=21458 + _globals['_ENTERPRISETYPE']._serialized_start=21460 + _globals['_ENTERPRISETYPE']._serialized_end=21521 + _globals['_TRANSFERACCEPTANCESTATUS']._serialized_start=21523 + _globals['_TRANSFERACCEPTANCESTATUS']._serialized_end=21638 + _globals['_ENTERPRISEDATAENTITY']._serialized_start=21641 + _globals['_ENTERPRISEDATAENTITY']._serialized_end=22122 + _globals['_CACHESTATUS']._serialized_start=22124 + _globals['_CACHESTATUS']._serialized_end=22158 + _globals['_BACKUPKEYTYPE']._serialized_start=22161 + _globals['_BACKUPKEYTYPE']._serialized_end=22308 + _globals['_BACKUPUSERDATAKEYTYPE']._serialized_start=22310 + _globals['_BACKUPUSERDATAKEYTYPE']._serialized_end=22368 + _globals['_ENCRYPTEDKEYTYPE']._serialized_start=22371 + _globals['_ENCRYPTEDKEYTYPE']._serialized_end=22536 + _globals['_ENTERPRISEFLAGTYPE']._serialized_start=22539 + _globals['_ENTERPRISEFLAGTYPE']._serialized_end=22850 + _globals['_USERUPDATESTATUS']._serialized_start=22853 + _globals['_USERUPDATESTATUS']._serialized_end=23096 + _globals['_AUDITUSERSTATUS']._serialized_start=23098 + _globals['_AUDITUSERSTATUS']._serialized_end=23171 + _globals['_TEAMUSERTYPE']._serialized_start=23173 + _globals['_TEAMUSERTYPE']._serialized_end=23224 + _globals['_APPCLIENTTYPE']._serialized_start=23226 + _globals['_APPCLIENTTYPE']._serialized_end=23346 + _globals['_DELETEENTERPRISEUSERSRESULT']._serialized_start=23349 + _globals['_DELETEENTERPRISEUSERSRESULT']._serialized_end=23492 + _globals['_CLEARSECURITYDATATYPE']._serialized_start=23495 + _globals['_CLEARSECURITYDATATYPE']._serialized_end=23630 + _globals['_RESERVEDOMAINACTION']._serialized_start=23632 + _globals['_RESERVEDOMAINACTION']._serialized_end=23706 + _globals['_USERLOCKSTATUS']._serialized_start=23708 + _globals['_USERLOCKSTATUS']._serialized_end=23823 + _globals['_EXTERNALCLOUDSECRETSSTORETYPE']._serialized_start=23826 + _globals['_EXTERNALCLOUDSECRETSSTORETYPE']._serialized_end=23954 + _globals['_ENTERPRISEKEYPAIRREQUEST']._serialized_start=47 + _globals['_ENTERPRISEKEYPAIRREQUEST']._serialized_end=179 + _globals['_GETTEAMMEMBERREQUEST']._serialized_start=181 + _globals['_GETTEAMMEMBERREQUEST']._serialized_end=220 + _globals['_ENTERPRISEUSER']._serialized_start=222 + _globals['_ENTERPRISEUSER']._serialized_end=347 + _globals['_GETTEAMMEMBERRESPONSE']._serialized_start=349 + _globals['_GETTEAMMEMBERRESPONSE']._serialized_end=424 + _globals['_ENTERPRISEUSERIDS']._serialized_start=426 + _globals['_ENTERPRISEUSERIDS']._serialized_end=471 + _globals['_ENTERPRISEPERSONALACCOUNT']._serialized_start=473 + _globals['_ENTERPRISEPERSONALACCOUNT']._serialized_end=539 + _globals['_ENCRYPTEDTEAMKEYREQUEST']._serialized_start=541 + _globals['_ENCRYPTEDTEAMKEYREQUEST']._serialized_end=624 + _globals['_REENCRYPTEDDATA']._serialized_start=626 + _globals['_REENCRYPTEDDATA']._serialized_end=669 + _globals['_REENCRYPTEDROLEKEY']._serialized_start=671 + _globals['_REENCRYPTEDROLEKEY']._serialized_end=734 + _globals['_REENCRYPTEDUSERDATAKEY']._serialized_start=736 + _globals['_REENCRYPTEDUSERDATAKEY']._serialized_end=816 + _globals['_NODETOMANAGEDCOMPANYREQUEST']._serialized_start=819 + _globals['_NODETOMANAGEDCOMPANYREQUEST']._serialized_end=1163 + _globals['_ROLETEAM']._serialized_start=1165 + _globals['_ROLETEAM']._serialized_end=1209 + _globals['_ROLETEAMS']._serialized_start=1211 + _globals['_ROLETEAMS']._serialized_end=1263 + _globals['_TEAMSBYROLE']._serialized_start=1265 + _globals['_TEAMSBYROLE']._serialized_end=1312 + _globals['_MANAGEDNODESBYROLE']._serialized_start=1314 + _globals['_MANAGEDNODESBYROLE']._serialized_end=1374 + _globals['_ROLEUSERADDKEYS']._serialized_start=1377 + _globals['_ROLEUSERADDKEYS']._serialized_end=1507 + _globals['_ROLEUSERADD']._serialized_start=1509 + _globals['_ROLEUSERADD']._serialized_end=1593 + _globals['_ROLEUSERSADDREQUEST']._serialized_start=1595 + _globals['_ROLEUSERSADDREQUEST']._serialized_end=1663 + _globals['_ROLEUSERADDRESULT']._serialized_start=1666 + _globals['_ROLEUSERADDRESULT']._serialized_end=1794 + _globals['_ROLEUSERSADDRESPONSE']._serialized_start=1796 + _globals['_ROLEUSERSADDRESPONSE']._serialized_end=1866 + _globals['_ROLEUSERREMOVE']._serialized_start=1868 + _globals['_ROLEUSERREMOVE']._serialized_end=1928 + _globals['_ROLEUSERSREMOVEREQUEST']._serialized_start=1930 + _globals['_ROLEUSERSREMOVEREQUEST']._serialized_end=2007 + _globals['_ROLEUSERREMOVERESULT']._serialized_start=2010 + _globals['_ROLEUSERREMOVERESULT']._serialized_end=2141 + _globals['_ROLEUSERSREMOVERESPONSE']._serialized_start=2143 + _globals['_ROLEUSERSREMOVERESPONSE']._serialized_end=2219 + _globals['_ENTERPRISEREGISTRATION']._serialized_start=2222 + _globals['_ENTERPRISEREGISTRATION']._serialized_end=2766 + _globals['_DOMAINPASSWORDRULESREQUEST']._serialized_start=2768 + _globals['_DOMAINPASSWORDRULESREQUEST']._serialized_end=2840 + _globals['_DOMAINPASSWORDRULESFIELDS']._serialized_start=2842 + _globals['_DOMAINPASSWORDRULESFIELDS']._serialized_end=2934 + _globals['_LOGINTOMCREQUEST']._serialized_start=2936 + _globals['_LOGINTOMCREQUEST']._serialized_end=3005 + _globals['_LOGINTOMCRESPONSE']._serialized_start=3007 + _globals['_LOGINTOMCRESPONSE']._serialized_end=3126 + _globals['_DOMAINPASSWORDRULESRESPONSE']._serialized_start=3128 + _globals['_DOMAINPASSWORDRULESRESPONSE']._serialized_end=3231 + _globals['_APPROVEUSERDEVICEREQUEST']._serialized_start=3234 + _globals['_APPROVEUSERDEVICEREQUEST']._serialized_end=3370 + _globals['_APPROVEUSERDEVICERESPONSE']._serialized_start=3372 + _globals['_APPROVEUSERDEVICERESPONSE']._serialized_end=3488 + _globals['_APPROVEUSERDEVICESREQUEST']._serialized_start=3490 + _globals['_APPROVEUSERDEVICESREQUEST']._serialized_end=3579 + _globals['_APPROVEUSERDEVICESRESPONSE']._serialized_start=3581 + _globals['_APPROVEUSERDEVICESRESPONSE']._serialized_end=3673 + _globals['_ENTERPRISEUSERDATAKEY']._serialized_start=3676 + _globals['_ENTERPRISEUSERDATAKEY']._serialized_end=3811 + _globals['_ENTERPRISEUSERDATAKEYS']._serialized_start=3813 + _globals['_ENTERPRISEUSERDATAKEYS']._serialized_end=3886 + _globals['_ENTERPRISEUSERDATAKEYLIGHT']._serialized_start=3888 + _globals['_ENTERPRISEUSERDATAKEYLIGHT']._serialized_end=3991 + _globals['_ENTERPRISEUSERDATAKEYSBYNODE']._serialized_start=3993 + _globals['_ENTERPRISEUSERDATAKEYSBYNODE']._serialized_end=4093 + _globals['_ENTERPRISEUSERDATAKEYSBYNODERESPONSE']._serialized_start=4095 + _globals['_ENTERPRISEUSERDATAKEYSBYNODERESPONSE']._serialized_end=4189 + _globals['_ENTERPRISEDATAREQUEST']._serialized_start=4191 + _globals['_ENTERPRISEDATAREQUEST']._serialized_end=4241 + _globals['_SPECIALPROVISIONING']._serialized_start=4243 + _globals['_SPECIALPROVISIONING']._serialized_end=4291 + _globals['_GENERALDATAENTITY']._serialized_start=4294 + _globals['_GENERALDATAENTITY']._serialized_end=4554 + _globals['_NODE']._serialized_start=4557 + _globals['_NODE']._serialized_end=4810 + _globals['_ROLE']._serialized_start=4813 + _globals['_ROLE']._serialized_end=4955 + _globals['_USER']._serialized_start=4958 + _globals['_USER']._serialized_end=5270 + _globals['_USERALIAS']._serialized_start=5272 + _globals['_USERALIAS']._serialized_end=5327 + _globals['_COMPLIANCEREPORTMETADATA']._serialized_start=5330 + _globals['_COMPLIANCEREPORTMETADATA']._serialized_end=5502 + _globals['_MANAGEDNODE']._serialized_start=5504 + _globals['_MANAGEDNODE']._serialized_end=5587 + _globals['_USERMANAGEDNODE']._serialized_start=5589 + _globals['_USERMANAGEDNODE']._serialized_end=5673 + _globals['_USERPRIVILEGE']._serialized_start=5675 + _globals['_USERPRIVILEGE']._serialized_end=5794 + _globals['_ROLEUSER']._serialized_start=5796 + _globals['_ROLEUSER']._serialized_end=5848 + _globals['_ROLEPRIVILEGE']._serialized_start=5850 + _globals['_ROLEPRIVILEGE']._serialized_end=5927 + _globals['_PRIVILEGESBYMANAGEDNODE']._serialized_start=5929 + _globals['_PRIVILEGESBYMANAGEDNODE']._serialized_end=6013 + _globals['_ROLEENFORCEMENT']._serialized_start=6015 + _globals['_ROLEENFORCEMENT']._serialized_end=6088 + _globals['_TEAM']._serialized_start=6091 + _globals['_TEAM']._serialized_end=6260 + _globals['_TEAMUSER']._serialized_start=6262 + _globals['_TEAMUSER']._serialized_end=6333 + _globals['_GETDISTRIBUTORINFORESPONSE']._serialized_start=6335 + _globals['_GETDISTRIBUTORINFORESPONSE']._serialized_end=6410 + _globals['_DISTRIBUTOR']._serialized_start=6412 + _globals['_DISTRIBUTOR']._serialized_end=6478 + _globals['_MSPINFO']._serialized_start=6481 + _globals['_MSPINFO']._serialized_end=6766 + _globals['_MANAGEDCOMPANY']._serialized_start=6769 + _globals['_MANAGEDCOMPANY']._serialized_end=7065 + _globals['_MSPPOOL']._serialized_start=7067 + _globals['_MSPPOOL']._serialized_end=7149 + _globals['_MSPCONTACT']._serialized_start=7151 + _globals['_MSPCONTACT']._serialized_end=7209 + _globals['_LICENSEADDON']._serialized_start=7212 + _globals['_LICENSEADDON']._serialized_end=7472 + _globals['_MCDEFAULT']._serialized_start=7474 + _globals['_MCDEFAULT']._serialized_end=7589 + _globals['_MSPPERMITS']._serialized_start=7592 + _globals['_MSPPERMITS']._serialized_end=7802 + _globals['_LICENSE']._serialized_start=7805 + _globals['_LICENSE']._serialized_end=8349 + _globals['_BRIDGE']._serialized_start=8351 + _globals['_BRIDGE']._serialized_end=8461 + _globals['_SCIM']._serialized_start=8463 + _globals['_SCIM']._serialized_end=8579 + _globals['_EMAILPROVISION']._serialized_start=8581 + _globals['_EMAILPROVISION']._serialized_end=8657 + _globals['_QUEUEDTEAM']._serialized_start=8659 + _globals['_QUEUEDTEAM']._serialized_end=8741 + _globals['_QUEUEDTEAMUSER']._serialized_start=8743 + _globals['_QUEUEDTEAMUSER']._serialized_end=8791 + _globals['_TEAMSADDRESULT']._serialized_start=8794 + _globals['_TEAMSADDRESULT']._serialized_end=8958 + _globals['_TEAMADDRESULT']._serialized_start=8960 + _globals['_TEAMADDRESULT']._serialized_end=9045 + _globals['_SSOSERVICE']._serialized_start=9048 + _globals['_SSOSERVICE']._serialized_end=9193 + _globals['_REPORTFILTERUSER']._serialized_start=9195 + _globals['_REPORTFILTERUSER']._serialized_end=9244 + _globals['_DEVICEREQUESTFORADMINAPPROVAL']._serialized_start=9247 + _globals['_DEVICEREQUESTFORADMINAPPROVAL']._serialized_end=9526 + _globals['_ENTERPRISEDATA']._serialized_start=9528 + _globals['_ENTERPRISEDATA']._serialized_end=9624 + _globals['_ENTERPRISEDATARESPONSE']._serialized_start=9627 + _globals['_ENTERPRISEDATARESPONSE']._serialized_end=9835 + _globals['_BACKUPREQUEST']._serialized_start=9837 + _globals['_BACKUPREQUEST']._serialized_end=9879 + _globals['_BACKUPRECORD']._serialized_start=9882 + _globals['_BACKUPRECORD']._serialized_end=10034 + _globals['_BACKUPKEY']._serialized_start=10036 + _globals['_BACKUPKEY']._serialized_end=10082 + _globals['_BACKUPUSER']._serialized_start=10085 + _globals['_BACKUPUSER']._serialized_end=10354 + _globals['_BACKUPRESPONSE']._serialized_start=10357 + _globals['_BACKUPRESPONSE']._serialized_end=10515 + _globals['_BACKUPFILE']._serialized_start=10517 + _globals['_BACKUPFILE']._serialized_end=10618 + _globals['_BACKUPSRESPONSE']._serialized_start=10620 + _globals['_BACKUPSRESPONSE']._serialized_end=10676 + _globals['_GETENTERPRISEDATAKEYSREQUEST']._serialized_start=10678 + _globals['_GETENTERPRISEDATAKEYSREQUEST']._serialized_end=10724 + _globals['_GETENTERPRISEDATAKEYSRESPONSE']._serialized_start=10727 + _globals['_GETENTERPRISEDATAKEYSRESPONSE']._serialized_end=10982 + _globals['_ROLEKEY']._serialized_start=10984 + _globals['_ROLEKEY']._serialized_end=11078 + _globals['_MSPKEY']._serialized_start=11080 + _globals['_MSPKEY']._serialized_end=11180 + _globals['_ENTERPRISEKEYS']._serialized_start=11182 + _globals['_ENTERPRISEKEYS']._serialized_end=11306 + _globals['_TREEKEY']._serialized_start=11308 + _globals['_TREEKEY']._serialized_end=11380 + _globals['_SHAREDRECORDRESPONSE']._serialized_start=11382 + _globals['_SHAREDRECORDRESPONSE']._serialized_end=11451 + _globals['_SHAREDRECORDEVENT']._serialized_start=11453 + _globals['_SHAREDRECORDEVENT']._serialized_end=11565 + _globals['_SETRESTRICTVISIBILITYREQUEST']._serialized_start=11567 + _globals['_SETRESTRICTVISIBILITYREQUEST']._serialized_end=11613 + _globals['_USERADDREQUEST']._serialized_start=11616 + _globals['_USERADDREQUEST']._serialized_end=11824 + _globals['_USERUPDATEREQUEST']._serialized_start=11826 + _globals['_USERUPDATEREQUEST']._serialized_end=11884 + _globals['_USERUPDATE']._serialized_start=11887 + _globals['_USERUPDATE']._serialized_end=12118 + _globals['_USERUPDATERESPONSE']._serialized_start=12120 + _globals['_USERUPDATERESPONSE']._serialized_end=12185 + _globals['_USERUPDATERESULT']._serialized_start=12188 + _globals['_USERUPDATERESULT']._serialized_end=12324 + _globals['_COMPLIANCERECORDOWNERSREQUEST']._serialized_start=12326 + _globals['_COMPLIANCERECORDOWNERSREQUEST']._serialized_end=12400 + _globals['_COMPLIANCERECORDOWNERSRESPONSE']._serialized_start=12402 + _globals['_COMPLIANCERECORDOWNERSRESPONSE']._serialized_end=12481 + _globals['_RECORDOWNER']._serialized_start=12483 + _globals['_RECORDOWNER']._serialized_end=12538 + _globals['_PRELIMINARYCOMPLIANCEDATAREQUEST']._serialized_start=12541 + _globals['_PRELIMINARYCOMPLIANCEDATAREQUEST']._serialized_end=12707 + _globals['_PRELIMINARYCOMPLIANCEDATARESPONSE']._serialized_start=12710 + _globals['_PRELIMINARYCOMPLIANCEDATARESPONSE']._serialized_end=12869 + _globals['_AUDITUSERRECORD']._serialized_start=12871 + _globals['_AUDITUSERRECORD']._serialized_end=12969 + _globals['_AUDITUSERDATA']._serialized_start=12972 + _globals['_AUDITUSERDATA']._serialized_end=13113 + _globals['_COMPLIANCEREPORTFILTERS']._serialized_start=13115 + _globals['_COMPLIANCEREPORTFILTERS']._serialized_end=13242 + _globals['_COMPLIANCEREPORTREQUEST']._serialized_start=13244 + _globals['_COMPLIANCEREPORTREQUEST']._serialized_end=13371 + _globals['_COMPLIANCEREPORTRUN']._serialized_start=13374 + _globals['_COMPLIANCEREPORTRUN']._serialized_end=13507 + _globals['_COMPLIANCEREPORTCRITERIAANDFILTER']._serialized_start=13510 + _globals['_COMPLIANCEREPORTCRITERIAANDFILTER']._serialized_end=13762 + _globals['_COMPLIANCEREPORTCRITERIA']._serialized_start=13764 + _globals['_COMPLIANCEREPORTCRITERIA']._serialized_end=13862 + _globals['_COMPLIANCEREPORTFILTER']._serialized_start=13864 + _globals['_COMPLIANCEREPORTFILTER']._serialized_end=13984 + _globals['_COMPLIANCEREPORTRESPONSE']._serialized_start=13987 + _globals['_COMPLIANCEREPORTRESPONSE']._serialized_end=14660 + _globals['_AUDITRECORD']._serialized_start=14663 + _globals['_AUDITRECORD']._serialized_end=14815 + _globals['_AUDITROLE']._serialized_start=14818 + _globals['_AUDITROLE']._serialized_end=15074 + _globals['_ROLENODEMANAGEMENT']._serialized_start=15076 + _globals['_ROLENODEMANAGEMENT']._serialized_end=15170 + _globals['_USERPROFILE']._serialized_start=15172 + _globals['_USERPROFILE']._serialized_end=15279 + _globals['_RECORDPERMISSION']._serialized_start=15281 + _globals['_RECORDPERMISSION']._serialized_end=15404 + _globals['_DRIVEPERMISSION']._serialized_start=15407 + _globals['_DRIVEPERMISSION']._serialized_end=15606 + _globals['_USERRECORD']._serialized_start=15608 + _globals['_USERRECORD']._serialized_end=15703 + _globals['_AUDITTEAM']._serialized_start=15705 + _globals['_AUDITTEAM']._serialized_end=15796 + _globals['_AUDITTEAMUSER']._serialized_start=15798 + _globals['_AUDITTEAMUSER']._serialized_end=15857 + _globals['_SHAREDFOLDERRECORD']._serialized_start=15860 + _globals['_SHAREDFOLDERRECORD']._serialized_end=16019 + _globals['_SHAREADMINRECORD']._serialized_start=16021 + _globals['_SHAREADMINRECORD']._serialized_end=16098 + _globals['_SHAREDFOLDERUSER']._serialized_start=16100 + _globals['_SHAREDFOLDERUSER']._serialized_end=16170 + _globals['_SHAREDFOLDERTEAM']._serialized_start=16172 + _globals['_SHAREDFOLDERTEAM']._serialized_end=16233 + _globals['_GETCOMPLIANCEREPORTREQUEST']._serialized_start=16235 + _globals['_GETCOMPLIANCEREPORTREQUEST']._serialized_end=16282 + _globals['_GETCOMPLIANCEREPORTRESPONSE']._serialized_start=16284 + _globals['_GETCOMPLIANCEREPORTRESPONSE']._serialized_end=16334 + _globals['_COMPLIANCEREPORTCRITERIAREQUEST']._serialized_start=16336 + _globals['_COMPLIANCEREPORTCRITERIAREQUEST']._serialized_end=16390 + _globals['_SAVECOMPLIANCEREPORTCRITERIARESPONSE']._serialized_start=16392 + _globals['_SAVECOMPLIANCEREPORTCRITERIARESPONSE']._serialized_end=16451 + _globals['_LINKEDRECORD']._serialized_start=16453 + _globals['_LINKEDRECORD']._serialized_end=16505 + _globals['_GETSHARINGADMINSREQUEST']._serialized_start=16507 + _globals['_GETSHARINGADMINSREQUEST']._serialized_end=16594 + _globals['_USERPROFILEEXT']._serialized_start=16597 + _globals['_USERPROFILEEXT']._serialized_end=16821 + _globals['_GETSHARINGADMINSRESPONSE']._serialized_start=16823 + _globals['_GETSHARINGADMINSRESPONSE']._serialized_end=16902 + _globals['_TEAMSENTERPRISEUSERSADDREQUEST']._serialized_start=16904 + _globals['_TEAMSENTERPRISEUSERSADDREQUEST']._serialized_end=16999 + _globals['_TEAMSENTERPRISEUSERSADDTEAMREQUEST']._serialized_start=17001 + _globals['_TEAMSENTERPRISEUSERSADDTEAMREQUEST']._serialized_end=17117 + _globals['_TEAMSENTERPRISEUSERSADDUSERREQUEST']._serialized_start=17120 + _globals['_TEAMSENTERPRISEUSERSADDUSERREQUEST']._serialized_end=17291 + _globals['_TYPEDKEY']._serialized_start=17293 + _globals['_TYPEDKEY']._serialized_end=17363 + _globals['_TEAMSENTERPRISEUSERSADDRESPONSE']._serialized_start=17365 + _globals['_TEAMSENTERPRISEUSERSADDRESPONSE']._serialized_end=17480 + _globals['_TEAMSENTERPRISEUSERSADDTEAMRESPONSE']._serialized_start=17483 + _globals['_TEAMSENTERPRISEUSERSADDTEAMRESPONSE']._serialized_end=17679 + _globals['_TEAMSENTERPRISEUSERSADDUSERRESPONSE']._serialized_start=17682 + _globals['_TEAMSENTERPRISEUSERSADDUSERRESPONSE']._serialized_end=17841 + _globals['_TEAMENTERPRISEUSERREMOVE']._serialized_start=17843 + _globals['_TEAMENTERPRISEUSERREMOVE']._serialized_end=17912 + _globals['_TEAMENTERPRISEUSERREMOVESREQUEST']._serialized_start=17914 + _globals['_TEAMENTERPRISEUSERREMOVESREQUEST']._serialized_end=18020 + _globals['_TEAMENTERPRISEUSERREMOVESRESPONSE']._serialized_start=18022 + _globals['_TEAMENTERPRISEUSERREMOVESRESPONSE']._serialized_end=18145 + _globals['_TEAMENTERPRISEUSERREMOVERESPONSE']._serialized_start=18148 + _globals['_TEAMENTERPRISEUSERREMOVERESPONSE']._serialized_end=18332 + _globals['_DOMAINALIAS']._serialized_start=18334 + _globals['_DOMAINALIAS']._serialized_end=18411 + _globals['_DOMAINALIASREQUEST']._serialized_start=18413 + _globals['_DOMAINALIASREQUEST']._serialized_end=18479 + _globals['_DOMAINALIASRESPONSE']._serialized_start=18481 + _globals['_DOMAINALIASRESPONSE']._serialized_end=18548 + _globals['_ENTERPRISEUSERSPROVISIONREQUEST']._serialized_start=18550 + _globals['_ENTERPRISEUSERSPROVISIONREQUEST']._serialized_end=18659 + _globals['_ENTERPRISEUSERSPROVISION']._serialized_start=18662 + _globals['_ENTERPRISEUSERSPROVISION']._serialized_end=19100 + _globals['_ENTERPRISEUSERSPROVISIONRESPONSE']._serialized_start=19102 + _globals['_ENTERPRISEUSERSPROVISIONRESPONSE']._serialized_end=19197 + _globals['_ENTERPRISEUSERSPROVISIONRESULT']._serialized_start=19199 + _globals['_ENTERPRISEUSERSPROVISIONRESULT']._serialized_end=19312 + _globals['_ENTERPRISEUSERSADDREQUEST']._serialized_start=19314 + _globals['_ENTERPRISEUSERSADDREQUEST']._serialized_end=19411 + _globals['_ENTERPRISEUSERSADD']._serialized_start=19414 + _globals['_ENTERPRISEUSERSADD']._serialized_end=19682 + _globals['_ENTERPRISEUSERSADDRESPONSE']._serialized_start=19685 + _globals['_ENTERPRISEUSERSADDRESPONSE']._serialized_end=19840 + _globals['_ENTERPRISEUSERSADDRESULT']._serialized_start=19843 + _globals['_ENTERPRISEUSERSADDRESULT']._serialized_end=19993 + _globals['_UPDATEMSPPERMITSREQUEST']._serialized_start=19996 + _globals['_UPDATEMSPPERMITSREQUEST']._serialized_end=20181 + _globals['_DELETEENTERPRISEUSERSREQUEST']._serialized_start=20183 + _globals['_DELETEENTERPRISEUSERSREQUEST']._serialized_end=20240 + _globals['_DELETEENTERPRISEUSERSTATUS']._serialized_start=20242 + _globals['_DELETEENTERPRISEUSERSTATUS']._serialized_end=20353 + _globals['_DELETEENTERPRISEUSERSRESPONSE']._serialized_start=20355 + _globals['_DELETEENTERPRISEUSERSRESPONSE']._serialized_end=20448 + _globals['_CLEARSECURITYDATAREQUEST']._serialized_start=20450 + _globals['_CLEARSECURITYDATAREQUEST']._serialized_end=20569 + _globals['_LISTDOMAINSRESPONSE']._serialized_start=20571 + _globals['_LISTDOMAINSRESPONSE']._serialized_end=20608 + _globals['_RESERVEDOMAINREQUEST']._serialized_start=20610 + _globals['_RESERVEDOMAINREQUEST']._serialized_end=20710 + _globals['_RESERVEDOMAINRESPONSE']._serialized_start=20712 + _globals['_RESERVEDOMAINRESPONSE']._serialized_end=20750 + _globals['_ROLESBYTEAM']._serialized_start=20752 + _globals['_ROLESBYTEAM']._serialized_end=20798 + _globals['_LOCKUSERSREQUEST']._serialized_start=20801 + _globals['_LOCKUSERSREQUEST']._serialized_end=20942 + _globals['_LOCKUSERSRESPONSE']._serialized_start=20944 + _globals['_LOCKUSERSRESPONSE']._serialized_end=21011 + _globals['_LOCKUSERRESPONSE']._serialized_start=21013 + _globals['_LOCKUSERRESPONSE']._serialized_end=21123 # @@protoc_insertion_point(module_scope) diff --git a/keepercommander/proto/enterprise_pb2.pyi b/keepercommander/proto/enterprise_pb2.pyi index 2d6931822..c527a7059 100644 --- a/keepercommander/proto/enterprise_pb2.pyi +++ b/keepercommander/proto/enterprise_pb2.pyi @@ -1,3 +1,4 @@ +from . import folder_pb2 as _folder_pb2 from google.protobuf.internal import containers as _containers from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper from google.protobuf import descriptor as _descriptor @@ -23,6 +24,7 @@ class RoleUserModifyStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): MUST_HAVE_ONE_USER_ADMIN: _ClassVar[RoleUserModifyStatus] INVALID_ROLE_ID: _ClassVar[RoleUserModifyStatus] PAM_LICENSE_SEAT_EXCEEDED: _ClassVar[RoleUserModifyStatus] + WOULD_LOCK_SELF: _ClassVar[RoleUserModifyStatus] class EnterpriseType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () @@ -110,6 +112,12 @@ class UserUpdateStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () USER_UPDATE_OK: _ClassVar[UserUpdateStatus] USER_UPDATE_ACCESS_DENIED: _ClassVar[UserUpdateStatus] + USER_UPDATE_EXCEEDED_LICENSE_SEATS: _ClassVar[UserUpdateStatus] + USER_UPDATE_BAD_REQUEST: _ClassVar[UserUpdateStatus] + USER_UPDATE_DUPLICATE: _ClassVar[UserUpdateStatus] + USER_UPDATE_INVALID_STATE: _ClassVar[UserUpdateStatus] + USER_UPDATE_FAILED: _ClassVar[UserUpdateStatus] + USER_UPDATE_ERROR: _ClassVar[UserUpdateStatus] class AuditUserStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () @@ -178,6 +186,7 @@ MAY_NOT_REMOVE_SELF_FROM_ROLE: RoleUserModifyStatus MUST_HAVE_ONE_USER_ADMIN: RoleUserModifyStatus INVALID_ROLE_ID: RoleUserModifyStatus PAM_LICENSE_SEAT_EXCEEDED: RoleUserModifyStatus +WOULD_LOCK_SELF: RoleUserModifyStatus ENTERPRISE_STANDARD: EnterpriseType ENTERPRISE_MSP: EnterpriseType UNDEFINED: TransferAcceptanceStatus @@ -238,6 +247,12 @@ FORBID_KEY_TYPE_1: EnterpriseFlagType KEEPER_DRIVE: EnterpriseFlagType USER_UPDATE_OK: UserUpdateStatus USER_UPDATE_ACCESS_DENIED: UserUpdateStatus +USER_UPDATE_EXCEEDED_LICENSE_SEATS: UserUpdateStatus +USER_UPDATE_BAD_REQUEST: UserUpdateStatus +USER_UPDATE_DUPLICATE: UserUpdateStatus +USER_UPDATE_INVALID_STATE: UserUpdateStatus +USER_UPDATE_FAILED: UserUpdateStatus +USER_UPDATE_ERROR: UserUpdateStatus OK: AuditUserStatus ACCESS_DENIED: AuditUserStatus NO_LONGER_IN_ENTERPRISE: AuditUserStatus @@ -299,7 +314,7 @@ class EnterpriseUser(_message.Message): enterpriseUsername: str isShareAdmin: bool username: str - def __init__(self, enterpriseUserId: _Optional[int] = ..., email: _Optional[str] = ..., enterpriseUsername: _Optional[str] = ..., isShareAdmin: bool = ..., username: _Optional[str] = ...) -> None: ... + def __init__(self, enterpriseUserId: _Optional[int] = ..., email: _Optional[str] = ..., enterpriseUsername: _Optional[str] = ..., isShareAdmin: _Optional[bool] = ..., username: _Optional[str] = ...) -> None: ... class GetTeamMemberResponse(_message.Message): __slots__ = ("enterpriseUser",) @@ -329,7 +344,7 @@ class EncryptedTeamKeyRequest(_message.Message): teamUid: bytes encryptedTeamKey: bytes force: bool - def __init__(self, teamUid: _Optional[bytes] = ..., encryptedTeamKey: _Optional[bytes] = ..., force: bool = ...) -> None: ... + def __init__(self, teamUid: _Optional[bytes] = ..., encryptedTeamKey: _Optional[bytes] = ..., force: _Optional[bool] = ...) -> None: ... class ReEncryptedData(_message.Message): __slots__ = ("id", "data") @@ -404,14 +419,16 @@ class ManagedNodesByRole(_message.Message): def __init__(self, role_id: _Optional[int] = ..., managedNodeId: _Optional[_Iterable[int]] = ...) -> None: ... class RoleUserAddKeys(_message.Message): - __slots__ = ("enterpriseUserId", "treeKey", "roleAdminKey") + __slots__ = ("enterpriseUserId", "treeKey", "roleAdminKey", "typedTreeKey") ENTERPRISEUSERID_FIELD_NUMBER: _ClassVar[int] TREEKEY_FIELD_NUMBER: _ClassVar[int] ROLEADMINKEY_FIELD_NUMBER: _ClassVar[int] + TYPEDTREEKEY_FIELD_NUMBER: _ClassVar[int] enterpriseUserId: int treeKey: str roleAdminKey: str - def __init__(self, enterpriseUserId: _Optional[int] = ..., treeKey: _Optional[str] = ..., roleAdminKey: _Optional[str] = ...) -> None: ... + typedTreeKey: TypedKey + def __init__(self, enterpriseUserId: _Optional[int] = ..., treeKey: _Optional[str] = ..., roleAdminKey: _Optional[str] = ..., typedTreeKey: _Optional[_Union[TypedKey, _Mapping]] = ...) -> None: ... class RoleUserAdd(_message.Message): __slots__ = ("role_id", "roleUserAddKeys") @@ -531,7 +548,7 @@ class DomainPasswordRulesFields(_message.Message): minimum: int maximum: int allowed: bool - def __init__(self, type: _Optional[str] = ..., minimum: _Optional[int] = ..., maximum: _Optional[int] = ..., allowed: bool = ...) -> None: ... + def __init__(self, type: _Optional[str] = ..., minimum: _Optional[int] = ..., maximum: _Optional[int] = ..., allowed: _Optional[bool] = ...) -> None: ... class LoginToMcRequest(_message.Message): __slots__ = ("mcEnterpriseId", "messageSessionUid") @@ -551,7 +568,7 @@ class LoginToMcResponse(_message.Message): encryptedTreeKey: str keyTypeId: int forbidKeyType2: bool - def __init__(self, encryptedSessionToken: _Optional[bytes] = ..., encryptedTreeKey: _Optional[str] = ..., keyTypeId: _Optional[int] = ..., forbidKeyType2: bool = ...) -> None: ... + def __init__(self, encryptedSessionToken: _Optional[bytes] = ..., encryptedTreeKey: _Optional[str] = ..., keyTypeId: _Optional[int] = ..., forbidKeyType2: _Optional[bool] = ...) -> None: ... class DomainPasswordRulesResponse(_message.Message): __slots__ = ("domainPasswordRulesFields",) @@ -569,7 +586,7 @@ class ApproveUserDeviceRequest(_message.Message): encryptedDeviceToken: bytes encryptedDeviceDataKey: bytes denyApproval: bool - def __init__(self, enterpriseUserId: _Optional[int] = ..., encryptedDeviceToken: _Optional[bytes] = ..., encryptedDeviceDataKey: _Optional[bytes] = ..., denyApproval: bool = ...) -> None: ... + def __init__(self, enterpriseUserId: _Optional[int] = ..., encryptedDeviceToken: _Optional[bytes] = ..., encryptedDeviceDataKey: _Optional[bytes] = ..., denyApproval: _Optional[bool] = ...) -> None: ... class ApproveUserDeviceResponse(_message.Message): __slots__ = ("enterpriseUserId", "encryptedDeviceToken", "failed", "message") @@ -581,7 +598,7 @@ class ApproveUserDeviceResponse(_message.Message): encryptedDeviceToken: bytes failed: bool message: str - def __init__(self, enterpriseUserId: _Optional[int] = ..., encryptedDeviceToken: _Optional[bytes] = ..., failed: bool = ..., message: _Optional[str] = ...) -> None: ... + def __init__(self, enterpriseUserId: _Optional[int] = ..., encryptedDeviceToken: _Optional[bytes] = ..., failed: _Optional[bool] = ..., message: _Optional[str] = ...) -> None: ... class ApproveUserDevicesRequest(_message.Message): __slots__ = ("deviceRequests",) @@ -669,7 +686,7 @@ class GeneralDataEntity(_message.Message): distributor: bool forbidAccountTransfer: bool showUserOnboard: bool - def __init__(self, enterpriseName: _Optional[str] = ..., restrictVisibility: bool = ..., specialProvisioning: _Optional[_Union[SpecialProvisioning, _Mapping]] = ..., userPrivilege: _Optional[_Union[UserPrivilege, _Mapping]] = ..., distributor: bool = ..., forbidAccountTransfer: bool = ..., showUserOnboard: bool = ...) -> None: ... + def __init__(self, enterpriseName: _Optional[str] = ..., restrictVisibility: _Optional[bool] = ..., specialProvisioning: _Optional[_Union[SpecialProvisioning, _Mapping]] = ..., userPrivilege: _Optional[_Union[UserPrivilege, _Mapping]] = ..., distributor: _Optional[bool] = ..., forbidAccountTransfer: _Optional[bool] = ..., showUserOnboard: _Optional[bool] = ...) -> None: ... class Node(_message.Message): __slots__ = ("nodeId", "parentId", "bridgeId", "scimId", "licenseId", "encryptedData", "duoEnabled", "rsaEnabled", "ssoServiceProviderId", "restrictVisibility", "ssoServiceProviderIds") @@ -695,7 +712,7 @@ class Node(_message.Message): ssoServiceProviderId: int restrictVisibility: bool ssoServiceProviderIds: _containers.RepeatedScalarFieldContainer[int] - def __init__(self, nodeId: _Optional[int] = ..., parentId: _Optional[int] = ..., bridgeId: _Optional[int] = ..., scimId: _Optional[int] = ..., licenseId: _Optional[int] = ..., encryptedData: _Optional[str] = ..., duoEnabled: bool = ..., rsaEnabled: bool = ..., ssoServiceProviderId: _Optional[int] = ..., restrictVisibility: bool = ..., ssoServiceProviderIds: _Optional[_Iterable[int]] = ...) -> None: ... + def __init__(self, nodeId: _Optional[int] = ..., parentId: _Optional[int] = ..., bridgeId: _Optional[int] = ..., scimId: _Optional[int] = ..., licenseId: _Optional[int] = ..., encryptedData: _Optional[str] = ..., duoEnabled: _Optional[bool] = ..., rsaEnabled: _Optional[bool] = ..., ssoServiceProviderId: _Optional[int] = ..., restrictVisibility: _Optional[bool] = ..., ssoServiceProviderIds: _Optional[_Iterable[int]] = ...) -> None: ... class Role(_message.Message): __slots__ = ("roleId", "nodeId", "encryptedData", "keyType", "visibleBelow", "newUserInherit", "roleType") @@ -713,7 +730,7 @@ class Role(_message.Message): visibleBelow: bool newUserInherit: bool roleType: str - def __init__(self, roleId: _Optional[int] = ..., nodeId: _Optional[int] = ..., encryptedData: _Optional[str] = ..., keyType: _Optional[str] = ..., visibleBelow: bool = ..., newUserInherit: bool = ..., roleType: _Optional[str] = ...) -> None: ... + def __init__(self, roleId: _Optional[int] = ..., nodeId: _Optional[int] = ..., encryptedData: _Optional[str] = ..., keyType: _Optional[str] = ..., visibleBelow: _Optional[bool] = ..., newUserInherit: _Optional[bool] = ..., roleType: _Optional[str] = ...) -> None: ... class User(_message.Message): __slots__ = ("enterpriseUserId", "nodeId", "encryptedData", "keyType", "username", "status", "lock", "userId", "accountShareExpiration", "fullName", "jobTitle", "tfaEnabled", "transferAcceptanceStatus") @@ -743,7 +760,7 @@ class User(_message.Message): jobTitle: str tfaEnabled: bool transferAcceptanceStatus: TransferAcceptanceStatus - def __init__(self, enterpriseUserId: _Optional[int] = ..., nodeId: _Optional[int] = ..., encryptedData: _Optional[str] = ..., keyType: _Optional[str] = ..., username: _Optional[str] = ..., status: _Optional[str] = ..., lock: _Optional[int] = ..., userId: _Optional[int] = ..., accountShareExpiration: _Optional[int] = ..., fullName: _Optional[str] = ..., jobTitle: _Optional[str] = ..., tfaEnabled: bool = ..., transferAcceptanceStatus: _Optional[_Union[TransferAcceptanceStatus, str]] = ...) -> None: ... + def __init__(self, enterpriseUserId: _Optional[int] = ..., nodeId: _Optional[int] = ..., encryptedData: _Optional[str] = ..., keyType: _Optional[str] = ..., username: _Optional[str] = ..., status: _Optional[str] = ..., lock: _Optional[int] = ..., userId: _Optional[int] = ..., accountShareExpiration: _Optional[int] = ..., fullName: _Optional[str] = ..., jobTitle: _Optional[str] = ..., tfaEnabled: _Optional[bool] = ..., transferAcceptanceStatus: _Optional[_Union[TransferAcceptanceStatus, str]] = ...) -> None: ... class UserAlias(_message.Message): __slots__ = ("enterpriseUserId", "username") @@ -779,7 +796,7 @@ class ManagedNode(_message.Message): roleId: int managedNodeId: int cascadeNodeManagement: bool - def __init__(self, roleId: _Optional[int] = ..., managedNodeId: _Optional[int] = ..., cascadeNodeManagement: bool = ...) -> None: ... + def __init__(self, roleId: _Optional[int] = ..., managedNodeId: _Optional[int] = ..., cascadeNodeManagement: _Optional[bool] = ...) -> None: ... class UserManagedNode(_message.Message): __slots__ = ("nodeId", "cascadeNodeManagement", "privileges") @@ -789,7 +806,7 @@ class UserManagedNode(_message.Message): nodeId: int cascadeNodeManagement: bool privileges: _containers.RepeatedScalarFieldContainer[str] - def __init__(self, nodeId: _Optional[int] = ..., cascadeNodeManagement: bool = ..., privileges: _Optional[_Iterable[str]] = ...) -> None: ... + def __init__(self, nodeId: _Optional[int] = ..., cascadeNodeManagement: _Optional[bool] = ..., privileges: _Optional[_Iterable[str]] = ...) -> None: ... class UserPrivilege(_message.Message): __slots__ = ("userManagedNodes", "enterpriseUserId", "encryptedData") @@ -857,7 +874,7 @@ class Team(_message.Message): restrictView: bool encryptedData: str encryptedTeamKey: str - def __init__(self, teamUid: _Optional[bytes] = ..., name: _Optional[str] = ..., nodeId: _Optional[int] = ..., restrictEdit: bool = ..., restrictShare: bool = ..., restrictView: bool = ..., encryptedData: _Optional[str] = ..., encryptedTeamKey: _Optional[str] = ...) -> None: ... + def __init__(self, teamUid: _Optional[bytes] = ..., name: _Optional[str] = ..., nodeId: _Optional[int] = ..., restrictEdit: _Optional[bool] = ..., restrictShare: _Optional[bool] = ..., restrictView: _Optional[bool] = ..., encryptedData: _Optional[str] = ..., encryptedTeamKey: _Optional[str] = ...) -> None: ... class TeamUser(_message.Message): __slots__ = ("teamUid", "enterpriseUserId", "userType") @@ -903,7 +920,7 @@ class MspInfo(_message.Message): managedCompanies: _containers.RepeatedCompositeFieldContainer[ManagedCompany] allowUnlimitedLicenses: bool addOns: _containers.RepeatedCompositeFieldContainer[LicenseAddOn] - def __init__(self, enterpriseId: _Optional[int] = ..., enterpriseName: _Optional[str] = ..., allocatedLicenses: _Optional[int] = ..., allowedMcProducts: _Optional[_Iterable[str]] = ..., allowedAddOns: _Optional[_Iterable[str]] = ..., maxFilePlanType: _Optional[str] = ..., managedCompanies: _Optional[_Iterable[_Union[ManagedCompany, _Mapping]]] = ..., allowUnlimitedLicenses: bool = ..., addOns: _Optional[_Iterable[_Union[LicenseAddOn, _Mapping]]] = ...) -> None: ... + def __init__(self, enterpriseId: _Optional[int] = ..., enterpriseName: _Optional[str] = ..., allocatedLicenses: _Optional[int] = ..., allowedMcProducts: _Optional[_Iterable[str]] = ..., allowedAddOns: _Optional[_Iterable[str]] = ..., maxFilePlanType: _Optional[str] = ..., managedCompanies: _Optional[_Iterable[_Union[ManagedCompany, _Mapping]]] = ..., allowUnlimitedLicenses: _Optional[bool] = ..., addOns: _Optional[_Iterable[_Union[LicenseAddOn, _Mapping]]] = ...) -> None: ... class ManagedCompany(_message.Message): __slots__ = ("mcEnterpriseId", "mcEnterpriseName", "mspNodeId", "numberOfSeats", "numberOfUsers", "productId", "isExpired", "treeKey", "tree_key_role", "filePlanType", "addOns", "treeKeyTypeId") @@ -931,7 +948,7 @@ class ManagedCompany(_message.Message): filePlanType: str addOns: _containers.RepeatedCompositeFieldContainer[LicenseAddOn] treeKeyTypeId: int - def __init__(self, mcEnterpriseId: _Optional[int] = ..., mcEnterpriseName: _Optional[str] = ..., mspNodeId: _Optional[int] = ..., numberOfSeats: _Optional[int] = ..., numberOfUsers: _Optional[int] = ..., productId: _Optional[str] = ..., isExpired: bool = ..., treeKey: _Optional[str] = ..., tree_key_role: _Optional[int] = ..., filePlanType: _Optional[str] = ..., addOns: _Optional[_Iterable[_Union[LicenseAddOn, _Mapping]]] = ..., treeKeyTypeId: _Optional[int] = ...) -> None: ... + def __init__(self, mcEnterpriseId: _Optional[int] = ..., mcEnterpriseName: _Optional[str] = ..., mspNodeId: _Optional[int] = ..., numberOfSeats: _Optional[int] = ..., numberOfUsers: _Optional[int] = ..., productId: _Optional[str] = ..., isExpired: _Optional[bool] = ..., treeKey: _Optional[str] = ..., tree_key_role: _Optional[int] = ..., filePlanType: _Optional[str] = ..., addOns: _Optional[_Iterable[_Union[LicenseAddOn, _Mapping]]] = ..., treeKeyTypeId: _Optional[int] = ...) -> None: ... class MSPPool(_message.Message): __slots__ = ("productId", "seats", "availableSeats", "stash") @@ -979,7 +996,7 @@ class LicenseAddOn(_message.Message): tierDescription: str seatsAllocated: int nhiTierAddOnId: int - def __init__(self, name: _Optional[str] = ..., enabled: bool = ..., isTrial: bool = ..., expiration: _Optional[int] = ..., created: _Optional[int] = ..., seats: _Optional[int] = ..., activationTime: _Optional[int] = ..., includedInProduct: bool = ..., apiCallCount: _Optional[int] = ..., tierDescription: _Optional[str] = ..., seatsAllocated: _Optional[int] = ..., nhiTierAddOnId: _Optional[int] = ...) -> None: ... + def __init__(self, name: _Optional[str] = ..., enabled: _Optional[bool] = ..., isTrial: _Optional[bool] = ..., expiration: _Optional[int] = ..., created: _Optional[int] = ..., seats: _Optional[int] = ..., activationTime: _Optional[int] = ..., includedInProduct: _Optional[bool] = ..., apiCallCount: _Optional[int] = ..., tierDescription: _Optional[str] = ..., seatsAllocated: _Optional[int] = ..., nhiTierAddOnId: _Optional[int] = ...) -> None: ... class MCDefault(_message.Message): __slots__ = ("mcProduct", "addOns", "filePlanType", "maxLicenses", "fixedMaxLicenses") @@ -993,7 +1010,7 @@ class MCDefault(_message.Message): filePlanType: str maxLicenses: int fixedMaxLicenses: bool - def __init__(self, mcProduct: _Optional[str] = ..., addOns: _Optional[_Iterable[str]] = ..., filePlanType: _Optional[str] = ..., maxLicenses: _Optional[int] = ..., fixedMaxLicenses: bool = ...) -> None: ... + def __init__(self, mcProduct: _Optional[str] = ..., addOns: _Optional[_Iterable[str]] = ..., filePlanType: _Optional[str] = ..., maxLicenses: _Optional[int] = ..., fixedMaxLicenses: _Optional[bool] = ...) -> None: ... class MSPPermits(_message.Message): __slots__ = ("restricted", "maxAllowedLicenses", "allowedMcProducts", "allowedAddOns", "maxFilePlanType", "allowUnlimitedLicenses", "mcDefaults") @@ -1011,7 +1028,7 @@ class MSPPermits(_message.Message): maxFilePlanType: str allowUnlimitedLicenses: bool mcDefaults: _containers.RepeatedCompositeFieldContainer[MCDefault] - def __init__(self, restricted: bool = ..., maxAllowedLicenses: _Optional[int] = ..., allowedMcProducts: _Optional[_Iterable[str]] = ..., allowedAddOns: _Optional[_Iterable[str]] = ..., maxFilePlanType: _Optional[str] = ..., allowUnlimitedLicenses: bool = ..., mcDefaults: _Optional[_Iterable[_Union[MCDefault, _Mapping]]] = ...) -> None: ... + def __init__(self, restricted: _Optional[bool] = ..., maxAllowedLicenses: _Optional[int] = ..., allowedMcProducts: _Optional[_Iterable[str]] = ..., allowedAddOns: _Optional[_Iterable[str]] = ..., maxFilePlanType: _Optional[str] = ..., allowUnlimitedLicenses: _Optional[bool] = ..., mcDefaults: _Optional[_Iterable[_Union[MCDefault, _Mapping]]] = ...) -> None: ... class License(_message.Message): __slots__ = ("paid", "numberOfSeats", "expiration", "licenseKeyId", "productTypeId", "name", "enterpriseLicenseId", "seatsAllocated", "seatsPending", "tier", "filePlanTypeId", "maxBytes", "storageExpiration", "licenseStatus", "mspPool", "managedBy", "addOns", "nextBillingDate", "hasMSPLegacyLog", "mspPermits", "distributor") @@ -1057,7 +1074,7 @@ class License(_message.Message): hasMSPLegacyLog: bool mspPermits: MSPPermits distributor: bool - def __init__(self, paid: bool = ..., numberOfSeats: _Optional[int] = ..., expiration: _Optional[int] = ..., licenseKeyId: _Optional[int] = ..., productTypeId: _Optional[int] = ..., name: _Optional[str] = ..., enterpriseLicenseId: _Optional[int] = ..., seatsAllocated: _Optional[int] = ..., seatsPending: _Optional[int] = ..., tier: _Optional[int] = ..., filePlanTypeId: _Optional[int] = ..., maxBytes: _Optional[int] = ..., storageExpiration: _Optional[int] = ..., licenseStatus: _Optional[str] = ..., mspPool: _Optional[_Iterable[_Union[MSPPool, _Mapping]]] = ..., managedBy: _Optional[_Union[MSPContact, _Mapping]] = ..., addOns: _Optional[_Iterable[_Union[LicenseAddOn, _Mapping]]] = ..., nextBillingDate: _Optional[int] = ..., hasMSPLegacyLog: bool = ..., mspPermits: _Optional[_Union[MSPPermits, _Mapping]] = ..., distributor: bool = ...) -> None: ... + def __init__(self, paid: _Optional[bool] = ..., numberOfSeats: _Optional[int] = ..., expiration: _Optional[int] = ..., licenseKeyId: _Optional[int] = ..., productTypeId: _Optional[int] = ..., name: _Optional[str] = ..., enterpriseLicenseId: _Optional[int] = ..., seatsAllocated: _Optional[int] = ..., seatsPending: _Optional[int] = ..., tier: _Optional[int] = ..., filePlanTypeId: _Optional[int] = ..., maxBytes: _Optional[int] = ..., storageExpiration: _Optional[int] = ..., licenseStatus: _Optional[str] = ..., mspPool: _Optional[_Iterable[_Union[MSPPool, _Mapping]]] = ..., managedBy: _Optional[_Union[MSPContact, _Mapping]] = ..., addOns: _Optional[_Iterable[_Union[LicenseAddOn, _Mapping]]] = ..., nextBillingDate: _Optional[int] = ..., hasMSPLegacyLog: _Optional[bool] = ..., mspPermits: _Optional[_Union[MSPPermits, _Mapping]] = ..., distributor: _Optional[bool] = ...) -> None: ... class Bridge(_message.Message): __slots__ = ("bridgeId", "nodeId", "wanIpEnforcement", "lanIpEnforcement", "status") @@ -1087,7 +1104,7 @@ class Scim(_message.Message): lastSynced: int rolePrefix: str uniqueGroups: bool - def __init__(self, scimId: _Optional[int] = ..., nodeId: _Optional[int] = ..., status: _Optional[str] = ..., lastSynced: _Optional[int] = ..., rolePrefix: _Optional[str] = ..., uniqueGroups: bool = ...) -> None: ... + def __init__(self, scimId: _Optional[int] = ..., nodeId: _Optional[int] = ..., status: _Optional[str] = ..., lastSynced: _Optional[int] = ..., rolePrefix: _Optional[str] = ..., uniqueGroups: _Optional[bool] = ...) -> None: ... class EmailProvision(_message.Message): __slots__ = ("id", "nodeId", "domain", "method") @@ -1159,7 +1176,7 @@ class SsoService(_message.Message): inviteNewUsers: bool active: bool isCloud: bool - def __init__(self, ssoServiceProviderId: _Optional[int] = ..., nodeId: _Optional[int] = ..., name: _Optional[str] = ..., sp_url: _Optional[str] = ..., inviteNewUsers: bool = ..., active: bool = ..., isCloud: bool = ...) -> None: ... + def __init__(self, ssoServiceProviderId: _Optional[int] = ..., nodeId: _Optional[int] = ..., name: _Optional[str] = ..., sp_url: _Optional[str] = ..., inviteNewUsers: _Optional[bool] = ..., active: _Optional[bool] = ..., isCloud: _Optional[bool] = ...) -> None: ... class ReportFilterUser(_message.Message): __slots__ = ("userId", "email") @@ -1205,7 +1222,7 @@ class EnterpriseData(_message.Message): entity: EnterpriseDataEntity delete: bool data: _containers.RepeatedScalarFieldContainer[bytes] - def __init__(self, entity: _Optional[_Union[EnterpriseDataEntity, str]] = ..., delete: bool = ..., data: _Optional[_Iterable[bytes]] = ...) -> None: ... + def __init__(self, entity: _Optional[_Union[EnterpriseDataEntity, str]] = ..., delete: _Optional[bool] = ..., data: _Optional[_Iterable[bytes]] = ...) -> None: ... class EnterpriseDataResponse(_message.Message): __slots__ = ("continuationToken", "hasMore", "cacheStatus", "data", "generalData") @@ -1219,7 +1236,7 @@ class EnterpriseDataResponse(_message.Message): cacheStatus: CacheStatus data: _containers.RepeatedCompositeFieldContainer[EnterpriseData] generalData: GeneralDataEntity - def __init__(self, continuationToken: _Optional[bytes] = ..., hasMore: bool = ..., cacheStatus: _Optional[_Union[CacheStatus, str]] = ..., data: _Optional[_Iterable[_Union[EnterpriseData, _Mapping]]] = ..., generalData: _Optional[_Union[GeneralDataEntity, _Mapping]] = ...) -> None: ... + def __init__(self, continuationToken: _Optional[bytes] = ..., hasMore: _Optional[bool] = ..., cacheStatus: _Optional[_Union[CacheStatus, str]] = ..., data: _Optional[_Iterable[_Union[EnterpriseData, _Mapping]]] = ..., generalData: _Optional[_Union[GeneralDataEntity, _Mapping]] = ...) -> None: ... class BackupRequest(_message.Message): __slots__ = ("continuationToken",) @@ -1383,7 +1400,7 @@ class SharedRecordEvent(_message.Message): canEdit: bool canReshare: bool shareFrom: int - def __init__(self, recordUid: _Optional[bytes] = ..., userName: _Optional[str] = ..., canEdit: bool = ..., canReshare: bool = ..., shareFrom: _Optional[int] = ...) -> None: ... + def __init__(self, recordUid: _Optional[bytes] = ..., userName: _Optional[str] = ..., canEdit: _Optional[bool] = ..., canReshare: _Optional[bool] = ..., shareFrom: _Optional[int] = ...) -> None: ... class SetRestrictVisibilityRequest(_message.Message): __slots__ = ("nodeId",) @@ -1409,7 +1426,7 @@ class UserAddRequest(_message.Message): jobTitle: str email: str suppressEmailInvite: bool - def __init__(self, enterpriseUserId: _Optional[int] = ..., nodeId: _Optional[int] = ..., encryptedData: _Optional[bytes] = ..., keyType: _Optional[_Union[EncryptedKeyType, str]] = ..., fullName: _Optional[str] = ..., jobTitle: _Optional[str] = ..., email: _Optional[str] = ..., suppressEmailInvite: bool = ...) -> None: ... + def __init__(self, enterpriseUserId: _Optional[int] = ..., nodeId: _Optional[int] = ..., encryptedData: _Optional[bytes] = ..., keyType: _Optional[_Union[EncryptedKeyType, str]] = ..., fullName: _Optional[str] = ..., jobTitle: _Optional[str] = ..., email: _Optional[str] = ..., suppressEmailInvite: _Optional[bool] = ...) -> None: ... class UserUpdateRequest(_message.Message): __slots__ = ("users",) @@ -1418,7 +1435,7 @@ class UserUpdateRequest(_message.Message): def __init__(self, users: _Optional[_Iterable[_Union[UserUpdate, _Mapping]]] = ...) -> None: ... class UserUpdate(_message.Message): - __slots__ = ("enterpriseUserId", "nodeId", "encryptedData", "keyType", "fullName", "jobTitle", "email") + __slots__ = ("enterpriseUserId", "nodeId", "encryptedData", "keyType", "fullName", "jobTitle", "email", "inviteeLocale", "encryptedDataString") ENTERPRISEUSERID_FIELD_NUMBER: _ClassVar[int] NODEID_FIELD_NUMBER: _ClassVar[int] ENCRYPTEDDATA_FIELD_NUMBER: _ClassVar[int] @@ -1426,6 +1443,8 @@ class UserUpdate(_message.Message): FULLNAME_FIELD_NUMBER: _ClassVar[int] JOBTITLE_FIELD_NUMBER: _ClassVar[int] EMAIL_FIELD_NUMBER: _ClassVar[int] + INVITEELOCALE_FIELD_NUMBER: _ClassVar[int] + ENCRYPTEDDATASTRING_FIELD_NUMBER: _ClassVar[int] enterpriseUserId: int nodeId: int encryptedData: bytes @@ -1433,7 +1452,9 @@ class UserUpdate(_message.Message): fullName: str jobTitle: str email: str - def __init__(self, enterpriseUserId: _Optional[int] = ..., nodeId: _Optional[int] = ..., encryptedData: _Optional[bytes] = ..., keyType: _Optional[_Union[EncryptedKeyType, str]] = ..., fullName: _Optional[str] = ..., jobTitle: _Optional[str] = ..., email: _Optional[str] = ...) -> None: ... + inviteeLocale: str + encryptedDataString: str + def __init__(self, enterpriseUserId: _Optional[int] = ..., nodeId: _Optional[int] = ..., encryptedData: _Optional[bytes] = ..., keyType: _Optional[_Union[EncryptedKeyType, str]] = ..., fullName: _Optional[str] = ..., jobTitle: _Optional[str] = ..., email: _Optional[str] = ..., inviteeLocale: _Optional[str] = ..., encryptedDataString: _Optional[str] = ...) -> None: ... class UserUpdateResponse(_message.Message): __slots__ = ("users",) @@ -1442,12 +1463,16 @@ class UserUpdateResponse(_message.Message): def __init__(self, users: _Optional[_Iterable[_Union[UserUpdateResult, _Mapping]]] = ...) -> None: ... class UserUpdateResult(_message.Message): - __slots__ = ("enterpriseUserId", "status") + __slots__ = ("enterpriseUserId", "status", "errorMessage", "additionalInfo") ENTERPRISEUSERID_FIELD_NUMBER: _ClassVar[int] STATUS_FIELD_NUMBER: _ClassVar[int] + ERRORMESSAGE_FIELD_NUMBER: _ClassVar[int] + ADDITIONALINFO_FIELD_NUMBER: _ClassVar[int] enterpriseUserId: int status: UserUpdateStatus - def __init__(self, enterpriseUserId: _Optional[int] = ..., status: _Optional[_Union[UserUpdateStatus, str]] = ...) -> None: ... + errorMessage: str + additionalInfo: str + def __init__(self, enterpriseUserId: _Optional[int] = ..., status: _Optional[_Union[UserUpdateStatus, str]] = ..., errorMessage: _Optional[str] = ..., additionalInfo: _Optional[str] = ...) -> None: ... class ComplianceRecordOwnersRequest(_message.Message): __slots__ = ("nodeIds", "includeNonShared") @@ -1455,7 +1480,7 @@ class ComplianceRecordOwnersRequest(_message.Message): INCLUDENONSHARED_FIELD_NUMBER: _ClassVar[int] nodeIds: _containers.RepeatedScalarFieldContainer[int] includeNonShared: bool - def __init__(self, nodeIds: _Optional[_Iterable[int]] = ..., includeNonShared: bool = ...) -> None: ... + def __init__(self, nodeIds: _Optional[_Iterable[int]] = ..., includeNonShared: _Optional[bool] = ...) -> None: ... class ComplianceRecordOwnersResponse(_message.Message): __slots__ = ("recordOwners",) @@ -1469,7 +1494,7 @@ class RecordOwner(_message.Message): SHARED_FIELD_NUMBER: _ClassVar[int] enterpriseUserId: int shared: bool - def __init__(self, enterpriseUserId: _Optional[int] = ..., shared: bool = ...) -> None: ... + def __init__(self, enterpriseUserId: _Optional[int] = ..., shared: _Optional[bool] = ...) -> None: ... class PreliminaryComplianceDataRequest(_message.Message): __slots__ = ("enterpriseUserIds", "includeNonShared", "continuationToken", "includeTotalMatchingRecordsInFirstResponse") @@ -1481,7 +1506,7 @@ class PreliminaryComplianceDataRequest(_message.Message): includeNonShared: bool continuationToken: bytes includeTotalMatchingRecordsInFirstResponse: bool - def __init__(self, enterpriseUserIds: _Optional[_Iterable[int]] = ..., includeNonShared: bool = ..., continuationToken: _Optional[bytes] = ..., includeTotalMatchingRecordsInFirstResponse: bool = ...) -> None: ... + def __init__(self, enterpriseUserIds: _Optional[_Iterable[int]] = ..., includeNonShared: _Optional[bool] = ..., continuationToken: _Optional[bytes] = ..., includeTotalMatchingRecordsInFirstResponse: _Optional[bool] = ...) -> None: ... class PreliminaryComplianceDataResponse(_message.Message): __slots__ = ("auditUserData", "continuationToken", "hasMore", "totalMatchingRecords") @@ -1493,17 +1518,19 @@ class PreliminaryComplianceDataResponse(_message.Message): continuationToken: bytes hasMore: bool totalMatchingRecords: int - def __init__(self, auditUserData: _Optional[_Iterable[_Union[AuditUserData, _Mapping]]] = ..., continuationToken: _Optional[bytes] = ..., hasMore: bool = ..., totalMatchingRecords: _Optional[int] = ...) -> None: ... + def __init__(self, auditUserData: _Optional[_Iterable[_Union[AuditUserData, _Mapping]]] = ..., continuationToken: _Optional[bytes] = ..., hasMore: _Optional[bool] = ..., totalMatchingRecords: _Optional[int] = ...) -> None: ... class AuditUserRecord(_message.Message): - __slots__ = ("recordUid", "encryptedData", "shared") + __slots__ = ("recordUid", "encryptedData", "shared", "isDriveRecord") RECORDUID_FIELD_NUMBER: _ClassVar[int] ENCRYPTEDDATA_FIELD_NUMBER: _ClassVar[int] SHARED_FIELD_NUMBER: _ClassVar[int] + ISDRIVERECORD_FIELD_NUMBER: _ClassVar[int] recordUid: bytes encryptedData: bytes shared: bool - def __init__(self, recordUid: _Optional[bytes] = ..., encryptedData: _Optional[bytes] = ..., shared: bool = ...) -> None: ... + isDriveRecord: bool + def __init__(self, recordUid: _Optional[bytes] = ..., encryptedData: _Optional[bytes] = ..., shared: _Optional[bool] = ..., isDriveRecord: _Optional[bool] = ...) -> None: ... class AuditUserData(_message.Message): __slots__ = ("enterpriseUserId", "auditUserRecords", "status") @@ -1537,7 +1564,7 @@ class ComplianceReportRequest(_message.Message): complianceReportRun: ComplianceReportRun reportName: str saveReport: bool - def __init__(self, complianceReportRun: _Optional[_Union[ComplianceReportRun, _Mapping]] = ..., reportName: _Optional[str] = ..., saveReport: bool = ...) -> None: ... + def __init__(self, complianceReportRun: _Optional[_Union[ComplianceReportRun, _Mapping]] = ..., reportName: _Optional[str] = ..., saveReport: _Optional[bool] = ...) -> None: ... class ComplianceReportRun(_message.Message): __slots__ = ("reportCriteriaAndFilter", "users", "records") @@ -1575,7 +1602,7 @@ class ComplianceReportCriteria(_message.Message): jobTitles: _containers.RepeatedScalarFieldContainer[str] enterpriseUserIds: _containers.RepeatedScalarFieldContainer[int] includeNonShared: bool - def __init__(self, jobTitles: _Optional[_Iterable[str]] = ..., enterpriseUserIds: _Optional[_Iterable[int]] = ..., includeNonShared: bool = ...) -> None: ... + def __init__(self, jobTitles: _Optional[_Iterable[str]] = ..., enterpriseUserIds: _Optional[_Iterable[int]] = ..., includeNonShared: _Optional[bool] = ...) -> None: ... class ComplianceReportFilter(_message.Message): __slots__ = ("recordTitles", "recordUids", "jobTitles", "urls", "recordTypes") @@ -1626,20 +1653,22 @@ class ComplianceReportResponse(_message.Message): def __init__(self, dateGenerated: _Optional[int] = ..., runByUserName: _Optional[str] = ..., reportName: _Optional[str] = ..., reportUid: _Optional[bytes] = ..., complianceReportRun: _Optional[_Union[ComplianceReportRun, _Mapping]] = ..., userProfiles: _Optional[_Iterable[_Union[UserProfile, _Mapping]]] = ..., auditTeams: _Optional[_Iterable[_Union[AuditTeam, _Mapping]]] = ..., auditRecords: _Optional[_Iterable[_Union[AuditRecord, _Mapping]]] = ..., userRecords: _Optional[_Iterable[_Union[UserRecord, _Mapping]]] = ..., sharedFolderRecords: _Optional[_Iterable[_Union[SharedFolderRecord, _Mapping]]] = ..., sharedFolderUsers: _Optional[_Iterable[_Union[SharedFolderUser, _Mapping]]] = ..., sharedFolderTeams: _Optional[_Iterable[_Union[SharedFolderTeam, _Mapping]]] = ..., auditTeamUsers: _Optional[_Iterable[_Union[AuditTeamUser, _Mapping]]] = ..., auditRoles: _Optional[_Iterable[_Union[AuditRole, _Mapping]]] = ..., linkedRecords: _Optional[_Iterable[_Union[LinkedRecord, _Mapping]]] = ...) -> None: ... class AuditRecord(_message.Message): - __slots__ = ("recordUid", "auditData", "hasAttachments", "inTrash", "treeLeft", "treeRight") + __slots__ = ("recordUid", "auditData", "hasAttachments", "inTrash", "treeLeft", "treeRight", "isDriveRecord") RECORDUID_FIELD_NUMBER: _ClassVar[int] AUDITDATA_FIELD_NUMBER: _ClassVar[int] HASATTACHMENTS_FIELD_NUMBER: _ClassVar[int] INTRASH_FIELD_NUMBER: _ClassVar[int] TREELEFT_FIELD_NUMBER: _ClassVar[int] TREERIGHT_FIELD_NUMBER: _ClassVar[int] + ISDRIVERECORD_FIELD_NUMBER: _ClassVar[int] recordUid: bytes auditData: bytes hasAttachments: bool inTrash: bool treeLeft: int treeRight: int - def __init__(self, recordUid: _Optional[bytes] = ..., auditData: _Optional[bytes] = ..., hasAttachments: bool = ..., inTrash: bool = ..., treeLeft: _Optional[int] = ..., treeRight: _Optional[int] = ...) -> None: ... + isDriveRecord: bool + def __init__(self, recordUid: _Optional[bytes] = ..., auditData: _Optional[bytes] = ..., hasAttachments: _Optional[bool] = ..., inTrash: _Optional[bool] = ..., treeLeft: _Optional[int] = ..., treeRight: _Optional[int] = ..., isDriveRecord: _Optional[bool] = ...) -> None: ... class AuditRole(_message.Message): __slots__ = ("roleId", "encryptedData", "restrictShareOutsideEnterprise", "restrictShareAll", "restrictShareOfAttachments", "restrictMaskPasswordsWhileEditing", "roleNodeManagements") @@ -1657,7 +1686,7 @@ class AuditRole(_message.Message): restrictShareOfAttachments: bool restrictMaskPasswordsWhileEditing: bool roleNodeManagements: _containers.RepeatedCompositeFieldContainer[RoleNodeManagement] - def __init__(self, roleId: _Optional[int] = ..., encryptedData: _Optional[bytes] = ..., restrictShareOutsideEnterprise: bool = ..., restrictShareAll: bool = ..., restrictShareOfAttachments: bool = ..., restrictMaskPasswordsWhileEditing: bool = ..., roleNodeManagements: _Optional[_Iterable[_Union[RoleNodeManagement, _Mapping]]] = ...) -> None: ... + def __init__(self, roleId: _Optional[int] = ..., encryptedData: _Optional[bytes] = ..., restrictShareOutsideEnterprise: _Optional[bool] = ..., restrictShareAll: _Optional[bool] = ..., restrictShareOfAttachments: _Optional[bool] = ..., restrictMaskPasswordsWhileEditing: _Optional[bool] = ..., roleNodeManagements: _Optional[_Iterable[_Union[RoleNodeManagement, _Mapping]]] = ...) -> None: ... class RoleNodeManagement(_message.Message): __slots__ = ("treeLeft", "treeRight", "cascade", "privileges") @@ -1669,7 +1698,7 @@ class RoleNodeManagement(_message.Message): treeRight: int cascade: bool privileges: int - def __init__(self, treeLeft: _Optional[int] = ..., treeRight: _Optional[int] = ..., cascade: bool = ..., privileges: _Optional[int] = ...) -> None: ... + def __init__(self, treeLeft: _Optional[int] = ..., treeRight: _Optional[int] = ..., cascade: _Optional[bool] = ..., privileges: _Optional[int] = ...) -> None: ... class UserProfile(_message.Message): __slots__ = ("enterpriseUserId", "fullName", "jobTitle", "email", "roleIds") @@ -1686,12 +1715,32 @@ class UserProfile(_message.Message): def __init__(self, enterpriseUserId: _Optional[int] = ..., fullName: _Optional[str] = ..., jobTitle: _Optional[str] = ..., email: _Optional[str] = ..., roleIds: _Optional[_Iterable[int]] = ...) -> None: ... class RecordPermission(_message.Message): - __slots__ = ("recordUid", "permissionBits") + __slots__ = ("recordUid", "permissionBits", "drive") RECORDUID_FIELD_NUMBER: _ClassVar[int] PERMISSIONBITS_FIELD_NUMBER: _ClassVar[int] + DRIVE_FIELD_NUMBER: _ClassVar[int] recordUid: bytes permissionBits: int - def __init__(self, recordUid: _Optional[bytes] = ..., permissionBits: _Optional[int] = ...) -> None: ... + drive: DrivePermission + def __init__(self, recordUid: _Optional[bytes] = ..., permissionBits: _Optional[int] = ..., drive: _Optional[_Union[DrivePermission, _Mapping]] = ...) -> None: ... + +class DrivePermission(_message.Message): + __slots__ = ("owner", "denied", "canEdit", "canShare", "isShareAdmin", "accessType", "folderPermissions") + OWNER_FIELD_NUMBER: _ClassVar[int] + DENIED_FIELD_NUMBER: _ClassVar[int] + CANEDIT_FIELD_NUMBER: _ClassVar[int] + CANSHARE_FIELD_NUMBER: _ClassVar[int] + ISSHAREADMIN_FIELD_NUMBER: _ClassVar[int] + ACCESSTYPE_FIELD_NUMBER: _ClassVar[int] + FOLDERPERMISSIONS_FIELD_NUMBER: _ClassVar[int] + owner: bool + denied: bool + canEdit: bool + canShare: bool + isShareAdmin: bool + accessType: _folder_pb2.AccessType + folderPermissions: _folder_pb2.FolderPermissions + def __init__(self, owner: _Optional[bool] = ..., denied: _Optional[bool] = ..., canEdit: _Optional[bool] = ..., canShare: _Optional[bool] = ..., isShareAdmin: _Optional[bool] = ..., accessType: _Optional[_Union[_folder_pb2.AccessType, str]] = ..., folderPermissions: _Optional[_Union[_folder_pb2.FolderPermissions, _Mapping]] = ...) -> None: ... class UserRecord(_message.Message): __slots__ = ("enterpriseUserId", "recordPermissions") @@ -1711,7 +1760,7 @@ class AuditTeam(_message.Message): teamName: str restrictEdit: bool restrictShare: bool - def __init__(self, teamUid: _Optional[bytes] = ..., teamName: _Optional[str] = ..., restrictEdit: bool = ..., restrictShare: bool = ...) -> None: ... + def __init__(self, teamUid: _Optional[bytes] = ..., teamName: _Optional[str] = ..., restrictEdit: _Optional[bool] = ..., restrictShare: _Optional[bool] = ...) -> None: ... class AuditTeamUser(_message.Message): __slots__ = ("teamUid", "enterpriseUserIds") @@ -1815,7 +1864,7 @@ class UserProfileExt(_message.Message): isShareAdminForRequestedObject: bool isShareAdminForSharedFolderOwner: bool hasAccessToObject: bool - def __init__(self, email: _Optional[str] = ..., fullName: _Optional[str] = ..., jobTitle: _Optional[str] = ..., isMSPMCAdmin: bool = ..., isInSharedFolder: bool = ..., isShareAdminForRequestedObject: bool = ..., isShareAdminForSharedFolderOwner: bool = ..., hasAccessToObject: bool = ...) -> None: ... + def __init__(self, email: _Optional[str] = ..., fullName: _Optional[str] = ..., jobTitle: _Optional[str] = ..., isMSPMCAdmin: _Optional[bool] = ..., isInSharedFolder: _Optional[bool] = ..., isShareAdminForRequestedObject: _Optional[bool] = ..., isShareAdminForSharedFolderOwner: _Optional[bool] = ..., hasAccessToObject: _Optional[bool] = ...) -> None: ... class GetSharingAdminsResponse(_message.Message): __slots__ = ("userProfileExts",) @@ -1879,7 +1928,7 @@ class TeamsEnterpriseUsersAddTeamResponse(_message.Message): message: str resultCode: str additionalInfo: str - def __init__(self, teamUid: _Optional[bytes] = ..., users: _Optional[_Iterable[_Union[TeamsEnterpriseUsersAddUserResponse, _Mapping]]] = ..., success: bool = ..., message: _Optional[str] = ..., resultCode: _Optional[str] = ..., additionalInfo: _Optional[str] = ...) -> None: ... + def __init__(self, teamUid: _Optional[bytes] = ..., users: _Optional[_Iterable[_Union[TeamsEnterpriseUsersAddUserResponse, _Mapping]]] = ..., success: _Optional[bool] = ..., message: _Optional[str] = ..., resultCode: _Optional[str] = ..., additionalInfo: _Optional[str] = ...) -> None: ... class TeamsEnterpriseUsersAddUserResponse(_message.Message): __slots__ = ("enterpriseUserId", "revision", "success", "message", "resultCode", "additionalInfo") @@ -1895,7 +1944,7 @@ class TeamsEnterpriseUsersAddUserResponse(_message.Message): message: str resultCode: str additionalInfo: str - def __init__(self, enterpriseUserId: _Optional[int] = ..., revision: _Optional[int] = ..., success: bool = ..., message: _Optional[str] = ..., resultCode: _Optional[str] = ..., additionalInfo: _Optional[str] = ...) -> None: ... + def __init__(self, enterpriseUserId: _Optional[int] = ..., revision: _Optional[int] = ..., success: _Optional[bool] = ..., message: _Optional[str] = ..., resultCode: _Optional[str] = ..., additionalInfo: _Optional[str] = ...) -> None: ... class TeamEnterpriseUserRemove(_message.Message): __slots__ = ("teamUid", "enterpriseUserId") @@ -1929,7 +1978,7 @@ class TeamEnterpriseUserRemoveResponse(_message.Message): resultCode: str message: str additionalInfo: str - def __init__(self, teamEnterpriseUserRemove: _Optional[_Union[TeamEnterpriseUserRemove, _Mapping]] = ..., success: bool = ..., resultCode: _Optional[str] = ..., message: _Optional[str] = ..., additionalInfo: _Optional[str] = ...) -> None: ... + def __init__(self, teamEnterpriseUserRemove: _Optional[_Union[TeamEnterpriseUserRemove, _Mapping]] = ..., success: _Optional[bool] = ..., resultCode: _Optional[str] = ..., message: _Optional[str] = ..., additionalInfo: _Optional[str] = ...) -> None: ... class DomainAlias(_message.Message): __slots__ = ("domain", "alias", "status", "message") @@ -2049,7 +2098,7 @@ class EnterpriseUsersAdd(_message.Message): inviteeLocale: str move: bool roleId: int - def __init__(self, enterpriseUserId: _Optional[int] = ..., username: _Optional[str] = ..., nodeId: _Optional[int] = ..., encryptedData: _Optional[str] = ..., keyType: _Optional[_Union[EncryptedKeyType, str]] = ..., fullName: _Optional[str] = ..., jobTitle: _Optional[str] = ..., suppressEmailInvite: bool = ..., inviteeLocale: _Optional[str] = ..., move: bool = ..., roleId: _Optional[int] = ...) -> None: ... + def __init__(self, enterpriseUserId: _Optional[int] = ..., username: _Optional[str] = ..., nodeId: _Optional[int] = ..., encryptedData: _Optional[str] = ..., keyType: _Optional[_Union[EncryptedKeyType, str]] = ..., fullName: _Optional[str] = ..., jobTitle: _Optional[str] = ..., suppressEmailInvite: _Optional[bool] = ..., inviteeLocale: _Optional[str] = ..., move: _Optional[bool] = ..., roleId: _Optional[int] = ...) -> None: ... class EnterpriseUsersAddResponse(_message.Message): __slots__ = ("results", "success", "code", "message", "additionalInfo") @@ -2063,7 +2112,7 @@ class EnterpriseUsersAddResponse(_message.Message): code: str message: str additionalInfo: str - def __init__(self, results: _Optional[_Iterable[_Union[EnterpriseUsersAddResult, _Mapping]]] = ..., success: bool = ..., code: _Optional[str] = ..., message: _Optional[str] = ..., additionalInfo: _Optional[str] = ...) -> None: ... + def __init__(self, results: _Optional[_Iterable[_Union[EnterpriseUsersAddResult, _Mapping]]] = ..., success: _Optional[bool] = ..., code: _Optional[str] = ..., message: _Optional[str] = ..., additionalInfo: _Optional[str] = ...) -> None: ... class EnterpriseUsersAddResult(_message.Message): __slots__ = ("enterpriseUserId", "success", "verificationCode", "code", "message", "additionalInfo") @@ -2079,7 +2128,7 @@ class EnterpriseUsersAddResult(_message.Message): code: str message: str additionalInfo: str - def __init__(self, enterpriseUserId: _Optional[int] = ..., success: bool = ..., verificationCode: _Optional[str] = ..., code: _Optional[str] = ..., message: _Optional[str] = ..., additionalInfo: _Optional[str] = ...) -> None: ... + def __init__(self, enterpriseUserId: _Optional[int] = ..., success: _Optional[bool] = ..., verificationCode: _Optional[str] = ..., code: _Optional[str] = ..., message: _Optional[str] = ..., additionalInfo: _Optional[str] = ...) -> None: ... class UpdateMSPPermitsRequest(_message.Message): __slots__ = ("mspEnterpriseId", "maxAllowedLicenses", "allowedMcProducts", "allowedAddOns", "maxFilePlanType", "allowUnlimitedLicenses") @@ -2095,7 +2144,7 @@ class UpdateMSPPermitsRequest(_message.Message): allowedAddOns: _containers.RepeatedScalarFieldContainer[str] maxFilePlanType: str allowUnlimitedLicenses: bool - def __init__(self, mspEnterpriseId: _Optional[int] = ..., maxAllowedLicenses: _Optional[int] = ..., allowedMcProducts: _Optional[_Iterable[str]] = ..., allowedAddOns: _Optional[_Iterable[str]] = ..., maxFilePlanType: _Optional[str] = ..., allowUnlimitedLicenses: bool = ...) -> None: ... + def __init__(self, mspEnterpriseId: _Optional[int] = ..., maxAllowedLicenses: _Optional[int] = ..., allowedMcProducts: _Optional[_Iterable[str]] = ..., allowedAddOns: _Optional[_Iterable[str]] = ..., maxFilePlanType: _Optional[str] = ..., allowUnlimitedLicenses: _Optional[bool] = ...) -> None: ... class DeleteEnterpriseUsersRequest(_message.Message): __slots__ = ("enterpriseUserIds",) @@ -2125,7 +2174,7 @@ class ClearSecurityDataRequest(_message.Message): enterpriseUserId: _containers.RepeatedScalarFieldContainer[int] allUsers: bool type: ClearSecurityDataType - def __init__(self, enterpriseUserId: _Optional[_Iterable[int]] = ..., allUsers: bool = ..., type: _Optional[_Union[ClearSecurityDataType, str]] = ...) -> None: ... + def __init__(self, enterpriseUserId: _Optional[_Iterable[int]] = ..., allUsers: _Optional[bool] = ..., type: _Optional[_Union[ClearSecurityDataType, str]] = ...) -> None: ... class ListDomainsResponse(_message.Message): __slots__ = ("domain",) @@ -2165,7 +2214,7 @@ class LockUsersRequest(_message.Message): disableEnterpriseUserIds: _containers.RepeatedScalarFieldContainer[int] unlockEnterpriseUserIds: _containers.RepeatedScalarFieldContainer[int] deleteIfPending: bool - def __init__(self, lockEnterpriseUserIds: _Optional[_Iterable[int]] = ..., disableEnterpriseUserIds: _Optional[_Iterable[int]] = ..., unlockEnterpriseUserIds: _Optional[_Iterable[int]] = ..., deleteIfPending: bool = ...) -> None: ... + def __init__(self, lockEnterpriseUserIds: _Optional[_Iterable[int]] = ..., disableEnterpriseUserIds: _Optional[_Iterable[int]] = ..., unlockEnterpriseUserIds: _Optional[_Iterable[int]] = ..., deleteIfPending: _Optional[bool] = ...) -> None: ... class LockUsersResponse(_message.Message): __slots__ = ("response",) diff --git a/keepercommander/proto/folder_access_pb2.pyi b/keepercommander/proto/folder_access_pb2.pyi index 9e2e82bbf..6e93d97bc 100644 --- a/keepercommander/proto/folder_access_pb2.pyi +++ b/keepercommander/proto/folder_access_pb2.pyi @@ -25,7 +25,7 @@ class GetFolderAccessResponse(_message.Message): folderAccessResults: _containers.RepeatedCompositeFieldContainer[GetFolderAccessResult] continuationToken: ContinuationToken hasMore: bool - def __init__(self, folderAccessResults: _Optional[_Iterable[_Union[GetFolderAccessResult, _Mapping]]] = ..., continuationToken: _Optional[_Union[ContinuationToken, _Mapping]] = ..., hasMore: bool = ...) -> None: ... + def __init__(self, folderAccessResults: _Optional[_Iterable[_Union[GetFolderAccessResult, _Mapping]]] = ..., continuationToken: _Optional[_Union[ContinuationToken, _Mapping]] = ..., hasMore: _Optional[bool] = ...) -> None: ... class ContinuationToken(_message.Message): __slots__ = ("lastModified",) diff --git a/keepercommander/proto/folder_pb2.py b/keepercommander/proto/folder_pb2.py index d934a4354..935aed71a 100644 --- a/keepercommander/proto/folder_pb2.py +++ b/keepercommander/proto/folder_pb2.py @@ -26,7 +26,7 @@ from . import tla_pb2 as tla__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0c\x66older.proto\x12\x06\x46older\x1a\x0crecord.proto\x1a\ttla.proto\"\\\n\x10\x45ncryptedDataKey\x12\x14\n\x0c\x65ncryptedKey\x18\x01 \x01(\x0c\x12\x32\n\x10\x65ncryptedKeyType\x18\x02 \x01(\x0e\x32\x18.Folder.EncryptedKeyType\"\x82\x01\n\x16SharedFolderRecordData\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\x12\x0e\n\x06userId\x18\x03 \x01(\x05\x12\x32\n\x10\x65ncryptedDataKey\x18\x04 \x03(\x0b\x32\x18.Folder.EncryptedDataKey\"\\\n\x1aSharedFolderRecordDataList\x12>\n\x16sharedFolderRecordData\x18\x01 \x03(\x0b\x32\x1e.Folder.SharedFolderRecordData\"_\n\x15SharedFolderRecordFix\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\x12 \n\x18\x65ncryptedRecordFolderKey\x18\x03 \x01(\x0c\"Y\n\x19SharedFolderRecordFixList\x12<\n\x15sharedFolderRecordFix\x18\x01 \x03(\x0b\x32\x1d.Folder.SharedFolderRecordFix\"\xa2\x02\n\rRecordRequest\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12&\n\nrecordType\x18\x02 \x01(\x0e\x32\x12.Folder.RecordType\x12\x12\n\nrecordData\x18\x03 \x01(\x0c\x12\x1a\n\x12\x65ncryptedRecordKey\x18\x04 \x01(\x0c\x12&\n\nfolderType\x18\x05 \x01(\x0e\x32\x12.Folder.FolderType\x12\x12\n\nhowLongAgo\x18\x06 \x01(\x03\x12\x11\n\tfolderUid\x18\x07 \x01(\x0c\x12 \n\x18\x65ncryptedRecordFolderKey\x18\x08 \x01(\x0c\x12\r\n\x05\x65xtra\x18\t \x01(\x0c\x12\x15\n\rnonSharedData\x18\n \x01(\x0c\x12\x0f\n\x07\x66ileIds\x18\x0b \x03(\x03\"E\n\x0eRecordResponse\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x10\n\x08revision\x18\x02 \x01(\x03\x12\x0e\n\x06status\x18\x03 \x01(\t\"\x80\x01\n\x12SharedFolderFields\x12\x1b\n\x13\x65ncryptedFolderName\x18\x01 \x01(\x0c\x12\x13\n\x0bmanageUsers\x18\x02 \x01(\x08\x12\x15\n\rmanageRecords\x18\x03 \x01(\x08\x12\x0f\n\x07\x63\x61nEdit\x18\x04 \x01(\x08\x12\x10\n\x08\x63\x61nShare\x18\x05 \x01(\x08\"3\n\x18SharedFolderFolderFields\x12\x17\n\x0fsharedFolderUid\x18\x01 \x01(\x0c\"\x8f\x02\n\rFolderRequest\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12&\n\nfolderType\x18\x02 \x01(\x0e\x32\x12.Folder.FolderType\x12\x17\n\x0fparentFolderUid\x18\x03 \x01(\x0c\x12\x12\n\nfolderData\x18\x04 \x01(\x0c\x12\x1a\n\x12\x65ncryptedFolderKey\x18\x05 \x01(\x0c\x12\x36\n\x12sharedFolderFields\x18\x06 \x01(\x0b\x32\x1a.Folder.SharedFolderFields\x12\x42\n\x18sharedFolderFolderFields\x18\x07 \x01(\x0b\x32 .Folder.SharedFolderFolderFields\"E\n\x0e\x46olderResponse\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x10\n\x08revision\x18\x02 \x01(\x03\x12\x0e\n\x06status\x18\x03 \x01(\t\"w\n\x19ImportFolderRecordRequest\x12,\n\rfolderRequest\x18\x01 \x03(\x0b\x32\x15.Folder.FolderRequest\x12,\n\rrecordRequest\x18\x02 \x03(\x0b\x32\x15.Folder.RecordRequest\"|\n\x1aImportFolderRecordResponse\x12.\n\x0e\x66olderResponse\x18\x01 \x03(\x0b\x32\x16.Folder.FolderResponse\x12.\n\x0erecordResponse\x18\x02 \x03(\x0b\x32\x16.Folder.RecordResponse\"\xc9\x02\n\x18SharedFolderUpdateRecord\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x17\n\x0fsharedFolderUid\x18\x02 \x01(\x0c\x12\x0f\n\x07teamUid\x18\x03 \x01(\x0c\x12(\n\x07\x63\x61nEdit\x18\x04 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12)\n\x08\x63\x61nShare\x18\x05 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x1a\n\x12\x65ncryptedRecordKey\x18\x06 \x01(\x0c\x12\x10\n\x08revision\x18\x07 \x01(\x05\x12\x12\n\nexpiration\x18\x08 \x01(\x12\x12=\n\x15timerNotificationType\x18\t \x01(\x0e\x32\x1e.Records.TimerNotificationType\x12\x1a\n\x12rotateOnExpiration\x18\n \x01(\x08\"\xcc\x02\n\x16SharedFolderUpdateUser\x12\x10\n\x08username\x18\x01 \x01(\t\x12,\n\x0bmanageUsers\x18\x02 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12.\n\rmanageRecords\x18\x03 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x1b\n\x0fsharedFolderKey\x18\x04 \x01(\x0c\x42\x02\x18\x01\x12\x12\n\nexpiration\x18\x05 \x01(\x12\x12=\n\x15timerNotificationType\x18\x06 \x01(\x0e\x32\x1e.Records.TimerNotificationType\x12\x36\n\x14typedSharedFolderKey\x18\x07 \x01(\x0b\x32\x18.Folder.EncryptedDataKey\x12\x1a\n\x12rotateOnExpiration\x18\x08 \x01(\x08\"\x99\x02\n\x16SharedFolderUpdateTeam\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12\x13\n\x0bmanageUsers\x18\x02 \x01(\x08\x12\x15\n\rmanageRecords\x18\x03 \x01(\x08\x12\x1b\n\x0fsharedFolderKey\x18\x04 \x01(\x0c\x42\x02\x18\x01\x12\x12\n\nexpiration\x18\x05 \x01(\x12\x12=\n\x15timerNotificationType\x18\x06 \x01(\x0e\x32\x1e.Records.TimerNotificationType\x12\x36\n\x14typedSharedFolderKey\x18\x07 \x01(\x0b\x32\x18.Folder.EncryptedDataKey\x12\x1a\n\x12rotateOnExpiration\x18\x08 \x01(\x08\"\x8e\x07\n\x1bSharedFolderUpdateV3Request\x12,\n$sharedFolderUpdateOperation_dont_use\x18\x01 \x01(\x05\x12\x17\n\x0fsharedFolderUid\x18\x02 \x01(\x0c\x12!\n\x19\x65ncryptedSharedFolderName\x18\x03 \x01(\x0c\x12\x10\n\x08revision\x18\x04 \x01(\x03\x12\x13\n\x0b\x66orceUpdate\x18\x05 \x01(\x08\x12\x13\n\x0b\x66romTeamUid\x18\x06 \x01(\x0c\x12\x33\n\x12\x64\x65\x66\x61ultManageUsers\x18\x07 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x35\n\x14\x64\x65\x66\x61ultManageRecords\x18\x08 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12/\n\x0e\x64\x65\x66\x61ultCanEdit\x18\t \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x30\n\x0f\x64\x65\x66\x61ultCanShare\x18\n \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12?\n\x15sharedFolderAddRecord\x18\x0b \x03(\x0b\x32 .Folder.SharedFolderUpdateRecord\x12;\n\x13sharedFolderAddUser\x18\x0c \x03(\x0b\x32\x1e.Folder.SharedFolderUpdateUser\x12;\n\x13sharedFolderAddTeam\x18\r \x03(\x0b\x32\x1e.Folder.SharedFolderUpdateTeam\x12\x42\n\x18sharedFolderUpdateRecord\x18\x0e \x03(\x0b\x32 .Folder.SharedFolderUpdateRecord\x12>\n\x16sharedFolderUpdateUser\x18\x0f \x03(\x0b\x32\x1e.Folder.SharedFolderUpdateUser\x12>\n\x16sharedFolderUpdateTeam\x18\x10 \x03(\x0b\x32\x1e.Folder.SharedFolderUpdateTeam\x12 \n\x18sharedFolderRemoveRecord\x18\x11 \x03(\x0c\x12\x1e\n\x16sharedFolderRemoveUser\x18\x12 \x03(\t\x12\x1e\n\x16sharedFolderRemoveTeam\x18\x13 \x03(\x0c\x12\x19\n\x11sharedFolderOwner\x18\x14 \x01(\t\"c\n\x1dSharedFolderUpdateV3RequestV2\x12\x42\n\x15sharedFoldersUpdateV3\x18\x01 \x03(\x0b\x32#.Folder.SharedFolderUpdateV3Request\"C\n\x1eSharedFolderUpdateRecordStatus\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x0e\n\x06status\x18\x02 \x01(\t\"@\n\x1cSharedFolderUpdateUserStatus\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x0e\n\x06status\x18\x02 \x01(\t\"?\n\x1cSharedFolderUpdateTeamStatus\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12\x0e\n\x06status\x18\x02 \x01(\t\"\x88\x06\n\x1cSharedFolderUpdateV3Response\x12\x10\n\x08revision\x18\x01 \x01(\x03\x12K\n\x1bsharedFolderAddRecordStatus\x18\x02 \x03(\x0b\x32&.Folder.SharedFolderUpdateRecordStatus\x12G\n\x19sharedFolderAddUserStatus\x18\x03 \x03(\x0b\x32$.Folder.SharedFolderUpdateUserStatus\x12G\n\x19sharedFolderAddTeamStatus\x18\x04 \x03(\x0b\x32$.Folder.SharedFolderUpdateTeamStatus\x12N\n\x1esharedFolderUpdateRecordStatus\x18\x05 \x03(\x0b\x32&.Folder.SharedFolderUpdateRecordStatus\x12J\n\x1csharedFolderUpdateUserStatus\x18\x06 \x03(\x0b\x32$.Folder.SharedFolderUpdateUserStatus\x12J\n\x1csharedFolderUpdateTeamStatus\x18\x07 \x03(\x0b\x32$.Folder.SharedFolderUpdateTeamStatus\x12N\n\x1esharedFolderRemoveRecordStatus\x18\x08 \x03(\x0b\x32&.Folder.SharedFolderUpdateRecordStatus\x12J\n\x1csharedFolderRemoveUserStatus\x18\t \x03(\x0b\x32$.Folder.SharedFolderUpdateUserStatus\x12J\n\x1csharedFolderRemoveTeamStatus\x18\n \x03(\x0b\x32$.Folder.SharedFolderUpdateTeamStatus\x12\x17\n\x0fsharedFolderUid\x18\x0c \x01(\x0c\x12\x0e\n\x06status\x18\r \x01(\t\"m\n\x1eSharedFolderUpdateV3ResponseV2\x12K\n\x1dsharedFoldersUpdateV3Response\x18\x01 \x03(\x0b\x32$.Folder.SharedFolderUpdateV3Response\"\xfa\x01\n)GetDeletedSharedFoldersAndRecordsResponse\x12\x32\n\rsharedFolders\x18\x01 \x03(\x0b\x32\x1b.Folder.DeletedSharedFolder\x12>\n\x13sharedFolderRecords\x18\x02 \x03(\x0b\x32!.Folder.DeletedSharedFolderRecord\x12\x34\n\x11\x64\x65letedRecordData\x18\x03 \x03(\x0b\x32\x19.Folder.DeletedRecordData\x12#\n\tusernames\x18\x04 \x03(\x0b\x32\x10.Folder.Username\"\xd1\x01\n\x13\x44\x65letedSharedFolder\x12\x17\n\x0fsharedFolderUid\x18\x01 \x01(\x0c\x12\x11\n\tfolderUid\x18\x02 \x01(\x0c\x12\x11\n\tparentUid\x18\x03 \x01(\x0c\x12\x17\n\x0fsharedFolderKey\x18\x04 \x01(\x0c\x12-\n\rfolderKeyType\x18\x05 \x01(\x0e\x32\x16.Records.RecordKeyType\x12\x0c\n\x04\x64\x61ta\x18\x06 \x01(\x0c\x12\x13\n\x0b\x64\x61teDeleted\x18\x07 \x01(\x03\x12\x10\n\x08revision\x18\x08 \x01(\x03\"\x81\x01\n\x19\x44\x65letedSharedFolderRecord\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\x12\x17\n\x0fsharedRecordKey\x18\x03 \x01(\x0c\x12\x13\n\x0b\x64\x61teDeleted\x18\x04 \x01(\x03\x12\x10\n\x08revision\x18\x05 \x01(\x03\"\x85\x01\n\x11\x44\x65letedRecordData\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x10\n\x08ownerUid\x18\x02 \x01(\x0c\x12\x10\n\x08revision\x18\x03 \x01(\x03\x12\x1a\n\x12\x63lientModifiedTime\x18\x04 \x01(\x03\x12\x0c\n\x04\x64\x61ta\x18\x05 \x01(\x0c\x12\x0f\n\x07version\x18\x06 \x01(\x05\"0\n\x08Username\x12\x12\n\naccountUid\x18\x01 \x01(\x0c\x12\x10\n\x08username\x18\x02 \x01(\t\"\x8a\x01\n,RestoreDeletedSharedFoldersAndRecordsRequest\x12,\n\x07\x66olders\x18\x01 \x03(\x0b\x32\x1b.Folder.RestoreSharedObject\x12,\n\x07records\x18\x02 \x03(\x0b\x32\x1b.Folder.RestoreSharedObject\"<\n\x13RestoreSharedObject\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x12\n\nrecordUids\x18\x02 \x03(\x0c\"\x83\x02\n\nFolderData\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x11\n\tparentUid\x18\x02 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12%\n\x04type\x18\x04 \x01(\x0e\x32\x17.Folder.FolderUsageType\x12\x37\n\x16inheritUserPermissions\x18\x05 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x11\n\tfolderKey\x18\x06 \x01(\x0c\x12#\n\townerInfo\x18\x07 \x01(\x0b\x32\x10.Folder.UserInfo\x12\x13\n\x0b\x64\x61teCreated\x18\x08 \x01(\x03\x12\x14\n\x0clastModified\x18\t \x01(\x03\"z\n\tFolderKey\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x11\n\tparentUid\x18\x02 \x01(\x0c\x12\x11\n\tfolderKey\x18\x03 \x01(\x0c\x12\x34\n\x0b\x65ncryptedBy\x18\x04 \x01(\x0e\x32\x1f.Folder.FolderKeyEncryptionType\":\n\x10\x46olderAddRequest\x12&\n\nfolderData\x18\x01 \x03(\x0b\x32\x12.Folder.FolderData\"d\n\x12\x46olderModifyResult\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12*\n\x06status\x18\x02 \x01(\x0e\x32\x1a.Folder.FolderModifyStatus\x12\x0f\n\x07message\x18\x03 \x01(\t\"I\n\x11\x46olderAddResponse\x12\x34\n\x10\x66olderAddResults\x18\x01 \x03(\x0b\x32\x1a.Folder.FolderModifyResult\"=\n\x13\x46olderUpdateRequest\x12&\n\nfolderData\x18\x01 \x03(\x0b\x32\x12.Folder.FolderData\"O\n\x14\x46olderUpdateResponse\x12\x37\n\x13\x66olderUpdateResults\x18\x01 \x03(\x0b\x32\x1a.Folder.FolderModifyResult\"\xc3\x02\n\x11\x46olderPermissions\x12\x0e\n\x06\x63\x61nAdd\x18\x01 \x01(\x08\x12\x11\n\tcanRemove\x18\x02 \x01(\x08\x12\x11\n\tcanDelete\x18\x03 \x01(\x08\x12\x15\n\rcanListAccess\x18\x04 \x01(\x08\x12\x17\n\x0f\x63\x61nUpdateAccess\x18\x05 \x01(\x08\x12\x1a\n\x12\x63\x61nChangeOwnership\x18\x06 \x01(\x08\x12\x16\n\x0e\x63\x61nEditRecords\x18\x07 \x01(\x08\x12\x16\n\x0e\x63\x61nViewRecords\x18\x08 \x01(\x08\x12\x18\n\x10\x63\x61nApproveAccess\x18\t \x01(\x08\x12\x18\n\x10\x63\x61nRequestAccess\x18\n \x01(\x08\x12\x18\n\x10\x63\x61nUpdateSetting\x18\x0b \x01(\x08\x12\x16\n\x0e\x63\x61nListRecords\x18\x0c \x01(\x08\x12\x16\n\x0e\x63\x61nListFolders\x18\r \x01(\x08\"\x83\x05\n\x0c\x43\x61pabilities\x12\'\n\x06\x63\x61nAdd\x18\x01 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12*\n\tcanRemove\x18\x02 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12*\n\tcanDelete\x18\x03 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12.\n\rcanListAccess\x18\x04 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x30\n\x0f\x63\x61nUpdateAccess\x18\x05 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x33\n\x12\x63\x61nChangeOwnership\x18\x06 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12/\n\x0e\x63\x61nEditRecords\x18\x07 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12/\n\x0e\x63\x61nViewRecords\x18\x08 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x31\n\x10\x63\x61nApproveAccess\x18\t \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x31\n\x10\x63\x61nRequestAccess\x18\n \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x31\n\x10\x63\x61nUpdateSetting\x18\x0b \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12/\n\x0e\x63\x61nListRecords\x18\x0c \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12/\n\x0e\x63\x61nListFolders\x18\r \x01(\x0e\x32\x17.Folder.SetBooleanValue\"\xb8\x01\n\x19\x46olderRecordUpdateRequest\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12*\n\naddRecords\x18\x02 \x03(\x0b\x32\x16.Folder.RecordMetadata\x12-\n\rupdateRecords\x18\x03 \x03(\x0b\x32\x16.Folder.RecordMetadata\x12-\n\rremoveRecords\x18\x04 \x03(\x0b\x32\x16.Folder.RecordMetadata\"\xab\x01\n\x0eRecordMetadata\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x1a\n\x12\x65ncryptedRecordKey\x18\x02 \x01(\x0c\x12\x38\n\x16\x65ncryptedRecordKeyType\x18\x03 \x01(\x0e\x32\x18.Folder.EncryptedKeyType\x12\x30\n\rtlaProperties\x18\x05 \x01(\x0b\x32\x19.common.tla.TLAProperties\"\x93\x01\n\x0c\x46olderRecord\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12.\n\x0erecordMetadata\x18\x02 \x01(\x0b\x32\x16.Folder.RecordMetadata\x12@\n\x17\x66olderKeyEncryptionType\x18\x03 \x01(\x0e\x32\x1f.Folder.FolderKeyEncryptionType\"s\n\x1a\x46olderRecordUpdateResponse\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x42\n\x18\x66olderRecordUpdateResult\x18\x04 \x03(\x0b\x32 .Folder.FolderRecordUpdateResult\"j\n\x18\x46olderRecordUpdateResult\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12*\n\x06status\x18\x02 \x01(\x0e\x32\x1a.Folder.FolderModifyStatus\x12\x0f\n\x07message\x18\x03 \x01(\t\"\x87\x03\n\x10\x46olderAccessData\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x15\n\raccessTypeUid\x18\x02 \x01(\x0c\x12&\n\naccessType\x18\x03 \x01(\x0e\x32\x12.Folder.AccessType\x12.\n\x0e\x61\x63\x63\x65ssRoleType\x18\x04 \x01(\x0e\x32\x16.Folder.AccessRoleType\x12+\n\tfolderKey\x18\x05 \x01(\x0b\x32\x18.Folder.EncryptedDataKey\x12\x11\n\tinherited\x18\x06 \x01(\x08\x12\x0e\n\x06hidden\x18\x07 \x01(\x08\x12.\n\x0bpermissions\x18\x08 \x01(\x0b\x32\x19.Folder.FolderPermissions\x12\x30\n\rtlaProperties\x18\t \x01(\x0b\x32\x19.common.tla.TLAProperties\x12\x13\n\x0b\x64\x61teCreated\x18\n \x01(\x03\x12\x14\n\x0clastModified\x18\x0b \x01(\x03\x12\x14\n\x0c\x64\x65niedAccess\x18\x0c \x01(\x08\"\\\n\rRevokedAccess\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x10\n\x08\x61\x63torUid\x18\x02 \x01(\x0c\x12&\n\naccessType\x18\x03 \x01(\x0e\x32\x12.Folder.AccessType\"#\n\rFolderRemoved\x12\x12\n\nfolder_uid\x18\x01 \x01(\x0c\"\x93\x04\n\x10RecordAccessData\x12\x15\n\raccessTypeUid\x18\x01 \x01(\x0c\x12&\n\naccessType\x18\x02 \x01(\x0e\x32\x12.Folder.AccessType\x12\x11\n\trecordUid\x18\x03 \x01(\x0c\x12.\n\x0e\x61\x63\x63\x65ssRoleType\x18\x04 \x01(\x0e\x32\x16.Folder.AccessRoleType\x12\r\n\x05owner\x18\x05 \x01(\x08\x12\x11\n\tinherited\x18\x06 \x01(\x08\x12\x0e\n\x06hidden\x18\x07 \x01(\x08\x12\x14\n\x0c\x64\x65niedAccess\x18\x08 \x01(\x08\x12\x16\n\x0e\x63\x61n_view_title\x18\t \x01(\x08\x12\x10\n\x08\x63\x61n_edit\x18\n \x01(\x08\x12\x10\n\x08\x63\x61n_view\x18\x0b \x01(\x08\x12\x17\n\x0f\x63\x61n_list_access\x18\x0c \x01(\x08\x12\x19\n\x11\x63\x61n_update_access\x18\r \x01(\x08\x12\x12\n\ncan_delete\x18\x0e \x01(\x08\x12\x1c\n\x14\x63\x61n_change_ownership\x18\x0f \x01(\x08\x12\x1a\n\x12\x63\x61n_request_access\x18\x10 \x01(\x08\x12\x1a\n\x12\x63\x61n_approve_access\x18\x11 \x01(\x08\x12\x13\n\x0b\x64\x61teCreated\x18\x12 \x01(\x03\x12\x14\n\x0clastModified\x18\x13 \x01(\x03\x12\x30\n\rtlaProperties\x18\x14 \x01(\x0b\x32\x19.common.tla.TLAProperties\"\xb8\x01\n\nAccessData\x12\x15\n\raccessTypeUid\x18\x01 \x01(\x0c\x12.\n\x0e\x61\x63\x63\x65ssRoleType\x18\x02 \x01(\x0e\x32\x16.Folder.AccessRoleType\x12\x14\n\x0c\x64\x65niedAccess\x18\x03 \x01(\x08\x12\x11\n\tinherited\x18\x04 \x01(\x08\x12\x0e\n\x06hidden\x18\x05 \x01(\x08\x12*\n\x0c\x63\x61pabilities\x18\x06 \x01(\x0b\x32\x14.Folder.Capabilities\"\xb7\x01\n\x13\x46olderAccessRequest\x12\x32\n\x10\x66olderAccessAdds\x18\x01 \x03(\x0b\x32\x18.Folder.FolderAccessData\x12\x35\n\x13\x66olderAccessUpdates\x18\x02 \x03(\x0b\x32\x18.Folder.FolderAccessData\x12\x35\n\x13\x66olderAccessRemoves\x18\x03 \x03(\x0b\x32\x18.Folder.FolderAccessData\"\x9f\x01\n\x12\x46olderAccessResult\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x11\n\taccessUid\x18\x02 \x01(\x0c\x12&\n\naccessType\x18\x03 \x01(\x0e\x32\x12.Folder.AccessType\x12*\n\x06status\x18\x04 \x01(\x0e\x32\x1a.Folder.FolderModifyStatus\x12\x0f\n\x07message\x18\x05 \x01(\t\"O\n\x14\x46olderAccessResponse\x12\x37\n\x13\x66olderAccessResults\x18\x01 \x03(\x0b\x32\x1a.Folder.FolderAccessResult\"0\n\x08UserInfo\x12\x12\n\naccountUid\x18\x01 \x01(\x0c\x12\x10\n\x08username\x18\x02 \x01(\t\"M\n\nRecordData\x12\x1e\n\x04user\x18\x01 \x01(\x0b\x32\x10.Folder.UserInfo\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12\x11\n\trecordUid\x18\x03 \x01(\x0c\"{\n\tRecordKey\x12\x10\n\x08user_uid\x18\x01 \x01(\x0c\x12\x12\n\nrecord_uid\x18\x02 \x01(\x0c\x12\x12\n\nrecord_key\x18\x03 \x01(\x0c\x12\x34\n\x12\x65ncrypted_key_type\x18\x04 \x01(\x0e\x32\x18.Folder.EncryptedKeyType*\x1a\n\nRecordType\x12\x0c\n\x08password\x10\x00*^\n\nFolderType\x12\x12\n\x0e\x64\x65\x66\x61ult_folder\x10\x00\x12\x0f\n\x0buser_folder\x10\x01\x12\x11\n\rshared_folder\x10\x02\x12\x18\n\x14shared_folder_folder\x10\x03*\x96\x01\n\x10\x45ncryptedKeyType\x12\n\n\x06no_key\x10\x00\x12\x19\n\x15\x65ncrypted_by_data_key\x10\x01\x12\x1b\n\x17\x65ncrypted_by_public_key\x10\x02\x12\x1d\n\x19\x65ncrypted_by_data_key_gcm\x10\x03\x12\x1f\n\x1b\x65ncrypted_by_public_key_ecc\x10\x04*M\n\x0fSetBooleanValue\x12\x15\n\x11\x42OOLEAN_NO_CHANGE\x10\x00\x12\x10\n\x0c\x42OOLEAN_TRUE\x10\x01\x12\x11\n\rBOOLEAN_FALSE\x10\x02*R\n\x0f\x46olderUsageType\x12\x0e\n\nUT_UNKNOWN\x10\x00\x12\r\n\tUT_NORMAL\x10\x01\x12\x0f\n\x0bUT_WORKFLOW\x10\x02\x12\x0f\n\x0bUT_TRASHCAN\x10\x03*l\n\x17\x46olderKeyEncryptionType\x12\x19\n\x15\x45NCRYPTED_BY_USER_KEY\x10\x00\x12\x1b\n\x17\x45NCRYPTED_BY_PARENT_KEY\x10\x01\x12\x19\n\x15\x45NCRYPTED_BY_TEAM_KEY\x10\x02*T\n\x12\x46olderModifyStatus\x12\x0b\n\x07SUCCESS\x10\x00\x12\x0f\n\x0b\x42\x41\x44_REQUEST\x10\x01\x12\x11\n\rACCESS_DENIED\x10\x02\x12\r\n\tNOT_FOUND\x10\x03*\xa4\x02\n\x14\x46olderPermissionBits\x12\n\n\x06noBits\x10\x00\x12\n\n\x06\x63\x61nAdd\x10\x01\x12\r\n\tcanRemove\x10\x02\x12\r\n\tcanDelete\x10\x04\x12\x11\n\rcanListAccess\x10\x08\x12\x13\n\x0f\x63\x61nUpdateAccess\x10\x10\x12\x16\n\x12\x63\x61nChangeOwnership\x10 \x12\x12\n\x0e\x63\x61nEditRecords\x10@\x12\x13\n\x0e\x63\x61nViewRecords\x10\x80\x01\x12\x15\n\x10\x63\x61nApproveAccess\x10\x80\x02\x12\x15\n\x10\x63\x61nRequestAccess\x10\x80\x04\x12\x15\n\x10\x63\x61nUpdateSetting\x10\x80\x08\x12\x13\n\x0e\x63\x61nListRecords\x10\x80\x10\x12\x13\n\x0e\x63\x61nListFolders\x10\x80 *\x9b\x01\n\x0e\x41\x63\x63\x65ssRoleType\x12\r\n\tNAVIGATOR\x10\x00\x12\r\n\tREQUESTOR\x10\x01\x12\n\n\x06VIEWER\x10\x02\x12\x12\n\x0eSHARED_MANAGER\x10\x03\x12\x13\n\x0f\x43ONTENT_MANAGER\x10\x04\x12\x19\n\x15\x43ONTENT_SHARE_MANAGER\x10\x05\x12\x0b\n\x07MANAGER\x10\x06\x12\x0e\n\nUNRESOLVED\x10\x07*z\n\nAccessType\x12\x0e\n\nAT_UNKNOWN\x10\x00\x12\x0c\n\x08\x41T_OWNER\x10\x01\x12\x0b\n\x07\x41T_USER\x10\x02\x12\x0b\n\x07\x41T_TEAM\x10\x03\x12\x11\n\rAT_ENTERPRISE\x10\x04\x12\r\n\tAT_FOLDER\x10\x05\x12\x12\n\x0e\x41T_APPLICATION\x10\x06*:\n\nObjectType\x12\x0e\n\nOT_UNKNOWN\x10\x00\x12\r\n\tOT_RECORD\x10\x01\x12\r\n\tOT_FOLDER\x10\x02\x42\"\n\x18\x63om.keepersecurity.protoB\x06\x46olderb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0c\x66older.proto\x12\x06\x46older\x1a\x0crecord.proto\x1a\ttla.proto\"\\\n\x10\x45ncryptedDataKey\x12\x14\n\x0c\x65ncryptedKey\x18\x01 \x01(\x0c\x12\x32\n\x10\x65ncryptedKeyType\x18\x02 \x01(\x0e\x32\x18.Folder.EncryptedKeyType\"\x82\x01\n\x16SharedFolderRecordData\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\x12\x0e\n\x06userId\x18\x03 \x01(\x05\x12\x32\n\x10\x65ncryptedDataKey\x18\x04 \x03(\x0b\x32\x18.Folder.EncryptedDataKey\"\\\n\x1aSharedFolderRecordDataList\x12>\n\x16sharedFolderRecordData\x18\x01 \x03(\x0b\x32\x1e.Folder.SharedFolderRecordData\"_\n\x15SharedFolderRecordFix\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\x12 \n\x18\x65ncryptedRecordFolderKey\x18\x03 \x01(\x0c\"Y\n\x19SharedFolderRecordFixList\x12<\n\x15sharedFolderRecordFix\x18\x01 \x03(\x0b\x32\x1d.Folder.SharedFolderRecordFix\"\xa2\x02\n\rRecordRequest\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12&\n\nrecordType\x18\x02 \x01(\x0e\x32\x12.Folder.RecordType\x12\x12\n\nrecordData\x18\x03 \x01(\x0c\x12\x1a\n\x12\x65ncryptedRecordKey\x18\x04 \x01(\x0c\x12&\n\nfolderType\x18\x05 \x01(\x0e\x32\x12.Folder.FolderType\x12\x12\n\nhowLongAgo\x18\x06 \x01(\x03\x12\x11\n\tfolderUid\x18\x07 \x01(\x0c\x12 \n\x18\x65ncryptedRecordFolderKey\x18\x08 \x01(\x0c\x12\r\n\x05\x65xtra\x18\t \x01(\x0c\x12\x15\n\rnonSharedData\x18\n \x01(\x0c\x12\x0f\n\x07\x66ileIds\x18\x0b \x03(\x03\"E\n\x0eRecordResponse\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x10\n\x08revision\x18\x02 \x01(\x03\x12\x0e\n\x06status\x18\x03 \x01(\t\"\x80\x01\n\x12SharedFolderFields\x12\x1b\n\x13\x65ncryptedFolderName\x18\x01 \x01(\x0c\x12\x13\n\x0bmanageUsers\x18\x02 \x01(\x08\x12\x15\n\rmanageRecords\x18\x03 \x01(\x08\x12\x0f\n\x07\x63\x61nEdit\x18\x04 \x01(\x08\x12\x10\n\x08\x63\x61nShare\x18\x05 \x01(\x08\"3\n\x18SharedFolderFolderFields\x12\x17\n\x0fsharedFolderUid\x18\x01 \x01(\x0c\"\x8f\x02\n\rFolderRequest\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12&\n\nfolderType\x18\x02 \x01(\x0e\x32\x12.Folder.FolderType\x12\x17\n\x0fparentFolderUid\x18\x03 \x01(\x0c\x12\x12\n\nfolderData\x18\x04 \x01(\x0c\x12\x1a\n\x12\x65ncryptedFolderKey\x18\x05 \x01(\x0c\x12\x36\n\x12sharedFolderFields\x18\x06 \x01(\x0b\x32\x1a.Folder.SharedFolderFields\x12\x42\n\x18sharedFolderFolderFields\x18\x07 \x01(\x0b\x32 .Folder.SharedFolderFolderFields\"E\n\x0e\x46olderResponse\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x10\n\x08revision\x18\x02 \x01(\x03\x12\x0e\n\x06status\x18\x03 \x01(\t\"w\n\x19ImportFolderRecordRequest\x12,\n\rfolderRequest\x18\x01 \x03(\x0b\x32\x15.Folder.FolderRequest\x12,\n\rrecordRequest\x18\x02 \x03(\x0b\x32\x15.Folder.RecordRequest\"|\n\x1aImportFolderRecordResponse\x12.\n\x0e\x66olderResponse\x18\x01 \x03(\x0b\x32\x16.Folder.FolderResponse\x12.\n\x0erecordResponse\x18\x02 \x03(\x0b\x32\x16.Folder.RecordResponse\"\xc9\x02\n\x18SharedFolderUpdateRecord\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x17\n\x0fsharedFolderUid\x18\x02 \x01(\x0c\x12\x0f\n\x07teamUid\x18\x03 \x01(\x0c\x12(\n\x07\x63\x61nEdit\x18\x04 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12)\n\x08\x63\x61nShare\x18\x05 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x1a\n\x12\x65ncryptedRecordKey\x18\x06 \x01(\x0c\x12\x10\n\x08revision\x18\x07 \x01(\x05\x12\x12\n\nexpiration\x18\x08 \x01(\x12\x12=\n\x15timerNotificationType\x18\t \x01(\x0e\x32\x1e.Records.TimerNotificationType\x12\x1a\n\x12rotateOnExpiration\x18\n \x01(\x08\"\xcc\x02\n\x16SharedFolderUpdateUser\x12\x10\n\x08username\x18\x01 \x01(\t\x12,\n\x0bmanageUsers\x18\x02 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12.\n\rmanageRecords\x18\x03 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x1b\n\x0fsharedFolderKey\x18\x04 \x01(\x0c\x42\x02\x18\x01\x12\x12\n\nexpiration\x18\x05 \x01(\x12\x12=\n\x15timerNotificationType\x18\x06 \x01(\x0e\x32\x1e.Records.TimerNotificationType\x12\x36\n\x14typedSharedFolderKey\x18\x07 \x01(\x0b\x32\x18.Folder.EncryptedDataKey\x12\x1a\n\x12rotateOnExpiration\x18\x08 \x01(\x08\"\x99\x02\n\x16SharedFolderUpdateTeam\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12\x13\n\x0bmanageUsers\x18\x02 \x01(\x08\x12\x15\n\rmanageRecords\x18\x03 \x01(\x08\x12\x1b\n\x0fsharedFolderKey\x18\x04 \x01(\x0c\x42\x02\x18\x01\x12\x12\n\nexpiration\x18\x05 \x01(\x12\x12=\n\x15timerNotificationType\x18\x06 \x01(\x0e\x32\x1e.Records.TimerNotificationType\x12\x36\n\x14typedSharedFolderKey\x18\x07 \x01(\x0b\x32\x18.Folder.EncryptedDataKey\x12\x1a\n\x12rotateOnExpiration\x18\x08 \x01(\x08\"\x8e\x07\n\x1bSharedFolderUpdateV3Request\x12,\n$sharedFolderUpdateOperation_dont_use\x18\x01 \x01(\x05\x12\x17\n\x0fsharedFolderUid\x18\x02 \x01(\x0c\x12!\n\x19\x65ncryptedSharedFolderName\x18\x03 \x01(\x0c\x12\x10\n\x08revision\x18\x04 \x01(\x03\x12\x13\n\x0b\x66orceUpdate\x18\x05 \x01(\x08\x12\x13\n\x0b\x66romTeamUid\x18\x06 \x01(\x0c\x12\x33\n\x12\x64\x65\x66\x61ultManageUsers\x18\x07 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x35\n\x14\x64\x65\x66\x61ultManageRecords\x18\x08 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12/\n\x0e\x64\x65\x66\x61ultCanEdit\x18\t \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x30\n\x0f\x64\x65\x66\x61ultCanShare\x18\n \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12?\n\x15sharedFolderAddRecord\x18\x0b \x03(\x0b\x32 .Folder.SharedFolderUpdateRecord\x12;\n\x13sharedFolderAddUser\x18\x0c \x03(\x0b\x32\x1e.Folder.SharedFolderUpdateUser\x12;\n\x13sharedFolderAddTeam\x18\r \x03(\x0b\x32\x1e.Folder.SharedFolderUpdateTeam\x12\x42\n\x18sharedFolderUpdateRecord\x18\x0e \x03(\x0b\x32 .Folder.SharedFolderUpdateRecord\x12>\n\x16sharedFolderUpdateUser\x18\x0f \x03(\x0b\x32\x1e.Folder.SharedFolderUpdateUser\x12>\n\x16sharedFolderUpdateTeam\x18\x10 \x03(\x0b\x32\x1e.Folder.SharedFolderUpdateTeam\x12 \n\x18sharedFolderRemoveRecord\x18\x11 \x03(\x0c\x12\x1e\n\x16sharedFolderRemoveUser\x18\x12 \x03(\t\x12\x1e\n\x16sharedFolderRemoveTeam\x18\x13 \x03(\x0c\x12\x19\n\x11sharedFolderOwner\x18\x14 \x01(\t\"c\n\x1dSharedFolderUpdateV3RequestV2\x12\x42\n\x15sharedFoldersUpdateV3\x18\x01 \x03(\x0b\x32#.Folder.SharedFolderUpdateV3Request\"C\n\x1eSharedFolderUpdateRecordStatus\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x0e\n\x06status\x18\x02 \x01(\t\"@\n\x1cSharedFolderUpdateUserStatus\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x0e\n\x06status\x18\x02 \x01(\t\"?\n\x1cSharedFolderUpdateTeamStatus\x12\x0f\n\x07teamUid\x18\x01 \x01(\x0c\x12\x0e\n\x06status\x18\x02 \x01(\t\"\x88\x06\n\x1cSharedFolderUpdateV3Response\x12\x10\n\x08revision\x18\x01 \x01(\x03\x12K\n\x1bsharedFolderAddRecordStatus\x18\x02 \x03(\x0b\x32&.Folder.SharedFolderUpdateRecordStatus\x12G\n\x19sharedFolderAddUserStatus\x18\x03 \x03(\x0b\x32$.Folder.SharedFolderUpdateUserStatus\x12G\n\x19sharedFolderAddTeamStatus\x18\x04 \x03(\x0b\x32$.Folder.SharedFolderUpdateTeamStatus\x12N\n\x1esharedFolderUpdateRecordStatus\x18\x05 \x03(\x0b\x32&.Folder.SharedFolderUpdateRecordStatus\x12J\n\x1csharedFolderUpdateUserStatus\x18\x06 \x03(\x0b\x32$.Folder.SharedFolderUpdateUserStatus\x12J\n\x1csharedFolderUpdateTeamStatus\x18\x07 \x03(\x0b\x32$.Folder.SharedFolderUpdateTeamStatus\x12N\n\x1esharedFolderRemoveRecordStatus\x18\x08 \x03(\x0b\x32&.Folder.SharedFolderUpdateRecordStatus\x12J\n\x1csharedFolderRemoveUserStatus\x18\t \x03(\x0b\x32$.Folder.SharedFolderUpdateUserStatus\x12J\n\x1csharedFolderRemoveTeamStatus\x18\n \x03(\x0b\x32$.Folder.SharedFolderUpdateTeamStatus\x12\x17\n\x0fsharedFolderUid\x18\x0c \x01(\x0c\x12\x0e\n\x06status\x18\r \x01(\t\"m\n\x1eSharedFolderUpdateV3ResponseV2\x12K\n\x1dsharedFoldersUpdateV3Response\x18\x01 \x03(\x0b\x32$.Folder.SharedFolderUpdateV3Response\"\xfa\x01\n)GetDeletedSharedFoldersAndRecordsResponse\x12\x32\n\rsharedFolders\x18\x01 \x03(\x0b\x32\x1b.Folder.DeletedSharedFolder\x12>\n\x13sharedFolderRecords\x18\x02 \x03(\x0b\x32!.Folder.DeletedSharedFolderRecord\x12\x34\n\x11\x64\x65letedRecordData\x18\x03 \x03(\x0b\x32\x19.Folder.DeletedRecordData\x12#\n\tusernames\x18\x04 \x03(\x0b\x32\x10.Folder.Username\"\xd1\x01\n\x13\x44\x65letedSharedFolder\x12\x17\n\x0fsharedFolderUid\x18\x01 \x01(\x0c\x12\x11\n\tfolderUid\x18\x02 \x01(\x0c\x12\x11\n\tparentUid\x18\x03 \x01(\x0c\x12\x17\n\x0fsharedFolderKey\x18\x04 \x01(\x0c\x12-\n\rfolderKeyType\x18\x05 \x01(\x0e\x32\x16.Records.RecordKeyType\x12\x0c\n\x04\x64\x61ta\x18\x06 \x01(\x0c\x12\x13\n\x0b\x64\x61teDeleted\x18\x07 \x01(\x03\x12\x10\n\x08revision\x18\x08 \x01(\x03\"\x81\x01\n\x19\x44\x65letedSharedFolderRecord\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\x12\x17\n\x0fsharedRecordKey\x18\x03 \x01(\x0c\x12\x13\n\x0b\x64\x61teDeleted\x18\x04 \x01(\x03\x12\x10\n\x08revision\x18\x05 \x01(\x03\"\x85\x01\n\x11\x44\x65letedRecordData\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x10\n\x08ownerUid\x18\x02 \x01(\x0c\x12\x10\n\x08revision\x18\x03 \x01(\x03\x12\x1a\n\x12\x63lientModifiedTime\x18\x04 \x01(\x03\x12\x0c\n\x04\x64\x61ta\x18\x05 \x01(\x0c\x12\x0f\n\x07version\x18\x06 \x01(\x05\"0\n\x08Username\x12\x12\n\naccountUid\x18\x01 \x01(\x0c\x12\x10\n\x08username\x18\x02 \x01(\t\"\x8a\x01\n,RestoreDeletedSharedFoldersAndRecordsRequest\x12,\n\x07\x66olders\x18\x01 \x03(\x0b\x32\x1b.Folder.RestoreSharedObject\x12,\n\x07records\x18\x02 \x03(\x0b\x32\x1b.Folder.RestoreSharedObject\"<\n\x13RestoreSharedObject\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x12\n\nrecordUids\x18\x02 \x03(\x0c\"\x83\x02\n\nFolderData\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x11\n\tparentUid\x18\x02 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12%\n\x04type\x18\x04 \x01(\x0e\x32\x17.Folder.FolderUsageType\x12\x37\n\x16inheritUserPermissions\x18\x05 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x11\n\tfolderKey\x18\x06 \x01(\x0c\x12#\n\townerInfo\x18\x07 \x01(\x0b\x32\x10.Folder.UserInfo\x12\x13\n\x0b\x64\x61teCreated\x18\x08 \x01(\x03\x12\x14\n\x0clastModified\x18\t \x01(\x03\"z\n\tFolderKey\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x11\n\tparentUid\x18\x02 \x01(\x0c\x12\x11\n\tfolderKey\x18\x03 \x01(\x0c\x12\x34\n\x0b\x65ncryptedBy\x18\x04 \x01(\x0e\x32\x1f.Folder.FolderKeyEncryptionType\":\n\x10\x46olderAddRequest\x12&\n\nfolderData\x18\x01 \x03(\x0b\x32\x12.Folder.FolderData\"d\n\x12\x46olderModifyResult\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12*\n\x06status\x18\x02 \x01(\x0e\x32\x1a.Folder.FolderModifyStatus\x12\x0f\n\x07message\x18\x03 \x01(\t\"I\n\x11\x46olderAddResponse\x12\x34\n\x10\x66olderAddResults\x18\x01 \x03(\x0b\x32\x1a.Folder.FolderModifyResult\"=\n\x13\x46olderUpdateRequest\x12&\n\nfolderData\x18\x01 \x03(\x0b\x32\x12.Folder.FolderData\"O\n\x14\x46olderUpdateResponse\x12\x37\n\x13\x66olderUpdateResults\x18\x01 \x03(\x0b\x32\x1a.Folder.FolderModifyResult\"\xc3\x02\n\x11\x46olderPermissions\x12\x0e\n\x06\x63\x61nAdd\x18\x01 \x01(\x08\x12\x11\n\tcanRemove\x18\x02 \x01(\x08\x12\x11\n\tcanDelete\x18\x03 \x01(\x08\x12\x15\n\rcanListAccess\x18\x04 \x01(\x08\x12\x17\n\x0f\x63\x61nUpdateAccess\x18\x05 \x01(\x08\x12\x1a\n\x12\x63\x61nChangeOwnership\x18\x06 \x01(\x08\x12\x16\n\x0e\x63\x61nEditRecords\x18\x07 \x01(\x08\x12\x16\n\x0e\x63\x61nViewRecords\x18\x08 \x01(\x08\x12\x18\n\x10\x63\x61nApproveAccess\x18\t \x01(\x08\x12\x18\n\x10\x63\x61nRequestAccess\x18\n \x01(\x08\x12\x18\n\x10\x63\x61nUpdateSetting\x18\x0b \x01(\x08\x12\x16\n\x0e\x63\x61nListRecords\x18\x0c \x01(\x08\x12\x16\n\x0e\x63\x61nListFolders\x18\r \x01(\x08\"\x83\x05\n\x0c\x43\x61pabilities\x12\'\n\x06\x63\x61nAdd\x18\x01 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12*\n\tcanRemove\x18\x02 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12*\n\tcanDelete\x18\x03 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12.\n\rcanListAccess\x18\x04 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x30\n\x0f\x63\x61nUpdateAccess\x18\x05 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x33\n\x12\x63\x61nChangeOwnership\x18\x06 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12/\n\x0e\x63\x61nEditRecords\x18\x07 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12/\n\x0e\x63\x61nViewRecords\x18\x08 \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x31\n\x10\x63\x61nApproveAccess\x18\t \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x31\n\x10\x63\x61nRequestAccess\x18\n \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12\x31\n\x10\x63\x61nUpdateSetting\x18\x0b \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12/\n\x0e\x63\x61nListRecords\x18\x0c \x01(\x0e\x32\x17.Folder.SetBooleanValue\x12/\n\x0e\x63\x61nListFolders\x18\r \x01(\x0e\x32\x17.Folder.SetBooleanValue\"\xb8\x01\n\x19\x46olderRecordUpdateRequest\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12*\n\naddRecords\x18\x02 \x03(\x0b\x32\x16.Folder.RecordMetadata\x12-\n\rupdateRecords\x18\x03 \x03(\x0b\x32\x16.Folder.RecordMetadata\x12-\n\rremoveRecords\x18\x04 \x03(\x0b\x32\x16.Folder.RecordMetadata\"\xd1\x01\n\x0eRecordMetadata\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x1a\n\x12\x65ncryptedRecordKey\x18\x02 \x01(\x0c\x12\x38\n\x16\x65ncryptedRecordKeyType\x18\x03 \x01(\x0e\x32\x18.Folder.EncryptedKeyType\x12\x30\n\rtlaProperties\x18\x05 \x01(\x0b\x32\x19.common.tla.TLAProperties\x12$\n\x1crecordKeyEncryptedByOwnerKey\x18\x06 \x01(\x0c\"\x93\x01\n\x0c\x46olderRecord\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12.\n\x0erecordMetadata\x18\x02 \x01(\x0b\x32\x16.Folder.RecordMetadata\x12@\n\x17\x66olderKeyEncryptionType\x18\x03 \x01(\x0e\x32\x1f.Folder.FolderKeyEncryptionType\"s\n\x1a\x46olderRecordUpdateResponse\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x42\n\x18\x66olderRecordUpdateResult\x18\x04 \x03(\x0b\x32 .Folder.FolderRecordUpdateResult\"j\n\x18\x46olderRecordUpdateResult\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12*\n\x06status\x18\x02 \x01(\x0e\x32\x1a.Folder.FolderModifyStatus\x12\x0f\n\x07message\x18\x03 \x01(\t\"\x87\x03\n\x10\x46olderAccessData\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x15\n\raccessTypeUid\x18\x02 \x01(\x0c\x12&\n\naccessType\x18\x03 \x01(\x0e\x32\x12.Folder.AccessType\x12.\n\x0e\x61\x63\x63\x65ssRoleType\x18\x04 \x01(\x0e\x32\x16.Folder.AccessRoleType\x12+\n\tfolderKey\x18\x05 \x01(\x0b\x32\x18.Folder.EncryptedDataKey\x12\x11\n\tinherited\x18\x06 \x01(\x08\x12\x0e\n\x06hidden\x18\x07 \x01(\x08\x12.\n\x0bpermissions\x18\x08 \x01(\x0b\x32\x19.Folder.FolderPermissions\x12\x30\n\rtlaProperties\x18\t \x01(\x0b\x32\x19.common.tla.TLAProperties\x12\x13\n\x0b\x64\x61teCreated\x18\n \x01(\x03\x12\x14\n\x0clastModified\x18\x0b \x01(\x03\x12\x14\n\x0c\x64\x65niedAccess\x18\x0c \x01(\x08\"\\\n\rRevokedAccess\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x10\n\x08\x61\x63torUid\x18\x02 \x01(\x0c\x12&\n\naccessType\x18\x03 \x01(\x0e\x32\x12.Folder.AccessType\"#\n\rFolderRemoved\x12\x12\n\nfolder_uid\x18\x01 \x01(\x0c\"\x93\x04\n\x10RecordAccessData\x12\x15\n\raccessTypeUid\x18\x01 \x01(\x0c\x12&\n\naccessType\x18\x02 \x01(\x0e\x32\x12.Folder.AccessType\x12\x11\n\trecordUid\x18\x03 \x01(\x0c\x12.\n\x0e\x61\x63\x63\x65ssRoleType\x18\x04 \x01(\x0e\x32\x16.Folder.AccessRoleType\x12\r\n\x05owner\x18\x05 \x01(\x08\x12\x11\n\tinherited\x18\x06 \x01(\x08\x12\x0e\n\x06hidden\x18\x07 \x01(\x08\x12\x14\n\x0c\x64\x65niedAccess\x18\x08 \x01(\x08\x12\x16\n\x0e\x63\x61n_view_title\x18\t \x01(\x08\x12\x10\n\x08\x63\x61n_edit\x18\n \x01(\x08\x12\x10\n\x08\x63\x61n_view\x18\x0b \x01(\x08\x12\x17\n\x0f\x63\x61n_list_access\x18\x0c \x01(\x08\x12\x19\n\x11\x63\x61n_update_access\x18\r \x01(\x08\x12\x12\n\ncan_delete\x18\x0e \x01(\x08\x12\x1c\n\x14\x63\x61n_change_ownership\x18\x0f \x01(\x08\x12\x1a\n\x12\x63\x61n_request_access\x18\x10 \x01(\x08\x12\x1a\n\x12\x63\x61n_approve_access\x18\x11 \x01(\x08\x12\x13\n\x0b\x64\x61teCreated\x18\x12 \x01(\x03\x12\x14\n\x0clastModified\x18\x13 \x01(\x03\x12\x30\n\rtlaProperties\x18\x14 \x01(\x0b\x32\x19.common.tla.TLAProperties\"\xb8\x01\n\nAccessData\x12\x15\n\raccessTypeUid\x18\x01 \x01(\x0c\x12.\n\x0e\x61\x63\x63\x65ssRoleType\x18\x02 \x01(\x0e\x32\x16.Folder.AccessRoleType\x12\x14\n\x0c\x64\x65niedAccess\x18\x03 \x01(\x08\x12\x11\n\tinherited\x18\x04 \x01(\x08\x12\x0e\n\x06hidden\x18\x05 \x01(\x08\x12*\n\x0c\x63\x61pabilities\x18\x06 \x01(\x0b\x32\x14.Folder.Capabilities\"\xb7\x01\n\x13\x46olderAccessRequest\x12\x32\n\x10\x66olderAccessAdds\x18\x01 \x03(\x0b\x32\x18.Folder.FolderAccessData\x12\x35\n\x13\x66olderAccessUpdates\x18\x02 \x03(\x0b\x32\x18.Folder.FolderAccessData\x12\x35\n\x13\x66olderAccessRemoves\x18\x03 \x03(\x0b\x32\x18.Folder.FolderAccessData\"\x9f\x01\n\x12\x46olderAccessResult\x12\x11\n\tfolderUid\x18\x01 \x01(\x0c\x12\x11\n\taccessUid\x18\x02 \x01(\x0c\x12&\n\naccessType\x18\x03 \x01(\x0e\x32\x12.Folder.AccessType\x12*\n\x06status\x18\x04 \x01(\x0e\x32\x1a.Folder.FolderModifyStatus\x12\x0f\n\x07message\x18\x05 \x01(\t\"O\n\x14\x46olderAccessResponse\x12\x37\n\x13\x66olderAccessResults\x18\x01 \x03(\x0b\x32\x1a.Folder.FolderAccessResult\"0\n\x08UserInfo\x12\x12\n\naccountUid\x18\x01 \x01(\x0c\x12\x10\n\x08username\x18\x02 \x01(\t\"M\n\nRecordData\x12\x1e\n\x04user\x18\x01 \x01(\x0b\x32\x10.Folder.UserInfo\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12\x11\n\trecordUid\x18\x03 \x01(\x0c\"{\n\tRecordKey\x12\x10\n\x08user_uid\x18\x01 \x01(\x0c\x12\x12\n\nrecord_uid\x18\x02 \x01(\x0c\x12\x12\n\nrecord_key\x18\x03 \x01(\x0c\x12\x34\n\x12\x65ncrypted_key_type\x18\x04 \x01(\x0e\x32\x18.Folder.EncryptedKeyType*\x1a\n\nRecordType\x12\x0c\n\x08password\x10\x00*^\n\nFolderType\x12\x12\n\x0e\x64\x65\x66\x61ult_folder\x10\x00\x12\x0f\n\x0buser_folder\x10\x01\x12\x11\n\rshared_folder\x10\x02\x12\x18\n\x14shared_folder_folder\x10\x03*\x96\x01\n\x10\x45ncryptedKeyType\x12\n\n\x06no_key\x10\x00\x12\x19\n\x15\x65ncrypted_by_data_key\x10\x01\x12\x1b\n\x17\x65ncrypted_by_public_key\x10\x02\x12\x1d\n\x19\x65ncrypted_by_data_key_gcm\x10\x03\x12\x1f\n\x1b\x65ncrypted_by_public_key_ecc\x10\x04*M\n\x0fSetBooleanValue\x12\x15\n\x11\x42OOLEAN_NO_CHANGE\x10\x00\x12\x10\n\x0c\x42OOLEAN_TRUE\x10\x01\x12\x11\n\rBOOLEAN_FALSE\x10\x02*R\n\x0f\x46olderUsageType\x12\x0e\n\nUT_UNKNOWN\x10\x00\x12\r\n\tUT_NORMAL\x10\x01\x12\x0f\n\x0bUT_WORKFLOW\x10\x02\x12\x0f\n\x0bUT_TRASHCAN\x10\x03*l\n\x17\x46olderKeyEncryptionType\x12\x19\n\x15\x45NCRYPTED_BY_USER_KEY\x10\x00\x12\x1b\n\x17\x45NCRYPTED_BY_PARENT_KEY\x10\x01\x12\x19\n\x15\x45NCRYPTED_BY_TEAM_KEY\x10\x02*T\n\x12\x46olderModifyStatus\x12\x0b\n\x07SUCCESS\x10\x00\x12\x0f\n\x0b\x42\x41\x44_REQUEST\x10\x01\x12\x11\n\rACCESS_DENIED\x10\x02\x12\r\n\tNOT_FOUND\x10\x03*\xa4\x02\n\x14\x46olderPermissionBits\x12\n\n\x06noBits\x10\x00\x12\n\n\x06\x63\x61nAdd\x10\x01\x12\r\n\tcanRemove\x10\x02\x12\r\n\tcanDelete\x10\x04\x12\x11\n\rcanListAccess\x10\x08\x12\x13\n\x0f\x63\x61nUpdateAccess\x10\x10\x12\x16\n\x12\x63\x61nChangeOwnership\x10 \x12\x12\n\x0e\x63\x61nEditRecords\x10@\x12\x13\n\x0e\x63\x61nViewRecords\x10\x80\x01\x12\x15\n\x10\x63\x61nApproveAccess\x10\x80\x02\x12\x15\n\x10\x63\x61nRequestAccess\x10\x80\x04\x12\x15\n\x10\x63\x61nUpdateSetting\x10\x80\x08\x12\x13\n\x0e\x63\x61nListRecords\x10\x80\x10\x12\x13\n\x0e\x63\x61nListFolders\x10\x80 *\x9b\x01\n\x0e\x41\x63\x63\x65ssRoleType\x12\r\n\tNAVIGATOR\x10\x00\x12\r\n\tREQUESTOR\x10\x01\x12\n\n\x06VIEWER\x10\x02\x12\x12\n\x0eSHARED_MANAGER\x10\x03\x12\x13\n\x0f\x43ONTENT_MANAGER\x10\x04\x12\x19\n\x15\x43ONTENT_SHARE_MANAGER\x10\x05\x12\x0b\n\x07MANAGER\x10\x06\x12\x0e\n\nUNRESOLVED\x10\x07*z\n\nAccessType\x12\x0e\n\nAT_UNKNOWN\x10\x00\x12\x0c\n\x08\x41T_OWNER\x10\x01\x12\x0b\n\x07\x41T_USER\x10\x02\x12\x0b\n\x07\x41T_TEAM\x10\x03\x12\x11\n\rAT_ENTERPRISE\x10\x04\x12\r\n\tAT_FOLDER\x10\x05\x12\x12\n\x0e\x41T_APPLICATION\x10\x06*:\n\nObjectType\x12\x0e\n\nOT_UNKNOWN\x10\x00\x12\r\n\tOT_RECORD\x10\x01\x12\r\n\tOT_FOLDER\x10\x02\x42\"\n\x18\x63om.keepersecurity.protoB\x06\x46olderb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -38,28 +38,28 @@ _globals['_SHAREDFOLDERUPDATEUSER'].fields_by_name['sharedFolderKey']._serialized_options = b'\030\001' _globals['_SHAREDFOLDERUPDATETEAM'].fields_by_name['sharedFolderKey']._loaded_options = None _globals['_SHAREDFOLDERUPDATETEAM'].fields_by_name['sharedFolderKey']._serialized_options = b'\030\001' - _globals['_RECORDTYPE']._serialized_start=10143 - _globals['_RECORDTYPE']._serialized_end=10169 - _globals['_FOLDERTYPE']._serialized_start=10171 - _globals['_FOLDERTYPE']._serialized_end=10265 - _globals['_ENCRYPTEDKEYTYPE']._serialized_start=10268 - _globals['_ENCRYPTEDKEYTYPE']._serialized_end=10418 - _globals['_SETBOOLEANVALUE']._serialized_start=10420 - _globals['_SETBOOLEANVALUE']._serialized_end=10497 - _globals['_FOLDERUSAGETYPE']._serialized_start=10499 - _globals['_FOLDERUSAGETYPE']._serialized_end=10581 - _globals['_FOLDERKEYENCRYPTIONTYPE']._serialized_start=10583 - _globals['_FOLDERKEYENCRYPTIONTYPE']._serialized_end=10691 - _globals['_FOLDERMODIFYSTATUS']._serialized_start=10693 - _globals['_FOLDERMODIFYSTATUS']._serialized_end=10777 - _globals['_FOLDERPERMISSIONBITS']._serialized_start=10780 - _globals['_FOLDERPERMISSIONBITS']._serialized_end=11072 - _globals['_ACCESSROLETYPE']._serialized_start=11075 - _globals['_ACCESSROLETYPE']._serialized_end=11230 - _globals['_ACCESSTYPE']._serialized_start=11232 - _globals['_ACCESSTYPE']._serialized_end=11354 - _globals['_OBJECTTYPE']._serialized_start=11356 - _globals['_OBJECTTYPE']._serialized_end=11414 + _globals['_RECORDTYPE']._serialized_start=10181 + _globals['_RECORDTYPE']._serialized_end=10207 + _globals['_FOLDERTYPE']._serialized_start=10209 + _globals['_FOLDERTYPE']._serialized_end=10303 + _globals['_ENCRYPTEDKEYTYPE']._serialized_start=10306 + _globals['_ENCRYPTEDKEYTYPE']._serialized_end=10456 + _globals['_SETBOOLEANVALUE']._serialized_start=10458 + _globals['_SETBOOLEANVALUE']._serialized_end=10535 + _globals['_FOLDERUSAGETYPE']._serialized_start=10537 + _globals['_FOLDERUSAGETYPE']._serialized_end=10619 + _globals['_FOLDERKEYENCRYPTIONTYPE']._serialized_start=10621 + _globals['_FOLDERKEYENCRYPTIONTYPE']._serialized_end=10729 + _globals['_FOLDERMODIFYSTATUS']._serialized_start=10731 + _globals['_FOLDERMODIFYSTATUS']._serialized_end=10815 + _globals['_FOLDERPERMISSIONBITS']._serialized_start=10818 + _globals['_FOLDERPERMISSIONBITS']._serialized_end=11110 + _globals['_ACCESSROLETYPE']._serialized_start=11113 + _globals['_ACCESSROLETYPE']._serialized_end=11268 + _globals['_ACCESSTYPE']._serialized_start=11270 + _globals['_ACCESSTYPE']._serialized_end=11392 + _globals['_OBJECTTYPE']._serialized_start=11394 + _globals['_OBJECTTYPE']._serialized_end=11452 _globals['_ENCRYPTEDDATAKEY']._serialized_start=49 _globals['_ENCRYPTEDDATAKEY']._serialized_end=141 _globals['_SHAREDFOLDERRECORDDATA']._serialized_start=144 @@ -141,33 +141,33 @@ _globals['_FOLDERRECORDUPDATEREQUEST']._serialized_start=7479 _globals['_FOLDERRECORDUPDATEREQUEST']._serialized_end=7663 _globals['_RECORDMETADATA']._serialized_start=7666 - _globals['_RECORDMETADATA']._serialized_end=7837 - _globals['_FOLDERRECORD']._serialized_start=7840 - _globals['_FOLDERRECORD']._serialized_end=7987 - _globals['_FOLDERRECORDUPDATERESPONSE']._serialized_start=7989 - _globals['_FOLDERRECORDUPDATERESPONSE']._serialized_end=8104 - _globals['_FOLDERRECORDUPDATERESULT']._serialized_start=8106 - _globals['_FOLDERRECORDUPDATERESULT']._serialized_end=8212 - _globals['_FOLDERACCESSDATA']._serialized_start=8215 - _globals['_FOLDERACCESSDATA']._serialized_end=8606 - _globals['_REVOKEDACCESS']._serialized_start=8608 - _globals['_REVOKEDACCESS']._serialized_end=8700 - _globals['_FOLDERREMOVED']._serialized_start=8702 - _globals['_FOLDERREMOVED']._serialized_end=8737 - _globals['_RECORDACCESSDATA']._serialized_start=8740 - _globals['_RECORDACCESSDATA']._serialized_end=9271 - _globals['_ACCESSDATA']._serialized_start=9274 - _globals['_ACCESSDATA']._serialized_end=9458 - _globals['_FOLDERACCESSREQUEST']._serialized_start=9461 - _globals['_FOLDERACCESSREQUEST']._serialized_end=9644 - _globals['_FOLDERACCESSRESULT']._serialized_start=9647 - _globals['_FOLDERACCESSRESULT']._serialized_end=9806 - _globals['_FOLDERACCESSRESPONSE']._serialized_start=9808 - _globals['_FOLDERACCESSRESPONSE']._serialized_end=9887 - _globals['_USERINFO']._serialized_start=9889 - _globals['_USERINFO']._serialized_end=9937 - _globals['_RECORDDATA']._serialized_start=9939 - _globals['_RECORDDATA']._serialized_end=10016 - _globals['_RECORDKEY']._serialized_start=10018 - _globals['_RECORDKEY']._serialized_end=10141 + _globals['_RECORDMETADATA']._serialized_end=7875 + _globals['_FOLDERRECORD']._serialized_start=7878 + _globals['_FOLDERRECORD']._serialized_end=8025 + _globals['_FOLDERRECORDUPDATERESPONSE']._serialized_start=8027 + _globals['_FOLDERRECORDUPDATERESPONSE']._serialized_end=8142 + _globals['_FOLDERRECORDUPDATERESULT']._serialized_start=8144 + _globals['_FOLDERRECORDUPDATERESULT']._serialized_end=8250 + _globals['_FOLDERACCESSDATA']._serialized_start=8253 + _globals['_FOLDERACCESSDATA']._serialized_end=8644 + _globals['_REVOKEDACCESS']._serialized_start=8646 + _globals['_REVOKEDACCESS']._serialized_end=8738 + _globals['_FOLDERREMOVED']._serialized_start=8740 + _globals['_FOLDERREMOVED']._serialized_end=8775 + _globals['_RECORDACCESSDATA']._serialized_start=8778 + _globals['_RECORDACCESSDATA']._serialized_end=9309 + _globals['_ACCESSDATA']._serialized_start=9312 + _globals['_ACCESSDATA']._serialized_end=9496 + _globals['_FOLDERACCESSREQUEST']._serialized_start=9499 + _globals['_FOLDERACCESSREQUEST']._serialized_end=9682 + _globals['_FOLDERACCESSRESULT']._serialized_start=9685 + _globals['_FOLDERACCESSRESULT']._serialized_end=9844 + _globals['_FOLDERACCESSRESPONSE']._serialized_start=9846 + _globals['_FOLDERACCESSRESPONSE']._serialized_end=9925 + _globals['_USERINFO']._serialized_start=9927 + _globals['_USERINFO']._serialized_end=9975 + _globals['_RECORDDATA']._serialized_start=9977 + _globals['_RECORDDATA']._serialized_end=10054 + _globals['_RECORDKEY']._serialized_start=10056 + _globals['_RECORDKEY']._serialized_end=10179 # @@protoc_insertion_point(module_scope) diff --git a/keepercommander/proto/folder_pb2.pyi b/keepercommander/proto/folder_pb2.pyi index 57a857974..4e97bb0c2 100644 --- a/keepercommander/proto/folder_pb2.pyi +++ b/keepercommander/proto/folder_pb2.pyi @@ -243,7 +243,7 @@ class SharedFolderFields(_message.Message): manageRecords: bool canEdit: bool canShare: bool - def __init__(self, encryptedFolderName: _Optional[bytes] = ..., manageUsers: bool = ..., manageRecords: bool = ..., canEdit: bool = ..., canShare: bool = ...) -> None: ... + def __init__(self, encryptedFolderName: _Optional[bytes] = ..., manageUsers: _Optional[bool] = ..., manageRecords: _Optional[bool] = ..., canEdit: _Optional[bool] = ..., canShare: _Optional[bool] = ...) -> None: ... class SharedFolderFolderFields(_message.Message): __slots__ = ("sharedFolderUid",) @@ -317,7 +317,7 @@ class SharedFolderUpdateRecord(_message.Message): expiration: int timerNotificationType: _record_pb2.TimerNotificationType rotateOnExpiration: bool - def __init__(self, recordUid: _Optional[bytes] = ..., sharedFolderUid: _Optional[bytes] = ..., teamUid: _Optional[bytes] = ..., canEdit: _Optional[_Union[SetBooleanValue, str]] = ..., canShare: _Optional[_Union[SetBooleanValue, str]] = ..., encryptedRecordKey: _Optional[bytes] = ..., revision: _Optional[int] = ..., expiration: _Optional[int] = ..., timerNotificationType: _Optional[_Union[_record_pb2.TimerNotificationType, str]] = ..., rotateOnExpiration: bool = ...) -> None: ... + def __init__(self, recordUid: _Optional[bytes] = ..., sharedFolderUid: _Optional[bytes] = ..., teamUid: _Optional[bytes] = ..., canEdit: _Optional[_Union[SetBooleanValue, str]] = ..., canShare: _Optional[_Union[SetBooleanValue, str]] = ..., encryptedRecordKey: _Optional[bytes] = ..., revision: _Optional[int] = ..., expiration: _Optional[int] = ..., timerNotificationType: _Optional[_Union[_record_pb2.TimerNotificationType, str]] = ..., rotateOnExpiration: _Optional[bool] = ...) -> None: ... class SharedFolderUpdateUser(_message.Message): __slots__ = ("username", "manageUsers", "manageRecords", "sharedFolderKey", "expiration", "timerNotificationType", "typedSharedFolderKey", "rotateOnExpiration") @@ -337,7 +337,7 @@ class SharedFolderUpdateUser(_message.Message): timerNotificationType: _record_pb2.TimerNotificationType typedSharedFolderKey: EncryptedDataKey rotateOnExpiration: bool - def __init__(self, username: _Optional[str] = ..., manageUsers: _Optional[_Union[SetBooleanValue, str]] = ..., manageRecords: _Optional[_Union[SetBooleanValue, str]] = ..., sharedFolderKey: _Optional[bytes] = ..., expiration: _Optional[int] = ..., timerNotificationType: _Optional[_Union[_record_pb2.TimerNotificationType, str]] = ..., typedSharedFolderKey: _Optional[_Union[EncryptedDataKey, _Mapping]] = ..., rotateOnExpiration: bool = ...) -> None: ... + def __init__(self, username: _Optional[str] = ..., manageUsers: _Optional[_Union[SetBooleanValue, str]] = ..., manageRecords: _Optional[_Union[SetBooleanValue, str]] = ..., sharedFolderKey: _Optional[bytes] = ..., expiration: _Optional[int] = ..., timerNotificationType: _Optional[_Union[_record_pb2.TimerNotificationType, str]] = ..., typedSharedFolderKey: _Optional[_Union[EncryptedDataKey, _Mapping]] = ..., rotateOnExpiration: _Optional[bool] = ...) -> None: ... class SharedFolderUpdateTeam(_message.Message): __slots__ = ("teamUid", "manageUsers", "manageRecords", "sharedFolderKey", "expiration", "timerNotificationType", "typedSharedFolderKey", "rotateOnExpiration") @@ -357,7 +357,7 @@ class SharedFolderUpdateTeam(_message.Message): timerNotificationType: _record_pb2.TimerNotificationType typedSharedFolderKey: EncryptedDataKey rotateOnExpiration: bool - def __init__(self, teamUid: _Optional[bytes] = ..., manageUsers: bool = ..., manageRecords: bool = ..., sharedFolderKey: _Optional[bytes] = ..., expiration: _Optional[int] = ..., timerNotificationType: _Optional[_Union[_record_pb2.TimerNotificationType, str]] = ..., typedSharedFolderKey: _Optional[_Union[EncryptedDataKey, _Mapping]] = ..., rotateOnExpiration: bool = ...) -> None: ... + def __init__(self, teamUid: _Optional[bytes] = ..., manageUsers: _Optional[bool] = ..., manageRecords: _Optional[bool] = ..., sharedFolderKey: _Optional[bytes] = ..., expiration: _Optional[int] = ..., timerNotificationType: _Optional[_Union[_record_pb2.TimerNotificationType, str]] = ..., typedSharedFolderKey: _Optional[_Union[EncryptedDataKey, _Mapping]] = ..., rotateOnExpiration: _Optional[bool] = ...) -> None: ... class SharedFolderUpdateV3Request(_message.Message): __slots__ = ("sharedFolderUpdateOperation_dont_use", "sharedFolderUid", "encryptedSharedFolderName", "revision", "forceUpdate", "fromTeamUid", "defaultManageUsers", "defaultManageRecords", "defaultCanEdit", "defaultCanShare", "sharedFolderAddRecord", "sharedFolderAddUser", "sharedFolderAddTeam", "sharedFolderUpdateRecord", "sharedFolderUpdateUser", "sharedFolderUpdateTeam", "sharedFolderRemoveRecord", "sharedFolderRemoveUser", "sharedFolderRemoveTeam", "sharedFolderOwner") @@ -401,7 +401,7 @@ class SharedFolderUpdateV3Request(_message.Message): sharedFolderRemoveUser: _containers.RepeatedScalarFieldContainer[str] sharedFolderRemoveTeam: _containers.RepeatedScalarFieldContainer[bytes] sharedFolderOwner: str - def __init__(self, sharedFolderUpdateOperation_dont_use: _Optional[int] = ..., sharedFolderUid: _Optional[bytes] = ..., encryptedSharedFolderName: _Optional[bytes] = ..., revision: _Optional[int] = ..., forceUpdate: bool = ..., fromTeamUid: _Optional[bytes] = ..., defaultManageUsers: _Optional[_Union[SetBooleanValue, str]] = ..., defaultManageRecords: _Optional[_Union[SetBooleanValue, str]] = ..., defaultCanEdit: _Optional[_Union[SetBooleanValue, str]] = ..., defaultCanShare: _Optional[_Union[SetBooleanValue, str]] = ..., sharedFolderAddRecord: _Optional[_Iterable[_Union[SharedFolderUpdateRecord, _Mapping]]] = ..., sharedFolderAddUser: _Optional[_Iterable[_Union[SharedFolderUpdateUser, _Mapping]]] = ..., sharedFolderAddTeam: _Optional[_Iterable[_Union[SharedFolderUpdateTeam, _Mapping]]] = ..., sharedFolderUpdateRecord: _Optional[_Iterable[_Union[SharedFolderUpdateRecord, _Mapping]]] = ..., sharedFolderUpdateUser: _Optional[_Iterable[_Union[SharedFolderUpdateUser, _Mapping]]] = ..., sharedFolderUpdateTeam: _Optional[_Iterable[_Union[SharedFolderUpdateTeam, _Mapping]]] = ..., sharedFolderRemoveRecord: _Optional[_Iterable[bytes]] = ..., sharedFolderRemoveUser: _Optional[_Iterable[str]] = ..., sharedFolderRemoveTeam: _Optional[_Iterable[bytes]] = ..., sharedFolderOwner: _Optional[str] = ...) -> None: ... + def __init__(self, sharedFolderUpdateOperation_dont_use: _Optional[int] = ..., sharedFolderUid: _Optional[bytes] = ..., encryptedSharedFolderName: _Optional[bytes] = ..., revision: _Optional[int] = ..., forceUpdate: _Optional[bool] = ..., fromTeamUid: _Optional[bytes] = ..., defaultManageUsers: _Optional[_Union[SetBooleanValue, str]] = ..., defaultManageRecords: _Optional[_Union[SetBooleanValue, str]] = ..., defaultCanEdit: _Optional[_Union[SetBooleanValue, str]] = ..., defaultCanShare: _Optional[_Union[SetBooleanValue, str]] = ..., sharedFolderAddRecord: _Optional[_Iterable[_Union[SharedFolderUpdateRecord, _Mapping]]] = ..., sharedFolderAddUser: _Optional[_Iterable[_Union[SharedFolderUpdateUser, _Mapping]]] = ..., sharedFolderAddTeam: _Optional[_Iterable[_Union[SharedFolderUpdateTeam, _Mapping]]] = ..., sharedFolderUpdateRecord: _Optional[_Iterable[_Union[SharedFolderUpdateRecord, _Mapping]]] = ..., sharedFolderUpdateUser: _Optional[_Iterable[_Union[SharedFolderUpdateUser, _Mapping]]] = ..., sharedFolderUpdateTeam: _Optional[_Iterable[_Union[SharedFolderUpdateTeam, _Mapping]]] = ..., sharedFolderRemoveRecord: _Optional[_Iterable[bytes]] = ..., sharedFolderRemoveUser: _Optional[_Iterable[str]] = ..., sharedFolderRemoveTeam: _Optional[_Iterable[bytes]] = ..., sharedFolderOwner: _Optional[str] = ...) -> None: ... class SharedFolderUpdateV3RequestV2(_message.Message): __slots__ = ("sharedFoldersUpdateV3",) @@ -649,7 +649,7 @@ class FolderPermissions(_message.Message): canUpdateSetting: bool canListRecords: bool canListFolders: bool - def __init__(self, canAdd: bool = ..., canRemove: bool = ..., canDelete: bool = ..., canListAccess: bool = ..., canUpdateAccess: bool = ..., canChangeOwnership: bool = ..., canEditRecords: bool = ..., canViewRecords: bool = ..., canApproveAccess: bool = ..., canRequestAccess: bool = ..., canUpdateSetting: bool = ..., canListRecords: bool = ..., canListFolders: bool = ...) -> None: ... + def __init__(self, canAdd: _Optional[bool] = ..., canRemove: _Optional[bool] = ..., canDelete: _Optional[bool] = ..., canListAccess: _Optional[bool] = ..., canUpdateAccess: _Optional[bool] = ..., canChangeOwnership: _Optional[bool] = ..., canEditRecords: _Optional[bool] = ..., canViewRecords: _Optional[bool] = ..., canApproveAccess: _Optional[bool] = ..., canRequestAccess: _Optional[bool] = ..., canUpdateSetting: _Optional[bool] = ..., canListRecords: _Optional[bool] = ..., canListFolders: _Optional[bool] = ...) -> None: ... class Capabilities(_message.Message): __slots__ = ("canAdd", "canRemove", "canDelete", "canListAccess", "canUpdateAccess", "canChangeOwnership", "canEditRecords", "canViewRecords", "canApproveAccess", "canRequestAccess", "canUpdateSetting", "canListRecords", "canListFolders") @@ -694,16 +694,18 @@ class FolderRecordUpdateRequest(_message.Message): def __init__(self, folderUid: _Optional[bytes] = ..., addRecords: _Optional[_Iterable[_Union[RecordMetadata, _Mapping]]] = ..., updateRecords: _Optional[_Iterable[_Union[RecordMetadata, _Mapping]]] = ..., removeRecords: _Optional[_Iterable[_Union[RecordMetadata, _Mapping]]] = ...) -> None: ... class RecordMetadata(_message.Message): - __slots__ = ("recordUid", "encryptedRecordKey", "encryptedRecordKeyType", "tlaProperties") + __slots__ = ("recordUid", "encryptedRecordKey", "encryptedRecordKeyType", "tlaProperties", "recordKeyEncryptedByOwnerKey") RECORDUID_FIELD_NUMBER: _ClassVar[int] ENCRYPTEDRECORDKEY_FIELD_NUMBER: _ClassVar[int] ENCRYPTEDRECORDKEYTYPE_FIELD_NUMBER: _ClassVar[int] TLAPROPERTIES_FIELD_NUMBER: _ClassVar[int] + RECORDKEYENCRYPTEDBYOWNERKEY_FIELD_NUMBER: _ClassVar[int] recordUid: bytes encryptedRecordKey: bytes encryptedRecordKeyType: EncryptedKeyType tlaProperties: _tla_pb2.TLAProperties - def __init__(self, recordUid: _Optional[bytes] = ..., encryptedRecordKey: _Optional[bytes] = ..., encryptedRecordKeyType: _Optional[_Union[EncryptedKeyType, str]] = ..., tlaProperties: _Optional[_Union[_tla_pb2.TLAProperties, _Mapping]] = ...) -> None: ... + recordKeyEncryptedByOwnerKey: bytes + def __init__(self, recordUid: _Optional[bytes] = ..., encryptedRecordKey: _Optional[bytes] = ..., encryptedRecordKeyType: _Optional[_Union[EncryptedKeyType, str]] = ..., tlaProperties: _Optional[_Union[_tla_pb2.TLAProperties, _Mapping]] = ..., recordKeyEncryptedByOwnerKey: _Optional[bytes] = ...) -> None: ... class FolderRecord(_message.Message): __slots__ = ("folderUid", "recordMetadata", "folderKeyEncryptionType") @@ -759,7 +761,7 @@ class FolderAccessData(_message.Message): dateCreated: int lastModified: int deniedAccess: bool - def __init__(self, folderUid: _Optional[bytes] = ..., accessTypeUid: _Optional[bytes] = ..., accessType: _Optional[_Union[AccessType, str]] = ..., accessRoleType: _Optional[_Union[AccessRoleType, str]] = ..., folderKey: _Optional[_Union[EncryptedDataKey, _Mapping]] = ..., inherited: bool = ..., hidden: bool = ..., permissions: _Optional[_Union[FolderPermissions, _Mapping]] = ..., tlaProperties: _Optional[_Union[_tla_pb2.TLAProperties, _Mapping]] = ..., dateCreated: _Optional[int] = ..., lastModified: _Optional[int] = ..., deniedAccess: bool = ...) -> None: ... + def __init__(self, folderUid: _Optional[bytes] = ..., accessTypeUid: _Optional[bytes] = ..., accessType: _Optional[_Union[AccessType, str]] = ..., accessRoleType: _Optional[_Union[AccessRoleType, str]] = ..., folderKey: _Optional[_Union[EncryptedDataKey, _Mapping]] = ..., inherited: _Optional[bool] = ..., hidden: _Optional[bool] = ..., permissions: _Optional[_Union[FolderPermissions, _Mapping]] = ..., tlaProperties: _Optional[_Union[_tla_pb2.TLAProperties, _Mapping]] = ..., dateCreated: _Optional[int] = ..., lastModified: _Optional[int] = ..., deniedAccess: _Optional[bool] = ...) -> None: ... class RevokedAccess(_message.Message): __slots__ = ("folderUid", "actorUid", "accessType") @@ -819,7 +821,7 @@ class RecordAccessData(_message.Message): dateCreated: int lastModified: int tlaProperties: _tla_pb2.TLAProperties - def __init__(self, accessTypeUid: _Optional[bytes] = ..., accessType: _Optional[_Union[AccessType, str]] = ..., recordUid: _Optional[bytes] = ..., accessRoleType: _Optional[_Union[AccessRoleType, str]] = ..., owner: bool = ..., inherited: bool = ..., hidden: bool = ..., deniedAccess: bool = ..., can_view_title: bool = ..., can_edit: bool = ..., can_view: bool = ..., can_list_access: bool = ..., can_update_access: bool = ..., can_delete: bool = ..., can_change_ownership: bool = ..., can_request_access: bool = ..., can_approve_access: bool = ..., dateCreated: _Optional[int] = ..., lastModified: _Optional[int] = ..., tlaProperties: _Optional[_Union[_tla_pb2.TLAProperties, _Mapping]] = ...) -> None: ... + def __init__(self, accessTypeUid: _Optional[bytes] = ..., accessType: _Optional[_Union[AccessType, str]] = ..., recordUid: _Optional[bytes] = ..., accessRoleType: _Optional[_Union[AccessRoleType, str]] = ..., owner: _Optional[bool] = ..., inherited: _Optional[bool] = ..., hidden: _Optional[bool] = ..., deniedAccess: _Optional[bool] = ..., can_view_title: _Optional[bool] = ..., can_edit: _Optional[bool] = ..., can_view: _Optional[bool] = ..., can_list_access: _Optional[bool] = ..., can_update_access: _Optional[bool] = ..., can_delete: _Optional[bool] = ..., can_change_ownership: _Optional[bool] = ..., can_request_access: _Optional[bool] = ..., can_approve_access: _Optional[bool] = ..., dateCreated: _Optional[int] = ..., lastModified: _Optional[int] = ..., tlaProperties: _Optional[_Union[_tla_pb2.TLAProperties, _Mapping]] = ...) -> None: ... class AccessData(_message.Message): __slots__ = ("accessTypeUid", "accessRoleType", "deniedAccess", "inherited", "hidden", "capabilities") @@ -835,7 +837,7 @@ class AccessData(_message.Message): inherited: bool hidden: bool capabilities: Capabilities - def __init__(self, accessTypeUid: _Optional[bytes] = ..., accessRoleType: _Optional[_Union[AccessRoleType, str]] = ..., deniedAccess: bool = ..., inherited: bool = ..., hidden: bool = ..., capabilities: _Optional[_Union[Capabilities, _Mapping]] = ...) -> None: ... + def __init__(self, accessTypeUid: _Optional[bytes] = ..., accessRoleType: _Optional[_Union[AccessRoleType, str]] = ..., deniedAccess: _Optional[bool] = ..., inherited: _Optional[bool] = ..., hidden: _Optional[bool] = ..., capabilities: _Optional[_Union[Capabilities, _Mapping]] = ...) -> None: ... class FolderAccessRequest(_message.Message): __slots__ = ("folderAccessAdds", "folderAccessUpdates", "folderAccessRemoves") diff --git a/keepercommander/proto/pagination_pb2.pyi b/keepercommander/proto/pagination_pb2.pyi index bb7a09bfb..08c2e73a5 100644 --- a/keepercommander/proto/pagination_pb2.pyi +++ b/keepercommander/proto/pagination_pb2.pyi @@ -26,4 +26,4 @@ class PageInfo(_message.Message): totalCount: int hasMore: bool cursorToken: str - def __init__(self, pageNumber: _Optional[int] = ..., pageSize: _Optional[int] = ..., totalCount: _Optional[int] = ..., hasMore: bool = ..., cursorToken: _Optional[str] = ...) -> None: ... + def __init__(self, pageNumber: _Optional[int] = ..., pageSize: _Optional[int] = ..., totalCount: _Optional[int] = ..., hasMore: _Optional[bool] = ..., cursorToken: _Optional[str] = ...) -> None: ... diff --git a/keepercommander/proto/record_endpoints_pb2.py b/keepercommander/proto/record_endpoints_pb2.py index 55cbe6d3b..a33789aec 100644 --- a/keepercommander/proto/record_endpoints_pb2.py +++ b/keepercommander/proto/record_endpoints_pb2.py @@ -26,7 +26,7 @@ from . import folder_pb2 as folder__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x16record_endpoints.proto\x12\trecord.v3\x1a\x0crecord.proto\x1a\x0c\x66older.proto\"\x83\x01\n\x11RecordsAddRequest\x12%\n\x07records\x18\x01 \x03(\x0b\x32\x14.record.v3.RecordAdd\x12\x12\n\nclientTime\x18\x02 \x01(\x03\x12\x33\n\x13securityDataKeyType\x18\x03 \x01(\x0e\x32\x16.Records.RecordKeyType\"\xa8\x03\n\tRecordAdd\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x11\n\trecordKey\x18\x02 \x01(\x0c\x12/\n\rrecordKeyType\x18\x03 \x01(\x0e\x32\x18.Folder.EncryptedKeyType\x12=\n\x14recordKeyEncryptedBy\x18\x04 \x01(\x0e\x32\x1f.Folder.FolderKeyEncryptionType\x12\x1a\n\x12\x63lientModifiedTime\x18\x05 \x01(\x03\x12\x0c\n\x04\x64\x61ta\x18\x06 \x01(\x0c\x12\x15\n\rnonSharedData\x18\x07 \x01(\x0c\x12\x11\n\tfolderUid\x18\x08 \x01(\x0c\x12(\n\x0brecordLinks\x18\t \x03(\x0b\x32\x13.Records.RecordLink\x12#\n\x05\x61udit\x18\n \x01(\x0b\x32\x14.Records.RecordAudit\x12+\n\x0csecurityData\x18\x0b \x01(\x0b\x32\x15.Records.SecurityData\x12\x35\n\x11securityScoreData\x18\x0c \x01(\x0b\x32\x1a.Records.SecurityScoreDataB*\n&com.keepersecurity.proto.api.record.v3P\x01\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x16record_endpoints.proto\x12\trecord.v3\x1a\x0crecord.proto\x1a\x0c\x66older.proto\"\x83\x01\n\x11RecordsAddRequest\x12%\n\x07records\x18\x01 \x03(\x0b\x32\x14.record.v3.RecordAdd\x12\x12\n\nclientTime\x18\x02 \x01(\x03\x12\x33\n\x13securityDataKeyType\x18\x03 \x01(\x0e\x32\x16.Records.RecordKeyType\"\xce\x03\n\tRecordAdd\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x11\n\trecordKey\x18\x02 \x01(\x0c\x12/\n\rrecordKeyType\x18\x03 \x01(\x0e\x32\x18.Folder.EncryptedKeyType\x12=\n\x14recordKeyEncryptedBy\x18\x04 \x01(\x0e\x32\x1f.Folder.FolderKeyEncryptionType\x12\x1a\n\x12\x63lientModifiedTime\x18\x05 \x01(\x03\x12\x0c\n\x04\x64\x61ta\x18\x06 \x01(\x0c\x12\x15\n\rnonSharedData\x18\x07 \x01(\x0c\x12\x11\n\tfolderUid\x18\x08 \x01(\x0c\x12(\n\x0brecordLinks\x18\t \x03(\x0b\x32\x13.Records.RecordLink\x12#\n\x05\x61udit\x18\n \x01(\x0b\x32\x14.Records.RecordAudit\x12+\n\x0csecurityData\x18\x0b \x01(\x0b\x32\x15.Records.SecurityData\x12\x35\n\x11securityScoreData\x18\x0c \x01(\x0b\x32\x1a.Records.SecurityScoreData\x12$\n\x1crecordKeyEncryptedByOwnerKey\x18\r \x01(\x0c\x42*\n&com.keepersecurity.proto.api.record.v3P\x01\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -37,5 +37,5 @@ _globals['_RECORDSADDREQUEST']._serialized_start=66 _globals['_RECORDSADDREQUEST']._serialized_end=197 _globals['_RECORDADD']._serialized_start=200 - _globals['_RECORDADD']._serialized_end=624 + _globals['_RECORDADD']._serialized_end=662 # @@protoc_insertion_point(module_scope) diff --git a/keepercommander/proto/record_endpoints_pb2.pyi b/keepercommander/proto/record_endpoints_pb2.pyi index a79e7e9c4..d52d8c60a 100644 --- a/keepercommander/proto/record_endpoints_pb2.pyi +++ b/keepercommander/proto/record_endpoints_pb2.pyi @@ -18,7 +18,7 @@ class RecordsAddRequest(_message.Message): def __init__(self, records: _Optional[_Iterable[_Union[RecordAdd, _Mapping]]] = ..., clientTime: _Optional[int] = ..., securityDataKeyType: _Optional[_Union[_record_pb2.RecordKeyType, str]] = ...) -> None: ... class RecordAdd(_message.Message): - __slots__ = ("recordUid", "recordKey", "recordKeyType", "recordKeyEncryptedBy", "clientModifiedTime", "data", "nonSharedData", "folderUid", "recordLinks", "audit", "securityData", "securityScoreData") + __slots__ = ("recordUid", "recordKey", "recordKeyType", "recordKeyEncryptedBy", "clientModifiedTime", "data", "nonSharedData", "folderUid", "recordLinks", "audit", "securityData", "securityScoreData", "recordKeyEncryptedByOwnerKey") RECORDUID_FIELD_NUMBER: _ClassVar[int] RECORDKEY_FIELD_NUMBER: _ClassVar[int] RECORDKEYTYPE_FIELD_NUMBER: _ClassVar[int] @@ -31,6 +31,7 @@ class RecordAdd(_message.Message): AUDIT_FIELD_NUMBER: _ClassVar[int] SECURITYDATA_FIELD_NUMBER: _ClassVar[int] SECURITYSCOREDATA_FIELD_NUMBER: _ClassVar[int] + RECORDKEYENCRYPTEDBYOWNERKEY_FIELD_NUMBER: _ClassVar[int] recordUid: bytes recordKey: bytes recordKeyType: _folder_pb2.EncryptedKeyType @@ -43,4 +44,5 @@ class RecordAdd(_message.Message): audit: _record_pb2.RecordAudit securityData: _record_pb2.SecurityData securityScoreData: _record_pb2.SecurityScoreData - def __init__(self, recordUid: _Optional[bytes] = ..., recordKey: _Optional[bytes] = ..., recordKeyType: _Optional[_Union[_folder_pb2.EncryptedKeyType, str]] = ..., recordKeyEncryptedBy: _Optional[_Union[_folder_pb2.FolderKeyEncryptionType, str]] = ..., clientModifiedTime: _Optional[int] = ..., data: _Optional[bytes] = ..., nonSharedData: _Optional[bytes] = ..., folderUid: _Optional[bytes] = ..., recordLinks: _Optional[_Iterable[_Union[_record_pb2.RecordLink, _Mapping]]] = ..., audit: _Optional[_Union[_record_pb2.RecordAudit, _Mapping]] = ..., securityData: _Optional[_Union[_record_pb2.SecurityData, _Mapping]] = ..., securityScoreData: _Optional[_Union[_record_pb2.SecurityScoreData, _Mapping]] = ...) -> None: ... + recordKeyEncryptedByOwnerKey: bytes + def __init__(self, recordUid: _Optional[bytes] = ..., recordKey: _Optional[bytes] = ..., recordKeyType: _Optional[_Union[_folder_pb2.EncryptedKeyType, str]] = ..., recordKeyEncryptedBy: _Optional[_Union[_folder_pb2.FolderKeyEncryptionType, str]] = ..., clientModifiedTime: _Optional[int] = ..., data: _Optional[bytes] = ..., nonSharedData: _Optional[bytes] = ..., folderUid: _Optional[bytes] = ..., recordLinks: _Optional[_Iterable[_Union[_record_pb2.RecordLink, _Mapping]]] = ..., audit: _Optional[_Union[_record_pb2.RecordAudit, _Mapping]] = ..., securityData: _Optional[_Union[_record_pb2.SecurityData, _Mapping]] = ..., securityScoreData: _Optional[_Union[_record_pb2.SecurityScoreData, _Mapping]] = ..., recordKeyEncryptedByOwnerKey: _Optional[bytes] = ...) -> None: ... diff --git a/keepercommander/proto/record_pb2.py b/keepercommander/proto/record_pb2.py index 6fb04617e..0f4ac5822 100644 --- a/keepercommander/proto/record_pb2.py +++ b/keepercommander/proto/record_pb2.py @@ -24,7 +24,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0crecord.proto\x12\x07Records\"\\\n\nRecordType\x12\x14\n\x0crecordTypeId\x18\x01 \x01(\x05\x12\x0f\n\x07\x63ontent\x18\x02 \x01(\t\x12\'\n\x05scope\x18\x03 \x01(\x0e\x32\x18.Records.RecordTypeScope\"U\n\x12RecordTypesRequest\x12\x10\n\x08standard\x18\x01 \x01(\x08\x12\x0c\n\x04user\x18\x02 \x01(\x08\x12\x12\n\nenterprise\x18\x03 \x01(\x08\x12\x0b\n\x03pam\x18\x04 \x01(\x08\"\x9c\x01\n\x13RecordTypesResponse\x12(\n\x0brecordTypes\x18\x01 \x03(\x0b\x32\x13.Records.RecordType\x12\x17\n\x0fstandardCounter\x18\x02 \x01(\x05\x12\x13\n\x0buserCounter\x18\x03 \x01(\x05\x12\x19\n\x11\x65nterpriseCounter\x18\x04 \x01(\x05\x12\x12\n\npamCounter\x18\x05 \x01(\x05\"A\n\x18RecordTypeModifyResponse\x12\x14\n\x0crecordTypeId\x18\x01 \x01(\x05\x12\x0f\n\x07\x63ounter\x18\x02 \x01(\x05\"=\n\x11RecordsGetRequest\x12\x13\n\x0brecord_uids\x18\x01 \x03(\x0c\x12\x13\n\x0b\x63lient_time\x18\x02 \x01(\x03\"\xd1\x01\n\x06Record\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12\x12\n\nrecord_key\x18\x02 \x01(\x0c\x12/\n\x0frecord_key_type\x18\x03 \x01(\x0e\x32\x16.Records.RecordKeyType\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\x12\r\n\x05\x65xtra\x18\x05 \x01(\x0c\x12\x0f\n\x07version\x18\x06 \x01(\x05\x12\x1c\n\x14\x63lient_modified_time\x18\x07 \x01(\x03\x12\x10\n\x08revision\x18\x08 \x01(\x03\x12\x10\n\x08\x66ile_ids\x18\t \x03(\x0c\"M\n\x0f\x46olderRecordKey\x12\x12\n\nfolder_uid\x18\x01 \x01(\x0c\x12\x12\n\nrecord_uid\x18\x02 \x01(\x0c\x12\x12\n\nrecord_key\x18\x03 \x01(\x0c\"a\n\x06\x46older\x12\x12\n\nfolder_uid\x18\x01 \x01(\x0c\x12\x12\n\nfolder_key\x18\x02 \x01(\x0c\x12/\n\x0f\x66older_key_type\x18\x03 \x01(\x0e\x32\x16.Records.RecordKeyType\"\x95\x01\n\x04Team\x12\x10\n\x08team_uid\x18\x01 \x01(\x0c\x12\x10\n\x08team_key\x18\x02 \x01(\x0c\x12\x18\n\x10team_private_key\x18\x03 \x01(\x0c\x12-\n\rteam_key_type\x18\x04 \x01(\x0e\x32\x16.Records.RecordKeyType\x12 \n\x07\x66olders\x18\x05 \x03(\x0b\x32\x0f.Records.Folder\"\xac\x01\n\x12RecordsGetResponse\x12 \n\x07records\x18\x01 \x03(\x0b\x32\x0f.Records.Record\x12\x34\n\x12\x66older_record_keys\x18\x02 \x03(\x0b\x32\x18.Records.FolderRecordKey\x12 \n\x07\x66olders\x18\x03 \x03(\x0b\x32\x0f.Records.Folder\x12\x1c\n\x05teams\x18\x04 \x03(\x0b\x32\r.Records.Team\"4\n\nRecordLink\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12\x12\n\nrecord_key\x18\x02 \x01(\x0c\",\n\x0bRecordAudit\x12\x0f\n\x07version\x18\x01 \x01(\x05\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\"\x1c\n\x0cSecurityData\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\"!\n\x11SecurityScoreData\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\"\x84\x03\n\tRecordAdd\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12\x12\n\nrecord_key\x18\x02 \x01(\x0c\x12\x1c\n\x14\x63lient_modified_time\x18\x03 \x01(\x03\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\x12\x17\n\x0fnon_shared_data\x18\x05 \x01(\x0c\x12.\n\x0b\x66older_type\x18\x06 \x01(\x0e\x32\x19.Records.RecordFolderType\x12\x12\n\nfolder_uid\x18\x07 \x01(\x0c\x12\x12\n\nfolder_key\x18\x08 \x01(\x0c\x12)\n\x0crecord_links\x18\t \x03(\x0b\x32\x13.Records.RecordLink\x12#\n\x05\x61udit\x18\n \x01(\x0b\x32\x14.Records.RecordAudit\x12+\n\x0csecurityData\x18\x0b \x01(\x0b\x32\x15.Records.SecurityData\x12\x35\n\x11securityScoreData\x18\x0c \x01(\x0b\x32\x1a.Records.SecurityScoreData\"\x85\x01\n\x11RecordsAddRequest\x12#\n\x07records\x18\x01 \x03(\x0b\x32\x12.Records.RecordAdd\x12\x13\n\x0b\x63lient_time\x18\x02 \x01(\x03\x12\x36\n\x16security_data_key_type\x18\x03 \x01(\x0e\x32\x16.Records.RecordKeyType\"\xce\x02\n\x0cRecordUpdate\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12\x1c\n\x14\x63lient_modified_time\x18\x02 \x01(\x03\x12\x10\n\x08revision\x18\x03 \x01(\x03\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\x12\x17\n\x0fnon_shared_data\x18\x05 \x01(\x0c\x12-\n\x10record_links_add\x18\x06 \x03(\x0b\x32\x13.Records.RecordLink\x12\x1b\n\x13record_links_remove\x18\x07 \x03(\x0c\x12#\n\x05\x61udit\x18\x08 \x01(\x0b\x32\x14.Records.RecordAudit\x12+\n\x0csecurityData\x18\t \x01(\x0b\x32\x15.Records.SecurityData\x12\x35\n\x11securityScoreData\x18\n \x01(\x0b\x32\x1a.Records.SecurityScoreData\"\x8b\x01\n\x14RecordsUpdateRequest\x12&\n\x07records\x18\x01 \x03(\x0b\x32\x15.Records.RecordUpdate\x12\x13\n\x0b\x63lient_time\x18\x02 \x01(\x03\x12\x36\n\x16security_data_key_type\x18\x03 \x01(\x0e\x32\x16.Records.RecordKeyType\"\x8e\x01\n\x17RecordFileForConversion\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12\x14\n\x0c\x66ile_file_id\x18\x02 \x01(\t\x12\x15\n\rthumb_file_id\x18\x03 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\x12\x12\n\nrecord_key\x18\x05 \x01(\x0c\x12\x10\n\x08link_key\x18\x06 \x01(\x0c\"J\n\x19RecordFolderForConversion\x12\x12\n\nfolder_uid\x18\x01 \x01(\x0c\x12\x19\n\x11record_folder_key\x18\x02 \x01(\x0c\"\x92\x02\n\x11RecordConvertToV3\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12\x1c\n\x14\x63lient_modified_time\x18\x02 \x01(\x03\x12\x10\n\x08revision\x18\x03 \x01(\x03\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\x12\x17\n\x0fnon_shared_data\x18\x05 \x01(\x0c\x12#\n\x05\x61udit\x18\x06 \x01(\x0b\x32\x14.Records.RecordAudit\x12\x35\n\x0brecord_file\x18\x07 \x03(\x0b\x32 .Records.RecordFileForConversion\x12\x36\n\nfolder_key\x18\x08 \x03(\x0b\x32\".Records.RecordFolderForConversion\"]\n\x19RecordsConvertToV3Request\x12+\n\x07records\x18\x01 \x03(\x0b\x32\x1a.Records.RecordConvertToV3\x12\x13\n\x0b\x63lient_time\x18\x02 \x01(\x03\"\'\n\x14RecordsRemoveRequest\x12\x0f\n\x07records\x18\x01 \x03(\x0c\">\n\x0cRecordRevert\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12\x1a\n\x12revert_to_revision\x18\x02 \x01(\x03\">\n\x14RecordsRevertRequest\x12&\n\x07records\x18\x01 \x03(\x0b\x32\x15.Records.RecordRevert\"c\n\x0fRecordLinkError\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12+\n\x06status\x18\x02 \x01(\x0e\x32\x1b.Records.RecordModifyResult\x12\x0f\n\x07message\x18\x03 \x01(\t\"\x95\x01\n\x12RecordModifyStatus\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12+\n\x06status\x18\x02 \x01(\x0e\x32\x1b.Records.RecordModifyResult\x12\x0f\n\x07message\x18\x03 \x01(\t\x12-\n\x0blink_errors\x18\x04 \x03(\x0b\x32\x18.Records.RecordLinkError\"W\n\x15RecordsModifyResponse\x12,\n\x07records\x18\x01 \x03(\x0b\x32\x1b.Records.RecordModifyStatus\x12\x10\n\x08revision\x18\x02 \x01(\x03\"Y\n\x12RecordAddAuditData\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12\x10\n\x08revision\x18\x02 \x01(\x03\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12\x0f\n\x07version\x18\x04 \x01(\x05\"C\n\x13\x41\x64\x64\x41uditDataRequest\x12,\n\x07records\x18\x01 \x03(\x0b\x32\x1b.Records.RecordAddAuditData\"t\n\x04\x46ile\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12\x12\n\nrecord_key\x18\x02 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12\x10\n\x08\x66ileSize\x18\x04 \x01(\x03\x12\x11\n\tthumbSize\x18\x05 \x01(\x05\x12\x11\n\tis_script\x18\x06 \x01(\x08\"D\n\x0f\x46ilesAddRequest\x12\x1c\n\x05\x66iles\x18\x01 \x03(\x0b\x32\r.Records.File\x12\x13\n\x0b\x63lient_time\x18\x02 \x01(\x03\"\xa7\x01\n\rFileAddStatus\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12&\n\x06status\x18\x02 \x01(\x0e\x32\x16.Records.FileAddResult\x12\x0b\n\x03url\x18\x03 \x01(\t\x12\x12\n\nparameters\x18\x04 \x01(\t\x12\x1c\n\x14thumbnail_parameters\x18\x05 \x01(\t\x12\x1b\n\x13success_status_code\x18\x06 \x01(\x05\"K\n\x10\x46ilesAddResponse\x12%\n\x05\x66iles\x18\x01 \x03(\x0b\x32\x16.Records.FileAddStatus\x12\x10\n\x08revision\x18\x02 \x01(\x03\"f\n\x0f\x46ilesGetRequest\x12\x13\n\x0brecord_uids\x18\x01 \x03(\x0c\x12\x16\n\x0e\x66or_thumbnails\x18\x02 \x01(\x08\x12&\n\x1e\x65mergency_access_account_owner\x18\x03 \x01(\t\"\xa2\x01\n\rFileGetStatus\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12&\n\x06status\x18\x02 \x01(\x0e\x32\x16.Records.FileGetResult\x12\x0b\n\x03url\x18\x03 \x01(\t\x12\x1b\n\x13success_status_code\x18\x04 \x01(\x05\x12+\n\x0b\x66ileKeyType\x18\x05 \x01(\x0e\x32\x16.Records.RecordKeyType\"9\n\x10\x46ilesGetResponse\x12%\n\x05\x66iles\x18\x01 \x03(\x0b\x32\x16.Records.FileGetStatus\"\x8d\x01\n\x15\x41pplicationAddRequest\x12\x0f\n\x07\x61pp_uid\x18\x01 \x01(\x0c\x12\x12\n\nrecord_key\x18\x02 \x01(\x0c\x12\x1c\n\x14\x63lient_modified_time\x18\x03 \x01(\x03\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\x12#\n\x05\x61udit\x18\x05 \x01(\x0b\x32\x14.Records.RecordAudit\"\x88\x01\n\"GetRecordDataWithAccessInfoRequest\x12\x12\n\nclientTime\x18\x01 \x01(\x03\x12\x11\n\trecordUid\x18\x02 \x03(\x0c\x12;\n\x14recordDetailsInclude\x18\x03 \x01(\x0e\x32\x1d.Records.RecordDetailsInclude\"\x86\x02\n\x0eUserPermission\x12\x10\n\x08username\x18\x01 \x01(\t\x12\r\n\x05owner\x18\x02 \x01(\x08\x12\x12\n\nshareAdmin\x18\x03 \x01(\x08\x12\x10\n\x08sharable\x18\x04 \x01(\x08\x12\x10\n\x08\x65\x64itable\x18\x05 \x01(\x08\x12\x18\n\x10\x61waitingApproval\x18\x06 \x01(\x08\x12\x12\n\nexpiration\x18\x07 \x01(\x03\x12\x12\n\naccountUid\x18\x08 \x01(\x0c\x12=\n\x15timerNotificationType\x18\t \x01(\x0e\x32\x1e.Records.TimerNotificationType\x12\x1a\n\x12rotateOnExpiration\x18\n \x01(\x08\"\xd8\x01\n\x16SharedFolderPermission\x12\x17\n\x0fsharedFolderUid\x18\x01 \x01(\x0c\x12\x12\n\nresharable\x18\x02 \x01(\x08\x12\x10\n\x08\x65\x64itable\x18\x03 \x01(\x08\x12\x10\n\x08revision\x18\x04 \x01(\x03\x12\x12\n\nexpiration\x18\x05 \x01(\x03\x12=\n\x15timerNotificationType\x18\x06 \x01(\x0e\x32\x1e.Records.TimerNotificationType\x12\x1a\n\x12rotateOnExpiration\x18\x07 \x01(\x08\"\xe8\x02\n\nRecordData\x12\x10\n\x08revision\x18\x01 \x01(\x03\x12\x0f\n\x07version\x18\x02 \x01(\x05\x12\x0e\n\x06shared\x18\x03 \x01(\x08\x12\x1b\n\x13\x65ncryptedRecordData\x18\x04 \x01(\t\x12\x1a\n\x12\x65ncryptedExtraData\x18\x05 \x01(\t\x12\x1a\n\x12\x63lientModifiedTime\x18\x06 \x01(\x03\x12\x15\n\rnonSharedData\x18\x07 \x01(\t\x12-\n\x10linkedRecordData\x18\x08 \x03(\x0b\x32\x13.Records.RecordData\x12\x0e\n\x06\x66ileId\x18\t \x03(\x0c\x12\x10\n\x08\x66ileSize\x18\n \x01(\x03\x12\x15\n\rthumbnailSize\x18\x0b \x01(\x03\x12-\n\rrecordKeyType\x18\x0c \x01(\x0e\x32\x16.Records.RecordKeyType\x12\x11\n\trecordKey\x18\r \x01(\x0c\x12\x11\n\trecordUid\x18\x0e \x01(\x0c\"\xc8\x01\n\x18RecordDataWithAccessInfo\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\'\n\nrecordData\x18\x02 \x01(\x0b\x32\x13.Records.RecordData\x12/\n\x0euserPermission\x18\x03 \x03(\x0b\x32\x17.Records.UserPermission\x12?\n\x16sharedFolderPermission\x18\x04 \x03(\x0b\x32\x1f.Records.SharedFolderPermission\"\x89\x01\n#GetRecordDataWithAccessInfoResponse\x12\x43\n\x18recordDataWithAccessInfo\x18\x01 \x03(\x0b\x32!.Records.RecordDataWithAccessInfo\x12\x1d\n\x15noPermissionRecordUid\x18\x02 \x03(\x0c\"j\n\x12IsObjectShareAdmin\x12\x0b\n\x03uid\x18\x01 \x01(\x0c\x12\x0f\n\x07isAdmin\x18\x02 \x01(\x08\x12\x36\n\nobjectType\x18\x03 \x01(\x0e\x32\".Records.CheckShareAdminObjectType\"H\n\rAmIShareAdmin\x12\x37\n\x12isObjectShareAdmin\x18\x01 \x03(\x0b\x32\x1b.Records.IsObjectShareAdmin\"\xbc\x01\n\x18RecordShareUpdateRequest\x12.\n\x0f\x61\x64\x64SharedRecord\x18\x01 \x03(\x0b\x32\x15.Records.SharedRecord\x12\x31\n\x12updateSharedRecord\x18\x02 \x03(\x0b\x32\x15.Records.SharedRecord\x12\x31\n\x12removeSharedRecord\x18\x03 \x03(\x0b\x32\x15.Records.SharedRecord\x12\n\n\x02pt\x18\x04 \x01(\t\"\xc4\x02\n\x0cSharedRecord\x12\x12\n\ntoUsername\x18\x01 \x01(\t\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\x12\x11\n\trecordKey\x18\x03 \x01(\x0c\x12\x17\n\x0fsharedFolderUid\x18\x04 \x01(\x0c\x12\x0f\n\x07teamUid\x18\x05 \x01(\x0c\x12\x10\n\x08\x65\x64itable\x18\x06 \x01(\x08\x12\x11\n\tshareable\x18\x07 \x01(\x08\x12\x10\n\x08transfer\x18\x08 \x01(\x08\x12\x11\n\tuseEccKey\x18\t \x01(\x08\x12\x17\n\x0fremoveVaultData\x18\n \x01(\x08\x12\x12\n\nexpiration\x18\x0b \x01(\x03\x12=\n\x15timerNotificationType\x18\x0c \x01(\x0e\x32\x1e.Records.TimerNotificationType\x12\x1a\n\x12rotateOnExpiration\x18\r \x01(\x08\"\xd5\x01\n\x19RecordShareUpdateResponse\x12:\n\x15\x61\x64\x64SharedRecordStatus\x18\x01 \x03(\x0b\x32\x1b.Records.SharedRecordStatus\x12=\n\x18updateSharedRecordStatus\x18\x02 \x03(\x0b\x32\x1b.Records.SharedRecordStatus\x12=\n\x18removeSharedRecordStatus\x18\x03 \x03(\x0b\x32\x1b.Records.SharedRecordStatus\"Z\n\x12SharedRecordStatus\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x0e\n\x06status\x18\x02 \x01(\t\x12\x0f\n\x07message\x18\x03 \x01(\t\x12\x10\n\x08username\x18\x04 \x01(\t\"G\n\x1bGetRecordPermissionsRequest\x12\x12\n\nrecordUids\x18\x01 \x03(\x0c\x12\x14\n\x0cisShareAdmin\x18\x02 \x01(\x08\"T\n\x1cGetRecordPermissionsResponse\x12\x34\n\x11recordPermissions\x18\x01 \x03(\x0b\x32\x19.Records.RecordPermission\"l\n\x10RecordPermission\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\r\n\x05owner\x18\x02 \x01(\x08\x12\x0f\n\x07\x63\x61nEdit\x18\x03 \x01(\x08\x12\x10\n\x08\x63\x61nShare\x18\x04 \x01(\x08\x12\x13\n\x0b\x63\x61nTransfer\x18\x05 \x01(\x08\"h\n\x16GetShareObjectsRequest\x12\x11\n\tstartWith\x18\x01 \x01(\t\x12\x10\n\x08\x63ontains\x18\x02 \x01(\t\x12\x10\n\x08\x66iltered\x18\x03 \x01(\x08\x12\x17\n\x0fsharedFolderUid\x18\x04 \x01(\x0c\"\xe7\x02\n\x17GetShareObjectsResponse\x12.\n\x12shareRelationships\x18\x01 \x03(\x0b\x32\x12.Records.ShareUser\x12,\n\x10shareFamilyUsers\x18\x02 \x03(\x0b\x32\x12.Records.ShareUser\x12\x30\n\x14shareEnterpriseUsers\x18\x03 \x03(\x0b\x32\x12.Records.ShareUser\x12&\n\nshareTeams\x18\x04 \x03(\x0b\x32\x12.Records.ShareTeam\x12(\n\x0cshareMCTeams\x18\x05 \x03(\x0b\x32\x12.Records.ShareTeam\x12\x32\n\x16shareMCEnterpriseUsers\x18\x06 \x03(\x0b\x32\x12.Records.ShareUser\x12\x36\n\x14shareEnterpriseNames\x18\x07 \x03(\x0b\x32\x18.Records.ShareEnterprise\"\xbd\x01\n\tShareUser\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x10\n\x08\x66ullname\x18\x02 \x01(\t\x12\x14\n\x0c\x65nterpriseId\x18\x03 \x01(\x05\x12$\n\x06status\x18\x04 \x01(\x0e\x32\x14.Records.ShareStatus\x12\x14\n\x0cisShareAdmin\x18\x05 \x01(\x08\x12\"\n\x1aisAdminOfSharedFolderOwner\x18\x06 \x01(\x08\x12\x16\n\x0euserAccountUid\x18\x07 \x01(\x0c\"D\n\tShareTeam\x12\x10\n\x08teamname\x18\x01 \x01(\t\x12\x14\n\x0c\x65nterpriseId\x18\x02 \x01(\x05\x12\x0f\n\x07teamUid\x18\x03 \x01(\x0c\"?\n\x0fShareEnterprise\x12\x16\n\x0e\x65nterprisename\x18\x01 \x01(\t\x12\x14\n\x0c\x65nterpriseId\x18\x02 \x01(\x05\"S\n\x1fRecordsOnwershipTransferRequest\x12\x30\n\x0ftransferRecords\x18\x01 \x03(\x0b\x32\x17.Records.TransferRecord\"[\n\x0eTransferRecord\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\x12\x11\n\trecordKey\x18\x03 \x01(\x0c\x12\x11\n\tuseEccKey\x18\x04 \x01(\x08\"_\n RecordsOnwershipTransferResponse\x12;\n\x14transferRecordStatus\x18\x01 \x03(\x0b\x32\x1d.Records.TransferRecordStatus\"\\\n\x14TransferRecordStatus\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\x12\x0e\n\x06status\x18\x03 \x01(\t\x12\x0f\n\x07message\x18\x04 \x01(\t\"y\n\x15RecordsUnshareRequest\x12\x34\n\rsharedFolders\x18\x01 \x03(\x0b\x32\x1d.Records.RecordsUnshareFolder\x12*\n\x05users\x18\x02 \x03(\x0b\x32\x1b.Records.RecordsUnshareUser\"\x86\x01\n\x16RecordsUnshareResponse\x12:\n\rsharedFolders\x18\x01 \x03(\x0b\x32#.Records.RecordsUnshareFolderStatus\x12\x30\n\x05users\x18\x02 \x03(\x0b\x32!.Records.RecordsUnshareUserStatus\"B\n\x14RecordsUnshareFolder\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x17\n\x0fsharedFolderUid\x18\x02 \x01(\x0c\";\n\x12RecordsUnshareUser\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x12\n\naccountUid\x18\x02 \x01(\x0c\"H\n\x1aRecordsUnshareFolderStatus\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x17\n\x0fsharedFolderUid\x18\x02 \x01(\x0c\"A\n\x18RecordsUnshareUserStatus\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x12\n\naccountUid\x18\x02 \x01(\x0c\"[\n\x1aTimedAccessCallbackPayload\x12=\n\x15timeLimitedAccessType\x18\x01 \x01(\x0e\x32\x1e.Records.TimeLimitedAccessType\"\xfd\x01\n\x18TimeLimitedAccessRequest\x12\x12\n\naccountUid\x18\x01 \x03(\x0c\x12\x0f\n\x07teamUid\x18\x02 \x03(\x0c\x12\x11\n\trecordUid\x18\x03 \x03(\x0c\x12\x17\n\x0fsharedObjectUid\x18\x04 \x01(\x0c\x12=\n\x15timeLimitedAccessType\x18\x05 \x01(\x0e\x32\x1e.Records.TimeLimitedAccessType\x12\x12\n\nexpiration\x18\x06 \x01(\x03\x12=\n\x15timerNotificationType\x18\x07 \x01(\x0e\x32\x1e.Records.TimerNotificationType\"7\n\x17TimeLimitedAccessStatus\x12\x0b\n\x03uid\x18\x01 \x01(\x0c\x12\x0f\n\x07message\x18\x02 \x01(\t\"\xe3\x01\n\x19TimeLimitedAccessResponse\x12\x10\n\x08revision\x18\x01 \x01(\x03\x12:\n\x10userAccessStatus\x18\x02 \x03(\x0b\x32 .Records.TimeLimitedAccessStatus\x12:\n\x10teamAccessStatus\x18\x03 \x03(\x0b\x32 .Records.TimeLimitedAccessStatus\x12<\n\x12recordAccessStatus\x18\x04 \x03(\x0b\x32 .Records.TimeLimitedAccessStatus*h\n\x0fRecordTypeScope\x12\x0f\n\x0bRT_STANDARD\x10\x00\x12\x0b\n\x07RT_USER\x10\x01\x12\x11\n\rRT_ENTERPRISE\x10\x02\x12\n\n\x06RT_PAM\x10\x03\x12\x18\n\x14RT_PAM_CONFIGURATION\x10\x04*\xd1\x01\n\rRecordKeyType\x12\n\n\x06NO_KEY\x10\x00\x12\x19\n\x15\x45NCRYPTED_BY_DATA_KEY\x10\x01\x12\x1b\n\x17\x45NCRYPTED_BY_PUBLIC_KEY\x10\x02\x12\x1d\n\x19\x45NCRYPTED_BY_DATA_KEY_GCM\x10\x03\x12\x1f\n\x1b\x45NCRYPTED_BY_PUBLIC_KEY_ECC\x10\x04\x12\x1d\n\x19\x45NCRYPTED_BY_ROOT_KEY_CBC\x10\x05\x12\x1d\n\x19\x45NCRYPTED_BY_ROOT_KEY_GCM\x10\x06*P\n\x10RecordFolderType\x12\x0f\n\x0buser_folder\x10\x00\x12\x11\n\rshared_folder\x10\x01\x12\x18\n\x14shared_folder_folder\x10\x02*\xec\x02\n\x12RecordModifyResult\x12\x0e\n\nRS_SUCCESS\x10\x00\x12\x12\n\x0eRS_OUT_OF_SYNC\x10\x01\x12\x14\n\x10RS_ACCESS_DENIED\x10\x02\x12\x13\n\x0fRS_SHARE_DENIED\x10\x03\x12\x14\n\x10RS_RECORD_EXISTS\x10\x04\x12\x1e\n\x1aRS_OLD_RECORD_VERSION_TYPE\x10\x05\x12\x1e\n\x1aRS_NEW_RECORD_VERSION_TYPE\x10\x06\x12\x16\n\x12RS_FILES_NOT_MATCH\x10\x07\x12\x1b\n\x17RS_RECORD_NOT_SHAREABLE\x10\x08\x12\x1f\n\x1bRS_ATTACHMENT_NOT_SHAREABLE\x10\t\x12\x19\n\x15RS_FILE_LIMIT_REACHED\x10\n\x12\x1a\n\x16RS_SIZE_EXCEEDED_LIMIT\x10\x0b\x12$\n RS_ONLY_OWNER_CAN_MODIFY_SCRIPTS\x10\x0c*-\n\rFileAddResult\x12\x0e\n\nFA_SUCCESS\x10\x00\x12\x0c\n\x08\x46\x41_ERROR\x10\x01*C\n\rFileGetResult\x12\x0e\n\nFG_SUCCESS\x10\x00\x12\x0c\n\x08\x46G_ERROR\x10\x01\x12\x14\n\x10\x46G_ACCESS_DENIED\x10\x02*J\n\x14RecordDetailsInclude\x12\x13\n\x0f\x44\x41TA_PLUS_SHARE\x10\x00\x12\r\n\tDATA_ONLY\x10\x01\x12\x0e\n\nSHARE_ONLY\x10\x02*b\n\x19\x43heckShareAdminObjectType\x12\x19\n\x15\x43HECK_SA_INVALID_TYPE\x10\x00\x12\x12\n\x0e\x43HECK_SA_ON_SF\x10\x01\x12\x16\n\x12\x43HECK_SA_ON_RECORD\x10\x02*1\n\x0bShareStatus\x12\n\n\x06\x41\x43TIVE\x10\x00\x12\t\n\x05\x42LOCK\x10\x01\x12\x0b\n\x07INVITED\x10\x02*:\n\x15RecordTransactionType\x12\x0f\n\x0bRTT_GENERAL\x10\x00\x12\x10\n\x0cRTT_ROTATION\x10\x01*\xdc\x02\n\x15TimeLimitedAccessType\x12$\n INVALID_TIME_LIMITED_ACCESS_TYPE\x10\x00\x12\x19\n\x15USER_ACCESS_TO_RECORD\x10\x01\x12\'\n#USER_OR_TEAM_ACCESS_TO_SHAREDFOLDER\x10\x02\x12!\n\x1dRECORD_ACCESS_TO_SHAREDFOLDER\x10\x03\x12\x1f\n\x1bUSER_ACCESS_TO_SHAREDFOLDER\x10\x04\x12\x1f\n\x1bTEAM_ACCESS_TO_SHAREDFOLDER\x10\x05\x12\x1b\n\x17RECORD_ACCESS_TO_FOLDER\x10\x06\x12\x19\n\x15USER_ACCESS_TO_FOLDER\x10\x07\x12\x19\n\x15TEAM_ACCESS_TO_FOLDER\x10\x08\x12!\n\x1dUSER_OR_TEAM_ACCESS_TO_FOLDER\x10\t*\\\n\x15TimerNotificationType\x12\x14\n\x10NOTIFICATION_OFF\x10\x00\x12\x10\n\x0cNOTIFY_OWNER\x10\x01\x12\x1b\n\x17NOTIFY_PRIVILEGED_USERS\x10\x02\x42#\n\x18\x63om.keepersecurity.protoB\x07Recordsb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0crecord.proto\x12\x07Records\"\\\n\nRecordType\x12\x14\n\x0crecordTypeId\x18\x01 \x01(\x05\x12\x0f\n\x07\x63ontent\x18\x02 \x01(\t\x12\'\n\x05scope\x18\x03 \x01(\x0e\x32\x18.Records.RecordTypeScope\"U\n\x12RecordTypesRequest\x12\x10\n\x08standard\x18\x01 \x01(\x08\x12\x0c\n\x04user\x18\x02 \x01(\x08\x12\x12\n\nenterprise\x18\x03 \x01(\x08\x12\x0b\n\x03pam\x18\x04 \x01(\x08\"\x9c\x01\n\x13RecordTypesResponse\x12(\n\x0brecordTypes\x18\x01 \x03(\x0b\x32\x13.Records.RecordType\x12\x17\n\x0fstandardCounter\x18\x02 \x01(\x05\x12\x13\n\x0buserCounter\x18\x03 \x01(\x05\x12\x19\n\x11\x65nterpriseCounter\x18\x04 \x01(\x05\x12\x12\n\npamCounter\x18\x05 \x01(\x05\"A\n\x18RecordTypeModifyResponse\x12\x14\n\x0crecordTypeId\x18\x01 \x01(\x05\x12\x0f\n\x07\x63ounter\x18\x02 \x01(\x05\"=\n\x11RecordsGetRequest\x12\x13\n\x0brecord_uids\x18\x01 \x03(\x0c\x12\x13\n\x0b\x63lient_time\x18\x02 \x01(\x03\"\xd1\x01\n\x06Record\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12\x12\n\nrecord_key\x18\x02 \x01(\x0c\x12/\n\x0frecord_key_type\x18\x03 \x01(\x0e\x32\x16.Records.RecordKeyType\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\x12\r\n\x05\x65xtra\x18\x05 \x01(\x0c\x12\x0f\n\x07version\x18\x06 \x01(\x05\x12\x1c\n\x14\x63lient_modified_time\x18\x07 \x01(\x03\x12\x10\n\x08revision\x18\x08 \x01(\x03\x12\x10\n\x08\x66ile_ids\x18\t \x03(\x0c\"~\n\x0f\x46olderRecordKey\x12\x12\n\nfolder_uid\x18\x01 \x01(\x0c\x12\x12\n\nrecord_uid\x18\x02 \x01(\x0c\x12\x12\n\nrecord_key\x18\x03 \x01(\x0c\x12/\n\x0frecord_key_type\x18\x04 \x01(\x0e\x32\x16.Records.RecordKeyType\"a\n\x06\x46older\x12\x12\n\nfolder_uid\x18\x01 \x01(\x0c\x12\x12\n\nfolder_key\x18\x02 \x01(\x0c\x12/\n\x0f\x66older_key_type\x18\x03 \x01(\x0e\x32\x16.Records.RecordKeyType\"\x95\x01\n\x04Team\x12\x10\n\x08team_uid\x18\x01 \x01(\x0c\x12\x10\n\x08team_key\x18\x02 \x01(\x0c\x12\x18\n\x10team_private_key\x18\x03 \x01(\x0c\x12-\n\rteam_key_type\x18\x04 \x01(\x0e\x32\x16.Records.RecordKeyType\x12 \n\x07\x66olders\x18\x05 \x03(\x0b\x32\x0f.Records.Folder\"\xac\x01\n\x12RecordsGetResponse\x12 \n\x07records\x18\x01 \x03(\x0b\x32\x0f.Records.Record\x12\x34\n\x12\x66older_record_keys\x18\x02 \x03(\x0b\x32\x18.Records.FolderRecordKey\x12 \n\x07\x66olders\x18\x03 \x03(\x0b\x32\x0f.Records.Folder\x12\x1c\n\x05teams\x18\x04 \x03(\x0b\x32\r.Records.Team\"4\n\nRecordLink\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12\x12\n\nrecord_key\x18\x02 \x01(\x0c\",\n\x0bRecordAudit\x12\x0f\n\x07version\x18\x01 \x01(\x05\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\"\x1c\n\x0cSecurityData\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\"!\n\x11SecurityScoreData\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\"\x84\x03\n\tRecordAdd\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12\x12\n\nrecord_key\x18\x02 \x01(\x0c\x12\x1c\n\x14\x63lient_modified_time\x18\x03 \x01(\x03\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\x12\x17\n\x0fnon_shared_data\x18\x05 \x01(\x0c\x12.\n\x0b\x66older_type\x18\x06 \x01(\x0e\x32\x19.Records.RecordFolderType\x12\x12\n\nfolder_uid\x18\x07 \x01(\x0c\x12\x12\n\nfolder_key\x18\x08 \x01(\x0c\x12)\n\x0crecord_links\x18\t \x03(\x0b\x32\x13.Records.RecordLink\x12#\n\x05\x61udit\x18\n \x01(\x0b\x32\x14.Records.RecordAudit\x12+\n\x0csecurityData\x18\x0b \x01(\x0b\x32\x15.Records.SecurityData\x12\x35\n\x11securityScoreData\x18\x0c \x01(\x0b\x32\x1a.Records.SecurityScoreData\"\x85\x01\n\x11RecordsAddRequest\x12#\n\x07records\x18\x01 \x03(\x0b\x32\x12.Records.RecordAdd\x12\x13\n\x0b\x63lient_time\x18\x02 \x01(\x03\x12\x36\n\x16security_data_key_type\x18\x03 \x01(\x0e\x32\x16.Records.RecordKeyType\"\xce\x02\n\x0cRecordUpdate\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12\x1c\n\x14\x63lient_modified_time\x18\x02 \x01(\x03\x12\x10\n\x08revision\x18\x03 \x01(\x03\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\x12\x17\n\x0fnon_shared_data\x18\x05 \x01(\x0c\x12-\n\x10record_links_add\x18\x06 \x03(\x0b\x32\x13.Records.RecordLink\x12\x1b\n\x13record_links_remove\x18\x07 \x03(\x0c\x12#\n\x05\x61udit\x18\x08 \x01(\x0b\x32\x14.Records.RecordAudit\x12+\n\x0csecurityData\x18\t \x01(\x0b\x32\x15.Records.SecurityData\x12\x35\n\x11securityScoreData\x18\n \x01(\x0b\x32\x1a.Records.SecurityScoreData\"\x8b\x01\n\x14RecordsUpdateRequest\x12&\n\x07records\x18\x01 \x03(\x0b\x32\x15.Records.RecordUpdate\x12\x13\n\x0b\x63lient_time\x18\x02 \x01(\x03\x12\x36\n\x16security_data_key_type\x18\x03 \x01(\x0e\x32\x16.Records.RecordKeyType\"\x8e\x01\n\x17RecordFileForConversion\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12\x14\n\x0c\x66ile_file_id\x18\x02 \x01(\t\x12\x15\n\rthumb_file_id\x18\x03 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\x12\x12\n\nrecord_key\x18\x05 \x01(\x0c\x12\x10\n\x08link_key\x18\x06 \x01(\x0c\"J\n\x19RecordFolderForConversion\x12\x12\n\nfolder_uid\x18\x01 \x01(\x0c\x12\x19\n\x11record_folder_key\x18\x02 \x01(\x0c\"\x92\x02\n\x11RecordConvertToV3\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12\x1c\n\x14\x63lient_modified_time\x18\x02 \x01(\x03\x12\x10\n\x08revision\x18\x03 \x01(\x03\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\x12\x17\n\x0fnon_shared_data\x18\x05 \x01(\x0c\x12#\n\x05\x61udit\x18\x06 \x01(\x0b\x32\x14.Records.RecordAudit\x12\x35\n\x0brecord_file\x18\x07 \x03(\x0b\x32 .Records.RecordFileForConversion\x12\x36\n\nfolder_key\x18\x08 \x03(\x0b\x32\".Records.RecordFolderForConversion\"]\n\x19RecordsConvertToV3Request\x12+\n\x07records\x18\x01 \x03(\x0b\x32\x1a.Records.RecordConvertToV3\x12\x13\n\x0b\x63lient_time\x18\x02 \x01(\x03\"\'\n\x14RecordsRemoveRequest\x12\x0f\n\x07records\x18\x01 \x03(\x0c\">\n\x0cRecordRevert\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12\x1a\n\x12revert_to_revision\x18\x02 \x01(\x03\">\n\x14RecordsRevertRequest\x12&\n\x07records\x18\x01 \x03(\x0b\x32\x15.Records.RecordRevert\"c\n\x0fRecordLinkError\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12+\n\x06status\x18\x02 \x01(\x0e\x32\x1b.Records.RecordModifyResult\x12\x0f\n\x07message\x18\x03 \x01(\t\"\x95\x01\n\x12RecordModifyStatus\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12+\n\x06status\x18\x02 \x01(\x0e\x32\x1b.Records.RecordModifyResult\x12\x0f\n\x07message\x18\x03 \x01(\t\x12-\n\x0blink_errors\x18\x04 \x03(\x0b\x32\x18.Records.RecordLinkError\"W\n\x15RecordsModifyResponse\x12,\n\x07records\x18\x01 \x03(\x0b\x32\x1b.Records.RecordModifyStatus\x12\x10\n\x08revision\x18\x02 \x01(\x03\"Y\n\x12RecordAddAuditData\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12\x10\n\x08revision\x18\x02 \x01(\x03\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12\x0f\n\x07version\x18\x04 \x01(\x05\"C\n\x13\x41\x64\x64\x41uditDataRequest\x12,\n\x07records\x18\x01 \x03(\x0b\x32\x1b.Records.RecordAddAuditData\"t\n\x04\x46ile\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12\x12\n\nrecord_key\x18\x02 \x01(\x0c\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12\x10\n\x08\x66ileSize\x18\x04 \x01(\x03\x12\x11\n\tthumbSize\x18\x05 \x01(\x05\x12\x11\n\tis_script\x18\x06 \x01(\x08\"D\n\x0f\x46ilesAddRequest\x12\x1c\n\x05\x66iles\x18\x01 \x03(\x0b\x32\r.Records.File\x12\x13\n\x0b\x63lient_time\x18\x02 \x01(\x03\"\xa7\x01\n\rFileAddStatus\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12&\n\x06status\x18\x02 \x01(\x0e\x32\x16.Records.FileAddResult\x12\x0b\n\x03url\x18\x03 \x01(\t\x12\x12\n\nparameters\x18\x04 \x01(\t\x12\x1c\n\x14thumbnail_parameters\x18\x05 \x01(\t\x12\x1b\n\x13success_status_code\x18\x06 \x01(\x05\"K\n\x10\x46ilesAddResponse\x12%\n\x05\x66iles\x18\x01 \x03(\x0b\x32\x16.Records.FileAddStatus\x12\x10\n\x08revision\x18\x02 \x01(\x03\"f\n\x0f\x46ilesGetRequest\x12\x13\n\x0brecord_uids\x18\x01 \x03(\x0c\x12\x16\n\x0e\x66or_thumbnails\x18\x02 \x01(\x08\x12&\n\x1e\x65mergency_access_account_owner\x18\x03 \x01(\t\"\xa2\x01\n\rFileGetStatus\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12&\n\x06status\x18\x02 \x01(\x0e\x32\x16.Records.FileGetResult\x12\x0b\n\x03url\x18\x03 \x01(\t\x12\x1b\n\x13success_status_code\x18\x04 \x01(\x05\x12+\n\x0b\x66ileKeyType\x18\x05 \x01(\x0e\x32\x16.Records.RecordKeyType\"9\n\x10\x46ilesGetResponse\x12%\n\x05\x66iles\x18\x01 \x03(\x0b\x32\x16.Records.FileGetStatus\"\x8d\x01\n\x15\x41pplicationAddRequest\x12\x0f\n\x07\x61pp_uid\x18\x01 \x01(\x0c\x12\x12\n\nrecord_key\x18\x02 \x01(\x0c\x12\x1c\n\x14\x63lient_modified_time\x18\x03 \x01(\x03\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\x12#\n\x05\x61udit\x18\x05 \x01(\x0b\x32\x14.Records.RecordAudit\"\x88\x01\n\"GetRecordDataWithAccessInfoRequest\x12\x12\n\nclientTime\x18\x01 \x01(\x03\x12\x11\n\trecordUid\x18\x02 \x03(\x0c\x12;\n\x14recordDetailsInclude\x18\x03 \x01(\x0e\x32\x1d.Records.RecordDetailsInclude\"\x86\x02\n\x0eUserPermission\x12\x10\n\x08username\x18\x01 \x01(\t\x12\r\n\x05owner\x18\x02 \x01(\x08\x12\x12\n\nshareAdmin\x18\x03 \x01(\x08\x12\x10\n\x08sharable\x18\x04 \x01(\x08\x12\x10\n\x08\x65\x64itable\x18\x05 \x01(\x08\x12\x18\n\x10\x61waitingApproval\x18\x06 \x01(\x08\x12\x12\n\nexpiration\x18\x07 \x01(\x03\x12\x12\n\naccountUid\x18\x08 \x01(\x0c\x12=\n\x15timerNotificationType\x18\t \x01(\x0e\x32\x1e.Records.TimerNotificationType\x12\x1a\n\x12rotateOnExpiration\x18\n \x01(\x08\"\xd8\x01\n\x16SharedFolderPermission\x12\x17\n\x0fsharedFolderUid\x18\x01 \x01(\x0c\x12\x12\n\nresharable\x18\x02 \x01(\x08\x12\x10\n\x08\x65\x64itable\x18\x03 \x01(\x08\x12\x10\n\x08revision\x18\x04 \x01(\x03\x12\x12\n\nexpiration\x18\x05 \x01(\x03\x12=\n\x15timerNotificationType\x18\x06 \x01(\x0e\x32\x1e.Records.TimerNotificationType\x12\x1a\n\x12rotateOnExpiration\x18\x07 \x01(\x08\"\xe8\x02\n\nRecordData\x12\x10\n\x08revision\x18\x01 \x01(\x03\x12\x0f\n\x07version\x18\x02 \x01(\x05\x12\x0e\n\x06shared\x18\x03 \x01(\x08\x12\x1b\n\x13\x65ncryptedRecordData\x18\x04 \x01(\t\x12\x1a\n\x12\x65ncryptedExtraData\x18\x05 \x01(\t\x12\x1a\n\x12\x63lientModifiedTime\x18\x06 \x01(\x03\x12\x15\n\rnonSharedData\x18\x07 \x01(\t\x12-\n\x10linkedRecordData\x18\x08 \x03(\x0b\x32\x13.Records.RecordData\x12\x0e\n\x06\x66ileId\x18\t \x03(\x0c\x12\x10\n\x08\x66ileSize\x18\n \x01(\x03\x12\x15\n\rthumbnailSize\x18\x0b \x01(\x03\x12-\n\rrecordKeyType\x18\x0c \x01(\x0e\x32\x16.Records.RecordKeyType\x12\x11\n\trecordKey\x18\r \x01(\x0c\x12\x11\n\trecordUid\x18\x0e \x01(\x0c\"\xc8\x01\n\x18RecordDataWithAccessInfo\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\'\n\nrecordData\x18\x02 \x01(\x0b\x32\x13.Records.RecordData\x12/\n\x0euserPermission\x18\x03 \x03(\x0b\x32\x17.Records.UserPermission\x12?\n\x16sharedFolderPermission\x18\x04 \x03(\x0b\x32\x1f.Records.SharedFolderPermission\"\x89\x01\n#GetRecordDataWithAccessInfoResponse\x12\x43\n\x18recordDataWithAccessInfo\x18\x01 \x03(\x0b\x32!.Records.RecordDataWithAccessInfo\x12\x1d\n\x15noPermissionRecordUid\x18\x02 \x03(\x0c\"j\n\x12IsObjectShareAdmin\x12\x0b\n\x03uid\x18\x01 \x01(\x0c\x12\x0f\n\x07isAdmin\x18\x02 \x01(\x08\x12\x36\n\nobjectType\x18\x03 \x01(\x0e\x32\".Records.CheckShareAdminObjectType\"H\n\rAmIShareAdmin\x12\x37\n\x12isObjectShareAdmin\x18\x01 \x03(\x0b\x32\x1b.Records.IsObjectShareAdmin\"\xbc\x01\n\x18RecordShareUpdateRequest\x12.\n\x0f\x61\x64\x64SharedRecord\x18\x01 \x03(\x0b\x32\x15.Records.SharedRecord\x12\x31\n\x12updateSharedRecord\x18\x02 \x03(\x0b\x32\x15.Records.SharedRecord\x12\x31\n\x12removeSharedRecord\x18\x03 \x03(\x0b\x32\x15.Records.SharedRecord\x12\n\n\x02pt\x18\x04 \x01(\t\"\xc4\x02\n\x0cSharedRecord\x12\x12\n\ntoUsername\x18\x01 \x01(\t\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\x12\x11\n\trecordKey\x18\x03 \x01(\x0c\x12\x17\n\x0fsharedFolderUid\x18\x04 \x01(\x0c\x12\x0f\n\x07teamUid\x18\x05 \x01(\x0c\x12\x10\n\x08\x65\x64itable\x18\x06 \x01(\x08\x12\x11\n\tshareable\x18\x07 \x01(\x08\x12\x10\n\x08transfer\x18\x08 \x01(\x08\x12\x11\n\tuseEccKey\x18\t \x01(\x08\x12\x17\n\x0fremoveVaultData\x18\n \x01(\x08\x12\x12\n\nexpiration\x18\x0b \x01(\x03\x12=\n\x15timerNotificationType\x18\x0c \x01(\x0e\x32\x1e.Records.TimerNotificationType\x12\x1a\n\x12rotateOnExpiration\x18\r \x01(\x08\"\xd5\x01\n\x19RecordShareUpdateResponse\x12:\n\x15\x61\x64\x64SharedRecordStatus\x18\x01 \x03(\x0b\x32\x1b.Records.SharedRecordStatus\x12=\n\x18updateSharedRecordStatus\x18\x02 \x03(\x0b\x32\x1b.Records.SharedRecordStatus\x12=\n\x18removeSharedRecordStatus\x18\x03 \x03(\x0b\x32\x1b.Records.SharedRecordStatus\"Z\n\x12SharedRecordStatus\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x0e\n\x06status\x18\x02 \x01(\t\x12\x0f\n\x07message\x18\x03 \x01(\t\x12\x10\n\x08username\x18\x04 \x01(\t\"G\n\x1bGetRecordPermissionsRequest\x12\x12\n\nrecordUids\x18\x01 \x03(\x0c\x12\x14\n\x0cisShareAdmin\x18\x02 \x01(\x08\"T\n\x1cGetRecordPermissionsResponse\x12\x34\n\x11recordPermissions\x18\x01 \x03(\x0b\x32\x19.Records.RecordPermission\"l\n\x10RecordPermission\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\r\n\x05owner\x18\x02 \x01(\x08\x12\x0f\n\x07\x63\x61nEdit\x18\x03 \x01(\x08\x12\x10\n\x08\x63\x61nShare\x18\x04 \x01(\x08\x12\x13\n\x0b\x63\x61nTransfer\x18\x05 \x01(\x08\"h\n\x16GetShareObjectsRequest\x12\x11\n\tstartWith\x18\x01 \x01(\t\x12\x10\n\x08\x63ontains\x18\x02 \x01(\t\x12\x10\n\x08\x66iltered\x18\x03 \x01(\x08\x12\x17\n\x0fsharedFolderUid\x18\x04 \x01(\x0c\"\xe7\x02\n\x17GetShareObjectsResponse\x12.\n\x12shareRelationships\x18\x01 \x03(\x0b\x32\x12.Records.ShareUser\x12,\n\x10shareFamilyUsers\x18\x02 \x03(\x0b\x32\x12.Records.ShareUser\x12\x30\n\x14shareEnterpriseUsers\x18\x03 \x03(\x0b\x32\x12.Records.ShareUser\x12&\n\nshareTeams\x18\x04 \x03(\x0b\x32\x12.Records.ShareTeam\x12(\n\x0cshareMCTeams\x18\x05 \x03(\x0b\x32\x12.Records.ShareTeam\x12\x32\n\x16shareMCEnterpriseUsers\x18\x06 \x03(\x0b\x32\x12.Records.ShareUser\x12\x36\n\x14shareEnterpriseNames\x18\x07 \x03(\x0b\x32\x18.Records.ShareEnterprise\"\xbd\x01\n\tShareUser\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x10\n\x08\x66ullname\x18\x02 \x01(\t\x12\x14\n\x0c\x65nterpriseId\x18\x03 \x01(\x05\x12$\n\x06status\x18\x04 \x01(\x0e\x32\x14.Records.ShareStatus\x12\x14\n\x0cisShareAdmin\x18\x05 \x01(\x08\x12\"\n\x1aisAdminOfSharedFolderOwner\x18\x06 \x01(\x08\x12\x16\n\x0euserAccountUid\x18\x07 \x01(\x0c\"D\n\tShareTeam\x12\x10\n\x08teamname\x18\x01 \x01(\t\x12\x14\n\x0c\x65nterpriseId\x18\x02 \x01(\x05\x12\x0f\n\x07teamUid\x18\x03 \x01(\x0c\"?\n\x0fShareEnterprise\x12\x16\n\x0e\x65nterprisename\x18\x01 \x01(\t\x12\x14\n\x0c\x65nterpriseId\x18\x02 \x01(\x05\"S\n\x1fRecordsOnwershipTransferRequest\x12\x30\n\x0ftransferRecords\x18\x01 \x03(\x0b\x32\x17.Records.TransferRecord\"[\n\x0eTransferRecord\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\x12\x11\n\trecordKey\x18\x03 \x01(\x0c\x12\x11\n\tuseEccKey\x18\x04 \x01(\x08\"_\n RecordsOnwershipTransferResponse\x12;\n\x14transferRecordStatus\x18\x01 \x03(\x0b\x32\x1d.Records.TransferRecordStatus\"\\\n\x14TransferRecordStatus\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x11\n\trecordUid\x18\x02 \x01(\x0c\x12\x0e\n\x06status\x18\x03 \x01(\t\x12\x0f\n\x07message\x18\x04 \x01(\t\"y\n\x15RecordsUnshareRequest\x12\x34\n\rsharedFolders\x18\x01 \x03(\x0b\x32\x1d.Records.RecordsUnshareFolder\x12*\n\x05users\x18\x02 \x03(\x0b\x32\x1b.Records.RecordsUnshareUser\"\x86\x01\n\x16RecordsUnshareResponse\x12:\n\rsharedFolders\x18\x01 \x03(\x0b\x32#.Records.RecordsUnshareFolderStatus\x12\x30\n\x05users\x18\x02 \x03(\x0b\x32!.Records.RecordsUnshareUserStatus\"B\n\x14RecordsUnshareFolder\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x17\n\x0fsharedFolderUid\x18\x02 \x01(\x0c\";\n\x12RecordsUnshareUser\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x12\n\naccountUid\x18\x02 \x01(\x0c\"H\n\x1aRecordsUnshareFolderStatus\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x17\n\x0fsharedFolderUid\x18\x02 \x01(\x0c\"A\n\x18RecordsUnshareUserStatus\x12\x11\n\trecordUid\x18\x01 \x01(\x0c\x12\x12\n\naccountUid\x18\x02 \x01(\x0c\"[\n\x1aTimedAccessCallbackPayload\x12=\n\x15timeLimitedAccessType\x18\x01 \x01(\x0e\x32\x1e.Records.TimeLimitedAccessType\"\xfd\x01\n\x18TimeLimitedAccessRequest\x12\x12\n\naccountUid\x18\x01 \x03(\x0c\x12\x0f\n\x07teamUid\x18\x02 \x03(\x0c\x12\x11\n\trecordUid\x18\x03 \x03(\x0c\x12\x17\n\x0fsharedObjectUid\x18\x04 \x01(\x0c\x12=\n\x15timeLimitedAccessType\x18\x05 \x01(\x0e\x32\x1e.Records.TimeLimitedAccessType\x12\x12\n\nexpiration\x18\x06 \x01(\x03\x12=\n\x15timerNotificationType\x18\x07 \x01(\x0e\x32\x1e.Records.TimerNotificationType\"7\n\x17TimeLimitedAccessStatus\x12\x0b\n\x03uid\x18\x01 \x01(\x0c\x12\x0f\n\x07message\x18\x02 \x01(\t\"\xe3\x01\n\x19TimeLimitedAccessResponse\x12\x10\n\x08revision\x18\x01 \x01(\x03\x12:\n\x10userAccessStatus\x18\x02 \x03(\x0b\x32 .Records.TimeLimitedAccessStatus\x12:\n\x10teamAccessStatus\x18\x03 \x03(\x0b\x32 .Records.TimeLimitedAccessStatus\x12<\n\x12recordAccessStatus\x18\x04 \x03(\x0b\x32 .Records.TimeLimitedAccessStatus*h\n\x0fRecordTypeScope\x12\x0f\n\x0bRT_STANDARD\x10\x00\x12\x0b\n\x07RT_USER\x10\x01\x12\x11\n\rRT_ENTERPRISE\x10\x02\x12\n\n\x06RT_PAM\x10\x03\x12\x18\n\x14RT_PAM_CONFIGURATION\x10\x04*\xd1\x01\n\rRecordKeyType\x12\n\n\x06NO_KEY\x10\x00\x12\x19\n\x15\x45NCRYPTED_BY_DATA_KEY\x10\x01\x12\x1b\n\x17\x45NCRYPTED_BY_PUBLIC_KEY\x10\x02\x12\x1d\n\x19\x45NCRYPTED_BY_DATA_KEY_GCM\x10\x03\x12\x1f\n\x1b\x45NCRYPTED_BY_PUBLIC_KEY_ECC\x10\x04\x12\x1d\n\x19\x45NCRYPTED_BY_ROOT_KEY_CBC\x10\x05\x12\x1d\n\x19\x45NCRYPTED_BY_ROOT_KEY_GCM\x10\x06*P\n\x10RecordFolderType\x12\x0f\n\x0buser_folder\x10\x00\x12\x11\n\rshared_folder\x10\x01\x12\x18\n\x14shared_folder_folder\x10\x02*\xec\x02\n\x12RecordModifyResult\x12\x0e\n\nRS_SUCCESS\x10\x00\x12\x12\n\x0eRS_OUT_OF_SYNC\x10\x01\x12\x14\n\x10RS_ACCESS_DENIED\x10\x02\x12\x13\n\x0fRS_SHARE_DENIED\x10\x03\x12\x14\n\x10RS_RECORD_EXISTS\x10\x04\x12\x1e\n\x1aRS_OLD_RECORD_VERSION_TYPE\x10\x05\x12\x1e\n\x1aRS_NEW_RECORD_VERSION_TYPE\x10\x06\x12\x16\n\x12RS_FILES_NOT_MATCH\x10\x07\x12\x1b\n\x17RS_RECORD_NOT_SHAREABLE\x10\x08\x12\x1f\n\x1bRS_ATTACHMENT_NOT_SHAREABLE\x10\t\x12\x19\n\x15RS_FILE_LIMIT_REACHED\x10\n\x12\x1a\n\x16RS_SIZE_EXCEEDED_LIMIT\x10\x0b\x12$\n RS_ONLY_OWNER_CAN_MODIFY_SCRIPTS\x10\x0c*-\n\rFileAddResult\x12\x0e\n\nFA_SUCCESS\x10\x00\x12\x0c\n\x08\x46\x41_ERROR\x10\x01*C\n\rFileGetResult\x12\x0e\n\nFG_SUCCESS\x10\x00\x12\x0c\n\x08\x46G_ERROR\x10\x01\x12\x14\n\x10\x46G_ACCESS_DENIED\x10\x02*J\n\x14RecordDetailsInclude\x12\x13\n\x0f\x44\x41TA_PLUS_SHARE\x10\x00\x12\r\n\tDATA_ONLY\x10\x01\x12\x0e\n\nSHARE_ONLY\x10\x02*b\n\x19\x43heckShareAdminObjectType\x12\x19\n\x15\x43HECK_SA_INVALID_TYPE\x10\x00\x12\x12\n\x0e\x43HECK_SA_ON_SF\x10\x01\x12\x16\n\x12\x43HECK_SA_ON_RECORD\x10\x02*1\n\x0bShareStatus\x12\n\n\x06\x41\x43TIVE\x10\x00\x12\t\n\x05\x42LOCK\x10\x01\x12\x0b\n\x07INVITED\x10\x02*:\n\x15RecordTransactionType\x12\x0f\n\x0bRTT_GENERAL\x10\x00\x12\x10\n\x0cRTT_ROTATION\x10\x01*\xdc\x02\n\x15TimeLimitedAccessType\x12$\n INVALID_TIME_LIMITED_ACCESS_TYPE\x10\x00\x12\x19\n\x15USER_ACCESS_TO_RECORD\x10\x01\x12\'\n#USER_OR_TEAM_ACCESS_TO_SHAREDFOLDER\x10\x02\x12!\n\x1dRECORD_ACCESS_TO_SHAREDFOLDER\x10\x03\x12\x1f\n\x1bUSER_ACCESS_TO_SHAREDFOLDER\x10\x04\x12\x1f\n\x1bTEAM_ACCESS_TO_SHAREDFOLDER\x10\x05\x12\x1b\n\x17RECORD_ACCESS_TO_FOLDER\x10\x06\x12\x19\n\x15USER_ACCESS_TO_FOLDER\x10\x07\x12\x19\n\x15TEAM_ACCESS_TO_FOLDER\x10\x08\x12!\n\x1dUSER_OR_TEAM_ACCESS_TO_FOLDER\x10\t*\\\n\x15TimerNotificationType\x12\x14\n\x10NOTIFICATION_OFF\x10\x00\x12\x10\n\x0cNOTIFY_OWNER\x10\x01\x12\x1b\n\x17NOTIFY_PRIVILEGED_USERS\x10\x02\x42#\n\x18\x63om.keepersecurity.protoB\x07Recordsb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -32,30 +32,30 @@ if not _descriptor._USE_C_DESCRIPTORS: _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'\n\030com.keepersecurity.protoB\007Records' - _globals['_RECORDTYPESCOPE']._serialized_start=9490 - _globals['_RECORDTYPESCOPE']._serialized_end=9594 - _globals['_RECORDKEYTYPE']._serialized_start=9597 - _globals['_RECORDKEYTYPE']._serialized_end=9806 - _globals['_RECORDFOLDERTYPE']._serialized_start=9808 - _globals['_RECORDFOLDERTYPE']._serialized_end=9888 - _globals['_RECORDMODIFYRESULT']._serialized_start=9891 - _globals['_RECORDMODIFYRESULT']._serialized_end=10255 - _globals['_FILEADDRESULT']._serialized_start=10257 - _globals['_FILEADDRESULT']._serialized_end=10302 - _globals['_FILEGETRESULT']._serialized_start=10304 - _globals['_FILEGETRESULT']._serialized_end=10371 - _globals['_RECORDDETAILSINCLUDE']._serialized_start=10373 - _globals['_RECORDDETAILSINCLUDE']._serialized_end=10447 - _globals['_CHECKSHAREADMINOBJECTTYPE']._serialized_start=10449 - _globals['_CHECKSHAREADMINOBJECTTYPE']._serialized_end=10547 - _globals['_SHARESTATUS']._serialized_start=10549 - _globals['_SHARESTATUS']._serialized_end=10598 - _globals['_RECORDTRANSACTIONTYPE']._serialized_start=10600 - _globals['_RECORDTRANSACTIONTYPE']._serialized_end=10658 - _globals['_TIMELIMITEDACCESSTYPE']._serialized_start=10661 - _globals['_TIMELIMITEDACCESSTYPE']._serialized_end=11009 - _globals['_TIMERNOTIFICATIONTYPE']._serialized_start=11011 - _globals['_TIMERNOTIFICATIONTYPE']._serialized_end=11103 + _globals['_RECORDTYPESCOPE']._serialized_start=9539 + _globals['_RECORDTYPESCOPE']._serialized_end=9643 + _globals['_RECORDKEYTYPE']._serialized_start=9646 + _globals['_RECORDKEYTYPE']._serialized_end=9855 + _globals['_RECORDFOLDERTYPE']._serialized_start=9857 + _globals['_RECORDFOLDERTYPE']._serialized_end=9937 + _globals['_RECORDMODIFYRESULT']._serialized_start=9940 + _globals['_RECORDMODIFYRESULT']._serialized_end=10304 + _globals['_FILEADDRESULT']._serialized_start=10306 + _globals['_FILEADDRESULT']._serialized_end=10351 + _globals['_FILEGETRESULT']._serialized_start=10353 + _globals['_FILEGETRESULT']._serialized_end=10420 + _globals['_RECORDDETAILSINCLUDE']._serialized_start=10422 + _globals['_RECORDDETAILSINCLUDE']._serialized_end=10496 + _globals['_CHECKSHAREADMINOBJECTTYPE']._serialized_start=10498 + _globals['_CHECKSHAREADMINOBJECTTYPE']._serialized_end=10596 + _globals['_SHARESTATUS']._serialized_start=10598 + _globals['_SHARESTATUS']._serialized_end=10647 + _globals['_RECORDTRANSACTIONTYPE']._serialized_start=10649 + _globals['_RECORDTRANSACTIONTYPE']._serialized_end=10707 + _globals['_TIMELIMITEDACCESSTYPE']._serialized_start=10710 + _globals['_TIMELIMITEDACCESSTYPE']._serialized_end=11058 + _globals['_TIMERNOTIFICATIONTYPE']._serialized_start=11060 + _globals['_TIMERNOTIFICATIONTYPE']._serialized_end=11152 _globals['_RECORDTYPE']._serialized_start=25 _globals['_RECORDTYPE']._serialized_end=117 _globals['_RECORDTYPESREQUEST']._serialized_start=119 @@ -69,135 +69,135 @@ _globals['_RECORD']._serialized_start=496 _globals['_RECORD']._serialized_end=705 _globals['_FOLDERRECORDKEY']._serialized_start=707 - _globals['_FOLDERRECORDKEY']._serialized_end=784 - _globals['_FOLDER']._serialized_start=786 - _globals['_FOLDER']._serialized_end=883 - _globals['_TEAM']._serialized_start=886 - _globals['_TEAM']._serialized_end=1035 - _globals['_RECORDSGETRESPONSE']._serialized_start=1038 - _globals['_RECORDSGETRESPONSE']._serialized_end=1210 - _globals['_RECORDLINK']._serialized_start=1212 - _globals['_RECORDLINK']._serialized_end=1264 - _globals['_RECORDAUDIT']._serialized_start=1266 - _globals['_RECORDAUDIT']._serialized_end=1310 - _globals['_SECURITYDATA']._serialized_start=1312 - _globals['_SECURITYDATA']._serialized_end=1340 - _globals['_SECURITYSCOREDATA']._serialized_start=1342 - _globals['_SECURITYSCOREDATA']._serialized_end=1375 - _globals['_RECORDADD']._serialized_start=1378 - _globals['_RECORDADD']._serialized_end=1766 - _globals['_RECORDSADDREQUEST']._serialized_start=1769 - _globals['_RECORDSADDREQUEST']._serialized_end=1902 - _globals['_RECORDUPDATE']._serialized_start=1905 - _globals['_RECORDUPDATE']._serialized_end=2239 - _globals['_RECORDSUPDATEREQUEST']._serialized_start=2242 - _globals['_RECORDSUPDATEREQUEST']._serialized_end=2381 - _globals['_RECORDFILEFORCONVERSION']._serialized_start=2384 - _globals['_RECORDFILEFORCONVERSION']._serialized_end=2526 - _globals['_RECORDFOLDERFORCONVERSION']._serialized_start=2528 - _globals['_RECORDFOLDERFORCONVERSION']._serialized_end=2602 - _globals['_RECORDCONVERTTOV3']._serialized_start=2605 - _globals['_RECORDCONVERTTOV3']._serialized_end=2879 - _globals['_RECORDSCONVERTTOV3REQUEST']._serialized_start=2881 - _globals['_RECORDSCONVERTTOV3REQUEST']._serialized_end=2974 - _globals['_RECORDSREMOVEREQUEST']._serialized_start=2976 - _globals['_RECORDSREMOVEREQUEST']._serialized_end=3015 - _globals['_RECORDREVERT']._serialized_start=3017 - _globals['_RECORDREVERT']._serialized_end=3079 - _globals['_RECORDSREVERTREQUEST']._serialized_start=3081 - _globals['_RECORDSREVERTREQUEST']._serialized_end=3143 - _globals['_RECORDLINKERROR']._serialized_start=3145 - _globals['_RECORDLINKERROR']._serialized_end=3244 - _globals['_RECORDMODIFYSTATUS']._serialized_start=3247 - _globals['_RECORDMODIFYSTATUS']._serialized_end=3396 - _globals['_RECORDSMODIFYRESPONSE']._serialized_start=3398 - _globals['_RECORDSMODIFYRESPONSE']._serialized_end=3485 - _globals['_RECORDADDAUDITDATA']._serialized_start=3487 - _globals['_RECORDADDAUDITDATA']._serialized_end=3576 - _globals['_ADDAUDITDATAREQUEST']._serialized_start=3578 - _globals['_ADDAUDITDATAREQUEST']._serialized_end=3645 - _globals['_FILE']._serialized_start=3647 - _globals['_FILE']._serialized_end=3763 - _globals['_FILESADDREQUEST']._serialized_start=3765 - _globals['_FILESADDREQUEST']._serialized_end=3833 - _globals['_FILEADDSTATUS']._serialized_start=3836 - _globals['_FILEADDSTATUS']._serialized_end=4003 - _globals['_FILESADDRESPONSE']._serialized_start=4005 - _globals['_FILESADDRESPONSE']._serialized_end=4080 - _globals['_FILESGETREQUEST']._serialized_start=4082 - _globals['_FILESGETREQUEST']._serialized_end=4184 - _globals['_FILEGETSTATUS']._serialized_start=4187 - _globals['_FILEGETSTATUS']._serialized_end=4349 - _globals['_FILESGETRESPONSE']._serialized_start=4351 - _globals['_FILESGETRESPONSE']._serialized_end=4408 - _globals['_APPLICATIONADDREQUEST']._serialized_start=4411 - _globals['_APPLICATIONADDREQUEST']._serialized_end=4552 - _globals['_GETRECORDDATAWITHACCESSINFOREQUEST']._serialized_start=4555 - _globals['_GETRECORDDATAWITHACCESSINFOREQUEST']._serialized_end=4691 - _globals['_USERPERMISSION']._serialized_start=4694 - _globals['_USERPERMISSION']._serialized_end=4956 - _globals['_SHAREDFOLDERPERMISSION']._serialized_start=4959 - _globals['_SHAREDFOLDERPERMISSION']._serialized_end=5175 - _globals['_RECORDDATA']._serialized_start=5178 - _globals['_RECORDDATA']._serialized_end=5538 - _globals['_RECORDDATAWITHACCESSINFO']._serialized_start=5541 - _globals['_RECORDDATAWITHACCESSINFO']._serialized_end=5741 - _globals['_GETRECORDDATAWITHACCESSINFORESPONSE']._serialized_start=5744 - _globals['_GETRECORDDATAWITHACCESSINFORESPONSE']._serialized_end=5881 - _globals['_ISOBJECTSHAREADMIN']._serialized_start=5883 - _globals['_ISOBJECTSHAREADMIN']._serialized_end=5989 - _globals['_AMISHAREADMIN']._serialized_start=5991 - _globals['_AMISHAREADMIN']._serialized_end=6063 - _globals['_RECORDSHAREUPDATEREQUEST']._serialized_start=6066 - _globals['_RECORDSHAREUPDATEREQUEST']._serialized_end=6254 - _globals['_SHAREDRECORD']._serialized_start=6257 - _globals['_SHAREDRECORD']._serialized_end=6581 - _globals['_RECORDSHAREUPDATERESPONSE']._serialized_start=6584 - _globals['_RECORDSHAREUPDATERESPONSE']._serialized_end=6797 - _globals['_SHAREDRECORDSTATUS']._serialized_start=6799 - _globals['_SHAREDRECORDSTATUS']._serialized_end=6889 - _globals['_GETRECORDPERMISSIONSREQUEST']._serialized_start=6891 - _globals['_GETRECORDPERMISSIONSREQUEST']._serialized_end=6962 - _globals['_GETRECORDPERMISSIONSRESPONSE']._serialized_start=6964 - _globals['_GETRECORDPERMISSIONSRESPONSE']._serialized_end=7048 - _globals['_RECORDPERMISSION']._serialized_start=7050 - _globals['_RECORDPERMISSION']._serialized_end=7158 - _globals['_GETSHAREOBJECTSREQUEST']._serialized_start=7160 - _globals['_GETSHAREOBJECTSREQUEST']._serialized_end=7264 - _globals['_GETSHAREOBJECTSRESPONSE']._serialized_start=7267 - _globals['_GETSHAREOBJECTSRESPONSE']._serialized_end=7626 - _globals['_SHAREUSER']._serialized_start=7629 - _globals['_SHAREUSER']._serialized_end=7818 - _globals['_SHARETEAM']._serialized_start=7820 - _globals['_SHARETEAM']._serialized_end=7888 - _globals['_SHAREENTERPRISE']._serialized_start=7890 - _globals['_SHAREENTERPRISE']._serialized_end=7953 - _globals['_RECORDSONWERSHIPTRANSFERREQUEST']._serialized_start=7955 - _globals['_RECORDSONWERSHIPTRANSFERREQUEST']._serialized_end=8038 - _globals['_TRANSFERRECORD']._serialized_start=8040 - _globals['_TRANSFERRECORD']._serialized_end=8131 - _globals['_RECORDSONWERSHIPTRANSFERRESPONSE']._serialized_start=8133 - _globals['_RECORDSONWERSHIPTRANSFERRESPONSE']._serialized_end=8228 - _globals['_TRANSFERRECORDSTATUS']._serialized_start=8230 - _globals['_TRANSFERRECORDSTATUS']._serialized_end=8322 - _globals['_RECORDSUNSHAREREQUEST']._serialized_start=8324 - _globals['_RECORDSUNSHAREREQUEST']._serialized_end=8445 - _globals['_RECORDSUNSHARERESPONSE']._serialized_start=8448 - _globals['_RECORDSUNSHARERESPONSE']._serialized_end=8582 - _globals['_RECORDSUNSHAREFOLDER']._serialized_start=8584 - _globals['_RECORDSUNSHAREFOLDER']._serialized_end=8650 - _globals['_RECORDSUNSHAREUSER']._serialized_start=8652 - _globals['_RECORDSUNSHAREUSER']._serialized_end=8711 - _globals['_RECORDSUNSHAREFOLDERSTATUS']._serialized_start=8713 - _globals['_RECORDSUNSHAREFOLDERSTATUS']._serialized_end=8785 - _globals['_RECORDSUNSHAREUSERSTATUS']._serialized_start=8787 - _globals['_RECORDSUNSHAREUSERSTATUS']._serialized_end=8852 - _globals['_TIMEDACCESSCALLBACKPAYLOAD']._serialized_start=8854 - _globals['_TIMEDACCESSCALLBACKPAYLOAD']._serialized_end=8945 - _globals['_TIMELIMITEDACCESSREQUEST']._serialized_start=8948 - _globals['_TIMELIMITEDACCESSREQUEST']._serialized_end=9201 - _globals['_TIMELIMITEDACCESSSTATUS']._serialized_start=9203 - _globals['_TIMELIMITEDACCESSSTATUS']._serialized_end=9258 - _globals['_TIMELIMITEDACCESSRESPONSE']._serialized_start=9261 - _globals['_TIMELIMITEDACCESSRESPONSE']._serialized_end=9488 + _globals['_FOLDERRECORDKEY']._serialized_end=833 + _globals['_FOLDER']._serialized_start=835 + _globals['_FOLDER']._serialized_end=932 + _globals['_TEAM']._serialized_start=935 + _globals['_TEAM']._serialized_end=1084 + _globals['_RECORDSGETRESPONSE']._serialized_start=1087 + _globals['_RECORDSGETRESPONSE']._serialized_end=1259 + _globals['_RECORDLINK']._serialized_start=1261 + _globals['_RECORDLINK']._serialized_end=1313 + _globals['_RECORDAUDIT']._serialized_start=1315 + _globals['_RECORDAUDIT']._serialized_end=1359 + _globals['_SECURITYDATA']._serialized_start=1361 + _globals['_SECURITYDATA']._serialized_end=1389 + _globals['_SECURITYSCOREDATA']._serialized_start=1391 + _globals['_SECURITYSCOREDATA']._serialized_end=1424 + _globals['_RECORDADD']._serialized_start=1427 + _globals['_RECORDADD']._serialized_end=1815 + _globals['_RECORDSADDREQUEST']._serialized_start=1818 + _globals['_RECORDSADDREQUEST']._serialized_end=1951 + _globals['_RECORDUPDATE']._serialized_start=1954 + _globals['_RECORDUPDATE']._serialized_end=2288 + _globals['_RECORDSUPDATEREQUEST']._serialized_start=2291 + _globals['_RECORDSUPDATEREQUEST']._serialized_end=2430 + _globals['_RECORDFILEFORCONVERSION']._serialized_start=2433 + _globals['_RECORDFILEFORCONVERSION']._serialized_end=2575 + _globals['_RECORDFOLDERFORCONVERSION']._serialized_start=2577 + _globals['_RECORDFOLDERFORCONVERSION']._serialized_end=2651 + _globals['_RECORDCONVERTTOV3']._serialized_start=2654 + _globals['_RECORDCONVERTTOV3']._serialized_end=2928 + _globals['_RECORDSCONVERTTOV3REQUEST']._serialized_start=2930 + _globals['_RECORDSCONVERTTOV3REQUEST']._serialized_end=3023 + _globals['_RECORDSREMOVEREQUEST']._serialized_start=3025 + _globals['_RECORDSREMOVEREQUEST']._serialized_end=3064 + _globals['_RECORDREVERT']._serialized_start=3066 + _globals['_RECORDREVERT']._serialized_end=3128 + _globals['_RECORDSREVERTREQUEST']._serialized_start=3130 + _globals['_RECORDSREVERTREQUEST']._serialized_end=3192 + _globals['_RECORDLINKERROR']._serialized_start=3194 + _globals['_RECORDLINKERROR']._serialized_end=3293 + _globals['_RECORDMODIFYSTATUS']._serialized_start=3296 + _globals['_RECORDMODIFYSTATUS']._serialized_end=3445 + _globals['_RECORDSMODIFYRESPONSE']._serialized_start=3447 + _globals['_RECORDSMODIFYRESPONSE']._serialized_end=3534 + _globals['_RECORDADDAUDITDATA']._serialized_start=3536 + _globals['_RECORDADDAUDITDATA']._serialized_end=3625 + _globals['_ADDAUDITDATAREQUEST']._serialized_start=3627 + _globals['_ADDAUDITDATAREQUEST']._serialized_end=3694 + _globals['_FILE']._serialized_start=3696 + _globals['_FILE']._serialized_end=3812 + _globals['_FILESADDREQUEST']._serialized_start=3814 + _globals['_FILESADDREQUEST']._serialized_end=3882 + _globals['_FILEADDSTATUS']._serialized_start=3885 + _globals['_FILEADDSTATUS']._serialized_end=4052 + _globals['_FILESADDRESPONSE']._serialized_start=4054 + _globals['_FILESADDRESPONSE']._serialized_end=4129 + _globals['_FILESGETREQUEST']._serialized_start=4131 + _globals['_FILESGETREQUEST']._serialized_end=4233 + _globals['_FILEGETSTATUS']._serialized_start=4236 + _globals['_FILEGETSTATUS']._serialized_end=4398 + _globals['_FILESGETRESPONSE']._serialized_start=4400 + _globals['_FILESGETRESPONSE']._serialized_end=4457 + _globals['_APPLICATIONADDREQUEST']._serialized_start=4460 + _globals['_APPLICATIONADDREQUEST']._serialized_end=4601 + _globals['_GETRECORDDATAWITHACCESSINFOREQUEST']._serialized_start=4604 + _globals['_GETRECORDDATAWITHACCESSINFOREQUEST']._serialized_end=4740 + _globals['_USERPERMISSION']._serialized_start=4743 + _globals['_USERPERMISSION']._serialized_end=5005 + _globals['_SHAREDFOLDERPERMISSION']._serialized_start=5008 + _globals['_SHAREDFOLDERPERMISSION']._serialized_end=5224 + _globals['_RECORDDATA']._serialized_start=5227 + _globals['_RECORDDATA']._serialized_end=5587 + _globals['_RECORDDATAWITHACCESSINFO']._serialized_start=5590 + _globals['_RECORDDATAWITHACCESSINFO']._serialized_end=5790 + _globals['_GETRECORDDATAWITHACCESSINFORESPONSE']._serialized_start=5793 + _globals['_GETRECORDDATAWITHACCESSINFORESPONSE']._serialized_end=5930 + _globals['_ISOBJECTSHAREADMIN']._serialized_start=5932 + _globals['_ISOBJECTSHAREADMIN']._serialized_end=6038 + _globals['_AMISHAREADMIN']._serialized_start=6040 + _globals['_AMISHAREADMIN']._serialized_end=6112 + _globals['_RECORDSHAREUPDATEREQUEST']._serialized_start=6115 + _globals['_RECORDSHAREUPDATEREQUEST']._serialized_end=6303 + _globals['_SHAREDRECORD']._serialized_start=6306 + _globals['_SHAREDRECORD']._serialized_end=6630 + _globals['_RECORDSHAREUPDATERESPONSE']._serialized_start=6633 + _globals['_RECORDSHAREUPDATERESPONSE']._serialized_end=6846 + _globals['_SHAREDRECORDSTATUS']._serialized_start=6848 + _globals['_SHAREDRECORDSTATUS']._serialized_end=6938 + _globals['_GETRECORDPERMISSIONSREQUEST']._serialized_start=6940 + _globals['_GETRECORDPERMISSIONSREQUEST']._serialized_end=7011 + _globals['_GETRECORDPERMISSIONSRESPONSE']._serialized_start=7013 + _globals['_GETRECORDPERMISSIONSRESPONSE']._serialized_end=7097 + _globals['_RECORDPERMISSION']._serialized_start=7099 + _globals['_RECORDPERMISSION']._serialized_end=7207 + _globals['_GETSHAREOBJECTSREQUEST']._serialized_start=7209 + _globals['_GETSHAREOBJECTSREQUEST']._serialized_end=7313 + _globals['_GETSHAREOBJECTSRESPONSE']._serialized_start=7316 + _globals['_GETSHAREOBJECTSRESPONSE']._serialized_end=7675 + _globals['_SHAREUSER']._serialized_start=7678 + _globals['_SHAREUSER']._serialized_end=7867 + _globals['_SHARETEAM']._serialized_start=7869 + _globals['_SHARETEAM']._serialized_end=7937 + _globals['_SHAREENTERPRISE']._serialized_start=7939 + _globals['_SHAREENTERPRISE']._serialized_end=8002 + _globals['_RECORDSONWERSHIPTRANSFERREQUEST']._serialized_start=8004 + _globals['_RECORDSONWERSHIPTRANSFERREQUEST']._serialized_end=8087 + _globals['_TRANSFERRECORD']._serialized_start=8089 + _globals['_TRANSFERRECORD']._serialized_end=8180 + _globals['_RECORDSONWERSHIPTRANSFERRESPONSE']._serialized_start=8182 + _globals['_RECORDSONWERSHIPTRANSFERRESPONSE']._serialized_end=8277 + _globals['_TRANSFERRECORDSTATUS']._serialized_start=8279 + _globals['_TRANSFERRECORDSTATUS']._serialized_end=8371 + _globals['_RECORDSUNSHAREREQUEST']._serialized_start=8373 + _globals['_RECORDSUNSHAREREQUEST']._serialized_end=8494 + _globals['_RECORDSUNSHARERESPONSE']._serialized_start=8497 + _globals['_RECORDSUNSHARERESPONSE']._serialized_end=8631 + _globals['_RECORDSUNSHAREFOLDER']._serialized_start=8633 + _globals['_RECORDSUNSHAREFOLDER']._serialized_end=8699 + _globals['_RECORDSUNSHAREUSER']._serialized_start=8701 + _globals['_RECORDSUNSHAREUSER']._serialized_end=8760 + _globals['_RECORDSUNSHAREFOLDERSTATUS']._serialized_start=8762 + _globals['_RECORDSUNSHAREFOLDERSTATUS']._serialized_end=8834 + _globals['_RECORDSUNSHAREUSERSTATUS']._serialized_start=8836 + _globals['_RECORDSUNSHAREUSERSTATUS']._serialized_end=8901 + _globals['_TIMEDACCESSCALLBACKPAYLOAD']._serialized_start=8903 + _globals['_TIMEDACCESSCALLBACKPAYLOAD']._serialized_end=8994 + _globals['_TIMELIMITEDACCESSREQUEST']._serialized_start=8997 + _globals['_TIMELIMITEDACCESSREQUEST']._serialized_end=9250 + _globals['_TIMELIMITEDACCESSSTATUS']._serialized_start=9252 + _globals['_TIMELIMITEDACCESSSTATUS']._serialized_end=9307 + _globals['_TIMELIMITEDACCESSRESPONSE']._serialized_start=9310 + _globals['_TIMELIMITEDACCESSRESPONSE']._serialized_end=9537 # @@protoc_insertion_point(module_scope) diff --git a/keepercommander/proto/record_pb2.pyi b/keepercommander/proto/record_pb2.pyi index 19b58230f..aae6f7158 100644 --- a/keepercommander/proto/record_pb2.pyi +++ b/keepercommander/proto/record_pb2.pyi @@ -176,7 +176,7 @@ class RecordTypesRequest(_message.Message): user: bool enterprise: bool pam: bool - def __init__(self, standard: bool = ..., user: bool = ..., enterprise: bool = ..., pam: bool = ...) -> None: ... + def __init__(self, standard: _Optional[bool] = ..., user: _Optional[bool] = ..., enterprise: _Optional[bool] = ..., pam: _Optional[bool] = ...) -> None: ... class RecordTypesResponse(_message.Message): __slots__ = ("recordTypes", "standardCounter", "userCounter", "enterpriseCounter", "pamCounter") @@ -231,14 +231,16 @@ class Record(_message.Message): def __init__(self, record_uid: _Optional[bytes] = ..., record_key: _Optional[bytes] = ..., record_key_type: _Optional[_Union[RecordKeyType, str]] = ..., data: _Optional[bytes] = ..., extra: _Optional[bytes] = ..., version: _Optional[int] = ..., client_modified_time: _Optional[int] = ..., revision: _Optional[int] = ..., file_ids: _Optional[_Iterable[bytes]] = ...) -> None: ... class FolderRecordKey(_message.Message): - __slots__ = ("folder_uid", "record_uid", "record_key") + __slots__ = ("folder_uid", "record_uid", "record_key", "record_key_type") FOLDER_UID_FIELD_NUMBER: _ClassVar[int] RECORD_UID_FIELD_NUMBER: _ClassVar[int] RECORD_KEY_FIELD_NUMBER: _ClassVar[int] + RECORD_KEY_TYPE_FIELD_NUMBER: _ClassVar[int] folder_uid: bytes record_uid: bytes record_key: bytes - def __init__(self, folder_uid: _Optional[bytes] = ..., record_uid: _Optional[bytes] = ..., record_key: _Optional[bytes] = ...) -> None: ... + record_key_type: RecordKeyType + def __init__(self, folder_uid: _Optional[bytes] = ..., record_uid: _Optional[bytes] = ..., record_key: _Optional[bytes] = ..., record_key_type: _Optional[_Union[RecordKeyType, str]] = ...) -> None: ... class Folder(_message.Message): __slots__ = ("folder_uid", "folder_key", "folder_key_type") @@ -510,7 +512,7 @@ class File(_message.Message): fileSize: int thumbSize: int is_script: bool - def __init__(self, record_uid: _Optional[bytes] = ..., record_key: _Optional[bytes] = ..., data: _Optional[bytes] = ..., fileSize: _Optional[int] = ..., thumbSize: _Optional[int] = ..., is_script: bool = ...) -> None: ... + def __init__(self, record_uid: _Optional[bytes] = ..., record_key: _Optional[bytes] = ..., data: _Optional[bytes] = ..., fileSize: _Optional[int] = ..., thumbSize: _Optional[int] = ..., is_script: _Optional[bool] = ...) -> None: ... class FilesAddRequest(_message.Message): __slots__ = ("files", "client_time") @@ -552,7 +554,7 @@ class FilesGetRequest(_message.Message): record_uids: _containers.RepeatedScalarFieldContainer[bytes] for_thumbnails: bool emergency_access_account_owner: str - def __init__(self, record_uids: _Optional[_Iterable[bytes]] = ..., for_thumbnails: bool = ..., emergency_access_account_owner: _Optional[str] = ...) -> None: ... + def __init__(self, record_uids: _Optional[_Iterable[bytes]] = ..., for_thumbnails: _Optional[bool] = ..., emergency_access_account_owner: _Optional[str] = ...) -> None: ... class FileGetStatus(_message.Message): __slots__ = ("record_uid", "status", "url", "success_status_code", "fileKeyType") @@ -620,7 +622,7 @@ class UserPermission(_message.Message): accountUid: bytes timerNotificationType: TimerNotificationType rotateOnExpiration: bool - def __init__(self, username: _Optional[str] = ..., owner: bool = ..., shareAdmin: bool = ..., sharable: bool = ..., editable: bool = ..., awaitingApproval: bool = ..., expiration: _Optional[int] = ..., accountUid: _Optional[bytes] = ..., timerNotificationType: _Optional[_Union[TimerNotificationType, str]] = ..., rotateOnExpiration: bool = ...) -> None: ... + def __init__(self, username: _Optional[str] = ..., owner: _Optional[bool] = ..., shareAdmin: _Optional[bool] = ..., sharable: _Optional[bool] = ..., editable: _Optional[bool] = ..., awaitingApproval: _Optional[bool] = ..., expiration: _Optional[int] = ..., accountUid: _Optional[bytes] = ..., timerNotificationType: _Optional[_Union[TimerNotificationType, str]] = ..., rotateOnExpiration: _Optional[bool] = ...) -> None: ... class SharedFolderPermission(_message.Message): __slots__ = ("sharedFolderUid", "resharable", "editable", "revision", "expiration", "timerNotificationType", "rotateOnExpiration") @@ -638,7 +640,7 @@ class SharedFolderPermission(_message.Message): expiration: int timerNotificationType: TimerNotificationType rotateOnExpiration: bool - def __init__(self, sharedFolderUid: _Optional[bytes] = ..., resharable: bool = ..., editable: bool = ..., revision: _Optional[int] = ..., expiration: _Optional[int] = ..., timerNotificationType: _Optional[_Union[TimerNotificationType, str]] = ..., rotateOnExpiration: bool = ...) -> None: ... + def __init__(self, sharedFolderUid: _Optional[bytes] = ..., resharable: _Optional[bool] = ..., editable: _Optional[bool] = ..., revision: _Optional[int] = ..., expiration: _Optional[int] = ..., timerNotificationType: _Optional[_Union[TimerNotificationType, str]] = ..., rotateOnExpiration: _Optional[bool] = ...) -> None: ... class RecordData(_message.Message): __slots__ = ("revision", "version", "shared", "encryptedRecordData", "encryptedExtraData", "clientModifiedTime", "nonSharedData", "linkedRecordData", "fileId", "fileSize", "thumbnailSize", "recordKeyType", "recordKey", "recordUid") @@ -670,7 +672,7 @@ class RecordData(_message.Message): recordKeyType: RecordKeyType recordKey: bytes recordUid: bytes - def __init__(self, revision: _Optional[int] = ..., version: _Optional[int] = ..., shared: bool = ..., encryptedRecordData: _Optional[str] = ..., encryptedExtraData: _Optional[str] = ..., clientModifiedTime: _Optional[int] = ..., nonSharedData: _Optional[str] = ..., linkedRecordData: _Optional[_Iterable[_Union[RecordData, _Mapping]]] = ..., fileId: _Optional[_Iterable[bytes]] = ..., fileSize: _Optional[int] = ..., thumbnailSize: _Optional[int] = ..., recordKeyType: _Optional[_Union[RecordKeyType, str]] = ..., recordKey: _Optional[bytes] = ..., recordUid: _Optional[bytes] = ...) -> None: ... + def __init__(self, revision: _Optional[int] = ..., version: _Optional[int] = ..., shared: _Optional[bool] = ..., encryptedRecordData: _Optional[str] = ..., encryptedExtraData: _Optional[str] = ..., clientModifiedTime: _Optional[int] = ..., nonSharedData: _Optional[str] = ..., linkedRecordData: _Optional[_Iterable[_Union[RecordData, _Mapping]]] = ..., fileId: _Optional[_Iterable[bytes]] = ..., fileSize: _Optional[int] = ..., thumbnailSize: _Optional[int] = ..., recordKeyType: _Optional[_Union[RecordKeyType, str]] = ..., recordKey: _Optional[bytes] = ..., recordUid: _Optional[bytes] = ...) -> None: ... class RecordDataWithAccessInfo(_message.Message): __slots__ = ("recordUid", "recordData", "userPermission", "sharedFolderPermission") @@ -700,7 +702,7 @@ class IsObjectShareAdmin(_message.Message): uid: bytes isAdmin: bool objectType: CheckShareAdminObjectType - def __init__(self, uid: _Optional[bytes] = ..., isAdmin: bool = ..., objectType: _Optional[_Union[CheckShareAdminObjectType, str]] = ...) -> None: ... + def __init__(self, uid: _Optional[bytes] = ..., isAdmin: _Optional[bool] = ..., objectType: _Optional[_Union[CheckShareAdminObjectType, str]] = ...) -> None: ... class AmIShareAdmin(_message.Message): __slots__ = ("isObjectShareAdmin",) @@ -748,7 +750,7 @@ class SharedRecord(_message.Message): expiration: int timerNotificationType: TimerNotificationType rotateOnExpiration: bool - def __init__(self, toUsername: _Optional[str] = ..., recordUid: _Optional[bytes] = ..., recordKey: _Optional[bytes] = ..., sharedFolderUid: _Optional[bytes] = ..., teamUid: _Optional[bytes] = ..., editable: bool = ..., shareable: bool = ..., transfer: bool = ..., useEccKey: bool = ..., removeVaultData: bool = ..., expiration: _Optional[int] = ..., timerNotificationType: _Optional[_Union[TimerNotificationType, str]] = ..., rotateOnExpiration: bool = ...) -> None: ... + def __init__(self, toUsername: _Optional[str] = ..., recordUid: _Optional[bytes] = ..., recordKey: _Optional[bytes] = ..., sharedFolderUid: _Optional[bytes] = ..., teamUid: _Optional[bytes] = ..., editable: _Optional[bool] = ..., shareable: _Optional[bool] = ..., transfer: _Optional[bool] = ..., useEccKey: _Optional[bool] = ..., removeVaultData: _Optional[bool] = ..., expiration: _Optional[int] = ..., timerNotificationType: _Optional[_Union[TimerNotificationType, str]] = ..., rotateOnExpiration: _Optional[bool] = ...) -> None: ... class RecordShareUpdateResponse(_message.Message): __slots__ = ("addSharedRecordStatus", "updateSharedRecordStatus", "removeSharedRecordStatus") @@ -778,7 +780,7 @@ class GetRecordPermissionsRequest(_message.Message): ISSHAREADMIN_FIELD_NUMBER: _ClassVar[int] recordUids: _containers.RepeatedScalarFieldContainer[bytes] isShareAdmin: bool - def __init__(self, recordUids: _Optional[_Iterable[bytes]] = ..., isShareAdmin: bool = ...) -> None: ... + def __init__(self, recordUids: _Optional[_Iterable[bytes]] = ..., isShareAdmin: _Optional[bool] = ...) -> None: ... class GetRecordPermissionsResponse(_message.Message): __slots__ = ("recordPermissions",) @@ -798,7 +800,7 @@ class RecordPermission(_message.Message): canEdit: bool canShare: bool canTransfer: bool - def __init__(self, recordUid: _Optional[bytes] = ..., owner: bool = ..., canEdit: bool = ..., canShare: bool = ..., canTransfer: bool = ...) -> None: ... + def __init__(self, recordUid: _Optional[bytes] = ..., owner: _Optional[bool] = ..., canEdit: _Optional[bool] = ..., canShare: _Optional[bool] = ..., canTransfer: _Optional[bool] = ...) -> None: ... class GetShareObjectsRequest(_message.Message): __slots__ = ("startWith", "contains", "filtered", "sharedFolderUid") @@ -810,7 +812,7 @@ class GetShareObjectsRequest(_message.Message): contains: str filtered: bool sharedFolderUid: bytes - def __init__(self, startWith: _Optional[str] = ..., contains: _Optional[str] = ..., filtered: bool = ..., sharedFolderUid: _Optional[bytes] = ...) -> None: ... + def __init__(self, startWith: _Optional[str] = ..., contains: _Optional[str] = ..., filtered: _Optional[bool] = ..., sharedFolderUid: _Optional[bytes] = ...) -> None: ... class GetShareObjectsResponse(_message.Message): __slots__ = ("shareRelationships", "shareFamilyUsers", "shareEnterpriseUsers", "shareTeams", "shareMCTeams", "shareMCEnterpriseUsers", "shareEnterpriseNames") @@ -846,7 +848,7 @@ class ShareUser(_message.Message): isShareAdmin: bool isAdminOfSharedFolderOwner: bool userAccountUid: bytes - def __init__(self, username: _Optional[str] = ..., fullname: _Optional[str] = ..., enterpriseId: _Optional[int] = ..., status: _Optional[_Union[ShareStatus, str]] = ..., isShareAdmin: bool = ..., isAdminOfSharedFolderOwner: bool = ..., userAccountUid: _Optional[bytes] = ...) -> None: ... + def __init__(self, username: _Optional[str] = ..., fullname: _Optional[str] = ..., enterpriseId: _Optional[int] = ..., status: _Optional[_Union[ShareStatus, str]] = ..., isShareAdmin: _Optional[bool] = ..., isAdminOfSharedFolderOwner: _Optional[bool] = ..., userAccountUid: _Optional[bytes] = ...) -> None: ... class ShareTeam(_message.Message): __slots__ = ("teamname", "enterpriseId", "teamUid") @@ -882,7 +884,7 @@ class TransferRecord(_message.Message): recordUid: bytes recordKey: bytes useEccKey: bool - def __init__(self, username: _Optional[str] = ..., recordUid: _Optional[bytes] = ..., recordKey: _Optional[bytes] = ..., useEccKey: bool = ...) -> None: ... + def __init__(self, username: _Optional[str] = ..., recordUid: _Optional[bytes] = ..., recordKey: _Optional[bytes] = ..., useEccKey: _Optional[bool] = ...) -> None: ... class RecordsOnwershipTransferResponse(_message.Message): __slots__ = ("transferRecordStatus",) diff --git a/keepercommander/proto/record_sharing_pb2.pyi b/keepercommander/proto/record_sharing_pb2.pyi index c5f25e8db..18bd3db39 100644 --- a/keepercommander/proto/record_sharing_pb2.pyi +++ b/keepercommander/proto/record_sharing_pb2.pyi @@ -50,7 +50,7 @@ class Permissions(_message.Message): recordKey: bytes useEccKey: bool rules: _folder_pb2.RecordAccessData - def __init__(self, recipientUid: _Optional[bytes] = ..., recordUid: _Optional[bytes] = ..., recordKey: _Optional[bytes] = ..., useEccKey: bool = ..., rules: _Optional[_Union[_folder_pb2.RecordAccessData, _Mapping]] = ...) -> None: ... + def __init__(self, recipientUid: _Optional[bytes] = ..., recordUid: _Optional[bytes] = ..., recordKey: _Optional[bytes] = ..., useEccKey: _Optional[bool] = ..., rules: _Optional[_Union[_folder_pb2.RecordAccessData, _Mapping]] = ...) -> None: ... class Response(_message.Message): __slots__ = ("createdSharingStatus", "updatedSharingStatus", "revokedSharingStatus") @@ -92,4 +92,4 @@ class RecordSharingState(_message.Message): isDirectlyShared: bool isIndirectlyShared: bool isShared: bool - def __init__(self, recordUid: _Optional[bytes] = ..., isDirectlyShared: bool = ..., isIndirectlyShared: bool = ..., isShared: bool = ...) -> None: ... + def __init__(self, recordUid: _Optional[bytes] = ..., isDirectlyShared: _Optional[bool] = ..., isIndirectlyShared: _Optional[bool] = ..., isShared: _Optional[bool] = ...) -> None: ... diff --git a/keepercommander/proto/remove_pb2.py b/keepercommander/proto/remove_pb2.py index 3e2d50dd0..5cbc374a2 100644 --- a/keepercommander/proto/remove_pb2.py +++ b/keepercommander/proto/remove_pb2.py @@ -25,7 +25,7 @@ from google.api import annotations_pb2 as google_dot_api_dot_annotations__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0cremove.proto\x12\x10\x66older.v3.remove\x1a\x1cgoogle/api/annotations.proto\"v\n\rRecordRemoval\x12\x12\n\nfolder_uid\x18\x01 \x01(\x0c\x12\x12\n\nrecord_uid\x18\x02 \x01(\x0c\x12=\n\x0eoperation_type\x18\x03 \x01(\x0e\x32%.folder.v3.remove.RecordOperationType\"b\n\rFolderRemoval\x12\x12\n\nfolder_uid\x18\x01 \x01(\x0c\x12=\n\x0eoperation_type\x18\x02 \x01(\x0e\x32%.folder.v3.remove.FolderOperationType\"\x93\x01\n\x13RemoveRecordRequest\x12.\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x1e.folder.v3.remove.RemoveAction\x12\x30\n\x07records\x18\x02 \x03(\x0b\x32\x1f.folder.v3.remove.RecordRemoval\x12\x1a\n\x12\x63onfirmation_token\x18\x03 \x01(\x0c\"\x93\x01\n\x13RemoveFolderRequest\x12.\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x1e.folder.v3.remove.RemoveAction\x12\x30\n\x07\x66olders\x18\x02 \x03(\x0b\x32\x1f.folder.v3.remove.FolderRemoval\x12\x1a\n\x12\x63onfirmation_token\x18\x03 \x01(\x0c\"\x8e\x01\n\x0eRemoveResponse\x12\x1a\n\x12\x63onfirmation_token\x18\x01 \x01(\x0c\x12\x18\n\x10token_expires_at\x18\x02 \x01(\x03\x12/\n\x07results\x18\x03 \x03(\x0b\x32\x1e.folder.v3.remove.RemoveResult\x12\x15\n\rerror_message\x18\x04 \x01(\t\"\xba\x01\n\x0cRemoveResult\x12\x10\n\x08item_uid\x18\x01 \x01(\x0c\x12\x12\n\nfolder_uid\x18\x02 \x01(\x0c\x12.\n\x06status\x18\x03 \x01(\x0e\x32\x1e.folder.v3.remove.RemoveStatus\x12(\n\x06impact\x18\x04 \x01(\x0b\x32\x18.folder.v3.remove.Impact\x12*\n\x05\x65rror\x18\x05 \x01(\x0b\x32\x1b.folder.v3.remove.ItemError\"\xb7\x01\n\x06Impact\x12\x15\n\rfolders_count\x18\x01 \x01(\x05\x12\x15\n\rrecords_count\x18\x02 \x01(\x05\x12\x1c\n\x14\x61\x66\x66\x65\x63ted_users_count\x18\x03 \x01(\x05\x12\x1c\n\x14\x61\x66\x66\x65\x63ted_teams_count\x18\x04 \x01(\x05\x12\x31\n\x0brecord_info\x18\x05 \x03(\x0b\x32\x1c.folder.v3.remove.RecordInfo\x12\x10\n\x08warnings\x18\x06 \x03(\t\"9\n\nRecordInfo\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12\x17\n\x0flocations_count\x18\x02 \x01(\x05\"M\n\tItemError\x12/\n\x04\x63ode\x18\x01 \x01(\x0e\x32!.folder.v3.remove.RemoveErrorCode\x12\x0f\n\x07message\x18\x02 \x01(\t\"\xa7\x01\n\x13RemovalTokenPayload\x12<\n\x11item_fingerprints\x18\x01 \x03(\x0b\x32!.folder.v3.remove.ItemFingerprint\x12\x0f\n\x07user_id\x18\x02 \x01(\x05\x12\x11\n\tdevice_id\x18\x03 \x01(\x03\x12\x13\n\x0bsession_uid\x18\x04 \x01(\x0c\x12\x19\n\x11\x65xpires_at_millis\x18\x05 \x01(\x03\"\x94\x01\n\x0fItemFingerprint\x12\x30\n\x06record\x18\x01 \x01(\x0b\x32\x1e.folder.v3.remove.RecordTargetH\x00\x12\x30\n\x06\x66older\x18\x02 \x01(\x0b\x32\x1e.folder.v3.remove.FolderTargetH\x00\x12\x13\n\x0b\x66ingerprint\x18\n \x01(\x0c\x42\x08\n\x06target\"u\n\x0cRecordTarget\x12\x12\n\nfolder_uid\x18\x01 \x01(\x0c\x12\x12\n\nrecord_uid\x18\x02 \x01(\x0c\x12=\n\x0eoperation_type\x18\x03 \x01(\x0e\x32%.folder.v3.remove.RecordOperationType\"a\n\x0c\x46olderTarget\x12\x12\n\nfolder_uid\x18\x01 \x01(\x0c\x12=\n\x0eoperation_type\x18\x02 \x01(\x0e\x32%.folder.v3.remove.FolderOperationType*D\n\x0cRemoveAction\x12\x19\n\x15REMOVE_ACTION_PREVIEW\x10\x00\x12\x19\n\x15REMOVE_ACTION_CONFIRM\x10\x01*~\n\x13RecordOperationType\x12\x1c\n\x18RECORD_OPERATION_UNKNOWN\x10\x00\x12\x16\n\x12UNLINK_FROM_FOLDER\x10\x01\x12\x18\n\x14MOVE_TO_FOLDER_TRASH\x10\x02\x12\x17\n\x13MOVE_TO_OWNER_TRASH\x10\x03*\x91\x01\n\x13\x46olderOperationType\x12\x1c\n\x18\x46OLDER_OPERATION_UNKNOWN\x10\x00\x12\x1f\n\x1b\x46OLDER_MOVE_TO_FOLDER_TRASH\x10\x01\x12\x1e\n\x1a\x46OLDER_MOVE_TO_OWNER_TRASH\x10\x02\x12\x1b\n\x17\x46OLDER_DELETE_PERMANENT\x10\x03*\xcb\x01\n\x0fRemoveErrorCode\x12\x18\n\x14REMOVE_ERROR_UNKNOWN\x10\x00\x12\x1a\n\x16REMOVE_ERROR_NOT_FOUND\x10\x01\x12\x1e\n\x1aREMOVE_ERROR_ACCESS_DENIED\x10\x02\x12 \n\x1cREMOVE_ERROR_TRASHCAN_FOLDER\x10\x03\x12\x1c\n\x18REMOVE_ERROR_ROOT_FOLDER\x10\x04\x12\"\n\x1eREMOVE_ERROR_DESCENDANT_DENIED\x10\x05*\xec\x01\n\x0cRemoveStatus\x12\x19\n\x15REMOVE_STATUS_UNKNOWN\x10\x00\x12\x19\n\x15REMOVE_STATUS_SUCCESS\x10\x01\x12\x1f\n\x1bREMOVE_STATUS_STALE_PREVIEW\x10\x02\x12\x1f\n\x1bREMOVE_STATUS_TOKEN_EXPIRED\x10\x03\x12\x1f\n\x1bREMOVE_STATUS_TOKEN_INVALID\x10\x04\x12\x1f\n\x1bREMOVE_STATUS_ACCESS_DENIED\x10\x05\x12\"\n\x1eREMOVE_STATUS_VALIDATION_ERROR\x10\x06\x32\xad\x02\n\rRemoveService\x12\x8c\x01\n\x0cRemoveRecord\x12%.folder.v3.remove.RemoveRecordRequest\x1a .folder.v3.remove.RemoveResponse\"3\x82\xd3\xe4\x93\x02-\"(/api/rest/vault/folders/v3/remove_record:\x01*\x12\x8c\x01\n\x0cRemoveFolder\x12%.folder.v3.remove.RemoveFolderRequest\x1a .folder.v3.remove.RemoveResponse\"3\x82\xd3\xe4\x93\x02-\"(/api/rest/vault/folders/v3/remove_folder:\x01*B1\n-com.keepersecurity.proto.api.folder.v3.removeP\x01\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0cremove.proto\x12\x10\x66older.v3.remove\x1a\x1cgoogle/api/annotations.proto\"v\n\rRecordRemoval\x12\x12\n\nfolder_uid\x18\x01 \x01(\x0c\x12\x12\n\nrecord_uid\x18\x02 \x01(\x0c\x12=\n\x0eoperation_type\x18\x03 \x01(\x0e\x32%.folder.v3.remove.RecordOperationType\"b\n\rFolderRemoval\x12\x12\n\nfolder_uid\x18\x01 \x01(\x0c\x12=\n\x0eoperation_type\x18\x02 \x01(\x0e\x32%.folder.v3.remove.FolderOperationType\"\x93\x01\n\x13RemoveRecordRequest\x12.\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x1e.folder.v3.remove.RemoveAction\x12\x30\n\x07records\x18\x02 \x03(\x0b\x32\x1f.folder.v3.remove.RecordRemoval\x12\x1a\n\x12\x63onfirmation_token\x18\x03 \x01(\x0c\"\x93\x01\n\x13RemoveFolderRequest\x12.\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x1e.folder.v3.remove.RemoveAction\x12\x30\n\x07\x66olders\x18\x02 \x03(\x0b\x32\x1f.folder.v3.remove.FolderRemoval\x12\x1a\n\x12\x63onfirmation_token\x18\x03 \x01(\x0c\"\x8e\x01\n\x0eRemoveResponse\x12\x1a\n\x12\x63onfirmation_token\x18\x01 \x01(\x0c\x12\x18\n\x10token_expires_at\x18\x02 \x01(\x03\x12/\n\x07results\x18\x03 \x03(\x0b\x32\x1e.folder.v3.remove.RemoveResult\x12\x15\n\rerror_message\x18\x04 \x01(\t\"\xba\x01\n\x0cRemoveResult\x12\x10\n\x08item_uid\x18\x01 \x01(\x0c\x12\x12\n\nfolder_uid\x18\x02 \x01(\x0c\x12.\n\x06status\x18\x03 \x01(\x0e\x32\x1e.folder.v3.remove.RemoveStatus\x12(\n\x06impact\x18\x04 \x01(\x0b\x32\x18.folder.v3.remove.Impact\x12*\n\x05\x65rror\x18\x05 \x01(\x0b\x32\x1b.folder.v3.remove.ItemError\"\xb7\x01\n\x06Impact\x12\x15\n\rfolders_count\x18\x01 \x01(\x05\x12\x15\n\rrecords_count\x18\x02 \x01(\x05\x12\x1c\n\x14\x61\x66\x66\x65\x63ted_users_count\x18\x03 \x01(\x05\x12\x1c\n\x14\x61\x66\x66\x65\x63ted_teams_count\x18\x04 \x01(\x05\x12\x31\n\x0brecord_info\x18\x05 \x03(\x0b\x32\x1c.folder.v3.remove.RecordInfo\x12\x10\n\x08warnings\x18\x06 \x03(\t\"9\n\nRecordInfo\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12\x17\n\x0flocations_count\x18\x02 \x01(\x05\"M\n\tItemError\x12/\n\x04\x63ode\x18\x01 \x01(\x0e\x32!.folder.v3.remove.RemoveErrorCode\x12\x0f\n\x07message\x18\x02 \x01(\t\"\xa7\x01\n\x13RemovalTokenPayload\x12<\n\x11item_fingerprints\x18\x01 \x03(\x0b\x32!.folder.v3.remove.ItemFingerprint\x12\x0f\n\x07user_id\x18\x02 \x01(\x05\x12\x11\n\tdevice_id\x18\x03 \x01(\x03\x12\x13\n\x0bsession_uid\x18\x04 \x01(\x0c\x12\x19\n\x11\x65xpires_at_millis\x18\x05 \x01(\x03\"\x94\x01\n\x0fItemFingerprint\x12\x30\n\x06record\x18\x01 \x01(\x0b\x32\x1e.folder.v3.remove.RecordTargetH\x00\x12\x30\n\x06\x66older\x18\x02 \x01(\x0b\x32\x1e.folder.v3.remove.FolderTargetH\x00\x12\x13\n\x0b\x66ingerprint\x18\n \x01(\x0c\x42\x08\n\x06target\"u\n\x0cRecordTarget\x12\x12\n\nfolder_uid\x18\x01 \x01(\x0c\x12\x12\n\nrecord_uid\x18\x02 \x01(\x0c\x12=\n\x0eoperation_type\x18\x03 \x01(\x0e\x32%.folder.v3.remove.RecordOperationType\"a\n\x0c\x46olderTarget\x12\x12\n\nfolder_uid\x18\x01 \x01(\x0c\x12=\n\x0eoperation_type\x18\x02 \x01(\x0e\x32%.folder.v3.remove.FolderOperationType\"\x9f\x01\n\rRestoreResult\x12\x10\n\x08item_uid\x18\x01 \x01(\x0c\x12\x34\n\titem_type\x18\x02 \x01(\x0e\x32!.folder.v3.remove.RestoreItemType\x12/\n\x06status\x18\x03 \x01(\x0e\x32\x1f.folder.v3.remove.RestoreStatus\x12\x15\n\rerror_message\x18\x04 \x01(\t\"b\n\x17TrashcanRestoreResponse\x12\x30\n\x07results\x18\x01 \x03(\x0b\x32\x1f.folder.v3.remove.RestoreResult\x12\x15\n\rerror_message\x18\x02 \x01(\t\"\\\n\rRestoreRecord\x12\x12\n\nrecord_uid\x18\x01 \x01(\x0c\x12\x1c\n\x14\x65ncrypted_record_key\x18\x02 \x01(\x0c\x12\x19\n\x11source_folder_uid\x18\x03 \x01(\x0c\"A\n\rRestoreFolder\x12\x12\n\nfolder_uid\x18\x01 \x01(\x0c\x12\x1c\n\x14\x65ncrypted_folder_key\x18\x02 \x01(\x0c\"\x97\x01\n\x16TrashcanRestoreRequest\x12\x30\n\x07records\x18\x01 \x03(\x0b\x32\x1f.folder.v3.remove.RestoreRecord\x12\x30\n\x07\x66olders\x18\x02 \x03(\x0b\x32\x1f.folder.v3.remove.RestoreFolder\x12\x19\n\x11target_folder_uid\x18\x03 \x01(\x0c*D\n\x0cRemoveAction\x12\x19\n\x15REMOVE_ACTION_PREVIEW\x10\x00\x12\x19\n\x15REMOVE_ACTION_CONFIRM\x10\x01*~\n\x13RecordOperationType\x12\x1c\n\x18RECORD_OPERATION_UNKNOWN\x10\x00\x12\x16\n\x12UNLINK_FROM_FOLDER\x10\x01\x12\x18\n\x14MOVE_TO_FOLDER_TRASH\x10\x02\x12\x17\n\x13MOVE_TO_OWNER_TRASH\x10\x03*\x91\x01\n\x13\x46olderOperationType\x12\x1c\n\x18\x46OLDER_OPERATION_UNKNOWN\x10\x00\x12\x1f\n\x1b\x46OLDER_MOVE_TO_FOLDER_TRASH\x10\x01\x12\x1e\n\x1a\x46OLDER_MOVE_TO_OWNER_TRASH\x10\x02\x12\x1b\n\x17\x46OLDER_DELETE_PERMANENT\x10\x03*\xcb\x01\n\x0fRemoveErrorCode\x12\x18\n\x14REMOVE_ERROR_UNKNOWN\x10\x00\x12\x1a\n\x16REMOVE_ERROR_NOT_FOUND\x10\x01\x12\x1e\n\x1aREMOVE_ERROR_ACCESS_DENIED\x10\x02\x12 \n\x1cREMOVE_ERROR_TRASHCAN_FOLDER\x10\x03\x12\x1c\n\x18REMOVE_ERROR_ROOT_FOLDER\x10\x04\x12\"\n\x1eREMOVE_ERROR_DESCENDANT_DENIED\x10\x05*\xec\x01\n\x0cRemoveStatus\x12\x19\n\x15REMOVE_STATUS_UNKNOWN\x10\x00\x12\x19\n\x15REMOVE_STATUS_SUCCESS\x10\x01\x12\x1f\n\x1bREMOVE_STATUS_STALE_PREVIEW\x10\x02\x12\x1f\n\x1bREMOVE_STATUS_TOKEN_EXPIRED\x10\x03\x12\x1f\n\x1bREMOVE_STATUS_TOKEN_INVALID\x10\x04\x12\x1f\n\x1bREMOVE_STATUS_ACCESS_DENIED\x10\x05\x12\"\n\x1eREMOVE_STATUS_VALIDATION_ERROR\x10\x06*\xb7\x01\n\rRestoreStatus\x12\x1a\n\x16RESTORE_STATUS_UNKNOWN\x10\x00\x12\x0e\n\nRS_SUCCESS\x10\x01\x12\x16\n\x12RS_NOT_IN_TRASHCAN\x10\x02\x12\x14\n\x10RS_ACCESS_DENIED\x10\x03\x12\x1e\n\x1aRS_TARGET_FOLDER_NOT_FOUND\x10\x04\x12\x1f\n\x1bRS_ALREADY_EXISTS_IN_TARGET\x10\x05\x12\x0b\n\x07RS_FAIL\x10\x06*]\n\x0fRestoreItemType\x12\x18\n\x14RESTORE_ITEM_UNKNOWN\x10\x00\x12\x17\n\x13RESTORE_ITEM_RECORD\x10\x01\x12\x17\n\x13RESTORE_ITEM_FOLDER\x10\x02\x32\xce\x03\n\rRemoveService\x12\x8c\x01\n\x0cRemoveRecord\x12%.folder.v3.remove.RemoveRecordRequest\x1a .folder.v3.remove.RemoveResponse\"3\x82\xd3\xe4\x93\x02-\"(/api/rest/vault/folders/v3/remove_record:\x01*\x12\x8c\x01\n\x0cRemoveFolder\x12%.folder.v3.remove.RemoveFolderRequest\x1a .folder.v3.remove.RemoveResponse\"3\x82\xd3\xe4\x93\x02-\"(/api/rest/vault/folders/v3/remove_folder:\x01*\x12\x9e\x01\n\x0fTrashcanRestore\x12(.folder.v3.remove.TrashcanRestoreRequest\x1a).folder.v3.remove.TrashcanRestoreResponse\"6\x82\xd3\xe4\x93\x02\x30\"+/api/rest/vault/folders/v3/trashcan/restore:\x01*B1\n-com.keepersecurity.proto.api.folder.v3.removeP\x01\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -37,16 +37,22 @@ _globals['_REMOVESERVICE'].methods_by_name['RemoveRecord']._serialized_options = b'\202\323\344\223\002-\"(/api/rest/vault/folders/v3/remove_record:\001*' _globals['_REMOVESERVICE'].methods_by_name['RemoveFolder']._loaded_options = None _globals['_REMOVESERVICE'].methods_by_name['RemoveFolder']._serialized_options = b'\202\323\344\223\002-\"(/api/rest/vault/folders/v3/remove_folder:\001*' - _globals['_REMOVEACTION']._serialized_start=1781 - _globals['_REMOVEACTION']._serialized_end=1849 - _globals['_RECORDOPERATIONTYPE']._serialized_start=1851 - _globals['_RECORDOPERATIONTYPE']._serialized_end=1977 - _globals['_FOLDEROPERATIONTYPE']._serialized_start=1980 - _globals['_FOLDEROPERATIONTYPE']._serialized_end=2125 - _globals['_REMOVEERRORCODE']._serialized_start=2128 - _globals['_REMOVEERRORCODE']._serialized_end=2331 - _globals['_REMOVESTATUS']._serialized_start=2334 - _globals['_REMOVESTATUS']._serialized_end=2570 + _globals['_REMOVESERVICE'].methods_by_name['TrashcanRestore']._loaded_options = None + _globals['_REMOVESERVICE'].methods_by_name['TrashcanRestore']._serialized_options = b'\202\323\344\223\0020\"+/api/rest/vault/folders/v3/trashcan/restore:\001*' + _globals['_REMOVEACTION']._serialized_start=2358 + _globals['_REMOVEACTION']._serialized_end=2426 + _globals['_RECORDOPERATIONTYPE']._serialized_start=2428 + _globals['_RECORDOPERATIONTYPE']._serialized_end=2554 + _globals['_FOLDEROPERATIONTYPE']._serialized_start=2557 + _globals['_FOLDEROPERATIONTYPE']._serialized_end=2702 + _globals['_REMOVEERRORCODE']._serialized_start=2705 + _globals['_REMOVEERRORCODE']._serialized_end=2908 + _globals['_REMOVESTATUS']._serialized_start=2911 + _globals['_REMOVESTATUS']._serialized_end=3147 + _globals['_RESTORESTATUS']._serialized_start=3150 + _globals['_RESTORESTATUS']._serialized_end=3333 + _globals['_RESTOREITEMTYPE']._serialized_start=3335 + _globals['_RESTOREITEMTYPE']._serialized_end=3428 _globals['_RECORDREMOVAL']._serialized_start=64 _globals['_RECORDREMOVAL']._serialized_end=182 _globals['_FOLDERREMOVAL']._serialized_start=184 @@ -73,6 +79,16 @@ _globals['_RECORDTARGET']._serialized_end=1680 _globals['_FOLDERTARGET']._serialized_start=1682 _globals['_FOLDERTARGET']._serialized_end=1779 - _globals['_REMOVESERVICE']._serialized_start=2573 - _globals['_REMOVESERVICE']._serialized_end=2874 + _globals['_RESTORERESULT']._serialized_start=1782 + _globals['_RESTORERESULT']._serialized_end=1941 + _globals['_TRASHCANRESTORERESPONSE']._serialized_start=1943 + _globals['_TRASHCANRESTORERESPONSE']._serialized_end=2041 + _globals['_RESTORERECORD']._serialized_start=2043 + _globals['_RESTORERECORD']._serialized_end=2135 + _globals['_RESTOREFOLDER']._serialized_start=2137 + _globals['_RESTOREFOLDER']._serialized_end=2202 + _globals['_TRASHCANRESTOREREQUEST']._serialized_start=2205 + _globals['_TRASHCANRESTOREREQUEST']._serialized_end=2356 + _globals['_REMOVESERVICE']._serialized_start=3431 + _globals['_REMOVESERVICE']._serialized_end=3893 # @@protoc_insertion_point(module_scope) diff --git a/keepercommander/proto/remove_pb2.pyi b/keepercommander/proto/remove_pb2.pyi index 093275ccb..bd21c9aee 100644 --- a/keepercommander/proto/remove_pb2.pyi +++ b/keepercommander/proto/remove_pb2.pyi @@ -44,6 +44,22 @@ class RemoveStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): REMOVE_STATUS_TOKEN_INVALID: _ClassVar[RemoveStatus] REMOVE_STATUS_ACCESS_DENIED: _ClassVar[RemoveStatus] REMOVE_STATUS_VALIDATION_ERROR: _ClassVar[RemoveStatus] + +class RestoreStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + RESTORE_STATUS_UNKNOWN: _ClassVar[RestoreStatus] + RS_SUCCESS: _ClassVar[RestoreStatus] + RS_NOT_IN_TRASHCAN: _ClassVar[RestoreStatus] + RS_ACCESS_DENIED: _ClassVar[RestoreStatus] + RS_TARGET_FOLDER_NOT_FOUND: _ClassVar[RestoreStatus] + RS_ALREADY_EXISTS_IN_TARGET: _ClassVar[RestoreStatus] + RS_FAIL: _ClassVar[RestoreStatus] + +class RestoreItemType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + RESTORE_ITEM_UNKNOWN: _ClassVar[RestoreItemType] + RESTORE_ITEM_RECORD: _ClassVar[RestoreItemType] + RESTORE_ITEM_FOLDER: _ClassVar[RestoreItemType] REMOVE_ACTION_PREVIEW: RemoveAction REMOVE_ACTION_CONFIRM: RemoveAction RECORD_OPERATION_UNKNOWN: RecordOperationType @@ -67,6 +83,16 @@ REMOVE_STATUS_TOKEN_EXPIRED: RemoveStatus REMOVE_STATUS_TOKEN_INVALID: RemoveStatus REMOVE_STATUS_ACCESS_DENIED: RemoveStatus REMOVE_STATUS_VALIDATION_ERROR: RemoveStatus +RESTORE_STATUS_UNKNOWN: RestoreStatus +RS_SUCCESS: RestoreStatus +RS_NOT_IN_TRASHCAN: RestoreStatus +RS_ACCESS_DENIED: RestoreStatus +RS_TARGET_FOLDER_NOT_FOUND: RestoreStatus +RS_ALREADY_EXISTS_IN_TARGET: RestoreStatus +RS_FAIL: RestoreStatus +RESTORE_ITEM_UNKNOWN: RestoreItemType +RESTORE_ITEM_RECORD: RestoreItemType +RESTORE_ITEM_FOLDER: RestoreItemType class RecordRemoval(_message.Message): __slots__ = ("folder_uid", "record_uid", "operation_type") @@ -205,3 +231,51 @@ class FolderTarget(_message.Message): folder_uid: bytes operation_type: FolderOperationType def __init__(self, folder_uid: _Optional[bytes] = ..., operation_type: _Optional[_Union[FolderOperationType, str]] = ...) -> None: ... + +class RestoreResult(_message.Message): + __slots__ = ("item_uid", "item_type", "status", "error_message") + ITEM_UID_FIELD_NUMBER: _ClassVar[int] + ITEM_TYPE_FIELD_NUMBER: _ClassVar[int] + STATUS_FIELD_NUMBER: _ClassVar[int] + ERROR_MESSAGE_FIELD_NUMBER: _ClassVar[int] + item_uid: bytes + item_type: RestoreItemType + status: RestoreStatus + error_message: str + def __init__(self, item_uid: _Optional[bytes] = ..., item_type: _Optional[_Union[RestoreItemType, str]] = ..., status: _Optional[_Union[RestoreStatus, str]] = ..., error_message: _Optional[str] = ...) -> None: ... + +class TrashcanRestoreResponse(_message.Message): + __slots__ = ("results", "error_message") + RESULTS_FIELD_NUMBER: _ClassVar[int] + ERROR_MESSAGE_FIELD_NUMBER: _ClassVar[int] + results: _containers.RepeatedCompositeFieldContainer[RestoreResult] + error_message: str + def __init__(self, results: _Optional[_Iterable[_Union[RestoreResult, _Mapping]]] = ..., error_message: _Optional[str] = ...) -> None: ... + +class RestoreRecord(_message.Message): + __slots__ = ("record_uid", "encrypted_record_key", "source_folder_uid") + RECORD_UID_FIELD_NUMBER: _ClassVar[int] + ENCRYPTED_RECORD_KEY_FIELD_NUMBER: _ClassVar[int] + SOURCE_FOLDER_UID_FIELD_NUMBER: _ClassVar[int] + record_uid: bytes + encrypted_record_key: bytes + source_folder_uid: bytes + def __init__(self, record_uid: _Optional[bytes] = ..., encrypted_record_key: _Optional[bytes] = ..., source_folder_uid: _Optional[bytes] = ...) -> None: ... + +class RestoreFolder(_message.Message): + __slots__ = ("folder_uid", "encrypted_folder_key") + FOLDER_UID_FIELD_NUMBER: _ClassVar[int] + ENCRYPTED_FOLDER_KEY_FIELD_NUMBER: _ClassVar[int] + folder_uid: bytes + encrypted_folder_key: bytes + def __init__(self, folder_uid: _Optional[bytes] = ..., encrypted_folder_key: _Optional[bytes] = ...) -> None: ... + +class TrashcanRestoreRequest(_message.Message): + __slots__ = ("records", "folders", "target_folder_uid") + RECORDS_FIELD_NUMBER: _ClassVar[int] + FOLDERS_FIELD_NUMBER: _ClassVar[int] + TARGET_FOLDER_UID_FIELD_NUMBER: _ClassVar[int] + records: _containers.RepeatedCompositeFieldContainer[RestoreRecord] + folders: _containers.RepeatedCompositeFieldContainer[RestoreFolder] + target_folder_uid: bytes + def __init__(self, records: _Optional[_Iterable[_Union[RestoreRecord, _Mapping]]] = ..., folders: _Optional[_Iterable[_Union[RestoreFolder, _Mapping]]] = ..., target_folder_uid: _Optional[bytes] = ...) -> None: ... diff --git a/keepercommander/proto/tla_pb2.pyi b/keepercommander/proto/tla_pb2.pyi index 08e7a2c3b..7075a1c31 100644 --- a/keepercommander/proto/tla_pb2.pyi +++ b/keepercommander/proto/tla_pb2.pyi @@ -22,4 +22,4 @@ class TLAProperties(_message.Message): expiration: int timerNotificationType: TimerNotificationType rotateOnExpiration: bool - def __init__(self, expiration: _Optional[int] = ..., timerNotificationType: _Optional[_Union[TimerNotificationType, str]] = ..., rotateOnExpiration: bool = ...) -> None: ... + def __init__(self, expiration: _Optional[int] = ..., timerNotificationType: _Optional[_Union[TimerNotificationType, str]] = ..., rotateOnExpiration: _Optional[bool] = ...) -> None: ... diff --git a/keepercommander/sync_down.py b/keepercommander/sync_down.py index 85f7fca28..ad6759e1c 100644 --- a/keepercommander/sync_down.py +++ b/keepercommander/sync_down.py @@ -602,21 +602,9 @@ def convert_user_folder_shared_folder(ufsf): if len(response.recordRotations) > 0: # Stuff still uses record_rotation_cache; it cannot just be removed. + from . import vault_extensions for rr in response.recordRotations: - record_uid = utils.base64_url_encode(rr.recordUid) - rr_obj = { - 'record_uid': record_uid, - 'revision': rr.revision, - 'configuration_uid': utils.base64_url_encode(rr.configurationUid), - 'schedule': rr.schedule, - 'pwd_complexity': utils.base64_url_encode(rr.pwdComplexity), - 'disabled': rr.disabled, - 'resource_uid': utils.base64_url_encode(rr.resourceUid), - 'last_rotation': rr.lastRotation, - 'last_rotation_status': rr.lastRotationStatus, - } - params.record_rotation_cache[record_uid] = rr_obj - + vault_extensions.cache_record_rotation(params, rr) record_rotation_items.extend(response.recordRotations) params.sync_down_token = response.continuationToken diff --git a/keepercommander/vault_extensions.py b/keepercommander/vault_extensions.py index a1bd49034..8b3900d98 100644 --- a/keepercommander/vault_extensions.py +++ b/keepercommander/vault_extensions.py @@ -125,12 +125,31 @@ def record_has_rotation_configured(params, record_uid): return record_uid in (params.record_rotation_cache or {}) -def shared_folder_has_pam_user_with_rotation(params, shared_folder_uid): - # type: (KeeperParams, str) -> bool - """Return True if the shared folder contains at least one pamUser record with rotation configured. +def cache_record_rotation(params, rr): + # type: (KeeperParams, Any) -> str + """Mirror a RecordRotation proto into ``params.record_rotation_cache``. - Required precondition for the server to accept --rotate-on-expiration on a folder share. + Used by classic sync_down and Nested Share Folder sync (NSF rotations arrive + on ``keeperDriveData.recordRotationData``). """ + record_uid = utils.base64_url_encode(rr.recordUid) + params.record_rotation_cache[record_uid] = { + 'record_uid': record_uid, + 'revision': rr.revision, + 'configuration_uid': utils.base64_url_encode(rr.configurationUid), + 'schedule': rr.schedule, + 'pwd_complexity': utils.base64_url_encode(rr.pwdComplexity), + 'disabled': rr.disabled, + 'resource_uid': utils.base64_url_encode(rr.resourceUid), + 'last_rotation': rr.lastRotation, + 'last_rotation_status': rr.lastRotationStatus, + } + return record_uid + + +def shared_folder_has_pam_user_with_rotation(params, shared_folder_uid): + # type: (KeeperParams, str) -> bool + """True if the shared folder has a pamUser with rotation configured.""" sf = params.shared_folder_cache.get(shared_folder_uid) if not sf: return False @@ -144,6 +163,42 @@ def shared_folder_has_pam_user_with_rotation(params, shared_folder_uid): return False +def nested_share_record_is_pam_user_with_rotation(params, record_uid): + # type: (KeeperParams, str) -> bool + """True if an NSF record is pamUser with rotation configured.""" + rec_type = None + nsf_record_data = getattr(params, 'nested_share_record_data', {}) or {} + obj = nsf_record_data.get(record_uid) + if obj and isinstance(obj.get('data_json'), dict): + rec_type = obj['data_json'].get('type') + if rec_type is None: + record = vault.KeeperRecord.load(params, record_uid) + rec_type = getattr(record, 'record_type', None) if record is not None else None + return rec_type == 'pamUser' and record_has_rotation_configured(params, record_uid) + + +def nested_share_folder_has_pam_user_with_rotation(params, folder_uid): + # type: (KeeperParams, str) -> bool + """True if an NSF folder (or sub-folder) has a pamUser with rotation configured.""" + nsf_folders = getattr(params, 'nested_share_folders', {}) or {} + nsf_folder_records = getattr(params, 'nested_share_folder_records', {}) or {} + + visited = set() + stack = [folder_uid] + while stack: + fuid = stack.pop() + if fuid in visited: + continue + visited.add(fuid) + for record_uid in (nsf_folder_records.get(fuid) or set()): + if nested_share_record_is_pam_user_with_rotation(params, record_uid): + return True + for child_uid, child_obj in nsf_folders.items(): + if child_obj.get('parent_uid') == fuid and child_uid not in visited: + stack.append(child_uid) + return False + + def find_records(params, # type: KeeperParams search_str=None, # type: Optional[str] record_type=None, # type: Union[str, Iterable[str], None] diff --git a/tests/test_pam_privileged_cloud.py b/tests/test_pam_privileged_cloud.py index 1c840f32f..0acecaa02 100644 --- a/tests/test_pam_privileged_cloud.py +++ b/tests/test_pam_privileged_cloud.py @@ -4,6 +4,7 @@ import unittest from unittest.mock import MagicMock, patch +import keepercommander.commands.record # noqa: F401 from keepercommander.commands.pam.pam_dto import ( GatewayActionIdpInputs, GatewayActionIdpCreateUser, @@ -270,4 +271,4 @@ def test_group_has_add_remove_list(self): if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() diff --git a/unit-tests/pam/test_pam_nsf_config.py b/unit-tests/pam/test_pam_nsf_config.py new file mode 100644 index 000000000..aa75e930c --- /dev/null +++ b/unit-tests/pam/test_pam_nsf_config.py @@ -0,0 +1,509 @@ +import unittest +from unittest import mock + +import keepercommander.commands.record # noqa: F401 +from keepercommander import vault +from keepercommander.commands.discoveryrotation import ( + PAMConfigurationEditCommand, PAMConfigurationNewCommand, PAMConfigurationRemoveCommand, + PAMCreateRecordRotationCommand) +from keepercommander.commands.pam.vault_target import ( + create_pam_configuration_in_folder, create_record_in_folder, place_record_in_folder, + resolve_pam_folder_uid, records_in_folder) +from keepercommander.error import CommandError +from keepercommander.subfolder import NestedShareFolderNode, RootFolderNode, SharedFolderNode + + +def _make_params(): + params = mock.MagicMock() + params.folder_cache = {} + params.shared_folder_cache = {} + params.nested_share_folders = {} + params.current_folder = '' + params.root_folder = RootFolderNode() + params.environment_variables = {} + params.sync_data = False + return params + + +class TestPamVaultTarget(unittest.TestCase): + + @mock.patch('keepercommander.commands.pam.vault_target.api.sync_down') + @mock.patch('keepercommander.commands.pam.vault_target.move_record_v3') + @mock.patch('keepercommander.commands.pam.vault_target.FolderMoveCommand') + def test_place_record_uses_legacy_move_for_legacy_folder(self, mock_move_command, mock_nsf_move, mock_sync): + params = _make_params() + folder = SharedFolderNode() + folder.uid = 'legacy_folder' + params.folder_cache[folder.uid] = folder + + place_record_in_folder(params, 'record_uid', folder.uid, command='pam-config-new') + + mock_move_command.return_value.execute.assert_called_once_with( + params, src='record_uid', dst='legacy_folder', force=True) + mock_nsf_move.assert_not_called() + mock_sync.assert_not_called() + + @mock.patch('keepercommander.commands.pam.vault_target.api.sync_down') + @mock.patch('keepercommander.commands.pam.vault_target.move_record_v3') + @mock.patch('keepercommander.commands.pam.vault_target.FolderMoveCommand') + def test_place_record_uses_nsf_move_for_nsf_folder(self, mock_move_command, mock_nsf_move, mock_sync): + params = _make_params() + folder = NestedShareFolderNode() + folder.uid = 'nsf_folder' + params.folder_cache[folder.uid] = folder + + place_record_in_folder(params, 'record_uid', folder.uid, command='pam-config-new') + + mock_nsf_move.assert_called_once_with(params, 'record_uid', to_folder_uid='nsf_folder') + mock_sync.assert_called_once_with(params) + mock_move_command.assert_not_called() + + @mock.patch('keepercommander.commands.pam.vault_target.api.sync_down') + @mock.patch('keepercommander.commands.pam.vault_target.move_record_v3', + return_value={'success': False, 'message': 'denied'}) + def test_place_record_raises_when_nsf_move_fails(self, mock_nsf_move, mock_sync): + params = _make_params() + folder = NestedShareFolderNode() + folder.uid = 'nsf_folder' + params.folder_cache[folder.uid] = folder + + with self.assertRaises(CommandError): + place_record_in_folder(params, 'record_uid', folder.uid, command='pam-config-new') + mock_sync.assert_not_called() + + def test_place_record_rejects_missing_folder(self): + params = _make_params() + + with self.assertRaises(CommandError): + place_record_in_folder(params, 'record_uid', 'missing_folder', command='pam-config-new') + + @mock.patch('keepercommander.commands.pam.vault_target.record_management.add_record_to_folder') + def test_create_record_in_folder_uses_legacy_add_for_legacy_folder(self, mock_add_record): + params = _make_params() + folder = SharedFolderNode() + folder.uid = 'legacy_folder' + params.folder_cache[folder.uid] = folder + record = vault.TypedRecord() + + create_record_in_folder(params, record, folder.uid, command='pam-access-user-provision') + + mock_add_record.assert_called_once_with(params, record, 'legacy_folder') + + @mock.patch('keepercommander.commands.pam_import.nsf_helpers.api.sync_down') + @mock.patch('keepercommander.nested_share_folder.record_api.create_record_v3', + return_value={'success': True, 'record_uid': 'nsf_record_uid'}) + @mock.patch('keepercommander.vault_extensions.extract_typed_record_data', + return_value={'type': 'pamUser', 'title': 'Test', 'fields': [], 'custom': []}) + def test_create_record_in_folder_uses_v3_add_for_nsf_folder(self, _extract, mock_create, _sync): + params = _make_params() + params.nested_share_folders['nsf_folder'] = { + 'name': 'Project - Users', + 'parent_uid': 'app_uid', + 'folder_key_unencrypted': b'0' * 32, + } + folder = NestedShareFolderNode() + folder.uid = 'nsf_folder' + params.folder_cache[folder.uid] = folder + record = vault.TypedRecord() + + create_record_in_folder(params, record, folder.uid, command='pam-access-user-provision') + + mock_create.assert_called_once() + self.assertEqual(mock_create.call_args.kwargs['folder_uid'], 'nsf_folder') + self.assertEqual(record.record_uid, 'nsf_record_uid') + _sync.assert_called_once_with(params) + + def test_resolve_pam_folder_uid_finds_root_nsf_folder_by_name(self): + params = _make_params() + folder = NestedShareFolderNode() + folder.uid = 'nsf_folder' + folder.name = 'testpam' + params.folder_cache[folder.uid] = folder + params.root_folder.subfolders.append(folder.uid) + + self.assertEqual(resolve_pam_folder_uid(params, 'testpam'), 'nsf_folder') + + def test_resolve_pam_folder_uid_finds_nsf_cache_folder_by_name(self): + params = _make_params() + params.nested_share_folders['nsf_folder'] = {'name': 'testpam'} + + self.assertEqual(resolve_pam_folder_uid(params, 'testpam'), 'nsf_folder') + + def test_records_in_folder_merges_legacy_and_nsf_membership(self): + params = _make_params() + params.subfolder_record_cache = {'nsf_folder': {'legacy_rec_uid'}} + params.nested_share_folder_records = {'nsf_folder': {'nsf_rec_uid'}} + + self.assertEqual( + records_in_folder(params, 'nsf_folder'), + {'legacy_rec_uid', 'nsf_rec_uid'}, + ) + + @mock.patch('keepercommander.commands.pam_import.nsf_helpers.api.sync_down') + @mock.patch('keepercommander.commands.pam.config_helper.pam_configuration_create_record_nsf') + def test_create_pam_configuration_in_folder_uses_add_pam_configuration_for_nsf( + self, mock_create_nsf, mock_sync): + params = _make_params() + params.nested_share_folders['nsf_folder'] = {'name': 'Project - Users'} + folder = NestedShareFolderNode() + folder.uid = 'nsf_folder' + params.folder_cache[folder.uid] = folder + record = vault.TypedRecord(version=6) + + create_pam_configuration_in_folder(params, record, 'nsf_folder', command='pam-config-new') + + mock_create_nsf.assert_called_once_with(params, record, 'nsf_folder') + mock_sync.assert_called_once_with(params) + + @mock.patch('keepercommander.commands.pam.vault_target.place_record_in_folder') + @mock.patch('keepercommander.commands.pam.vault_target.api.sync_down') + @mock.patch('keepercommander.commands.pam.config_helper.pam_configuration_create_record_v6') + def test_create_pam_configuration_in_folder_uses_classic_api_for_legacy( + self, mock_create_v6, mock_sync, mock_place): + params = _make_params() + folder = SharedFolderNode() + folder.uid = 'legacy_folder' + params.folder_cache[folder.uid] = folder + record = vault.TypedRecord(version=6) + record.record_uid = 'config_uid' + + create_pam_configuration_in_folder(params, record, 'legacy_folder', command='pam-config-new') + + mock_create_v6.assert_called_once_with(params, record, 'legacy_folder') + mock_sync.assert_called_once_with(params) + mock_place.assert_called_once_with( + params, 'config_uid', 'legacy_folder', command='pam-config-new') + + +class TestPamConfigNewNsfPlacement(unittest.TestCase): + + def test_parse_pam_configuration_accepts_nsf_folder_uid(self): + params = _make_params() + folder = NestedShareFolderNode() + folder.uid = 'nsf_folder' + params.folder_cache[folder.uid] = folder + + record = vault.TypedRecord(version=6) + command = PAMConfigurationNewCommand() + command.parse_pam_configuration(params, record, shared_folder_uid='nsf_folder') + + field = record.get_typed_field('pamResources') + self.assertEqual(field.value[0]['folderUid'], 'nsf_folder') + + def test_parse_pam_configuration_accepts_nsf_folder_name(self): + params = _make_params() + folder = NestedShareFolderNode() + folder.uid = 'nsf_folder' + folder.name = 'testpam' + params.folder_cache[folder.uid] = folder + params.root_folder.subfolders.append(folder.uid) + + record = vault.TypedRecord(version=6) + command = PAMConfigurationNewCommand() + command.parse_pam_configuration(params, record, shared_folder_uid='testpam') + + field = record.get_typed_field('pamResources') + self.assertEqual(field.value[0]['folderUid'], 'nsf_folder') + + @mock.patch('keepercommander.commands.discoveryrotation.TunnelDAG') + @mock.patch('keepercommander.commands.discoveryrotation.get_keeper_tokens', + return_value=(b'encrypted_session', b'encrypted_key', b'transmission_key')) + @mock.patch('keepercommander.commands.discoveryrotation.create_pam_configuration_in_folder') + @mock.patch('keepercommander.commands.discoveryrotation.RecordEditMixin.get_record_type_fields', + return_value=[]) + def test_execute_creates_new_config_in_nsf_folder( + self, mock_record_fields, mock_create_config, mock_tokens, mock_dag): + params = _make_params() + params.nested_share_folders['nsf_folder'] = { + 'name': 'Project - Users', + 'parent_uid': 'app_uid', + 'folder_key_unencrypted': b'0' * 32, + } + folder = NestedShareFolderNode() + folder.uid = 'nsf_folder' + params.folder_cache[folder.uid] = folder + command = PAMConfigurationNewCommand() + + def parse_properties(_, record, **kwargs): + record.fields.append(vault.TypedField.new_field( + 'pamResources', {'folderUid': 'nsf_folder'})) + + def create_config(_, record, folder_uid, command='pam-config-new'): + record.record_uid = 'config_uid' + + mock_create_config.side_effect = create_config + with mock.patch.object(command, 'parse_properties', side_effect=parse_properties), \ + mock.patch.object(command, 'verify_required'): + result = command.execute(params, config_type='local', title='Config') + + self.assertEqual(result, 'config_uid') + mock_create_config.assert_called_once_with( + params, mock.ANY, 'nsf_folder', command='pam-config-new') + + @mock.patch('keepercommander.commands.discoveryrotation.TunnelDAG') + @mock.patch('keepercommander.commands.discoveryrotation.get_keeper_tokens', + return_value=(b'encrypted_session', b'encrypted_key', b'transmission_key')) + @mock.patch('keepercommander.commands.discoveryrotation.create_pam_configuration_in_folder') + @mock.patch('keepercommander.commands.discoveryrotation.RecordEditMixin.get_record_type_fields', + return_value=[]) + def test_execute_creates_new_config_in_legacy_folder( + self, mock_record_fields, mock_create_config, mock_tokens, mock_dag): + params = _make_params() + folder = SharedFolderNode() + folder.uid = 'legacy_folder' + params.folder_cache[folder.uid] = folder + command = PAMConfigurationNewCommand() + + def parse_properties(_, record, **kwargs): + record.fields.append(vault.TypedField.new_field( + 'pamResources', {'folderUid': 'legacy_folder'})) + + def create_config(_, record, folder_uid, command='pam-config-new'): + record.record_uid = 'config_uid' + + mock_create_config.side_effect = create_config + with mock.patch.object(command, 'parse_properties', side_effect=parse_properties), \ + mock.patch.object(command, 'verify_required'): + result = command.execute(params, config_type='local', title='Config') + + self.assertEqual(result, 'config_uid') + mock_create_config.assert_called_once_with( + params, mock.ANY, 'legacy_folder', command='pam-config-new') + + +class TestPamConfigEditNsfPlacement(unittest.TestCase): + + @mock.patch('keepercommander.commands.discoveryrotation.place_record_in_folder') + @mock.patch('keepercommander.commands.discoveryrotation.update_pam_record') + @mock.patch('keepercommander.commands.discoveryrotation.RecordEditMixin.get_record_type_fields', + return_value=[]) + @mock.patch('keepercommander.vault.KeeperRecord.load') + def test_execute_updates_and_places_edited_config_in_nsf_folder( + self, mock_load, mock_record_fields, mock_update_record, mock_place): + params = _make_params() + params.nested_share_folders['nsf_folder'] = { + 'name': 'Project - Users', + 'parent_uid': 'app_uid', + 'folder_key_unencrypted': b'0' * 32, + } + folder = NestedShareFolderNode() + folder.uid = 'nsf_folder' + params.folder_cache[folder.uid] = folder + params.record_cache = {'config_uid': {}} + command = PAMConfigurationEditCommand() + + configuration = vault.TypedRecord(version=6) + configuration.record_uid = 'config_uid' + configuration.type_name = 'pamNetworkConfiguration' + configuration.fields.append(vault.TypedField.new_field( + 'pamResources', {'folderUid': 'legacy_folder'})) + mock_load.return_value = configuration + + def parse_properties(_, record, **kwargs): + field = record.get_typed_field('pamResources') + field.value[0]['folderUid'] = 'nsf_folder' + + with mock.patch.object(command, 'parse_properties', side_effect=parse_properties), \ + mock.patch.object(command, 'verify_required'): + command.execute(params, uid='config_uid') + + mock_update_record.assert_called_once_with(params, configuration, command='pam-config-edit') + mock_place.assert_called_once_with( + params, 'config_uid', 'nsf_folder', command='pam-config-edit') + + @mock.patch('keepercommander.commands.discoveryrotation.place_record_in_folder') + @mock.patch('keepercommander.commands.discoveryrotation.update_pam_record') + @mock.patch('keepercommander.commands.discoveryrotation.RecordEditMixin.get_record_type_fields', + return_value=[]) + @mock.patch('keepercommander.vault.KeeperRecord.load') + def test_execute_places_edited_config_with_backend_aware_helper( + self, mock_load, mock_record_fields, mock_update_record, mock_place): + params = _make_params() + folder = SharedFolderNode() + folder.uid = 'legacy_folder' + params.folder_cache[folder.uid] = folder + params.record_cache = {'config_uid': {}} + command = PAMConfigurationEditCommand() + + configuration = vault.TypedRecord(version=6) + configuration.record_uid = 'config_uid' + configuration.type_name = 'pamNetworkConfiguration' + configuration.fields.append(vault.TypedField.new_field( + 'pamResources', {'folderUid': 'nsf_folder'})) + mock_load.return_value = configuration + + def parse_properties(_, record, **kwargs): + field = record.get_typed_field('pamResources') + field.value[0]['folderUid'] = 'legacy_folder' + + with mock.patch.object(command, 'parse_properties', side_effect=parse_properties), \ + mock.patch.object(command, 'verify_required'): + command.execute(params, uid='config_uid') + + mock_update_record.assert_called_once_with(params, configuration, command='pam-config-edit') + mock_place.assert_called_once_with( + params, 'config_uid', 'legacy_folder', command='pam-config-edit') + + +class TestPamConfigRemoveNsf(unittest.TestCase): + + @mock.patch('keepercommander.vault.KeeperRecord.load') + def test_resolve_pam_config_uid_finds_nsf_record_by_uid(self, mock_load): + params = _make_params() + params.record_cache = {} + params.nested_share_records = { + 'config_uid': {'record_uid': 'config_uid', 'version': 6}, + } + + configuration = vault.TypedRecord(version=6) + configuration.record_uid = 'config_uid' + configuration.type_name = 'pamNetworkConfiguration' + mock_load.return_value = configuration + + uid = PAMConfigurationRemoveCommand._resolve_pam_config_uid(params, 'config_uid') + self.assertEqual(uid, 'config_uid') + + @mock.patch('keepercommander.vault.KeeperRecord.load') + def test_resolve_pam_config_uid_finds_nsf_record_by_title(self, mock_load): + params = _make_params() + params.record_cache = {} + params.nested_share_records = { + 'config_uid': {'record_uid': 'config_uid', 'version': 6}, + } + params.nested_share_record_data = { + 'config_uid': { + 'data_json': { + 'title': 'PAM NSF Test Configuration', + 'type': 'pamNetworkConfiguration', + }, + }, + } + + configuration = vault.TypedRecord(version=6) + configuration.record_uid = 'config_uid' + configuration.type_name = 'pamNetworkConfiguration' + configuration.title = 'PAM NSF Test Configuration' + mock_load.return_value = configuration + + uid = PAMConfigurationRemoveCommand._resolve_pam_config_uid( + params, 'PAM NSF Test Configuration') + self.assertEqual(uid, 'config_uid') + + @mock.patch('keepercommander.commands.pam.config_helper.RecordRemoveCommand') + @mock.patch('keepercommander.commands.nested_share_folder.helpers.is_nested_share_record', + return_value=False) + def test_pam_configuration_remove_uses_legacy_remove_for_non_nsf_record( + self, _mock_is_nsf, mock_remove_cmd): + from keepercommander.commands.pam.config_helper import pam_configuration_remove + + params = _make_params() + params.record_cache = {'config_uid': {}} + params.nested_share_records = {} + + pam_configuration_remove(params, 'config_uid') + + mock_remove_cmd.return_value.execute.assert_called_once_with( + params, record='config_uid', force=True) + self.assertNotIn('config_uid', params.record_cache) + + @mock.patch('keepercommander.nested_share_folder.remove_record_v3', + return_value={'confirmed': True}) + @mock.patch('keepercommander.subfolder.find_folders', return_value=['nsf_folder']) + @mock.patch('keepercommander.commands.nested_share_folder.helpers.is_nested_share_record', + return_value=True) + def test_pam_configuration_remove_uses_nsf_remove_for_nsf_record( + self, _mock_is_nsf, mock_find_folders, mock_remove_v3): + from keepercommander.commands.pam.config_helper import pam_configuration_remove + + params = _make_params() + params.record_cache = {'config_uid': {}} + params.nested_share_records = {'config_uid': {'record_uid': 'config_uid', 'version': 6}} + + pam_configuration_remove(params, 'config_uid') + + mock_remove_v3.assert_called_once_with(params, [{ + 'record_uid': 'config_uid', + 'folder_uid': 'nsf_folder', + 'operation_type': 'owner-trash', + }], dry_run=False) + self.assertNotIn('config_uid', params.record_cache) + self.assertNotIn('config_uid', params.nested_share_records) + + @mock.patch('keepercommander.commands.discoveryrotation.pam_configuration_remove') + @mock.patch('keepercommander.commands.discoveryrotation.TunnelDAG') + @mock.patch('keepercommander.commands.discoveryrotation.get_keeper_tokens', + return_value=(b'encrypted_session', b'encrypted_key', b'transmission_key')) + @mock.patch('keepercommander.vault.KeeperRecord.load') + def test_execute_removes_nsf_config( + self, mock_load, mock_tokens, mock_dag, mock_remove): + params = _make_params() + params.record_cache = {} + params.nested_share_records = { + 'config_uid': {'record_uid': 'config_uid', 'version': 6}, + } + command = PAMConfigurationRemoveCommand() + + configuration = vault.TypedRecord(version=6) + configuration.record_uid = 'config_uid' + configuration.type_name = 'pamNetworkConfiguration' + mock_load.return_value = configuration + mock_dag.return_value.linking_dag.has_graph = False + + command.execute(params, uid='config_uid') + + mock_remove.assert_called_once_with(params, 'config_uid') + self.assertTrue(params.sync_data) + + +class TestPamRotationNsfFolder(unittest.TestCase): + + @mock.patch('keepercommander.commands.discoveryrotation.router_set_record_rotation_information') + @mock.patch('keepercommander.commands.discoveryrotation.TunnelDAG') + @mock.patch('keepercommander.commands.discoveryrotation.get_keeper_tokens', + return_value=(b'encrypted_session', b'encrypted_key', b'transmission_key')) + @mock.patch('keepercommander.commands.discoveryrotation.collect_pam_folder_uids', + return_value={'nsf_folder'}) + @mock.patch('keepercommander.commands.discoveryrotation.records_in_folder', + return_value={'nsf_pam_user_uid'}) + @mock.patch('keepercommander.vault.KeeperRecord.load') + def test_execute_with_nsf_folder_collects_nsf_records( + self, mock_load, mock_records_in_folder, mock_collect_folders, + mock_tokens, mock_dag, _mock_router_set): + from cryptography.hazmat.primitives.asymmetric import ec + + params = _make_params() + params.rest_context.server_key_id = 8 + params.session_token = 'base64_encoded_session_token' + params.record_rotation_cache = {} + params.subfolder_record_cache = {} + params.nested_share_folder_records = {'nsf_folder': {'nsf_pam_user_uid'}} + + pam_user = vault.TypedRecord(version=3) + pam_user.record_uid = 'nsf_pam_user_uid' + pam_user.type_name = 'pamMachine' + pam_user.title = 'PAM NSF Test Machine' + pam_user.record_key = b'\x00' * 32 + mock_load.return_value = pam_user + + mock_dag_instance = mock_dag.return_value + mock_dag_instance.linking_dag.has_graph = True + mock_dag_instance.resource_belongs_to_config.return_value = True + mock_dag_instance.record.record_uid = 'config_uid' + + config_record = vault.TypedRecord(version=6) + config_record.record_uid = 'config_uid' + config_record.type_name = 'pamNetworkConfiguration' + + command = PAMCreateRecordRotationCommand() + with mock.patch('keepercommander.rest_api.SERVER_PUBLIC_KEYS', + {8: ec.generate_private_key(ec.SECP256R1()).public_key()}), \ + mock.patch('keepercommander.vault_extensions.find_records', return_value=[config_record]), \ + mock.patch('keepercommander.commands.discoveryrotation.resolve_pam_record', + side_effect=lambda _p, ident, rec_type=None: config_record if ident == 'config_uid' else pam_user), \ + mock.patch('keepercommander.commands.discoveryrotation.router_set_record_rotation_information'): + command.execute(params, folder_name='M_SR5x7Q4cu9O0-RiLDN4A', force=True, on_demand=True, + config='config_uid') + + mock_collect_folders.assert_called_once_with(params, 'M_SR5x7Q4cu9O0-RiLDN4A') + mock_records_in_folder.assert_called_once_with(params, 'nsf_folder') + mock_load.assert_any_call(params, 'nsf_pam_user_uid') diff --git a/unit-tests/pam/test_pam_project_export.py b/unit-tests/pam/test_pam_project_export.py index 0f7900a5a..f12a37784 100644 --- a/unit-tests/pam/test_pam_project_export.py +++ b/unit-tests/pam/test_pam_project_export.py @@ -22,6 +22,8 @@ import unittest from unittest.mock import patch +import keepercommander.commands.record # noqa: F401 + from keepercommander import vault # ── record builders ──────────────────────────────────────────────────────── @@ -122,8 +124,9 @@ def setUp(self): self.params.record_cache = {uid: {} for uid in _RECORDS} def _execute(self, project_uid=CONFIG_UID, output=None): - """Run execute() with vault.KeeperRecord.load mocked.""" - with patch("keepercommander.vault.KeeperRecord.load", side_effect=_fake_load): + """Run execute() with load_pam_record mocked.""" + with patch("keepercommander.commands.pam_import.export.load_pam_record", + side_effect=_fake_load): with patch.object(self.cmd, "_get_allowed_settings", return_value=dict(_DEFAULT_ALLOWED)): kwargs = {"project_uid": project_uid} @@ -242,12 +245,14 @@ def test_output_flag_writes_file(self): # ── error handling ─────────────────────────────────────────── def test_missing_project_uid_returns_none(self): - with patch("keepercommander.vault.KeeperRecord.load", side_effect=_fake_load): + with patch("keepercommander.commands.pam_import.export.load_pam_record", + side_effect=_fake_load): result = self.cmd.execute(self.params, project_uid="", output=None) self.assertIsNone(result) def test_unknown_uid_returns_none(self): - with patch("keepercommander.vault.KeeperRecord.load", return_value=None): + with patch("keepercommander.commands.pam_import.export.load_pam_record", + return_value=None): result = self.cmd.execute(self.params, project_uid="unknown-uid", output=None) self.assertIsNone(result) @@ -256,7 +261,8 @@ def test_non_v6_record_returns_none(self): v3_rec.type_name = "pamMachine" v3_rec.title = "some" v3_rec.record_uid = "some-uid" - with patch("keepercommander.vault.KeeperRecord.load", return_value=v3_rec): + with patch("keepercommander.commands.pam_import.export.load_pam_record", + return_value=v3_rec): result = self.cmd.execute(self.params, project_uid="some-uid", output=None) self.assertIsNone(result) @@ -334,7 +340,8 @@ def setUp(self): def _execute(self): def _load(_p, uid): return self.records.get(uid) - with patch("keepercommander.vault.KeeperRecord.load", side_effect=_load): + with patch("keepercommander.commands.pam_import.export.load_pam_record", + side_effect=_load): with patch.object(self.cmd, "_get_allowed_settings", return_value=dict(_DEFAULT_ALLOWED)): return self.cmd.execute(self.params, project_uid=self.KCM_CFG) @@ -385,5 +392,86 @@ def test_uid_in_launch_credentials_accepted(self): self.assertEqual(users[0]["uid"], uid_22) +class TestPAMProjectExportNSF(unittest.TestCase): + """Export must resolve records stored only in NSF caches (not record_cache).""" + + NSF_CFG = "nsf-cfg-001" + NSF_MACHINE = "nsf-res-machine" + NSF_USER = "nsf-usr-admin" + + def setUp(self): + from keepercommander.commands.pam_import.export import PAMProjectExportCommand + self.cmd = PAMProjectExportCommand() + self.params = MagicMock() + self.params.record_cache = {} + self.params.nested_share_records = { + self.NSF_CFG: {"record_uid": self.NSF_CFG, "version": 6, "revision": 1}, + self.NSF_MACHINE: {"record_uid": self.NSF_MACHINE, "version": 3, "revision": 1}, + self.NSF_USER: {"record_uid": self.NSF_USER, "version": 3, "revision": 1}, + } + self.params.nested_share_record_data = { + self.NSF_CFG: { + "record_uid": self.NSF_CFG, + "data_json": { + "title": "NSF PAM Project", + "type": "pamNetworkConfiguration", + "fields": [{ + "type": "pamResources", + "value": [{ + "controllerUid": "gw-nsf", + "folderUid": "sf-nsf", + "resourceRef": [self.NSF_MACHINE], + }], + }], + }, + }, + self.NSF_MACHINE: { + "record_uid": self.NSF_MACHINE, + "data_json": { + "title": "NSF Linux Server", + "type": "pamMachine", + "fields": [{ + "type": "pamSettings", + "value": [{ + "connection": { + "userRecords": [self.NSF_USER], + "protocol": "ssh", + }, + }], + }], + }, + }, + self.NSF_USER: { + "record_uid": self.NSF_USER, + "data_json": { + "title": "NSF Admin", + "type": "pamUser", + "fields": [{"type": "login", "value": ["root"]}], + }, + }, + } + + def _execute(self): + with patch.object(self.cmd, "_get_allowed_settings", + return_value=dict(_DEFAULT_ALLOWED)): + return self.cmd.execute(self.params, project_uid=self.NSF_CFG) + + def test_nsf_config_export_succeeds(self): + parsed = json.loads(self._execute()) + self.assertEqual(parsed["project"], "NSF PAM Project") + self.assertEqual(parsed["pam_configuration"]["environment"], "local") + + def test_nsf_resources_and_users_exported(self): + parsed = json.loads(self._execute()) + self.assertEqual(len(parsed["pam_data"]["resources"]), 1) + res = parsed["pam_data"]["resources"][0] + self.assertEqual(res["uid"], self.NSF_MACHINE) + self.assertEqual(res["type"], "pamMachine") + self.assertEqual(len(res["users"]), 1) + self.assertEqual(res["users"][0]["uid"], self.NSF_USER) + self.assertEqual(len(parsed["pam_data"]["users"]), 1) + self.assertEqual(parsed["pam_data"]["users"][0]["login"], "root") + + if __name__ == "__main__": unittest.main() diff --git a/unit-tests/pam/test_pam_project_extend_nsf.py b/unit-tests/pam/test_pam_project_extend_nsf.py new file mode 100644 index 000000000..86dfa7478 --- /dev/null +++ b/unit-tests/pam/test_pam_project_extend_nsf.py @@ -0,0 +1,112 @@ +from types import SimpleNamespace +from unittest.mock import patch + +import keepercommander.commands.record # noqa: F401 + +from keepercommander.commands.pam_import.extend import PAMProjectExtendCommand +from keepercommander.subfolder import BaseFolderNode, NestedShareFolderNode, RootFolderNode + + +def _folder(uid, name, parent_uid=None): + folder = NestedShareFolderNode() + folder.uid = uid + folder.name = name + folder.parent_uid = parent_uid + folder.subfolders = [] + return folder + + +def _params(): + project = _folder('project_nsf', 'NSF Project') + users = _folder('users_nsf', 'NSF Project - Users', 'project_nsf') + resources = _folder('resources_nsf', 'NSF Project - Resources', 'project_nsf') + project.subfolders = ['users_nsf', 'resources_nsf'] + return SimpleNamespace( + data_key=b'1' * 32, + root_folder=RootFolderNode(), + folder_cache={ + 'project_nsf': project, + 'users_nsf': users, + 'resources_nsf': resources, + }, + subfolder_cache={}, + subfolder_record_cache={'users_nsf': {'config_uid'}}, + shared_folder_cache={}, + nested_share_folders={ + 'project_nsf': {'name': 'NSF Project', 'parent_uid': None}, + 'users_nsf': {'name': 'NSF Project - Users', 'parent_uid': 'project_nsf'}, + 'resources_nsf': {'name': 'NSF Project - Resources', 'parent_uid': 'project_nsf'}, + }, + environment_variables={}, + ) + + +def test_get_nsf_project_folders_from_config_folder_siblings(): + params = _params() + + folders = PAMProjectExtendCommand.get_nsf_project_folders(params, 'config_uid') + + assert {x['uid'] for x in folders} == {'users_nsf', 'resources_nsf'} + assert all(x['source'] == 'nested_share_folder' for x in folders) + + +def test_get_app_shared_folders_falls_back_to_nsf_project_folders(): + params = _params() + + with patch('keepercommander.commands.ksm.KSMCommand.get_app_info', + return_value=[]): + folders = PAMProjectExtendCommand().get_app_shared_folders(params, 'ksm_uid', 'config_uid') + + assert {x['uid'] for x in folders} == {'users_nsf', 'resources_nsf'} + + +def test_create_subfolder_uses_nsf_api_under_nsf_parent(): + params = _params() + + with patch('keepercommander.nested_share_folder.folder_api.create_folder_v3', + return_value={'success': True, 'folder_uid': 'child_nsf'}) as create_folder, \ + patch('keepercommander.commands.pam_import.extend.api.sync_down'): + uid = PAMProjectExtendCommand().create_subfolder(params, 'Child', 'users_nsf') + + assert uid == 'child_nsf' + create_folder.assert_called_once_with(params, 'Child', parent_uid='users_nsf') + + +def test_create_subfolder_uses_pre_generated_uid_for_nsf(): + params = _params() + + with patch('keepercommander.commands.pam_import.extend.create_nsf_subfolder', + return_value='pre_generated') as create_sub: + uid = PAMProjectExtendCommand().create_subfolder( + params, 'Child', 'users_nsf', folder_uid='pre_generated') + + assert uid == 'pre_generated' + create_sub.assert_called_once_with(params, 'Child', 'users_nsf', folder_uid='pre_generated') + + +def test_process_folders_creates_new_nsf_folder_paths(): + params = _params() + project = { + 'data': { + 'pam_data': { + 'users': [{'type': 'pamUser', 'title': 'Admin', 'login': 'admin', 'folder_path': 'NSF Project - Users/Admins'}], + 'resources': [], + } + }, + 'options': {'dry_run': False}, + 'ksm_shared_folders': [ + {'uid': 'users_nsf', 'name': 'NSF Project - Users', 'folder_tree': {}}, + {'uid': 'resources_nsf', 'name': 'NSF Project - Resources', 'folder_tree': {}}, + ], + 'folders': {}, + 'error_count': 0, + } + + with patch('keepercommander.commands.pam_import.extend.create_nsf_subfolder', + return_value='admins_nsf') as create_sub, \ + patch('keepercommander.commands.pam_import.extend.api.sync_down'): + folders = PAMProjectExtendCommand().process_folders(params, project) + + create_sub.assert_called_once() + assert folders['path_to_folder_uid']['NSF Project - Users/Admins'] == 'admins_nsf' + assert project['error_count'] == 0 diff --git a/unit-tests/pam/test_pam_project_extend_nsf_helpers.py b/unit-tests/pam/test_pam_project_extend_nsf_helpers.py new file mode 100644 index 000000000..1fd3299d9 --- /dev/null +++ b/unit-tests/pam/test_pam_project_extend_nsf_helpers.py @@ -0,0 +1,114 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' → calls add_client.""" diff --git a/unit-tests/test_nested_share_folder.py b/unit-tests/test_nested_share_folder.py index f946d8b07..a022fb2a0 100644 --- a/unit-tests/test_nested_share_folder.py +++ b/unit-tests/test_nested_share_folder.py @@ -110,11 +110,32 @@ def test_parse_expiration_iso_date(self): def test_parse_expiration_relative(self): from keepercommander.commands.nested_share_folder.helpers import parse_expiration - for unit in ('30d', '24h', '30mi', '6mo', '1y'): + for unit in ('30d', '24h', '30mi', '6mo', '1y', '1mi'): result = parse_expiration(None, unit, 'test') self.assertIsInstance(result, int) self.assertGreater(result, int(time.time() * 1000)) + def test_parse_expiration_one_minute_uses_frozen_now(self): + """``1mi`` must not race against a second clock read (classic share fix).""" + from keepercommander.commands.nested_share_folder.helpers import parse_expiration + result = parse_expiration(None, '1mi', 'test') + self.assertIsInstance(result, int) + self.assertGreater(result, int(time.time() * 1000) + 55_000) + + def test_validate_rotate_on_expiration(self): + from keepercommander.commands.nested_share_folder.helpers import ( + validate_rotate_on_expiration) + + validate_rotate_on_expiration('nsf-share-record', 'grant', 1_900_000_000_000) + with self.assertRaises(CommandError) as ctx: + validate_rotate_on_expiration('nsf-share-record', 'revoke', 1_900_000_000_000) + self.assertIn('only valid when granting', str(ctx.exception)) + with self.assertRaises(CommandError) as ctx: + validate_rotate_on_expiration('nsf-share-folder', 'grant', -1) + self.assertIn('positive', str(ctx.exception)) + with self.assertRaises(CommandError): + validate_rotate_on_expiration('nsf-share-folder', 'grant', None) + def test_parse_expiration_invalid(self): from keepercommander.commands.nested_share_folder.helpers import parse_expiration with self.assertRaises(CommandError): @@ -373,6 +394,33 @@ def test_list_with_data(self): cmd.execute(params, folders=True) cmd.execute(params, records=True) + def test_list_roe_eligible_filters_folders(self): + from keepercommander.commands.nested_share_folder import NestedShareFolderListCommand + eligible_uid, eligible_obj = _make_folder(name='Eligible') + plain_uid, plain_obj = _make_folder(name='Plain') + pam_uid, pam_obj = _make_record(title='PAM User') + params = _make_params( + nested_share_folders={ + eligible_uid: eligible_obj, + plain_uid: plain_obj, + }, + nested_share_folder_records={ + eligible_uid: {pam_uid}, + plain_uid: set(), + }, + nested_share_records={pam_uid: pam_obj}, + nested_share_record_data={ + pam_uid: {'data_json': {'title': 'PAM User', 'type': 'pamUser'}}, + }, + record_rotation_cache={pam_uid: {'record_uid': pam_uid}}, + ) + cmd = NestedShareFolderListCommand() + with mock.patch('keepercommander.commands.base.dump_report_data') as dump: + cmd.execute(params, roe_eligible=True) + rows = dump.call_args[0][0] + uids = {row[1] for row in rows} + self.assertEqual(uids, {eligible_uid}) + class TestNestedShareFolderRecordCommands(TestCase): @@ -869,6 +917,95 @@ def test_share_folder_invite_message_uses_command_prefix(self, mock_grant): self.assertIn('nsf-share-folder: Share invitation has been sent', output) self.assertNotIn("User '", output) + def test_share_record_roe_rejects_non_grant(self): + from keepercommander.commands.nested_share_folder import NestedShareRecordShareCommand + ruid, robj = _make_record() + cmd = NestedShareRecordShareCommand() + with self.assertRaises(CommandError) as ctx: + cmd.execute(_make_params(nested_share_records={ruid: robj}), + record=ruid, email=['user@example.com'], + action='revoke', expire_in='1mi', rotate_on_expiration=True) + self.assertIn('only valid when granting', str(ctx.exception)) + + def test_share_record_roe_rejects_ineligible_record(self): + from keepercommander.commands.nested_share_folder import NestedShareRecordShareCommand + ruid, robj = _make_record() + params = _make_params( + nested_share_records={ruid: robj}, + nested_share_record_data={ruid: {'data_json': {'type': 'login'}}}, + record_rotation_cache={}, + ) + cmd = NestedShareRecordShareCommand() + with self.assertRaises(CommandError) as ctx: + cmd.execute(params, record=ruid, email=['user@example.com'], + action='grant', role='viewer', expire_in='1d', + rotate_on_expiration=True) + self.assertIn('pamUser', str(ctx.exception)) + + @patch('keepercommander.nested_share_folder.record_api.share_record_v3') + def test_share_record_roe_passes_flag(self, mock_share): + import keepercommander.nested_share_folder as nsf + from keepercommander.commands.nested_share_folder import NestedShareRecordShareCommand + + nsf.__dict__.pop('share_record_v3', None) + ruid, robj = _make_record() + mock_share.return_value = { + 'success': True, 'message': '', + 'results': [{'record_uid': ruid, 'success': True, 'message': '', 'pending': False}], + } + params = _make_params( + nested_share_records={ruid: robj}, + nested_share_record_data={ruid: {'data_json': {'type': 'pamUser'}}}, + record_rotation_cache={ruid: {'record_uid': ruid}}, + ) + cmd = NestedShareRecordShareCommand() + with mock.patch('builtins.print'), \ + mock.patch.object(NestedShareRecordShareCommand, '_get_direct_user_share', + return_value=None): + cmd.execute(params, record=ruid, email=['user@example.com'], + action='grant', role='viewer', expire_in='1d', + rotate_on_expiration=True) + self.assertTrue(mock_share.call_args.kwargs.get('rotate_on_expiration')) + + def test_share_folder_roe_rejects_folder_without_pam_user(self): + from keepercommander.commands.nested_share_folder import NestedShareFolderShareCommand + fuid, fobj = _make_folder() + params = _make_params( + nested_share_folders={fuid: fobj}, + nested_share_folder_records={fuid: set()}, + ) + cmd = NestedShareFolderShareCommand() + with self.assertRaises(CommandError) as ctx: + cmd.execute(params, folder=[fuid], user=['user@example.com'], + action='grant', role='viewer', expire_in='1d', + rotate_on_expiration=True) + self.assertIn('pamUser', str(ctx.exception)) + + @patch('keepercommander.nested_share_folder.folder_api.grant_folder_access_v3') + def test_share_folder_roe_passes_flag(self, mock_grant): + import keepercommander.nested_share_folder as nsf + from keepercommander.commands.nested_share_folder import NestedShareFolderShareCommand + + nsf.__dict__.pop('grant_folder_access_v3', None) + fuid, fobj = _make_folder() + ruid, robj = _make_record() + mock_grant.return_value = { + 'success': True, 'message': 'ok', 'folder_uid': fuid, + 'user_uid': 'user@example.com', 'access_type': 'AT_USER', + } + params = _make_params( + nested_share_folders={fuid: fobj}, + nested_share_folder_records={fuid: {ruid}}, + nested_share_records={ruid: robj}, + nested_share_record_data={ruid: {'data_json': {'type': 'pamUser'}}}, + record_rotation_cache={ruid: {'record_uid': ruid}}, + ) + cmd = NestedShareFolderShareCommand() + cmd.execute(params, folder=[fuid], user=['user@example.com'], + action='grant', role='viewer', expire_in='1d', + rotate_on_expiration=True) + self.assertTrue(mock_grant.call_args.kwargs.get('rotate_on_expiration')) + class TestNestedShareFolderFolderApi(TestCase): @@ -958,7 +1095,7 @@ def test_grant_folder_access_update_passes_expiration( mock_update.assert_called_once_with( mock.ANY, fuid, email, role='content-manager', as_team=False, - expiration_timestamp=expiration) + expiration_timestamp=expiration, rotate_on_expiration=False) @patch('keepercommander.nested_share_folder.folder_api.update_folder_access_v3') @patch('keepercommander.nested_share_folder.folder_api._check_existing_access') @@ -984,7 +1121,7 @@ def test_grant_folder_access_same_role_updates_expiration( mock_update.assert_called_once_with( mock.ANY, fuid, email, role='viewer', as_team=False, - expiration_timestamp=expiration) + expiration_timestamp=expiration, rotate_on_expiration=False) @patch('keepercommander.nested_share_folder.folder_api.folder_access_update_v3') @@ -1226,6 +1363,39 @@ def test_update_record_share_v3_recreate_when_setting_expiration( self.assertEqual(perm.rules.tlaProperties.expiration, expiration) self.assertEqual(perm.rules.tlaProperties.timerNotificationType, tla_pb2.NOTIFY_OWNER) + @patch('keepercommander.sync_down.sync_down') + @patch('keepercommander.nested_share_folder.record_api.api.communicate_rest') + @patch('keepercommander.nested_share_folder.record_api.encrypt_for_recipient') + @patch('keepercommander.nested_share_folder.record_api.get_user_public_key') + @patch('keepercommander.nested_share_folder.record_api.get_record_from_cache') + def test_update_record_share_v3_recreate_sets_roe( + self, mock_get_record, mock_get_public_key, + mock_encrypt, mock_communicate, mock_sync_down): + from keepercommander.nested_share_folder.record_api import update_record_share_v3 + from keepercommander.proto import tla_pb2 + + ruid, robj = _make_record() + email = 'user@example.com' + uid_bytes = utils.base64_url_decode(utils.generate_uid()) + mock_get_record.return_value = robj + mock_get_public_key.return_value = (Mock(), False, uid_bytes, False) + mock_encrypt.return_value = b'enc-key' + mock_response = Mock() + mock_response.revokedSharingStatus = [_make_sharing_status(ruid, uid_bytes)] + mock_response.createdSharingStatus = [_make_sharing_status(ruid, uid_bytes)] + mock_communicate.side_effect = [mock_response, mock_response] + + expiration = 1_900_000_000_000 + update_record_share_v3( + _make_params(nested_share_records={ruid: robj}), + ruid, email, access_role_type=1, expiration_timestamp=expiration, + rotate_on_expiration=True) + + create_rq = mock_communicate.call_args_list[1][0][1] + perm = create_rq.createSharingPermissions[0] + self.assertTrue(perm.rules.tlaProperties.rotateOnExpiration) + self.assertEqual(perm.rules.tlaProperties.timerNotificationType, tla_pb2.NOTIFY_OWNER) + @patch('keepercommander.sync_down.sync_down') @patch('keepercommander.nested_share_folder.record_api.api.communicate_rest') @patch('keepercommander.nested_share_folder.record_api.encrypt_for_recipient') @@ -1265,6 +1435,12 @@ def test_is_record_share_update_noop(self): existing, 2, 1_900_001_000_000)) self.assertTrue(is_record_share_update_noop( {'access_role_type': 2}, 2, -1)) + # Applying ROE to a share that does not already have it is not a no-op. + self.assertFalse(is_record_share_update_noop( + existing, 2, 1_900_000_000_500, rotate_on_expiration=True)) + self.assertTrue(is_record_share_update_noop( + {**existing, 'tla_rotate_on_expiration': True}, + 2, 1_900_000_000_500, rotate_on_expiration=True)) @patch('keepercommander.nested_share_folder.record_api.api.communicate_rest') def test_get_record_accesses_v3_reads_tla_expiration(self, mock_communicate): From ac9d788f64ecba910564ca5be1c8cc3e152d4c92 Mon Sep 17 00:00:00 2001 From: sshrushanth-ks Date: Wed, 15 Jul 2026 10:24:29 +0530 Subject: [PATCH 12/19] KC-1346: Fix misleading communication error when assigning team to admin role (#2209) * Fix misleading communication error when assigning team to admin role * fixed unit test file * removed team details from response after warning is displayed --- keepercommander/commands/enterprise.py | 11 +++-- keepercommander/commands/enterprise_common.py | 16 ++++--- tests/test_enterprise_role_team_validation.py | 42 +++++++++++++++++++ 3 files changed, 60 insertions(+), 9 deletions(-) create mode 100644 tests/test_enterprise_role_team_validation.py diff --git a/keepercommander/commands/enterprise.py b/keepercommander/commands/enterprise.py index 110e0d237..512c4fce1 100644 --- a/keepercommander/commands/enterprise.py +++ b/keepercommander/commands/enterprise.py @@ -2384,7 +2384,10 @@ def execute(self, params, **kwargs): elif add_team or remove_team or add_user or remove_user: if add_team or remove_team: - non_batch_update_msgs += self.change_role_teams(params, matched_roles, add_team, remove_team) + team_msgs, blocked_admin = self.change_role_teams(params, matched_roles, add_team, remove_team) + non_batch_update_msgs += team_msgs + if blocked_admin: + skip_display = True if add_user or remove_user: request_batch += self.get_role_users_change_batch( @@ -3499,9 +3502,11 @@ def execute(self, params, **kwargs): request_batch.append(rq) if kwargs.get('add_role') or kwargs.get('remove_role'): - non_batch_update_msgs = self.change_team_roles( + non_batch_update_msgs, blocked_admin = self.change_team_roles( params, matched_teams, kwargs.get('add_role'), kwargs.get('remove_role') ) + if blocked_admin: + has_warnings = True if kwargs.get('add_user') or kwargs.get('remove_user'): users = {} @@ -3705,7 +3710,7 @@ def execute(self, params, **kwargs): created_teams.append(team_data) if created_teams: - role_msgs = self.change_team_roles(params, created_teams, role_list, None) + role_msgs, _ = self.change_team_roles(params, created_teams, role_list, None) for msg in role_msgs: logging.info(msg) elif not has_warnings: diff --git a/keepercommander/commands/enterprise_common.py b/keepercommander/commands/enterprise_common.py index acf5663d8..eb0bba7e5 100644 --- a/keepercommander/commands/enterprise_common.py +++ b/keepercommander/commands/enterprise_common.py @@ -139,6 +139,7 @@ def decrypt_role_key(params, rk): def change_role_teams(params, roles, add_team, remove_team): """Change enterprise role teams""" update_msgs = [] + blocked_admin_assignment = False add_teams = None remove_teams = None team_changes = {} @@ -171,6 +172,7 @@ def change_role_teams(params, roles, add_team, remove_team): if is_managed_role: logging.warning('Teams cannot be assigned to roles with administrative permissions.') + blocked_admin_assignment = True else: role_team = enterprise_pb2.RoleTeam() role_team.role_id = role_id @@ -182,15 +184,16 @@ def change_role_teams(params, roles, add_team, remove_team): else: remove_teams.role_team.append(role_team) update_msgs.append(f"'{role_name}' role removed from team '{team_name}'") - if remove_teams: + if remove_teams and remove_teams.role_team: api.communicate_rest(params, remove_teams, 'enterprise/role_team_remove') - if add_teams: + if add_teams and add_teams.role_team: api.communicate_rest(params, add_teams, 'enterprise/role_team_add') - return update_msgs + return update_msgs, blocked_admin_assignment @staticmethod def change_team_roles(params, teams, add_roles, remove_roles): update_msgs = [] + blocked_admin_assignment = False add_role_teams = None remove_role_teams = None role_changes = {} @@ -224,6 +227,7 @@ def change_team_roles(params, teams, add_roles, remove_roles): if is_managed_role: logging.warning('Teams cannot be assigned to roles with administrative permissions.') + blocked_admin_assignment = True else: for team in teams: if is_add and team['team_uid'] in role_teams: @@ -245,11 +249,11 @@ def change_team_roles(params, teams, add_roles, remove_roles): else: remove_role_teams.role_team.append(role_team) update_msgs.append(f"'{role_name}' role removed from team '{team_name}'") - if remove_role_teams: + if remove_role_teams and remove_role_teams.role_team: api.communicate_rest(params, remove_role_teams, 'enterprise/role_team_remove') - if add_role_teams: + if add_role_teams and add_role_teams.role_team: api.communicate_rest(params, add_role_teams, 'enterprise/role_team_add') - return update_msgs + return update_msgs, blocked_admin_assignment @staticmethod def get_enterprise_id(params): diff --git a/tests/test_enterprise_role_team_validation.py b/tests/test_enterprise_role_team_validation.py new file mode 100644 index 000000000..4214f2950 --- /dev/null +++ b/tests/test_enterprise_role_team_validation.py @@ -0,0 +1,42 @@ +import unittest +from unittest import mock + +from keepercommander.commands.enterprise_common import EnterpriseCommand +from keepercommander.params import KeeperParams + + +class TestEnterpriseRoleTeamValidation(unittest.TestCase): + def test_change_team_roles_skips_api_for_admin_role(self): + params = KeeperParams() + admin_role_id = 894448414228484 + params.enterprise = { + 'roles': [{'role_id': admin_role_id, 'data': {'displayname': 'Admin Role'}}], + 'managed_nodes': [{'role_id': admin_role_id}], + 'role_teams': [], + } + teams = [{'team_uid': '4GjeorSt3FiI2KBhgCKI2Q', 'name': 'Test Team'}] + + with mock.patch('keepercommander.api.communicate_rest') as communicate_rest: + msgs, blocked_admin = EnterpriseCommand.change_team_roles( + params, teams, add_roles=[str(admin_role_id)], remove_roles=None) + + self.assertEqual(msgs, []) + self.assertTrue(blocked_admin) + communicate_rest.assert_not_called() + + def test_change_role_teams_skips_api_for_admin_role(self): + params = KeeperParams() + admin_role_id = 894448414228484 + params.enterprise = { + 'teams': [{'team_uid': '4GjeorSt3FiI2KBhgCKI2Q', 'name': 'Test Team'}], + 'managed_nodes': [{'role_id': admin_role_id}], + } + roles = [{'role_id': admin_role_id, 'data': {'displayname': 'Admin Role'}}] + + with mock.patch('keepercommander.api.communicate_rest') as communicate_rest: + msgs, blocked_admin = EnterpriseCommand.change_role_teams( + params, roles, add_team=['4GjeorSt3FiI2KBhgCKI2Q'], remove_team=None) + + self.assertEqual(msgs, []) + self.assertTrue(blocked_admin) + communicate_rest.assert_not_called() From 6969445cc5c8c0de97e3cf7821326b09411aef94 Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Thu, 16 Jul 2026 20:15:48 +0530 Subject: [PATCH 13/19] Add NSF support to workflow commands --- keepercommander/commands/discoveryrotation.py | 53 ++++++++++--------- .../commands/workflow/approver_commands.py | 3 +- keepercommander/commands/workflow/helpers.py | 41 +++++++++----- unit-tests/pam/test_pam_rotation.py | 22 +++++++- 4 files changed, 76 insertions(+), 43 deletions(-) diff --git a/keepercommander/commands/discoveryrotation.py b/keepercommander/commands/discoveryrotation.py index a6fbb9cc9..fbc045a33 100644 --- a/keepercommander/commands/discoveryrotation.py +++ b/keepercommander/commands/discoveryrotation.py @@ -188,14 +188,30 @@ def parse_schedule_data(kwargs): def resolve_record_rotation_revision(params, record_uid): # type: (KeeperParams, str) -> int - """Return server-assigned rotation revision from cache, syncing when missing (not sequential).""" + """Return server-assigned rotation revision from cache, syncing when missing (not sequential). + + NSF rotations may not yet arrive via sync_down into ``record_rotation_cache``. + """ + cached = params.record_rotation_cache.get(record_uid) + if cached is not None and cached.get('revision') is not None: + return cached['revision'] + + nsf_records = getattr(params, 'nested_share_records', None) + if isinstance(nsf_records, dict): + nsf_rec = nsf_records.get(record_uid) or {} + if isinstance(nsf_rec, dict) and nsf_rec.get('revision') is not None: + return nsf_rec['revision'] + + api.sync_down(params) cached = params.record_rotation_cache.get(record_uid) - if cached is None or cached.get('revision') is None: - api.sync_down(params) - cached = params.record_rotation_cache.get(record_uid) - if cached is None or cached.get('revision') is None: - return 0 - return cached['revision'] + if cached is not None and cached.get('revision') is not None: + return cached['revision'] + + if isinstance(nsf_records, dict): + nsf_rec = nsf_records.get(record_uid) or {} + if isinstance(nsf_rec, dict) and nsf_rec.get('revision') is not None: + return nsf_rec['revision'] + return 0 def schedule_from_pam_config(record_pam_config): @@ -226,18 +242,6 @@ def resolve_record_schedule_data(schedule_data, current_record_rotation, schedul return schedule_from_pam_config(record_pam_config) -def resolve_record_rotation_revision(params, record_uid): - # type: (KeeperParams, str) -> int - """Return server-assigned rotation revision from cache, syncing when missing (not sequential).""" - cached = params.record_rotation_cache.get(record_uid) - if cached is None or cached.get('revision') is None: - api.sync_down(params) - cached = params.record_rotation_cache.get(record_uid) - if cached is None or cached.get('revision') is None: - return 0 - return cached['revision'] - - def refresh_vault_for_schedule_config(params): # type: (KeeperParams) -> None """Sync vault so PAM configuration defaultRotationSchedule is current for --schedule-config.""" @@ -1164,7 +1168,7 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None noop_rotation = True rq = router_pb2.RouterRecordRotationRequest() - rq.revision = current_record_rotation.get('revision', 0) + rq.revision = resolve_record_rotation_revision(params, target_record.record_uid) rq.recordUid = utils.base64_url_decode(target_record.record_uid) rq.configurationUid = utils.base64_url_decode(record_config_uid) rq.resourceUid = utils.base64_url_decode(record_resource_uid) if record_resource_uid else b'' @@ -1395,8 +1399,7 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None # 6. Construct Request object rq = router_pb2.RouterRecordRotationRequest() - if current_record_rotation: - rq.revision = current_record_rotation.get('revision', 0) + rq.revision = resolve_record_rotation_revision(params, target_record.record_uid) rq.recordUid = utils.base64_url_decode(target_record.record_uid) rq.configurationUid = utils.base64_url_decode(record_config_uid) rq.resourceUid = utils.base64_url_decode(record_resource_uid) if record_resource_uid else b'' @@ -1751,12 +1754,10 @@ def execute(self, params, **kwargs): row.append(f'{schedule_str}') # Controller Info - - enterprise_controllers_connected = router_get_connected_gateways(params) connected_controller = None - if enterprise_controllers_connected and controller_details: + if enterprise_controllers_connected_resp and controller_details: router_controllers = {controller.controllerUid: controller for controller in - list(enterprise_controllers_connected.controllers)} + list(enterprise_controllers_connected_resp.controllers)} connected_controller = router_controllers.get(controller_details.controllerUid) if connected_controller: diff --git a/keepercommander/commands/workflow/approver_commands.py b/keepercommander/commands/workflow/approver_commands.py index 644bc2296..ff0b5d796 100644 --- a/keepercommander/commands/workflow/approver_commands.py +++ b/keepercommander/commands/workflow/approver_commands.py @@ -105,7 +105,8 @@ def _extract_param(wf, key): def _decrypt_param(params, record_uid, encrypted_bytes): if not encrypted_bytes: return None - record_key = params.record_cache.get(record_uid, {}).get('record_key_unencrypted') + from ...nested_share_folder.common import get_record_key + record_key = get_record_key(params, record_uid, raise_on_missing=False) if not record_key: return 'No permission to view. Only users with record access can view this information.' try: diff --git a/keepercommander/commands/workflow/helpers.py b/keepercommander/commands/workflow/helpers.py index 2851c9c46..96e49c4fd 100644 --- a/keepercommander/commands/workflow/helpers.py +++ b/keepercommander/commands/workflow/helpers.py @@ -249,12 +249,15 @@ def submit_access_request(params: KeeperParams, record_uid: str, record = vault.KeeperRecord.load(params, record_uid) record_name = record.title if record else record_uid - record_key = params.record_cache.get(record_uid, {}).get('record_key_unencrypted') - if not record_key and (reason or ticket): - raise CommandError( - '', 'Record key not available — cannot encrypt reason/ticket. ' - 'You do not have sufficient access to this record to send encrypted parameters.', - ) + record_key = None + if reason or ticket: + from ...nested_share_folder.common import get_record_key + record_key = get_record_key(params, record_uid, raise_on_missing=False) + if not record_key: + raise CommandError( + '', 'Record key not available — cannot encrypt reason/ticket. ' + 'You do not have sufficient access to this record to send encrypted parameters.', + ) access_request = workflow_pb2.WorkflowAccessRequest() access_request.resource.CopyFrom(ProtobufRefBuilder.record_ref(record_uid_bytes, record_name)) @@ -302,12 +305,16 @@ class RecordResolver: @staticmethod def resolve(params, record_input, allow_missing=False): - if record_input in params.record_cache: - return record_input, vault.KeeperRecord.load(params, record_input) - for uid in params.record_cache: - rec = vault.KeeperRecord.load(params, uid) - if rec and rec.title == record_input: - return uid, rec + from ..pam.vault_target import resolve_pam_record + + if not record_input: + if allow_missing: + return None, None + raise CommandError('', 'Record is required') + + rec = resolve_pam_record(params, record_input) + if rec: + return rec.record_uid, rec if allow_missing: return None, None raise CommandError('', f'Record "{record_input}" not found') @@ -327,8 +334,10 @@ def validate_workflow_record_type(record): @staticmethod def get_uid_bytes(params: KeeperParams, record_uid: str) -> bytes: + from ..pam.vault_target import record_exists_in_vault + uid_bytes = utils.base64_url_decode(record_uid) - if record_uid not in params.record_cache: + if not record_exists_in_vault(params, record_uid): raise CommandError('', f'Record {record_uid} not found') return uid_bytes @@ -339,7 +348,11 @@ def resolve_name(params, resource_ref) -> str: if resource_ref.value: rec_uid = utils.base64_url_encode(resource_ref.value) rec = vault.KeeperRecord.load(params, rec_uid) - return rec.title if rec else '' + if rec: + return rec.title + from ..pam.vault_target import get_vault_record_title_type + title, _ = get_vault_record_title_type(params, rec_uid) + return title if title and title != '[record inaccessible]' else '' return '' @staticmethod diff --git a/unit-tests/pam/test_pam_rotation.py b/unit-tests/pam/test_pam_rotation.py index 60adc8a29..79f723e88 100644 --- a/unit-tests/pam/test_pam_rotation.py +++ b/unit-tests/pam/test_pam_rotation.py @@ -198,6 +198,7 @@ def test_execute_with_valid_record(self, mock_TunnelDAG, mock_load): self.assertTrue(mock_TunnelDAG.called) self.assertEqual(mock_typed_record.record_key, b'\x00' * 16) + @patch('keepercommander.commands.discoveryrotation.api.sync_down') @patch('keepercommander.commands.discoveryrotation.router_set_record_rotation_information') @patch('keepercommander.vault_extensions.find_records') @patch('keepercommander.commands.discoveryrotation.resolve_pam_record') @@ -207,13 +208,14 @@ def test_execute_with_valid_record(self, mock_TunnelDAG, mock_load): @patch('keepercommander.rest_api.SERVER_PUBLIC_KEYS', {8: ec.generate_private_key(ec.SECP256R1()).public_key()}) def test_execute_pam_user_with_config_no_resource(self, mock_TunnelDAG, mock_get_keeper_tokens, mock_load, mock_resolve_pam_record, mock_find_records, - mock_router_set_rotation): + mock_router_set_rotation, mock_sync_down): record_uid = 'OYNvVgpPPJBrVfYOIRtdag' config_uid = 'bU2LVM6LjX_hmCoSMDA7vg' resource_uid = 'dU2LVM6LjX_hmCoSMDA7vx' mock_params, mock_typed_record = create_mock_params_and_record() mock_params.record_rotation_cache = {} + mock_params.nested_share_records = {} mock_typed_record.record_uid = record_uid mock_load.return_value = mock_typed_record @@ -254,6 +256,7 @@ def test_execute_pam_user_with_config_no_resource(self, mock_TunnelDAG, mock_get mock_config_dag.link_resource_to_config.assert_called_once_with(resource_uid) mock_config_dag.link_user_to_resource.assert_called_once_with(record_uid, resource_uid, belongs_to=True) self.assertTrue(mock_router_set_rotation.called) + mock_sync_down.assert_called() config_call = mock_TunnelDAG.call_args_list[1] self.assertTrue(config_call.kwargs.get('is_config')) @@ -976,6 +979,7 @@ class TestResolveRecordRotationRevision(unittest.TestCase): def test_returns_cached_revision_without_sync(self): params = MagicMock() params.record_rotation_cache = {'record_uid': {'revision': 42}} + params.nested_share_records = {} with patch('keepercommander.commands.discoveryrotation.api.sync_down') as sync_down: revision = resolve_record_rotation_revision(params, 'record_uid') self.assertEqual(revision, 42) @@ -984,6 +988,7 @@ def test_returns_cached_revision_without_sync(self): def test_syncs_when_revision_missing_from_cache(self): params = MagicMock() params.record_rotation_cache = {} + params.nested_share_records = {} def _sync(p): params.record_rotation_cache['record_uid'] = {'revision': 17} @@ -995,21 +1000,32 @@ def _sync(p): def test_returns_zero_when_no_rotation_after_sync(self): params = MagicMock() params.record_rotation_cache = {} + params.nested_share_records = {} with patch('keepercommander.commands.discoveryrotation.api.sync_down'): revision = resolve_record_rotation_revision(params, 'record_uid') self.assertEqual(revision, 0) + def test_uses_nsf_record_revision_when_rotation_cache_empty(self): + params = MagicMock() + params.record_rotation_cache = {} + params.nested_share_records = {'nsf_uid': {'revision': 9, 'record_uid': 'nsf_uid'}} + with patch('keepercommander.commands.discoveryrotation.api.sync_down') as sync_down: + revision = resolve_record_rotation_revision(params, 'nsf_uid') + self.assertEqual(revision, 9) + sync_down.assert_not_called() + USER_UID = 'AAAAAAAAAAAAAAAAAAAAAA' CONFIG_UID = 'BBBBBBBBBBBBBBBBBBBBBB' CRON_SCHEDULE_JSON = '{"type":"CRON","cron":"0 0 3 ? * 2","tz":"Etc/UTC"}' -class TestResolveRecordRotationRevision(unittest.TestCase): +class TestResolveRecordRotationRevisionIam(unittest.TestCase): def test_returns_cached_revision_without_sync(self): params = MagicMock() params.record_rotation_cache = {USER_UID: {'revision': 42}} + params.nested_share_records = {} with patch('keepercommander.commands.discoveryrotation.api.sync_down') as sync_down: revision = resolve_record_rotation_revision(params, USER_UID) self.assertEqual(revision, 42) @@ -1018,6 +1034,7 @@ def test_returns_cached_revision_without_sync(self): def test_syncs_when_revision_missing_from_cache(self): params = MagicMock() params.record_rotation_cache = {} + params.nested_share_records = {} def _sync(p): params.record_rotation_cache[USER_UID] = {'revision': 17} @@ -1029,6 +1046,7 @@ def _sync(p): def test_returns_zero_when_no_rotation_after_sync(self): params = MagicMock() params.record_rotation_cache = {} + params.nested_share_records = {} with patch('keepercommander.commands.discoveryrotation.api.sync_down'): revision = resolve_record_rotation_revision(params, USER_UID) self.assertEqual(revision, 0) From fe863191390c81e3b7b04bb1de26394074c6c79d Mon Sep 17 00:00:00 2001 From: amangalampalli-ks Date: Thu, 16 Jul 2026 20:52:08 +0530 Subject: [PATCH 14/19] Add Slack multi-channel approvals setup and slack record sync-down (#2219) (#2222) * Implement multi channel flow for slack-app-setup command * Add default channel prompt * Add list-team in command list * Make default channel mandatory for multi channel * Add --sync-down for Slack multi-channel approvals config * Refactor slack-app changes --- .../commands/integrations/approvals_setup.py | 361 ++++++++++++++++++ .../commands/integrations/approvals_sync.py | 252 ++++++++++++ .../integrations/integration_setup_base.py | 169 +++++++- .../commands/integrations/slack_app_setup.py | 29 +- .../commands/integrations/teams_app_setup.py | 2 +- .../service/commands/service_docker_setup.py | 18 +- keepercommander/service/docker/__init__.py | 7 +- keepercommander/service/docker/models.py | 25 +- keepercommander/service/docker/printer.py | 3 +- keepercommander/service/docker/setup_base.py | 18 + 10 files changed, 845 insertions(+), 39 deletions(-) create mode 100644 keepercommander/service/commands/integrations/approvals_setup.py create mode 100644 keepercommander/service/commands/integrations/approvals_sync.py diff --git a/keepercommander/service/commands/integrations/approvals_setup.py b/keepercommander/service/commands/integrations/approvals_setup.py new file mode 100644 index 000000000..50e4d0fc3 --- /dev/null +++ b/keepercommander/service/commands/integrations/approvals_setup.py @@ -0,0 +1,361 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bool: + if not value or not value.strip(): + return False + try: + return len(utils.base64_url_decode(value.strip())) == 16 + except Exception: + return False + + +def parse_comma_separated_uids(raw: str) -> List[str]: + if not raw or not raw.strip(): + return [] + return list(dict.fromkeys(part.strip() for part in raw.split(',') if part.strip())) + + +def build_team_lookup( + params: 'KeeperParams', +) -> Tuple[Dict[str, Tuple[str, str]], Dict[str, List[Tuple[str, str]]]]: + """Build team UID and case-insensitive name lookups from cached vault data.""" + if params.available_team_cache is None: + try: + api.load_available_teams(params) + except Exception as exc: + logger.debug('Could not load available teams: %s', exc) + + by_uid: Dict[str, Tuple[str, str]] = {} + by_name_lower: Dict[str, List[Tuple[str, str]]] = defaultdict(list) + + def add_team(team_uid: str, team_name: str) -> None: + if not team_uid: + return + name = team_name or team_uid + if team_uid not in by_uid: + by_uid[team_uid] = (team_uid, name) + by_name_lower[name.casefold()].append((team_uid, name)) + + for team_uid, team_data in params.team_cache.items(): + add_team(team_uid, team_data.get('name', '')) + + enterprise = params.enterprise or {} + for source_key in ('teams', 'queued_teams'): + for team in enterprise.get(source_key, []): + add_team(team.get('team_uid', ''), team.get('name', '')) + + for team in params.available_team_cache or []: + add_team(team.get('team_uid', ''), team.get('team_name', '')) + + return by_uid, by_name_lower + + +def build_shared_folder_uids(params: 'KeeperParams') -> Set[str]: + """Shared folders and nested share folders only (not private user folders).""" + uids = set(params.shared_folder_cache.keys()) + uids.update(getattr(params, 'nested_share_folders', {}).keys()) + uids.discard('') + return uids + + +def build_record_uids(params: 'KeeperParams') -> Set[str]: + uids = set(params.record_cache.keys()) + uids.update(getattr(params, 'nested_share_records', {}).keys()) + return uids + + +def _resolve_keeper_team( + team_input: str, + by_uid: Dict[str, Tuple[str, str]], + by_name_lower: Dict[str, List[Tuple[str, str]]], +) -> Tuple[Optional[Tuple[str, str]], Optional[str]]: + value = team_input.strip() + if not value: + return None, 'Team name or UID is required' + + if value in by_uid: + return by_uid[value], None + + matches = list({(uid, name) for uid, name in by_name_lower.get(value.casefold(), [])}) + if len(matches) == 1: + return matches[0], None + if len(matches) > 1: + return None, f'Team name "{value}" is not unique. Use the team UID.' + return None, f'Team "{value}" not found. Use a valid team name or team UID.' + + +def _prompt_keeper_team( + by_uid: Dict[str, Tuple[str, str]], + by_name_lower: Dict[str, List[Tuple[str, str]]], +) -> Tuple[str, str]: + print(f"\n{bcolors.BOLD}TEAM:{bcolors.ENDC}") + print(f" Keeper team name or team UID for this approver group") + while True: + value = input(f"{bcolors.OKBLUE}Team name or UID:{bcolors.ENDC} ").strip() + resolved, error = _resolve_keeper_team(value, by_uid, by_name_lower) + if resolved: + return resolved + print(f"{bcolors.FAIL}Error: {error}{bcolors.ENDC}") + + +def _validate_uids( + uids: List[str], + known_uids: Set[str], + uid_label: str, + mistyped: Optional[Dict[str, Set[str]]] = None, +) -> List[str]: + """Return validation errors for UIDs. mistyped maps error message -> UID set.""" + errors: List[str] = [] + mistyped = mistyped or {} + for uid in uids: + if uid in known_uids: + continue + typed_error = next((msg for msg, bad_uids in mistyped.items() if uid in bad_uids), None) + if typed_error: + errors.append(typed_error.format(uid=uid)) + else: + errors.append( + f'{uid_label} UID "{uid}" not found in your vault ' + f'(run "sync-down" if it was recently added)' + ) + return errors + + +def _prompt_vault_uid_list( + header: str, + description: str, + prompt_label: str, + known_uids: Set[str], + uid_label: str, + mistyped: Optional[Dict[str, Set[str]]] = None, +) -> List[str]: + print(f"\n{bcolors.BOLD}{header}:{bcolors.ENDC}") + print(f" {description}") + while True: + value = input(f"{bcolors.OKBLUE}{prompt_label}{bcolors.ENDC} ").strip() + if not value: + return [] + uids = parse_comma_separated_uids(value) + if not uids or not all(is_valid_keeper_uid(uid) for uid in uids): + print(f"{bcolors.FAIL}Error: Each UID must be a valid Keeper {uid_label} UID{bcolors.ENDC}") + continue + errors = _validate_uids(uids, known_uids, uid_label, mistyped=mistyped) + if errors: + for error in errors: + print(f"{bcolors.FAIL}Error: {error}{bcolors.ENDC}") + continue + return uids + + +def _prompt_channel( + profile: ApprovalsChannelProfile, + prompt_with_validation: Callable[[str, Callable[[str], bool], str], str], + description: str, + header: Optional[str] = None, +) -> str: + print(f"\n{bcolors.BOLD}{header or profile.channel_header}:{bcolors.ENDC}") + print(f" {description}") + return prompt_with_validation( + profile.channel_prompt, + profile.validate_channel, + profile.channel_error, + ) + + +def collect_approvals_config( + params: 'KeeperParams', + prompt_yes_no: Callable[[str, bool], bool], + prompt_with_validation: Callable[[str, Callable[[str], bool], str], str], + profile: ApprovalsChannelProfile, +) -> ApprovalsConfig: + """Collect single- or multi-channel approval routing configuration.""" + print(f"\n{bcolors.BOLD}MULTI-CHANNEL APPROVERS:{bcolors.ENDC}") + print(f" Route approval notifications to different channels by approver team") + multi_channel = prompt_yes_no('Enable multi-channel approvers?', default=False) + + if not multi_channel: + return ApprovalsConfig( + multi_channel_enabled=False, + single_channel_id=_prompt_channel( + profile, prompt_with_validation, profile.single_channel_description, + ), + ) + + teams: List[ApproverTeam] = [] + by_uid, by_name_lower = build_team_lookup(params) + while True: + team_uid, team_name = _prompt_keeper_team(by_uid, by_name_lower) + if any(team.team_uid == team_uid for team in teams): + print(f"{bcolors.FAIL}Error: Team \"{team_name}\" is already configured{bcolors.ENDC}") + continue + channel_id = _prompt_channel( + profile, + prompt_with_validation, + f'{profile.channel_description} for {team_name}', + ) + teams.append(ApproverTeam( + team_uid=team_uid, + name=team_name, + channel_id=channel_id, + )) + if not prompt_yes_no('Add another approver team?', default=False): + break + + print(f"\n{bcolors.BOLD}FOLDER/RECORD BOUNDARIES (optional):{bcolors.ENDC}") + print(f" Restrict each team to specific shared folder or record UIDs") + if prompt_yes_no('Specify shared folder or record UIDs per team?', default=False): + teams = _collect_team_boundaries(params, teams) + + return ApprovalsConfig( + multi_channel_enabled=True, + single_channel_id=_prompt_channel( + profile, + prompt_with_validation, + profile.default_channel_description, + header=f'DEFAULT {profile.channel_header}', + ), + teams=teams, + ) + + +def _collect_team_boundaries( + params: 'KeeperParams', + teams: List[ApproverTeam], +) -> List[ApproverTeam]: + shared_folder_uids = build_shared_folder_uids(params) + record_uids = build_record_uids(params) + user_folder_uids = ( + set(params.folder_cache.keys()) | set(params.subfolder_cache.keys()) + ) - shared_folder_uids - {''} + + updated: List[ApproverTeam] = [] + for team in teams: + folders = _prompt_vault_uid_list( + f'SHARED FOLDER UIDs FOR {team.name}', + 'Comma-separated shared folder UIDs (optional, press Enter to skip)', + 'Shared Folder UIDs:', + shared_folder_uids, + 'shared folder', + mistyped={ + '"{uid}" is a record UID, not a shared folder UID': record_uids, + '"{uid}" is not a shared folder UID (use a shared folder UID from list-sf)': user_folder_uids, + }, + ) + records = _prompt_vault_uid_list( + f'RECORD UIDs FOR {team.name}', + 'Comma-separated record UIDs (optional, press Enter to skip)', + 'Record UIDs:', + record_uids, + 'record', + mistyped={ + '"{uid}" is a shared folder UID, not a record UID': shared_folder_uids, + }, + ) + updated.append(replace(team, folder_uids=folders, record_uids=records)) + return updated + + +def approvals_config_to_record_fields(config: ApprovalsConfig) -> List: + teams_json = '' + if config.multi_channel_enabled: + teams_json = json.dumps([ + { + 'team_uid': team.team_uid, + 'name': team.name, + 'channel_id': team.channel_id, + 'folder_uids': team.folder_uids, + 'record_uids': team.record_uids, + } + for team in config.teams + ], indent=2) + + return [ + vault.TypedField.new_field( + 'text', + 'true' if config.multi_channel_enabled else 'false', + FIELD_MULTI_CHANNEL_ENABLED, + ), + vault.TypedField.new_field( + 'text', + config.single_channel_id, + FIELD_APPROVALS_CHANNEL_ID, + ), + vault.TypedField.new_field('multiline', teams_json, FIELD_APPROVALS_TEAMS), + ] + + +def print_approvals_config(config: ApprovalsConfig) -> None: + if not config.multi_channel_enabled: + print(f" • Approvals Channel: {bcolors.OKBLUE}{config.single_channel_id}{bcolors.ENDC}") + return + + print(f" • Multi-Channel Approvers: {bcolors.OKBLUE}enabled ({len(config.teams)} teams){bcolors.ENDC}") + print(f" • Default Approvals Channel: {bcolors.OKBLUE}{config.single_channel_id}{bcolors.ENDC}") + for team in config.teams: + print(f" • {team.name} ({team.team_uid}): channel {bcolors.OKBLUE}{team.channel_id}{bcolors.ENDC}") + if team.folder_uids: + print(f" Shared Folder UIDs: {', '.join(team.folder_uids)}") + if team.record_uids: + print(f" Record UIDs: {', '.join(team.record_uids)}") diff --git a/keepercommander/service/commands/integrations/approvals_sync.py b/keepercommander/service/commands/integrations/approvals_sync.py new file mode 100644 index 000000000..e82045365 --- /dev/null +++ b/keepercommander/service/commands/integrations/approvals_sync.py @@ -0,0 +1,252 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' bool: + return bool( + self.removed + or self.renamed_to + or self.removed_folder_uids + or self.removed_record_uids + ) + + +@dataclass +class ApprovalsDriftReport: + entries: List[TeamDriftEntry] = field(default_factory=list) + + @property + def has_changes(self) -> bool: + return any(entry.has_changes for entry in self.entries) + + +def custom_fields_by_label(record: vault.KeeperRecord) -> Dict[str, str]: + result: Dict[str, str] = {} + for custom in getattr(record, 'custom', None) or []: + value = custom.get_default_value() + if value is not None: + result[custom.label] = str(value) + return result + + +def parse_approvals_from_record( + record: vault.KeeperRecord, + command_name: str = '', +) -> ApprovalsConfig: + fields = custom_fields_by_label(record) + multi_channel = fields.get(FIELD_MULTI_CHANNEL_ENABLED, 'false').strip().lower() == 'true' + single_channel_id = fields.get(FIELD_APPROVALS_CHANNEL_ID, '').strip() + teams_json = fields.get(FIELD_APPROVALS_TEAMS, '').strip() + + teams: List[ApproverTeam] = [] + if teams_json: + try: + raw_teams = json.loads(teams_json) + except json.JSONDecodeError as exc: + raise CommandError(command_name, f'Invalid {FIELD_APPROVALS_TEAMS} JSON: {exc}') from exc + if not isinstance(raw_teams, list): + raise CommandError(command_name, f'{FIELD_APPROVALS_TEAMS} must be a JSON array') + + seen_team_uids = set() + for item in raw_teams: + if not isinstance(item, dict): + continue + team_uid = str(item.get('team_uid', '')).strip() + channel_id = str(item.get('channel_id', '')).strip() + if not team_uid or not channel_id or team_uid in seen_team_uids: + continue + seen_team_uids.add(team_uid) + teams.append(ApproverTeam( + team_uid=team_uid, + name=str(item.get('name', '')).strip() or team_uid, + channel_id=channel_id, + folder_uids=list(dict.fromkeys( + str(u).strip() for u in (item.get('folder_uids') or []) if str(u).strip() + )), + record_uids=list(dict.fromkeys( + str(u).strip() for u in (item.get('record_uids') or []) if str(u).strip() + )), + )) + + return ApprovalsConfig( + multi_channel_enabled=multi_channel, + single_channel_id=single_channel_id, + teams=teams, + ) + + +def merge_approvals_custom_fields(existing_custom: List, config: ApprovalsConfig) -> List: + preserved = [f for f in (existing_custom or []) if f.label not in APPROVALS_FIELD_LABELS] + return preserved + approvals_config_to_record_fields(config) + + +def analyze_approvals_drift( + params: 'KeeperParams', + config: ApprovalsConfig, +) -> Tuple[ApprovalsConfig, ApprovalsDriftReport]: + by_uid, _ = build_team_lookup(params) + known_folders = build_shared_folder_uids(params) + known_records = build_record_uids(params) + + report = ApprovalsDriftReport() + cleaned_teams: List[ApproverTeam] = [] + + for team in config.teams: + if team.team_uid not in by_uid: + report.entries.append(TeamDriftEntry(team=team, removed=True)) + continue + + current_name = by_uid[team.team_uid][1] or team.name + removed_folders = [uid for uid in team.folder_uids if uid not in known_folders] + removed_records = [uid for uid in team.record_uids if uid not in known_records] + renamed_to = current_name if current_name != team.name else None + + entry = TeamDriftEntry( + team=team, + renamed_to=renamed_to, + removed_folder_uids=removed_folders, + removed_record_uids=removed_records, + ) + if entry.has_changes: + report.entries.append(entry) + + cleaned_teams.append(replace( + team, + name=current_name, + folder_uids=[uid for uid in team.folder_uids if uid in known_folders], + record_uids=[uid for uid in team.record_uids if uid in known_records], + )) + + cleaned = ApprovalsConfig( + multi_channel_enabled=config.multi_channel_enabled, + single_channel_id=config.single_channel_id, + teams=cleaned_teams, + ) + return cleaned, report + + +def print_approvals_drift_report(report: ApprovalsDriftReport) -> None: + print(f"\n{bcolors.BOLD}Vault sync changes:{bcolors.ENDC}") + if not report.has_changes: + print(f" {bcolors.OKGREEN}No removed or renamed approver references found.{bcolors.ENDC}") + return + + for entry in report.entries: + team = entry.team + label = f'{team.name} ({team.team_uid})' + if entry.removed: + print(f" {bcolors.FAIL}• Removed team (no longer in vault): {label}{bcolors.ENDC}") + continue + if entry.renamed_to: + print( + f" {bcolors.OKBLUE}• Team renamed: {team.name} → {entry.renamed_to} " + f'({team.team_uid}){bcolors.ENDC}' + ) + for folder_uid in entry.removed_folder_uids: + print(f" {bcolors.FAIL}• Removed shared folder UID for {label}: {folder_uid}{bcolors.ENDC}") + for record_uid in entry.removed_record_uids: + print(f" {bcolors.FAIL}• Removed record UID for {label}: {record_uid}{bcolors.ENDC}") + + +def sync_approvals_config( + params: 'KeeperParams', + config: ApprovalsConfig, + command_name: str = '', +) -> Tuple[ApprovalsConfig, ApprovalsDriftReport]: + """Remove stale vault references and refresh team names. Non-interactive.""" + if not config.multi_channel_enabled: + raise CommandError( + command_name, + 'Single-channel mode has no approver team mappings to sync. ' + 'Re-run setup or enable multi-channel approvers first.', + ) + + cleaned, report = analyze_approvals_drift(params, config) + print_approvals_drift_report(report) + + if not cleaned.teams and config.teams: + print( + f" {bcolors.OKBLUE}All configured approver teams were removed. " + f'Requests will use the default approvals channel until teams are added again.{bcolors.ENDC}' + ) + + return cleaned, report + + +def run_approvals_sync_down( + params: 'KeeperParams', + record_uid: str, + marker_field: str, + update_record: Callable[[str, ApprovalsConfig], None], + command_name: str = '', + sync_vault: bool = True, +) -> ApprovalsConfig: + if sync_vault: + params.sync_data = True + api.sync_down(params) + + record = vault.KeeperRecord.load(params, record_uid) + if not record: + raise CommandError(command_name, f'Record not found: {record_uid}') + + fields = custom_fields_by_label(record) + if marker_field not in fields: + raise CommandError( + command_name, + f'Record {record_uid} is not a supported integration config record ' + f'(missing "{marker_field}" field)', + ) + + config = parse_approvals_from_record(record, command_name=command_name) + updated, report = sync_approvals_config(params, config, command_name=command_name) + + if report.has_changes: + update_record(record_uid, updated) + print(f"\n{bcolors.OKGREEN}{bcolors.BOLD}✓ Approver configuration synced with vault{bcolors.ENDC}") + else: + print(f"\n{bcolors.OKGREEN}{bcolors.BOLD}✓ Approver configuration is already up to date{bcolors.ENDC}") + + print_approvals_config(updated) + return updated diff --git a/keepercommander/service/commands/integrations/integration_setup_base.py b/keepercommander/service/commands/integrations/integration_setup_base.py index 504e6b1df..55da36f5b 100644 --- a/keepercommander/service/commands/integrations/integration_setup_base.py +++ b/keepercommander/service/commands/integrations/integration_setup_base.py @@ -16,7 +16,7 @@ import re from abc import ABC, abstractmethod from dataclasses import asdict -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List, Optional, Tuple from ....commands.base import Command, raise_parse_exception, suppress_exit from ....display import bcolors @@ -26,8 +26,10 @@ from ...config.config_validation import ConfigValidator, ValidationError from ...docker import ( SetupResult, DockerSetupPrinter, DockerSetupConstants, - ServiceConfig, DockerComposeBuilder, DockerSetupBase + ServiceConfig, DockerComposeBuilder, DockerSetupBase, ApprovalsConfig, ) +from .approvals_setup import ApprovalsChannelProfile, collect_approvals_config, is_valid_keeper_uid +from .approvals_sync import merge_approvals_custom_fields, run_approvals_sync_down UUID_PATTERN = re.compile( r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' @@ -48,7 +50,7 @@ def get_integration_name(self) -> str: """e.g. 'Slack', 'Teams' -- drives all naming conventions.""" @abstractmethod - def collect_integration_config(self) -> Any: + def collect_integration_config(self, params) -> Any: """Prompt user for config values, return a config dataclass.""" @abstractmethod @@ -63,6 +65,14 @@ def print_integration_specific_resources(self, config) -> None: def print_integration_commands(self) -> None: """Print available bot commands for this integration.""" + def get_approvals_profile(self) -> Optional[ApprovalsChannelProfile]: + """Return approvals profile when this integration supports multi-channel approvers.""" + return None + + def get_integration_config_marker_field(self) -> str: + """Custom field label that identifies this integration's config record.""" + return f'{self.get_integration_name().lower()}_app_token' + # -- Convention defaults (derived from name, override if needed) - def get_command_name(self) -> str: @@ -97,7 +107,15 @@ def get_commander_container_name(self) -> str: return f'keeper-service-{self.get_integration_name().lower()}' def get_service_commands(self) -> str: - return 'search,share-record,nsf-share-record,share-folder,nsf-share-folder,share-report,record-add,nsf-record-add,one-time-share,epm,pedm,device-approve,get,tree,server,sync-down,list-sf' + commands = ( + 'search,share-record,nsf-share-record,share-folder,nsf-share-folder,share-report,' + 'record-add,nsf-record-add,one-time-share,epm,pedm,device-approve,get,tree,server,' + 'sync-down,list-sf,list-team' + ) + # Allow service API callers to run --sync-down for multi-channel approvals configs. + if self.get_approvals_profile() is not None: + commands = f'{commands},{self.get_command_name()}' + return commands # -- Parser (auto-built from name, cached per subclass) ---------- @@ -138,7 +156,7 @@ def _build_parser(self) -> argparse.ArgumentParser: ) parser.add_argument( '--config-path', dest='config_path', type=str, - help='Path to config.json file (default: ~/.keeper/config.json)' + help='Path to config.json file (default: active session config file)' ) parser.add_argument( '--timeout', dest='timeout', type=str, default=DockerSetupConstants.DEFAULT_TIMEOUT, @@ -148,6 +166,22 @@ def _build_parser(self) -> argparse.ArgumentParser: '--skip-device-setup', dest='skip_device_setup', action='store_true', help='Skip device registration and setup if already configured' ) + if self.get_approvals_profile() is not None: + parser.add_argument( + '--sync-down', dest='sync_down', action='store_true', + help=( + 'Non-interactively sync multi-channel approvals config with the vault ' + '(remove deleted teams; drop missing shared-folder/record UIDs; ' + 'refresh team names). Requires an existing integration config record.' + ) + ) + parser.add_argument( + '--integration-record', '-r', dest='integration_record_uid', type=str, + help=( + 'UID of the integration config record for --sync-down ' + f'(optional; defaults to "{default_record}" in "{default_folder}")' + ) + ) parser.error = raise_parse_exception parser.exit = suppress_exit return parser @@ -156,6 +190,31 @@ def _build_parser(self) -> argparse.ArgumentParser: def execute(self, params, **kwargs): name = self.get_integration_name() + + if kwargs.get('sync_down'): + if self.get_approvals_profile() is None: + raise CommandError( + self.get_command_name(), + f'{name} does not support multi-channel approver sync yet', + ) + record_uid = kwargs.get('integration_record_uid') + sync_vault = True + if record_uid: + record_uid = record_uid.strip() + if not is_valid_keeper_uid(record_uid): + raise CommandError( + self.get_command_name(), + f'Invalid integration record UID: {record_uid}', + ) + else: + # Default resolution already syncs the vault. + record_uid = self._resolve_default_integration_record( + params, kwargs.get('integration_record_name') + ) + sync_vault = False + self._execute_sync_down(params, record_uid, sync_vault=sync_vault) + return + self._require_file_based_config(params, f'{name.lower()}-app-setup') # Phase 1 -- Docker service mode setup @@ -178,9 +237,11 @@ def execute(self, params, **kwargs): def _run_base_docker_setup(self, params, kwargs: Dict[str, Any]) -> Tuple[SetupResult, ServiceConfig, str]: docker_cmd = ServiceDockerSetupCommand() - config_path = kwargs.get('config_path') or os.path.expanduser('~/.keeper/config.json') - if not os.path.isfile(config_path): - raise CommandError(self.get_command_name(), f'Config file not found: {config_path}') + config_path = self._require_commander_config_file( + self.get_command_name(), + kwargs.get('config_path'), + params, + ) DockerSetupPrinter.print_header("Docker Setup") @@ -248,7 +309,7 @@ def _run_integration_setup(self, params, setup_result: SetupResult, name = self.get_integration_name() DockerSetupPrinter.print_header(f"{name} App Configuration") - config = self.collect_integration_config() + config = self.collect_integration_config(params) DockerSetupPrinter.print_step(1, 2, f"Creating {name} config record '{record_name}'...") custom_fields = self.build_record_custom_fields(config) @@ -307,6 +368,70 @@ def _update_record_custom_fields(self, params, record_uid: str, custom_fields: L except Exception as e: raise CommandError(self.get_command_name(), f'Failed to update record fields: {str(e)}') + def _patch_record_approvals(self, params, record_uid: str, approvals: ApprovalsConfig) -> None: + try: + record = vault.KeeperRecord.load(params, record_uid) + if not record: + raise CommandError(self.get_command_name(), f'Record not found: {record_uid}') + if not isinstance(record, vault.TypedRecord): + raise CommandError( + self.get_command_name(), + f'Record {record_uid} is not a typed record and cannot store approvals config', + ) + record.custom = merge_approvals_custom_fields(record.custom, approvals) + record_management.update_record(params, record) + params.sync_data = True + api.sync_down(params) + except CommandError: + raise + except Exception as e: + raise CommandError(self.get_command_name(), f'Failed to update record fields: {str(e)}') + + def _find_folder_uid_by_name(self, params, folder_name: str) -> Optional[str]: + # Prefer shared folders; integration setup always creates a shared folder. + for folder_uid, folder_data in params.shared_folder_cache.items(): + if folder_data.get('name') == folder_name: + return folder_uid + return None + + def _resolve_default_integration_record(self, params, record_name: Optional[str] = None) -> str: + """Find the default integration config record by folder/name when -r is omitted.""" + params.sync_data = True + api.sync_down(params) + + record_name = record_name or self.get_default_record_name() + folder_name = self.get_default_folder_name() + folder_uid = self._find_folder_uid_by_name(params, folder_name) + if not folder_uid: + raise CommandError( + self.get_command_name(), + f'Default folder "{folder_name}" not found. Pass --integration-record .', + ) + + record_uid = self._find_record_in_folder(params, folder_uid, record_name) + if not record_uid: + raise CommandError( + self.get_command_name(), + f'Record "{record_name}" not found in folder "{folder_name}". ' + f'Pass --integration-record .', + ) + return record_uid + + def _execute_sync_down(self, params, record_uid: str, sync_vault: bool = True) -> None: + name = self.get_integration_name() + print(f"\n{bcolors.BOLD}{name} App Config Sync{bcolors.ENDC}") + print(f" Reconciling approver teams, shared folders, and records with the vault") + print(f" Config record: {bcolors.OKBLUE}{record_uid}{bcolors.ENDC}") + + run_approvals_sync_down( + params=params, + record_uid=record_uid, + marker_field=self.get_integration_config_marker_field(), + update_record=lambda uid, approvals: self._patch_record_approvals(params, uid, approvals), + command_name=self.get_command_name(), + sync_vault=sync_vault, + ) + # -- Docker Compose update ----------------------------------------- def _update_docker_compose(self, setup_result: SetupResult, @@ -381,7 +506,7 @@ def _print_integration_resources(self, record_uid: str, config) -> None: def _collect_pedm_config(self) -> Tuple[bool, int]: print(f"\n{bcolors.BOLD}EPM (Endpoint Privilege Manager) Integration (optional):{bcolors.ENDC}") print(f" Integrate with Keeper EPM for privilege elevation") - enabled = input(f"{bcolors.OKBLUE}Enable EPM? [Press Enter for No] (y/n):{bcolors.ENDC} ").strip().lower() == 'y' + enabled = self._prompt_yes_no('Enable EPM?', default=False) interval = 120 if enabled: interval_input = input(f"{bcolors.OKBLUE}EPM polling interval in seconds [Press Enter for 120]:{bcolors.ENDC} ").strip() @@ -392,7 +517,7 @@ def _collect_device_approval_config(self) -> Tuple[bool, int]: name = self.get_integration_name() print(f"\n{bcolors.BOLD}SSO Cloud Device Approval Integration (optional):{bcolors.ENDC}") print(f" Approve SSO Cloud device registrations via {name}") - enabled = input(f"{bcolors.OKBLUE}Enable Device Approval? [Press Enter for No] (y/n):{bcolors.ENDC} ").strip().lower() == 'y' + enabled = self._prompt_yes_no('Enable Device Approval?', default=False) interval = 120 if enabled: interval_input = input(f"{bcolors.OKBLUE}Device approval polling interval in seconds [Press Enter for 120]:{bcolors.ENDC} ").strip() @@ -401,6 +526,28 @@ def _collect_device_approval_config(self) -> Tuple[bool, int]: # -- Input / validation -------------------------------------------- + def _prompt_yes_no(self, question: str, default: bool = False) -> bool: + suffix = '[Press Enter for Yes] (y/n):' if default else '[Press Enter for No] (y/n):' + while True: + response = input( + f"{bcolors.OKBLUE}{question} {suffix}{bcolors.ENDC} " + ).strip().lower() + if not response: + return default + if response == 'y': + return True + if response == 'n': + return False + print(f"{bcolors.FAIL}Error: Enter y, n, or press Enter for default{bcolors.ENDC}") + + def _collect_approvals_config(self, params, profile: ApprovalsChannelProfile) -> ApprovalsConfig: + return collect_approvals_config( + params=params, + prompt_yes_no=self._prompt_yes_no, + prompt_with_validation=self._prompt_with_validation, + profile=profile, + ) + def _prompt_with_validation(self, prompt: str, validator, error_msg: str) -> str: while True: value = input(f"{bcolors.OKBLUE}{prompt}{bcolors.ENDC} ").strip() diff --git a/keepercommander/service/commands/integrations/slack_app_setup.py b/keepercommander/service/commands/integrations/slack_app_setup.py index cebfb4a9a..b8fcb2447 100644 --- a/keepercommander/service/commands/integrations/slack_app_setup.py +++ b/keepercommander/service/commands/integrations/slack_app_setup.py @@ -14,6 +14,11 @@ from .... import vault from ....display import bcolors from ...docker import SlackConfig +from .approvals_setup import ( + SLACK_APPROVALS_PROFILE, + approvals_config_to_record_fields, + print_approvals_config, +) from .integration_setup_base import IntegrationSetupCommand @@ -22,9 +27,15 @@ class SlackAppSetupCommand(IntegrationSetupCommand): def get_integration_name(self): return 'Slack' + def get_approvals_profile(self): + return SLACK_APPROVALS_PROFILE + + def get_integration_config_marker_field(self): + return 'slack_app_token' + # ── Slack-specific configuration ────────────────────────────── - def collect_integration_config(self): + def collect_integration_config(self, params): print(f"\n{bcolors.BOLD}SLACK_APP_TOKEN:{bcolors.ENDC}") print(f" App-level token for Slack App") slack_app_token = self._prompt_with_validation( @@ -49,13 +60,9 @@ def collect_integration_config(self): "Invalid Slack Signing Secret (must be exactly 32 characters)" ) - print(f"\n{bcolors.BOLD}APPROVALS_CHANNEL_ID:{bcolors.ENDC}") - print(f" Slack channel ID for approval notifications") - approvals_channel_id = self._prompt_with_validation( - "Channel ID (starts with C):", - lambda c: c and c.startswith('C'), - "Invalid Approvals Channel ID (must start with 'C')" - ) + profile = self.get_approvals_profile() + assert profile is not None + approvals = self._collect_approvals_config(params, profile) pedm_enabled, pedm_interval = self._collect_pedm_config() da_enabled, da_interval = self._collect_device_approval_config() @@ -66,7 +73,7 @@ def collect_integration_config(self): slack_app_token=slack_app_token, slack_bot_token=slack_bot_token, slack_signing_secret=slack_signing_secret, - approvals_channel_id=approvals_channel_id, + approvals=approvals, pedm_enabled=pedm_enabled, pedm_polling_interval=pedm_interval, device_approval_enabled=da_enabled, @@ -78,7 +85,7 @@ def build_record_custom_fields(self, config): vault.TypedField.new_field('secret', config.slack_app_token, 'slack_app_token'), vault.TypedField.new_field('secret', config.slack_bot_token, 'slack_bot_token'), vault.TypedField.new_field('secret', config.slack_signing_secret, 'slack_signing_secret'), - vault.TypedField.new_field('text', config.approvals_channel_id, 'approvals_channel_id'), + *approvals_config_to_record_fields(config.approvals), vault.TypedField.new_field('text', 'true' if config.pedm_enabled else 'false', 'pedm_enabled'), vault.TypedField.new_field('text', str(config.pedm_polling_interval), 'pedm_polling_interval'), vault.TypedField.new_field('text', 'true' if config.device_approval_enabled else 'false', 'device_approval_enabled'), @@ -88,7 +95,7 @@ def build_record_custom_fields(self, config): # ── Display ─────────────────────────────────────────────────── def print_integration_specific_resources(self, config): - print(f" • Approvals Channel: {bcolors.OKBLUE}{config.approvals_channel_id}{bcolors.ENDC}") + print_approvals_config(config.approvals) def print_integration_commands(self): print(f"\n{bcolors.BOLD}Slack Commands Available:{bcolors.ENDC}") diff --git a/keepercommander/service/commands/integrations/teams_app_setup.py b/keepercommander/service/commands/integrations/teams_app_setup.py index 3d3b80aaf..f12b113a4 100644 --- a/keepercommander/service/commands/integrations/teams_app_setup.py +++ b/keepercommander/service/commands/integrations/teams_app_setup.py @@ -28,7 +28,7 @@ def get_integration_name(self): # ── Teams-specific configuration ────────────────────────────── - def collect_integration_config(self): + def collect_integration_config(self, params): print(f"\n{bcolors.BOLD}CLIENT_ID:{bcolors.ENDC}") print(f" Azure AD App Registration Client ID") client_id = self._prompt_with_validation( diff --git a/keepercommander/service/commands/service_docker_setup.py b/keepercommander/service/commands/service_docker_setup.py index af74f3480..f3795b1ef 100644 --- a/keepercommander/service/commands/service_docker_setup.py +++ b/keepercommander/service/commands/service_docker_setup.py @@ -47,7 +47,7 @@ ) service_docker_setup_parser.add_argument( '--config-path', dest='config_path', type=str, - help='Path to config.json file (default: ~/.keeper/config.json)' + help='Path to config.json file (default: active session config file)' ) service_docker_setup_parser.add_argument( '--timeout', dest='timeout', type=str, default=DockerSetupConstants.DEFAULT_TIMEOUT, @@ -72,7 +72,11 @@ def execute(self, params, **kwargs): self._require_file_based_config(params, 'service-docker-setup') # Parse arguments - config_path = self._get_config_path(kwargs.get('config_path') or params.config_filename) + config_path = self._require_commander_config_file( + 'service-docker-setup', + kwargs.get('config_path'), + params, + ) # Print header DockerSetupPrinter.print_header("Docker Setup") @@ -348,13 +352,3 @@ def _get_token_expiration_config(self) -> Dict[str, str]: break return {'token_expiration': ''} - - def _get_config_path(self, config_path: str = None) -> str: - """Get and validate config file path""" - if not config_path: - config_path = os.path.expanduser('~/.keeper/config.json') - - if not os.path.isfile(config_path): - raise CommandError('service-docker-setup', f'Config file not found: {config_path}') - - return config_path diff --git a/keepercommander/service/docker/__init__.py b/keepercommander/service/docker/__init__.py index 97f4e998b..c30315181 100644 --- a/keepercommander/service/docker/__init__.py +++ b/keepercommander/service/docker/__init__.py @@ -19,7 +19,10 @@ - Docker Compose generation """ -from .models import DockerSetupConstants, SetupResult, ServiceConfig, SlackConfig, TeamsConfig, SetupStep +from .models import ( + DockerSetupConstants, SetupResult, ServiceConfig, SlackConfig, TeamsConfig, SetupStep, + ApproverTeam, ApprovalsConfig, +) from .printer import DockerSetupPrinter from .setup_base import DockerSetupBase from .compose_builder import DockerComposeBuilder @@ -30,6 +33,8 @@ 'ServiceConfig', 'SlackConfig', 'TeamsConfig', + 'ApproverTeam', + 'ApprovalsConfig', 'SetupStep', 'DockerSetupPrinter', 'DockerSetupBase', diff --git a/keepercommander/service/docker/models.py b/keepercommander/service/docker/models.py index d601c5581..d81e26175 100644 --- a/keepercommander/service/docker/models.py +++ b/keepercommander/service/docker/models.py @@ -11,8 +11,9 @@ """Docker setup data models and constants.""" -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum +from typing import List # ======================== @@ -77,17 +78,37 @@ class ServiceConfig: cloudflare_public_url: str = '' +@dataclass +class ApproverTeam: + team_uid: str + name: str + channel_id: str + folder_uids: List[str] = field(default_factory=list) + record_uids: List[str] = field(default_factory=list) + + +@dataclass +class ApprovalsConfig: + multi_channel_enabled: bool + single_channel_id: str = '' + teams: List[ApproverTeam] = field(default_factory=list) + + @dataclass class SlackConfig: slack_app_token: str slack_bot_token: str slack_signing_secret: str - approvals_channel_id: str + approvals: ApprovalsConfig pedm_enabled: bool = False pedm_polling_interval: int = 120 device_approval_enabled: bool = False device_approval_polling_interval: int = 120 + @property + def approvals_channel_id(self) -> str: + return self.approvals.single_channel_id + @dataclass class TeamsConfig: diff --git a/keepercommander/service/docker/printer.py b/keepercommander/service/docker/printer.py index af1b66dfd..224e44c30 100644 --- a/keepercommander/service/docker/printer.py +++ b/keepercommander/service/docker/printer.py @@ -68,7 +68,8 @@ def print_common_deployment_steps(port: str, config_path: str = None) -> None: print(f"\n{bcolors.BOLD}Step 1: Quit from this session{bcolors.ENDC}") print(f" {bcolors.OKGREEN}quit{bcolors.ENDC}") - config_file = config_path if config_path else '~/.keeper/config.json' + from ... import utils + config_file = config_path if config_path else str(utils.get_default_path() / 'config.json') print(f"\n{bcolors.BOLD}Step 2: Delete the local config.json 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.") diff --git a/keepercommander/service/docker/setup_base.py b/keepercommander/service/docker/setup_base.py index 9bc1ff6f6..49285225a 100644 --- a/keepercommander/service/docker/setup_base.py +++ b/keepercommander/service/docker/setup_base.py @@ -39,6 +39,24 @@ class DockerSetupBase: """Base class for Docker setup with reusable core logic""" + @staticmethod + def resolve_commander_config_path(config_path: str = None, params=None) -> str: + """Resolve config.json using the same rules as login/shell startup.""" + if config_path: + resolved = os.path.expanduser(config_path) + elif params and getattr(params, 'config_filename', None): + resolved = os.path.expanduser(params.config_filename) + else: + resolved = str(utils.get_default_path() / 'config.json') + return os.path.abspath(resolved) + + @staticmethod + def _require_commander_config_file(command_name: str, config_path: str = None, params=None) -> str: + resolved = DockerSetupBase.resolve_commander_config_path(config_path, params) + if not os.path.isfile(resolved): + raise CommandError(command_name, f'Config file not found: {resolved}') + return resolved + @staticmethod def _require_file_based_config(params, command_name: str) -> None: """Raise CommandError if credentials are in the OS keychain rather than config.json.""" From c7a09a44698569407be425321d27f03184ff2fdc Mon Sep 17 00:00:00 2001 From: Ivan Dimov <78815270+idimov-keeper@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:38:29 -0500 Subject: [PATCH 15/19] Add secrets-manager usage report matching Admin Console KSM widgets. Extends KSMCommand with usage modes (totals, detail, by-device, summary, timeline), billing-cycle dates, and ARAM-backed aggregation plus unit tests. Co-authored-by: Cursor --- keepercommander/commands/ksm.py | 485 +++++++++++++++++++++++++++++++- unit-tests/test_command_ksm.py | 281 +++++++++++++++++- 2 files changed, 764 insertions(+), 2 deletions(-) diff --git a/keepercommander/commands/ksm.py b/keepercommander/commands/ksm.py index e6215b531..45d0aa61c 100644 --- a/keepercommander/commands/ksm.py +++ b/keepercommander/commands/ksm.py @@ -11,11 +11,13 @@ import argparse import base64 +import calendar import datetime import hmac import json import logging import os +import shlex import time import urllib.parse from itertools import product, groupby @@ -24,7 +26,9 @@ from typing import Sequence, List, Optional -from .base import Command, dump_report_data, user_choice, as_boolean +from .base import ( + Command, dump_report_data, user_choice, as_boolean, report_output_parser, + suppress_exit, raise_parse_exception, expand_cmd_args, normalize_output_param, ParseError) from . import record from ..nested_share_folder.common import get_folder_key, get_record_key from .nested_share_folder.helpers import ( @@ -119,6 +123,31 @@ Adds one or more one-time access tokens to an existing KSM application. Equivalent to: secrets-manager client add --app [APP NAME OR UID] + {bcolors.BOLD}Usage Report:{bcolors.ENDC} + {bcolors.OKGREEN}secrets-manager usage{bcolors.ENDC} + Modes: + (default) : Top Application Usage - owner (email), count + --detail : Full User Usage - owner (name+email), device, API usage + --by-device : Top Usage by Device - device, count (+ Exist with --exists) + --detail --by-device : Full Device Usage - device, app UID, owner, API usage + --summary : Total API usage, applications, devices, avg calls/user + --timeline : Event timeline over all KSM event types + --range [24h|7d|30d] : Timeline window (default 30d) + --all : Export All rows (Date / Event / Number of Events) + Options: + --created [RANGE] : Override usage/summary date window (default: KSM billing cycle) + --limit [N] : Max rows to fetch (paginated; -1 for all) + --sort [count|name] : Sort by usage count (default) or name/title (groups related rows) + --exists : Add an Exist column to --by-device (Y=live, T=in Trash, N=purged, + ?=unknown) resolved from enterprise compliance data. + {bcolors.WARNING}*** VERY SLOW: runs a FULL enterprise compliance sync (can take + minutes on large tenants). ***{bcolors.ENDC} Only applies to --by-device; it is the + only way to detect deleted KSM apps, since the local Trash API cannot + list v5 records. + --format [table|csv|json] --output [FILE] : Output format / file + Note: usage covers only applications with recorded activity and may include ones since deleted. + To list all live applications you have access to, run: {bcolors.OKGREEN}secrets-manager app list{bcolors.ENDC} + ----- Note: If the UID you are using contains a dash (-) in the beginning, the value should be wrapped in quotes and prepended with an equal sign. For example: @@ -172,6 +201,69 @@ help='Output format (table, json)') +# KSM audit event types, mirrors Admin Console `ksmEventTypes` (const.ts). Only `app_client_access` +# drives the usage-metrics report; the full list is used for the event timeline / Export All. +KSM_EVENT_TYPES = [ + 'app_record_shared', 'app_folder_shared', 'app_record_removed', 'app_folder_removed', + 'app_record_share_changed', 'app_folder_share_changed', 'app_client_added', 'app_client_removed', + 'app_client_connected', 'app_client_access', 'app_client_record_update', 'app_client_access_denied', + 'record_rotation_on_demand_fail', 'record_rotation_on_demand_ok', + 'record_rotation_scheduled_fail', 'record_rotation_scheduled_ok', +] +KSM_USAGE_EVENT_TYPE = 'app_client_access' +# Shown after every usage table. +USAGE_APPS_TAIL = ('Usage covers only applications with recorded activity and may include ones since ' + 'deleted. Run "secrets-manager app list" to see all live applications you can access.') +# Legend for the Exist column, shown only with --exists (on --by-device). Existence is resolved from +# enterprise compliance data - deleted KSM apps (v5) cannot be listed via the local Trash API (the +# backend excludes version >= 4), so this tenant-wide check is the only way to detect them. N (purged) +# and T (in trash) are best-effort: only as complete/current as the compliance dataset. +USAGE_EXIST_LEGEND = ( + 'Exist: Y=live, T=in Trash (restorable), N=purged, ?=unknown (compliance data unavailable). ' + 'Resolved from enterprise compliance data (best-effort).') +# Composite-key separator, mirrors Admin Console (`getSecretsManagerMetrics`). +_USAGE_KEY_SEP = '~|~' +# Timeline presets -> (audit report_type, console preset). Mirrors reportsDatePresets + auditTimeline.js. +KSM_TIMELINE_RANGES = { + '24h': ('hour', 'last_24_hours', 1), + '7d': ('day', 'last_7_days', 7), + '30d': ('day', 'last_30_days', 30), +} + +ksm_usage_parser = argparse.ArgumentParser( + prog='secrets-manager usage', parents=[report_output_parser], add_help=False, + description='Keeper Secrets Manager usage report') +ksm_usage_parser.add_argument('--detail', dest='detail', action='store_true', + help='Full detail table (per owner+device, or per device with --by-device)') +ksm_usage_parser.add_argument('--by-device', dest='by_device', action='store_true', + help='Aggregate by device instead of by application owner') +ksm_usage_parser.add_argument('--summary', dest='summary', action='store_true', + help='Usage metrics summary (total API usage, apps, devices, avg calls/user)') +ksm_usage_parser.add_argument('--timeline', dest='timeline', action='store_true', + help='Secrets Manager event timeline (all KSM event types)') +ksm_usage_parser.add_argument('--range', dest='range', action='store', choices=list(KSM_TIMELINE_RANGES.keys()), + help='Timeline window: 24h, 7d or 30d (default 30d). Timeline only') +ksm_usage_parser.add_argument('--all', dest='export_all', action='store_true', + help='Timeline only: Export All rows (Date / Event / Number of Events)') +ksm_usage_parser.add_argument('--created', dest='created', action='store', + help='Override usage/summary date window (same syntax as audit-report --created). ' + 'Default: current KSM billing cycle') +ksm_usage_parser.add_argument('--limit', dest='limit', type=int, action='store', + help='Max rows to fetch (paginated). Default: all. Use -1 for all') +ksm_usage_parser.add_argument('--exists', dest='exists', action='store_true', + help='Add an Exist column to --by-device showing whether each app is ' + 'live / in Trash / purged, resolved from enterprise compliance data. ' + '*** VERY SLOW *** - runs a FULL enterprise compliance sync (can take ' + 'minutes on large tenants). Only applies to --by-device; ignored on ' + 'other views (deleted KSM apps cannot be detected any other way)') +ksm_usage_parser.add_argument('--sort', dest='sort', action='store', choices=['count', 'name'], + default='count', + help='Sort rows by usage count (default, descending) or by name/title ' + '(ascending) - use "name" to group related rows, e.g. all "Playground" devices') +ksm_usage_parser.add_argument('--help', '-h', dest='helpflag', action='store_true', help='Display help') +ksm_usage_parser.error = raise_parse_exception +ksm_usage_parser.exit = suppress_exit + def ms_to_str(ms, frmt='%Y-%m-%d %H:%M:%S'): dt = datetime.datetime.fromtimestamp(ms // 1000) @@ -187,6 +279,25 @@ class KSMCommand(Command): def get_parser(self): return ksm_parser + def execute_args(self, params, args, **kwargs): + # Route the `usage` verb to its own parser so its many flags stay out of the shared ksm_parser. + try: + tokens = shlex.split(args) if args else [] + except ValueError: + tokens = [] + if tokens and tokens[0].lower() == 'usage': + raw = normalize_output_param(expand_cmd_args(args, params.environment_variables)) + try: + opts = ksm_usage_parser.parse_args(shlex.split(raw)[1:]) + except ParseError as e: + logging.error(e) + return + d = {} + d.update(kwargs) + d.update(opts.__dict__) + return KSMCommand.execute_usage(params, **d) + return super(KSMCommand, self).execute_args(params, args, **kwargs) + def execute(self, params, **kwargs): ksm_command = kwargs.get('command') # type: List[str] @@ -475,6 +586,378 @@ def execute(self, params, **kwargs): print(f"{bcolors.WARNING}Unknown combination of KSM commands. " + f"Type 'secrets-manager' for more details'{bcolors.ENDC}") + # ------------------------------------------------------------------ # + # secrets-manager usage # + # ------------------------------------------------------------------ # + + @staticmethod + def _ts_to_ms(value): + """Normalize an epoch timestamp to milliseconds (Console stores add-on/billing dates in ms, + but Commander protos may deliver seconds). Values below 1e11 are treated as seconds.""" + try: + value = int(value) + except (TypeError, ValueError): + return 0 + if value <= 0: + return 0 + return value if value >= 100_000_000_000 else value * 1000 + + @staticmethod + def _ksm_pick_license(params): + licenses = params.enterprise.get('licenses', []) if params.enterprise else [] + if not licenses: + return None + # Prefer a license carrying the secrets_manager add-on, else the first (Console uses licenses[0]). + for lic in licenses: + if any(a.get('name') == 'secrets_manager' for a in lic.get('add_ons', [])): + return lic + return licenses[0] + + @staticmethod + def _set_month(dt, month0, year): + """Mirror Console checkDateChangeRisk: set month (0-based) + year, clamping day to month length.""" + month = month0 + 1 + last_day = calendar.monthrange(year, month)[1] + return dt.replace(year=year, month=month, day=min(dt.day, last_day)) + + @staticmethod + def _ksm_billing_cycle(params, run_on_ms=None): + """Port of Admin Console getKSMBillingCycle (yearly/enterprise path). + Returns (min_epoch_sec, max_epoch_sec) for the current billing cycle, or None if unavailable.""" + lic = KSMCommand._ksm_pick_license(params) + if not lic: + return None + nb_ms = KSMCommand._ts_to_ms(lic.get('next_billing_date')) + if not nb_ms: + return None + + nb = datetime.datetime.fromtimestamp(nb_ms / 1000) + nb_day = nb.day + run = datetime.datetime.fromtimestamp(run_on_ms / 1000) if run_on_ms else datetime.datetime.now() + run_month0 = run.month - 1 + run_year = run.year + + def case_current_to_next(): + start = KSMCommand._set_month(nb, run_month0, run_year) + end_month0 = 0 if run_month0 + 1 > 11 else run_month0 + 1 + end_year = run_year + 1 if run_month0 + 1 > 11 else run_year + end = KSMCommand._set_month(nb, end_month0, end_year) + return start, end + + def case_prev_to_current(): + start_month0 = run_month0 - 1 if run_month0 else 11 + start_year = run_year if run_month0 else run_year - 1 + start = KSMCommand._set_month(nb, start_month0, start_year) + end = KSMCommand._set_month(nb, run_month0, run_year) + return start, end + + if run.day > nb_day: + start, end = case_current_to_next() + elif run.day < nb_day: + start, end = case_prev_to_current() + else: + run_tod = (run.hour * 3600 + run.minute * 60 + run.second) * 1000 + run.microsecond // 1000 + nb_tod = (nb.hour * 3600 + nb.minute * 60 + nb.second) * 1000 + nb.microsecond // 1000 + start, end = case_prev_to_current() if run_tod < nb_tod else case_current_to_next() + + # Never report before KSM was actually created (Console clamps to max(created, activationTime); + # Commander add-ons expose only `created`). + ksm_created_ms = 0 + for a in lic.get('add_ons', []): + if a.get('name') == 'secrets_manager': + ksm_created_ms = KSMCommand._ts_to_ms(a.get('created')) + break + if ksm_created_ms and ksm_created_ms > int(start.timestamp() * 1000): + start = datetime.datetime.fromtimestamp(ksm_created_ms / 1000) + + return int(start.timestamp()), int(end.timestamp()) + + @staticmethod + def _user_fullname_map(params): + result = {} + for u in (params.enterprise.get('users', []) if params.enterprise else []): + data = u.get('data') or {} + name = data.get('displayname') + if u.get('username'): + result[u['username']] = name or '' + return result + + @staticmethod + def _compliance_app_status(params): + """Tenant-wide record existence via compliance/SOX data. Returns (live_uids, trash_uids) or + None if compliance reporting is unavailable. VERY SLOW: performs a FULL enterprise compliance + sync (can take minutes on large tenants) - callers must gate this behind the opt-in --exists + flag. This is the ONLY way to detect deleted KSM apps - the local Trash API (get_deleted_records) + excludes version >= 4 in the backend, so deleted v5 app records are never returned to the client. + `in_trash` is point-in-time (last sync); a record restored after the sync may read stale, and + absence from the dataset is treated as 'purged' only as best effort.""" + try: + from .. import sox + if not sox.is_compliance_reporting_enabled(params): + logging.warning('secrets-manager usage --exists: compliance reporting is not enabled for ' + 'this account; the Exist column will be reported as unknown (?)') + return None + nodes = params.enterprise.get('nodes') or [] + enterprise_id = next(((n['node_id'] >> 32) for n in nodes), 0) + root_node_id = nodes[0]['node_id'] if nodes else 0 + sd = sox.get_compliance_data(params, root_node_id, enterprise_id, rebuild=False, min_updated=0) + live, trash = set(), set() + for rec in sd.get_records().values(): + (trash if rec.in_trash else live).add(rec.record_uid) + return live, trash + except Exception as e: + logging.warning('secrets-manager usage --exists: could not load compliance data (%s); ' + 'the Exist column will be reported as unknown (?)', e) + return None + + @staticmethod + def _resolve_usage_window(params, kwargs): + """Return (min_sec, max_sec) for the usage/summary path. --created overrides billing cycle.""" + from .aram import AuditReportCommand + created = kwargs.get('created') + if created: + if created in ('today', 'yesterday', 'last_7_days', 'last_30_days', 'month_to_date', + 'last_month', 'year_to_date', 'last_year'): + # Named ranges are resolved server-side; return the token for the filter. + return created + flt = AuditReportCommand.get_filter(created, AuditReportCommand.convert_date) + return flt + cycle = KSMCommand._ksm_billing_cycle(params) + if cycle: + return {'min': cycle[0], 'max': cycle[1], 'exclude_max': True} + # No billing-cycle anchor available (license has no next_billing_date); fall back to a 30-day window. + logging.warning('KSM billing cycle unavailable; defaulting to the last 30 days') + return 'last_30_days' + + @staticmethod + def execute_usage(params, **kwargs): + if kwargs.get('helpflag'): + ksm_usage_parser.print_help() + return + if not params.enterprise: + print(f'{bcolors.WARNING}secrets-manager usage requires an enterprise account.{bcolors.ENDC}') + return + + detail = kwargs.get('detail', False) + by_device = kwargs.get('by_device', False) + summary = kwargs.get('summary', False) + timeline = kwargs.get('timeline', False) + export_all = kwargs.get('export_all', False) + time_range = kwargs.get('range') + + # Validate mutually-exclusive families. + if timeline and (detail or by_device or summary): + print(f'{bcolors.WARNING}--timeline cannot be combined with --detail/--by-device/--summary.{bcolors.ENDC}') + return + if summary and (detail or by_device): + print(f'{bcolors.WARNING}--summary is its own report; do not combine with --detail/--by-device.{bcolors.ENDC}') + return + if not timeline and (export_all or time_range): + print(f'{bcolors.WARNING}--all and --range apply only to --timeline.{bcolors.ENDC}') + return + + if timeline: + return KSMCommand._usage_timeline(params, **kwargs) + return KSMCommand._usage_metrics(params, **kwargs) + + @staticmethod + def _usage_metrics(params, **kwargs): + from .aram import fetch_audit_events + detail = kwargs.get('detail', False) + by_device = kwargs.get('by_device', False) + summary = kwargs.get('summary', False) + fmt = kwargs.get('format') or 'table' + output = kwargs.get('output') + limit = kwargs.get('limit') + + created_filter = KSMCommand._resolve_usage_window(params, kwargs) + audit_filter = {'audit_event_type': [KSM_USAGE_EVENT_TYPE], 'created': created_filter} + rows = fetch_audit_events( + params, audit_filter, columns=['app_uid', 'device_name', 'username'], + aggregate=['occurrences'], report_type='span', limit=limit) + + # Aggregation maps mirror Console getSecretsManagerMetrics. The *_apps maps track which + # application UID(s) contributed to each row so we can flag rows whose app has been deleted + # (audit events outlive the app, so usage can reference apps no longer in the vault inventory). + users = {} # username -> occurrences + devices = {} # device_name -> occurrences + applications = set() # distinct app_uid + by_owner = {} # username~|~device_name -> occurrences + by_dev = {} # device_name~|~app_uid~|~username -> occurrences + users_apps = {} # username -> {app_uid} (for the aggregate Exist column) + devices_apps = {} # device_name -> {app_uid} + # (device_name, app_uid) -> occurrences. Device names are NOT unique - the same name can + # belong to different apps (e.g. several "Playground Gateway" from repeated sample-data + # imports). Aggregating by name alone would merge distinct apps into one row with a blurred + # Exist; keying by (name, app_uid) keeps them separate so each row maps to exactly one app. + dev_app = {} + # username~|~device_name -> {app_uid}. Not consumed today (detail views omit the Exist column), + # but kept so a per-owner+device existence flag can be re-added without touching aggregation. + by_owner_apps = {} + for r in rows: + username = r.get('username') or '' + device_name = r.get('device_name') or '' + app_uid = r.get('app_uid') or '' + occ = int(r.get('occurrences') or 0) + users[username] = users.get(username, 0) + occ + devices[device_name] = devices.get(device_name, 0) + occ + dev_app[(device_name, app_uid)] = dev_app.get((device_name, app_uid), 0) + occ + if app_uid: + applications.add(app_uid) + users_apps.setdefault(username, set()).add(app_uid) + devices_apps.setdefault(device_name, set()).add(app_uid) + by_owner_apps.setdefault(_USAGE_KEY_SEP.join((username, device_name)), set()).add(app_uid) + by_owner[_USAGE_KEY_SEP.join((username, device_name))] = \ + by_owner.get(_USAGE_KEY_SEP.join((username, device_name)), 0) + occ + by_dev[_USAGE_KEY_SEP.join((device_name, app_uid, username))] = \ + by_dev.get(_USAGE_KEY_SEP.join((device_name, app_uid, username)), 0) + occ + + name_map = KSMCommand._user_fullname_map(params) + + # Exist column: shown only with --exists, and only on --by-device (each row is a single app). + # Existence comes solely from enterprise compliance data - deleted KSM apps (v5) cannot be + # listed via any local API (get_deleted_records excludes version >= 4 in the backend), so the + # compliance sync is the only source. That sync is VERY SLOW (full tenant scan) - hence opt-in + # behind --exists. Unknown ('?') when compliance data is unavailable. + show_exist = by_device and not detail and bool(kwargs.get('exists')) + sox_live, sox_trash, sox_loaded = set(), set(), False + if show_exist: + logging.warning(f'{bcolors.WARNING}secrets-manager usage --exists is VERY SLOW: it runs a ' + f'FULL enterprise compliance sync (can take minutes on large tenants). ' + f'Resolving app existence...{bcolors.ENDC}') + status = KSMCommand._compliance_app_status(params) + if status is not None: + sox_live, sox_trash = status + sox_loaded = True + + def app_status(a): # -> 'Y' (live) | 'T' (in trash) | 'N' (purged) | '?' (unknown) + if not a or not sox_loaded: + return '?' + if a in sox_live: + return 'Y' + if a in sox_trash: + return 'T' + return 'N' # absent from compliance data -> purged (best effort) + + if summary: + total = sum(users.values()) + users_count = len(users) + avg = (total / users_count) if users_count else 0 + table = [ + ['Total API Usage This Cycle', total], + ['Applications', len(applications)], + ['Devices', len(devices)], + ['Average API Calls Per User', round(avg, 2)], + ] + headers = ['Metric', 'Value'] + elif detail and by_device: + # Full Device Usage: device, app_uid, owner (name+email), usage + table = [] + for key, occ in sorted(by_dev.items(), key=lambda kv: kv[1], reverse=True): + device_name, app_uid, username = key.split(_USAGE_KEY_SEP) + full = name_map.get(username, '') + table.append([device_name, app_uid, full, username, occ]) + headers = ['Device', 'Application UID', 'Owner', 'Email', 'API Usage per Month'] + elif detail: + # Full User Usage: owner (name+email), device, usage + table = [] + for key, occ in sorted(by_owner.items(), key=lambda kv: kv[1], reverse=True): + username, device_name = key.split(_USAGE_KEY_SEP) + full = name_map.get(username, '') + table.append([full, username, device_name, occ]) + headers = ['Owner', 'Email', 'Device', 'API Usage per Month'] + elif by_device: + # Top Usage by Device: one row per (device, app). Suffix the name with the app UID only + # when that device name is shared by more than one app, so each row is unambiguous. The + # Exist column is appended only with --exists (compliance-based; see above). + name_counts = {} + for (dev, _app) in dev_app: + name_counts[dev] = name_counts.get(dev, 0) + 1 + table = [] + for (dev, app), occ in sorted(dev_app.items(), key=lambda kv: kv[1], reverse=True): + label = dev if name_counts.get(dev, 0) <= 1 else f'{dev} ({app or "no-app-uid"})' + row = [label, occ] + if show_exist: + row.append(app_status(app)) + table.append(row) + headers = ['Device', 'Count', 'Exist'] if show_exist else ['Device', 'Count'] + else: + # Default: Top Application Usage (by owner/user): email, count. No Exist column - a single + # owner aggregates many apps, so a combined existence verdict would be misleading. + table = [[user, occ] + for user, occ in sorted(users.items(), key=lambda kv: kv[1], reverse=True)] + headers = ['Application Owner', 'Count'] + + # --sort name: re-sort by the first (name/title) column so related rows group together + # (e.g. all "Playground*" devices). Default 'count' keeps the per-view descending-count order. + # Skipped for --summary, whose rows are fixed metric labels. + if kwargs.get('sort') == 'name' and not summary: + table.sort(key=lambda r: str(r[0]).casefold()) + + result = dump_report_data(table, headers=headers, fmt=fmt, filename=output) + # Table mode: always print the app-list pointer; add the Exist legend only when the Exist + # column is shown (--by-device --exists). + if fmt == 'table': + if show_exist: + print(f'\n{bcolors.OKBLUE}{USAGE_EXIST_LEGEND} {USAGE_APPS_TAIL}{bcolors.ENDC}') + else: + print(f'\n{bcolors.OKBLUE}{USAGE_APPS_TAIL}{bcolors.ENDC}') + return result + + @staticmethod + def _usage_timeline(params, **kwargs): + from .aram import fetch_audit_events + from ..constants import AUDIT_EVENT_STATE_MAPPING + fmt = kwargs.get('format') or 'table' + output = kwargs.get('output') + limit = kwargs.get('limit') + export_all = kwargs.get('export_all', False) + time_range = kwargs.get('range') or '30d' + report_type, _preset, _days = KSM_TIMELINE_RANGES[time_range] + + # Window: mirror Console reportsDatePresets / auditTimeline.js. + now = datetime.datetime.now() + if time_range == '24h': + to_dt = now.replace(minute=0, second=0, microsecond=0) + datetime.timedelta(hours=1) + from_dt = to_dt - datetime.timedelta(hours=24) + else: + to_dt = now.replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1) + from_dt = to_dt - datetime.timedelta(days=_days + 1) + created_filter = {'min': int(from_dt.timestamp()), 'max': int(to_dt.timestamp())} + + audit_filter = {'audit_event_type': KSM_EVENT_TYPES, 'created': created_filter} + rows = fetch_audit_events( + params, audit_filter, columns=['audit_event_type'], aggregate=['occurrences'], + report_type=report_type, limit=limit, order='descending') + + def label(evt): + return AUDIT_EVENT_STATE_MAPPING.get(evt, evt) + + def fmt_time(created): + dt = datetime.datetime.fromtimestamp(int(created)) + return dt.strftime('%Y-%m-%d %H:%M') if report_type == 'hour' else dt.strftime('%Y-%m-%d') + + if export_all: + # Export All: one row per (time bucket x event), columns Date / Event / Number of Events. + table = [] + for r in sorted(rows, key=lambda x: int(x.get('created') or 0)): + evt = r.get('audit_event_type') or '' + table.append([fmt_time(r.get('created')), label(evt), int(r.get('occurrences') or 0)]) + headers = ['Date', 'Event', 'Number of Events'] + return dump_report_data(table, headers=headers, fmt=fmt, filename=output) + + # Default timeline: event totals over the range, with % of total. + totals = {} + for r in rows: + evt = r.get('audit_event_type') or '' + totals[evt] = totals.get(evt, 0) + int(r.get('occurrences') or 0) + grand = sum(totals.values()) + table = [] + for evt, cnt in sorted(totals.items(), key=lambda kv: kv[1], reverse=True): + pct = round(cnt * 100.0 / grand, 1) if grand else 0 + table.append([label(evt), cnt, f'{pct}%']) + return dump_report_data(table, headers=['Event', 'Count', '% of Total'], fmt=fmt, filename=output) + @staticmethod def share_app(params, app_name_or_uid, email, is_admin=False, unshare=False): # type: (KeeperParams, str, List[str], Optional[bool], Optional[bool]) -> None diff --git a/unit-tests/test_command_ksm.py b/unit-tests/test_command_ksm.py index b2f18521d..ab0c98716 100644 --- a/unit-tests/test_command_ksm.py +++ b/unit-tests/test_command_ksm.py @@ -1,8 +1,9 @@ """Unit tests for secrets-manager (KSM) CLI commands.""" +import datetime import unittest from unittest.mock import MagicMock, patch -from keepercommander.commands.ksm import KSMCommand +from keepercommander.commands.ksm import KSMCommand, KSM_EVENT_TYPES, KSM_USAGE_EVENT_TYPE class TestKSMSecretResolution(unittest.TestCase): @@ -215,5 +216,283 @@ def test_token_add_missing_app_prints_help(self): assert result is None, f"Expected None, got {result!r}" +class TestKSMUsage(unittest.TestCase): + """secrets-manager usage report - aggregation, billing cycle, timeline, flag matrix.""" + + SAMPLE_ROWS = [ + {'username': 'a@x.com', 'device_name': 'D1', 'app_uid': 'APP1', 'occurrences': 10}, + {'username': 'a@x.com', 'device_name': 'D2', 'app_uid': 'APP1', 'occurrences': 5}, + {'username': 'b@x.com', 'device_name': 'D1', 'app_uid': 'APP2', 'occurrences': 3}, + ] + + def _make_params(self): + params = MagicMock() + params.environment_variables = {} + params.enterprise = { + 'users': [ + {'username': 'a@x.com', 'data': {'displayname': 'Alice A'}}, + {'username': 'b@x.com', 'data': {'displayname': 'Bob B'}}, + ], + 'licenses': [{ + 'next_billing_date': int(datetime.datetime(2026, 1, 15, 12, 0, 0).timestamp() * 1000), + 'add_ons': [{'name': 'secrets_manager', + 'created': int(datetime.datetime(2025, 1, 1).timestamp() * 1000)}], + }], + } + return params + + # ---- unit helpers ---- + + def test_event_type_constants(self): + self.assertEqual(len(KSM_EVENT_TYPES), 16) + self.assertIn('app_client_access', KSM_EVENT_TYPES) + self.assertEqual(KSM_USAGE_EVENT_TYPE, 'app_client_access') + + def test_ts_to_ms_normalization(self): + self.assertEqual(KSMCommand._ts_to_ms(1_700_000_000), 1_700_000_000_000) # seconds -> ms + self.assertEqual(KSMCommand._ts_to_ms(1_700_000_000_000), 1_700_000_000_000) # ms passthrough + self.assertEqual(KSMCommand._ts_to_ms(0), 0) + self.assertEqual(KSMCommand._ts_to_ms(None), 0) + + def test_set_month_day_clamp(self): + jan31 = datetime.datetime(2026, 1, 31, 9, 0, 0) + feb = KSMCommand._set_month(jan31, 1, 2026) # month0=1 -> February + self.assertEqual((feb.month, feb.day), (2, 28)) + + def test_billing_cycle_run_after_billing_day(self): + params = self._make_params() + run_ms = int(datetime.datetime(2026, 7, 16, 9, 0, 0).timestamp() * 1000) + min_sec, max_sec = KSMCommand._ksm_billing_cycle(params, run_on_ms=run_ms) + start = datetime.datetime.fromtimestamp(min_sec) + end = datetime.datetime.fromtimestamp(max_sec) + self.assertEqual((start.year, start.month, start.day), (2026, 7, 15)) + self.assertEqual((end.year, end.month, end.day), (2026, 8, 15)) + self.assertLess(min_sec, max_sec) + + def test_billing_cycle_none_without_billing_date(self): + params = self._make_params() + params.enterprise['licenses'][0]['next_billing_date'] = 0 + self.assertIsNone(KSMCommand._ksm_billing_cycle(params)) + + # ---- aggregation / rendering (assert the table handed to dump_report_data) ---- + + # One device -> one app, for clean per-device Exist assertions. + ONE_DEV_ROWS = [{'username': 'u@x.com', 'device_name': 'Donly', 'app_uid': 'APPX', 'occurrences': 7}] + + def _run_metrics(self, params, sox=None, rows=None, **flags): + # sox: None (compliance unavailable) or (live_uids, trash_uids) returned by the --exists path. + rows = list(self.SAMPLE_ROWS if rows is None else rows) + with patch('keepercommander.commands.aram.fetch_audit_events', return_value=rows), \ + patch('keepercommander.commands.ksm.KSMCommand._compliance_app_status', return_value=sox), \ + patch('keepercommander.commands.ksm.dump_report_data') as mock_dump: + KSMCommand._usage_metrics(params, format='json', **flags) + self.assertTrue(mock_dump.called) + _args, kwargs = mock_dump.call_args + table = _args[0] + headers = kwargs.get('headers') or (_args[1] if len(_args) > 1 else None) + return table, headers + + def test_default_top_application_usage_no_exist_column(self): + table, headers = self._run_metrics(self._make_params()) + self.assertEqual(headers, ['Application Owner', 'Count']) + self.assertEqual(table, [['a@x.com', 15], ['b@x.com', 3]]) + + def test_by_device_no_exist_column_by_default(self): + # Without --exists there is NO Exist column - just Device/Count. Names shared by multiple apps + # are still split with a UID suffix so each row maps to one app. + table, headers = self._run_metrics(self._make_params(), by_device=True) + self.assertEqual(headers, ['Device', 'Count']) + self.assertEqual(table, [['D1 (APP1)', 10], ['D2', 5], ['D1 (APP2)', 3]]) + + def test_by_device_sort_by_name(self): + # --sort name groups related device names alphabetically instead of by count. + rows = [{'username': 'u@x.com', 'device_name': 'zeta', 'app_uid': 'A1', 'occurrences': 100}, + {'username': 'u@x.com', 'device_name': 'Playground B', 'app_uid': 'A2', 'occurrences': 5}, + {'username': 'u@x.com', 'device_name': 'Playground A', 'app_uid': 'A3', 'occurrences': 50}] + table, _ = self._run_metrics(self._make_params(), rows=rows, by_device=True, sort='name') + self.assertEqual([r[0] for r in table], ['Playground A', 'Playground B', 'zeta']) + # default (count) keeps descending-usage order + table2, _ = self._run_metrics(self._make_params(), rows=rows, by_device=True, sort='count') + self.assertEqual([r[0] for r in table2], ['zeta', 'Playground A', 'Playground B']) + + def test_by_device_unique_name_no_suffix(self): + rows = [{'username': 'u@x.com', 'device_name': 'Solo', 'app_uid': 'APP1', 'occurrences': 9}] + table, headers = self._run_metrics(self._make_params(), rows=rows, by_device=True) + self.assertEqual(headers, ['Device', 'Count']) + self.assertEqual(table, [['Solo', 9]]) # single app -> no UID suffix + + def test_by_device_exists_live_and_purged(self): + # --exists: compliance says APP1 live, APP2 unknown -> purged (N). + table, headers = self._run_metrics(self._make_params(), sox=(frozenset({'APP1'}), frozenset()), + by_device=True, exists=True) + self.assertEqual(headers, ['Device', 'Count', 'Exist']) + self.assertEqual(table, [['D1 (APP1)', 10, 'Y'], ['D2', 5, 'Y'], ['D1 (APP2)', 3, 'N']]) + + def test_by_device_exists_trash(self): + table, _ = self._run_metrics(self._make_params(), sox=(frozenset(), frozenset({'APPX'})), + rows=self.ONE_DEV_ROWS, by_device=True, exists=True) + self.assertEqual(table, [['Donly', 7, 'T']]) # in_trash per compliance + + def test_by_device_exists_unknown_when_compliance_unavailable(self): + # --exists but compliance data can't be loaded -> '?' + table, _ = self._run_metrics(self._make_params(), sox=None, + rows=self.ONE_DEV_ROWS, by_device=True, exists=True) + self.assertEqual(table, [['Donly', 7, '?']]) + + def test_exists_not_queried_without_flag(self): + params = self._make_params() + with patch('keepercommander.commands.aram.fetch_audit_events', return_value=list(self.SAMPLE_ROWS)), \ + patch('keepercommander.commands.ksm.KSMCommand._compliance_app_status') as mock_sox, \ + patch('keepercommander.commands.ksm.dump_report_data'): + KSMCommand._usage_metrics(params, format='json', by_device=True) # no --exists + mock_sox.assert_not_called() + + def test_exists_ignored_off_by_device(self): + # --exists only applies to --by-device; on default/summary/detail the compliance sync must not run. + params = self._make_params() + for flags in ({}, {'summary': True}, {'detail': True}, {'detail': True, 'by_device': True}): + with patch('keepercommander.commands.aram.fetch_audit_events', return_value=list(self.SAMPLE_ROWS)), \ + patch('keepercommander.commands.ksm.KSMCommand._compliance_app_status') as mock_sox, \ + patch('keepercommander.commands.ksm.dump_report_data'): + KSMCommand._usage_metrics(params, format='json', exists=True, **flags) + mock_sox.assert_not_called() + + def test_detail_full_user_usage_no_exist_column(self): + table, headers = self._run_metrics(self._make_params(), detail=True) + self.assertEqual(headers, ['Owner', 'Email', 'Device', 'API Usage per Month']) + self.assertEqual(table, [['Alice A', 'a@x.com', 'D1', 10], + ['Alice A', 'a@x.com', 'D2', 5], + ['Bob B', 'b@x.com', 'D1', 3]]) + + def test_detail_by_device_full_device_usage_no_exist_column(self): + table, headers = self._run_metrics(self._make_params(), detail=True, by_device=True) + self.assertEqual(headers, ['Device', 'Application UID', 'Owner', 'Email', 'API Usage per Month']) + self.assertEqual(table, [['D1', 'APP1', 'Alice A', 'a@x.com', 10], + ['D2', 'APP1', 'Alice A', 'a@x.com', 5], + ['D1', 'APP2', 'Bob B', 'b@x.com', 3]]) + + def test_summary(self): + table, headers = self._run_metrics(self._make_params(), summary=True) + self.assertEqual(headers, ['Metric', 'Value']) + as_dict = {row[0]: row[1] for row in table} + self.assertEqual(as_dict['Total API Usage This Cycle'], 18) + self.assertEqual(as_dict['Applications'], 2) + self.assertEqual(as_dict['Devices'], 2) + self.assertEqual(as_dict['Average API Calls Per User'], 9.0) + self.assertNotIn(' live (Y)', as_dict) + self.assertNotIn(' purged (N)', as_dict) + + def _capture_table_footer(self, sox=None, **flags): + import io + import contextlib + params = self._make_params() + buf = io.StringIO() + with patch('keepercommander.commands.aram.fetch_audit_events', return_value=list(self.SAMPLE_ROWS)), \ + patch('keepercommander.commands.ksm.KSMCommand._compliance_app_status', return_value=sox), \ + contextlib.redirect_stdout(buf): + KSMCommand._usage_metrics(params, format='table', **flags) + return buf.getvalue() + + def test_footer_exist_legend_only_with_exists(self): + # No --exists anywhere -> app-list note present, Exist legend absent. + for flags in ({}, {'by_device': True}, {'detail': True}, {'summary': True}): + out = self._capture_table_footer(**flags) + self.assertIn('secrets-manager app list', out) + self.assertNotIn('Exist:', out) + # --by-device --exists -> legend present. + out = self._capture_table_footer(sox=(frozenset({'APP1'}), frozenset()), by_device=True, exists=True) + self.assertIn('Exist:', out) + + def test_usage_uses_only_app_client_access_filter(self): + params = self._make_params() + with patch('keepercommander.commands.aram.fetch_audit_events', return_value=[]) as mock_fetch, \ + patch('keepercommander.commands.ksm.dump_report_data'): + KSMCommand._usage_metrics(params, format='table') + _args, kwargs = mock_fetch.call_args + audit_filter = _args[1] + self.assertEqual(audit_filter['audit_event_type'], ['app_client_access']) + self.assertEqual(kwargs.get('columns'), ['app_uid', 'device_name', 'username']) + self.assertEqual(kwargs.get('aggregate'), ['occurrences']) + + # ---- timeline ---- + + def test_timeline_export_all_row_shape(self): + params = self._make_params() + rows = [ + {'audit_event_type': 'app_client_access', 'created': 1_700_000_100, 'occurrences': 4}, + {'audit_event_type': 'app_client_added', 'created': 1_700_000_000, 'occurrences': 2}, + ] + with patch('keepercommander.commands.aram.fetch_audit_events', return_value=rows) as mock_fetch, \ + patch('keepercommander.commands.ksm.dump_report_data') as mock_dump: + KSMCommand._usage_timeline(params, format='csv', export_all=True, range='7d') + # full KSM event set requested for timeline + _fargs, fkwargs = mock_fetch.call_args + self.assertEqual(_fargs[1]['audit_event_type'], KSM_EVENT_TYPES) + self.assertEqual(fkwargs.get('report_type'), 'day') + _args, kwargs = mock_dump.call_args + table = _args[0] + headers = kwargs.get('headers') + self.assertEqual(headers, ['Date', 'Event', 'Number of Events']) + # sorted ascending by created; count in last column + self.assertEqual([r[2] for r in table], [2, 4]) + self.assertEqual(len(table[0]), 3) + + def test_timeline_default_totals(self): + params = self._make_params() + rows = [ + {'audit_event_type': 'app_client_access', 'created': 1_700_000_000, 'occurrences': 6}, + {'audit_event_type': 'app_client_access', 'created': 1_700_100_000, 'occurrences': 4}, + {'audit_event_type': 'app_client_added', 'created': 1_700_000_000, 'occurrences': 10}, + ] + with patch('keepercommander.commands.aram.fetch_audit_events', return_value=rows), \ + patch('keepercommander.commands.ksm.dump_report_data') as mock_dump: + KSMCommand._usage_timeline(params, format='table', range='24h') + _args, kwargs = mock_dump.call_args + table = _args[0] + self.assertEqual(kwargs.get('headers'), ['Event', 'Count', '% of Total']) + counts = {row[0]: row[1] for row in table} + # app_client_access totals to 10, app_client_added to 10 + self.assertEqual(sorted(counts.values(), reverse=True), [10, 10]) + + # ---- flag validation & routing ---- + + def test_timeline_conflicts_with_detail(self): + params = self._make_params() + with patch('keepercommander.commands.aram.fetch_audit_events') as mock_fetch: + KSMCommand.execute_usage(params, timeline=True, detail=True) + mock_fetch.assert_not_called() + + def test_range_without_timeline_rejected(self): + params = self._make_params() + with patch('keepercommander.commands.aram.fetch_audit_events') as mock_fetch: + KSMCommand.execute_usage(params, range='7d') + mock_fetch.assert_not_called() + + def test_requires_enterprise(self): + params = self._make_params() + params.enterprise = None + with patch('keepercommander.commands.aram.fetch_audit_events') as mock_fetch: + KSMCommand.execute_usage(params) + mock_fetch.assert_not_called() + + def test_execute_args_routes_usage_verb(self): + params = self._make_params() + cmd = KSMCommand() + with patch('keepercommander.commands.ksm.KSMCommand.execute_usage') as mock_usage: + cmd.execute_args(params, 'usage --by-device') + self.assertTrue(mock_usage.called) + _args, kwargs = mock_usage.call_args + self.assertTrue(kwargs.get('by_device')) + + def test_execute_args_non_usage_delegates(self): + params = self._make_params() + cmd = KSMCommand() + with patch('keepercommander.commands.ksm.KSMCommand.execute_usage') as mock_usage, \ + patch('keepercommander.commands.base.Command.execute_args') as mock_super: + cmd.execute_args(params, 'app list') + mock_usage.assert_not_called() + mock_super.assert_called_once() + + if __name__ == '__main__': unittest.main() From 8a6e2fca03ff6babdf7da6529bf13d47c1f226c9 Mon Sep 17 00:00:00 2001 From: Ivan Dimov <78815270+idimov-keeper@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:20:14 -0500 Subject: [PATCH 16/19] KC-1352 re-apply local-only changes onto release Restore playground generator, credential cleanup in examples/tests, and vendored keeper_dag import fixes that do not overlap with upstream. Co-authored-by: Cursor --- examples/pam_import_generator.py | 17 +- .../commands/pam_import/playground.py | 1171 +++++++++++++++++ keepercommander/commands/record.py | 2 +- keepercommander/commands/supershell/app.py | 2 +- tests/test_tunnel_close_leak.py | 12 +- unit-tests/pam/test_pam_import_playground.py | 370 ++++++ 6 files changed, 1563 insertions(+), 11 deletions(-) create mode 100644 keepercommander/commands/pam_import/playground.py create mode 100644 unit-tests/pam/test_pam_import_playground.py diff --git a/examples/pam_import_generator.py b/examples/pam_import_generator.py index cdb4f35ed..bd16a2e69 100644 --- a/examples/pam_import_generator.py +++ b/examples/pam_import_generator.py @@ -30,10 +30,21 @@ import csv import json import os +import secrets +import string import sys from pathlib import Path from typing import Any, Dict, List +_ALPHANUM = string.ascii_letters + string.digits + + +def _random_alphanumeric(length: int = 16) -> str: + return "".join(secrets.choice(_ALPHANUM) for _ in range(length)) + + +_AD_DEMO = _random_alphanumeric() +_MACHINE_DEMO = _random_alphanumeric() DEFAULT_IMPORT_TEMPLATE = """{ "project": "Project1", @@ -70,7 +81,7 @@ "title": "XXX:DomainAdmin", "_comment_login_password": "Must provide valid credentials but delete sensitive data/json after import", "login": "XXX:administrator@demo.local", - "password": "XXX:P4ssw0rd_123", + "password": "XXX:__AD_DEMO__", "rotation_settings": { "rotation": "general", "enabled": "on", "schedule": {"type": "on-demand"}} }] }, @@ -106,13 +117,13 @@ "_comment_login": "username value from CSV", "login": "xxx:Administrator", "_comment_password": "password value from CSV", - "password": "xxx:P4ssw0rd_123", + "password": "xxx:__MACHINE_DEMO__", "rotation_settings": { "rotation": "general", "enabled": "on", "schedule": {"type": "on-demand"} } }] } ] } -}""" +}""".replace("__AD_DEMO__", _AD_DEMO).replace("__MACHINE_DEMO__", _MACHINE_DEMO) def _build_cli() -> argparse.ArgumentParser: diff --git a/keepercommander/commands/pam_import/playground.py b/keepercommander/commands/pam_import/playground.py new file mode 100644 index 000000000..49a9978bb --- /dev/null +++ b/keepercommander/commands/pam_import/playground.py @@ -0,0 +1,1171 @@ +# _ __ +# | |/ /___ ___ _ __ ___ _ _ ® +# | ' records -> compose -> save) + 4. Record creators - _create_* helpers (one per playground service) + 5. Compose - build_compose() + per-service YAML builders + 6. Output - save_compose_and_seccomp() + +The credentials are generated once per run (no hard-coded secrets) and shared +between the vault records and the generated ``docker-compose.yaml`` so the two +always agree. The compose template mirrors the discovery-playground repo's +``origin/main:docker-compose.yaml``. +""" + +from __future__ import annotations + +import base64 +import contextlib +import json +import logging +import os +import re +import tempfile +from datetime import datetime +from secrets import token_hex +from typing import Any, Callable, Dict, List, Optional + +from ... import api +from ...display import bcolors +from ...generator import KeeperPasswordGenerator, DEFAULT_PASSWORD_LENGTH +from ..record_edit import RecordAddCommand as RecordEditAddCommand +from ..tunnel.port_forward.TunnelGraph import TunnelDAG +from ..tunnel.port_forward.tunnel_helpers import get_keeper_tokens +from ..tunnel_and_connections import PAMTunnelEditCommand + +# ===================================================================== +# 1. Constants +# ===================================================================== + +#: Gateway image per KeeperPAM Setup Steps (supersedes origin gateway-dev:x.y.z). +GATEWAY_IMAGE = "keeper/gateway:latest" + +#: Subnet for the generated docker network (matches discovery-playground origin). +NETWORK_SUBNET = "192.168.1.0/24" + +#: Gateway ``depends_on`` list (11 services). Origin lists only 8; the three +#: extra backends (db-mssql, db-mongo, server-telnet) are a deliberate KC +#: improvement so the gateway starts after every backend it exercises. +GATEWAY_DEPENDS_ON = [ + "db-mysql-1", + "db-postgres-1", + "db-mariadb-1", + "db-mssql", + # "db-mongo", # disabled with the MongoDB record (see _create_mongodb_records) + "server-ssh-with-pwd-1", + "server-ssh-with-key-1", + "server-openldap-1", + "server-vnc", + "server-rdp", + "server-telnet", +] + +#: Canonical seccomp profile URL (KeeperPAM Setup Steps). Printed to the user +#: after generation; the profile bytes themselves are embedded below. +SECCOMP_URL = ( + "https://raw.githubusercontent.com/Keeper-Security/KeeperPAM/" + "refs/heads/main/gateway/docker-seccomp.json" +) + +# Base64 of gateway/docker-seccomp.json (KeeperPAM main; byte-identical to +# discovery-playground/docker-seccomp.json - 12,710 bytes, +# sha256 268fe62ef534293fb1af851cea3da0f1a5ce386e772fd3cb4aaa1c7a4f88aa6b). +# Wrapped for readability; whitespace is stripped before decoding. +_SECCOMP_B64_WRAPPED = """ +ewoJImRlZmF1bHRBY3Rpb24iOiAiU0NNUF9BQ1RfRVJSTk8iLAoJImRlZmF1bHRFcnJub1JldCI6 +IDEsCgkiYXJjaE1hcCI6IFsKCQl7CgkJCSJhcmNoaXRlY3R1cmUiOiAiU0NNUF9BUkNIX1g4Nl82 +NCIsCgkJCSJzdWJBcmNoaXRlY3R1cmVzIjogWwoJCQkJIlNDTVBfQVJDSF9YODYiLAoJCQkJIlND +TVBfQVJDSF9YMzIiCgkJCV0KCQl9LAoJCXsKCQkJImFyY2hpdGVjdHVyZSI6ICJTQ01QX0FSQ0hf +QUFSQ0g2NCIsCgkJCSJzdWJBcmNoaXRlY3R1cmVzIjogWwoJCQkJIlNDTVBfQVJDSF9BUk0iCgkJ +CV0KCQl9LAoJCXsKCQkJImFyY2hpdGVjdHVyZSI6ICJTQ01QX0FSQ0hfTUlQUzY0IiwKCQkJInN1 +YkFyY2hpdGVjdHVyZXMiOiBbCgkJCQkiU0NNUF9BUkNIX01JUFMiLAoJCQkJIlNDTVBfQVJDSF9N +SVBTNjROMzIiCgkJCV0KCQl9LAoJCXsKCQkJImFyY2hpdGVjdHVyZSI6ICJTQ01QX0FSQ0hfTUlQ +UzY0TjMyIiwKCQkJInN1YkFyY2hpdGVjdHVyZXMiOiBbCgkJCQkiU0NNUF9BUkNIX01JUFMiLAoJ +CQkJIlNDTVBfQVJDSF9NSVBTNjQiCgkJCV0KCQl9LAoJCXsKCQkJImFyY2hpdGVjdHVyZSI6ICJT +Q01QX0FSQ0hfTUlQU0VMNjQiLAoJCQkic3ViQXJjaGl0ZWN0dXJlcyI6IFsKCQkJCSJTQ01QX0FS +Q0hfTUlQU0VMIiwKCQkJCSJTQ01QX0FSQ0hfTUlQU0VMNjROMzIiCgkJCV0KCQl9LAoJCXsKCQkJ +ImFyY2hpdGVjdHVyZSI6ICJTQ01QX0FSQ0hfTUlQU0VMNjROMzIiLAoJCQkic3ViQXJjaGl0ZWN0 +dXJlcyI6IFsKCQkJCSJTQ01QX0FSQ0hfTUlQU0VMIiwKCQkJCSJTQ01QX0FSQ0hfTUlQU0VMNjQi +CgkJCV0KCQl9LAoJCXsKCQkJImFyY2hpdGVjdHVyZSI6ICJTQ01QX0FSQ0hfUzM5MFgiLAoJCQki +c3ViQXJjaGl0ZWN0dXJlcyI6IFsKCQkJCSJTQ01QX0FSQ0hfUzM5MCIKCQkJXQoJCX0sCgkJewoJ +CQkiYXJjaGl0ZWN0dXJlIjogIlNDTVBfQVJDSF9SSVNDVjY0IiwKCQkJInN1YkFyY2hpdGVjdHVy +ZXMiOiBudWxsCgkJfQoJXSwKCSJzeXNjYWxscyI6IFsKCQl7CgkJCSJuYW1lcyI6IFsKCQkJCSJh +Y2NlcHQiLAoJCQkJImFjY2VwdDQiLAoJCQkJImFjY2VzcyIsCgkJCQkiYWRqdGltZXgiLAoJCQkJ +ImFsYXJtIiwKCQkJCSJiaW5kIiwKCQkJCSJicmsiLAoJCQkJImNhcGdldCIsCgkJCQkiY2Fwc2V0 +IiwKCQkJCSJjaGRpciIsCgkJCQkiY2htb2QiLAoJCQkJImNob3duIiwKCQkJCSJjaG93bjMyIiwK +CQkJCSJjaHJvb3QiLAoJCQkJImNsb2NrX2FkanRpbWUiLAoJCQkJImNsb2NrX2FkanRpbWU2NCIs +CgkJCQkiY2xvY2tfZ2V0cmVzIiwKCQkJCSJjbG9ja19nZXRyZXNfdGltZTY0IiwKCQkJCSJjbG9j +a19nZXR0aW1lIiwKCQkJCSJjbG9ja19nZXR0aW1lNjQiLAoJCQkJImNsb2NrX25hbm9zbGVlcCIs +CgkJCQkiY2xvY2tfbmFub3NsZWVwX3RpbWU2NCIsCgkJCQkiY2xvbmUiLAoJCQkJImNsb3NlIiwK +CQkJCSJjbG9zZV9yYW5nZSIsCgkJCQkiY29ubmVjdCIsCgkJCQkiY29weV9maWxlX3JhbmdlIiwK +CQkJCSJjcmVhdCIsCgkJCQkiZHVwIiwKCQkJCSJkdXAyIiwKCQkJCSJkdXAzIiwKCQkJCSJlcG9s +bF9jcmVhdGUiLAoJCQkJImVwb2xsX2NyZWF0ZTEiLAoJCQkJImVwb2xsX2N0bCIsCgkJCQkiZXBv +bGxfY3RsX29sZCIsCgkJCQkiZXBvbGxfcHdhaXQiLAoJCQkJImVwb2xsX3B3YWl0MiIsCgkJCQki +ZXBvbGxfd2FpdCIsCgkJCQkiZXBvbGxfd2FpdF9vbGQiLAoJCQkJImV2ZW50ZmQiLAoJCQkJImV2 +ZW50ZmQyIiwKCQkJCSJleGVjdmUiLAoJCQkJImV4ZWN2ZWF0IiwKCQkJCSJleGl0IiwKCQkJCSJl +eGl0X2dyb3VwIiwKCQkJCSJmYWNjZXNzYXQiLAoJCQkJImZhY2Nlc3NhdDIiLAoJCQkJImZhZHZp +c2U2NCIsCgkJCQkiZmFkdmlzZTY0XzY0IiwKCQkJCSJmYWxsb2NhdGUiLAoJCQkJImZhbm90aWZ5 +X21hcmsiLAoJCQkJImZjaGRpciIsCgkJCQkiZmNobW9kIiwKCQkJCSJmY2htb2RhdCIsCgkJCQki +ZmNob3duIiwKCQkJCSJmY2hvd24zMiIsCgkJCQkiZmNob3duYXQiLAoJCQkJImZjbnRsIiwKCQkJ +CSJmY250bDY0IiwKCQkJCSJmZGF0YXN5bmMiLAoJCQkJImZnZXR4YXR0ciIsCgkJCQkiZmxpc3R4 +YXR0ciIsCgkJCQkiZmxvY2siLAoJCQkJImZvcmsiLAoJCQkJImZyZW1vdmV4YXR0ciIsCgkJCQki +ZnNldHhhdHRyIiwKCQkJCSJmc3RhdCIsCgkJCQkiZnN0YXQ2NCIsCgkJCQkiZnN0YXRhdDY0IiwK +CQkJCSJmc3RhdGZzIiwKCQkJCSJmc3RhdGZzNjQiLAoJCQkJImZzeW5jIiwKCQkJCSJmdHJ1bmNh +dGUiLAoJCQkJImZ0cnVuY2F0ZTY0IiwKCQkJCSJmdXRleCIsCgkJCQkiZnV0ZXhfdGltZTY0IiwK +CQkJCSJmdXRleF93YWl0diIsCgkJCQkiZnV0aW1lc2F0IiwKCQkJCSJnZXRjcHUiLAoJCQkJImdl +dGN3ZCIsCgkJCQkiZ2V0ZGVudHMiLAoJCQkJImdldGRlbnRzNjQiLAoJCQkJImdldGVnaWQiLAoJ +CQkJImdldGVnaWQzMiIsCgkJCQkiZ2V0ZXVpZCIsCgkJCQkiZ2V0ZXVpZDMyIiwKCQkJCSJnZXRn +aWQiLAoJCQkJImdldGdpZDMyIiwKCQkJCSJnZXRncm91cHMiLAoJCQkJImdldGdyb3VwczMyIiwK +CQkJCSJnZXRpdGltZXIiLAoJCQkJImdldHBlZXJuYW1lIiwKCQkJCSJnZXRwZ2lkIiwKCQkJCSJn +ZXRwZ3JwIiwKCQkJCSJnZXRwaWQiLAoJCQkJImdldHBwaWQiLAoJCQkJImdldHByaW9yaXR5IiwK +CQkJCSJnZXRyYW5kb20iLAoJCQkJImdldHJlc2dpZCIsCgkJCQkiZ2V0cmVzZ2lkMzIiLAoJCQkJ +ImdldHJlc3VpZCIsCgkJCQkiZ2V0cmVzdWlkMzIiLAoJCQkJImdldHJsaW1pdCIsCgkJCQkiZ2V0 +X3JvYnVzdF9saXN0IiwKCQkJCSJnZXRydXNhZ2UiLAoJCQkJImdldHNpZCIsCgkJCQkiZ2V0c29j +a25hbWUiLAoJCQkJImdldHNvY2tvcHQiLAoJCQkJImdldF90aHJlYWRfYXJlYSIsCgkJCQkiZ2V0 +dGlkIiwKCQkJCSJnZXR0aW1lb2ZkYXkiLAoJCQkJImdldHVpZCIsCgkJCQkiZ2V0dWlkMzIiLAoJ +CQkJImdldHhhdHRyIiwKCQkJCSJpbm90aWZ5X2FkZF93YXRjaCIsCgkJCQkiaW5vdGlmeV9pbml0 +IiwKCQkJCSJpbm90aWZ5X2luaXQxIiwKCQkJCSJpbm90aWZ5X3JtX3dhdGNoIiwKCQkJCSJpb19j +YW5jZWwiLAoJCQkJImlvY3RsIiwKCQkJCSJpb19kZXN0cm95IiwKCQkJCSJpb19nZXRldmVudHMi +LAoJCQkJImlvX3BnZXRldmVudHMiLAoJCQkJImlvX3BnZXRldmVudHNfdGltZTY0IiwKCQkJCSJp +b3ByaW9fZ2V0IiwKCQkJCSJpb3ByaW9fc2V0IiwKCQkJCSJpb19zZXR1cCIsCgkJCQkiaW9fc3Vi +bWl0IiwKCQkJCSJpcGMiLAoJCQkJImtpbGwiLAoJCQkJImxhbmRsb2NrX2FkZF9ydWxlIiwKCQkJ +CSJsYW5kbG9ja19jcmVhdGVfcnVsZXNldCIsCgkJCQkibGFuZGxvY2tfcmVzdHJpY3Rfc2VsZiIs +CgkJCQkibGNob3duIiwKCQkJCSJsY2hvd24zMiIsCgkJCQkibGdldHhhdHRyIiwKCQkJCSJsaW5r +IiwKCQkJCSJsaW5rYXQiLAoJCQkJImxpc3RlbiIsCgkJCQkibGlzdHhhdHRyIiwKCQkJCSJsbGlz +dHhhdHRyIiwKCQkJCSJfbGxzZWVrIiwKCQkJCSJscmVtb3ZleGF0dHIiLAoJCQkJImxzZWVrIiwK +CQkJCSJsc2V0eGF0dHIiLAoJCQkJImxzdGF0IiwKCQkJCSJsc3RhdDY0IiwKCQkJCSJtYWR2aXNl +IiwKCQkJCSJtZW1iYXJyaWVyIiwKCQkJCSJtZW1mZF9jcmVhdGUiLAoJCQkJIm1lbWZkX3NlY3Jl +dCIsCgkJCQkibWluY29yZSIsCgkJCQkibWtkaXIiLAoJCQkJIm1rZGlyYXQiLAoJCQkJIm1rbm9k +IiwKCQkJCSJta25vZGF0IiwKCQkJCSJtbG9jayIsCgkJCQkibWxvY2syIiwKCQkJCSJtbG9ja2Fs +bCIsCgkJCQkibW1hcCIsCgkJCQkibW1hcDIiLAoJCQkJIm1vdW50IiwKCQkJCSJtcHJvdGVjdCIs +CgkJCQkibXFfZ2V0c2V0YXR0ciIsCgkJCQkibXFfbm90aWZ5IiwKCQkJCSJtcV9vcGVuIiwKCQkJ +CSJtcV90aW1lZHJlY2VpdmUiLAoJCQkJIm1xX3RpbWVkcmVjZWl2ZV90aW1lNjQiLAoJCQkJIm1x +X3RpbWVkc2VuZCIsCgkJCQkibXFfdGltZWRzZW5kX3RpbWU2NCIsCgkJCQkibXFfdW5saW5rIiwK +CQkJCSJtcmVtYXAiLAoJCQkJIm1zZ2N0bCIsCgkJCQkibXNnZ2V0IiwKCQkJCSJtc2dyY3YiLAoJ +CQkJIm1zZ3NuZCIsCgkJCQkibXN5bmMiLAoJCQkJIm11bmxvY2siLAoJCQkJIm11bmxvY2thbGwi +LAoJCQkJIm11bm1hcCIsCgkJCQkibmFtZV90b19oYW5kbGVfYXQiLAoJCQkJIm5hbm9zbGVlcCIs +CgkJCQkibmV3ZnN0YXRhdCIsCgkJCQkiX25ld3NlbGVjdCIsCgkJCQkib3BlbiIsCgkJCQkib3Bl +bmF0IiwKCQkJCSJvcGVuYXQyIiwKCQkJCSJwYXVzZSIsCgkJCQkicGlkZmRfb3BlbiIsCgkJCQki +cGlkZmRfc2VuZF9zaWduYWwiLAoJCQkJInBpcGUiLAoJCQkJInBpcGUyIiwKCQkJCSJwa2V5X2Fs +bG9jIiwKCQkJCSJwa2V5X2ZyZWUiLAoJCQkJInBrZXlfbXByb3RlY3QiLAoJCQkJInBvbGwiLAoJ +CQkJInBwb2xsIiwKCQkJCSJwcG9sbF90aW1lNjQiLAoJCQkJInByY3RsIiwKCQkJCSJwcmVhZDY0 +IiwKCQkJCSJwcmVhZHYiLAoJCQkJInByZWFkdjIiLAoJCQkJInBybGltaXQ2NCIsCgkJCQkicHJv +Y2Vzc19tcmVsZWFzZSIsCgkJCQkicHNlbGVjdDYiLAoJCQkJInBzZWxlY3Q2X3RpbWU2NCIsCgkJ +CQkicHdyaXRlNjQiLAoJCQkJInB3cml0ZXYiLAoJCQkJInB3cml0ZXYyIiwKCQkJCSJyZWFkIiwK +CQkJCSJyZWFkYWhlYWQiLAoJCQkJInJlYWRsaW5rIiwKCQkJCSJyZWFkbGlua2F0IiwKCQkJCSJy +ZWFkdiIsCgkJCQkicmVjdiIsCgkJCQkicmVjdmZyb20iLAoJCQkJInJlY3ZtbXNnIiwKCQkJCSJy +ZWN2bW1zZ190aW1lNjQiLAoJCQkJInJlY3Ztc2ciLAoJCQkJInJlbWFwX2ZpbGVfcGFnZXMiLAoJ +CQkJInJlbW92ZXhhdHRyIiwKCQkJCSJyZW5hbWUiLAoJCQkJInJlbmFtZWF0IiwKCQkJCSJyZW5h +bWVhdDIiLAoJCQkJInJlc3RhcnRfc3lzY2FsbCIsCgkJCQkicm1kaXIiLAoJCQkJInJzZXEiLAoJ +CQkJInJ0X3NpZ2FjdGlvbiIsCgkJCQkicnRfc2lncGVuZGluZyIsCgkJCQkicnRfc2lncHJvY21h +c2siLAoJCQkJInJ0X3NpZ3F1ZXVlaW5mbyIsCgkJCQkicnRfc2lncmV0dXJuIiwKCQkJCSJydF9z +aWdzdXNwZW5kIiwKCQkJCSJydF9zaWd0aW1lZHdhaXQiLAoJCQkJInJ0X3NpZ3RpbWVkd2FpdF90 +aW1lNjQiLAoJCQkJInJ0X3Rnc2lncXVldWVpbmZvIiwKCQkJCSJzY2hlZF9nZXRhZmZpbml0eSIs +CgkJCQkic2NoZWRfZ2V0YXR0ciIsCgkJCQkic2NoZWRfZ2V0cGFyYW0iLAoJCQkJInNjaGVkX2dl +dF9wcmlvcml0eV9tYXgiLAoJCQkJInNjaGVkX2dldF9wcmlvcml0eV9taW4iLAoJCQkJInNjaGVk +X2dldHNjaGVkdWxlciIsCgkJCQkic2NoZWRfcnJfZ2V0X2ludGVydmFsIiwKCQkJCSJzY2hlZF9y +cl9nZXRfaW50ZXJ2YWxfdGltZTY0IiwKCQkJCSJzY2hlZF9zZXRhZmZpbml0eSIsCgkJCQkic2No +ZWRfc2V0YXR0ciIsCgkJCQkic2NoZWRfc2V0cGFyYW0iLAoJCQkJInNjaGVkX3NldHNjaGVkdWxl +ciIsCgkJCQkic2NoZWRfeWllbGQiLAoJCQkJInNlY2NvbXAiLAoJCQkJInNlbGVjdCIsCgkJCQki +c2VtY3RsIiwKCQkJCSJzZW1nZXQiLAoJCQkJInNlbW9wIiwKCQkJCSJzZW10aW1lZG9wIiwKCQkJ +CSJzZW10aW1lZG9wX3RpbWU2NCIsCgkJCQkic2VuZCIsCgkJCQkic2VuZGZpbGUiLAoJCQkJInNl +bmRmaWxlNjQiLAoJCQkJInNlbmRtbXNnIiwKCQkJCSJzZW5kbXNnIiwKCQkJCSJzZW5kdG8iLAoJ +CQkJInNldGZzZ2lkIiwKCQkJCSJzZXRmc2dpZDMyIiwKCQkJCSJzZXRmc3VpZCIsCgkJCQkic2V0 +ZnN1aWQzMiIsCgkJCQkic2V0Z2lkIiwKCQkJCSJzZXRnaWQzMiIsCgkJCQkic2V0Z3JvdXBzIiwK +CQkJCSJzZXRncm91cHMzMiIsCgkJCQkic2V0aXRpbWVyIiwKCQkJCSJzZXRwZ2lkIiwKCQkJCSJz +ZXRwcmlvcml0eSIsCgkJCQkic2V0cmVnaWQiLAoJCQkJInNldHJlZ2lkMzIiLAoJCQkJInNldHJl +c2dpZCIsCgkJCQkic2V0cmVzZ2lkMzIiLAoJCQkJInNldHJlc3VpZCIsCgkJCQkic2V0cmVzdWlk +MzIiLAoJCQkJInNldHJldWlkIiwKCQkJCSJzZXRyZXVpZDMyIiwKCQkJCSJzZXRybGltaXQiLAoJ +CQkJInNldF9yb2J1c3RfbGlzdCIsCgkJCQkic2V0c2lkIiwKCQkJCSJzZXRzb2Nrb3B0IiwKCQkJ +CSJzZXRfdGhyZWFkX2FyZWEiLAoJCQkJInNldF90aWRfYWRkcmVzcyIsCgkJCQkic2V0dWlkIiwK +CQkJCSJzZXR1aWQzMiIsCgkJCQkic2V0eGF0dHIiLAoJCQkJInNobWF0IiwKCQkJCSJzaG1jdGwi +LAoJCQkJInNobWR0IiwKCQkJCSJzaG1nZXQiLAoJCQkJInNodXRkb3duIiwKCQkJCSJzaWdhbHRz +dGFjayIsCgkJCQkic2lnbmFsZmQiLAoJCQkJInNpZ25hbGZkNCIsCgkJCQkic2lncHJvY21hc2si +LAoJCQkJInNpZ3JldHVybiIsCgkJCQkic29ja2V0Y2FsbCIsCgkJCQkic29ja2V0cGFpciIsCgkJ +CQkic3BsaWNlIiwKCQkJCSJzdGF0IiwKCQkJCSJzdGF0NjQiLAoJCQkJInN0YXRmcyIsCgkJCQki +c3RhdGZzNjQiLAoJCQkJInN0YXR4IiwKCQkJCSJzeW1saW5rIiwKCQkJCSJzeW1saW5rYXQiLAoJ +CQkJInN5bmMiLAoJCQkJInN5bmNfZmlsZV9yYW5nZSIsCgkJCQkic3luY2ZzIiwKCQkJCSJzeXNp +bmZvIiwKCQkJCSJ0ZWUiLAoJCQkJInRna2lsbCIsCgkJCQkidGltZSIsCgkJCQkidGltZXJfY3Jl +YXRlIiwKCQkJCSJ0aW1lcl9kZWxldGUiLAoJCQkJInRpbWVyX2dldG92ZXJydW4iLAoJCQkJInRp +bWVyX2dldHRpbWUiLAoJCQkJInRpbWVyX2dldHRpbWU2NCIsCgkJCQkidGltZXJfc2V0dGltZSIs +CgkJCQkidGltZXJfc2V0dGltZTY0IiwKCQkJCSJ0aW1lcmZkX2NyZWF0ZSIsCgkJCQkidGltZXJm +ZF9nZXR0aW1lIiwKCQkJCSJ0aW1lcmZkX2dldHRpbWU2NCIsCgkJCQkidGltZXJmZF9zZXR0aW1l +IiwKCQkJCSJ0aW1lcmZkX3NldHRpbWU2NCIsCgkJCQkidGltZXMiLAoJCQkJInRraWxsIiwKCQkJ +CSJ0cnVuY2F0ZSIsCgkJCQkidHJ1bmNhdGU2NCIsCgkJCQkidWdldHJsaW1pdCIsCgkJCQkidW1h +c2siLAoJCQkJInVuYW1lIiwKCQkJCSJ1bmxpbmsiLAoJCQkJInVubGlua2F0IiwKCQkJCSJ1bnNo +YXJlIiwKCQkJCSJ1dGltZSIsCgkJCQkidXRpbWVuc2F0IiwKCQkJCSJ1dGltZW5zYXRfdGltZTY0 +IiwKCQkJCSJ1dGltZXMiLAoJCQkJInZmb3JrIiwKCQkJCSJ2bXNwbGljZSIsCgkJCQkid2FpdDQi +LAoJCQkJIndhaXRpZCIsCgkJCQkid2FpdHBpZCIsCgkJCQkid3JpdGUiLAoJCQkJIndyaXRldiIK +CQkJXSwKCQkJImFjdGlvbiI6ICJTQ01QX0FDVF9BTExPVyIKCQl9LAoJCXsKCQkJIm5hbWVzIjog +WwoJCQkJInByb2Nlc3Nfdm1fcmVhZHYiLAoJCQkJInByb2Nlc3Nfdm1fd3JpdGV2IiwKCQkJCSJw +dHJhY2UiCgkJCV0sCgkJCSJhY3Rpb24iOiAiU0NNUF9BQ1RfQUxMT1ciLAoJCQkiaW5jbHVkZXMi +OiB7CgkJCQkibWluS2VybmVsIjogIjQuOCIKCQkJfQoJCX0sCgkJewoJCQkibmFtZXMiOiBbCgkJ +CQkic29ja2V0IgoJCQldLAoJCQkiYWN0aW9uIjogIlNDTVBfQUNUX0FMTE9XIiwKCQkJImFyZ3Mi +OiBbCgkJCQl7CgkJCQkJImluZGV4IjogMCwKCQkJCQkidmFsdWUiOiA0MCwKCQkJCQkib3AiOiAi +U0NNUF9DTVBfTkUiCgkJCQl9CgkJCV0KCQl9LAoJCXsKCQkJIm5hbWVzIjogWwoJCQkJInBlcnNv +bmFsaXR5IgoJCQldLAoJCQkiYWN0aW9uIjogIlNDTVBfQUNUX0FMTE9XIiwKCQkJImFyZ3MiOiBb +CgkJCQl7CgkJCQkJImluZGV4IjogMCwKCQkJCQkidmFsdWUiOiAwLAoJCQkJCSJvcCI6ICJTQ01Q +X0NNUF9FUSIKCQkJCX0KCQkJXQoJCX0sCgkJewoJCQkibmFtZXMiOiBbCgkJCQkicGVyc29uYWxp +dHkiCgkJCV0sCgkJCSJhY3Rpb24iOiAiU0NNUF9BQ1RfQUxMT1ciLAoJCQkiYXJncyI6IFsKCQkJ +CXsKCQkJCQkiaW5kZXgiOiAwLAoJCQkJCSJ2YWx1ZSI6IDgsCgkJCQkJIm9wIjogIlNDTVBfQ01Q +X0VRIgoJCQkJfQoJCQldCgkJfSwKCQl7CgkJCSJuYW1lcyI6IFsKCQkJCSJwZXJzb25hbGl0eSIK +CQkJXSwKCQkJImFjdGlvbiI6ICJTQ01QX0FDVF9BTExPVyIsCgkJCSJhcmdzIjogWwoJCQkJewoJ +CQkJCSJpbmRleCI6IDAsCgkJCQkJInZhbHVlIjogMTMxMDcyLAoJCQkJCSJvcCI6ICJTQ01QX0NN +UF9FUSIKCQkJCX0KCQkJXQoJCX0sCgkJewoJCQkibmFtZXMiOiBbCgkJCQkicGVyc29uYWxpdHki +CgkJCV0sCgkJCSJhY3Rpb24iOiAiU0NNUF9BQ1RfQUxMT1ciLAoJCQkiYXJncyI6IFsKCQkJCXsK +CQkJCQkiaW5kZXgiOiAwLAoJCQkJCSJ2YWx1ZSI6IDEzMTA4MCwKCQkJCQkib3AiOiAiU0NNUF9D +TVBfRVEiCgkJCQl9CgkJCV0KCQl9LAoJCXsKCQkJIm5hbWVzIjogWwoJCQkJInBlcnNvbmFsaXR5 +IgoJCQldLAoJCQkiYWN0aW9uIjogIlNDTVBfQUNUX0FMTE9XIiwKCQkJImFyZ3MiOiBbCgkJCQl7 +CgkJCQkJImluZGV4IjogMCwKCQkJCQkidmFsdWUiOiA0Mjk0OTY3Mjk1LAoJCQkJCSJvcCI6ICJT +Q01QX0NNUF9FUSIKCQkJCX0KCQkJXQoJCX0sCgkJewoJCQkibmFtZXMiOiBbCgkJCQkic3luY19m +aWxlX3JhbmdlMiIsCgkJCQkic3dhcGNvbnRleHQiCgkJCV0sCgkJCSJhY3Rpb24iOiAiU0NNUF9B +Q1RfQUxMT1ciLAoJCQkiaW5jbHVkZXMiOiB7CgkJCQkiYXJjaGVzIjogWwoJCQkJCSJwcGM2NGxl +IgoJCQkJXQoJCQl9CgkJfSwKCQl7CgkJCSJuYW1lcyI6IFsKCQkJCSJhcm1fZmFkdmlzZTY0XzY0 +IiwKCQkJCSJhcm1fc3luY19maWxlX3JhbmdlIiwKCQkJCSJzeW5jX2ZpbGVfcmFuZ2UyIiwKCQkJ +CSJicmVha3BvaW50IiwKCQkJCSJjYWNoZWZsdXNoIiwKCQkJCSJzZXRfdGxzIgoJCQldLAoJCQki +YWN0aW9uIjogIlNDTVBfQUNUX0FMTE9XIiwKCQkJImluY2x1ZGVzIjogewoJCQkJImFyY2hlcyI6 +IFsKCQkJCQkiYXJtIiwKCQkJCQkiYXJtNjQiCgkJCQldCgkJCX0KCQl9LAoJCXsKCQkJIm5hbWVz +IjogWwoJCQkJImFyY2hfcHJjdGwiCgkJCV0sCgkJCSJhY3Rpb24iOiAiU0NNUF9BQ1RfQUxMT1ci +LAoJCQkiaW5jbHVkZXMiOiB7CgkJCQkiYXJjaGVzIjogWwoJCQkJCSJhbWQ2NCIsCgkJCQkJIngz +MiIKCQkJCV0KCQkJfQoJCX0sCgkJewoJCQkibmFtZXMiOiBbCgkJCQkibW9kaWZ5X2xkdCIKCQkJ +XSwKCQkJImFjdGlvbiI6ICJTQ01QX0FDVF9BTExPVyIsCgkJCSJpbmNsdWRlcyI6IHsKCQkJCSJh +cmNoZXMiOiBbCgkJCQkJImFtZDY0IiwKCQkJCQkieDMyIiwKCQkJCQkieDg2IgoJCQkJXQoJCQl9 +CgkJfSwKCQl7CgkJCSJuYW1lcyI6IFsKCQkJCSJzMzkwX3BjaV9tbWlvX3JlYWQiLAoJCQkJInMz +OTBfcGNpX21taW9fd3JpdGUiLAoJCQkJInMzOTBfcnVudGltZV9pbnN0ciIKCQkJXSwKCQkJImFj +dGlvbiI6ICJTQ01QX0FDVF9BTExPVyIsCgkJCSJpbmNsdWRlcyI6IHsKCQkJCSJhcmNoZXMiOiBb +CgkJCQkJInMzOTAiLAoJCQkJCSJzMzkweCIKCQkJCV0KCQkJfQoJCX0sCgkJewoJCQkibmFtZXMi +OiBbCgkJCQkicmlzY3ZfZmx1c2hfaWNhY2hlIgoJCQldLAoJCQkiYWN0aW9uIjogIlNDTVBfQUNU +X0FMTE9XIiwKCQkJImluY2x1ZGVzIjogewoJCQkJImFyY2hlcyI6IFsKCQkJCQkicmlzY3Y2NCIK +CQkJCV0KCQkJfQoJCX0sCgkJewoJCQkibmFtZXMiOiBbCgkJCQkib3Blbl9ieV9oYW5kbGVfYXQi +CgkJCV0sCgkJCSJhY3Rpb24iOiAiU0NNUF9BQ1RfQUxMT1ciLAoJCQkiaW5jbHVkZXMiOiB7CgkJ +CQkiY2FwcyI6IFsKCQkJCQkiQ0FQX0RBQ19SRUFEX1NFQVJDSCIKCQkJCV0KCQkJfQoJCX0sCgkJ +ewoJCQkibmFtZXMiOiBbCgkJCQkiYnBmIiwKCQkJCSJjbG9uZSIsCgkJCQkiY2xvbmUzIiwKCQkJ +CSJmYW5vdGlmeV9pbml0IiwKCQkJCSJmc2NvbmZpZyIsCgkJCQkiZnNtb3VudCIsCgkJCQkiZnNv +cGVuIiwKCQkJCSJmc3BpY2siLAoJCQkJImxvb2t1cF9kY29va2llIiwKCQkJCSJtb3VudF9zZXRh +dHRyIiwKCQkJCSJtb3ZlX21vdW50IiwKCQkJCSJvcGVuX3RyZWUiLAoJCQkJInBlcmZfZXZlbnRf +b3BlbiIsCgkJCQkicXVvdGFjdGwiLAoJCQkJInF1b3RhY3RsX2ZkIiwKCQkJCSJzZXRkb21haW5u +YW1lIiwKCQkJCSJzZXRob3N0bmFtZSIsCgkJCQkic2V0bnMiLAoJCQkJInN5c2xvZyIsCgkJCQki +dW1vdW50IiwKCQkJCSJ1bW91bnQyIgoJCQldLAoJCQkiYWN0aW9uIjogIlNDTVBfQUNUX0FMTE9X +IiwKCQkJImluY2x1ZGVzIjogewoJCQkJImNhcHMiOiBbCgkJCQkJIkNBUF9TWVNfQURNSU4iCgkJ +CQldCgkJCX0KCQl9LAoJCXsKCQkJIm5hbWVzIjogWwoJCQkJImNsb25lIgoJCQldLAoJCQkiYWN0 +aW9uIjogIlNDTVBfQUNUX0FMTE9XIiwKCQkJImFyZ3MiOiBbCgkJCQl7CgkJCQkJImluZGV4Ijog +MCwKCQkJCQkidmFsdWUiOiAyMTE0MDYwMjg4LAoJCQkJCSJvcCI6ICJTQ01QX0NNUF9NQVNLRURf +RVEiCgkJCQl9CgkJCV0sCgkJCSJleGNsdWRlcyI6IHsKCQkJCSJjYXBzIjogWwoJCQkJCSJDQVBf +U1lTX0FETUlOIgoJCQkJXSwKCQkJCSJhcmNoZXMiOiBbCgkJCQkJInMzOTAiLAoJCQkJCSJzMzkw +eCIKCQkJCV0KCQkJfQoJCX0sCgkJewoJCQkibmFtZXMiOiBbCgkJCQkiY2xvbmUiCgkJCV0sCgkJ +CSJhY3Rpb24iOiAiU0NNUF9BQ1RfQUxMT1ciLAoJCQkiYXJncyI6IFsKCQkJCXsKCQkJCQkiaW5k +ZXgiOiAxLAoJCQkJCSJ2YWx1ZSI6IDIxMTQwNjAyODgsCgkJCQkJIm9wIjogIlNDTVBfQ01QX01B +U0tFRF9FUSIKCQkJCX0KCQkJXSwKCQkJImNvbW1lbnQiOiAiczM5MCBwYXJhbWV0ZXIgb3JkZXJp +bmcgZm9yIGNsb25lIGlzIGRpZmZlcmVudCIsCgkJCSJpbmNsdWRlcyI6IHsKCQkJCSJhcmNoZXMi +OiBbCgkJCQkJInMzOTAiLAoJCQkJCSJzMzkweCIKCQkJCV0KCQkJfSwKCQkJImV4Y2x1ZGVzIjog +ewoJCQkJImNhcHMiOiBbCgkJCQkJIkNBUF9TWVNfQURNSU4iCgkJCQldCgkJCX0KCQl9LAoJCXsK +CQkJIm5hbWVzIjogWwoJCQkJImNsb25lMyIKCQkJXSwKCQkJImFjdGlvbiI6ICJTQ01QX0FDVF9F +UlJOTyIsCgkJCSJlcnJub1JldCI6IDM4LAoJCQkiZXhjbHVkZXMiOiB7CgkJCQkiY2FwcyI6IFsK +CQkJCQkiQ0FQX1NZU19BRE1JTiIKCQkJCV0KCQkJfQoJCX0sCgkJewoJCQkibmFtZXMiOiBbCgkJ +CQkicmVib290IgoJCQldLAoJCQkiYWN0aW9uIjogIlNDTVBfQUNUX0FMTE9XIiwKCQkJImluY2x1 +ZGVzIjogewoJCQkJImNhcHMiOiBbCgkJCQkJIkNBUF9TWVNfQk9PVCIKCQkJCV0KCQkJfQoJCX0s +CgkJewoJCQkibmFtZXMiOiBbCgkJCQkiY2hyb290IgoJCQldLAoJCQkiYWN0aW9uIjogIlNDTVBf +QUNUX0FMTE9XIiwKCQkJImluY2x1ZGVzIjogewoJCQkJImNhcHMiOiBbCgkJCQkJIkNBUF9TWVNf +Q0hST09UIgoJCQkJXQoJCQl9CgkJfSwKCQl7CgkJCSJuYW1lcyI6IFsKCQkJCSJkZWxldGVfbW9k +dWxlIiwKCQkJCSJpbml0X21vZHVsZSIsCgkJCQkiZmluaXRfbW9kdWxlIgoJCQldLAoJCQkiYWN0 +aW9uIjogIlNDTVBfQUNUX0FMTE9XIiwKCQkJImluY2x1ZGVzIjogewoJCQkJImNhcHMiOiBbCgkJ +CQkJIkNBUF9TWVNfTU9EVUxFIgoJCQkJXQoJCQl9CgkJfSwKCQl7CgkJCSJuYW1lcyI6IFsKCQkJ +CSJhY2N0IgoJCQldLAoJCQkiYWN0aW9uIjogIlNDTVBfQUNUX0FMTE9XIiwKCQkJImluY2x1ZGVz +IjogewoJCQkJImNhcHMiOiBbCgkJCQkJIkNBUF9TWVNfUEFDQ1QiCgkJCQldCgkJCX0KCQl9LAoJ +CXsKCQkJIm5hbWVzIjogWwoJCQkJImtjbXAiLAoJCQkJInBpZGZkX2dldGZkIiwKCQkJCSJwcm9j +ZXNzX21hZHZpc2UiLAoJCQkJInByb2Nlc3Nfdm1fcmVhZHYiLAoJCQkJInByb2Nlc3Nfdm1fd3Jp +dGV2IiwKCQkJCSJwdHJhY2UiCgkJCV0sCgkJCSJhY3Rpb24iOiAiU0NNUF9BQ1RfQUxMT1ciLAoJ +CQkiaW5jbHVkZXMiOiB7CgkJCQkiY2FwcyI6IFsKCQkJCQkiQ0FQX1NZU19QVFJBQ0UiCgkJCQld +CgkJCX0KCQl9LAoJCXsKCQkJIm5hbWVzIjogWwoJCQkJImlvcGwiLAoJCQkJImlvcGVybSIKCQkJ +XSwKCQkJImFjdGlvbiI6ICJTQ01QX0FDVF9BTExPVyIsCgkJCSJpbmNsdWRlcyI6IHsKCQkJCSJj +YXBzIjogWwoJCQkJCSJDQVBfU1lTX1JBV0lPIgoJCQkJXQoJCQl9CgkJfSwKCQl7CgkJCSJuYW1l +cyI6IFsKCQkJCSJzZXR0aW1lb2ZkYXkiLAoJCQkJInN0aW1lIiwKCQkJCSJjbG9ja19zZXR0aW1l +IiwKCQkJCSJjbG9ja19zZXR0aW1lNjQiCgkJCV0sCgkJCSJhY3Rpb24iOiAiU0NNUF9BQ1RfQUxM +T1ciLAoJCQkiaW5jbHVkZXMiOiB7CgkJCQkiY2FwcyI6IFsKCQkJCQkiQ0FQX1NZU19USU1FIgoJ +CQkJXQoJCQl9CgkJfSwKCQl7CgkJCSJuYW1lcyI6IFsKCQkJCSJ2aGFuZ3VwIgoJCQldLAoJCQki +YWN0aW9uIjogIlNDTVBfQUNUX0FMTE9XIiwKCQkJImluY2x1ZGVzIjogewoJCQkJImNhcHMiOiBb +CgkJCQkJIkNBUF9TWVNfVFRZX0NPTkZJRyIKCQkJCV0KCQkJfQoJCX0sCgkJewoJCQkibmFtZXMi +OiBbCgkJCQkiZ2V0X21lbXBvbGljeSIsCgkJCQkibWJpbmQiLAoJCQkJInNldF9tZW1wb2xpY3ki +CgkJCV0sCgkJCSJhY3Rpb24iOiAiU0NNUF9BQ1RfQUxMT1ciLAoJCQkiaW5jbHVkZXMiOiB7CgkJ +CQkiY2FwcyI6IFsKCQkJCQkiQ0FQX1NZU19OSUNFIgoJCQkJXQoJCQl9CgkJfSwKCQl7CgkJCSJu +YW1lcyI6IFsKCQkJCSJzeXNsb2ciCgkJCV0sCgkJCSJhY3Rpb24iOiAiU0NNUF9BQ1RfQUxMT1ci +LAoJCQkiaW5jbHVkZXMiOiB7CgkJCQkiY2FwcyI6IFsKCQkJCQkiQ0FQX1NZU0xPRyIKCQkJCV0K +CQkJfQoJCX0sCgkJewoJCQkibmFtZXMiOiBbCgkJCQkiYnBmIgoJCQldLAoJCQkiYWN0aW9uIjog +IlNDTVBfQUNUX0FMTE9XIiwKCQkJImluY2x1ZGVzIjogewoJCQkJImNhcHMiOiBbCgkJCQkJIkNB +UF9CUEYiCgkJCQldCgkJCX0KCQl9LAoJCXsKCQkJIm5hbWVzIjogWwoJCQkJInBlcmZfZXZlbnRf +b3BlbiIKCQkJXSwKCQkJImFjdGlvbiI6ICJTQ01QX0FDVF9BTExPVyIsCgkJCSJpbmNsdWRlcyI6 +IHsKCQkJCSJjYXBzIjogWwoJCQkJCSJDQVBfUEVSRk1PTiIKCQkJCV0KCQkJfQoJCX0KCV0KfQo= +""" + +#: docker-seccomp.json contents (bytes), decoded from the embedded base64. +SECCOMP_BYTES = base64.b64decode("".join(_SECCOMP_B64_WRAPPED.split())) + +#: Special characters allowed in generated passwords. Restricted to the chars +#: that need no escaping in cmd, bash, JSON and YAML (single-quoted) contexts - +#: so the same value is safe in a vault record, a docker-compose env and a shell. +#: A special char is required to satisfy enterprise password-complexity policy. +SAFE_SPECIAL_CHARACTERS = "-._" + +#: Base name for the generated compose file. +COMPOSE_FILENAME = "docker-compose.yaml" +#: Seccomp filename referenced by the gateway ``security_opt``. +SECCOMP_FILENAME = "docker-seccomp.json" + + +def compute_network_id(project_name: str) -> str: + """Derive the docker network id from the project name. + + Matches the Web Vault ``ContentWizardProcessing`` rule so the PAM + configuration ``networkId`` and the generated compose network name agree. + """ + return (project_name or "").replace(" ", "_")[:10] or "pam-net" + + +def compose_project_name(project_name: str) -> str: + """Derive a docker-compose project name (top-level ``name:``) from the project. + + Compose requires lowercase ``[a-z0-9][a-z0-9_-]*``; without an explicit name it + defaults to the output directory (e.g. ``tmp``). We set it from ``--name`` so + Docker Desktop shows the project meaningfully. + """ + s = re.sub(r"[^a-z0-9_-]+", "-", (project_name or "").lower()).strip("-_") + return s or "playground" + + +# ===================================================================== +# 2. Credentials +# ===================================================================== + +def _generate_password() -> str: + """Generate a 20-char password: 4 symbols, 4 digits, 4 caps, 4 lower. + + Matches the ``pwd_complexity="20,4,4,4,4"`` used for rotation, but the symbol + set is restricted to :data:`SAFE_SPECIAL_CHARACTERS` (``-._``) so the value + never needs escaping in cmd/bash/JSON/YAML. Including a symbol satisfies + enterprise password-complexity policy; the mix also satisfies SQL Server + complexity (>=3 of upper/lower/digit/symbol). + """ + return KeeperPasswordGenerator( + length=20, symbols=4, digits=4, caps=4, lower=4, + special_characters=SAFE_SPECIAL_CHARACTERS).generate() + + +#: A generated secret is "safe" when it needs no escaping in cmd/bash/JSON/YAML. +_SAFE_SECRET_RE = re.compile(r"^[A-Za-z0-9" + re.escape(SAFE_SPECIAL_CHARACTERS) + r"]+$") + +# Substrings identifying the expected, benign log noise emitted by the record-add +# path (enforcement.py password-complexity + breachwatch.py) when a record uses a +# KNOWN weak/non-compliant documentary credential (image-fixed OpenLDAP demo +# users). Suppressed only around those records - generated passwords are +# pre-validated and random, so they never trip these. +_EXPECTED_NOISE_MARKERS = ( + "Passphrase must", "Passphrase cannot", "Passphrase contains", + "passphrase word must", "Password must", "complexity policy", + "password policy", "High-Risk password detected", +) + + +class _ExpectedNoiseFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: # False drops the record + try: + msg = record.getMessage() + except Exception: # pragma: no cover - defensive + return True + return not any(marker in msg for marker in _EXPECTED_NOISE_MARKERS) + + +@contextlib.contextmanager +def _suppress_expected_warnings(): + """Drop expected complexity/BreachWatch noise for the duration of the block. + + Installed on the root logger and its handlers so it catches the messages + regardless of which logger or level emits them. Used only when creating + records with intentionally weak, image-fixed documentary credentials + (see _create_openldap_records). + """ + filt = _ExpectedNoiseFilter() + root = logging.getLogger() + # Filter on the root logger (catches module-level logging.warning calls) and + # on its handlers (catches records propagated from child loggers). + targets = [root, *root.handlers] + for t in targets: + t.addFilter(filt) + try: + yield + finally: + for t in targets: + t.removeFilter(filt) + + +def _is_safe_secret(value: str) -> bool: + return bool(value) and bool(_SAFE_SECRET_RE.match(value)) + + +def _generate_policy_password(policy: dict) -> str: + """Generate a random password satisfying the policy's password (not passphrase) rules. + + Mirrors the Web Vault (``getPasswordRules.ts`` + ``generateKeeperPassword``): + honor each per-class minimum and the policy's allowed special-character set. + Passphrase is only the Vault's *fallback* when a random password fails the + rules, so a random password built to the rules always passes the primary + branch - no passphrase needed. The special set prefers the shell/YAML-safe + subset (``-._``) when the policy allows it, else uses the policy's set (which + still round-trips through single-quoted YAML and JSON). + """ + from ...enforcement import _coerce_int + + def _req(use_key: str, min_key: str) -> int: + if not policy.get(use_key): + return 0 + n = _coerce_int(policy.get(min_key)) + return n if (isinstance(n, int) and n > 0) else 1 + + # Keep a solid baseline mix (helps service complexity, e.g. SQL Server) while + # never going below what the policy demands. + lower = max(_req("lower-use", "lower-min"), 2) + upper = max(_req("upper-use", "upper-min"), 2) + digit = max(_req("digit-use", "digit-min"), 2) + special = _req("special-use", "special-min") + + if special > 0: + allowed = policy.get("special") or "" + if allowed: + # The validator counts only chars in the policy's special set. + safe = "".join(ch for ch in SAFE_SPECIAL_CHARACTERS if ch in allowed) + specials = safe or allowed # prefer safe chars, else the policy's set + else: + specials = SAFE_SPECIAL_CHARACTERS # any non-alnum counts -> use safe + else: + specials = SAFE_SPECIAL_CHARACTERS + special = 1 # include one safe special: harmless and strengthens the password + + length = _coerce_int(policy.get("length")) or 0 + length = max(length, DEFAULT_PASSWORD_LENGTH, lower + upper + digit + special) + + return KeeperPasswordGenerator( + length=length, symbols=special, digits=digit, caps=upper, lower=lower, + special_characters=specials).generate() + + +def _build_password_factory(params) -> Callable[[], str]: + """Return a zero-arg secret generator honoring the enterprise password policy. + + With no policy, yields a shell/YAML-safe random password. With a policy, + yields a random password built to satisfy the policy's password rules + (see :func:`_generate_policy_password`), re-validated with a small retry. + """ + try: + from ...enforcement import PasswordComplexityEnforcer + policy = PasswordComplexityEnforcer.get_policy(params) + except Exception as e: # pragma: no cover - defensive + logging.debug("Could not read password-complexity policy: %s", e) + return _generate_password + + if not policy: + return _generate_password + + from ...enforcement import PasswordComplexityEnforcer + + def _gen() -> str: + pw = _generate_policy_password(policy) + for _ in range(5): + if not PasswordComplexityEnforcer.validate_password(pw, policy): + return pw + pw = _generate_policy_password(policy) + logging.debug("Generated password still failed policy after retries; using last attempt") + return pw + + return _gen + + +def _generate_ssh_keypair(comment: str = "linuxuser@local"): + """Generate an RSA-2048 keypair (OpenSSH PEM private, OpenSSH public). + + Matches the origin/vault sample (RSA, not Ed25519). Returns + ``(private_openssh_pem, public_openssh_line)``. + """ + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + private_pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.OpenSSH, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + public_line = key.public_key().public_bytes( + encoding=serialization.Encoding.OpenSSH, + format=serialization.PublicFormat.OpenSSH, + ).decode("utf-8") + if comment: + public_line = f"{public_line} {comment}" + return private_pem, public_line + + +class PlaygroundCredentials: + """All secrets for one ``--sample-data`` run, generated once up front. + + Randomized per run (see the plan table). ``server-openldap-1`` is baked + into the ``rroemhild/test-openldap`` image and ``server-telnet`` has no auth, + so those creds are fixed/documentary, not generated. + """ + + # OpenLDAP demo directory (image rroemhild/test-openldap - fixed creds). + OPENLDAP_DOMAIN = "planetexpress.com" + OPENLDAP_ADMIN_DN = "cn=admin,dc=planetexpress,dc=com" + OPENLDAP_ADMIN_PASSWORD = "GoodNewsEveryone" + # (login, password, distinguished name) for the fixed demo users. + OPENLDAP_DEMO_USERS = [ + ("professor", "professor", "cn=Hubert J. Farnsworth,ou=people,dc=planetexpress,dc=com"), + ("fry", "fry", "cn=Philip J. Fry,ou=people,dc=planetexpress,dc=com"), + ("zoidberg", "zoidberg", "cn=John A. Zoidberg,ou=people,dc=planetexpress,dc=com"), + ("hermes", "hermes", "cn=Hermes Conrad,ou=people,dc=planetexpress,dc=com"), + ("leela", "leela", "cn=Turanga Leela,ou=people,dc=planetexpress,dc=com"), + ("bender", "bender", "cn=Bender Bending Rodriguez,ou=people,dc=planetexpress,dc=com"), + ("amy", "amy", "cn=Amy Wong+sn=Kroker,ou=people,dc=planetexpress,dc=com"), + ] + + def __init__(self, password_factory: Optional[Callable[[], str]] = None): + # `password_factory` yields one policy-compliant, shell/YAML-safe secret + # per call (see _build_password_factory). Defaults to a safe random password. + gen = password_factory or _generate_password + # MySQL (db-mysql-1) + self.mysql_root_password = gen() + self.mysql_user_password = gen() + # PostgreSQL (db-postgres-1) + self.postgres_password = gen() + # MariaDB (db-mariadb-1) + self.mariadb_root_password = gen() + self.mariadb_user_password = gen() + # Microsoft SQL Server (db-mssql) + self.mssql_sa_password = gen() + # MongoDB (db-mongo) + self.mongo_root_password = gen() + self.mongo_user_password = gen() + # SSH password (server-ssh-with-pwd-1) + self.ssh_password = gen() + # SSH key (server-ssh-with-key-1) + self.ssh_private_key, self.ssh_public_key = _generate_ssh_keypair() + # VNC (server-vnc) + self.vnc_password = gen() + # RDP (server-rdp) + self.rdp_user_password = gen() + self.rdp_root_password = gen() + # Telnet (server-telnet) - compose has no auth; used for rotation testing only + self.telnet_password = gen() + + +# ===================================================================== +# 3. Session +# ===================================================================== + +class PlaygroundSession: + """Orchestrates the sample-data flow: credentials, records, compose, output.""" + + def __init__(self, params, project: dict): + self.params = params + self.project = project + self.project_name: str = project["options"]["project_name"] + self.network_id: str = compute_network_id(self.project_name) + self.users_folder_uid: str = project["folders"]["users_folder_uid"] + self.resources_folder_uid: str = project["folders"]["resources_folder_uid"] + self.pam_config_uid: str = project["pam_config"]["pam_config_uid"] + # Generate credentials compliant with the enterprise password policy + # (passphrase or random), all shell/YAML-safe. + self.creds = PlaygroundCredentials(_build_password_factory(params)) + + # Command/DAG helpers (initialized in create_all_records). + self._command: Optional[RecordEditAddCommand] = None + self._pte: Optional[PAMTunnelEditCommand] = None + self._tdag: Optional[TunnelDAG] = None + + # ---- record creation ------------------------------------------------ + + def create_all_records(self): + """Create every playground record + DAG link, matching the origin services.""" + from ..discoveryrotation import PAMCreateRecordRotationCommand + self._rotation_cls = PAMCreateRecordRotationCommand + + self._command = RecordEditAddCommand() + self._pte = PAMTunnelEditCommand() + + encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(self.params) + self._tdag = TunnelDAG( + self.params, encrypted_session_token, encrypted_transmission_key, + self.pam_config_uid, True, transmission_key=transmission_key) + # Fix: rotation/connections disabled by the PAM configuration. + self._tdag.set_resource_allowed( + self.pam_config_uid, is_config=True, rotation=True, connections=True, tunneling=True, + session_recording=True, typescript_recording=True, remote_browser_isolation=True) + + self._create_mysql_records() + self._create_ssh_password_records() + self._create_ssh_key_records() + self._create_vnc_records() + self._create_rdp_records() + self._create_rbi_record() + self._create_postgresql_records() + self._create_mariadb_records() + self._create_mssql_records() + # MongoDB interactive connect is not yet wired end-to-end: the gateway's + # WebRTC ConversationType enum (keeper-pam-webrtc-rs models.rs) has no + # "mongodb" variant, so a KeeperDB connect fails with + # "'mongodb' is not a valid ConversationType" + # even though a guacr MongoDbHandler exists (it's never reached). WV sends + # conversationType="mongodb"+useKeeperDb=true correctly; only + # mysql/postgresql/sql-server are routable DB conversation types today. + # Re-enable this call + the db-mongo compose service + its GATEWAY_DEPENDS_ON + # entry once the gateway/keeper-pam-connections adds mongodb (and the other + # DB protocols) to ConversationType. + # self._create_mongodb_records() + self._create_telnet_records() + self._create_openldap_records() + + api.sync_down(self.params) + + # ---- record-creation helpers --------------------------------------- + + def _title(self, suffix: str) -> str: + # Record titles are NOT project-prefixed - the enclosing folders + # (e.g. " - Resources"/"- Users") already provide that context. + return suffix + + def _add(self, record_type: str, folder_uid: str, suffix: str, + fields: List[str], notes: Optional[str] = None, + quiet_policy: bool = False) -> str: + """Create a typed record via the record-add command; return its UID. + + Uses dot-notation field specs (``f.login=..``, ``c.pamSettings=$JSON:..``) + exactly like the PAM importer (pam_import/base.py) so records are built + from the record-type definition - no legacy "Unknown field types" noise - + and ``force=True`` bypasses the interactive password-policy prompt. + + ``quiet_policy`` suppresses the (expected) complexity-policy and + BreachWatch "High-Risk password" noise for records whose credentials are + intentionally weak/fixed (image-baked OpenLDAP demo users); ``force=True`` + still creates them. + """ + args: Dict[str, Any] = { + "force": True, "folder": folder_uid, + "record_type": record_type, "title": self._title(suffix), + } + if fields: + args["fields"] = fields + if notes: + args["notes"] = notes + if quiet_policy: + with _suppress_expected_warnings(): + return self._command.execute(self.params, **args) + return self._command.execute(self.params, **args) + + def _add_user(self, suffix: str, login: str, password: Optional[str] = None, + private_key: Optional[str] = None, notes: Optional[str] = None, + distinguished_name: Optional[str] = None, quiet_policy: bool = False) -> str: + """Create a pamUser record and return its UID. + + ``distinguished_name`` is required to rotate a directory (LDAP/AD) user - + kdnrm modifies the entry by its DN, not by the login/uid. + """ + fields = [f"f.login={login}"] + if private_key is not None: + fields.append(f"f.secret.privatePEMKey={private_key}") # password left empty + elif password: + fields.append(f"f.password={password}") + if distinguished_name: + fields.append(f"f.text.distinguishedName={distinguished_name}") + return self._add("pamUser", self.users_folder_uid, suffix, fields, notes, + quiet_policy=quiet_policy) + + def _add_resource(self, record_type: str, suffix: str, *, host: Optional[str] = None, + port: Optional[str] = None, database_type: Optional[str] = None, + directory_type: Optional[str] = None, domain_name: Optional[str] = None, + rbi_url: Optional[str] = None, connection: Optional[dict] = None, + port_forward: Optional[dict] = None, notes: Optional[str] = None) -> str: + """Create a resource record (pamMachine/pamDatabase/pamDirectory/pamRemoteBrowser). + + ``trafficEncryptionSeed`` is intentionally omitted - it is populated later + by the tunnel/DAG step (matches pam_import/base.py). + """ + fields: List[str] = [] + if host is not None or port is not None: + fields.append("f.pamHostname=$JSON:" + json.dumps({"hostName": host or "", "port": port or ""})) + if database_type: + fields.append(f"f.databaseType={database_type}") + if directory_type: + fields.append(f"f.directoryType={directory_type}") + if domain_name: + fields.append(f"f.text.domainName={domain_name}") + if record_type == "pamRemoteBrowser": + if rbi_url: + fields.append(f"rbiUrl={rbi_url}") + if connection is not None: + fields.append("pamRemoteBrowserSettings=$JSON:" + json.dumps({"connection": connection})) + elif connection is not None or port_forward is not None: + fields.append("c.pamSettings=$JSON:" + json.dumps( + {"allowSupplyHost": False, "portForward": port_forward or {}, "connection": connection or {}})) + return self._add(record_type, self.resources_folder_uid, suffix, fields, notes) + + def _allow_resource(self, resource_uid: str, rotation=True, connections=True, tunneling=True, + session_recording=True, typescript_recording=True, remote_browser_isolation=True): + self._tdag.set_resource_allowed( + resource_uid=resource_uid, rotation=rotation, connections=connections, tunneling=tunneling, + session_recording=session_recording, typescript_recording=typescript_recording, + remote_browser_isolation=remote_browser_isolation) + + def _enable_connection(self, resource_uid: str, admin_user_uid: Optional[str] = None, + enable_connections=True, enable_tunneling=True): + self._tdag.link_resource_to_config(resource_uid) + kwargs = dict(record=resource_uid, config=self.pam_config_uid, + enable_connections=enable_connections, enable_tunneling=enable_tunneling, silent=True) + if admin_user_uid: + kwargs["admin"] = admin_user_uid + self._pte.execute(self.params, **kwargs) + + def _rotate(self, record_uid: str, admin_user_uid: str, resource_uid: str): + # PAMCreateRecordRotationCommand resolves the record/admin/resource from + # the local cache; records are created with sync deferred (sync_data), so + # sync first or the rotation SETTINGS silently fail to persist (the record + # would then show RRS_NO_ROTATION even though the DAG rotation flag is set). + api.sync_down(self.params) + self._rotation_cls().execute( + self.params, record_name=record_uid, admin=admin_user_uid, + config=self.pam_config_uid, resource=resource_uid, + on_demand=True, pwd_complexity="20,4,4,4,4", enable=True, force=True, silent=True) + + # ---- per-service record creators ----------------------------------- + + def _create_mysql_records(self): + admin_uid = self._add_user("MySQL Admin User", "root", self.creds.mysql_root_password) + rotation_uid = self._add_user("MySQL Rotation User", "sqluser", self.creds.mysql_user_password) + db_uid = self._add_resource( + "pamDatabase", "MySQL Database", host="db-mysql-1", port="3306", + connection={"protocol": "mysql", "database": "salesdb", "userRecords": [admin_uid]}, + port_forward={"reusePort": True}) + self._enable_connection(db_uid, admin_uid) + self._allow_resource(db_uid) + self._tdag.link_user_to_resource(admin_uid, db_uid, True, True) + self._tdag.link_user_to_resource(rotation_uid, db_uid, False, True) + self._rotate(rotation_uid, admin_uid, db_uid) + + def _create_ssh_password_records(self): + admin_uid = self._add_user("SSH Admin with Password", "linuxuser", self.creds.ssh_password) + machine_uid = self._add_resource( + "pamMachine", "SSH Machine with Password Access", + host="server-ssh-with-pwd-1", port="2222", + connection={"protocol": "ssh", "userRecords": [admin_uid]}, + port_forward={"reusePort": True}) + self._enable_connection(machine_uid, admin_uid) + self._allow_resource(machine_uid) + self._tdag.link_user_to_resource(admin_uid, machine_uid, True, True) + self._rotate(admin_uid, admin_uid, machine_uid) + + def _create_ssh_key_records(self): + admin_uid = self._add_user("SSH Admin with Private Key", "linuxuser", + private_key=self.creds.ssh_private_key) + machine_uid = self._add_resource( + "pamMachine", "SSH Machine with Private Key Access", + host="server-ssh-with-key-1", port="2222", + connection={"protocol": "ssh", "userRecords": [admin_uid]}, + port_forward={"reusePort": True}) + self._enable_connection(machine_uid, admin_uid) + self._allow_resource(machine_uid) + self._tdag.link_user_to_resource(admin_uid, machine_uid, True, True) + self._rotate(admin_uid, admin_uid, machine_uid) + + def _create_vnc_records(self): + admin_uid = self._add_user("VNC Admin", "vncuser", self.creds.vnc_password) + machine_uid = self._add_resource( + "pamMachine", "VNC Machine", host="server-vnc", port="5901", + connection={"protocol": "vnc", "userRecords": [admin_uid]}, + port_forward={"reusePort": True}) + self._enable_connection(machine_uid, admin_uid) + self._allow_resource(machine_uid) + self._tdag.link_user_to_resource(admin_uid, machine_uid, True, True) + + def _create_rdp_records(self): + user_uid = self._add_user("RDP User", "linuxuser", self.creds.rdp_user_password) + admin_uid = self._add_user("RDP Admin", "root", self.creds.rdp_root_password) + machine_uid = self._add_resource( + "pamMachine", "RDP Machine", host="server-rdp", port="3389", + connection={"protocol": "rdp", "security": "any", "ignoreCert": True, + "resizeMethod": "display-update", "userRecords": [admin_uid]}, + port_forward={"reusePort": True}) + self._enable_connection(machine_uid, admin_uid) + self._allow_resource(machine_uid) + self._tdag.link_user_to_resource(admin_uid, machine_uid, True, True) + self._tdag.link_user_to_resource(user_uid, machine_uid, False, True) + + def _create_rbi_record(self): + rbi_uid = self._add_resource( + "pamRemoteBrowser", "Bing Remote Browser", rbi_url="https://bing.com", + connection={"protocol": "http", "allowUrlManipulation": True, "userRecords": []}) + self._tdag.link_resource_to_config(rbi_uid) + self._pte.execute(self.params, record=rbi_uid, config=self.pam_config_uid, + enable_rotation=False, enable_connections=True, enable_tunneling=False, + enable_typescripts_recording=False, enable_connections_recording=True, silent=True) + self._allow_resource(rbi_uid, rotation=False, connections=True, tunneling=False, + session_recording=True, typescript_recording=False, remote_browser_isolation=True) + + def _create_postgresql_records(self): + admin_uid = self._add_user("PostgreSQL Admin User", "postgres", self.creds.postgres_password) + db_uid = self._add_resource( + "pamDatabase", "PostgreSQL Database", host="db-postgres-1", port="5432", + database_type="postgresql", + # database name must match compose POSTGRES_DB ("postgres"), not the type + connection={"protocol": "postgresql", "database": "postgres", "userRecords": [admin_uid]}, + port_forward={"reusePort": True}) + self._enable_connection(db_uid, admin_uid) + self._allow_resource(db_uid) + self._tdag.link_user_to_resource(admin_uid, db_uid, True, True) + self._rotate(admin_uid, admin_uid, db_uid) + + def _create_mariadb_records(self): + admin_uid = self._add_user("MariaDB Admin User", "root", self.creds.mariadb_root_password) + rotation_uid = self._add_user("MariaDB Rotation User", "max", self.creds.mariadb_user_password) + db_uid = self._add_resource( + "pamDatabase", "MariaDB Database", host="db-mariadb-1", port="3306", + database_type="mariadb", + connection={"protocol": "mysql", "database": "mydb", "userRecords": [admin_uid]}, + port_forward={"reusePort": True}) + self._enable_connection(db_uid, admin_uid) + self._allow_resource(db_uid) + self._tdag.link_user_to_resource(admin_uid, db_uid, True, True) + self._tdag.link_user_to_resource(rotation_uid, db_uid, False, True) + self._rotate(rotation_uid, admin_uid, db_uid) + + def _create_mssql_records(self): + admin_uid = self._add_user("Microsoft SQL Server Admin User", "sa", self.creds.mssql_sa_password) + db_uid = self._add_resource( + "pamDatabase", "Microsoft SQL Server Database", host="db-mssql", port="1433", + database_type="mssql", + connection={"protocol": "sql-server", "database": "master", "userRecords": [admin_uid]}, + port_forward={"reusePort": True}) + self._enable_connection(db_uid, admin_uid) + self._allow_resource(db_uid) + self._tdag.link_user_to_resource(admin_uid, db_uid, True, True) + self._rotate(admin_uid, admin_uid, db_uid) + + def _create_mongodb_records(self): + admin_uid = self._add_user("MongoDB Admin User", "root", self.creds.mongo_root_password) + rotation_uid = self._add_user("MongoDB Rotation User", "user1", self.creds.mongo_user_password) + db_uid = self._add_resource( + "pamDatabase", "MongoDB Database", host="db-mongo", port="27017", + database_type="mongodb", + connection={"protocol": "mongodb", "database": "mydatabase", "userRecords": [admin_uid]}, + port_forward={"reusePort": True}) + self._enable_connection(db_uid, admin_uid) + self._allow_resource(db_uid) + self._tdag.link_user_to_resource(admin_uid, db_uid, True, True) + self._tdag.link_user_to_resource(rotation_uid, db_uid, False, True) + self._rotate(rotation_uid, admin_uid, db_uid) + + def _create_telnet_records(self): + # Compose telnet (nyancat) has no auth; these creds exist only for + # rotation/discovery testing on the KC side. + admin_uid = self._add_user("Telnet Admin", "user", self.creds.telnet_password) + machine_uid = self._add_resource( + "pamMachine", "Telnet Machine", host="server-telnet", port="23", + connection={"protocol": "telnet", "userRecords": [admin_uid]}, + port_forward={"reusePort": True}) + self._enable_connection(machine_uid, admin_uid) + self._allow_resource(machine_uid) + self._tdag.link_user_to_resource(admin_uid, machine_uid, True, True) + self._rotate(admin_uid, admin_uid, machine_uid) + + def _create_openldap_records(self): + # server-openldap-1 uses the rroemhild/test-openldap image with fixed + # demo credentials (documented, not generated). No compose secrets. + c = self.creds + admin_uid = self._add_user( + "OpenLDAP Admin", c.OPENLDAP_ADMIN_DN, c.OPENLDAP_ADMIN_PASSWORD, + notes=f"OpenLDAP directory admin (image-fixed). Domain: {c.OPENLDAP_DOMAIN}", + distinguished_name=c.OPENLDAP_ADMIN_DN, # bind DN for directory rotation + quiet_policy=True) # image-fixed creds intentionally don't meet policy + dir_uid = self._add_resource( + "pamDirectory", "OpenLDAP Directory", host="server-openldap-1", port="10389", + directory_type="openldap", domain_name=c.OPENLDAP_DOMAIN, + connection={"userRecords": [admin_uid]}, port_forward={"reusePort": True}, + notes="OpenLDAP demo directory (rroemhild/test-openldap, image-fixed users).") + # Directories have no interactive connect protocol; enable tunneling for + # discovery/rotation reachability. The admin is image-fixed (not rotated). + self._tdag.link_resource_to_config(dir_uid) + self._pte.execute(self.params, record=dir_uid, config=self.pam_config_uid, + admin=admin_uid, enable_connections=False, enable_tunneling=True, silent=True) + self._allow_resource(dir_uid, rotation=True, connections=False, tunneling=True, + session_recording=False, typescript_recording=False, + remote_browser_isolation=False) + self._tdag.link_user_to_resource(admin_uid, dir_uid, True, True) + + # Demo users. Their initial password comes from the image; the gateway + # rotates them via the cn=admin credential (rootDN has write access to the + # planetexpress DIT). Rotation must be CONFIGURED here - just linking a user + # to a rotation-allowed resource makes WV show a rotate button that would + # otherwise fail with RRS_NO_ROTATION (no rotation settings). Same pattern + # as the DB rotation users (sqluser, max, ...). + for login, password, dn in c.OPENLDAP_DEMO_USERS: + user_uid = self._add_user(f"OpenLDAP User ({login})", login, password, + notes="OpenLDAP demo user. Initial password from image; " + "rotated via cn=admin.", + distinguished_name=dn, # kdnrm needs the DN to rotate the entry + quiet_policy=True) # weak initial creds; policy noise expected + self._tdag.link_user_to_resource(user_uid, dir_uid, False, True) + self._rotate(user_uid, admin_uid, dir_uid) + + # ---- compose -------------------------------------------------------- + + def build_compose(self, gateway_config_b64: str) -> str: + """Build the ``docker-compose.yaml`` text for the playground.""" + return build_compose(self.network_id, gateway_config_b64, self.creds, self.project_name) + + # ---- output --------------------------------------------------------- + + def save_compose_and_seccomp(self, compose_yaml: str) -> str: + """Write compose + seccomp to disk and print the two-line summary.""" + return save_compose_and_seccomp(compose_yaml) + + +# ===================================================================== +# 5. Compose +# ===================================================================== + +def _yq(value: Any) -> str: + """Quote a scalar as a docker-compose value (safe for any character). + + Two independent escapes are applied: + + * ``'`` -> ``''`` - YAML single-quote escaping. Inside single quotes every + other character (``\\``, ``"``, backtick, ``:`` ...) is already literal. + * ``$`` -> ``$$`` - docker-compose runs variable interpolation (``$VAR`` / + ``${VAR}``) on parsed string values *after* YAML quoting is stripped, so a + literal ``$`` must be doubled to reach the container unchanged. + + This makes any enterprise-policy special character round-trip correctly, so + the value the service receives matches the value stored in the vault record. + """ + s = "" if value is None else str(value) + s = s.replace("$", "$$") # docker-compose interpolation escape + s = s.replace("'", "''") # YAML single-quote escape + return "'" + s + "'" + + +def _service_lines(name: str, body: List[str]) -> str: + indented = "\n".join(" " + line if line else "" for line in body) + return f" {name}:\n{indented}\n" + + +def _gateway_block(network_id: str, gateway_config_b64: str) -> str: + body = [ + "platform: linux/amd64", + f"image: {GATEWAY_IMAGE}", + "shm_size: 2g", + "restart: unless-stopped", + "deploy:", + " resources:", + " limits:", + ' cpus: "4"', + ' memory: "2g"', + "security_opt:", + f" - seccomp:{SECCOMP_FILENAME}", + " - apparmor=unconfined", + "environment:", + ' ACCEPT_EULA: "Y"', + # Verbose logging - this is a debugging/testing playground; makes rotation + # and connection activity visible in `docker logs`. + ' KEEPER_GATEWAY_LOG_LEVEL: "debug"', + ' LOG_LEVEL: "debug"', + f" GATEWAY_CONFIG: {_yq(gateway_config_b64)}", + "networks:", + f" - {network_id}", + "depends_on:", + ] + body += [f" - {svc}" for svc in GATEWAY_DEPENDS_ON] + return _service_lines("keeper-gateway", body) + + +def _backend_block(name: str, image: str, env: List, network_id: str, + extra: Optional[List[str]] = None) -> str: + body = ["platform: linux/amd64", f"image: {image}", "restart: unless-stopped"] + if extra: + body += extra + if env: + body.append("environment:") + for key, val in env: + body.append(f" {key}: {_yq(val)}") + body += ["networks:", f" - {network_id}"] + return _service_lines(name, body) + + +def build_compose(network_id: str, gateway_config_b64: str, creds: PlaygroundCredentials, + project_name: str = "") -> str: + """Render the full docker-compose.yaml (name + network + gateway + 11 backends).""" + parts: List[str] = [] + parts.append( + "# Generated by Keeper Commander `pam project import --sample-data`.\n" + "# Credentials below are also stored as records in your Keeper vault.\n" + f"name: {compose_project_name(project_name)}\n" + "\n" + "# Docker auto-assigns this network's subnet to avoid pool overlaps. To\n" + f"# pin a subnet (e.g. for discovery), add an `ipam` block under {network_id}:\n" + "# ipam:\n" + "# config:\n" + f"# - subnet: {NETWORK_SUBNET}\n" + "networks:\n" + f" {network_id}:\n" + " driver: bridge\n" + "\n" + "services:\n" + ) + + parts.append(_gateway_block(network_id, gateway_config_b64)) + + parts.append(_backend_block("db-mysql-1", "mysql/mysql-server:8.0", [ + ("MYSQL_ROOT_HOST", "%"), + ("MYSQL_ROOT_PASSWORD", creds.mysql_root_password), + ("MYSQL_DATABASE", "salesdb"), + ("MYSQL_USER", "sqluser"), + ("MYSQL_PASSWORD", creds.mysql_user_password), + ], network_id)) + + parts.append(_backend_block("db-postgres-1", "postgres", [ + ("POSTGRES_DB", "postgres"), + ("POSTGRES_USER", "postgres"), + ("POSTGRES_PASSWORD", creds.postgres_password), + ], network_id)) + + parts.append(_backend_block("db-mariadb-1", "mariadb:10.8", [ + ("MARIADB_ROOT_HOST", "%"), + ("MARIADB_ROOT_PASSWORD", creds.mariadb_root_password), + ("MARIADB_DATABASE", "mydb"), + ("MARIADB_USER", "max"), + ("MARIADB_PASSWORD", creds.mariadb_user_password), + ], network_id, extra=[ + "ports:", + " - mode: host", + " target: 3306", + " published: 33306", + ])) + + parts.append(_backend_block("db-mssql", "mcr.microsoft.com/mssql/server:2022-latest", [ + ("MSSQL_SA_PASSWORD", creds.mssql_sa_password), + ("MSSQL_PID", "Developer"), + ("ACCEPT_EULA", "Y"), + ], network_id)) + + # db-mongo is disabled together with the MongoDB record (see + # _create_mongodb_records) - no vault record would manage this container until + # the gateway supports the "mongodb" WebRTC ConversationType. Re-enable this + # block, the record call, and the GATEWAY_DEPENDS_ON entry together. + # parts.append(_backend_block("db-mongo", "bitnami/mongodb:latest", [ + # ("MONGO_INITDB_ROOT_USERNAME", "root"), + # ("MONGO_INITDB_ROOT_PASSWORD", creds.mongo_root_password), + # ("MONGO_INITDB_DATABASE", "mydatabase"), + # ("MONGO_INITDB_USERNAME", "user1"), + # ("MONGO_INITDB_PASSWORD", creds.mongo_user_password), + # ("EXPERIMENTAL_DOCKER_DESKTOP_FORCE_QEMU", "1"), + # ], network_id)) + + parts.append(_backend_block("server-ssh-with-pwd-1", "lscr.io/linuxserver/openssh-server", [ + ("PUID", "1000"), + ("PGID", "1000"), + ("TZ", "America/Los_Angeles"), + ("SUDO_ACCESS", "true"), + ("PASSWORD_ACCESS", "true"), + ("USER_NAME", "linuxuser"), + ("USER_PASSWORD", creds.ssh_password), + ], network_id)) + + parts.append(_backend_block("server-ssh-with-key-1", "lscr.io/linuxserver/openssh-server", [ + ("PUID", "1000"), + ("PGID", "1000"), + ("TZ", "America/Los_Angeles"), + ("PUBLIC_KEY", creds.ssh_public_key), + ("SUDO_ACCESS", "true"), + ("USER_NAME", "linuxuser"), + ("PASSWORD_ACCESS", "false"), + ], network_id)) + + parts.append(_backend_block("server-vnc", "keeper/playground-vnc-xfce:latest", [ + ("VNC_PW", creds.vnc_password), + ("VNC_RESOLUTION", "1010x760"), + ], network_id)) + + parts.append(_backend_block("server-rdp", "keeper/playground-rdp-xfce:latest", [ + ("USER", "linuxuser"), + ("PASSWORD", creds.rdp_user_password), + ("ROOT_PASSWORD", creds.rdp_root_password), + ], network_id, extra=[ + "shm_size: 2g", + "security_opt:", + " - seccomp:unconfined", + "deploy:", + " resources:", + " limits:", + ' cpus: "4"', + ' memory: "2g"', + ])) + + # server-telnet: nyancat, no auth / no env. + parts.append(_backend_block("server-telnet", "ddhhz/nyancat-server", [], network_id)) + + # server-openldap-1: rroemhild/test-openldap, image-fixed creds / no env. + parts.append(_backend_block("server-openldap-1", "rroemhild/test-openldap", [], network_id)) + + return "".join(parts) + + +# ===================================================================== +# 6. Output +# ===================================================================== + +def _unique_compose_path(directory: str) -> str: + """Pick a non-colliding compose path in ``directory``.""" + candidate = os.path.join(directory, COMPOSE_FILENAME) + if not os.path.exists(candidate): + return candidate + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + candidate = os.path.join(directory, f"docker-compose-{stamp}.yaml") + if not os.path.exists(candidate): + return candidate + return os.path.join(directory, f"docker-compose-{stamp}-{token_hex(3)}.yaml") + + +def save_compose_and_seccomp(compose_yaml: str) -> str: + """Write compose (collision-safe) + seccomp (skip if present); print 2 lines. + + Primary target is the current working directory; on write failure it falls + back to the OS temp dir. Returns the absolute compose path. + """ + for directory in (os.getcwd(), tempfile.gettempdir()): + try: + compose_path = _unique_compose_path(directory) + with open(compose_path, "w", encoding="utf-8", newline="\n") as f: + f.write(compose_yaml) + # seccomp lives beside the compose; write only if absent (identical + # for every run, referenced by security_opt: seccomp:docker-seccomp.json). + seccomp_path = os.path.join(os.path.dirname(compose_path), SECCOMP_FILENAME) + if not os.path.exists(seccomp_path): + with open(seccomp_path, "wb") as f: + f.write(SECCOMP_BYTES) + compose_abs = os.path.abspath(compose_path) + print(compose_abs) + print(SECCOMP_URL) + return compose_abs + except OSError as e: + logging.debug("Could not write compose to %s: %s", directory, e) + continue + logging.warning(f"{bcolors.FAIL}Failed to write docker-compose.yaml " + f"(cwd and temp dir both unwritable){bcolors.ENDC}") + return "" diff --git a/keepercommander/commands/record.py b/keepercommander/commands/record.py index bc745ebed..c8c87fc93 100644 --- a/keepercommander/commands/record.py +++ b/keepercommander/commands/record.py @@ -1263,7 +1263,7 @@ def display_rotation_info(self, params, r): """Display rotation info for pamUser records in table format (similar to web vault)""" from .tunnel.port_forward.tunnel_helpers import get_keeper_tokens from .tunnel.port_forward.TunnelGraph import TunnelDAG - from keeper_dag.edge import EdgeType + from ..keeper_dag import EdgeType # vendored; bare `keeper_dag` is not importable try: # Get rotation data from cache diff --git a/keepercommander/commands/supershell/app.py b/keepercommander/commands/supershell/app.py index 10e48f3f3..c97544727 100644 --- a/keepercommander/commands/supershell/app.py +++ b/keepercommander/commands/supershell/app.py @@ -1654,7 +1654,7 @@ def _get_rotation_info(self, record_uid: str) -> Optional[Dict[str, Any]]: try: from keepercommander.commands.tunnel.port_forward.tunnel_helpers import get_keeper_tokens from keepercommander.commands.tunnel.port_forward.TunnelGraph import TunnelDAG - from keeper_dag.edge import EdgeType + from keepercommander.keeper_dag import EdgeType # vendored; bare `keeper_dag` is not importable encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(self.params) tdag = TunnelDAG(self.params, encrypted_session_token, encrypted_transmission_key, record_uid, diff --git a/tests/test_tunnel_close_leak.py b/tests/test_tunnel_close_leak.py index 72696e6bb..7d5b1d839 100644 --- a/tests/test_tunnel_close_leak.py +++ b/tests/test_tunnel_close_leak.py @@ -13,11 +13,12 @@ 4. Assert the SSH process dies within CLOSE_TIMEOUT_S 5. Assert the tunnel session is unregistered from _GLOBAL_TUNNEL_SESSIONS """ +import logging +import os import subprocess import sys import threading import time -import logging # Linux-only e2e repro: shells out to `sshpass`/`ssh` with `/dev/null` paths and # needs an SSH container on 127.0.0.1:2222. Bail before importing the native @@ -39,13 +40,12 @@ register_tunnel_session, get_tunnel_session, TunnelSession, - CloseConnectionReasons, ) SSH_HOST = "127.0.0.1" SSH_PORT = 2222 SSH_USER = "linuxuser" -SSH_PASS = "alpine" +SSH_PASS = os.environ.get("TEST_SSH_PASS", "") CLOSE_TIMEOUT_S = 5 TEST_CALLBACK_TOKEN = "TEST_MODE_CALLBACK_TOKEN" @@ -135,7 +135,7 @@ def main(): print(f" Tunnel ready — {listen_addr}") time.sleep(0.3) - print(f"[2] Opening SSH session through tunnel ...") + print("[2] Opening SSH session through tunnel ...") ssh_proc = subprocess.Popen( [ "sshpass", f"-p{SSH_PASS}", @@ -198,10 +198,10 @@ def main(): # Check 1: tunnel session must be unregistered remaining_session = get_tunnel_session(server_id) if remaining_session is not None: - print(f"FAIL: tunnel session still registered after connection_state_changed=closed") + print("FAIL: tunnel session still registered after connection_state_changed=closed") ssh_proc.kill() sys.exit(2) - print(f" Session unregistered ✓") + print(" Session unregistered ✓") # Check 2: SSH process must die within CLOSE_TIMEOUT_S try: diff --git a/unit-tests/pam/test_pam_import_playground.py b/unit-tests/pam/test_pam_import_playground.py new file mode 100644 index 000000000..0f9caf109 --- /dev/null +++ b/unit-tests/pam/test_pam_import_playground.py @@ -0,0 +1,370 @@ +""" +Unit tests for the `pam project import --sample-data` ("Discovery Playground") +generator in keepercommander.commands.pam_import.playground. + +Covers the pure (no-vault) pieces: credential generation (no static secrets, +MSSQL complexity, SSH round-trip), the embedded seccomp profile (base64 round-trip ++ canonical SHA256), the docker-compose builder (parses, 11 depends_on, creds in +env, network name), and file output (cwd -> temp fallback, compose collision +suffixes, seccomp skip-if-present, two-line output). +""" + +import hashlib +import io +import os +import tempfile +import unittest +from contextlib import redirect_stdout + +skip_tests = False +skip_reason = "" +try: + import yaml + from keepercommander.commands.pam_import import playground as pg +except ImportError as e: # pragma: no cover + skip_tests = True + skip_reason = f"Cannot import pam_import.playground / pyyaml: {e}" + +# Canonical docker-seccomp.json (KeeperPAM main == discovery-playground). +SECCOMP_SHA256 = "268fe62ef534293fb1af851cea3da0f1a5ce386e772fd3cb4aaa1c7a4f88aa6b" +SECCOMP_SIZE = 12710 + +# Full Keeper password special set (PW_SPECIAL_CHARACTERS). +PW_SPECIAL_CHARACTERS = "!@#$%^?();',.=+[]<>{}-_/\\*&:\"`~|" + + +def _compose_effective(yaml_value): + """Model docker-compose interpolation: a literal '$' is written '$$'.""" + return yaml_value.replace("$$", "$") + + +# db-mongo is intentionally disabled until the gateway supports the "mongodb" +# WebRTC ConversationType (see _create_mongodb_records in playground.py). +BACKEND_SERVICES = [ + "db-mysql-1", "db-postgres-1", "db-mariadb-1", "db-mssql", + "server-ssh-with-pwd-1", "server-ssh-with-key-1", "server-vnc", + "server-rdp", "server-telnet", "server-openldap-1", +] + + +@unittest.skipIf(skip_tests, skip_reason) +class TestSeccompEmbed(unittest.TestCase): + def test_base64_roundtrip_matches_canonical(self): + self.assertEqual(len(pg.SECCOMP_BYTES), SECCOMP_SIZE) + self.assertEqual(hashlib.sha256(pg.SECCOMP_BYTES).hexdigest(), SECCOMP_SHA256) + + def test_seccomp_is_valid_json(self): + import json + doc = json.loads(pg.SECCOMP_BYTES.decode("utf-8")) + self.assertIn("syscalls", doc) + + def test_seccomp_url(self): + self.assertTrue(pg.SECCOMP_URL.endswith("gateway/docker-seccomp.json")) + + +@unittest.skipIf(skip_tests, skip_reason) +class TestCredentials(unittest.TestCase): + def setUp(self): + self.c = pg.PlaygroundCredentials() + + def test_no_static_password_literals(self): + # Values that used to be hard-coded in edit.py must never appear. + banned = {"alpine", "postgres", "z@ggz?y|w#I_NFCW!41", "maxpass", + "user1pwd", "rootpassword", "root_password", "password"} + generated = [ + self.c.mysql_root_password, self.c.mysql_user_password, + self.c.postgres_password, self.c.mariadb_root_password, + self.c.mariadb_user_password, self.c.mssql_sa_password, + self.c.mongo_root_password, self.c.mongo_user_password, + self.c.ssh_password, self.c.vnc_password, + self.c.rdp_user_password, self.c.rdp_root_password, + self.c.telnet_password, + ] + for pw in generated: + self.assertNotIn(pw, banned) + + def test_passwords_unique_and_length(self): + pwds = [ + self.c.mysql_root_password, self.c.mysql_user_password, + self.c.postgres_password, self.c.mariadb_root_password, + self.c.mariadb_user_password, self.c.mssql_sa_password, + self.c.mongo_root_password, self.c.mongo_user_password, + ] + self.assertEqual(len(pwds), len(set(pwds)), "generated passwords should differ") + for pw in pwds: + self.assertEqual(len(pw), 20) + + def test_passwords_use_only_safe_chars(self): + import re + # Only [A-Za-z0-9] plus the safe special set (-._); nothing that needs + # escaping in cmd/bash/JSON/YAML, no spaces. + safe_re = re.compile(r"^[A-Za-z0-9" + re.escape(pg.SAFE_SPECIAL_CHARACTERS) + r"]+$") + for _ in range(25): + pw = pg._generate_password() + self.assertRegex(pw, safe_re, f"unsafe char in {pw!r}") + + def test_passwords_contain_a_special_char(self): + # Enterprise complexity policy requires a special character. + specials = set(pg.SAFE_SPECIAL_CHARACTERS) + for _ in range(25): + pw = pg._generate_password() + self.assertTrue(specials.intersection(pw), f"no special char in {pw!r}") + + def test_mssql_complexity(self): + # SQL Server: >= 3 of upper/lower/digit/symbol. Alphanumeric gen gives 3. + pw = self.c.mssql_sa_password + categories = sum([ + any(ch.isupper() for ch in pw), + any(ch.islower() for ch in pw), + any(ch.isdigit() for ch in pw), + ]) + self.assertGreaterEqual(categories, 3) + self.assertGreaterEqual(len(pw), 8) + + def test_ssh_keypair_roundtrip(self): + from cryptography.hazmat.primitives import serialization + self.assertTrue(self.c.ssh_private_key.startswith("-----BEGIN OPENSSH PRIVATE KEY-----")) + self.assertTrue(self.c.ssh_public_key.startswith("ssh-rsa ")) + self.assertTrue(self.c.ssh_public_key.endswith("linuxuser@local")) + # Private key parses and the derived public matches the emitted public line. + key = serialization.load_ssh_private_key(self.c.ssh_private_key.encode(), password=None) + self.assertEqual(key.key_size, 2048) + pub = key.public_key().public_bytes( + encoding=serialization.Encoding.OpenSSH, + format=serialization.PublicFormat.OpenSSH, + ).decode() + self.assertEqual(self.c.ssh_public_key.split()[1], pub.split()[1]) + + def test_fresh_instances_differ(self): + other = pg.PlaygroundCredentials() + self.assertNotEqual(self.c.mysql_root_password, other.mysql_root_password) + self.assertNotEqual(self.c.ssh_private_key, other.ssh_private_key) + + +@unittest.skipIf(skip_tests, skip_reason) +class TestPasswordPolicyCompliance(unittest.TestCase): + """Generated secrets must satisfy the enterprise generated_password_complexity policy. + + We satisfy the policy's *password* rules directly (like the Web Vault), rather + than relying on the passphrase fallback. + """ + + class _FakeParams: + def __init__(self, policy): + self.enforcements = {"generated_password_complexity": policy} + + # Strict random-password policy whose special set excludes the safe chars - + # this is the case that used to fall through to the passphrase check. + STRICT_POLICY = { + "length": 24, + "lower-use": True, "lower-min": 3, + "upper-use": True, "upper-min": 3, + "digit-use": True, "digit-min": 3, + "special-use": True, "special-min": 2, "special": "!@#$%^&*", + "passphrase-allow": True, "passphrase-length": 5, + } + # Policy whose special set includes safe chars -> generator prefers safe. + SAFE_ALLOWED_POLICY = { + "length": 20, + "special-use": True, "special-min": 2, "special": "-._!@#", + } + + def _factory(self, policy): + return pg._build_password_factory(self._FakeParams(policy)) + + def test_strict_policy_generates_compliant_password(self): + from keepercommander.enforcement import PasswordComplexityEnforcer + creds = pg.PlaygroundCredentials(self._factory(self.STRICT_POLICY)) + for pw in (creds.mysql_root_password, creds.mssql_sa_password, + creds.postgres_password, creds.telnet_password): + self.assertEqual(PasswordComplexityEnforcer.validate_password(pw, self.STRICT_POLICY), []) + self.assertGreaterEqual(len(pw), 24) + + def test_strict_policy_password_roundtrips_through_compose(self): + # The special set includes '$'; the compose value the container receives + # (after docker-compose collapses '$$' -> '$') must equal the vault value. + creds = pg.PlaygroundCredentials(self._factory(self.STRICT_POLICY)) + doc = yaml.safe_load(pg.build_compose(pg.compute_network_id("P"), "CFG==", creds)) + for svc, key, secret in [ + ("db-mysql-1", "MYSQL_ROOT_PASSWORD", creds.mysql_root_password), + ("db-mssql", "MSSQL_SA_PASSWORD", creds.mssql_sa_password), + ]: + yaml_val = doc["services"][svc]["environment"][key] + self.assertEqual(_compose_effective(yaml_val), secret) + + def test_safe_allowed_policy_prefers_safe_specials(self): + from keepercommander.enforcement import PasswordComplexityEnforcer + creds = pg.PlaygroundCredentials(self._factory(self.SAFE_ALLOWED_POLICY)) + pw = creds.mysql_root_password + self.assertEqual(PasswordComplexityEnforcer.validate_password(pw, self.SAFE_ALLOWED_POLICY), []) + self.assertTrue(pg._is_safe_secret(pw), f"expected safe specials, got {pw!r}") + + def test_no_policy_uses_safe_random_password(self): + factory = self._factory(None) + self.assertIs(factory, pg._generate_password) + + def test_suppress_expected_warnings_filters_only_expected_noise(self): + import logging + logger = logging.getLogger() + records = [] + + class _Capture(logging.Handler): + def emit(self, r): + records.append(r.getMessage()) + + handler = _Capture() + handler.setLevel(logging.DEBUG) + logger.addHandler(handler) + prev_level = logger.level + logger.setLevel(logging.INFO) # ensure the INFO BreachWatch line is emitted + try: + with pg._suppress_expected_warnings(): + logging.warning("Passphrase must contain an allowed separator character. Allowed: -, ., _") + logging.warning("Password does not meet enterprise complexity policy. Pass --force ...") + logging.info("High-Risk password detected") # BreachWatch (INFO level) + logging.warning("some unrelated warning that must survive") + finally: + logger.setLevel(prev_level) + logger.removeHandler(handler) + self.assertIn("some unrelated warning that must survive", records) + self.assertFalse(any("Passphrase must contain" in m for m in records)) + self.assertFalse(any("complexity policy" in m for m in records)) + self.assertFalse(any("High-Risk password detected" in m for m in records)) + + +@unittest.skipIf(skip_tests, skip_reason) +class TestNetworkId(unittest.TestCase): + def test_truncated_and_underscored(self): + self.assertEqual(pg.compute_network_id("My Playground"), "My_Playgro") + self.assertEqual(pg.compute_network_id("abc"), "abc") + + def test_empty_fallback(self): + self.assertEqual(pg.compute_network_id(""), "pam-net") + + +@unittest.skipIf(skip_tests, skip_reason) +class TestComposeBuilder(unittest.TestCase): + def setUp(self): + self.c = pg.PlaygroundCredentials() + self.nid = pg.compute_network_id("My Playground") + self.yaml_text = pg.build_compose(self.nid, "GWCONFIGB64==", self.c) + self.doc = yaml.safe_load(self.yaml_text) + + def test_depends_on_matches_backend_services(self): + # db-mongo temporarily disabled -> 10 (was 11). depends_on must list every + # generated backend service and nothing that isn't defined. + self.assertEqual(len(pg.GATEWAY_DEPENDS_ON), 10) + depends_on = self.doc["services"]["keeper-gateway"]["depends_on"] + self.assertEqual(set(depends_on), set(BACKEND_SERVICES)) + self.assertNotIn("db-mongo", depends_on) + + def test_all_services_present(self): + svcs = set(self.doc["services"].keys()) + self.assertEqual(svcs, {"keeper-gateway", *BACKEND_SERVICES}) + + def test_compose_project_name_set(self): + # Top-level `name:` sets the Compose project name (else it defaults to the + # output directory, e.g. "tmp"); derived from --name, docker-sanitized. + self.assertEqual(pg.compose_project_name("My Playground"), "my-playground") + self.assertEqual(pg.compose_project_name("SampleData_Playground"), "sampledata_playground") + self.assertEqual(pg.compose_project_name(""), "playground") + doc = yaml.safe_load(pg.build_compose(self.nid, "CFG==", self.c, "My Playground")) + self.assertEqual(doc["name"], "my-playground") + + def test_network_matches_network_id(self): + self.assertIn(self.nid, self.doc["networks"]) + # Subnet is auto-assigned by Docker (no fixed ipam) to avoid pool overlaps. + self.assertNotIn("ipam", self.doc["networks"][self.nid] or {}) + self.assertEqual(self.doc["services"]["keeper-gateway"]["networks"], [self.nid]) + + def test_gateway_image_and_security_opt(self): + gw = self.doc["services"]["keeper-gateway"] + self.assertEqual(gw["image"], pg.GATEWAY_IMAGE) + self.assertIn("seccomp:docker-seccomp.json", gw["security_opt"]) + self.assertIn("apparmor=unconfined", gw["security_opt"]) + self.assertEqual(gw["environment"]["GATEWAY_CONFIG"], "GWCONFIGB64==") + + def test_credentials_in_env(self): + env = self.doc["services"]["db-mysql-1"]["environment"] + self.assertEqual(env["MYSQL_ROOT_PASSWORD"], self.c.mysql_root_password) + self.assertEqual(env["MYSQL_PASSWORD"], self.c.mysql_user_password) + self.assertEqual( + self.doc["services"]["server-ssh-with-key-1"]["environment"]["PUBLIC_KEY"], + self.c.ssh_public_key) + self.assertEqual( + self.doc["services"]["db-mssql"]["environment"]["MSSQL_SA_PASSWORD"], + self.c.mssql_sa_password) + + def test_no_auth_services_have_no_secrets(self): + # telnet + openldap carry no env secrets in compose. + self.assertNotIn("environment", self.doc["services"]["server-telnet"]) + self.assertNotIn("environment", self.doc["services"]["server-openldap-1"]) + + def test_mariadb_published_port(self): + ports = self.doc["services"]["db-mariadb-1"]["ports"] + self.assertEqual(ports[0]["published"], 33306) + + def test_yq_roundtrips_all_special_chars(self): + # Every special char (incl. ' $ \ " ` etc.) must survive YAML parsing + # and docker-compose interpolation back to the original value. + values = [ + PW_SPECIAL_CHARACTERS, + "a$b$$c", # bare + doubled dollars + "quote'and$dollar", # single quote next to dollar + "trailing$", + "back\\slash\"quote`tick", + ] + for val in values: + loaded = yaml.safe_load("k: " + pg._yq(val))["k"] + self.assertEqual(_compose_effective(loaded), val, f"roundtrip failed for {val!r}") + + +@unittest.skipIf(skip_tests, skip_reason) +class TestFileOutput(unittest.TestCase): + def setUp(self): + self._cwd = os.getcwd() + self.tmp = tempfile.mkdtemp() + os.chdir(self.tmp) + + def tearDown(self): + os.chdir(self._cwd) + + def _run(self, yaml_text="services: {}\n"): + buf = io.StringIO() + with redirect_stdout(buf): + path = pg.save_compose_and_seccomp(yaml_text) + return path, buf.getvalue() + + def test_writes_compose_and_seccomp(self): + path, out = self._run() + self.assertTrue(os.path.isfile(path)) + seccomp = os.path.join(os.path.dirname(path), pg.SECCOMP_FILENAME) + self.assertTrue(os.path.isfile(seccomp)) + with open(seccomp, "rb") as f: + self.assertEqual(hashlib.sha256(f.read()).hexdigest(), SECCOMP_SHA256) + + def test_two_line_output(self): + path, out = self._run() + lines = out.strip().splitlines() + self.assertEqual(len(lines), 2) + self.assertEqual(lines[0], os.path.abspath(path)) + self.assertEqual(lines[1], pg.SECCOMP_URL) + + def test_compose_collision_gets_suffix(self): + p1, _ = self._run() + self.assertEqual(os.path.basename(p1), pg.COMPOSE_FILENAME) + p2, _ = self._run() + self.assertNotEqual(p1, p2) + self.assertTrue(os.path.basename(p2).startswith("docker-compose-")) + + def test_seccomp_skipped_when_present(self): + p1, _ = self._run() + seccomp = os.path.join(os.path.dirname(p1), pg.SECCOMP_FILENAME) + os.utime(seccomp, (1, 1)) # mark + before = os.stat(seccomp).st_mtime + self._run() # second run must not overwrite seccomp + self.assertEqual(os.stat(seccomp).st_mtime, before) + + +if __name__ == "__main__": + unittest.main() From a1621f66ce802214d6fa4dc72b26fb8ea2c5e463 Mon Sep 17 00:00:00 2001 From: Ivan Dimov <78815270+idimov-keeper@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:24:06 -0500 Subject: [PATCH 17/19] KC-1352 re-apply sample-data playground wiring onto release NSF base Preserve upstream NSF gateway/sync paths; delegate --sample-data to playground.py and document generated credentials. Co-authored-by: Cursor --- keepercommander/commands/pam_import/README.md | 39 +- keepercommander/commands/pam_import/edit.py | 589 ++---------------- 2 files changed, 74 insertions(+), 554 deletions(-) diff --git a/keepercommander/commands/pam_import/README.md b/keepercommander/commands/pam_import/README.md index de43eec19..9370cae03 100644 --- a/keepercommander/commands/pam_import/README.md +++ b/keepercommander/commands/pam_import/README.md @@ -11,6 +11,33 @@ Initial Import. - `--dry-run`, `-d` → Test import without modifying vault. +### Sample data (Discovery Playground) + +Generate a ready-to-run PAM project that matches the [discovery-playground](https://github.com/Keeper-Security) docker stack — folders, KSM app, gateway, PAM configuration, sample records **and** a matching `docker-compose.yaml`. +`pam project import --sample-data [--name "My Playground"] [--dry-run]` + +- `--sample-data`, `-s` → Generate sample data _(default project name: `Discovery Playground`)_. + +What it creates: +- Vault records for MySQL, PostgreSQL, MariaDB, MSSQL, MongoDB, SSH (password + key), VNC, RDP, Telnet, OpenLDAP and a Remote Browser (RBI) resource. +- A `docker-compose.yaml` in the current directory (falls back to the OS temp dir if the cwd is not writable). If a `docker-compose.yaml` already exists it is written with a timestamp/random suffix instead of overwriting. +- A `docker-seccomp.json` next to the compose file (required by the gateway's `security_opt: seccomp:docker-seccomp.json`). It is written only if absent — the profile is identical for every run and is never overwritten. + +All credentials are generated fresh per run and stored **both** in the vault records and in the generated compose — no secrets are hard-coded. They satisfy your enterprise password-complexity policy (a random password built to the policy's length / character-class / special-set rules, matching the Web Vault) and prefer characters that need no escaping in a shell, JSON or YAML — falling back to the policy's own special set when it requires one. On success the command prints exactly two lines: the absolute path of the compose file and the canonical seccomp URL. + +The generated gateway uses `image: keeper/gateway:latest` and `security_opt: [seccomp:docker-seccomp.json, apparmor=unconfined]`, with its `GATEWAY_CONFIG` set to the base64 config of the gateway created during the import. Start the stack with `docker compose up -d`. + +To re-download the seccomp profile manually: +```sh +curl -O https://raw.githubusercontent.com/Keeper-Security/KeeperPAM/refs/heads/main/gateway/docker-seccomp.json +``` + +Notes: +- **RBI** (Remote Browser) is a Keeper record only — it has no compose service. +- **OpenLDAP** (`server-openldap-1`, image `rroemhild/test-openldap`) uses image-fixed demo credentials (`cn=admin` / `GoodNewsEveryone`, domain `planetexpress.com`); the compose block carries no secrets and the demo users reset on container restart. +- **Telnet** (`server-telnet`, `ddhhz/nyancat-server`) has no authentication; the Keeper pamUser exists only for rotation/discovery testing. + + Adding new PAM resources and users to an existing PAM configuration from an import file. The command validates folders and records, then creates only new items (match by title, existing records are skipped). The import JSON format is the same. `pam project extend --config= --filename=/path/to/import.json [--dry-run]` @@ -850,7 +877,7 @@ Workflow controls how privileged access to a resource is gated: how many approva "host": "127.0.0.8", "port": "27017", "use_ssl": true, - "users": [{"type": "pamUser","login": "pamuser2","password": "p4mus3r2!"}] + "users": [{"type": "pamUser","login": "pamuser2","password": "*****"}] } ] } @@ -924,7 +951,7 @@ Workflow controls how privileged access to a resource is gated: how many approva "host": "127.0.0.8", "port": "636", "use_ssl": true, - "users": [{"type": "pamUser","login": "pamuser2","password": "p4mus3r2!"}] + "users": [{"type": "pamUser","login": "pamuser2","password": "*****"}] } ] } @@ -1002,7 +1029,7 @@ Workflow controls how privileged access to a resource is gated: how many approva "type": "login", "title": "rbi_hotmail_user1", "login": "user1@hotmail.com", - "password": "User1Pa$$w0rd!", + "password": "*****", "otp": "otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=ExampleApp" } ] @@ -1022,7 +1049,7 @@ Workflow controls how privileged access to a resource is gated: how many approva "title": "PAM User1 - general rotation", "notes": "PAM User1 Notes", "login": "pamuser1", - "password": "pamuser1Pa$$w0rd!", + "password": "*****", "private_pem_key": "-----BEGIN RSA PRIVATE KEY-----\n-----END RSA PRIVATE KEY-----", "distinguished_name": "User1 Distinguished Name", "connect_database": "user1_connect_db1", @@ -1041,7 +1068,7 @@ Workflow controls how privileged access to a resource is gated: how many approva "type": "pamUser", "title": "PAM User2 - iam_user rotation", "login": "pamuser2", - "password": "pamuser2Pa$$w0rd!", + "password": "*****", "rotation_settings": { "rotation": "iam_user", "enabled": "on", @@ -1053,7 +1080,7 @@ Workflow controls how privileged access to a resource is gated: how many approva "type": "pamUser", "title": "PAM User3 - scripts_only rotation (NOOP)", "login": "pamuser3", - "password": "pamuser3Pa$$w0rd!", + "password": "*****", "rotation_settings": { "rotation": "scripts_only", "enabled": "on", diff --git a/keepercommander/commands/pam_import/edit.py b/keepercommander/commands/pam_import/edit.py index 8bd69c2cd..1e99f07cb 100644 --- a/keepercommander/commands/pam_import/edit.py +++ b/keepercommander/commands/pam_import/edit.py @@ -19,6 +19,7 @@ from typing import Any, Dict, Optional, List, Union from .keeper_ai_settings import set_resource_jit_settings, set_resource_keeper_ai_settings, refresh_meta_to_latest, refresh_link_to_config_to_latest +from .playground import compute_network_id as _pg_compute_network_id from .workflow_apply import apply_workflow, validate_workflow_principals from .base import ( PAM_RESOURCES_RECORD_TYPES, @@ -138,8 +139,9 @@ def execute(self, params, **kwargs): project["options"]["project_name"] = "Discovery Playground" project["options"]["file_name"] = "" # project["options"]["dry_run"] = False # dry-run is allowed - extra = self.get_extra_args(["sample_data", "dry_run", "use_nsf"], **kwargs) - if extra: + # --name and --dry-run are honored with -s; NSF flag is orthogonal. + extra = self.get_extra_args(["sample_data", "dry_run", "project_name", "use_nsf"], **kwargs) + if extra: logging.warning(f"{bcolors.WARNING}Warning: --sample-data|-s overrides other options {extra}{bcolors.ENDC}") if project["options"]["sample_data"] is False: @@ -209,6 +211,10 @@ def _has_perms(section_key): project["options"]["sample_data"] = project["options"]["sample_data"] or project["data"].get("options", {}).get("generate_sample_data", False) or False if project["options"]["sample_data"] == True: self.generate_sample_data(params, project) + # --sample-data output is limited to the two file lines (compose path + # + seccomp URL), already printed above - or the dry-run preview. + # Skip the usual JSON result / docs note. + return else: self.process_data(params, project) @@ -425,7 +431,14 @@ def process_gateway(self, params, project: dict) -> dict: from .nsf_helpers import restore_nsf_folder_keys, snapshot_nsf_folder_keys, sync_down_preserving_nsf_keys preserved = snapshot_nsf_folder_keys(params) if use_nsf else None output_fmt = project["options"]["output"] - token_format = None if output_fmt == "token" else ("k8s" if output_fmt == "k8s" else "b64") + # --sample-data ignores --output: the deliverable is always the generated + # docker-compose.yaml, which embeds the base64 gateway config (GATEWAY_CONFIG), + # so force config_init="b64". + is_sample = project["options"].get("sample_data", False) + if is_sample: + token_format = "b64" + else: + token_format = None if output_fmt == "token" else ("k8s" if output_fmt == "k8s" else "b64") ksm_app_uid = project["ksm_app"]["app_uid"] gw = self.create_gateway( params, @@ -435,13 +448,18 @@ def process_gateway(self, params, project: dict) -> dict: if preserved is not None: restore_nsf_folder_keys(params, preserved) - if token_format is None: + config_raw = gw[0].get("config", "") if gw else "" # base64 config (if config_init set) + if is_sample: + res["gateway_token"] = config_raw # --output ignored; config lives in docker-compose.yaml + elif token_format is None: res["gateway_token"] = gw[0].get("oneTimeToken", "") if gw else "" # OTT else: - res["gateway_token"] = gw[0].get("config", "") if gw else "" # Config + res["gateway_token"] = config_raw # Config if output_fmt == "json": - res["gateway_token"] = json.loads(utils.base64_url_decode(res["gateway_token"])) + res["gateway_token"] = json.loads(utils.base64_url_decode(config_raw)) # k8s: config is already Kubernetes Secret YAML string; base64: keep as-is + # base64 config for the sample-data docker-compose GATEWAY_CONFIG + res["gateway_config_b64"] = config_raw res["gateway_device_token"] = gw[0].get("deviceToken", "") if gw else "" # controller_uid is not returned by vault/app_client_add @@ -512,7 +530,8 @@ def process_pam_config(self, params, project: dict) -> dict: "title": res["pam_config_name"], "port_mapping": ["2222=ssh"], # "network_cidr": "192.168.1.0/24", - "network_id": "discovery-net", + # network_id must match the docker-compose network name (playground.py) + "network_id": _pg_compute_network_id(project["options"]["project_name"]), "connections": "on", "tunneling": "on", "rotation": "on", @@ -609,550 +628,24 @@ def process_pam_config(self, params, project: dict) -> dict: return res def generate_sample_data(self, params, project: dict): + # All sample-data logic (records + credentials + docker-compose) lives + # in playground.py; this method just orchestrates it. + from .playground import PlaygroundSession, COMPOSE_FILENAME, SECCOMP_URL if project["options"].get("dry_run", False) is True: - print("Will generate sample data here...") + # Dry-run: no vault writes, no files - just report intent + target paths. + intended = os.path.abspath(os.path.join(os.getcwd(), COMPOSE_FILENAME)) + print("[DRY RUN] Would generate discovery-playground sample records " + "(MySQL, SSH, VNC, RDP, RBI, PostgreSQL, MariaDB, MSSQL, MongoDB, " + "Telnet, OpenLDAP) with freshly generated credentials.") + print(f"[DRY RUN] Would write docker-compose to: {intended}") + print(f"[DRY RUN] Seccomp profile: {SECCOMP_URL}") return - # self.generate_simple_content_data(params, project) - self.generate_discovery_playground_data(params, project) - - def generate_discovery_playground_data(self, params, project: dict): - """ Generate data that works with discovery-playground docker setup """ - # Local import to avoid circular import with discoveryrotation - from ..discoveryrotation import PAMCreateRecordRotationCommand - # PUBLIC_KEY = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC0bH13XfBiKcej3/W"\ - # "mnc7GYbx+B+hmfYTaDFqfJ/vEGy3HTSz2t5nDb3+S1clBcCmse5FzEA7aXC3cZXurGBH"\ - # "irz2Ud8wCL2t95cJnrkzfft7lsILnchm0J0Y0TyDW42gLj1JWh/E5qQyUxF0F6xEBKcy"\ - # "5cYwlgtkBcrkF1xdpuTKTMBg+xjB9XSlvLv+4rwZ448tvyILuw4DcIZDWjNxn1v+a/43"\ - # "ybhUNjGdd6zeR1ZdfB6O209VU1V0zTNS/jGsKPDK03vmJ1j42S/ZyNZ16CKDmsixhSVI"\ - # "aZ+qNOQx4eF6l/cavX+LAm94jPFZSsjr3BdE6jOZhJN+XWBmIpYd9 linuxuser@local" - - PRIVATE_KEY = "-----BEGIN OPENSSH PRIVATE KEY-----\\n"\ - "b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn\\n"\ - "NhAAAAAwEAAQAAAQEAtGx9d13wYinHo9/1pp3OxmG8fgfoZn2E2gxanyf7xBstx00s9reZ\\n"\ - "w29/ktXJQXAprHuRcxAO2lwt3GV7qxgR4q89lHfMAi9rfeXCZ65M337e5bCC53IZtCdGNE\\n"\ - "8g1uNoC49SVofxOakMlMRdBesRASnMuXGMJYLZAXK5BdcXabkykzAYPsYwfV0pby7/uK8G\\n"\ - "eOPLb8iC7sOA3CGQ1ozcZ9b/mv+N8m4VDYxnXes3kdWXXwejttPVVNVdM0zUv4xrCjwytN\\n"\ - "75idY+Nkv2cjWdegig5rIsYUlSGmfqjTkMeHhepf3Gr1/iwJveIzxWUrI69wXROozmYSTf\\n"\ - "l1gZiKWHfQAAA8j5NtJt+TbSbQAAAAdzc2gtcnNhAAABAQC0bH13XfBiKcej3/Wmnc7GYb\\n"\ - "x+B+hmfYTaDFqfJ/vEGy3HTSz2t5nDb3+S1clBcCmse5FzEA7aXC3cZXurGBHirz2Ud8wC\\n"\ - "L2t95cJnrkzfft7lsILnchm0J0Y0TyDW42gLj1JWh/E5qQyUxF0F6xEBKcy5cYwlgtkBcr\\n"\ - "kF1xdpuTKTMBg+xjB9XSlvLv+4rwZ448tvyILuw4DcIZDWjNxn1v+a/43ybhUNjGdd6zeR\\n"\ - "1ZdfB6O209VU1V0zTNS/jGsKPDK03vmJ1j42S/ZyNZ16CKDmsixhSVIaZ+qNOQx4eF6l/c\\n"\ - "avX+LAm94jPFZSsjr3BdE6jOZhJN+XWBmIpYd9AAAAAwEAAQAAAQAEs0DV5iOxgviGKEfC\\n"\ - "syC9+7GiSa8M7UWTop4nwEearvSTcrGME3HIU035AGQrHFkEx8rpuvTc5mlBcRlc9mMQGA\\n"\ - "c1wdf8N8nU/UvO6w3Qn4IyBjx0YbB4VRkxZ3a2pZtbyO+MFopUhlCWfY98BhXEa7DY8ebR\\n"\ - "p798fkWCRYpNtDyja2m0zrFo6Kp0PusmAXWnu5z4SLgpdNKIaz+6AX+vQpv2QTTpunzGUr\\n"\ - "XvhlLpLhK5sOPhR88VuddNKJFZi7SzNUC3DW66NdtU8jVeTKOgOB8fdvzwkX5AgAFpj/cr\\n"\ - "MmbS5GpkrKpVjkWXfTxAit+S1Ykg/ay6po4y8s9RHjsxAAAAgQCPof49LmwUJuBiheAklQ\\n"\ - "fcxCv4CnGvT926FueqADuN2g85R8EjOQ7qB0xtZckIflyqMnCVEiA9D6m8LUtEAmB9nC2x\\n"\ - "5Iz+uNByfadxthAgQXBc1qCm8Q0CCwKGE4LzshugdJap5d4i5sOM8pvNb9lo81LjXjzBw9\\n"\ - "3aNR5cPxH1uwAAAIEA8An6rWHpq494jjWdbyKI65qgBAIuIHTGxhonze5q0mYQSkor9R3k\\n"\ - "0w1ZPzOI8U78qpzGmL7hKa5QT5SOYsTffb8ofYTky0Agbqo1Ax8JK4+JytC8u6Pjc4G1U/\\n"\ - "3Njxu2aPT0xEsIxdVdDqT0sbrY3Cmn2PPr1MWM2xYb/PS2l2UAAACBAMBruM9OswMXmJ6/\\n"\ - "MClr0X9JfqWSNMKvOEYnoCmroGnjNBbf+66U9a6ecDSXNF8EMCG1pDSVHwtTIb2vUUEnwW\\n"\ - "MxQ33Xl8pUHmP94FqD8wrhZta/YRWzPeQs6LOBGoAFoSJBhALIhjlj48HW1TqtAJ+TuiGU\\n"\ - "4nTd/dKHHH5aNmo5AAAAD2xpbnV4dXNlckBsb2NhbAECAw==\\n"\ - "-----END OPENSSH PRIVATE KEY-----" - - project_name = project["options"]["project_name"] - users_folder_uid = project["folders"]["users_folder_uid"] - resources_folder_uid = project["folders"]["resources_folder_uid"] - pam_config_uid = project["pam_config"]["pam_config_uid"] - - encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params) - tdag = TunnelDAG(params, encrypted_session_token, encrypted_transmission_key, pam_config_uid, True, - transmission_key=transmission_key) - # if not tdag.check_tunneling_enabled_config(enable_connections=True): - # logging.warning(f"{bcolors.WARNING}Warning: {bcolors.ENDC} Connections are disabled by PAM Configuration!") - # Fix: Rotation is disabled by the PAM configuration. - tdag.set_resource_allowed(pam_config_uid, is_config=True, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True) - - class PamProjectRecordAddCommand: - @staticmethod - def execute(params, **kwargs): - return execute_record_v3_add_in_folder( - params, - kwargs, - kwargs.get('folder'), - command='pam-project-import' - ) - - command = PamProjectRecordAddCommand() - pte = PAMTunnelEditCommand() - - # when NOOP rotation is added to PAMCreateRecordRotationCommand... - # # create_noop_rotation_records - # json_data = """{ - # "type": "pamUser", - # "title": "#project_name# - NOOP Rotation User", - # "fields": [ - # {"type": "login", "required": true, "value": ["noop"]}, - # {"type": "password", "value": ["noop"]} - # ], - # "custom": [ - # {"type": "text", "label": "NOOP", "value": [ "True" ] } - # ]}""".replace("#project_name#", project_name) - # # {"type": "script", "label": "rotationScripts", "value": [ - # # { - # # "command": "pwsh.exe", - # # "fileRef": "D:/Download/00params.ps1", - # # "recordRef": [] - # # }, - # # { - # # "command": "cmd.exe", - # # "fileRef": "D:/Download/00params.bat", - # # "recordRef": [] - # # } - # # ]} - # rotation_user_uid = command.execute(params, folder = users_folder_uid, data = json_data) - # # TO: Extract scripts from JSON: remove from record data and add after record creation - # # NB! add_credential must be empty - NOOP records do not need additional resources - # # RRC_BAD_REQUEST - Noop and resource cannot be both assigned - # PAMScriptAddCommand().execute(params, record=rotation_user_uid, script="D:/Download/00params.ps1", script_command="pwsh.exe", add_credential="") - # PAMScriptAddCommand().execute(params, record=rotation_user_uid, script="D:/Download/00params.bat", script_command="cmd.exe", add_credential="") - # tdag.set_resource_allowed(pam_config_uid, is_config=True, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True) - # tdag.link_resource_to_config(rotation_user_uid) - # api.sync_down(params) - # PAMCreateRecordRotationCommand().execute(params, record_name=rotation_user_uid, - # config=pam_config_uid, noop=True, - # on_demand=True, pwd_complexity="20,4,4,4,4", enable=True, force=True) - - # create_mysql_records - json_data = """{ - "type": "pamUser", - "title": "#project_name# - MySQL Admin User", - "fields": [ - {"type": "login", "required": true, "value": ["root"]}, - {"type": "password", "value": ["z@ggz?y|w#I_NFCW!41"]} - ]}""".replace("#project_name#", project_name) - admin_user_uid = command.execute(params, folder=users_folder_uid, data=json_data) - - json_data = """{ - "type": "pamUser", - "title": "#project_name# - MySQL Rotation User", - "fields": [ - {"type": "login", "required": true, "value": ["sqluser"]}, - {"type": "password", "value": ["alpine"]} - ]}""".replace("#project_name#", project_name) - # ,"custom": [{"type": "text", "label": "NOOP", "value": [ "True" ] }] - rotation_user_uid = command.execute(params, folder=users_folder_uid, data=json_data) - - json_data = """{ - "type": "pamDatabase", - "title": "#project_name# - MySQL Database", - "fields": [ - {"type": "pamHostname", "required": true, "value": [{"hostName": "db-mysql-1","port": "3306"}]}, - {"type": "trafficEncryptionSeed", "value": []}, - {"type": "pamSettings", "value": [{ - "connection": { - "protocol": "mysql", - "database": "salesdb", - "userRecords": ["#admin_user_uid#"] - }, - "portForward": { "reusePort": true } - }]} - ]}""".replace("#project_name#", project_name).replace("#admin_user_uid#", admin_user_uid) - database_machine_uid = command.execute(params, folder=resources_folder_uid, data=json_data) - - # Database Machine -> Config -> Enable connections/portForwards/set trafficEncryptionSeed - tdag.link_resource_to_config(database_machine_uid) - pte.execute(params, record=database_machine_uid, config=pam_config_uid, admin=admin_user_uid, enable_connections=True, enable_tunneling=True, silent=True) - tdag.set_resource_allowed(resource_uid=database_machine_uid, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True) - - # bugfix: Apparently PAMCreateRecordRotationCommand do not create the links - # Links: User -> Database Machine - tdag.link_user_to_resource(admin_user_uid, database_machine_uid, True, True) - tdag.link_user_to_resource(rotation_user_uid, database_machine_uid, False, True) - # PAMCreateRecordRotationCommand().execute(params, record_name=admin_user_uid,admin=admin_user_uid,config=pam_config_uid, resource=database_machine_uid,on_demand=True, pwd_complexity="20,4,4,4,4", enable=True, force=True) - PAMCreateRecordRotationCommand().execute(params, record_name=rotation_user_uid, - admin=admin_user_uid, - config=pam_config_uid, resource=database_machine_uid, - on_demand=True, pwd_complexity="20,4,4,4,4", enable=True, force=True, silent=True) - - # create_ssh_with_password_records - json_data = """{ - "type": "pamUser", - "title": "#project_name# - SSH Admin with Password", - "fields": [ - {"type": "login", "required": true, "value": ["linuxuser"]}, - {"type": "password", "value": ["alpine"]} - ]}""".replace("#project_name#", project_name) - admin_user_uid = command.execute(params, folder=users_folder_uid, data=json_data) - - json_data = """{ - "type": "pamMachine", - "title": "#project_name# - SSH Machine with Password Access", - "fields": [ - {"type": "pamHostname", "required": true, "value": [{"hostName": "server-ssh-with-pwd-1","port": "2222"}]}, - {"type": "trafficEncryptionSeed", "value": []}, - {"type": "pamSettings", "value": [{ - "connection": { - "protocol": "ssh", - "userRecords": ["#admin_user_uid#"] - }, - "portForward": { "reusePort": true } - }]} - ]}""".replace("#project_name#", project_name).replace("#admin_user_uid#", admin_user_uid) - ssh_machine_uid = command.execute(params, folder=resources_folder_uid, data=json_data) - - # Machine -> Config -> Enable connections/portForwards/set trafficEncryptionSeed - tdag.link_resource_to_config(ssh_machine_uid) - pte.execute(params, record=ssh_machine_uid, config=pam_config_uid, admin=admin_user_uid, enable_connections=True, enable_tunneling=True, silent=True) - # if tdag.check_if_resource_allowed(ssh_machine_uid, "connections") != True: - tdag.set_resource_allowed(resource_uid=ssh_machine_uid, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True) - - # Admin User -> Machine; Admin User -> Rotation - tdag.link_user_to_resource(admin_user_uid, ssh_machine_uid, True, True) - PAMCreateRecordRotationCommand().execute(params, record_name=admin_user_uid, - admin=admin_user_uid, - config=pam_config_uid, resource=ssh_machine_uid, - on_demand=True, pwd_complexity="20,4,4,4,4", enable=True, force=True, silent=True) - - # create_ssh_with_private_key_records - json_data = """{ - "type": "pamUser", - "title": "#project_name# - SSH Admin with Private Key", - "fields": [ - {"type": "login", "required": true, "value": ["linuxuser"]}, - {"type": "password", "value": []}, - {"type": "secret", "label": "privatePEMKey", "value": ["#private_key#"]} - ]}""".replace("#project_name#", project_name).replace("#private_key#", PRIVATE_KEY) - admin_user_uid = command.execute(params, folder=users_folder_uid, data=json_data) - - json_data = """{ - "type": "pamMachine", - "title": "#project_name# - SSH Machine with Private Key Access", - "fields": [ - {"type": "pamHostname", "required": true, "value": [{"hostName": "server-ssh-with-key-1","port": "2222"}]}, - {"type": "trafficEncryptionSeed", "value": []}, - {"type": "pamSettings", "value": [{ - "connection": { - "protocol": "ssh", - "userRecords": ["#admin_user_uid#"] - }, - "portForward": { "reusePort": true } - }]} - ]}""".replace("#project_name#", project_name).replace("#admin_user_uid#", admin_user_uid) - ssh_machine_uid = command.execute(params, folder=resources_folder_uid, data=json_data) - - # Machine -> Config -> Enable connections/portForwards/set trafficEncryptionSeed - tdag.link_resource_to_config(ssh_machine_uid) - pte.execute(params, record=ssh_machine_uid, config=pam_config_uid, admin=admin_user_uid, enable_connections=True, enable_tunneling=True, silent=True) - tdag.set_resource_allowed(resource_uid=ssh_machine_uid, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True) - - # Admin User -> Machine; Admin User -> Rotation - tdag.link_user_to_resource(admin_user_uid, ssh_machine_uid, True, True) - PAMCreateRecordRotationCommand().execute(params, record_name=admin_user_uid, - admin=admin_user_uid, - config=pam_config_uid, resource=ssh_machine_uid, - on_demand=True, pwd_complexity="20,4,4,4,4", enable=True, force=True, silent=True) - - # create_vnc_records - json_data = """{ - "type": "pamUser", - "title": "#project_name# - VNC Admin", - "fields": [ - {"type": "login", "required": true, "value": ["vncuser"]}, - {"type": "password", "value": ["alpine"]} - ]}""".replace("#project_name#", project_name) - admin_user_uid = command.execute(params, folder=users_folder_uid, data=json_data) - - json_data = """{ - "type": "pamMachine", - "title": "#project_name# - VNC Machine", - "fields": [ - {"type": "pamHostname", "required": true, "value": [{"hostName": "server-vnc","port": "5901"}]}, - {"type": "trafficEncryptionSeed", "value": []}, - {"type": "pamSettings", "value": [{ - "connection": { - "protocol": "vnc", - "userRecords": ["#admin_user_uid#"] - }, - "portForward": { "reusePort": true } - }]} - ]}""".replace("#project_name#", project_name).replace("#admin_user_uid#", admin_user_uid) - machine_uid = command.execute(params, folder=resources_folder_uid, data=json_data) - - # Machine -> Config -> Enable connections/portForwards/set trafficEncryptionSeed - tdag.link_resource_to_config(machine_uid) - pte.execute(params, record=machine_uid, config=pam_config_uid, admin=admin_user_uid, enable_connections=True, enable_tunneling=True, silent=True) - tdag.set_resource_allowed(resource_uid=machine_uid, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True) - - # Admin User -> Machine; Admin User - tdag.link_user_to_resource(admin_user_uid, machine_uid, True, True) - - # create_rdp_records - json_data = """{ - "type": "pamUser", - "title": "#project_name# - RDP User", - "fields": [ - {"type": "login", "required": true, "value": ["linuxuser"]}, - {"type": "password", "value": ["alpine"]} - ]}""".replace("#project_name#", project_name) - user_uid = command.execute(params, folder=users_folder_uid, data=json_data) - - json_data = """{ - "type": "pamUser", - "title": "#project_name# - RDP Admin", - "fields": [ - {"type": "login", "required": true, "value": ["root"]}, - {"type": "password", "value": ["rootpassword"]} - ]}""".replace("#project_name#", project_name) - admin_user_uid = command.execute(params, folder=users_folder_uid, data=json_data) - - json_data = """{ - "type": "pamMachine", - "title": "#project_name# - RDP Machine", - "fields": [ - {"type": "pamHostname", "required": true, "value": [{"hostName": "server-rdp","port": "3389"}]}, - {"type": "trafficEncryptionSeed", "value": []}, - {"type": "pamSettings", "value": [{ - "connection": { - "protocol": "rdp", - "security": "any", - "ignoreCert": true, - "resizeMethod": "display-update", - "userRecords": ["#admin_user_uid#"] - }, - "portForward": { "reusePort": true } - }]} - ]}""".replace("#project_name#", project_name).replace("#admin_user_uid#", admin_user_uid) - machine_uid = command.execute(params, folder=resources_folder_uid, data=json_data) - - # Machine -> Config -> Enable connections/portForwards/set trafficEncryptionSeed - tdag.link_resource_to_config(machine_uid) - pte.execute(params, record=machine_uid, config=pam_config_uid, admin=admin_user_uid, enable_connections=True, enable_tunneling=True, silent=True) - tdag.set_resource_allowed(resource_uid=machine_uid, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True) - - # Admin User -> Machine; User -> Machine - tdag.link_user_to_resource(admin_user_uid, machine_uid, True, True) - tdag.link_user_to_resource(user_uid, machine_uid, False, True) - - # create_rbi_record - json_data = """{ - "type": "pamRemoteBrowser", - "title": "#project_name# - Bing Remote Browser", - "fields": [ - {"type": "rbiUrl", "required": true, "value": ["https://bing.com"]}, - {"type": "trafficEncryptionSeed", "value": []}, - {"type": "pamRemoteBrowserSettings", "value": [{ - "connection": { - "protocol": "http", - "allowUrlManipulation": true, - "userRecords": [] - } - }]} - ]}""".replace("#project_name#", project_name) - rbi_uid = command.execute(params, folder=resources_folder_uid, data=json_data) - tdag.link_resource_to_config(rbi_uid) - pte.execute(params, record=rbi_uid, config=pam_config_uid, - enable_rotation=False, enable_connections=True, enable_tunneling=False, - enable_typescripts_recording=False, enable_connections_recording=True, silent=True) - # bugfix: Edit command not always populates correctly everything - tdag.set_resource_allowed(resource_uid=rbi_uid, rotation=False, connections=True, tunneling=False, session_recording=True, typescript_recording=False, remote_browser_isolation=True) - - # Additional discovery-playground resources: postgres, mariadb, mssql, mongodb, telnet - # create_postgresql_records - json_data = """{ - "type": "pamUser", - "title": "#project_name# - PostgreSQL Admin User", - "fields": [ - {"type": "login", "required": true, "value": ["postgres"]}, - {"type": "password", "value": ["postgres"]} - ]}""".replace("#project_name#", project_name) - admin_user_uid = command.execute(params, folder=users_folder_uid, data=json_data) - - json_data = """{ - "type": "pamDatabase", - "title": "#project_name# - PostgreSQL Database", - "fields": [ - {"type": "pamHostname", "required": true, "value": [{"hostName": "db-postgres-1","port": "5432"}]}, - {"type": "trafficEncryptionSeed", "value": []}, - {"type": "databaseType", "value": ["postgresql"]}, - {"type": "pamSettings", "value": [{ - "connection": { - "protocol": "postgresql", - "database": "postgresql", - "userRecords": ["#admin_user_uid#"] - }, - "portForward": { "reusePort": true } - }]} - ]}""".replace("#project_name#", project_name).replace("#admin_user_uid#", admin_user_uid) - database_machine_uid = command.execute(params, folder=resources_folder_uid, data=json_data) - tdag.link_resource_to_config(database_machine_uid) - pte.execute(params, record=database_machine_uid, config=pam_config_uid, admin=admin_user_uid, enable_connections=True, enable_tunneling=True, silent=True) - tdag.set_resource_allowed(resource_uid=database_machine_uid, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True) - tdag.link_user_to_resource(admin_user_uid, database_machine_uid, True, True) - PAMCreateRecordRotationCommand().execute(params, record_name=admin_user_uid, - admin=admin_user_uid, - config=pam_config_uid, resource=database_machine_uid, - on_demand=True, pwd_complexity="20,4,4,4,4", enable=True, force=True, silent=True) - - # create_mariadb_records - json_data = """{ - "type": "pamUser", - "title": "#project_name# - MariaDB Admin User", - "fields": [ - {"type": "login", "required": true, "value": ["root"]}, - {"type": "password", "value": ["z@ggz?y|w#I_NFCW!41"]} - ]}""".replace("#project_name#", project_name) - admin_user_uid = command.execute(params, folder=users_folder_uid, data=json_data) - - json_data = """{ - "type": "pamUser", - "title": "#project_name# - MariaDB Rotation User", - "fields": [ - {"type": "login", "required": true, "value": ["max"]}, - {"type": "password", "value": ["maxpass"]} - ]}""".replace("#project_name#", project_name) - rotation_user_uid = command.execute(params, folder=users_folder_uid, data=json_data) - - json_data = """{ - "type": "pamDatabase", - "title": "#project_name# - MariaDB Database", - "fields": [ - {"type": "pamHostname", "required": true, "value": [{"hostName": "db-mariadb-1","port": "3306"}]}, - {"type": "trafficEncryptionSeed", "value": []}, - {"type": "databaseType", "value": ["mariadb"]}, - {"type": "pamSettings", "value": [{ - "connection": { - "protocol": "mysql", - "database": "mydb", - "userRecords": ["#admin_user_uid#"] - }, - "portForward": { "reusePort": true } - }]} - ]}""".replace("#project_name#", project_name).replace("#admin_user_uid#", admin_user_uid) - database_machine_uid = command.execute(params, folder=resources_folder_uid, data=json_data) - tdag.link_resource_to_config(database_machine_uid) - pte.execute(params, record=database_machine_uid, config=pam_config_uid, admin=admin_user_uid, enable_connections=True, enable_tunneling=True, silent=True) - tdag.set_resource_allowed(resource_uid=database_machine_uid, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True) - tdag.link_user_to_resource(admin_user_uid, database_machine_uid, True, True) - tdag.link_user_to_resource(rotation_user_uid, database_machine_uid, False, True) - PAMCreateRecordRotationCommand().execute(params, record_name=rotation_user_uid, - admin=admin_user_uid, - config=pam_config_uid, resource=database_machine_uid, - on_demand=True, pwd_complexity="20,4,4,4,4", enable=True, force=True, silent=True) - - # create_mssql_records - json_data = """{ - "type": "pamUser", - "title": "#project_name# - Microsoft SQL Server Admin User", - "fields": [ - {"type": "login", "required": true, "value": ["sa"]}, - {"type": "password", "value": ["password"]} - ]}""".replace("#project_name#", project_name) - admin_user_uid = command.execute(params, folder=users_folder_uid, data=json_data) - - json_data = """{ - "type": "pamDatabase", - "title": "#project_name# - Microsoft SQL Server Database", - "fields": [ - {"type": "pamHostname", "required": true, "value": [{"hostName": "db-mssql","port": "1433"}]}, - {"type": "trafficEncryptionSeed", "value": []}, - {"type": "databaseType", "value": ["mssql"]}, - {"type": "pamSettings", "value": [{ - "connection": { - "protocol": "sql-server", - "database": "master", - "userRecords": ["#admin_user_uid#"] - }, - "portForward": { "reusePort": true } - }]} - ]}""".replace("#project_name#", project_name).replace("#admin_user_uid#", admin_user_uid) - database_machine_uid = command.execute(params, folder=resources_folder_uid, data=json_data) - tdag.link_resource_to_config(database_machine_uid) - pte.execute(params, record=database_machine_uid, config=pam_config_uid, admin=admin_user_uid, enable_connections=True, enable_tunneling=True, silent=True) - tdag.set_resource_allowed(resource_uid=database_machine_uid, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True) - tdag.link_user_to_resource(admin_user_uid, database_machine_uid, True, True) - PAMCreateRecordRotationCommand().execute(params, record_name=admin_user_uid, - admin=admin_user_uid, - config=pam_config_uid, resource=database_machine_uid, - on_demand=True, pwd_complexity="20,4,4,4,4", enable=True, force=True, silent=True) - - # create_mongodb_records - protocol not supported yet, so only RBI currently available - # MongoDB Wire Protocol is a simple socket-based, request-response style protocol over TCP/IP socket - json_data = """{ - "type": "pamUser", - "title": "#project_name# - MongoDB Admin User", - "fields": [ - {"type": "login", "required": true, "value": ["root"]}, - {"type": "password", "value": ["root_password"]} - ]}""".replace("#project_name#", project_name) - admin_user_uid = command.execute(params, folder=users_folder_uid, data=json_data) - - json_data = """{ - "type": "pamUser", - "title": "#project_name# - MongoDB Rotation User", - "fields": [ - {"type": "login", "required": true, "value": ["user1"]}, - {"type": "password", "value": ["user1pwd"]} - ]}""".replace("#project_name#", project_name) - rotation_user_uid = command.execute(params, folder=users_folder_uid, data=json_data) - - json_data = """{ - "type": "pamDatabase", - "title": "#project_name# - MongoDB Database", - "fields": [ - {"type": "pamHostname", "required": true, "value": [{"hostName": "db-mongo","port": "27017"}]}, - {"type": "trafficEncryptionSeed", "value": []}, - {"type": "databaseType", "value": ["mongodb"]}, - {"type": "pamSettings", "value": [{ - "connection": { - "protocol": "http", - "database": "mydatabase", - "userRecords": ["#admin_user_uid#"] - }, - "portForward": { "reusePort": true } - }]} - ]}""".replace("#project_name#", project_name).replace("#admin_user_uid#", admin_user_uid) - database_machine_uid = command.execute(params, folder=resources_folder_uid, data=json_data) - tdag.link_resource_to_config(database_machine_uid) - pte.execute(params, record=database_machine_uid, config=pam_config_uid, admin=admin_user_uid, enable_connections=True, enable_tunneling=True, silent=True) - tdag.set_resource_allowed(resource_uid=database_machine_uid, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True) - tdag.link_user_to_resource(admin_user_uid, database_machine_uid, True, True) - tdag.link_user_to_resource(rotation_user_uid, database_machine_uid, False, True) - PAMCreateRecordRotationCommand().execute(params, record_name=rotation_user_uid, - admin=admin_user_uid, - config=pam_config_uid, resource=database_machine_uid, - on_demand=True, pwd_complexity="20,4,4,4,4", enable=True, force=True, silent=True) - - # create_telnet_records - json_data = """{ - "type": "pamUser", - "title": "#project_name# - Telnet Admin", - "fields": [ - {"type": "login", "required": true, "value": ["user"]}, - {"type": "password", "value": ["user1pwd"]} - ]}""".replace("#project_name#", project_name) - admin_user_uid = command.execute(params, folder=users_folder_uid, data=json_data) - - json_data = """{ - "type": "pamMachine", - "title": "#project_name# - Telnet Machine", - "fields": [ - {"type": "pamHostname", "required": true, "value": [{"hostName": "server-telnet","port": "23"}]}, - {"type": "trafficEncryptionSeed", "value": []}, - {"type": "pamSettings", "value": [{ - "connection": { - "protocol": "telnet", - "userRecords": ["#admin_user_uid#"] - }, - "portForward": { "reusePort": true } - }]} - ]}""".replace("#project_name#", project_name).replace("#admin_user_uid#", admin_user_uid) - ssh_machine_uid = command.execute(params, folder=resources_folder_uid, data=json_data) - tdag.link_resource_to_config(ssh_machine_uid) - pte.execute(params, record=ssh_machine_uid, config=pam_config_uid, admin=admin_user_uid, enable_connections=True, enable_tunneling=True, silent=True) - tdag.set_resource_allowed(resource_uid=ssh_machine_uid, rotation=True, connections=True, tunneling=True, session_recording=True, typescript_recording=True, remote_browser_isolation=True) - tdag.link_user_to_resource(admin_user_uid, ssh_machine_uid, True, True) - PAMCreateRecordRotationCommand().execute(params, record_name=admin_user_uid, - admin=admin_user_uid, - config=pam_config_uid, resource=ssh_machine_uid, - on_demand=True, pwd_complexity="20,4,4,4,4", enable=True, force=True, silent=True) - - api.sync_down(params) + session = PlaygroundSession(params, project) + session.create_all_records() + gateway_config_b64 = project["gateway"].get("gateway_config_b64", "") + compose_yaml = session.build_compose(gateway_config_b64) + session.save_compose_and_seccomp(compose_yaml) # def generate_simple_content_data(self, params, project: dict): # """ Generate one Connection and one Tunnel """ From aec928ed37358439df4b4085d4ffa706f7792348 Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Thu, 16 Jul 2026 15:56:34 -0700 Subject: [PATCH 18/19] Import from Bitwarden: Bitwarden collection support --- .../importer/bitwarden/bitwarden.py | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/keepercommander/importer/bitwarden/bitwarden.py b/keepercommander/importer/bitwarden/bitwarden.py index 44cde5cf4..42fcdd9c6 100644 --- a/keepercommander/importer/bitwarden/bitwarden.py +++ b/keepercommander/importer/bitwarden/bitwarden.py @@ -17,9 +17,14 @@ from ...record_types import FieldTypes +def _to_keeper_path(name): # type: (str) -> str + # protect literal Keeper delimiters, then translate Bitwarden nesting '/' -> '\' + return name.replace('\\', '\\\\').replace('/', '\\') + + class BitwardenImporter(importer.BaseFileImporter): def do_import(self, filename, **kwargs): - # type: (importer.BaseImporter, str, dict) -> Iterable[Union[importer.Record]] + # type: (importer.BaseImporter, str, dict) -> Iterable[Union[importer.Record, importer.SharedFolder]] with open(filename, 'r', encoding='utf-8') as bw_file: bw_import = json.load(bw_file) @@ -30,9 +35,22 @@ def do_import(self, filename, **kwargs): raise Exception(f'File {filename}: Encrypted Bitwarden JSON export not supported.') folders = {} + shared_folders = {} for f in bw_import.get('folders', []): if 'id' in f and 'name' in f: folders[f['id']] = f['name'] + for c in bw_import.get('collections', []): + if 'id' in c and 'name' in c: + shared_folders[c['id']] = c['name'] + + seen_sf = set() + for name in shared_folders.values(): + domain = name.partition('/')[0].replace('\\', '\\\\') + if domain and domain not in seen_sf: + seen_sf.add(domain) + sf = importer.SharedFolder() + sf.path = domain + yield sf for i in bw_import.get('items') or []: record = importer.Record() @@ -56,11 +74,23 @@ def do_import(self, filename, **kwargs): else: record.notes = note + record_folders = [] + for cid in i.get('collectionIds') or []: + if cid in shared_folders: + name = shared_folders[cid] + first, _, rest = name.partition('/') + f = importer.Folder() + f.domain = first.replace('\\', '\\\\') # shared folder = first segment + if rest: + f.path = _to_keeper_path(rest) # remaining segments nest beneath it + record_folders.append(f) folder_id = i.get('folderId') or '' if folder_id in folders: f = importer.Folder() - f.path = folders[folder_id] - record.folders = [f] + f.path = _to_keeper_path(folders[folder_id]) + record_folders.append(f) + if record_folders: + record.folders = record_folders fields = i.get('fields') if isinstance(fields, list): for field in fields: From 0da055dbea380ce92fb0c431ea3dfa4e3c732e3a Mon Sep 17 00:00:00 2001 From: Sergey Kolupaev Date: Thu, 16 Jul 2026 16:05:15 -0700 Subject: [PATCH 19/19] Release 18.0.12 --- .github/workflows/test-with-pytest.yml | 6 +++--- keepercommander/__init__.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-with-pytest.yml b/.github/workflows/test-with-pytest.yml index 164247cc3..395c12fda 100644 --- a/.github/workflows/test-with-pytest.yml +++ b/.github/workflows/test-with-pytest.yml @@ -9,16 +9,16 @@ jobs: test-with-pytest: strategy: matrix: - python-version: ['3.8', '3.14'] + python-version: ['3.9', '3.14'] runs-on: ubuntu-24.04 steps: - name: Checkout branch - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/keepercommander/__init__.py b/keepercommander/__init__.py index 0c9cce431..c130c36b2 100644 --- a/keepercommander/__init__.py +++ b/keepercommander/__init__.py @@ -10,4 +10,4 @@ # Contact: commander@keepersecurity.com # -__version__ = '18.0.11' +__version__ = '18.0.12'