Skip to content

Commit

Permalink
Add microsoft.ad.split_dn filter (#170)
Browse files Browse the repository at this point in the history
Adds the microsoft.ad.split_dn to split an LDAP DistinguishedName into
either the leaf or parent component. This allows the caller to easily
retrieve either value without resorting to regex which can be
complicated and prone to escaping issues.
  • Loading branch information
jborean93 authored Dec 11, 2024
1 parent 8f2820c commit 8904855
Show file tree
Hide file tree
Showing 5 changed files with 162 additions and 0 deletions.
1 change: 1 addition & 0 deletions docs/docsite/rst/guide_ldap_inventory.rst
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ The following filters can be used as an easy way to further convert the coerced
* :ref:`microsoft.ad.as_guid <ansible_collections.microsoft.ad.as_guid_filter>`
* :ref:`microsoft.ad.as_sid <ansible_collections.microsoft.ad.as_sid_filter>`
* :ref:`microsoft.ad.parse_dn <ansible_collections.microsoft.ad.parse_dn_filter>`
* :ref:`microsoft.ad.split_dn <ansible_collections.microsoft.ad.split_dn_filter>`

An example of these filters being used in the ``attributes`` option can be seen below:

Expand Down
27 changes: 27 additions & 0 deletions plugins/filter/ldap_converters.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
}
2 changes: 2 additions & 0 deletions plugins/filter/parse_dn.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ DOCUMENTATION:
seealso:
- ref: microsoft.ad.dn_escape <ansible_collections.microsoft.ad.dn_escape_filter>
description: microsoft.ad.dn_escape filter
- ref: microsoft.ad.split_dn <ansible_collections.microsoft.ad.split_dn_filter>
description: microsoft.ad.split_dn filter
- ref: microsoft.ad.ldap <ansible_collections.microsoft.ad.ldap_inventory>
description: microsoft.ad.ldap inventory
description:
Expand Down
73 changes: 73 additions & 0 deletions plugins/filter/split_dn.yml
Original file line number Diff line number Diff line change
@@ -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 <ansible_collections.microsoft.ad.dn_escape_filter>
description: microsoft.ad.dn_escape filter
- ref: microsoft.ad.parse_dn <ansible_collections.microsoft.ad.parse_dn_filter>
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
59 changes: 59 additions & 0 deletions tests/unit/plugins/filter/test_ldap_converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
as_datetime,
dn_escape,
parse_dn,
split_dn,
)


Expand Down Expand Up @@ -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

0 comments on commit 8904855

Please sign in to comment.