Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/test.ansible.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@
import random
from enum import Enum
import traceback
import pickle
import io
import base64
import socket

Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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: [email protected]
register: my_subfolder_record
'''

RETURN = r'''
Expand All @@ -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.")
Expand Down Expand Up @@ -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))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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: [email protected]
register: my_subfolder_record
'''

RETURN = r'''
Expand Down
4 changes: 2 additions & 2 deletions integration/keeper_secrets_manager_ansible/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions integration/keeper_secrets_manager_ansible/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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)
Loading