libsmb: Use clistr_smb2_extract_snapshot_token() in cli_smb2_create_fnum_send()
[Samba.git] / python / samba / tests / ldap_upn_sam_account.py
blobcc1cce9b6c3fd676a59594e95b9e2533255abb1b
1 # Unix SMB/CIFS implementation.
3 # Copyright 2021 (C) Catalyst IT Ltd
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 3 of the License, or
8 # (at your option) any later version.
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
15 # You should have received a copy of the GNU General Public License
16 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19 import os
20 import sys
21 from samba.samdb import SamDB
22 from samba.auth import system_session
23 import ldb
24 from samba.tests.subunitrun import SubunitOptions, TestProgram
25 from samba.tests import TestCase, ldb_err
26 from samba.tests import DynamicTestCase
27 import samba.getopt as options
28 import optparse
29 from samba.colour import c_RED, c_GREEN, c_DARK_YELLOW
30 import re
31 import pprint
32 from samba.dsdb import (
33 UF_SERVER_TRUST_ACCOUNT,
34 UF_TRUSTED_FOR_DELEGATION,
38 # bad sAMAccountName characters from [MS-SAMR]
39 # "3.1.1.6 Attribute Constraints for Originating Updates"
40 BAD_SAM_CHARS = (''.join(chr(x) for x in range(0, 32)) +
41 '"/\\[]:|<>+=;?,*')
43 # 0x7f is *said* to be bad, but turns out to be fine.
44 ALLEGED_BAD_SAM_CHARS = chr(127)
46 LATIN1_BAD_CHARS = set([chr(x) for x in range(129, 160)] +
47 list("ªºÿ") +
48 [chr(x) for x in range(0xc0, 0xc6)] +
49 [chr(x) for x in range(0xc7, 0xd7)] +
50 [chr(x) for x in range(0xd8, 0xde)] +
51 [chr(x) for x in range(0xe0, 0xe6)] +
52 [chr(x) for x in range(0xe7, 0xf7)] +
53 [chr(x) for x in range(0xf8, 0xfe)])
56 LATIN_EXTENDED_A_NO_CLASH = {306, 307, 330, 331, 338, 339, 358, 359, 383}
58 #XXX does '\x00' just truncate the string though?
59 #XXX elsewhere we see "[\\\"|,/:<>+=;?*']" with "'"
62 ## UPN limits
63 # max length 1024 UTF-8 bytes, following "rfc822"
64 # for o365 sync https://docs.microsoft.com/en-us/microsoft-365/enterprise/prepare-for-directory-synchronization?view=o365-worldwide
65 # max length is 113 [64 before @] "@" [48 after @]
66 # invalid chars: '\\%&*+/=?{}|<>();:,[]"'
67 # allowed chars: A – Z, a - z, 0 – 9, ' . - _ ! # ^ ~
68 # "Letters with diacritical marks, such as umlauts, accents, and tildes, are invalid characters."
70 # "@" can't be first
71 # "The username cannot end with a period (.), an ampersand (&), a space, or an at sign (@)."
74 # per RFC 822, «"a b" @ example.org» is
77 ok = True
78 bad = False
79 report = 'report'
80 exists = ldb.ERR_ENTRY_ALREADY_EXISTS
83 if sys.stdout.isatty():
84 c_doc = c_DARK_YELLOW
85 else:
86 c_doc = lambda x: x
89 def get_samdb():
90 return SamDB(url=f"ldap://{SERVER}",
91 lp=LP,
92 session_info=system_session(),
93 credentials=CREDS)
96 def format(s):
97 if type(s) is str:
98 s = s.format(realm=REALM.upper(),
99 lrealm=REALM.lower(),
100 other_realm=(REALM + ".another.example.net"))
101 return s
104 class LdapUpnSamTestBase(TestCase):
105 """Make sure we can't add userPrincipalNames or sAMAccountNames that
106 implicitly collide.
108 _disabled = False
110 @classmethod
111 def setUpDynamicTestCases(cls):
112 if getattr(cls, '_disabled', False):
113 return
114 for doc, *rows in cls.cases:
115 name = re.sub(r'\W+', '_', doc)
116 cls.generate_dynamic_test("test_upn_sam", name, rows, doc)
118 def setup_objects(self, rows):
119 objects = set(r[0] for r in rows)
120 for name in objects:
121 if ':' in name:
122 objtype, name = name.split(':', 1)
123 else:
124 objtype = 'user'
125 getattr(self, f'add_{objtype}')(name)
126 self.addCleanup(self.remove_object, name)
128 def _test_upn_sam_with_args(self, rows, doc):
129 self.setup_objects(rows)
130 cdoc = c_doc(doc)
132 for i, row in enumerate(rows):
133 if len(row) == 4:
134 obj, data, expected, op = row
135 else:
136 obj, data, expected = row
137 op = ldb.FLAG_MOD_REPLACE
139 dn, dnsname = self.objects[obj]
140 sam, upn = None, None
141 if isinstance(data, dict):
142 sam = data.get('sam')
143 upn = data.get('upn')
144 elif isinstance(data, str):
145 if '@' in data:
146 upn = data
147 else:
148 sam = data
149 else: # bytes
150 if b'@' in data:
151 upn = data
152 else:
153 sam = data
155 m = {"dn": dn}
157 if upn is not None:
158 m["userPrincipalName"] = format(upn)
160 if sam is not None:
161 m["sAMAccountName"] = format(sam)
163 msg = ldb.Message.from_dict(self.samdb, m, op)
165 if expected is bad:
166 try:
167 self.samdb.modify(msg)
168 except ldb.LdbError as e:
169 print(f"row {i+1} of '{cdoc}' failed as expected with "
170 f"{ldb_err(e)}\n")
171 continue
172 self.fail(f"row {i+1} of '{cdoc}' should have failed:\n"
173 f"{pprint.pformat(m)} on {obj}")
174 elif expected is ok:
175 try:
176 self.samdb.modify(msg)
177 except ldb.LdbError as e:
178 raise AssertionError(
179 f"row {i+1} of '{cdoc}' failed with {ldb_err(e)}:\n"
180 f"{pprint.pformat(m)} on {obj}") from None
181 elif expected is report:
182 try:
183 self.samdb.modify(msg)
184 print(f"row {i+1} of '{cdoc}' SUCCEEDED:\n"
185 f"{pprint.pformat(m)} on {obj}")
186 except ldb.LdbError as e:
187 print(f"row {i+1} of '{cdoc}' FAILED "
188 f"with {ldb_err(e)}:\n"
189 f"{pprint.pformat(m)} on {obj}")
191 else:
192 try:
193 self.samdb.modify(msg)
194 except ldb.LdbError as e:
195 if hasattr(expected, '__contains__'):
196 if e.args[0] in expected:
197 continue
199 if e.args[0] == expected:
200 continue
202 self.fail(f"row {i+1} of '{cdoc}' "
203 f"should have failed with {ldb_err(expected)} "
204 f"but instead failed with {ldb_err(e)}:\n"
205 f"{pprint.pformat(m)} on {obj}")
206 self.fail(f"row {i+1} of '{cdoc}' "
207 f"should have failed with {ldb_err(expected)}:\n"
208 f"{pprint.pformat(m)} on {obj}")
210 def add_dc(self, name):
211 dn = f"CN={name},OU=Domain Controllers,{self.base_dn}"
212 dnsname = f"{name}.{REALM}".lower()
213 self.samdb.add({
214 "dn": dn,
215 "objectclass": "computer",
216 "userAccountControl": str(UF_SERVER_TRUST_ACCOUNT |
217 UF_TRUSTED_FOR_DELEGATION),
218 "dnsHostName": dnsname,
219 "carLicense": self.id()
221 self.objects[name] = (dn, dnsname)
223 def add_user(self, name):
224 dn = f"CN={name},{self.ou}"
225 self.samdb.add({
226 "dn": dn,
227 "name": name,
228 "objectclass": "user",
229 "carLicense": self.id()
231 self.objects[name] = (dn, None)
233 def remove_object(self, name):
234 dn, dnsname = self.objects.pop(name)
235 self.samdb.delete(dn)
237 def setUp(self):
238 super().setUp()
239 self.samdb = get_samdb()
240 self.base_dn = self.samdb.get_default_basedn()
241 self.short_id = self.id().rsplit('.', 1)[1][:63]
242 self.objects = {}
243 self.ou = f"OU={ self.short_id },{ self.base_dn }"
244 self.addCleanup(self.samdb.delete, self.ou, ["tree_delete:1"])
245 self.samdb.add({"dn": self.ou, "objectclass": "organizationalUnit"})
248 @DynamicTestCase
249 class LdapUpnSamTest(LdapUpnSamTestBase):
250 cases = [
251 # The structure is
252 # ( «documentation/message that becomes test name»,
253 # («short object id», «upn or sam or mapping», «expected»),
254 # («short object id», «upn or sam or mapping», «expected»),
255 # ...,
258 # where the first item is a one line string explaining the
259 # test, and subsequent items describe database modifications,
260 # to be applied in series.
262 # First is a short ID, which maps to an object DN. Second is
263 # either a string or a dictionary.
265 # * If a string, if it contains '@', it is a UPN, otherwise a
266 # samaccountname.
268 # * If a dictionary, it is a mapping of some of ['sam', 'upn']
269 # to strings (in this way, you can add two attributes in one
270 # mesage, or attempt a samaccountname with '@').
272 # expected can be «ok», «bad» (mapped to True and False,
273 # respectively), or a specific LDB error code, if that exact
274 # exception is wanted.
275 ("add good UPN",
276 ('A', 'a@{realm}', ok),
278 ("add the same upn to different objects",
279 ('A', 'a@{realm}', ok),
280 ('B', 'a@{realm}', ldb.ERR_CONSTRAINT_VIOLATION),
281 ('B', 'a@{lrealm}', ldb.ERR_CONSTRAINT_VIOLATION), # lowercase realm
283 ("replace UPN with itself",
284 ('A', 'a@{realm}', ok),
285 ('A', 'a@{realm}', ok),
286 ('A', 'a@{lrealm}', ok),
288 ("replace SAM with itself",
289 ('A', 'a', ok),
290 ('A', 'a', ok),
292 ("replace UPN realm",
293 ('A', 'a@{realm}', ok),
294 ('A', 'a@{other_realm}', ok),
296 ("matching SAM and UPN",
297 ('A', 'a', ok),
298 ('A', 'a@{realm}', ok),
300 ("matching SAM and UPN, other realm",
301 ('A', 'a', ok),
302 ('A', 'a@{other_realm}', ok),
304 ("matching SAM and UPN, single message",
305 ('A', {'sam': 'a', 'upn': 'a@{realm}'}, ok),
306 ('A', {'sam': 'a', 'upn': 'a@{other_realm}'}, ok),
308 ("different objects, different realms",
309 ('A', 'a@{realm}', ok),
310 ('B', 'a@{other_realm}', ok),
312 ("different objects, same UPN, different case",
313 ('A', 'a@{realm}', ok),
314 ('B', 'A@{realm}', ldb.ERR_CONSTRAINT_VIOLATION),
316 ("different objects, SAM after UPN",
317 ('A', 'a@{realm}', ok),
318 ('B', 'a', ldb.ERR_CONSTRAINT_VIOLATION),
320 ("different objects, SAM before UPN",
321 ('A', 'a', ok),
322 ('B', 'a@{realm}', exists),
324 ("different objects, SAM account clash",
325 ('A', 'a', ok),
326 ('B', 'a', exists),
328 ("different objects, SAM account clash, different case",
329 ('A', 'a', ok),
330 ('B', 'A', exists),
332 ("two way clash",
333 ('A', {'sam': 'x', 'upn': 'y@{realm}'}, ok),
334 # The sam account raises EXISTS while the UPN raises
335 # CONSTRAINT_VIOLATION. We don't really care in which order
336 # they are checked, so either error is ok.
337 ('B', {'sam': 'y', 'upn': 'x@{realm}'},
338 (exists, ldb.ERR_CONSTRAINT_VIOLATION)),
340 ("two way clash, other realm",
341 ('A', {'sam': 'x', 'upn': 'y@{other_realm}'}, ok),
342 ('B', {'sam': 'y', 'upn': 'x@{other_realm}'}, ok),
344 # UPN versions of bad sam account names
345 ("UPN clash on other realm",
346 ('A', 'a@x.x', ok),
347 ('B', 'a@x.x', ldb.ERR_CONSTRAINT_VIOLATION),
349 ("UPN same but for trailing spaces",
350 ('A', 'a@{realm}', ok),
351 ('B', 'a @{realm}', ok),
353 # UPN has no at
354 ("UPN has no at",
355 ('A', {'upn': 'noat'}, ok),
356 ('B', {'upn': 'noat'}, ldb.ERR_CONSTRAINT_VIOLATION),
357 ('C', {'upn': 'NOAT'}, ldb.ERR_CONSTRAINT_VIOLATION),
359 # UPN has non-ascii at, followed by real at.
360 ("UPN with non-ascii at vs real at",
361 ('A', {'upn': 'smallat﹫{realm}'}, ok),
362 ('B', {'upn': 'smallat@{realm}'}, ok),
363 ('C', {'upn': 'tagat\U000e0040{realm}'}, ok),
364 ('D', {'upn': 'tagat@{realm}'}, ok),
366 ("UPN with unicode at vs real at, real at first",
367 ('B', {'upn': 'smallat@{realm}'}, ok),
368 ('A', {'upn': 'smallat﹫{realm}'}, ok),
369 ('D', {'upn': 'tagat@{realm}'}, ok),
370 ('C', {'upn': 'tagat\U000e0040{realm}'}, ok),
372 ("UPN username too long",
373 # SPN soft limit 20; hard limit 256, overall UPN 1024
374 ('A', 'a' * 25 + '@b.c', ok),
375 ('A', 'a' * 65 + '@b.c', ok), # Azure AD limit is 64
376 ('A', 'a' * 257 + '@b.c', ok), # 256 is sam account name limit
378 ("sam account name 20 long",
379 # SPN soft limit 20
380 ('A', 'a' * 20, ok),
382 ("UPN has two at signs",
383 ('A', 'a@{realm}', ok),
384 ('A', 'a@{realm}@{realm}', ok),
385 ('A', 'a@a.b', ok),
386 ('A', 'a@a@a.b', ok),
388 ("SAM has at signs clashing upn second, non-realm",
389 ('A', {'sam': 'a@a.b'}, ok),
390 ('B', 'a@a.b@a.b', ok), # UPN won't clash with SAM, because realm
392 ("SAM has at signs clashing upn second",
393 ('A', {'sam': 'a@{realm}'}, ok),
394 ('B', 'a@{realm}@{realm}', bad), # UPN would clashes with SAM
396 ("SAM has at signs clashing upn first",
397 ('B', 'a@{realm}@{realm}', ok),
398 ('A', {'sam': 'a@{realm}'}, bad),
400 ("spaces around at",
401 ('A', 'a name @ {realm}', ok),
402 ('B', 'a name @ {realm}', ldb.ERR_CONSTRAINT_VIOLATION),
403 ('B', 'a name @{realm}', ok), # because realm looks different
404 ('C', 'a name@{realm}', ok),
405 ('D', 'a name', ldb.ERR_CONSTRAINT_VIOLATION),
406 ('D', 'a name ', (exists, ldb.ERR_CONSTRAINT_VIOLATION)), # matches B
408 ("SAM starts with at",
409 ('A', {'sam': '@{realm}'}, ok),
410 ('B', {'sam': '@a'}, ok),
411 ('C', {'sam': '@{realm}'}, exists),
412 ('C', {'sam': '@a'}, exists),
413 ('C', {'upn': '@{realm}@{realm}'}, bad),
414 ('C', {'upn': '@a@{realm}'}, bad),
416 ("UPN starts with at",
417 ('A', {'upn': '@{realm}'}, ok),
418 ('B', {'upn': '@a@{realm}'}, ok),
419 ('C', {'upn': '@{realm}'}, bad),
420 ('C', {'sam': '@a'}, bad),
422 ("SAM ends with at",
423 ('A', {'sam': '{realm}@'}, ok),
424 ('B', {'sam': 'a@'}, ok),
425 ('C', {'sam': '{realm}@'}, exists),
426 ('C', {'sam': 'a@'}, exists),
427 ('C', {'upn': 'a@@{realm}'}, bad),
428 ('C', {'upn': '{realm}@@{realm}'}, bad),
430 ("UPN ends with at",
431 ('A', {'upn': '{realm}@'}, ok),
432 ('B', {'upn': '@a@{realm}@'}, ok),
433 ('C', {'upn': '{realm}@'}, bad),
434 ('C', {'sam': '@a@{realm}'}, ok), # not like B, because other realm
439 @DynamicTestCase
440 class LdapUpnSamSambaOnlyTest(LdapUpnSamTestBase):
441 # We don't run these ones outside of selftest, where we are
442 # probably testing against Windows and these are known failures.
443 _disabled = 'SAMBA_SELFTEST' not in os.environ
444 cases = [
445 ("sam account name too long",
446 # SPN soft limit 20
447 ('A', 'a' * 19, ok),
448 ('A', 'a' * 20, ok),
449 ('A', 'a' * 65, ok),
450 ('A', 'a' * 255, ok),
451 ('A', 'a' * 256, ok),
452 ('A', 'a' * 257, ldb.ERR_INVALID_ATTRIBUTE_SYNTAX),
454 ("UPN username too long",
455 ('A', 'a' * 254 + '@' + 'b.c' * 257,
456 ldb.ERR_INVALID_ATTRIBUTE_SYNTAX), # 1024 is alleged UPN limit
458 ("UPN same but for internal spaces",
459 ('A', 'a b@x.x', ok),
460 ('B', 'a b@x.x', ldb.ERR_CONSTRAINT_VIOLATION),
462 ("SAM contains delete",
463 # forbidden according to documentation, but works in practice on Windows
464 ('A', 'a\x7f', ldb.ERR_CONSTRAINT_VIOLATION),
465 ('A', 'a\x7f'.encode(), ldb.ERR_CONSTRAINT_VIOLATION),
466 ('A', 'a\x7fb', ldb.ERR_CONSTRAINT_VIOLATION),
467 ('A', 'a\x7fb'.encode(), ldb.ERR_CONSTRAINT_VIOLATION),
468 ('A', '\x7fb', ldb.ERR_CONSTRAINT_VIOLATION),
469 ('A', '\x7fb'.encode(), ldb.ERR_CONSTRAINT_VIOLATION),
471 # The wide at symbol ('@' U+FF20) does not count as '@' for Samba
472 # so it will look like a string with no @s.
473 ("UPN with unicode wide at vs real at",
474 ('A', {'upn': 'wideat@{realm}'}, ok),
475 ('B', {'upn': 'wideat@{realm}'}, ok),
477 ("UPN with real at vs wide at",
478 ('B', {'upn': 'wideat@{realm}'}, ok),
479 ('A', {'upn': 'wideat@{realm}'}, ok)
484 def main():
485 global LP, CREDS, SERVER, REALM
487 parser = optparse.OptionParser(
488 "python3 ldap_upn_sam_account_name.py <server> [options]")
489 sambaopts = options.SambaOptions(parser)
490 parser.add_option_group(sambaopts)
492 # use command line creds if available
493 credopts = options.CredentialsOptions(parser)
494 parser.add_option_group(credopts)
495 subunitopts = SubunitOptions(parser)
496 parser.add_option_group(subunitopts)
498 opts, args = parser.parse_args()
499 if len(args) != 1:
500 parser.print_usage()
501 sys.exit(1)
503 LP = sambaopts.get_loadparm()
504 CREDS = credopts.get_credentials(LP)
505 SERVER = args[0]
506 REALM = CREDS.get_realm()
508 TestProgram(module=__name__, opts=subunitopts)
510 main()