diff --git a/docs/docsite/rst/guide_ldap_inventory.rst b/docs/docsite/rst/guide_ldap_inventory.rst index b9aa9b9..e26a31f 100644 --- a/docs/docsite/rst/guide_ldap_inventory.rst +++ b/docs/docsite/rst/guide_ldap_inventory.rst @@ -226,6 +226,7 @@ The following filters can be used as an easy way to further convert the coerced * :ref:`microsoft.ad.as_guid ` * :ref:`microsoft.ad.as_sid ` * :ref:`microsoft.ad.parse_dn ` +* :ref:`microsoft.ad.split_dn ` An example of these filters being used in the ``attributes`` option can be seen below: diff --git a/plugins/filter/ldap_converters.py b/plugins/filter/ldap_converters.py index 0f4c2af..527d607 100644 --- a/plugins/filter/ldap_converters.py +++ b/plugins/filter/ldap_converters.py @@ -1,6 +1,8 @@ # Copyright: (c) 2023, Ansible Project # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import annotations + import base64 import datetime import re @@ -318,6 +320,30 @@ def parse_dn(value: str) -> t.List[t.List[str]]: return dn +@per_sequence +def split_dn( + value: str, + section: t.Literal["leaf", "parent"] = "leaf", + /, +) -> str: + """Splits a DistinguishedName into either the leaf or parent RDNs.""" + + parsed_dn = parse_dn(value) + + if not parsed_dn: + return "" + + def join_rdn(rdn: list[str]) -> str: + pairs = zip(rdn[0::2], rdn[1::2]) + return "+".join([f"{atv[0]}={dn_escape(atv[1])}" for atv in pairs]) + + if section == "leaf": + return join_rdn(parsed_dn[0]) + else: + + return ",".join(join_rdn(rdn) for rdn in parsed_dn[1:]) + + class FilterModule: def filters(self) -> t.Dict[str, t.Callable]: return { @@ -326,4 +352,5 @@ def filters(self) -> t.Dict[str, t.Callable]: "as_sid": as_sid, "dn_escape": dn_escape, "parse_dn": parse_dn, + "split_dn": split_dn, } diff --git a/plugins/filter/parse_dn.yml b/plugins/filter/parse_dn.yml index 18feacb..bd77e24 100644 --- a/plugins/filter/parse_dn.yml +++ b/plugins/filter/parse_dn.yml @@ -10,6 +10,8 @@ DOCUMENTATION: seealso: - ref: microsoft.ad.dn_escape description: microsoft.ad.dn_escape filter + - ref: microsoft.ad.split_dn + description: microsoft.ad.split_dn filter - ref: microsoft.ad.ldap description: microsoft.ad.ldap inventory description: diff --git a/plugins/filter/split_dn.yml b/plugins/filter/split_dn.yml new file mode 100644 index 0000000..0bfa13d --- /dev/null +++ b/plugins/filter/split_dn.yml @@ -0,0 +1,73 @@ +# Copyright (c) 2024 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION: + name: split_dn + author: + - Jordan Borean (@jborean93) + short_description: Splits an LDAP DistinguishedName. + version_added: 1.8.0 + seealso: + - ref: microsoft.ad.dn_escape + description: microsoft.ad.dn_escape filter + - ref: microsoft.ad.parse_dn + description: microsoft.ad.parse_dn filter + description: + - Splits the provided LDAP DistinguishedName (C(DN)) string value giving + you the first/leaf RDN component or the remaining/parent RDN components. + - The rules for parsing as defined in + L(RFC 4514,https://www.ietf.org/rfc/rfc4514.txt). + - Each DN contains Relative DistinguishedNames (C(RDN)) separated by C(,). + - The returned string for each DN will be either the first/leaf RDN + component representing the name of the object, or the remaining/parent + components representing the parent DN path. Use the I(section) kwarg to + control what should be returned. + - A DN that is invalid will raise a filter error. + - As the values are canonicalized, the returned values may not match the + original DN string provided but do represent the same LDAP DN value. + - Leading and trailing whitespace from each component is removed from the + returned value. + positional: _input + options: + _input: + description: + - The LDAP DistinguishedName string to split. + type: str + required: true + section: + description: + - The DN section to return. + - Defaults to C(leaf) which will return the first RDN component. + - Set to C(parent) to return the remaining RDN components. + - Do not specify C(section) as a keyword, this value is passed as a + positional argument. + type: str + choices: + - leaf + - parent + default: leaf + + +EXAMPLES: | + - name: Gets the leaf RDN of a DN + set_fact: + my_dn: '{{ "CN=Foo,DC=domain,DC=com" | microsoft.ad.split_dn }}' + + # CN=Foo + + - name: Gets the parent RDNs of a DN + set_fact: + my_dn: >- + {{ + "CN=Acme\, Inc.,O=OrgName,C=AU+ST=Queensland" | + microsoft.ad.split_dn("parent") + }} + + # O=OrgName,C=AU+ST=Queensland, + +RETURN: + _value: + description: + - The split RDN components based on the section requested. + type: str + sample: CN=Foo diff --git a/tests/unit/plugins/filter/test_ldap_converters.py b/tests/unit/plugins/filter/test_ldap_converters.py index 362e76b..bafa1e0 100644 --- a/tests/unit/plugins/filter/test_ldap_converters.py +++ b/tests/unit/plugins/filter/test_ldap_converters.py @@ -14,6 +14,7 @@ as_datetime, dn_escape, parse_dn, + split_dn, ) @@ -236,3 +237,61 @@ def test_parse_dn_invalid_attr_value_escape() -> None: expected = r"Found invalid escape sequence in attribute value at '\\1z" with pytest.raises(AnsibleFilterError, match=expected): parse_dn("foo=bar \\1z") + + +@pytest.mark.parametrize( + "value, expected", + [ + ("", ""), + ("CN=foo", "CN=foo"), + (r"CN=foo,DC=bar", "CN=foo"), + (r"CN=foo, DC=bar", "CN=foo"), + (r"CN=foo , DC=bar", "CN=foo"), + (r"CN=foo , DC=bar", "CN=foo"), + (r"UID=jsmith,DC=example,DC=net", "UID=jsmith"), + (r"OU=Sales+CN=J. Smith,DC=example,DC=net", "OU=Sales+CN=J. Smith"), + (r"OU=Sales + CN=J. Smith,DC=example,DC=net", "OU=Sales+CN=J. Smith"), + ( + r"CN=James \"Jim\" Smith\, III,DC=example,DC=net", + r"CN=James \"Jim\" Smith\, III", + ), + (r"CN=Before\0dAfter,DC=example,DC=net", r"CN=Before\0DAfter"), + (r"1.3.6.1.4.1.1466.0=#FE04024869", "1.3.6.1.4.1.1466.0=\udcfe\x04\x02Hi"), + (r"1.3.6.1.4.1.1466.0 = #FE04024869", "1.3.6.1.4.1.1466.0=\udcfe\x04\x02Hi"), + (r"CN=Lu\C4\8Di\C4\87", "CN=Lučić"), + ], +) +def test_split_dn_leaf(value: str, expected: str) -> None: + actual = split_dn(value) + assert actual == expected + + +@pytest.mark.parametrize( + "value, expected", + [ + ("", ""), + ("CN=foo", ""), + (r"CN=foo,DC=bar", "DC=bar"), + (r"CN=foo, DC=bar", "DC=bar"), + (r"CN=foo , DC=bar", "DC=bar"), + (r"CN=foo , DC=bar", "DC=bar"), + (r"UID=jsmith,DC=example,DC=net", "DC=example,DC=net"), + (r"OU=Sales+CN=J. Smith,DC=example,DC=net", "DC=example,DC=net"), + (r"OU=Sales + CN=J. Smith,DC=example,DC=net", "DC=example,DC=net"), + ( + r"CN=James \"Jim\" Smith\, III,DC=example,DC=net", + r"DC=example,DC=net", + ), + (r"CN=Before\0dAfter,DC=example,DC=net", r"DC=example,DC=net"), + (r"1.3.6.1.4.1.1466.0=#FE04024869", ""), + (r"1.3.6.1.4.1.1466.0 = #FE04024869", ""), + (r"CN=Lu\C4\8Di\C4\87", ""), + ( + r"CN=foo,DC=bar+C=US\, test+OU=Fake\+Test,DC=end", + r"DC=bar+C=US\, test+OU=Fake\+Test,DC=end", + ), + ], +) +def test_split_dn_parent(value: str, expected: str) -> None: + actual = split_dn(value, "parent") + assert actual == expected