Gentoo Archives: gentoo-commits

From: "Matt Thode (prometheanfire)" <prometheanfire@g.o>
To: gentoo-commits@l.g.o
Subject: [gentoo-commits] gentoo-x86 commit in sys-auth/keystone/files: 2014.1-CVE-2014-0204.patch
Date: Sun, 01 Jun 2014 02:19:21
Message-Id: 20140601021916.475712004F@flycatcher.gentoo.org
1 prometheanfire 14/06/01 02:19:16
2
3 Added: 2014.1-CVE-2014-0204.patch
4 Log:
5 fix for CVE-2014-0204 bug 511000
6
7 (Portage version: 2.2.8-r1/cvs/Linux x86_64, signed Manifest commit with key 0x2471eb3e40ac5ac3)
8
9 Revision Changes Path
10 1.1 sys-auth/keystone/files/2014.1-CVE-2014-0204.patch
11
12 file : http://sources.gentoo.org/viewvc.cgi/gentoo-x86/sys-auth/keystone/files/2014.1-CVE-2014-0204.patch?rev=1.1&view=markup
13 plain: http://sources.gentoo.org/viewvc.cgi/gentoo-x86/sys-auth/keystone/files/2014.1-CVE-2014-0204.patch?rev=1.1&content-type=text/plain
14
15 Index: 2014.1-CVE-2014-0204.patch
16 ===================================================================
17 From 786af9829c5329a982e3451f77afebbfb21850bd Mon Sep 17 00:00:00 2001
18 From: Brant Knudson <bknudson@××××××.com>
19 Date: Fri, 18 Apr 2014 11:18:42 -0500
20 Subject: [PATCH] SQL and LDAP fixes for get_roles_for_user_and_project
21 user=group ID
22
23 When there was a role assigned to a group with the same ID as a user,
24 the SQL and LDAP assignment backends would incorrectly return the
25 assignment to the group when requesting roles for the user via the
26 get_roles_for_user_and_project method.
27
28 With this change, assignments to a group with the same ID are not
29 returned for the user when calling get_roles_for_user_and_project.
30
31 Functions were added to compare DNs more accurately based on the
32 LDAP RFCs.
33
34 The fakeldap code was changed to normalize the values when
35 comparing values for checking if the values match the filter.
36
37 Co-Authored By: Nathan Kinder <nkinder@××××××.com>
38 Co-Authored By: Adam Young <ayoung@××××××.com>
39
40 Change-Id: Id3d6f66c995e65e37d909359420d71ecdde86b69
41 Closes-Bug: #1309228
42 ---
43 keystone/assignment/backends/ldap.py | 11 +--
44 keystone/assignment/backends/sql.py | 15 +++
45 keystone/common/ldap/core.py | 110 +++++++++++++++++++++
46 keystone/tests/fakeldap.py | 17 +++-
47 keystone/tests/test_backend.py | 37 +++++++
48 keystone/tests/test_backend_ldap.py | 28 ++++++
49 keystone/tests/unit/common/test_ldap.py | 169 ++++++++++++++++++++++++++++++++
50 7 files changed, 378 insertions(+), 9 deletions(-)
51 create mode 100644 keystone/tests/unit/common/test_ldap.py
52
53 diff --git a/keystone/assignment/backends/ldap.py b/keystone/assignment/backends/ldap.py
54 index 2afd339..09b0f01 100644
55 --- a/keystone/assignment/backends/ldap.py
56 +++ b/keystone/assignment/backends/ldap.py
57 @@ -88,24 +88,19 @@ def _get_metadata(self, user_id=None, tenant_id=None,
58
59 def _get_roles_for_just_user_and_project(user_id, tenant_id):
60 self.get_project(tenant_id)
61 + user_dn = self.user._id_to_dn(user_id)
62 return [self.role._dn_to_id(a.role_dn)
63 for a in self.role.get_role_assignments
64 (self.project._id_to_dn(tenant_id))
65 - if self.user._dn_to_id(a.user_dn) == user_id]
66 + if common_ldap.is_dn_equal(a.user_dn, user_dn)]
67
68 def _get_roles_for_group_and_project(group_id, project_id):
69 self.get_project(project_id)
70 group_dn = self.group._id_to_dn(group_id)
71 - # NOTE(marcos-fermin-lobo): In Active Directory, for functions
72 - # such as "self.role.get_role_assignments", it returns
73 - # the key "CN" or "OU" in uppercase.
74 - # The group_dn var has "CN" and "OU" in lowercase.
75 - # For this reason, it is necessary to use the "upper()"
76 - # function so both are consistent.
77 return [self.role._dn_to_id(a.role_dn)
78 for a in self.role.get_role_assignments
79 (self.project._id_to_dn(project_id))
80 - if a.user_dn.upper() == group_dn.upper()]
81 + if common_ldap.is_dn_equal(a.user_dn, group_dn)]
82
83 if domain_id is not None:
84 msg = _('Domain metadata not supported by LDAP')
85 diff --git a/keystone/assignment/backends/sql.py b/keystone/assignment/backends/sql.py
86 index 1d8c78f..b546a42 100644
87 --- a/keystone/assignment/backends/sql.py
88 +++ b/keystone/assignment/backends/sql.py
89 @@ -86,6 +86,21 @@ def _get_metadata(self, user_id=None, tenant_id=None,
90 session = sql.get_session()
91
92 q = session.query(RoleAssignment)
93 +
94 + def _calc_assignment_type():
95 + # Figure out the assignment type we're checking for from the args.
96 + if user_id:
97 + if tenant_id:
98 + return AssignmentType.USER_PROJECT
99 + else:
100 + return AssignmentType.USER_DOMAIN
101 + else:
102 + if tenant_id:
103 + return AssignmentType.GROUP_PROJECT
104 + else:
105 + return AssignmentType.GROUP_DOMAIN
106 +
107 + q = q.filter_by(type=_calc_assignment_type())
108 q = q.filter_by(actor_id=user_id or group_id)
109 q = q.filter_by(target_id=tenant_id or domain_id)
110 refs = q.all()
111 diff --git a/keystone/common/ldap/core.py b/keystone/common/ldap/core.py
112 index e8d1dc0..9561650 100644
113 --- a/keystone/common/ldap/core.py
114 +++ b/keystone/common/ldap/core.py
115 @@ -13,6 +13,7 @@
116 # under the License.
117
118 import os.path
119 +import re
120
121 import ldap
122 import ldap.filter
123 @@ -101,6 +102,115 @@ def ldap_scope(scope):
124 'options': ', '.join(LDAP_SCOPES.keys())})
125
126
127 +def prep_case_insensitive(value):
128 + """Prepare a string for case-insensitive comparison.
129 +
130 + This is defined in RFC4518. For simplicity, all this function does is
131 + lowercase all the characters, strip leading and trailing whitespace,
132 + and compress sequences of spaces to a single space.
133 + """
134 + value = re.sub(r'\s+', ' ', value.strip().lower())
135 + return value
136 +
137 +
138 +def is_ava_value_equal(attribute_type, val1, val2):
139 + """Returns True if and only if the AVAs are equal.
140 +
141 + When comparing AVAs, the equality matching rule for the attribute type
142 + should be taken into consideration. For simplicity, this implementation
143 + does a case-insensitive comparison.
144 +
145 + Note that this function uses prep_case_insenstive so the limitations of
146 + that function apply here.
147 +
148 + """
149 +
150 + return prep_case_insensitive(val1) == prep_case_insensitive(val2)
151 +
152 +
153 +def is_rdn_equal(rdn1, rdn2):
154 + """Returns True if and only if the RDNs are equal.
155 +
156 + * RDNs must have the same number of AVAs.
157 + * Each AVA of the RDNs must be the equal for the same attribute type. The
158 + order isn't significant. Note that an attribute type will only be in one
159 + AVA in an RDN, otherwise the DN wouldn't be valid.
160 + * Attribute types aren't case sensitive. Note that attribute type
161 + comparison is more complicated than implemented. This function only
162 + compares case-insentive. The code should handle multiple names for an
163 + attribute type (e.g., cn, commonName, and 2.5.4.3 are the same).
164 +
165 + Note that this function uses is_ava_value_equal to compare AVAs so the
166 + limitations of that function apply here.
167 +
168 + """
169 +
170 + if len(rdn1) != len(rdn2):
171 + return False
172 +
173 + for attr_type_1, val1, dummy in rdn1:
174 + found = False
175 + for attr_type_2, val2, dummy in rdn2:
176 + if attr_type_1.lower() != attr_type_2.lower():
177 + continue
178 +
179 + found = True
180 + if not is_ava_value_equal(attr_type_1, val1, val2):
181 + return False
182 + break
183 + if not found:
184 + return False
185 +
186 + return True
187 +
188 +
189 +def is_dn_equal(dn1, dn2):
190 + """Returns True if and only if the DNs are equal.
191 +
192 + Two DNs are equal if they've got the same number of RDNs and if the RDNs
193 + are the same at each position. See RFC4517.
194 +
195 + Note that this function uses is_rdn_equal to compare RDNs so the
196 + limitations of that function apply here.
197 +
198 + :param dn1: Either a string DN or a DN parsed by ldap.dn.str2dn.
199 + :param dn2: Either a string DN or a DN parsed by ldap.dn.str2dn.
200 +
201 + """
202 +
203 + if not isinstance(dn1, list):
204 + dn1 = ldap.dn.str2dn(dn1)
205 + if not isinstance(dn2, list):
206 + dn2 = ldap.dn.str2dn(dn2)
207 +
208 + if len(dn1) != len(dn2):
209 + return False
210 +
211 + for rdn1, rdn2 in zip(dn1, dn2):
212 + if not is_rdn_equal(rdn1, rdn2):
213 + return False
214 + return True
215 +
216 +
217 +def dn_startswith(descendant_dn, dn):
218 + """Returns True if and only if the descendant_dn is under the dn.
219 +
220 + :param descendant_dn: Either a string DN or a DN parsed by ldap.dn.str2dn.
221 + :param dn: Either a string DN or a DN parsed by ldap.dn.str2dn.
222 +
223 + """
224 +
225 + if not isinstance(descendant_dn, list):
226 + descendant_dn = ldap.dn.str2dn(descendant_dn)
227 + if not isinstance(dn, list):
228 + dn = ldap.dn.str2dn(dn)
229 +
230 + if len(descendant_dn) <= len(dn):
231 + return False
232 +
233 + return is_dn_equal(descendant_dn[len(dn):], dn)
234 +
235 +
236 _HANDLERS = {}
237
238
239 diff --git a/keystone/tests/fakeldap.py b/keystone/tests/fakeldap.py
240 index 8347d68..21e1bd3 100644
241 --- a/keystone/tests/fakeldap.py
242 +++ b/keystone/tests/fakeldap.py
243 @@ -51,6 +51,19 @@ def _process_attr(attr_name, value_or_values):
244
245 def normalize_dn(dn):
246 # Capitalize the attribute names as an LDAP server might.
247 +
248 + # NOTE(blk-u): Special case for this tested value, used with
249 + # test_user_id_comma. The call to str2dn here isn't always correct
250 + # here, because `dn` is escaped for an LDAP filter. str2dn() normally
251 + # works only because there's no special characters in `dn`.
252 + if dn == 'cn=Doe\\5c, John,ou=Users,cn=example,cn=com':
253 + return 'CN=Doe\\, John,OU=Users,CN=example,CN=com'
254 +
255 + # NOTE(blk-u): Another special case for this tested value. When a
256 + # roleOccupant has an escaped comma, it gets converted to \2C.
257 + if dn == 'cn=Doe\\, John,ou=Users,cn=example,cn=com':
258 + return 'CN=Doe\\2C John,OU=Users,CN=example,CN=com'
259 +
260 dn = ldap.dn.str2dn(dn)
261 norm = []
262 for part in dn:
263 @@ -118,7 +131,9 @@ def _match(key, value, attrs):
264 str_sids = [str(x) for x in attrs[key]]
265 return str(value) in str_sids
266 if key != 'objectclass':
267 - return _process_attr(key, value)[0] in attrs[key]
268 + check_value = _process_attr(key, value)[0]
269 + norm_values = list(_process_attr(key, x)[0] for x in attrs[key])
270 + return check_value in norm_values
271 # it is an objectclass check, so check subclasses
272 values = _subs(value)
273 for v in values:
274 diff --git a/keystone/tests/test_backend.py b/keystone/tests/test_backend.py
275 index c8d7341..b42b209 100644
276 --- a/keystone/tests/test_backend.py
277 +++ b/keystone/tests/test_backend.py
278 @@ -1377,6 +1377,43 @@ def test_multi_group_grants_on_project_domain(self):
279 self.assertIn(role_list[1]['id'], combined_role_list)
280 self.assertIn(role_list[2]['id'], combined_role_list)
281
282 + def test_get_roles_for_user_and_project_user_group_same_id(self):
283 + """When a user has the same ID as a group,
284 + get_roles_for_user_and_project returns only the roles for the user and
285 + not the group.
286 +
287 + """
288 +
289 + # Setup: create user, group with same ID, role, and project;
290 + # assign the group the role on the project.
291 +
292 + user_group_id = uuid.uuid4().hex
293 +
294 + user1 = {'id': user_group_id, 'name': uuid.uuid4().hex,
295 + 'domain_id': DEFAULT_DOMAIN_ID, }
296 + self.identity_api.create_user(user_group_id, user1)
297 +
298 + group1 = {'id': user_group_id, 'name': uuid.uuid4().hex,
299 + 'domain_id': DEFAULT_DOMAIN_ID, }
300 + self.identity_api.create_group(user_group_id, group1)
301 +
302 + role1 = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex}
303 + self.assignment_api.create_role(role1['id'], role1)
304 +
305 + project1 = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex,
306 + 'domain_id': DEFAULT_DOMAIN_ID, }
307 + self.assignment_api.create_project(project1['id'], project1)
308 +
309 + self.assignment_api.create_grant(role1['id'],
310 + group_id=user_group_id,
311 + project_id=project1['id'])
312 +
313 + # Check the roles, shouldn't be any since the user wasn't granted any.
314 + roles = self.assignment_api.get_roles_for_user_and_project(
315 + user_group_id, project1['id'])
316 +
317 + self.assertEqual([], roles, 'role for group is %s' % role1['id'])
318 +
319 def test_delete_role_with_user_and_group_grants(self):
320 role1 = {'id': uuid.uuid4().hex, 'name': uuid.uuid4().hex}
321 self.assignment_api.create_role(role1['id'], role1)
322 diff --git a/keystone/tests/test_backend_ldap.py b/keystone/tests/test_backend_ldap.py
323 index 310fbbc..9964527 100644
324 --- a/keystone/tests/test_backend_ldap.py
325 +++ b/keystone/tests/test_backend_ldap.py
326 @@ -546,6 +546,34 @@ def test_new_arbitrary_attributes_are_returned_from_update_user(self):
327 def test_updated_arbitrary_attributes_are_returned_from_update_user(self):
328 self.skipTest("Using arbitrary attributes doesn't work under LDAP")
329
330 + def test_user_id_comma_grants(self):
331 + """Even if the user has a , in their ID, can get user and group grants.
332 + """
333 +
334 + # Create a user with a , in their ID
335 + # NOTE(blk-u): the DN for this user is hard-coded in fakeldap!
336 + user_id = u'Doe, John'
337 + user = {
338 + 'id': user_id,
339 + 'name': self.getUniqueString(),
340 + 'password': self.getUniqueString(),
341 + 'domain_id': CONF.identity.default_domain_id,
342 + }
343 + self.identity_api.create_user(user_id, user)
344 +
345 + # Grant the user a role on a project.
346 +
347 + role_id = 'member'
348 + project_id = self.tenant_baz['id']
349 +
350 + self.assignment_api.create_grant(role_id, user_id=user_id,
351 + project_id=project_id)
352 +
353 + role_ref = self.assignment_api.get_grant(role_id, user_id=user_id,
354 + project_id=project_id)
355 +
356 + self.assertEqual(role_id, role_ref['id'])
357 +
358
359 class LDAPIdentity(BaseLDAPIdentity, tests.TestCase):
360 def setUp(self):
361 diff --git a/keystone/tests/unit/common/test_ldap.py b/keystone/tests/unit/common/test_ldap.py
362 new file mode 100644
363 index 0000000..220bf1a
364 --- /dev/null
365 +++ b/keystone/tests/unit/common/test_ldap.py
366 @@ -0,0 +1,169 @@
367 +# Licensed under the Apache License, Version 2.0 (the "License"); you may
368 +# not use this file except in compliance with the License. You may obtain
369 +# a copy of the License at
370 +#
371 +# http://www.apache.org/licenses/LICENSE-2.0
372 +#
373 +# Unless required by applicable law or agreed to in writing, software
374 +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
375 +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
376 +# License for the specific language governing permissions and limitations
377 +# under the License.
378 +
379 +import ldap.dn
380 +
381 +from keystone.common import ldap as ks_ldap
382 +from keystone import tests
383 +
384 +
385 +class DnCompareTest(tests.BaseTestCase):
386 + """Tests for the DN comparison functions in keystone.common.ldap.core."""
387 +
388 + def test_prep(self):
389 + # prep_case_insensitive returns the string with spaces at the front and
390 + # end if it's already lowercase and no insignificant characters.
391 + value = 'lowercase value'
392 + self.assertEqual(value, ks_ldap.prep_case_insensitive(value))
393 +
394 + def test_prep_lowercase(self):
395 + # prep_case_insensitive returns the string with spaces at the front and
396 + # end and lowercases the value.
397 + value = 'UPPERCASE VALUE'
398 + exp_value = value.lower()
399 + self.assertEqual(exp_value, ks_ldap.prep_case_insensitive(value))
400 +
401 + def test_prep_insignificant(self):
402 + # prep_case_insensitive remove insignificant spaces.
403 + value = 'before after'
404 + exp_value = 'before after'
405 + self.assertEqual(exp_value, ks_ldap.prep_case_insensitive(value))
406 +
407 + def test_prep_insignificant_pre_post(self):
408 + # prep_case_insensitive remove insignificant spaces.
409 + value = ' value '
410 + exp_value = 'value'
411 + self.assertEqual(exp_value, ks_ldap.prep_case_insensitive(value))
412 +
413 + def test_ava_equal_same(self):
414 + # is_ava_value_equal returns True if the two values are the same.
415 + value = 'val1'
416 + self.assertTrue(ks_ldap.is_ava_value_equal('cn', value, value))
417 +
418 + def test_ava_equal_complex(self):
419 + # is_ava_value_equal returns True if the two values are the same using
420 + # a value that's got different capitalization and insignificant chars.
421 + val1 = 'before after'
422 + val2 = ' BEFORE afTer '
423 + self.assertTrue(ks_ldap.is_ava_value_equal('cn', val1, val2))
424 +
425 + def test_ava_different(self):
426 + # is_ava_value_equal returns False if the values aren't the same.
427 + self.assertFalse(ks_ldap.is_ava_value_equal('cn', 'val1', 'val2'))
428 +
429 + def test_rdn_same(self):
430 + # is_rdn_equal returns True if the two values are the same.
431 + rdn = ldap.dn.str2dn('cn=val1')[0]
432 + self.assertTrue(ks_ldap.is_rdn_equal(rdn, rdn))
433 +
434 + def test_rdn_diff_length(self):
435 + # is_rdn_equal returns False if the RDNs have a different number of
436 + # AVAs.
437 + rdn1 = ldap.dn.str2dn('cn=cn1')[0]
438 + rdn2 = ldap.dn.str2dn('cn=cn1+ou=ou1')[0]
439 + self.assertFalse(ks_ldap.is_rdn_equal(rdn1, rdn2))
440 +
441 + def test_rdn_multi_ava_same_order(self):
442 + # is_rdn_equal returns True if the RDNs have the same number of AVAs
443 + # and the values are the same.
444 + rdn1 = ldap.dn.str2dn('cn=cn1+ou=ou1')[0]
445 + rdn2 = ldap.dn.str2dn('cn=CN1+ou=OU1')[0]
446 + self.assertTrue(ks_ldap.is_rdn_equal(rdn1, rdn2))
447 +
448 + def test_rdn_multi_ava_diff_order(self):
449 + # is_rdn_equal returns True if the RDNs have the same number of AVAs
450 + # and the values are the same, even if in a different order
451 + rdn1 = ldap.dn.str2dn('cn=cn1+ou=ou1')[0]
452 + rdn2 = ldap.dn.str2dn('ou=OU1+cn=CN1')[0]
453 + self.assertTrue(ks_ldap.is_rdn_equal(rdn1, rdn2))
454 +
455 + def test_rdn_multi_ava_diff_type(self):
456 + # is_rdn_equal returns False if the RDNs have the same number of AVAs
457 + # and the attribute types are different.
458 + rdn1 = ldap.dn.str2dn('cn=cn1+ou=ou1')[0]
459 + rdn2 = ldap.dn.str2dn('cn=cn1+sn=sn1')[0]
460 + self.assertFalse(ks_ldap.is_rdn_equal(rdn1, rdn2))
461 +
462 + def test_rdn_attr_type_case_diff(self):
463 + # is_rdn_equal returns True for same RDNs even when attr type case is
464 + # different.
465 + rdn1 = ldap.dn.str2dn('cn=cn1')[0]
466 + rdn2 = ldap.dn.str2dn('CN=cn1')[0]
467 + self.assertTrue(ks_ldap.is_rdn_equal(rdn1, rdn2))
468 +
469 + def test_rdn_attr_type_alias(self):
470 + # is_rdn_equal returns False for same RDNs even when attr type alias is
471 + # used. Note that this is a limitation since an LDAP server should
472 + # consider them equal.
473 + rdn1 = ldap.dn.str2dn('cn=cn1')[0]
474 + rdn2 = ldap.dn.str2dn('2.5.4.3=cn1')[0]
475 + self.assertFalse(ks_ldap.is_rdn_equal(rdn1, rdn2))
476 +
477 + def test_dn_same(self):
478 + # is_dn_equal returns True if the DNs are the same.
479 + dn = 'cn=Babs Jansen,ou=OpenStack'
480 + self.assertTrue(ks_ldap.is_dn_equal(dn, dn))
481 +
482 + def test_dn_diff_length(self):
483 + # is_dn_equal returns False if the DNs don't have the same number of
484 + # RDNs
485 + dn1 = 'cn=Babs Jansen,ou=OpenStack'
486 + dn2 = 'cn=Babs Jansen,ou=OpenStack,dc=example.com'
487 + self.assertFalse(ks_ldap.is_dn_equal(dn1, dn2))
488 +
489 + def test_dn_equal_rdns(self):
490 + # is_dn_equal returns True if the DNs have the same number of RDNs
491 + # and each RDN is the same.
492 + dn1 = 'cn=Babs Jansen,ou=OpenStack+cn=OpenSource'
493 + dn2 = 'CN=Babs Jansen,cn=OpenSource+ou=OpenStack'
494 + self.assertTrue(ks_ldap.is_dn_equal(dn1, dn2))
495 +
496 + def test_dn_parsed_dns(self):
497 + # is_dn_equal can also accept parsed DNs.
498 + dn_str1 = ldap.dn.str2dn('cn=Babs Jansen,ou=OpenStack+cn=OpenSource')
499 + dn_str2 = ldap.dn.str2dn('CN=Babs Jansen,cn=OpenSource+ou=OpenStack')
500 + self.assertTrue(ks_ldap.is_dn_equal(dn_str1, dn_str2))
501 +
502 + def test_startswith_under_child(self):
503 + # dn_startswith returns True if descendant_dn is a child of dn.
504 + child = 'cn=Babs Jansen,ou=OpenStack'
505 + parent = 'ou=OpenStack'
506 + self.assertTrue(ks_ldap.dn_startswith(child, parent))
507 +
508 + def test_startswith_parent(self):
509 + # dn_startswith returns False if descendant_dn is a parent of dn.
510 + child = 'cn=Babs Jansen,ou=OpenStack'
511 + parent = 'ou=OpenStack'
512 + self.assertFalse(ks_ldap.dn_startswith(parent, child))
513 +
514 + def test_startswith_same(self):
515 + # dn_startswith returns False if DNs are the same.
516 + dn = 'cn=Babs Jansen,ou=OpenStack'
517 + self.assertFalse(ks_ldap.dn_startswith(dn, dn))
518 +
519 + def test_startswith_not_parent(self):
520 + # dn_startswith returns False if descendant_dn is not under the dn
521 + child = 'cn=Babs Jansen,ou=OpenStack'
522 + parent = 'dc=example.com'
523 + self.assertFalse(ks_ldap.dn_startswith(child, parent))
524 +
525 + def test_startswith_descendant(self):
526 + # dn_startswith returns True if descendant_dn is a descendant of dn.
527 + descendant = 'cn=Babs Jansen,ou=Keystone,ou=OpenStack,dc=example.com'
528 + dn = 'ou=OpenStack,dc=example.com'
529 + self.assertTrue(ks_ldap.dn_startswith(descendant, dn))
530 +
531 + def test_startswith_parsed_dns(self):
532 + # dn_startswith also accepts parsed DNs.
533 + descendant = ldap.dn.str2dn('cn=Babs Jansen,ou=OpenStack')
534 + dn = ldap.dn.str2dn('ou=OpenStack')
535 + self.assertTrue(ks_ldap.dn_startswith(descendant, dn))
536 --
537 1.9.3