diff --git a/.github/workflows/test.ansible.yml b/.github/workflows/test.ansible.yml index e1706adba..c8b141c53 100644 --- a/.github/workflows/test.ansible.yml +++ b/.github/workflows/test.ansible.yml @@ -3,6 +3,10 @@ name: Test-Ansible on: pull_request: branches: [ master ] + paths: + - 'integration/keeper_secrets_manager_ansible/**' + - 'sdk/python/core/**' + - '.github/workflows/test.ansible.yml' jobs: test-ansible: diff --git a/integration/keeper_secrets_manager_ansible/keeper_secrets_manager_ansible/__init__.py b/integration/keeper_secrets_manager_ansible/keeper_secrets_manager_ansible/__init__.py index eb41bb7c3..ccb65315d 100644 --- a/integration/keeper_secrets_manager_ansible/keeper_secrets_manager_ansible/__init__.py +++ b/integration/keeper_secrets_manager_ansible/keeper_secrets_manager_ansible/__init__.py @@ -21,8 +21,6 @@ import random from enum import Enum import traceback -import pickle -import io import base64 import socket @@ -37,6 +35,7 @@ from keeper_secrets_manager_core.core import KSMCache, CreateOptions from keeper_secrets_manager_core.storage import FileKeyValueStorage, InMemoryKeyValueStorage from keeper_secrets_manager_core.utils import generate_password as sdk_generate_password, strtobool + from keeper_secrets_manager_core.dto.dtos import Record as _Record, KeeperFile as _KeeperFile # If keeper_secrets_manager_core is installed, then these will be installed. They are deps. from cryptography.fernet import Fernet @@ -306,16 +305,95 @@ def get_encryption_key(self): return base64.urlsafe_b64encode(kdf.derive(cache_secret.encode())) - def encrypt(self, data): + @staticmethod + def _file_to_dict(keeper_file): + """Serialize a KeeperFile instance to a JSON-safe dictionary.""" + d = { + "name": keeper_file.name, + "title": keeper_file.title, + "type": keeper_file.type, + "last_modified": keeper_file.last_modified, + "size": keeper_file.size, + "f": keeper_file.f, + "file_key": keeper_file.file_key, + "meta_dict": keeper_file.meta_dict, + } + d["record_key_bytes"] = base64.b64encode(keeper_file.record_key_bytes).decode("ascii") \ + if keeper_file.record_key_bytes is not None else None + d["file_data"] = base64.b64encode(keeper_file.file_data).decode("ascii") \ + if keeper_file.file_data is not None else None + return d + @staticmethod + def _file_from_dict(d): + """Reconstruct a KeeperFile instance from a JSON-deserialized dictionary.""" + f = object.__new__(_KeeperFile) + f.name = d["name"] + f.title = d["title"] + f.type = d["type"] + f.last_modified = d["last_modified"] + f.size = d["size"] + f.f = d["f"] + f.file_key = d["file_key"] + f.meta_dict = d["meta_dict"] + f.record_key_bytes = base64.b64decode(d["record_key_bytes"]) \ + if d["record_key_bytes"] is not None else None + f.file_data = base64.b64decode(d["file_data"]) \ + if d["file_data"] is not None else None + return f + + @staticmethod + def _record_to_dict(record): + """Serialize a Record instance to a JSON-safe dictionary.""" + d = { + "uid": record.uid, + "title": record.title, + "type": record.type, + "raw_json": record.raw_json, + "dict": record.dict, + "password": record.password, + "revision": record.revision, + "is_editable": record.is_editable, + "folder_uid": record.folder_uid, + "inner_folder_uid": record.inner_folder_uid, + "links": record.links, + "files": [KeeperAnsible._file_to_dict(f) for f in record.files], + } + d["record_key_bytes"] = base64.b64encode(record.record_key_bytes).decode("ascii") \ + if record.record_key_bytes is not None else None + return d + + @staticmethod + def _record_from_dict(d): + """Reconstruct a Record instance from a JSON-deserialized dictionary.""" + r = object.__new__(_Record) + r.uid = d["uid"] + r.title = d["title"] + r.type = d["type"] + r.raw_json = d["raw_json"] + r.dict = d["dict"] + r.password = d["password"] + r.revision = d["revision"] + r.is_editable = d["is_editable"] + r.folder_uid = d["folder_uid"] + r.inner_folder_uid = d["inner_folder_uid"] + r.links = d["links"] + r.record_key_bytes = base64.b64decode(d["record_key_bytes"]) \ + if d["record_key_bytes"] is not None else None + r.files = [KeeperAnsible._file_from_dict(fd) for fd in d["files"]] + return r + + def encrypt(self, data): secret_key = self.get_encryption_key() - record_fh = io.BytesIO() - pickle.dump(data, record_fh) - return Fernet(secret_key).encrypt(record_fh.getvalue()) + serializable = [KeeperAnsible._record_to_dict(r) for r in data] + json_bytes = json.dumps(serializable).encode("utf-8") + return Fernet(secret_key).encrypt(json_bytes) def decrypt(self, ciphertext): secret_key = self.get_encryption_key() - return pickle.loads(Fernet(secret_key).decrypt(ciphertext)) + json_bytes = Fernet(secret_key).decrypt(ciphertext) + record_dicts = json.loads(json_bytes) + return [KeeperAnsible._record_from_dict(d) for d in record_dicts] @staticmethod def convert_records_into_dict(records): @@ -477,14 +555,14 @@ def get_record(self, uids=None, titles=None, cache=None): return records[0] - def create_record(self, new_record, shared_folder_uid): + def create_record(self, new_record, shared_folder_uid, folder_uid=None): # KSM-816: use create_secret_with_options() instead of create_secret() so # that folder keys are fetched via the get_folders endpoint, which returns # all folders including empty ones. create_secret() uses get_secrets() which # only returns folder keys when the folder already contains records. try: record_uid = self.client.create_secret_with_options( - CreateOptions(shared_folder_uid, None), new_record + CreateOptions(shared_folder_uid, folder_uid), new_record ) except Exception as err: raise Exception("Cannot get create record: {}".format(err)) diff --git a/integration/keeper_secrets_manager_ansible/keeper_secrets_manager_ansible/plugins/action/keeper_create.py b/integration/keeper_secrets_manager_ansible/keeper_secrets_manager_ansible/plugins/action/keeper_create.py index 1444c89b5..d06503a54 100644 --- a/integration/keeper_secrets_manager_ansible/keeper_secrets_manager_ansible/plugins/action/keeper_create.py +++ b/integration/keeper_secrets_manager_ansible/keeper_secrets_manager_ansible/plugins/action/keeper_create.py @@ -37,9 +37,16 @@ shared_folder_uid: description: - The UID of the top-level shared folder in your Keeper application. - - Must be a shared folder UID, not a subfolder UID. + - To create in a subfolder, also provide C(folder_uid). type: str required: yes + folder_uid: + description: + - The UID of a subfolder within the shared folder where the record should be created. + - When omitted, the record is created at the shared folder root. + - The subfolder must already exist and be accessible to the KSM application. + type: str + required: no record_type: description: - The type if record to create. @@ -204,9 +211,9 @@ ''' EXAMPLES = r''' -- name: Create a new record +- name: Create a record in a shared folder keeper_create: - share_folder_uid: XXX + shared_folder_uid: SHARED_FOLDER_UID record_type: login title: My Title notes: This record was created from Ansible @@ -221,6 +228,18 @@ label: Custom Field value: This is a value is a custom field. register: my_new_record + +- name: Create a record in a subfolder + keeper_create: + shared_folder_uid: SHARED_FOLDER_UID + folder_uid: SUBFOLDER_UID + record_type: login + title: My Subfolder Record + generate_password: True + fields: + - type: login + value: jane.doe@nowhere.com + register: my_subfolder_record ''' RETURN = r''' @@ -245,6 +264,7 @@ def run(self, tmp=None, task_vars=None): shared_folder_uid = self._task.args.get("shared_folder_uid") if shared_folder_uid is None: raise AnsibleError("The shared_folder_uid is blank. keeper_create requires this value to be set.") + folder_uid = self._task.args.get("folder_uid") record_type = self._task.args.get("record_type") if record_type is None: raise AnsibleError("The record_type is blank. keeper_create requires this value to be set.") @@ -313,7 +333,8 @@ def run(self, tmp=None, task_vars=None): password_complexity=password_complexity ) record_create = record[0].get_record_create_obj() - record_uid = keeper.create_record(record_create, shared_folder_uid=shared_folder_uid) + record_uid = keeper.create_record(record_create, shared_folder_uid=shared_folder_uid, + folder_uid=folder_uid) except Exception as err: raise AnsibleError("Could not create record: {}".format(err)) diff --git a/integration/keeper_secrets_manager_ansible/keeper_secrets_manager_ansible/plugins/modules/keeper_create.py b/integration/keeper_secrets_manager_ansible/keeper_secrets_manager_ansible/plugins/modules/keeper_create.py index 967209953..0373446a6 100644 --- a/integration/keeper_secrets_manager_ansible/keeper_secrets_manager_ansible/plugins/modules/keeper_create.py +++ b/integration/keeper_secrets_manager_ansible/keeper_secrets_manager_ansible/plugins/modules/keeper_create.py @@ -26,9 +26,16 @@ shared_folder_uid: description: - The UID of the top-level shared folder in your Keeper application. - - Must be a shared folder UID, not a subfolder UID. + - To create in a subfolder, also provide C(folder_uid). type: str required: yes + folder_uid: + description: + - The UID of a subfolder within the shared folder where the record should be created. + - When omitted, the record is created at the shared folder root. + - The subfolder must already exist and be accessible to the KSM application. + type: str + required: no record_type: description: - The type if record to create. @@ -182,9 +189,9 @@ ''' EXAMPLES = r''' -- name: Create a new record +- name: Create a record in a shared folder keeper_create: - share_folder_uid: XXX + shared_folder_uid: SHARED_FOLDER_UID record_type: login title: My Title notes: This record was created from Ansible @@ -199,6 +206,18 @@ label: Custom Field value: This is a value is a custom field. register: my_new_record + +- name: Create a record in a subfolder + keeper_create: + shared_folder_uid: SHARED_FOLDER_UID + folder_uid: SUBFOLDER_UID + record_type: login + title: My Subfolder Record + generate_password: True + fields: + - type: login + value: jane.doe@nowhere.com + register: my_subfolder_record ''' RETURN = r''' diff --git a/integration/keeper_secrets_manager_ansible/requirements.txt b/integration/keeper_secrets_manager_ansible/requirements.txt index fee550f07..db1fa4a38 100644 --- a/integration/keeper_secrets_manager_ansible/requirements.txt +++ b/integration/keeper_secrets_manager_ansible/requirements.txt @@ -1,3 +1,3 @@ ansible-core>=2.12.0 -keeper-secrets-manager-core>=17.2.0 -keeper-secrets-manager-helper>=1.1.0 +keeper-secrets-manager-core>=17.3.0 +keeper-secrets-manager-helper>=1.1.2 diff --git a/integration/keeper_secrets_manager_ansible/setup.py b/integration/keeper_secrets_manager_ansible/setup.py index 6c5f6da9b..927b29bb4 100644 --- a/integration/keeper_secrets_manager_ansible/setup.py +++ b/integration/keeper_secrets_manager_ansible/setup.py @@ -9,14 +9,14 @@ long_description = fp.read() install_requires = [ - 'keeper-secrets-manager-core>=17.2.0', - 'keeper-secrets-manager-helper>=1.1.0', + 'keeper-secrets-manager-core>=17.3.0', + 'keeper-secrets-manager-helper>=1.1.2', 'ansible-core>=2.12.0' # Use ansible-core instead of ansible to avoid community collections ] setup( name="keeper-secrets-manager-ansible", - version='1.4.0', + version='1.5.0', description="Keeper Secrets Manager plugins for Ansible.", long_description=long_description, long_description_content_type="text/markdown", diff --git a/integration/keeper_secrets_manager_ansible/tests/ansible_example/playbooks/keeper_create_subfolder.yml b/integration/keeper_secrets_manager_ansible/tests/ansible_example/playbooks/keeper_create_subfolder.yml new file mode 100644 index 000000000..2da2fa4ba --- /dev/null +++ b/integration/keeper_secrets_manager_ansible/tests/ansible_example/playbooks/keeper_create_subfolder.yml @@ -0,0 +1,23 @@ +# vim: set shiftwidth=2 tabstop=2 softtabstop=-1 expandtab: +--- +- name: Keeper Create Subfolder + hosts: "my_systems" + gather_facts: no + + tasks: + - name: "Create Record in Subfolder" + keeper_create: + shared_folder_uid: "{{ shared_folder_uid }}" + folder_uid: "{{ folder_uid | default(omit) }}" + record_type: login + generate_password: True + title: "My Subfolder Record" + fields: + - type: login + value: "johndoe@localhost" + register: "new_record" + + - name: "Print Record UID" + debug: + var: new_record.record_uid + verbosity: 0 diff --git a/integration/keeper_secrets_manager_ansible/tests/keeper_create_subfolder_test.py b/integration/keeper_secrets_manager_ansible/tests/keeper_create_subfolder_test.py new file mode 100644 index 000000000..e4992272a --- /dev/null +++ b/integration/keeper_secrets_manager_ansible/tests/keeper_create_subfolder_test.py @@ -0,0 +1,36 @@ +import unittest +from unittest.mock import MagicMock +from keeper_secrets_manager_core.dto.payload import CreateOptions +from keeper_secrets_manager_ansible import KeeperAnsible + + +class KeeperCreateSubfolderTest(unittest.TestCase): + """ + Unit tests for KeeperAnsible.create_record() subfolder support (KSM-845). + + Tests that folder_uid flows through to CreateOptions.subfolder_uid, + and that omitting folder_uid preserves backward-compatible None behavior. + """ + + def _make_keeper(self): + mock_client = MagicMock() + mock_client.create_secret_with_options.return_value = "NEW_UID" + keeper = object.__new__(KeeperAnsible) + keeper.client = mock_client + return keeper, mock_client + + def test_folder_uid_passed_as_subfolder_uid(self): + keeper, mock_client = self._make_keeper() + keeper.create_record(MagicMock(), "SHARED_UID", folder_uid="SUB_UID") + create_options = mock_client.create_secret_with_options.call_args[0][0] + self.assertIsInstance(create_options, CreateOptions) + self.assertEqual(create_options.folder_uid, "SHARED_UID") + self.assertEqual(create_options.subfolder_uid, "SUB_UID") + + def test_no_folder_uid_defaults_to_none(self): + keeper, mock_client = self._make_keeper() + keeper.create_record(MagicMock(), "SHARED_UID") + create_options = mock_client.create_secret_with_options.call_args[0][0] + self.assertIsInstance(create_options, CreateOptions) + self.assertEqual(create_options.folder_uid, "SHARED_UID") + self.assertIsNone(create_options.subfolder_uid)