Skip to content

Commit

Permalink
Merge pull request #39 from 1Password/dg/34-item_info_fixes
Browse files Browse the repository at this point in the history
Add connect.field_info module & make flattened response from `connect.item_info` configurable
  • Loading branch information
hculea committed Nov 16, 2021
2 parents 4a7556f + 3f11532 commit e126355
Show file tree
Hide file tree
Showing 14 changed files with 510 additions and 51 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ test/integration: ## Run integration tests inside a Docker container
$(SCRIPTS_DIR)/run-tests.sh integration

test/sanity: ## Run ansible sanity tests in a Docker container
$(SCRIPTS_DIR)/run_tests.sh sanity
$(SCRIPTS_DIR)/run-tests.sh sanity

build: clean ## Build collection artifact
ansible-galaxy collection build --output-path dist/
Expand Down
24 changes: 23 additions & 1 deletion plugins/module_utils/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
__metaclass__ = type

import json
import os
import base64

import sys
import re

from ansible.module_utils.urls import fetch_url
from ansible.module_utils.six.moves.urllib.parse import urlencode, quote, urlunparse, urlparse
from ansible_collections.onepassword.connect.plugins.module_utils import errors, const


def create_client(module):

if not module.params.get("hostname") or not module.params.get("token"):
raise errors.AccessDeniedError(message="Server hostname or auth token not defined")

Expand Down Expand Up @@ -189,3 +192,22 @@ def _format_user_agent(collection_version, python_version=None, ansible_version=
py_version=python_version or "unknown",
ansible=ansible_version or "unknown"
)


# Client UUIDs must be exactly 26 characters.
CLIENT_UUID_LENGTH = 26


def valid_client_uuid(uuid):
"""Checks whether a given UUID meets the client UUID spec"""
# triple curly braces needed to escape f-strings as regex quantifiers
return re.match(rf"^[0-9a-z]{{{CLIENT_UUID_LENGTH}}}$", uuid) is not None


def create_client_uuid():
"""Creates a valid client UUID.
The UUID is not intended to be cryptographically random."""
rand_bytes = os.urandom(16)
base32_utf8 = base64.b32encode(rand_bytes).decode("utf-8")
return base32_utf8.rstrip("=").lower()
4 changes: 4 additions & 0 deletions plugins/module_utils/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ class PrimaryPasswordUndefined(Error):
DEFAULT_MSG = "This item category requires at least one concealed field."


class FieldNotUnique(Error):
DEFAULT_MSG = "Provided field label is not unique. Please provide a section or a more specific field label."


class APIError(Error):
DEFAULT_MSG = "Error while communicating with Secrets Server"
STATUS_CODE = 400
Expand Down
18 changes: 3 additions & 15 deletions plugins/module_utils/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@

__metaclass__ = type

import unicodedata
from ansible.module_utils.six import text_type
from ansible_collections.onepassword.connect.plugins.module_utils import const
from ansible_collections.onepassword.connect.plugins.module_utils import const, util


def field_from_params(field_params, generate_field_value=False):
Expand Down Expand Up @@ -73,24 +71,14 @@ def _get_field_by_label(fields, label):
except TypeError:
return None

label = normalize_label(label)
label = util.utf8_normalize(label)

return next((
field for field in fields
if normalize_label(field.get("label")) == label
if util.utf8_normalize(field.get("label")) == label
), None)


def normalize_label(raw_str):
"""Standardizes utf-8 encoding for comparison
and removes leading/trailing spaces"""
if not raw_str:
return None

unicode_normalized = unicodedata.normalize("NFKD", text_type(raw_str))
return unicode_normalized.strip()


def _get_generator_recipe(config):
"""
Creates dict with Password Generator Recipe settings
Expand Down
35 changes: 34 additions & 1 deletion plugins/module_utils/specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,15 @@ def op_item_info():
type="str",
required=True
),
flatten_fields_by_label=dict(
type="bool",
default=True
),
# Direct users to field_info module instead
field=dict(
type="str"
type="str",
removed_from_collection="onepassword.connect",
removed_in_version="3.0.0",
),
vault=dict(
type="str"
Expand All @@ -79,6 +86,32 @@ def op_item_info():
return item_spec


def op_field_info():
"""
Helper that compiles the field_info argspec with common module specs
:return: dict
"""
field_spec = dict(
item=dict(
type="str",
required=True
),
field=dict(
type="str",
required=True
),
vault=dict(
type="str",
required=True,
),
section=dict(
type="str"
)
)
field_spec.update(common_options())
return field_spec


# Configuration for the "Secure Password/Value Generator"
GENERATOR_RECIPE_OPTIONS = dict(
length=dict(
Expand Down
15 changes: 15 additions & 0 deletions plugins/module_utils/util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from __future__ import (absolute_import, division, print_function)

__metaclass__ = type

import unicodedata
from ansible.module_utils.six import text_type


def utf8_normalize(raw):
"""Normalizes a utf-8 string for safe use in comparisons"""
if not raw:
return None

unicode_normalized = unicodedata.normalize("NFKD", text_type(raw))
return unicode_normalized.strip()
218 changes: 218 additions & 0 deletions plugins/modules/field_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
#
# (c) 2021, 1Password & Agilebits (@1Password)

from __future__ import (absolute_import, division, print_function)

__metaclass__ = type

DOCUMENTATION = '''
module: field_info
author:
- 1Password (@1Password)
requirements: []
notes:
version_added: 2.2.0
short_description: Returns the value of a field in a 1Password item.
description:
- Get the value a single field given its label.
- You may provide a section label to limit the search to that item section.
options:
item:
type: str
required: True
description:
- Name or ID of the item
field:
type: str
required: True
description:
- The field label to search for.
- If the section parameter is undefined, the field label must be unique across all item fields.
vault:
type: str
required: True
description:
- ID of the Vault containing the item
section:
type: str
description:
- An item section label or ID.
- If provided, the module limits the search for the field to this section.
- If not provided, the module searches the entire item for the field.
extends_documentation_fragment:
- onepassword.connect.api_params
'''

EXAMPLES = '''
---
- name: Find a field labeled "username" in an item named "MySQL Database" in a specific vault.
onepassword.connect.field_info:
item: MySQL Database
field: username
vault: 2zbeu4smcibizsuxmyvhdh57b6
- name: Find a field labeled "username" in a specific section.
onepassword.connect.field_info:
item: MySQL Database
section: Credentials
field: username
vault: 2zbeu4smcibizsuxmyvhdh57b6
'''

RETURN = '''
field:
description: The value and metadata of the field
type: complex
returned: always
contains:
value:
type: str
description: The field's stored value
returned: success
section:
type: str
description: The section containing this field, if any.
id:
type: str
returned: success
description: UUID for the returned field
sample: "fb3b40ac85f5435d26e"
'''

from ansible.module_utils.six import text_type
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.onepassword.connect.plugins.module_utils import specs, api, errors, fields, util
from ansible.module_utils.common.text.converters import to_native


def find_field(field_identifier, item, section=None) -> dict:
"""
Tries to find the requested field within the provided item.
The field may be a valid client UUID or it may be the field's label.
If the section kwarg is provided, the function limits its search
to fields within that section.
"""
if not item.get("fields"):
raise errors.NotFoundError("Item has no fields")

section_uuid = None
if section:
section_uuid = _get_section_uuid(item.get("sections"), section)

if api.valid_client_uuid(field_identifier):
return _find_field_by_id(field_identifier, item["fields"], section_uuid)

return _find_field_by_label(field_identifier, item["fields"], section_uuid)


def _find_section_id_by_label(sections, label):
label = util.utf8_normalize(label)

for section in sections:
if util.utf8_normalize(section["label"]) == label:
return section["id"]

raise errors.NotFoundError("Section label not found in item")


def _get_section_uuid(sections, section_identifier):
if not sections:
return None

if not api.valid_client_uuid(section_identifier):
return _find_section_id_by_label(sections, section_identifier)
return section_identifier


def _find_field_by_label(field_label, fields, section_id=None):
wanted_label = util.utf8_normalize(field_label)

for field in fields:
label = util.utf8_normalize(field["label"])
if section_id is None and label == wanted_label:
return field

if field.get("section", {}).get("id") == section_id \
and label == wanted_label:
return field

raise errors.NotFoundError("Field with provided label not found in item")


def _find_field_by_id(field_id, fields, section_id=None):
for field in fields:

if section_id is None and field["id"] == field_id:
return field

if field.get("section", {}).get("id") == section_id \
and field["id"] == field_id:
return field

raise errors.NotFoundError("Field not found in item")


def get_item(vault, item, op_client):
if not api.valid_client_uuid(vault):
vault = _get_vault_id(vault, op_client.get_vaults())

if not api.valid_client_uuid(item):
return op_client.get_item_by_name(vault, item)
return op_client.get_item_by_id(vault, item)


def _get_vault_id(vault_name, all_vaults):
normalized_vault_name = util.utf8_normalize(vault_name)

for vault in all_vaults:
if normalized_vault_name == util.utf8_normalize(vault.get("name")):
return vault["id"]
raise errors.NotFoundError("Vault not found")


def _to_field_info(field) -> dict:
return {
"value": field.get("value"),
"section": field.get("section", {}).get("id"),
"id": field.get("id")
}


def main():
result = {"field": {}}

module = AnsibleModule(
argument_spec=specs.op_field_info()
)

api_client = api.create_client(module)

field_label = module.params.get("field")
vault_id = module.params.get("vault")
item_id = module.params.get("item")
section_label = module.params.get("section")

if not api.valid_client_uuid(vault_id):
module.fail_json({"field": {}, "msg": "Vault ID invalid or undefined."})
return

try:
item = get_item(vault_id, item_id, api_client)
field = find_field(field_label, item, section=section_label)
result.update({"field": _to_field_info(field)})
except errors.NotFoundError as e:
result.update({"msg": to_native("Field not found: {err}".format(err=e))})
module.fail_json(**result)
except errors.Error as e:
result.update({"msg": to_native(e)})
module.fail_json(**result)

module.exit_json(**result)


if __name__ == '__main__':
main()

0 comments on commit e126355

Please sign in to comment.