Skip to content

Commit 8904855

Browse files
authored
Add microsoft.ad.split_dn filter (#170)
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.
1 parent 8f2820c commit 8904855

File tree

5 files changed

+162
-0
lines changed

5 files changed

+162
-0
lines changed

docs/docsite/rst/guide_ldap_inventory.rst

+1
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ The following filters can be used as an easy way to further convert the coerced
226226
* :ref:`microsoft.ad.as_guid <ansible_collections.microsoft.ad.as_guid_filter>`
227227
* :ref:`microsoft.ad.as_sid <ansible_collections.microsoft.ad.as_sid_filter>`
228228
* :ref:`microsoft.ad.parse_dn <ansible_collections.microsoft.ad.parse_dn_filter>`
229+
* :ref:`microsoft.ad.split_dn <ansible_collections.microsoft.ad.split_dn_filter>`
229230

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

plugins/filter/ldap_converters.py

+27
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Copyright: (c) 2023, Ansible Project
22
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
33

4+
from __future__ import annotations
5+
46
import base64
57
import datetime
68
import re
@@ -318,6 +320,30 @@ def parse_dn(value: str) -> t.List[t.List[str]]:
318320
return dn
319321

320322

323+
@per_sequence
324+
def split_dn(
325+
value: str,
326+
section: t.Literal["leaf", "parent"] = "leaf",
327+
/,
328+
) -> str:
329+
"""Splits a DistinguishedName into either the leaf or parent RDNs."""
330+
331+
parsed_dn = parse_dn(value)
332+
333+
if not parsed_dn:
334+
return ""
335+
336+
def join_rdn(rdn: list[str]) -> str:
337+
pairs = zip(rdn[0::2], rdn[1::2])
338+
return "+".join([f"{atv[0]}={dn_escape(atv[1])}" for atv in pairs])
339+
340+
if section == "leaf":
341+
return join_rdn(parsed_dn[0])
342+
else:
343+
344+
return ",".join(join_rdn(rdn) for rdn in parsed_dn[1:])
345+
346+
321347
class FilterModule:
322348
def filters(self) -> t.Dict[str, t.Callable]:
323349
return {
@@ -326,4 +352,5 @@ def filters(self) -> t.Dict[str, t.Callable]:
326352
"as_sid": as_sid,
327353
"dn_escape": dn_escape,
328354
"parse_dn": parse_dn,
355+
"split_dn": split_dn,
329356
}

plugins/filter/parse_dn.yml

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ DOCUMENTATION:
1010
seealso:
1111
- ref: microsoft.ad.dn_escape <ansible_collections.microsoft.ad.dn_escape_filter>
1212
description: microsoft.ad.dn_escape filter
13+
- ref: microsoft.ad.split_dn <ansible_collections.microsoft.ad.split_dn_filter>
14+
description: microsoft.ad.split_dn filter
1315
- ref: microsoft.ad.ldap <ansible_collections.microsoft.ad.ldap_inventory>
1416
description: microsoft.ad.ldap inventory
1517
description:

plugins/filter/split_dn.yml

+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Copyright (c) 2024 Ansible Project
2+
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
3+
4+
DOCUMENTATION:
5+
name: split_dn
6+
author:
7+
- Jordan Borean (@jborean93)
8+
short_description: Splits an LDAP DistinguishedName.
9+
version_added: 1.8.0
10+
seealso:
11+
- ref: microsoft.ad.dn_escape <ansible_collections.microsoft.ad.dn_escape_filter>
12+
description: microsoft.ad.dn_escape filter
13+
- ref: microsoft.ad.parse_dn <ansible_collections.microsoft.ad.parse_dn_filter>
14+
description: microsoft.ad.parse_dn filter
15+
description:
16+
- Splits the provided LDAP DistinguishedName (C(DN)) string value giving
17+
you the first/leaf RDN component or the remaining/parent RDN components.
18+
- The rules for parsing as defined in
19+
L(RFC 4514,https://www.ietf.org/rfc/rfc4514.txt).
20+
- Each DN contains Relative DistinguishedNames (C(RDN)) separated by C(,).
21+
- The returned string for each DN will be either the first/leaf RDN
22+
component representing the name of the object, or the remaining/parent
23+
components representing the parent DN path. Use the I(section) kwarg to
24+
control what should be returned.
25+
- A DN that is invalid will raise a filter error.
26+
- As the values are canonicalized, the returned values may not match the
27+
original DN string provided but do represent the same LDAP DN value.
28+
- Leading and trailing whitespace from each component is removed from the
29+
returned value.
30+
positional: _input
31+
options:
32+
_input:
33+
description:
34+
- The LDAP DistinguishedName string to split.
35+
type: str
36+
required: true
37+
section:
38+
description:
39+
- The DN section to return.
40+
- Defaults to C(leaf) which will return the first RDN component.
41+
- Set to C(parent) to return the remaining RDN components.
42+
- Do not specify C(section) as a keyword, this value is passed as a
43+
positional argument.
44+
type: str
45+
choices:
46+
- leaf
47+
- parent
48+
default: leaf
49+
50+
51+
EXAMPLES: |
52+
- name: Gets the leaf RDN of a DN
53+
set_fact:
54+
my_dn: '{{ "CN=Foo,DC=domain,DC=com" | microsoft.ad.split_dn }}'
55+
56+
# CN=Foo
57+
58+
- name: Gets the parent RDNs of a DN
59+
set_fact:
60+
my_dn: >-
61+
{{
62+
"CN=Acme\, Inc.,O=OrgName,C=AU+ST=Queensland" |
63+
microsoft.ad.split_dn("parent")
64+
}}
65+
66+
# O=OrgName,C=AU+ST=Queensland,
67+
68+
RETURN:
69+
_value:
70+
description:
71+
- The split RDN components based on the section requested.
72+
type: str
73+
sample: CN=Foo

tests/unit/plugins/filter/test_ldap_converters.py

+59
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
as_datetime,
1515
dn_escape,
1616
parse_dn,
17+
split_dn,
1718
)
1819

1920

@@ -236,3 +237,61 @@ def test_parse_dn_invalid_attr_value_escape() -> None:
236237
expected = r"Found invalid escape sequence in attribute value at '\\1z"
237238
with pytest.raises(AnsibleFilterError, match=expected):
238239
parse_dn("foo=bar \\1z")
240+
241+
242+
@pytest.mark.parametrize(
243+
"value, expected",
244+
[
245+
("", ""),
246+
("CN=foo", "CN=foo"),
247+
(r"CN=foo,DC=bar", "CN=foo"),
248+
(r"CN=foo, DC=bar", "CN=foo"),
249+
(r"CN=foo , DC=bar", "CN=foo"),
250+
(r"CN=foo , DC=bar", "CN=foo"),
251+
(r"UID=jsmith,DC=example,DC=net", "UID=jsmith"),
252+
(r"OU=Sales+CN=J. Smith,DC=example,DC=net", "OU=Sales+CN=J. Smith"),
253+
(r"OU=Sales + CN=J. Smith,DC=example,DC=net", "OU=Sales+CN=J. Smith"),
254+
(
255+
r"CN=James \"Jim\" Smith\, III,DC=example,DC=net",
256+
r"CN=James \"Jim\" Smith\, III",
257+
),
258+
(r"CN=Before\0dAfter,DC=example,DC=net", r"CN=Before\0DAfter"),
259+
(r"1.3.6.1.4.1.1466.0=#FE04024869", "1.3.6.1.4.1.1466.0=\udcfe\x04\x02Hi"),
260+
(r"1.3.6.1.4.1.1466.0 = #FE04024869", "1.3.6.1.4.1.1466.0=\udcfe\x04\x02Hi"),
261+
(r"CN=Lu\C4\8Di\C4\87", "CN=Lučić"),
262+
],
263+
)
264+
def test_split_dn_leaf(value: str, expected: str) -> None:
265+
actual = split_dn(value)
266+
assert actual == expected
267+
268+
269+
@pytest.mark.parametrize(
270+
"value, expected",
271+
[
272+
("", ""),
273+
("CN=foo", ""),
274+
(r"CN=foo,DC=bar", "DC=bar"),
275+
(r"CN=foo, DC=bar", "DC=bar"),
276+
(r"CN=foo , DC=bar", "DC=bar"),
277+
(r"CN=foo , DC=bar", "DC=bar"),
278+
(r"UID=jsmith,DC=example,DC=net", "DC=example,DC=net"),
279+
(r"OU=Sales+CN=J. Smith,DC=example,DC=net", "DC=example,DC=net"),
280+
(r"OU=Sales + CN=J. Smith,DC=example,DC=net", "DC=example,DC=net"),
281+
(
282+
r"CN=James \"Jim\" Smith\, III,DC=example,DC=net",
283+
r"DC=example,DC=net",
284+
),
285+
(r"CN=Before\0dAfter,DC=example,DC=net", r"DC=example,DC=net"),
286+
(r"1.3.6.1.4.1.1466.0=#FE04024869", ""),
287+
(r"1.3.6.1.4.1.1466.0 = #FE04024869", ""),
288+
(r"CN=Lu\C4\8Di\C4\87", ""),
289+
(
290+
r"CN=foo,DC=bar+C=US\, test+OU=Fake\+Test,DC=end",
291+
r"DC=bar+C=US\, test+OU=Fake\+Test,DC=end",
292+
),
293+
],
294+
)
295+
def test_split_dn_parent(value: str, expected: str) -> None:
296+
actual = split_dn(value, "parent")
297+
assert actual == expected

0 commit comments

Comments
 (0)