diff options
Diffstat (limited to 'python/samba/tests/samba_tool')
51 files changed, 16893 insertions, 0 deletions
diff --git a/python/samba/tests/samba_tool/__init__.py b/python/samba/tests/samba_tool/__init__.py new file mode 100644 index 0000000..3d7f059 --- /dev/null +++ b/python/samba/tests/samba_tool/__init__.py @@ -0,0 +1,15 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) Sean Dague <sdague@linux.vnet.ibm.com +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. diff --git a/python/samba/tests/samba_tool/base.py b/python/samba/tests/samba_tool/base.py new file mode 100644 index 0000000..a4f4578 --- /dev/null +++ b/python/samba/tests/samba_tool/base.py @@ -0,0 +1,137 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) Sean Dague <sdague@linux.vnet.ibm.com> 2011 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# This provides a wrapper around the cmd interface so that tests can +# easily be built on top of it and have minimal code to run basic tests +# of the commands. A list of the environmental variables can be found in +# ~/selftest/selftest.pl +# +# These can all be accessed via os.environ["VARIABLENAME"] when needed + +import os +import random +import string +from io import StringIO + +import samba.getopt as options +import samba.tests +from samba.auth import system_session +from samba.getopt import OptionParser +from samba.netcmd.main import cmd_sambatool +from samba.samdb import SamDB + + +def truncate_string(s, cutoff=100): + if len(s) < cutoff + 15: + return s + return s[:cutoff] + '[%d more characters]' % (len(s) - cutoff) + + +class SambaToolCmdTest(samba.tests.BlackboxTestCase): + # Use a class level reference to StringIO, which subclasses can + # override if they need to (to e.g. add a lying isatty() method). + stringIO = StringIO + + @staticmethod + def getSamDB(*argv): + """a convenience function to get a samdb instance so that we can query it""" + + parser = OptionParser() + sambaopts = options.SambaOptions(parser) + credopts = options.CredentialsOptions(parser) + hostopts = options.HostOptions(parser) + parser.parse_args(list(argv)) + + lp = sambaopts.get_loadparm() + creds = credopts.get_credentials(lp, fallback_machine=True) + + return SamDB(url=hostopts.H, session_info=system_session(), + credentials=creds, lp=lp) + + @classmethod + def _run(cls, *argv): + """run a samba-tool command""" + cmd, args = cmd_sambatool()._resolve('samba-tool', *argv, + outf=cls.stringIO(), + errf=cls.stringIO()) + result = cmd._run(*args) + return (result, cmd.outf.getvalue(), cmd.errf.getvalue()) + + runcmd = _run + runsubcmd = _run + + def runsublevelcmd(self, name, sublevels, *args): + """run a command with any number of sub command levels""" + # This is a weird and clunky interface for running a + # subcommand. Use self.runcmd() instead. + return self._run(name, *sublevels, *args) + + def assertCmdSuccess(self, exit, out, err, msg=""): + # Make sure we allow '\n]\n' in stdout and stderr + # without causing problems with the subunit protocol. + # We just inject a space... + msg = "exit[%s] stdout[%s] stderr[%s]: %s" % (exit, out, err, msg) + self.assertIsNone(exit, msg=msg.replace("\n]\n", "\n] \n")) + + def assertCmdFail(self, val, msg=""): + self.assertIsNotNone(val, msg) + + def assertMatch(self, base, string, msg=None): + # Note: we should stop doing this and just use self.assertIn() + if msg is None: + msg = "%r is not in %r" % (truncate_string(string), + truncate_string(base)) + self.assertIn(string, base, msg) + + def randomName(self, count=8): + """Create a random name, cap letters and numbers, and always starting with a letter""" + name = random.choice(string.ascii_uppercase) + name += ''.join(random.choice(string.ascii_uppercase + string.ascii_lowercase + string.digits) for x in range(count - 1)) + return name + + def randomXid(self): + # pick some unused, high UID/GID range to avoid interference + # from the system the test runs on + + # initialize a list to store used IDs + try: + self.used_xids + except AttributeError: + self.used_xids = [] + + # try to get an unused ID + failed = 0 + while failed < 50: + xid = random.randint(4711000, 4799000) + if xid not in self.used_xids: + self.used_xids += [xid] + return xid + failed += 1 + assert False, "No Xid are available" + + def assertWithin(self, val1, val2, delta, msg=""): + """Assert that val1 is within delta of val2, useful for time computations""" + self.assertTrue(((val1 + delta) > val2) and ((val1 - delta) < val2), msg) + + def cleanup_join(self, netbios_name): + (result, out, err) \ + = self.runsubcmd("domain", + "demote", + ("--remove-other-dead-server=%s " % netbios_name), + ("-U%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"])), + ("--server=%s" % os.environ["SERVER"])) + + self.assertCmdSuccess(result, out, err) diff --git a/python/samba/tests/samba_tool/computer.py b/python/samba/tests/samba_tool/computer.py new file mode 100644 index 0000000..b60e756 --- /dev/null +++ b/python/samba/tests/samba_tool/computer.py @@ -0,0 +1,378 @@ +# Unix SMB/CIFS implementation. +# +# Copyright (C) Bjoern Baumbach <bb@sernet.de> 2018 +# +# based on group.py: +# Copyright (C) Michael Adam 2012 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +import ldb +from samba.tests.samba_tool.base import SambaToolCmdTest +from samba import dsdb +from samba.ndr import ndr_unpack, ndr_pack +from samba.dcerpc import dnsp + + +class ComputerCmdTestCase(SambaToolCmdTest): + """Tests for samba-tool computer subcommands""" + computers = [] + samdb = None + + def setUp(self): + super().setUp() + self.creds = "-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"]) + self.samdb = self.getSamDB("-H", "ldap://%s" % os.environ["DC_SERVER"], self.creds) + # ips used to test --ip-address option + self.ipv4 = '10.10.10.10' + self.ipv6 = '2001:0db8:0a0b:12f0:0000:0000:0000:0001' + computer_basename = self.randomName().lower() + data = [ + { + 'name': computer_basename + 'cmp1', + 'ip_address_list': [self.ipv4] + }, + { + 'name': computer_basename + 'cmp2', + 'ip_address_list': [self.ipv6], + 'service_principal_name_list': [ + 'host/' + computer_basename + 'SPN20', + ], + }, + { + 'name': computer_basename + 'cmp3$', + 'ip_address_list': [self.ipv4, self.ipv6], + 'service_principal_name_list': [ + 'host/' + computer_basename + 'SPN30', + 'host/' + computer_basename + 'SPN31', + ], + }, + { + 'name': computer_basename + 'cmp4$', + }, + ] + self.computers = [self._randomComputer(base=item) for item in data] + + # setup the 4 computers and ensure they are correct + for computer in self.computers: + (result, out, err) = self._create_computer(computer) + + self.assertCmdSuccess(result, out, err) + self.assertNotIn( + "ERROR", err, "There shouldn't be any error message") + self.assertIn("Computer '%s' added successfully" % + computer["name"], out) + + found = self._find_computer(computer["name"]) + + self.assertIsNotNone(found) + + expectedname = computer["name"].rstrip('$') + expectedsamaccountname = computer["name"] + if not computer["name"].endswith('$'): + expectedsamaccountname = "%s$" % computer["name"] + self.assertEqual("%s" % found.get("name"), expectedname) + self.assertEqual("%s" % found.get("sAMAccountName"), + expectedsamaccountname) + self.assertEqual("%s" % found.get("description"), + computer["description"]) + + def tearDown(self): + super().tearDown() + # clean up all the left over computers, just in case + for computer in self.computers: + if self._find_computer(computer["name"]): + (result, out, err) = self.runsubcmd("computer", "delete", + "%s" % computer["name"]) + self.assertCmdSuccess(result, out, err, + "Failed to delete computer '%s'" % + computer["name"]) + + def test_newcomputer_with_service_principal_name(self): + # Each computer should have correct servicePrincipalName as provided. + for computer in self.computers: + expected_names = computer.get('service_principal_name_list', []) + found = self._find_service_principal_name(computer['name'], expected_names) + self.assertTrue(found) + + def test_newcomputer_with_dns_records(self): + + # Each computer should have correct DNS record and ip address. + for computer in self.computers: + for ip_address in computer.get('ip_address_list', []): + found = self._find_dns_record(computer['name'], ip_address) + self.assertTrue(found) + + # try to delete all the computers we just created + for computer in self.computers: + (result, out, err) = self.runsubcmd("computer", "delete", + "%s" % computer["name"]) + self.assertCmdSuccess(result, out, err, + "Failed to delete computer '%s'" % + computer["name"]) + found = self._find_computer(computer["name"]) + self.assertIsNone(found, + "Deleted computer '%s' still exists" % + computer["name"]) + + # all DNS records should be gone + for computer in self.computers: + for ip_address in computer.get('ip_address_list', []): + found = self._find_dns_record(computer['name'], ip_address) + self.assertFalse(found) + + def test_newcomputer(self): + """This tests the "computer add" and "computer delete" commands""" + # try to create all the computers again, this should fail + for computer in self.computers: + (result, out, err) = self._create_computer(computer) + self.assertCmdFail(result, "Succeeded to add existing computer") + self.assertIn("already exists", err) + + # try to delete all the computers we just added + for computer in self.computers: + (result, out, err) = self.runsubcmd("computer", "delete", "%s" % + computer["name"]) + self.assertCmdSuccess(result, out, err, + "Failed to delete computer '%s'" % + computer["name"]) + found = self._find_computer(computer["name"]) + self.assertIsNone(found, + "Deleted computer '%s' still exists" % + computer["name"]) + + # test creating computers + for computer in self.computers: + (result, out, err) = self.runsubcmd( + "computer", "add", "%s" % computer["name"], + "--description=%s" % computer["description"]) + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "There shouldn't be any error message") + self.assertIn("Computer '%s' added successfully" % + computer["name"], out) + + found = self._find_computer(computer["name"]) + + expectedname = computer["name"].rstrip('$') + expectedsamaccountname = computer["name"] + if not computer["name"].endswith('$'): + expectedsamaccountname = "%s$" % computer["name"] + self.assertEqual("%s" % found.get("name"), expectedname) + self.assertEqual("%s" % found.get("sAMAccountName"), + expectedsamaccountname) + self.assertEqual("%s" % found.get("description"), + computer["description"]) + + def test_list(self): + (result, out, err) = self.runsubcmd("computer", "list") + self.assertCmdSuccess(result, out, err, "Error running list") + + search_filter = ("(sAMAccountType=%u)" % + dsdb.ATYPE_WORKSTATION_TRUST) + + computerlist = self.samdb.search(base=self.samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter, + attrs=["samaccountname"]) + + self.assertTrue(len(computerlist) > 0, "no computers found in samdb") + + for computerobj in computerlist: + name = computerobj.get("samaccountname", idx=0) + found = self.assertMatch(out, str(name), + "computer '%s' not found" % name) + + def test_list_full_dn(self): + (result, out, err) = self.runsubcmd("computer", "list", "--full-dn") + self.assertCmdSuccess(result, out, err, "Error running list") + + search_filter = ("(sAMAccountType=%u)" % + dsdb.ATYPE_WORKSTATION_TRUST) + + computerlist = self.samdb.search(base=self.samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter, + attrs=[]) + + self.assertTrue(len(computerlist) > 0, "no computers found in samdb") + + for computerobj in computerlist: + name = computerobj.get("dn", idx=0) + found = self.assertMatch(out, str(name), + "computer '%s' not found" % name) + + def test_list_base_dn(self): + base_dn = str(self.samdb.domain_dn()) + (result, out, err) = self.runsubcmd("computer", "list", "-b", base_dn) + self.assertCmdSuccess(result, out, err, "Error running list") + + search_filter = ("(sAMAccountType=%u)" % + dsdb.ATYPE_WORKSTATION_TRUST) + + computerlist = self.samdb.search(base=base_dn, + scope=ldb.SCOPE_SUBTREE, + expression=search_filter, + attrs=["name"]) + + self.assertTrue(len(computerlist) > 0, "no computers found in samdb") + + for computerobj in computerlist: + name = computerobj.get("name", idx=0) + found = self.assertMatch(out, str(name), + "computer '%s' not found" % name) + + def test_move(self): + parentou = self._randomOU({"name": "parentOU"}) + (result, out, err) = self._create_ou(parentou) + self.assertCmdSuccess(result, out, err) + + for computer in self.computers: + olddn = self._find_computer(computer["name"]).get("dn") + + (result, out, err) = self.runsubcmd("computer", "move", + "%s" % computer["name"], + "OU=%s" % parentou["name"]) + self.assertCmdSuccess(result, out, err, + "Failed to move computer '%s'" % + computer["name"]) + self.assertEqual(err, "", "There shouldn't be any error message") + self.assertIn('Moved computer "%s"' % computer["name"], out) + + found = self._find_computer(computer["name"]) + self.assertNotEqual(found.get("dn"), olddn, + ("Moved computer '%s' still exists with the " + "same dn" % computer["name"])) + computername = computer["name"].rstrip('$') + newexpecteddn = ldb.Dn(self.samdb, + "CN=%s,OU=%s,%s" % + (computername, parentou["name"], + self.samdb.domain_dn())) + self.assertEqual(found.get("dn"), newexpecteddn, + "Moved computer '%s' does not exist" % + computer["name"]) + + (result, out, err) = self.runsubcmd("computer", "move", + "%s" % computer["name"], + "%s" % olddn.parent()) + self.assertCmdSuccess(result, out, err, + "Failed to move computer '%s'" % + computer["name"]) + + (result, out, err) = self.runsubcmd("ou", "delete", + "OU=%s" % parentou["name"]) + self.assertCmdSuccess(result, out, err, + "Failed to delete ou '%s'" % parentou["name"]) + + def _randomComputer(self, base=None): + """create a computer with random attribute values, you can specify base + attributes""" + if base is None: + base = {} + + computer = { + "name": self.randomName(), + "description": self.randomName(count=100), + } + computer.update(base) + return computer + + def _randomOU(self, base=None): + """create an ou with random attribute values, you can specify base + attributes""" + if base is None: + base = {} + + ou = { + "name": self.randomName(), + "description": self.randomName(count=100), + } + ou.update(base) + return ou + + def _create_computer(self, computer): + args = '{0} {1} --description={2}'.format( + computer['name'], self.creds, computer["description"]) + + for ip_address in computer.get('ip_address_list', []): + args += ' --ip-address={0}'.format(ip_address) + + for service_principal_name in computer.get('service_principal_name_list', []): + args += ' --service-principal-name={0}'.format(service_principal_name) + + args = args.split() + + return self.runsubcmd('computer', 'add', *args) + + def _create_ou(self, ou): + return self.runsubcmd("ou", "add", "OU=%s" % ou["name"], + "--description=%s" % ou["description"]) + + def _find_computer(self, name): + samaccountname = name + if not name.endswith('$'): + samaccountname = "%s$" % name + search_filter = ("(&(sAMAccountName=%s)(objectCategory=%s,%s))" % + (ldb.binary_encode(samaccountname), + "CN=Computer,CN=Schema,CN=Configuration", + self.samdb.domain_dn())) + computerlist = self.samdb.search(base=self.samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter) + if computerlist: + return computerlist[0] + else: + return None + + def _find_dns_record(self, name, ip_address): + name = name.rstrip('$') # computername + records = self.samdb.search( + base="DC=DomainDnsZones,{0}".format(self.samdb.get_default_basedn()), + scope=ldb.SCOPE_SUBTREE, + expression="(&(objectClass=dnsNode)(name={0}))".format(name), + attrs=['dnsRecord', 'dNSTombstoned']) + + # unpack data and compare + for record in records: + if 'dNSTombstoned' in record and str(record['dNSTombstoned']) == 'TRUE': + # if a record is dNSTombstoned, ignore it. + continue + for dns_record_bin in record['dnsRecord']: + dns_record_obj = ndr_unpack(dnsp.DnssrvRpcRecord, dns_record_bin) + ip = str(dns_record_obj.data) + + if str(ip) == str(ip_address): + return True + + return False + + def _find_service_principal_name(self, name, expected_service_principal_names): + """Find all servicePrincipalName values and compare with expected_service_principal_names""" + samaccountname = name.strip('$') + '$' + search_filter = ("(&(sAMAccountName=%s)(objectCategory=%s,%s))" % + (ldb.binary_encode(samaccountname), + "CN=Computer,CN=Schema,CN=Configuration", + self.samdb.domain_dn())) + computer_list = self.samdb.search( + base=self.samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter, + attrs=['servicePrincipalName']) + names = set() + for computer in computer_list: + for name in computer.get('servicePrincipalName', []): + names.add(str(name)) + return names == set(expected_service_principal_names) diff --git a/python/samba/tests/samba_tool/computer_edit.sh b/python/samba/tests/samba_tool/computer_edit.sh new file mode 100755 index 0000000..da32760 --- /dev/null +++ b/python/samba/tests/samba_tool/computer_edit.sh @@ -0,0 +1,197 @@ +#!/bin/sh +# +# Test for 'samba-tool computer edit' + +if [ $# -lt 3 ]; then + cat <<EOF +Usage: computer_edit.sh SERVER USERNAME PASSWORD +EOF + exit 1 +fi + +SERVER="$1" +USERNAME="$2" +PASSWORD="$3" + +STpath=$(pwd) +. $STpath/testprogs/blackbox/subunit.sh +. "${STpath}/testprogs/blackbox/common_test_fns.inc" + +ldbsearch=$(system_or_builddir_binary ldbsearch "${BINDIR}") + +display_name="Björns laptop" +display_name_b64="QmrDtnJucyBsYXB0b3A=" +display_name_new="Bjoerns new laptop" +# attribute value including control character +# echo -e "test \a string" | base64 +display_name_con_b64="dGVzdCAHIHN0cmluZwo=" + +tmpeditor=$(mktemp --suffix .sh -p $SELFTEST_TMPDIR samba-tool-editor-XXXXXXXX) +chmod +x $tmpeditor + +TEST_MACHINE="$(mktemp -u testmachineXXXXXX)" + +create_test_computer() +{ + $PYTHON ${STpath}/source4/scripting/bin/samba-tool \ + computer create ${TEST_MACHINE} \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +edit_computer() +{ + # create editor.sh + # enable computer account + cat >$tmpeditor <<-'EOF' +#!/usr/bin/env bash +computer_ldif="$1" +SED=$(which sed) +$SED -i -e 's/userAccountControl: 4098/userAccountControl: 4096/' $computer_ldif +EOF + + $PYTHON ${STpath}/source4/scripting/bin/samba-tool \ + computer edit ${TEST_MACHINE} --editor=$tmpeditor \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +# Test edit computer - add base64 attributes +add_attribute_base64() +{ + # create editor.sh + cat >$tmpeditor <<EOF +#!/usr/bin/env bash +computer_ldif="\$1" + +grep -v '^\$' \$computer_ldif > \${computer_ldif}.tmp +echo "displayName:: $display_name_b64" >> \${computer_ldif}.tmp + +mv \${computer_ldif}.tmp \$computer_ldif +EOF + + $PYTHON ${STpath}/source4/scripting/bin/samba-tool computer edit \ + ${TEST_MACHINE} --editor=$tmpeditor \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +get_attribute_base64() +{ + ${ldbsearch} "(sAMAccountName=${TEST_MACHINE}\$)" displayName \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +delete_attribute() +{ + # create editor.sh + cat >$tmpeditor <<EOF +#!/usr/bin/env bash +computer_ldif="\$1" + +grep -v '^displayName' \$computer_ldif >> \${computer_ldif}.tmp +mv \${computer_ldif}.tmp \$computer_ldif +EOF + $PYTHON ${STpath}/source4/scripting/bin/samba-tool computer edit \ + ${TEST_MACHINE} --editor=$tmpeditor \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +# Test edit computer - add base64 attribute value including control character +add_attribute_base64_control() +{ + # create editor.sh + cat >$tmpeditor <<EOF +#!/usr/bin/env bash +computer_ldif="\$1" + +grep -v '^\$' \$computer_ldif > \${computer_ldif}.tmp +echo "displayName:: $display_name_con_b64" >> \${computer_ldif}.tmp + +mv \${computer_ldif}.tmp \$computer_ldif +EOF + $PYTHON ${STpath}/source4/scripting/bin/samba-tool computer edit \ + ${TEST_MACHINE} --editor=$tmpeditor \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +get_attribute_base64_control() +{ + $PYTHON ${STpath}/source4/scripting/bin/samba-tool computer show \ + ${TEST_MACHINE} --attributes=displayName \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +get_attribute_force_no_base64() +{ + # LDB_FLAG_FORCE_NO_BASE64_LDIF should be used here. + $PYTHON ${STpath}/source4/scripting/bin/samba-tool computer show \ + ${TEST_MACHINE} --attributes=displayName \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +# Test edit computer - change base64 attribute value including control character +change_attribute_base64_control() +{ + # create editor.sh + cat >$tmpeditor <<EOF +#!/usr/bin/env bash +computer_ldif="\$1" + +sed -i -e 's/displayName:: $display_name_con_b64/displayName: $display_name/' \ + \$computer_ldif +EOF + $PYTHON ${STpath}/source4/scripting/bin/samba-tool computer edit \ + ${TEST_MACHINE} --editor=$tmpeditor \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +# Test edit computer - change attributes with LDB_FLAG_FORCE_NO_BASE64_LDIF +change_attribute_force_no_base64() +{ + # create editor.sh + # Expects that the original attribute is available as clear text, + # because the LDB_FLAG_FORCE_NO_BASE64_LDIF should be used here. + cat >$tmpeditor <<EOF +#!/usr/bin/env bash +computer_ldif="\$1" + +sed -i -e 's/displayName: $display_name/displayName: $display_name_new/' \ + \$computer_ldif +EOF + + $PYTHON ${STpath}/source4/scripting/bin/samba-tool computer edit \ + ${TEST_MACHINE} --editor=$tmpeditor \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +get_changed_attribute_force_no_base64() +{ + $PYTHON ${STpath}/source4/scripting/bin/samba-tool computer show \ + ${TEST_MACHINE} --attributes=displayName \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +delete_computer() +{ + $PYTHON ${STpath}/source4/scripting/bin/samba-tool \ + computer delete ${TEST_MACHINE} \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +failed=0 + +testit "create_test_computer" create_test_computer || failed=$(expr $failed + 1) +testit "edit_computer" edit_computer || failed=$(expr $failed + 1) +testit "add_attribute_base64" add_attribute_base64 || failed=$(expr $failed + 1) +testit_grep "get_attribute_base64" "^displayName:: $display_name_b64" get_attribute_base64 || failed=$(expr $failed + 1) +testit "delete_attribute" delete_attribute || failed=$(expr $failed + 1) +testit "add_attribute_base64_control" add_attribute_base64_control || failed=$(expr $failed + 1) +testit_grep "get_attribute_base64_control" "^displayName:: $display_name_con_b64" get_attribute_base64_control || failed=$(expr $failed + 1) +testit "change_attribute_base64_control" change_attribute_base64_control || failed=$(expr $failed + 1) +testit_grep "get_attribute_base64" "^displayName:: $display_name_b64" get_attribute_base64 || failed=$(expr $failed + 1) +testit_grep "get_attribute_force_no_base64" "^displayName: $display_name" get_attribute_force_no_base64 || failed=$(expr $failed + 1) +testit "change_attribute_force_no_base64" change_attribute_force_no_base64 || failed=$(expr $failed + 1) +testit_grep "get_changed_attribute_force_no_base64" "^displayName: $display_name_new" get_changed_attribute_force_no_base64 || failed=$(expr $failed + 1) +testit "delete_computer" delete_computer || failed=$(expr $failed + 1) + +rm -f $tmpeditor + +exit $failed diff --git a/python/samba/tests/samba_tool/contact.py b/python/samba/tests/samba_tool/contact.py new file mode 100644 index 0000000..2bec813 --- /dev/null +++ b/python/samba/tests/samba_tool/contact.py @@ -0,0 +1,468 @@ +# Unix SMB/CIFS implementation. +# +# Tests for samba-tool contact management commands +# +# Copyright (C) Bjoern Baumbach <bbaumbach@samba.org> 2019 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +import ldb +from samba.tests.samba_tool.base import SambaToolCmdTest + +class ContactCmdTestCase(SambaToolCmdTest): + """Tests for samba-tool contact subcommands""" + contacts = [] + samdb = None + + def setUp(self): + super().setUp() + self.creds = "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"]) + self.samdb = self.getSamDB("-H", + "ldap://%s" % os.environ["DC_SERVER"], + self.creds) + contact = None + self.contacts = [] + + contact = self._randomContact({"expectedname": "contact1", + "name": "contact1"}) + self.contacts.append(contact) + + # No 'name' is given here, so the name will be made from givenname. + contact = self._randomContact({"expectedname": "contact2", + "givenName": "contact2"}) + self.contacts.append(contact) + + contact = self._randomContact({"expectedname": "contact3", + "name": "contact3", + "displayName": "contact3displayname", + "givenName": "not_contact3", + "initials": "I", + "sn": "not_contact3", + "mobile": "12345"}) + self.contacts.append(contact) + + # No 'name' is given here, so the name will be made from the the + # sn, initials and givenName attributes. + contact = self._randomContact({"expectedname": "James T. Kirk", + "sn": "Kirk", + "initials": "T", + "givenName": "James"}) + self.contacts.append(contact) + + # setup the 4 contacts and ensure they are correct + for contact in self.contacts: + (result, out, err) = self._create_contact(contact) + + self.assertCmdSuccess(result, out, err) + self.assertNotIn( + "ERROR", err, "There shouldn't be any error message") + self.assertIn("Contact '%s' added successfully" % + contact["expectedname"], out) + + found = self._find_contact(contact["expectedname"]) + + self.assertIsNotNone(found) + + contactname = contact["expectedname"] + self.assertEqual("%s" % found.get("name"), contactname) + self.assertEqual("%s" % found.get("description"), + contact["description"]) + + def tearDown(self): + super().tearDown() + # clean up all the left over contacts, just in case + for contact in self.contacts: + if self._find_contact(contact["expectedname"]): + (result, out, err) = self.runsubcmd( + "contact", "delete", "%s" % contact["expectedname"]) + self.assertCmdSuccess(result, out, err, + "Failed to delete contact '%s'" % + contact["expectedname"]) + + def test_newcontact(self): + """This tests the "contact create" and "contact delete" commands""" + # try to create all the contacts again, this should fail + for contact in self.contacts: + (result, out, err) = self._create_contact(contact) + self.assertCmdFail(result, "Succeeded to create existing contact") + self.assertIn("already exists", err) + + # try to delete all the contacts we just added + for contact in self.contacts: + (result, out, err) = self.runsubcmd("contact", "delete", "%s" % + contact["expectedname"]) + self.assertCmdSuccess(result, out, err, + "Failed to delete contact '%s'" % + contact["expectedname"]) + found = self._find_contact(contact["expectedname"]) + self.assertIsNone(found, + "Deleted contact '%s' still exists" % + contact["expectedname"]) + + # test creating contacts in an specified OU + parentou = self._randomOU({"name": "testOU"}) + (result, out, err) = self._create_ou(parentou) + self.assertCmdSuccess(result, out, err) + + for contact in self.contacts: + (result, out, err) = self._create_contact(contact, ou="OU=testOU") + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "There shouldn't be any error message") + self.assertIn("Contact '%s' added successfully" % + contact["expectedname"], out) + + found = self._find_contact(contact["expectedname"]) + + contactname = contact["expectedname"] + self.assertEqual("%s" % found.get("name"), contactname) + self.assertEqual("%s" % found.get("description"), + contact["description"]) + + # try to delete all the contacts we just added, by DN + for contact in self.contacts: + expecteddn = ldb.Dn(self.samdb, + "CN=%s,OU=%s,%s" % + (contact["expectedname"], + parentou["name"], + self.samdb.domain_dn())) + (result, out, err) = self.runsubcmd("contact", "delete", "%s" % + expecteddn) + self.assertCmdSuccess(result, out, err, + "Failed to delete contact '%s'" % + contact["expectedname"]) + found = self._find_contact(contact["expectedname"]) + self.assertIsNone(found, + "Deleted contact '%s' still exists" % + contact["expectedname"]) + + (result, out, err) = self.runsubcmd("ou", "delete", + "OU=%s" % parentou["name"]) + self.assertCmdSuccess(result, out, err, + "Failed to delete ou '%s'" % parentou["name"]) + + # creating contacts, again for further tests + for contact in self.contacts: + (result, out, err) = self._create_contact(contact) + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "There shouldn't be any error message") + self.assertIn("Contact '%s' added successfully" % + contact["expectedname"], out) + + found = self._find_contact(contact["expectedname"]) + + contactname = contact["expectedname"] + self.assertEqual("%s" % found.get("name"), contactname) + self.assertEqual("%s" % found.get("description"), + contact["description"]) + + def test_list(self): + (result, out, err) = self.runsubcmd("contact", "list") + self.assertCmdSuccess(result, out, err, "Error running list") + + search_filter = "(objectClass=contact)" + contactlist = self.samdb.search(base=self.samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter, + attrs=["name"]) + + self.assertTrue(len(contactlist) > 0, "no contacts found in samdb") + + for contactobj in contactlist: + name = contactobj.get("name", idx=0) + self.assertMatch(out, str(name), + "contact '%s' not found" % name) + + def test_list_full_dn(self): + (result, out, err) = self.runsubcmd("contact", "list", "--full-dn") + self.assertCmdSuccess(result, out, err, "Error running list") + + search_filter = "(objectClass=contact)" + contactlist = self.samdb.search(base=self.samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter, + attrs=["dn"]) + + self.assertTrue(len(contactlist) > 0, "no contacts found in samdb") + + for contactobj in contactlist: + self.assertMatch(out, str(contactobj.dn), + "contact '%s' not found" % str(contactobj.dn)) + + def test_list_base_dn(self): + base_dn = str(self.samdb.domain_dn()) + (result, out, err) = self.runsubcmd("contact", "list", + "-b", base_dn) + self.assertCmdSuccess(result, out, err, "Error running list") + + search_filter = "(objectClass=contact)" + contactlist = self.samdb.search(base=base_dn, + scope=ldb.SCOPE_SUBTREE, + expression=search_filter, + attrs=["name"]) + + self.assertTrue(len(contactlist) > 0, "no contacts found in samdb") + + for contactobj in contactlist: + name = contactobj.get("name", idx=0) + self.assertMatch(out, str(name), + "contact '%s' not found" % name) + + def test_move(self): + parentou = self._randomOU({"name": "parentOU"}) + (result, out, err) = self._create_ou(parentou) + self.assertCmdSuccess(result, out, err) + + for contact in self.contacts: + olddn = self._find_contact(contact["expectedname"]).get("dn") + + (result, out, err) = self.runsubcmd("contact", "move", + "%s" % contact["expectedname"], + "OU=%s" % parentou["name"]) + self.assertCmdSuccess(result, out, err, + "Failed to move contact '%s'" % + contact["expectedname"]) + self.assertEqual(err, "", "There shouldn't be any error message") + self.assertIn('Moved contact "%s"' % contact["expectedname"], out) + + found = self._find_contact(contact["expectedname"]) + self.assertNotEqual(found.get("dn"), olddn, + ("Moved contact '%s' still exists with the " + "same dn" % contact["expectedname"])) + contactname = contact["expectedname"] + newexpecteddn = ldb.Dn(self.samdb, + "CN=%s,OU=%s,%s" % + (contactname, + parentou["name"], + self.samdb.domain_dn())) + self.assertEqual(found.get("dn"), newexpecteddn, + "Moved contact '%s' does not exist" % + contact["expectedname"]) + + (result, out, err) = self.runsubcmd("contact", "move", + "%s" % contact["expectedname"], + "%s" % olddn.parent()) + self.assertCmdSuccess(result, out, err, + "Failed to move contact '%s'" % + contact["expectedname"]) + + (result, out, err) = self.runsubcmd("ou", "delete", + "OU=%s" % parentou["name"]) + self.assertCmdSuccess(result, out, err, + "Failed to delete ou '%s'" % parentou["name"]) + + def test_rename_givenname_initials_surname(self): + """rename and remove given name, initials and surname for all contacts""" + for contact in self.contacts: + name = contact["name"] if "name" in contact else contact["expectedname"] + + new_givenname = "new_given_name_of_" + name + new_initials = "A" + new_surname = "new_surname_of_" + name + new_cn = "new_cn_of_" + name + expected_cn = "%s %s. %s" % (new_givenname, new_initials, new_surname) + + # rename given name, initials and surname + (result, out, err) = self.runsubcmd("contact", "rename", name, + "--reset-cn", + "--surname=%s" % new_surname, + "--initials=%s" % new_initials, + "--given-name=%s" % new_givenname) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn('successfully', out) + + found = self._find_contact(expected_cn) + self.assertEqual("%s" % found.get("givenName"), new_givenname) + self.assertEqual("%s" % found.get("initials"), new_initials) + self.assertEqual("%s" % found.get("sn"), new_surname) + self.assertEqual("%s" % found.get("name"), expected_cn) + self.assertEqual("%s" % found.get("cn"), expected_cn) + + # remove given name, initials and surname + # (must force new cn, because en empty new CN throws an error) + (result, out, err) = self.runsubcmd("contact", "rename", expected_cn, + "--force-new-cn=%s" % expected_cn, + "--surname=", + "--initials=", + "--given-name=") + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn('successfully', out) + + found = self._find_contact(expected_cn) + self.assertEqual(found.get("givenName"), None) + self.assertEqual(found.get("initials"), None) + self.assertEqual(found.get("sn"), None) + + # reset changes (initials are already removed) + old_surname = contact["sn"] if "sn" in contact else "" + old_initials = contact["initials"] if "initials" in contact else "" + old_givenname = contact["givenName"] if "givenName" in contact else "" + old_cn = contact["cn"] if "cn" in contact else name + (result, out, err) = self.runsubcmd("contact", "rename", expected_cn, + "--force-new-cn=%s" % old_cn, + "--surname=%s" % old_surname, + "--initials=%s" % old_initials, + "--given-name=%s" % old_givenname) + self.assertCmdSuccess(result, out, err) + + def test_rename_cn(self): + """rename and try to remove the cn of all contacts""" + for contact in self.contacts: + name = contact["name"] if "name" in contact else contact["expectedname"] + new_cn = "new_cn_of_" + name + + # rename cn + (result, out, err) = self.runsubcmd("contact", "rename", name, + "--force-new-cn=%s" % new_cn) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn('successfully', out) + + found = self._find_contact(new_cn) + self.assertEqual("%s" % found.get("cn"), new_cn) + + # trying to remove cn (throws an error) + (result, out, err) = self.runsubcmd("contact", "rename", new_cn, + "--force-new-cn=") + self.assertCmdFail(result) + self.assertIn('Failed to rename contact', err) + self.assertIn("delete protected attribute", err) + + # reset changes (cn must be the name) + (result, out, err) = self.runsubcmd("contact", "rename", new_cn, + "--force-new-cn=%s" % name) + self.assertCmdSuccess(result, out, err) + + + def test_rename_mailaddress_displayname(self): + """rename and remove the mail and the displayname attribute of all contacts""" + for contact in self.contacts: + name = contact["name"] if "name" in contact else contact["expectedname"] + new_mail = "new_mailaddress_of_" + name + new_displayname = "new displayname of " + name + + # change mail and displayname + (result, out, err) = self.runsubcmd("contact", "rename", name, + "--mail-address=%s" + % new_mail, + "--display-name=%s" + % new_displayname) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn('successfully', out) + + found = self._find_contact(name) + self.assertEqual("%s" % found.get("mail"), new_mail) + self.assertEqual("%s" % found.get("displayName"), new_displayname) + + # remove mail and displayname + (result, out, err) = self.runsubcmd("contact", "rename", name, + "--mail-address=", + "--display-name=") + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn('successfully', out) + + found = self._find_contact(name) + self.assertEqual(found.get("mail"), None) + self.assertEqual(found.get("displayName"), None) + + # reset changes + old_mail = contact["givenName"] if "givenName" in contact else "" + old_displayname = contact["cn"] if "cn" in contact else "" + (result, out, err) = self.runsubcmd("contact", "rename", name, + "--mail-address=%s" % old_mail, + "--display-name=%s" % old_displayname) + self.assertCmdSuccess(result, out, err) + + def _randomContact(self, base=None): + """Create a contact with random attribute values, you can specify base + attributes""" + if base is None: + base = {} + + # No name attributes are given here, because the object name will + # be made from the sn, givenName and initials attributes, if no name + # is given. + contact = { + "description": self.randomName(count=100), + } + contact.update(base) + return contact + + def _randomOU(self, base=None): + """Create an ou with random attribute values, you can specify base + attributes.""" + if base is None: + base = {} + + ou = { + "name": self.randomName(), + "description": self.randomName(count=100), + } + ou.update(base) + return ou + + def _create_contact(self, contact, ou=None): + args = "" + + if "name" in contact: + args += '{0}'.format(contact['name']) + + args += ' {0}'.format(self.creds) + + if ou is not None: + args += ' --ou={0}'.format(ou) + + if "description" in contact: + args += ' --description={0}'.format(contact["description"]) + if "sn" in contact: + args += ' --surname={0}'.format(contact["sn"]) + if "initials" in contact: + args += ' --initials={0}'.format(contact["initials"]) + if "givenName" in contact: + args += ' --given-name={0}'.format(contact["givenName"]) + if "displayName" in contact: + args += ' --display-name={0}'.format(contact["displayName"]) + if "mobile" in contact: + args += ' --mobile-number={0}'.format(contact["mobile"]) + + args = args.split() + + return self.runsubcmd('contact', 'create', *args) + + def _create_ou(self, ou): + return self.runsubcmd("ou", + "create", + "OU=%s" % ou["name"], + "--description=%s" % ou["description"]) + + def _find_contact(self, name): + contactname = name + search_filter = ("(&(objectClass=contact)(name=%s))" % + ldb.binary_encode(contactname)) + contactlist = self.samdb.search(base=self.samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter, + attrs=[]) + if contactlist: + return contactlist[0] + else: + return None diff --git a/python/samba/tests/samba_tool/contact_edit.sh b/python/samba/tests/samba_tool/contact_edit.sh new file mode 100755 index 0000000..d31413d --- /dev/null +++ b/python/samba/tests/samba_tool/contact_edit.sh @@ -0,0 +1,183 @@ +#!/bin/sh +# +# Test for 'samba-tool contact edit' + +if [ $# -lt 3 ]; then + cat <<EOF +Usage: contact_edit.sh SERVER USERNAME PASSWORD +EOF + exit 1 +fi + +SERVER="$1" +USERNAME="$2" +PASSWORD="$3" + +samba_ldbsearch=ldbsearch +if test -x $BINDIR/ldbsearch; then + samba_ldbsearch=$BINDIR/ldbsearch +fi + +STpath=$(pwd) +. $STpath/testprogs/blackbox/subunit.sh + +display_name="Björn" +display_name_b64="QmrDtnJu" +display_name_new="Renamed Bjoern" +# attribute value including control character +# echo -e "test \a string" | base64 +display_name_con_b64="dGVzdCAHIHN0cmluZwo=" + +TEST_USER="$(mktemp -u testcontactXXXXXX)" + +tmpeditor=$(mktemp --suffix .sh -p $SELFTEST_TMPDIR samba-tool-editor-XXXXXXXX) +chmod +x $tmpeditor + +create_test_contact() +{ + $PYTHON ${STpath}/source4/scripting/bin/samba-tool \ + contact create ${TEST_USER} \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +# Test edit contact - add base64 attributes +add_attribute_base64() +{ + # create editor.sh + cat >$tmpeditor <<EOF +#!/usr/bin/env bash +contact_ldif="\$1" + +grep -v '^\$' \$contact_ldif > \${contact_ldif}.tmp +echo "displayName:: $display_name_b64" >> \${contact_ldif}.tmp + +mv \${contact_ldif}.tmp \$contact_ldif +EOF + + $PYTHON ${STpath}/source4/scripting/bin/samba-tool contact edit \ + ${TEST_USER} --editor=$tmpeditor \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +get_attribute_base64() +{ + $samba_ldbsearch "(&(objectClass=contact)(name=${TEST_USER}))" \ + displayName \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +delete_attribute() +{ + # create editor.sh + cat >$tmpeditor <<EOF +#!/usr/bin/env bash +contact_ldif="\$1" + +grep -v '^displayName' \$contact_ldif >> \${contact_ldif}.tmp +mv \${contact_ldif}.tmp \$contact_ldif +EOF + $PYTHON ${STpath}/source4/scripting/bin/samba-tool contact edit \ + ${TEST_USER} --editor=$tmpeditor \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +# Test edit contact - add base64 attribute value including control character +add_attribute_base64_control() +{ + # create editor.sh + cat >$tmpeditor <<EOF +#!/usr/bin/env bash +contact_ldif="\$1" + +grep -v '^\$' \$contact_ldif > \${contact_ldif}.tmp +echo "displayName:: $display_name_con_b64" >> \${contact_ldif}.tmp + +mv \${contact_ldif}.tmp \$contact_ldif +EOF + $PYTHON ${STpath}/source4/scripting/bin/samba-tool contact edit \ + ${TEST_USER} --editor=$tmpeditor \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +get_attribute_base64_control() +{ + $PYTHON ${STpath}/source4/scripting/bin/samba-tool contact show \ + ${TEST_USER} --attributes=displayName \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +get_attribute_force_no_base64() +{ + # LDB_FLAG_FORCE_NO_BASE64_LDIF should be used here. + $PYTHON ${STpath}/source4/scripting/bin/samba-tool contact show \ + ${TEST_USER} --attributes=displayName \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +# Test edit contact - change base64 attribute value including control character +change_attribute_base64_control() +{ + # create editor.sh + cat >$tmpeditor <<EOF +#!/usr/bin/env bash +contact_ldif="\$1" + +sed -i -e 's/displayName:: $display_name_con_b64/displayName: $display_name/' \ + \$contact_ldif +EOF + $PYTHON ${STpath}/source4/scripting/bin/samba-tool contact edit \ + ${TEST_USER} --editor=$tmpeditor \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +# Test edit contact - change attributes with LDB_FLAG_FORCE_NO_BASE64_LDIF +change_attribute_force_no_base64() +{ + # create editor.sh + # Expects that the original attribute is available as clear text, + # because the LDB_FLAG_FORCE_NO_BASE64_LDIF should be used here. + cat >$tmpeditor <<EOF +#!/usr/bin/env bash +contact_ldif="\$1" + +sed -i -e 's/displayName: $display_name/displayName: $display_name_new/' \ + \$contact_ldif +EOF + + $PYTHON ${STpath}/source4/scripting/bin/samba-tool contact edit \ + ${TEST_USER} --editor=$tmpeditor \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +get_changed_attribute_force_no_base64() +{ + $PYTHON ${STpath}/source4/scripting/bin/samba-tool contact show \ + ${TEST_USER} --attributes=displayName \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +delete_contact() +{ + $PYTHON ${STpath}/source4/scripting/bin/samba-tool \ + contact delete ${TEST_USER} \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +failed=0 + +testit "create_test_contact" create_test_contact || failed=$(expr $failed + 1) +testit "add_attribute_base64" add_attribute_base64 || failed=$(expr $failed + 1) +testit_grep "get_attribute_base64" "^displayName:: $display_name_b64" get_attribute_base64 || failed=$(expr $failed + 1) +testit "delete_attribute" delete_attribute || failed=$(expr $failed + 1) +testit "add_attribute_base64_control" add_attribute_base64_control || failed=$(expr $failed + 1) +testit_grep "get_attribute_base64_control" "^displayName:: $display_name_con_b64" get_attribute_base64_control || failed=$(expr $failed + 1) +testit "change_attribute_base64_control" change_attribute_base64_control || failed=$(expr $failed + 1) +testit_grep "get_attribute_base64" "^displayName:: $display_name_b64" get_attribute_base64 || failed=$(expr $failed + 1) +testit_grep "get_attribute_force_no_base64" "^displayName: $display_name" get_attribute_force_no_base64 || failed=$(expr $failed + 1) +testit "change_attribute_force_no_base64" change_attribute_force_no_base64 || failed=$(expr $failed + 1) +testit_grep "get_changed_attribute_force_no_base64" "^displayName: $display_name_new" get_changed_attribute_force_no_base64 || failed=$(expr $failed + 1) +testit "delete_contact" delete_contact || failed=$(expr $failed + 1) + +rm -f $tmpeditor + +exit $failed diff --git a/python/samba/tests/samba_tool/demote.py b/python/samba/tests/samba_tool/demote.py new file mode 100644 index 0000000..2c63cca --- /dev/null +++ b/python/samba/tests/samba_tool/demote.py @@ -0,0 +1,106 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) Andrew Bartlett <abartlet@samba.org> 2018 +# Written by Joe Guo <joeg@catalyst.net.nz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +from samba.tests.samba_tool.base import SambaToolCmdTest + + +class DemoteCmdTestCase(SambaToolCmdTest): + """Test for samba-tool domain demote subcommand""" + + def setUp(self): + super().setUp() + self.creds_string = "-U{0}%{1}".format( + os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"]) + + self.dc_server = os.environ['DC_SERVER'] + self.dburl = "ldap://%s" % os.environ["DC_SERVER"] + self.samdb = self.getSamDB("-H", self.dburl, self.creds_string) + + def test_demote_and_remove_dns(self): + """ + Test domain demote command will also remove dns references + """ + + server = os.environ['SERVER'] # the server to demote + zone = os.environ['REALM'].lower() + + # make sure zone exist + result, out, err = self.runsubcmd( + "dns", "zoneinfo", server, zone, self.creds_string) + self.assertCmdSuccess(result, out, err) + + # add a A record for the server to demote + result, out, err = self.runsubcmd( + "dns", "add", self.dc_server, zone, + server, "A", "192.168.0.193", self.creds_string) + self.assertCmdSuccess(result, out, err) + + # make sure above A record exist + result, out, err = self.runsubcmd( + "dns", "query", self.dc_server, zone, + server, 'A', self.creds_string) + self.assertCmdSuccess(result, out, err) + + # the above A record points to this host + dnshostname = '{0}.{1}'.format(server, zone) + + # add a SRV record points to above host + srv_record = "{0} 65530 65530 65530".format(dnshostname) + self.runsubcmd( + "dns", "add", self.dc_server, zone, 'testrecord', "SRV", + srv_record, self.creds_string) + + # make sure above SRV record exist + result, out, err = self.runsubcmd( + "dns", "query", self.dc_server, zone, + "testrecord", 'SRV', self.creds_string) + self.assertCmdSuccess(result, out, err) + + for type_ in ['CNAME', 'NS', 'PTR']: + # create record + self.runsubcmd( + "dns", "add", self.dc_server, zone, + 'testrecord', type_, dnshostname, + self.creds_string) + self.assertCmdSuccess(result, out, err) + + # check exist + result, out, err = self.runsubcmd( + "dns", "query", self.dc_server, zone, + "testrecord", 'SRV', self.creds_string) + self.assertCmdSuccess(result, out, err) + + # now demote + result, out, err = self.runsubcmd( + "domain", "demote", + "--server", self.dc_server, + "--configfile", os.environ["CONFIGFILE"], + "--workgroup", os.environ["DOMAIN"], + self.creds_string) + self.assertCmdSuccess(result, out, err) + + result, out, err = self.runsubcmd( + "dns", "query", self.dc_server, zone, + server, 'ALL', self.creds_string) + self.assertCmdFail(result) + + result, out, err = self.runsubcmd( + "dns", "query", self.dc_server, zone, + "testrecord", 'ALL', self.creds_string) + self.assertCmdFail(result) diff --git a/python/samba/tests/samba_tool/dnscmd.py b/python/samba/tests/samba_tool/dnscmd.py new file mode 100644 index 0000000..d372bc5 --- /dev/null +++ b/python/samba/tests/samba_tool/dnscmd.py @@ -0,0 +1,1506 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) Andrew Bartlett <abartlet@catalyst.net.nz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +import ldb +import re + +from samba.ndr import ndr_unpack, ndr_pack +from samba.dcerpc import dnsp +from samba.tests.samba_tool.base import SambaToolCmdTest +import time +from samba import dsdb_dns + + +class DnsCmdTestCase(SambaToolCmdTest): + def setUp(self): + super().setUp() + + self.dburl = "ldap://%s" % os.environ["SERVER"] + self.creds_string = "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"]) + + self.samdb = self.getSamDB("-H", self.dburl, self.creds_string) + self.config_dn = str(self.samdb.get_config_basedn()) + + self.testip = "192.168.0.193" + self.testip2 = "192.168.0.194" + + self.addCleanup(self.deleteZone) + self.addZone() + + # Note: SOA types don't work (and shouldn't), as we only have one zone per DNS record. + + good_dns = ["SAMDOM.EXAMPLE.COM", + "1.EXAMPLE.COM", + "%sEXAMPLE.COM" % ("1." * 100), + "EXAMPLE", + "!@#$%^&*()_", + "HIGH\xFFBYTE", + "@.EXAMPLE.COM", + "."] + bad_dns = ["...", + ".EXAMPLE.COM", + ".EXAMPLE.", + "", + "SAMDOM..EXAMPLE.COM"] + + good_mx = ["SAMDOM.EXAMPLE.COM 65530", + "SAMDOM.EXAMPLE.COM 0"] + bad_mx = ["SAMDOM.EXAMPLE.COM -1", + "SAMDOM.EXAMPLE.COM", + " ", + "SAMDOM.EXAMPLE.COM 1 1", + "SAMDOM.EXAMPLE.COM SAMDOM.EXAMPLE.COM"] + + good_srv = ["SAMDOM.EXAMPLE.COM 65530 65530 65530", + "SAMDOM.EXAMPLE.COM 1 1 1"] + bad_srv = ["SAMDOM.EXAMPLE.COM 0 65536 0", + "SAMDOM.EXAMPLE.COM 0 0 65536", + "SAMDOM.EXAMPLE.COM 65536 0 0"] + + for bad_dn in bad_dns: + bad_mx.append("%s 1" % bad_dn) + bad_srv.append("%s 0 0 0" % bad_dn) + for good_dn in good_dns: + good_mx.append("%s 1" % good_dn) + good_srv.append("%s 0 0 0" % good_dn) + + self.good_records = { + "A":["192.168.0.1", "255.255.255.255"], + "AAAA":["1234:5678:9ABC:DEF0:0000:0000:0000:0000", + "0000:0000:0000:0000:0000:0000:0000:0000", + "1234:5678:9ABC:DEF0:1234:5678:9ABC:DEF0", + "1234:1234:1234::", + "1234:5678:9ABC:DEF0::", + "0000:0000::0000", + "1234::5678:9ABC:0000:0000:0000:0000", + "::1", + "::", + "1:1:1:1:1:1:1:1"], + "PTR": good_dns, + "CNAME": good_dns, + "NS": good_dns, + "MX": good_mx, + "SRV": good_srv, + "TXT": ["text", "", "@#!", "\n"] + } + + self.bad_records = { + "A":["192.168.0.500", + "255.255.255.255/32"], + "AAAA":["GGGG:1234:5678:9ABC:0000:0000:0000:0000", + "0000:0000:0000:0000:0000:0000:0000:0000/1", + "AAAA:AAAA:AAAA:AAAA:G000:0000:0000:1234", + "1234:5678:9ABC:DEF0:1234:5678:9ABC:DEF0:1234", + "1234:5678:9ABC:DEF0:1234:5678:9ABC", + "1111::1111::1111"], + "PTR": bad_dns, + "CNAME": bad_dns, + "NS": bad_dns, + "MX": bad_mx, + "SRV": bad_srv + } + + def resetZone(self): + self.deleteZone() + self.addZone() + + def addZone(self): + self.zone = "zone" + result, out, err = self.runsubcmd("dns", + "zonecreate", + os.environ["SERVER"], + self.zone, + self.creds_string) + self.assertCmdSuccess(result, out, err) + + def deleteZone(self): + result, out, err = self.runsubcmd("dns", + "zonedelete", + os.environ["SERVER"], + self.zone, + self.creds_string) + self.assertCmdSuccess(result, out, err) + + def get_all_records(self, zone_name): + zone_dn = (f"DC={zone_name},CN=MicrosoftDNS,DC=DomainDNSZones," + f"{self.samdb.get_default_basedn()}") + + expression = "(&(objectClass=dnsNode)(!(dNSTombstoned=TRUE)))" + + nodes = self.samdb.search(base=zone_dn, scope=ldb.SCOPE_SUBTREE, + expression=expression, + attrs=["dnsRecord", "name"]) + + record_map = {} + for node in nodes: + name = node["name"][0].decode() + record_map[name] = list(node["dnsRecord"]) + + return record_map + + def get_record_from_db(self, zone_name, record_name): + zones = self.samdb.search(base="DC=DomainDnsZones,%s" + % self.samdb.get_default_basedn(), + scope=ldb.SCOPE_SUBTREE, + expression="(objectClass=dnsZone)", + attrs=["cn"]) + + for zone in zones: + if zone_name in str(zone.dn): + zone_dn = zone.dn + break + + records = self.samdb.search(base=zone_dn, scope=ldb.SCOPE_SUBTREE, + expression="(objectClass=dnsNode)", + attrs=["dnsRecord"]) + + for old_packed_record in records: + if record_name in str(old_packed_record.dn): + return (old_packed_record.dn, + ndr_unpack(dnsp.DnssrvRpcRecord, + old_packed_record["dnsRecord"][0])) + + def test_rank_none(self): + record_str = "192.168.50.50" + record_type_str = "A" + + result, out, err = self.runsubcmd("dns", "add", os.environ["SERVER"], + self.zone, "testrecord", record_type_str, + record_str, self.creds_string) + self.assertCmdSuccess(result, out, err, + "Failed to add record '%s' with type %s." + % (record_str, record_type_str)) + + dn, record = self.get_record_from_db(self.zone, "testrecord") + record.rank = 0 # DNS_RANK_NONE + res = self.samdb.dns_replace_by_dn(dn, [record]) + if res is not None: + self.fail("Unable to update dns record to have DNS_RANK_NONE.") + + errors = [] + + # The record should still exist + result, out, err = self.runsubcmd("dns", "query", os.environ["SERVER"], + self.zone, "testrecord", record_type_str, + self.creds_string) + try: + self.assertCmdSuccess(result, out, err, + "Failed to query for a record" + "which had DNS_RANK_NONE.") + self.assertTrue("testrecord" in out and record_str in out, + "Query for a record which had DNS_RANK_NONE" + "succeeded but produced no resulting records.") + except AssertionError: + # Windows produces no resulting records + pass + + # We should not be able to add a duplicate + result, out, err = self.runsubcmd("dns", "add", os.environ["SERVER"], + self.zone, "testrecord", record_type_str, + record_str, self.creds_string) + try: + self.assertCmdFail(result, "Successfully added duplicate record" + "of one which had DNS_RANK_NONE.") + except AssertionError as e: + errors.append(e) + + # We should be able to delete it + result, out, err = self.runsubcmd("dns", "delete", os.environ["SERVER"], + self.zone, "testrecord", record_type_str, + record_str, self.creds_string) + try: + self.assertCmdSuccess(result, out, err, "Failed to delete record" + "which had DNS_RANK_NONE.") + except AssertionError as e: + errors.append(e) + + # Now the record should not exist + result, out, err = self.runsubcmd("dns", "query", os.environ["SERVER"], + self.zone, "testrecord", + record_type_str, self.creds_string) + try: + self.assertCmdFail(result, "Successfully queried for deleted record" + "which had DNS_RANK_NONE.") + except AssertionError as e: + errors.append(e) + + if len(errors) > 0: + err_str = "Failed appropriate behaviour with DNS_RANK_NONE:" + for error in errors: + err_str = err_str + "\n" + str(error) + raise AssertionError(err_str) + + def test_accept_valid_commands(self): + """ + For all good records, attempt to add, query and delete them. + """ + num_failures = 0 + failure_msgs = [] + for dnstype in self.good_records: + for record in self.good_records[dnstype]: + try: + result, out, err = self.runsubcmd("dns", "add", + os.environ["SERVER"], + self.zone, "testrecord", + dnstype, record, + self.creds_string) + self.assertCmdSuccess(result, out, err, "Failed to add" + "record %s with type %s." + % (record, dnstype)) + + result, out, err = self.runsubcmd("dns", "query", + os.environ["SERVER"], + self.zone, "testrecord", + dnstype, + self.creds_string) + self.assertCmdSuccess(result, out, err, "Failed to query" + "record %s with qualifier %s." + % (record, dnstype)) + + result, out, err = self.runsubcmd("dns", "delete", + os.environ["SERVER"], + self.zone, "testrecord", + dnstype, record, + self.creds_string) + self.assertCmdSuccess(result, out, err, "Failed to remove" + "record %s with type %s." + % (record, dnstype)) + except AssertionError as e: + num_failures = num_failures + 1 + failure_msgs.append(e) + + if num_failures > 0: + for msg in failure_msgs: + print(msg) + self.fail("Failed to accept valid commands. %d total failures." + "Errors above." % num_failures) + + def test_reject_invalid_commands(self): + """ + For all bad records, attempt to add them and update to them, + making sure that both operations fail. + """ + num_failures = 0 + failure_msgs = [] + + # Add invalid records and make sure they fail to be added + for dnstype in self.bad_records: + for record in self.bad_records[dnstype]: + try: + result, out, err = self.runsubcmd("dns", "add", + os.environ["SERVER"], + self.zone, "testrecord", + dnstype, record, + self.creds_string) + self.assertCmdFail(result, "Successfully added invalid" + "record '%s' of type '%s'." + % (record, dnstype)) + except AssertionError as e: + num_failures = num_failures + 1 + failure_msgs.append(e) + self.resetZone() + try: + result, out, err = self.runsubcmd("dns", "delete", + os.environ["SERVER"], + self.zone, "testrecord", + dnstype, record, + self.creds_string) + self.assertCmdFail(result, "Successfully deleted invalid" + "record '%s' of type '%s' which" + "shouldn't exist." % (record, dnstype)) + except AssertionError as e: + num_failures = num_failures + 1 + failure_msgs.append(e) + self.resetZone() + + # Update valid records to invalid ones and make sure they + # fail to be updated + for dnstype in self.bad_records: + for bad_record in self.bad_records[dnstype]: + good_record = self.good_records[dnstype][0] + + try: + result, out, err = self.runsubcmd("dns", "add", + os.environ["SERVER"], + self.zone, "testrecord", + dnstype, good_record, + self.creds_string) + self.assertCmdSuccess(result, out, err, "Failed to add " + "record '%s' with type %s." + % (record, dnstype)) + + result, out, err = self.runsubcmd("dns", "update", + os.environ["SERVER"], + self.zone, "testrecord", + dnstype, good_record, + bad_record, + self.creds_string) + self.assertCmdFail(result, "Successfully updated valid " + "record '%s' of type '%s' to invalid " + "record '%s' of the same type." + % (good_record, dnstype, bad_record)) + + result, out, err = self.runsubcmd("dns", "delete", + os.environ["SERVER"], + self.zone, "testrecord", + dnstype, good_record, + self.creds_string) + self.assertCmdSuccess(result, out, err, "Could not delete " + "valid record '%s' of type '%s'." + % (good_record, dnstype)) + except AssertionError as e: + num_failures = num_failures + 1 + failure_msgs.append(e) + self.resetZone() + + if num_failures > 0: + for msg in failure_msgs: + print(msg) + self.fail("Failed to reject invalid commands. %d total failures. " + "Errors above." % num_failures) + + def test_update_invalid_type(self): + """Make sure that a record can't be updated to another type leaving + the data the same, where that data would be incompatible with + the new type. This is not always enforced at the C level. + + We don't try with all types, because many types are compatible + in their representations (e.g. A records could be TXT or CNAME + records; PTR record values are exactly the same as CNAME + record values, etc). + """ + dnstypes = ('A', 'AAAA', 'SRV') + for dnstype1 in dnstypes: + record1 = self.good_records[dnstype1][0] + result, out, err = self.runsubcmd("dns", "add", + os.environ["SERVER"], + self.zone, "testrecord", + dnstype1, record1, + self.creds_string) + self.assertCmdSuccess(result, out, err, "Failed to add " + "record %s with type %s." + % (record1, dnstype1)) + + for dnstype2 in dnstypes: + if dnstype1 == dnstype2: + continue + + record2 = self.good_records[dnstype2][0] + + # Check both ways: Give the current type and try to update, + # and give the new type and try to update. + result, out, err = self.runsubcmd("dns", "update", + os.environ["SERVER"], + self.zone, "testrecord", + dnstype1, record1, + record2, self.creds_string) + self.assertCmdFail(result, "Successfully updated record '%s' " + "to '%s', even though the latter is of " + "type '%s' where '%s' was expected." + % (record1, record2, dnstype2, dnstype1)) + + result, out, err = self.runsubcmd("dns", "update", + os.environ["SERVER"], + self.zone, "testrecord", + dnstype2, record1, record2, + self.creds_string) + self.assertCmdFail(result, "Successfully updated record " + "'%s' to '%s', even though the former " + "is of type '%s' where '%s' was expected." + % (record1, record2, dnstype1, dnstype2)) + + def test_update_valid_type(self): + for dnstype in self.good_records: + for record in self.good_records[dnstype]: + result, out, err = self.runsubcmd("dns", "add", + os.environ["SERVER"], + self.zone, "testrecord", + dnstype, record, + self.creds_string) + self.assertCmdSuccess(result, out, err, "Failed to add " + "record %s with type %s." + % (record, dnstype)) + + if record == '.' and dnstype != 'TXT': + # This will fail because the update finds a match + # for "." that is actually "" (in + # dns_record_match()), then uses the "" record in + # a call to dns_to_dnsp_convert() which calls + # dns_name_check() which rejects "" as a bad DNS + # name. Maybe FIXME, maybe not. + continue + + # Update the record to be the same. + result, out, err = self.runsubcmd("dns", "update", + os.environ["SERVER"], + self.zone, "testrecord", + dnstype, record, record, + self.creds_string) + self.assertCmdSuccess(result, out, err, + "Could not update record " + "'%s' to be exactly the same." % record) + + result, out, err = self.runsubcmd("dns", "delete", + os.environ["SERVER"], + self.zone, "testrecord", + dnstype, record, + self.creds_string) + self.assertCmdSuccess(result, out, err, "Could not delete " + "valid record '%s' of type '%s'." + % (record, dnstype)) + + for record in self.good_records["SRV"]: + result, out, err = self.runsubcmd("dns", "add", + os.environ["SERVER"], + self.zone, "testrecord", + "SRV", record, + self.creds_string) + self.assertCmdSuccess(result, out, err, "Failed to add " + "record %s with type 'SRV'." % record) + + split = record.split() + new_bit = str(int(split[3]) + 1) + new_record = '%s %s %s %s' % (split[0], split[1], split[2], new_bit) + + result, out, err = self.runsubcmd("dns", "update", + os.environ["SERVER"], + self.zone, "testrecord", + "SRV", record, + new_record, self.creds_string) + self.assertCmdSuccess(result, out, err, "Failed to update record " + "'%s' of type '%s' to '%s'." + % (record, "SRV", new_record)) + + result, out, err = self.runsubcmd("dns", "query", + os.environ["SERVER"], + self.zone, "testrecord", + "SRV", self.creds_string) + self.assertCmdSuccess(result, out, err, "Failed to query for " + "record '%s' of type '%s'." + % (new_record, "SRV")) + + result, out, err = self.runsubcmd("dns", "delete", + os.environ["SERVER"], + self.zone, "testrecord", + "SRV", new_record, + self.creds_string) + self.assertCmdSuccess(result, out, err, "Could not delete " + "valid record '%s' of type '%s'." + % (new_record, "SRV")) + + # Since 'dns update' takes the current value as a parameter, make sure + # we can't enter the wrong current value for a given record. + for dnstype in self.good_records: + if len(self.good_records[dnstype]) < 3: + continue # Not enough records of this type to do this test + + used_record = self.good_records[dnstype][0] + unused_record = self.good_records[dnstype][1] + new_record = self.good_records[dnstype][2] + + result, out, err = self.runsubcmd("dns", "add", + os.environ["SERVER"], + self.zone, "testrecord", + dnstype, used_record, + self.creds_string) + self.assertCmdSuccess(result, out, err, "Failed to add record %s " + "with type %s." % (used_record, dnstype)) + + result, out, err = self.runsubcmd("dns", "update", + os.environ["SERVER"], + self.zone, "testrecord", + dnstype, unused_record, + new_record, + self.creds_string) + self.assertCmdFail(result, "Successfully updated record '%s' " + "from '%s' to '%s', even though the given " + "source record is incorrect." + % (used_record, unused_record, new_record)) + + def test_invalid_types(self): + result, out, err = self.runsubcmd("dns", "add", + os.environ["SERVER"], + self.zone, "testrecord", + "SOA", "test", + self.creds_string) + self.assertCmdFail(result, "Successfully added record of type SOA, " + "when this type should not be available.") + self.assertTrue("type SOA is not supported" in err, + "Invalid error message '%s' when attempting to " + "add record of type SOA." % err) + + def test_add_overlapping_different_type(self): + """ + Make sure that we can add an entry with the same name as an existing one but a different type. + """ + + i = 0 + for dnstype1 in self.good_records: + record1 = self.good_records[dnstype1][0] + for dnstype2 in self.good_records: + # Only do some subset of dns types, otherwise it takes a long time. + i += 1 + if i % 4 != 0: + continue + + if dnstype1 == dnstype2: + continue + + record2 = self.good_records[dnstype2][0] + + result, out, err = self.runsubcmd("dns", "add", + os.environ["SERVER"], + self.zone, "testrecord", + dnstype1, record1, + self.creds_string) + self.assertCmdSuccess(result, out, err, "Failed to add record " + "'%s' of type '%s'." % (record1, dnstype1)) + + result, out, err = self.runsubcmd("dns", "add", + os.environ["SERVER"], + self.zone, "testrecord", + dnstype2, record2, + self.creds_string) + self.assertCmdSuccess(result, out, err, "Failed to add record " + "'%s' of type '%s' when a record '%s' " + "of type '%s' with the same name exists." + % (record1, dnstype1, record2, dnstype2)) + + result, out, err = self.runsubcmd("dns", "query", + os.environ["SERVER"], + self.zone, "testrecord", + dnstype1, self.creds_string) + self.assertCmdSuccess(result, out, err, "Failed to query for " + "record '%s' of type '%s' when a new " + "record '%s' of type '%s' with the same " + "name was added." + % (record1, dnstype1, record2, dnstype2)) + + result, out, err = self.runsubcmd("dns", "query", + os.environ["SERVER"], + self.zone, "testrecord", + dnstype2, self.creds_string) + self.assertCmdSuccess(result, out, err, "Failed to query " + "record '%s' of type '%s' which should " + "have been added with the same name as " + "record '%s' of type '%s'." + % (record2, dnstype2, record1, dnstype1)) + + result, out, err = self.runsubcmd("dns", "delete", + os.environ["SERVER"], + self.zone, "testrecord", + dnstype1, record1, + self.creds_string) + self.assertCmdSuccess(result, out, err, "Failed to delete " + "record '%s' of type '%s'." + % (record1, dnstype1)) + + result, out, err = self.runsubcmd("dns", "delete", + os.environ["SERVER"], + self.zone, "testrecord", + dnstype2, record2, + self.creds_string) + self.assertCmdSuccess(result, out, err, "Failed to delete " + "record '%s' of type '%s'." + % (record2, dnstype2)) + + def test_query_deleted_record(self): + self.runsubcmd("dns", "add", os.environ["SERVER"], self.zone, + "testrecord", "A", self.testip, self.creds_string) + self.runsubcmd("dns", "delete", os.environ["SERVER"], self.zone, + "testrecord", "A", self.testip, self.creds_string) + + result, out, err = self.runsubcmd("dns", "query", + os.environ["SERVER"], + self.zone, "testrecord", + "A", self.creds_string) + self.assertCmdFail(result) + + def test_add_duplicate_record(self): + for record_type in self.good_records: + result, out, err = self.runsubcmd("dns", "add", + os.environ["SERVER"], + self.zone, "testrecord", + record_type, + self.good_records[record_type][0], + self.creds_string) + self.assertCmdSuccess(result, out, err) + result, out, err = self.runsubcmd("dns", "add", + os.environ["SERVER"], + self.zone, "testrecord", + record_type, + self.good_records[record_type][0], + self.creds_string) + self.assertCmdFail(result) + result, out, err = self.runsubcmd("dns", "query", + os.environ["SERVER"], + self.zone, "testrecord", + record_type, self.creds_string) + self.assertCmdSuccess(result, out, err) + result, out, err = self.runsubcmd("dns", "delete", + os.environ["SERVER"], + self.zone, "testrecord", + record_type, + self.good_records[record_type][0], + self.creds_string) + self.assertCmdSuccess(result, out, err) + + def test_remove_deleted_record(self): + self.runsubcmd("dns", "add", os.environ["SERVER"], self.zone, + "testrecord", "A", self.testip, self.creds_string) + self.runsubcmd("dns", "delete", os.environ["SERVER"], self.zone, + "testrecord", "A", self.testip, self.creds_string) + + # Attempting to delete a record that has already been deleted or has never existed should fail + result, out, err = self.runsubcmd("dns", "delete", + os.environ["SERVER"], + self.zone, "testrecord", + "A", self.testip, self.creds_string) + self.assertCmdFail(result) + result, out, err = self.runsubcmd("dns", "query", + os.environ["SERVER"], + self.zone, "testrecord", + "A", self.creds_string) + self.assertCmdFail(result) + result, out, err = self.runsubcmd("dns", "delete", + os.environ["SERVER"], + self.zone, "testrecord2", + "A", self.testip, self.creds_string) + self.assertCmdFail(result) + + def test_cleanup_record(self): + """ + Test dns cleanup command is working fine. + """ + + # add a A record + self.runsubcmd("dns", "add", os.environ["SERVER"], self.zone, + 'testa', "A", self.testip, self.creds_string) + + # the above A record points to this host + dnshostname = '{0}.{1}'.format('testa', self.zone.lower()) + + # add a CNAME record points to above host + self.runsubcmd("dns", "add", os.environ["SERVER"], self.zone, + 'testcname', "CNAME", dnshostname, self.creds_string) + + # add a NS record + self.runsubcmd("dns", "add", os.environ["SERVER"], self.zone, + 'testns', "NS", dnshostname, self.creds_string) + + # add a PTR record points to above host + self.runsubcmd("dns", "add", os.environ["SERVER"], self.zone, + 'testptr', "PTR", dnshostname, self.creds_string) + + # add a SRV record points to above host + srv_record = "{0} 65530 65530 65530".format(dnshostname) + self.runsubcmd("dns", "add", os.environ["SERVER"], self.zone, + 'testsrv', "SRV", srv_record, self.creds_string) + + # cleanup record for this dns host + self.runsubcmd("dns", "cleanup", os.environ["SERVER"], + dnshostname, self.creds_string) + + # all records should be marked as dNSTombstoned + for record_name in ['testa', 'testcname', 'testns', 'testptr', 'testsrv']: + + records = self.samdb.search( + base="DC=DomainDnsZones,{0}".format(self.samdb.get_default_basedn()), + scope=ldb.SCOPE_SUBTREE, + expression="(&(objectClass=dnsNode)(name={0}))".format(record_name), + attrs=["dNSTombstoned"]) + + self.assertEqual(len(records), 1) + for record in records: + self.assertEqual(str(record['dNSTombstoned']), 'TRUE') + + def test_cleanup_record_no_A_record(self): + """ + Test dns cleanup command works with no A record. + """ + + # add a A record + self.runsubcmd("dns", "add", os.environ["SERVER"], self.zone, + 'notesta', "A", self.testip, self.creds_string) + + # the above A record points to this host + dnshostname = '{0}.{1}'.format('testa', self.zone.lower()) + + # add a CNAME record points to above host + self.runsubcmd("dns", "add", os.environ["SERVER"], self.zone, + 'notestcname', "CNAME", dnshostname, self.creds_string) + + # add a NS record + self.runsubcmd("dns", "add", os.environ["SERVER"], self.zone, + 'notestns', "NS", dnshostname, self.creds_string) + + # add a PTR record points to above host + self.runsubcmd("dns", "add", os.environ["SERVER"], self.zone, + 'notestptr', "PTR", dnshostname, self.creds_string) + + # add a SRV record points to above host + srv_record = "{0} 65530 65530 65530".format(dnshostname) + self.runsubcmd("dns", "add", os.environ["SERVER"], self.zone, + 'notestsrv', "SRV", srv_record, self.creds_string) + + # Remove the initial A record (leading to hanging references) + self.runsubcmd("dns", "delete", os.environ["SERVER"], self.zone, + 'notesta', "A", self.testip, self.creds_string) + + # cleanup record for this dns host + self.runsubcmd("dns", "cleanup", os.environ["SERVER"], + dnshostname, self.creds_string) + + # all records should be marked as dNSTombstoned + for record_name in ['notestcname', 'notestns', 'notestptr', 'notestsrv']: + + records = self.samdb.search( + base="DC=DomainDnsZones,{0}".format(self.samdb.get_default_basedn()), + scope=ldb.SCOPE_SUBTREE, + expression="(&(objectClass=dnsNode)(name={0}))".format(record_name), + attrs=["dNSTombstoned"]) + + self.assertEqual(len(records), 1) + for record in records: + self.assertEqual(str(record['dNSTombstoned']), 'TRUE') + + def test_cleanup_multi_srv_record(self): + """ + Test dns cleanup command for multi-valued SRV record. + + Steps: + - Add 2 A records host1 and host2 + - Add a SRV record srv1 and points to both host1 and host2 + - Run cleanup command for host1 + - Check records for srv1, data for host1 should be gone and host2 is kept. + """ + + hosts = ['host1', 'host2'] # A record names + srv_name = 'srv1' + + # add A records + for host in hosts: + self.runsubcmd("dns", "add", os.environ["SERVER"], self.zone, + host, "A", self.testip, self.creds_string) + + # the above A record points to this host + dnshostname = '{0}.{1}'.format(host, self.zone.lower()) + + # add a SRV record points to above host + srv_record = "{0} 65530 65530 65530".format(dnshostname) + self.runsubcmd("dns", "add", os.environ["SERVER"], self.zone, + srv_name, "SRV", srv_record, self.creds_string) + + records = self.samdb.search( + base="DC=DomainDnsZones,{0}".format(self.samdb.get_default_basedn()), + scope=ldb.SCOPE_SUBTREE, + expression="(&(objectClass=dnsNode)(name={0}))".format(srv_name), + attrs=['dnsRecord']) + # should have 2 records here + self.assertEqual(len(records[0]['dnsRecord']), 2) + + # cleanup record for dns host1 + dnshostname1 = 'host1.{0}'.format(self.zone.lower()) + self.runsubcmd("dns", "cleanup", os.environ["SERVER"], + dnshostname1, self.creds_string) + + records = self.samdb.search( + base="DC=DomainDnsZones,{0}".format(self.samdb.get_default_basedn()), + scope=ldb.SCOPE_SUBTREE, + expression="(&(objectClass=dnsNode)(name={0}))".format(srv_name), + attrs=['dnsRecord', 'dNSTombstoned']) + + # dnsRecord for host1 should be deleted + self.assertEqual(len(records[0]['dnsRecord']), 1) + + # unpack data + dns_record_bin = records[0]['dnsRecord'][0] + dns_record_obj = ndr_unpack(dnsp.DnssrvRpcRecord, dns_record_bin) + + # dnsRecord for host2 is still there and is the only one + dnshostname2 = 'host2.{0}'.format(self.zone.lower()) + self.assertEqual(dns_record_obj.data.nameTarget, dnshostname2) + + # assert that the record isn't spuriously tombstoned + self.assertTrue('dNSTombstoned' not in records[0] or + str(records[0]['dNSTombstoned']) == 'FALSE') + + def test_dns_wildcards(self): + """ + Ensure that DNS wild card entries can be added deleted and queried + """ + num_failures = 0 + failure_msgs = [] + records = [("*.", "MISS", "A", "1.1.1.1"), + ("*.SAMDOM", "MISS.SAMDOM", "A", "1.1.1.2")] + for (name, miss, dnstype, record) in records: + try: + result, out, err = self.runsubcmd("dns", "add", + os.environ["SERVER"], + self.zone, name, + dnstype, record, + self.creds_string) + self.assertCmdSuccess( + result, + out, + err, + ("Failed to add record %s (%s) with type %s." + % (name, record, dnstype))) + + result, out, err = self.runsubcmd("dns", "query", + os.environ["SERVER"], + self.zone, name, + dnstype, + self.creds_string) + self.assertCmdSuccess( + result, + out, + err, + ("Failed to query record %s with qualifier %s." + % (record, dnstype))) + + # dns tool does not perform dns wildcard search if the name + # does not match + result, out, err = self.runsubcmd("dns", "query", + os.environ["SERVER"], + self.zone, miss, + dnstype, + self.creds_string) + self.assertCmdFail( + result, + ("Failed to query record %s with qualifier %s." + % (record, dnstype))) + + result, out, err = self.runsubcmd("dns", "delete", + os.environ["SERVER"], + self.zone, name, + dnstype, record, + self.creds_string) + self.assertCmdSuccess( + result, + out, + err, + ("Failed to remove record %s with type %s." + % (record, dnstype))) + except AssertionError as e: + num_failures = num_failures + 1 + failure_msgs.append(e) + + if num_failures > 0: + for msg in failure_msgs: + print(msg) + self.fail("Failed to accept valid commands. %d total failures." + "Errors above." % num_failures) + + def test_serverinfo(self): + for v in ['w2k', 'dotnet', 'longhorn']: + result, out, err = self.runsubcmd("dns", + "serverinfo", + "--client-version", v, + os.environ["SERVER"], + self.creds_string) + self.assertCmdSuccess(result, + out, + err, + "Failed to print serverinfo with " + "client version %s" % v) + self.assertTrue(out != '') + + def test_zoneinfo(self): + result, out, err = self.runsubcmd("dns", + "zoneinfo", + os.environ["SERVER"], + self.zone, + self.creds_string) + self.assertCmdSuccess(result, + out, + err, + "Failed to print zoneinfo") + self.assertTrue(out != '') + + def test_zoneoptions_aging(self): + for options, vals, error in ( + (['--aging=1'], {'fAging': 'TRUE'}, False), + (['--aging=0'], {'fAging': 'FALSE'}, False), + (['--aging=-1'], {'fAging': 'FALSE'}, True), + (['--aging=2'], {}, True), + (['--aging=2', '--norefreshinterval=1'], {}, True), + (['--aging=1', '--norefreshinterval=1'], + {'fAging': 'TRUE', 'dwNoRefreshInterval': '1'}, False), + (['--aging=1', '--norefreshinterval=0'], + {'fAging': 'TRUE', 'dwNoRefreshInterval': '0'}, False), + (['--aging=0', '--norefreshinterval=99', '--refreshinterval=99'], + {'fAging': 'FALSE', + 'dwNoRefreshInterval': '99', + 'dwRefreshInterval': '99'}, False), + (['--aging=0', '--norefreshinterval=-99', '--refreshinterval=99'], + {}, True), + (['--refreshinterval=9999999'], {}, True), + (['--norefreshinterval=9999999'], {}, True), + ): + result, out, err = self.runsubcmd("dns", + "zoneoptions", + os.environ["SERVER"], + self.zone, + self.creds_string, + *options) + if error: + self.assertCmdFail(result, "zoneoptions should fail") + else: + self.assertCmdSuccess(result, + out, + err, + "zoneoptions shouldn't fail") + + + info_r, info_out, info_err = self.runsubcmd("dns", + "zoneinfo", + os.environ["SERVER"], + self.zone, + self.creds_string) + + self.assertCmdSuccess(info_r, + info_out, + info_err, + "zoneinfo shouldn't fail after zoneoptions") + + info = {k: v for k, v in re.findall(r'^\s*(\w+)\s*:\s*(\w+)\s*$', + info_out, + re.MULTILINE)} + for k, v in vals.items(): + self.assertIn(k, info) + self.assertEqual(v, info[k]) + + + def ldap_add_node_with_records(self, name, records): + dn = (f"DC={name},DC={self.zone},CN=MicrosoftDNS,DC=DomainDNSZones," + f"{self.samdb.get_default_basedn()}") + + dns_records = [] + for r in records: + rec = dnsp.DnssrvRpcRecord() + rec.wType = r.get('wType', dnsp.DNS_TYPE_A) + rec.rank = dnsp.DNS_RANK_ZONE + rec.dwTtlSeconds = 900 + rec.dwTimeStamp = r.get('dwTimeStamp', 0) + rec.data = r.get('data', '10.10.10.10') + dns_records.append(ndr_pack(rec)) + + msg = ldb.Message.from_dict(self.samdb, + {'dn': dn, + "objectClass": ["top", "dnsNode"], + 'dnsRecord': dns_records + }) + self.samdb.add(msg) + + def get_timestamp_map(self): + re_wtypes = (dnsp.DNS_TYPE_A, + dnsp.DNS_TYPE_AAAA, + dnsp.DNS_TYPE_TXT) + + t = time.time() + now = dsdb_dns.unix_to_dns_timestamp(int(t)) + + records = self.get_all_records(self.zone) + tsmap = {} + for k, recs in records.items(): + m = [] + tsmap[k] = m + for rec in recs: + r = ndr_unpack(dnsp.DnssrvRpcRecord, rec) + timestamp = r.dwTimeStamp + if abs(timestamp - now) < 3: + timestamp = 'nowish' + + if r.wType in re_wtypes: + m.append(('R', timestamp)) + else: + m.append(('-', timestamp)) + + return tsmap + + + def test_zoneoptions_mark_records(self): + self.maxDiff = 10000 + # We need a number of records to work with, so we'll use part + # of our known good records list, using three different names + # to test the regex. All these records will be static. + for dnstype in self.good_records: + for record in self.good_records[dnstype][:2]: + self.runsubcmd("dns", "add", + os.environ["SERVER"], + self.zone, "frobitz", + dnstype, record, + self.creds_string) + self.runsubcmd("dns", "add", + os.environ["SERVER"], + self.zone, "weergly", + dnstype, record, + self.creds_string) + self.runsubcmd("dns", "add", + os.environ["SERVER"], + self.zone, "snizle", + dnstype, record, + self.creds_string) + + # and we also want some that aren't static, and some mixed + # static/dynamic records. + # timestamps are in hours since 1601; now ~= 3.7 million + for ts in (0, 100, 10 ** 6, 10 ** 7): + name = f"ts-{ts}" + self.ldap_add_node_with_records(name, [{"dwTimeStamp": ts}]) + + recs = [] + for ts in (0, 100, 10 ** 6, 10 ** 7): + addr = f'10.{(ts >> 16) & 255}.{(ts >> 8) & 255}.{ts & 255}' + recs.append({"dwTimeStamp": ts, "data": addr}) + + self.ldap_add_node_with_records("ts-multi", recs) + + # get the state of ALL records. + # then we make assertions about the diffs, keeping track of + # the current state. + + tsmap = self.get_timestamp_map() + + + + for options, diff, output_substrings, error in ( + # --mark-old-records-static + # --mark-records-static-regex + # --mark-records-dynamic-regex + ( + ['--mark-old-records-static=1971-13-04'], + {}, + [], + "bad date" + ), + ( + # using --dry-run, should be no change, but output. + ['--mark-old-records-static=1971-03-04', '--dry-run'], + {}, + [ + "would make 1/1 records static on ts-1000000.zone.", + "would make 1/1 records static on ts-100.zone.", + "would make 2/4 records static on ts-multi.zone.", + ], + False + ), + ( + # timestamps < ~ 3.25 million are now static + ['--mark-old-records-static=1971-03-04'], + { + 'ts-100': [('R', 0)], + 'ts-1000000': [('R', 0)], + 'ts-multi': [('R', 0), ('R', 0), ('R', 0), ('R', 10000000)] + }, + [ + "made 1/1 records static on ts-1000000.zone.", + "made 1/1 records static on ts-100.zone.", + "made 2/4 records static on ts-multi.zone.", + ], + False + ), + ( + # no change, old records already static + ['--mark-old-records-static=1972-03-04'], + {}, + [], + False + ), + ( + # no change, samba-tool added records already static + ['--mark-records-static-regex=sniz'], + {}, + [], + False + ), + ( + # snizle has 2 A, 2 AAAA, 10 fancy, and 2 TXT records, in + # that order. + # the A, AAAA, and TXT records should be dynamic + ['--mark-records-dynamic-regex=sniz'], + {'snizle': [('R', 'nowish'), + ('R', 'nowish'), + ('R', 'nowish'), + ('R', 'nowish'), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('R', 'nowish'), + ('R', 'nowish')] + }, + ['made 6/16 records dynamic on snizle.zone.'], + False + ), + ( + # This regex should catch snizle, weergly, and ts-* + # but we're doing dry-run so no change + ['--mark-records-dynamic-regex=[sw]', '-n'], + {}, + ['would make 3/4 records dynamic on ts-multi.zone.', + 'would make 1/1 records dynamic on ts-0.zone.', + 'would make 1/1 records dynamic on ts-1000000.zone.', + 'would make 6/16 records dynamic on weergly.zone.', + 'would make 1/1 records dynamic on ts-100.zone.' + ], + False + ), + ( + # This regex should catch snizle and frobitz + # but snizle has already been changed. + ['--mark-records-dynamic-regex=z'], + {'frobitz': [('R', 'nowish'), + ('R', 'nowish'), + ('R', 'nowish'), + ('R', 'nowish'), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('R', 'nowish'), + ('R', 'nowish')] + }, + ['made 6/16 records dynamic on frobitz.zone.'], + False + ), + ( + # This regex should catch snizle, frobitz, and + # ts-multi. Note that the 1e7 ts-multi record is + # already dynamic and doesn't change. + ['--mark-records-dynamic-regex=[i]'], + {'ts-multi': [('R', 'nowish'), + ('R', 'nowish'), + ('R', 'nowish'), + ('R', 10000000)] + }, + ['made 3/4 records dynamic on ts-multi.zone.'], + False + ), + ( + # matches no records + ['--mark-records-dynamic-regex=^aloooooo[qw]+'], + {}, + [], + False + ), + ( + # This should be an error, as only one --mark-* + # argument is allowed at a time + ['--mark-records-dynamic-regex=.', + '--mark-records-static-regex=.', + ], + {}, + [], + True + ), + ( + # This should also be an error + ['--mark-old-records-static=1997-07-07', + '--mark-records-static-regex=.', + ], + {}, + [], + True + ), + ( + # This should not be an error. --aging and refresh + # options can be mixed with --mark ones. + ['--mark-old-records-static=1997-07-07', + '--aging=0', + ], + {}, + ['Set Aging to 0'], + False + ), + ( + # This regex should catch weergly, but all the + # records are already static, + ['--mark-records-static-regex=wee'], + {}, + [], + False + ), + ( + # Make frobitz static again. + ['--mark-records-static-regex=obi'], + {'frobitz': [('R', 0), + ('R', 0), + ('R', 0), + ('R', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('R', 0), + ('R', 0)] + }, + ['made 6/16 records static on frobitz.zone.'], + False + ), + ( + # would make almost everything static, but --dry-run + ['--mark-old-records-static=2222-03-04', '--dry-run'], + {}, + [ + 'would make 6/16 records static on snizle.zone.', + 'would make 3/4 records static on ts-multi.zone.' + ], + False + ), + ( + # make everything static + ['--mark-records-static-regex=.'], + {'snizle': [('R', 0), + ('R', 0), + ('R', 0), + ('R', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('R', 0), + ('R', 0)], + 'ts-10000000': [('R', 0)], + 'ts-multi': [('R', 0), ('R', 0), ('R', 0), ('R', 0)] + }, + [ + 'made 4/4 records static on ts-multi.zone.', + 'made 1/1 records static on ts-10000000.zone.', + 'made 6/16 records static on snizle.zone.', + ], + False + ), + ( + # make everything dynamic that can be + ['--mark-records-dynamic-regex=.'], + {'frobitz': [('R', 'nowish'), + ('R', 'nowish'), + ('R', 'nowish'), + ('R', 'nowish'), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('R', 'nowish'), + ('R', 'nowish')], + 'snizle': [('R', 'nowish'), + ('R', 'nowish'), + ('R', 'nowish'), + ('R', 'nowish'), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('R', 'nowish'), + ('R', 'nowish')], + 'ts-0': [('R', 'nowish')], + 'ts-100': [('R', 'nowish')], + 'ts-1000000': [('R', 'nowish')], + 'ts-10000000': [('R', 'nowish')], + 'ts-multi': [('R', 'nowish'), + ('R', 'nowish'), + ('R', 'nowish'), + ('R', 'nowish')], + 'weergly': [('R', 'nowish'), + ('R', 'nowish'), + ('R', 'nowish'), + ('R', 'nowish'), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('-', 0), + ('R', 'nowish'), + ('R', 'nowish')] + }, + [ + 'made 4/4 records dynamic on ts-multi.zone.', + 'made 6/16 records dynamic on snizle.zone.', + 'made 1/1 records dynamic on ts-0.zone.', + 'made 1/1 records dynamic on ts-1000000.zone.', + 'made 1/1 records dynamic on ts-10000000.zone.', + 'made 1/1 records dynamic on ts-100.zone.', + 'made 6/16 records dynamic on frobitz.zone.', + 'made 6/16 records dynamic on weergly.zone.', + ], + False + ), + ): + result, out, err = self.runsubcmd("dns", + "zoneoptions", + os.environ["SERVER"], + self.zone, + self.creds_string, + *options) + if error: + self.assertCmdFail(result, f"zoneoptions should fail ({error})") + else: + self.assertCmdSuccess(result, + out, + err, + "zoneoptions shouldn't fail") + + new_tsmap = self.get_timestamp_map() + + # same keys, always + self.assertEqual(sorted(new_tsmap), sorted(tsmap)) + changes = {} + for k in tsmap: + if tsmap[k] != new_tsmap[k]: + changes[k] = new_tsmap[k] + + self.assertEqual(diff, changes) + + for s in output_substrings: + self.assertIn(s, out) + tsmap = new_tsmap + + def test_zonecreate_dns_domain_directory_partition(self): + zone = "test-dns-domain-dp-zone" + dns_dp_opt = "--dns-directory-partition=domain" + + result, out, err = self.runsubcmd("dns", + "zonecreate", + os.environ["SERVER"], + zone, + self.creds_string, + dns_dp_opt) + self.assertCmdSuccess(result, + out, + err, + "Failed to create zone with " + "--dns-directory-partition option") + self.assertTrue('Zone %s created successfully' % zone in out, + "Unexpected output: %s") + + result, out, err = self.runsubcmd("dns", + "zoneinfo", + os.environ["SERVER"], + zone, + self.creds_string) + self.assertCmdSuccess(result, out, err) + self.assertTrue("DNS_DP_DOMAIN_DEFAULT" in out, + "Missing DNS_DP_DOMAIN_DEFAULT flag") + + result, out, err = self.runsubcmd("dns", + "zonedelete", + os.environ["SERVER"], + zone, + self.creds_string) + self.assertCmdSuccess(result, out, err, + "Failed to delete zone in domain DNS directory " + "partition") + result, out, err = self.runsubcmd("dns", + "zonelist", + os.environ["SERVER"], + self.creds_string) + self.assertCmdSuccess(result, out, err, + "Failed to delete zone in domain DNS directory " + "partition") + self.assertTrue(zone not in out, + "Deleted zone still exists") + + def test_zonecreate_dns_forest_directory_partition(self): + zone = "test-dns-forest-dp-zone" + dns_dp_opt = "--dns-directory-partition=forest" + + result, out, err = self.runsubcmd("dns", + "zonecreate", + os.environ["SERVER"], + zone, + self.creds_string, + dns_dp_opt) + self.assertCmdSuccess(result, + out, + err, + "Failed to create zone with " + "--dns-directory-partition option") + self.assertTrue('Zone %s created successfully' % zone in out, + "Unexpected output: %s") + + result, out, err = self.runsubcmd("dns", + "zoneinfo", + os.environ["SERVER"], + zone, + self.creds_string) + self.assertCmdSuccess(result, out, err) + self.assertTrue("DNS_DP_FOREST_DEFAULT" in out, + "Missing DNS_DP_FOREST_DEFAULT flag") + + result, out, err = self.runsubcmd("dns", + "zonedelete", + os.environ["SERVER"], + zone, + self.creds_string) + self.assertCmdSuccess(result, out, err, + "Failed to delete zone in forest DNS directory " + "partition") + + result, out, err = self.runsubcmd("dns", + "zonelist", + os.environ["SERVER"], + self.creds_string) + self.assertCmdSuccess(result, out, err, + "Failed to delete zone in forest DNS directory " + "partition") + self.assertTrue(zone not in out, + "Deleted zone still exists") diff --git a/python/samba/tests/samba_tool/domain_auth_policy.py b/python/samba/tests/samba_tool/domain_auth_policy.py new file mode 100644 index 0000000..1854037 --- /dev/null +++ b/python/samba/tests/samba_tool/domain_auth_policy.py @@ -0,0 +1,1517 @@ +# Unix SMB/CIFS implementation. +# +# Tests for samba-tool domain auth policy command +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import json +from optparse import OptionValueError +from unittest.mock import patch + +from samba.dcerpc import security +from samba.ndr import ndr_pack, ndr_unpack +from samba.netcmd.domain.models.exceptions import ModelError +from samba.samdb import SamDB +from samba.sd_utils import SDUtils + +from .silo_base import SiloTest + + +class AuthPolicyCmdTestCase(SiloTest): + + def test_list(self): + """Test listing authentication policies in list format.""" + result, out, err = self.runcmd("domain", "auth", "policy", "list") + self.assertIsNone(result, msg=err) + + expected_policies = ["User Policy", "Service Policy", "Computer Policy"] + + for policy in expected_policies: + self.assertIn(policy, out) + + def test_list__json(self): + """Test listing authentication policies in JSON format.""" + result, out, err = self.runcmd("domain", "auth", "policy", + "list", "--json") + self.assertIsNone(result, msg=err) + + # we should get valid json + policies = json.loads(out) + + expected_policies = ["User Policy", "Service Policy", "Computer Policy"] + + for name in expected_policies: + policy = policies[name] + self.assertIn("name", policy) + self.assertIn("msDS-AuthNPolicy", list(policy["objectClass"])) + self.assertIn("msDS-AuthNPolicyEnforced", policy) + self.assertIn("msDS-StrongNTLMPolicy", policy) + self.assertIn("objectGUID", policy) + + def test_view(self): + """Test viewing a single authentication policy.""" + result, out, err = self.runcmd("domain", "auth", "policy", "view", + "--name", "User Policy") + self.assertIsNone(result, msg=err) + + # we should get valid json + policy = json.loads(out) + + # check a few fields only + self.assertEqual(policy["cn"], "User Policy") + self.assertEqual(policy["msDS-AuthNPolicyEnforced"], True) + + def test_view__notfound(self): + """Test viewing an authentication policy that doesn't exist.""" + result, out, err = self.runcmd("domain", "auth", "policy", "view", + "--name", "doesNotExist") + self.assertEqual(result, -1) + self.assertIn("Authentication policy doesNotExist not found.", err) + + def test_view__name_required(self): + """Test view authentication policy without --name argument.""" + result, out, err = self.runcmd("domain", "auth", "policy", "view") + self.assertEqual(result, -1) + self.assertIn("Argument --name is required.", err) + + def test_create__success(self): + """Test creating a new authentication policy.""" + name = self.unique_name() + + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name) + self.assertIsNone(result, msg=err) + + # Check policy that was created + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + self.assertEqual(str(policy["msDS-AuthNPolicyEnforced"]), "TRUE") + + def test_create__description(self): + """Test creating a new authentication policy with description set.""" + name = self.unique_name() + + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--description", "Custom Description") + self.assertIsNone(result, msg=err) + + # Check policy description + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + self.assertEqual(str(policy["description"]), "Custom Description") + + def test_create__user_tgt_lifetime_mins(self): + """Test create a new authentication policy with --user-tgt-lifetime-mins. + + Also checks the upper and lower bounds are handled. + """ + name = self.unique_name() + + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--user-tgt-lifetime-mins", "60") + self.assertIsNone(result, msg=err) + + # Check policy fields. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + self.assertEqual(str(policy["msDS-UserTGTLifetime"]), "60") + + # check lower bounds (45) + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name + "Lower", + "--user-tgt-lifetime-mins", "44") + self.assertEqual(result, -1) + self.assertIn("--user-tgt-lifetime-mins must be between 45 and 2147483647", + err) + + # check upper bounds (2147483647) + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name + "Upper", + "--user-tgt-lifetime-mins", "2147483648") + self.assertEqual(result, -1) + self.assertIn("--user-tgt-lifetime-mins must be between 45 and 2147483647", + err) + + def test_create__user_allowed_to_authenticate_from_device_group(self): + """Tests the --user-allowed-to-authenticate-from-device-group shortcut.""" + name = self.unique_name() + expected = "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of_any {SID(%s)}))" % ( + self.device_group.object_sid) + + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--user-allowed-to-authenticate-from-device-group", + self.device_group.name) + self.assertIsNone(result, msg=err) + + # Check policy fields. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + + # Check generated SDDL. + desc = policy["msDS-UserAllowedToAuthenticateFrom"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual(sddl, expected) + + def test_create__user_allowed_to_authenticate_from_device_silo(self): + """Tests the --user-allowed-to-authenticate-from-device-silo shortcut.""" + name = self.unique_name() + + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--user-allowed-to-authenticate-from-device-silo", + "Developers") + self.assertIsNone(result, msg=err) + + # Check policy fields. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + + # Check generated SDDL. + desc = policy["msDS-UserAllowedToAuthenticateFrom"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual( + sddl, + 'O:SYG:SYD:(XA;OICI;CR;;;WD;(@USER.ad://ext/AuthenticationSilo == "Developers"))') + + def test_create__user_allowed_to_authenticate_to_by_group(self): + """Tests the --user-allowed-to-authenticate-to-by-group shortcut.""" + name = self.unique_name() + expected = "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of_any {SID(%s)}))" % ( + self.device_group.object_sid) + + # Create a user with authenticate to by group attribute. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + result, out, err = self.runcmd( + "domain", "auth", "policy", "create", "--name", name, + "--user-allowed-to-authenticate-to-by-group", + self.device_group.name) + self.assertIsNone(result, msg=err) + + # Check user allowed to authenticate to field was modified. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + desc = policy["msDS-UserAllowedToAuthenticateTo"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual(sddl, expected) + + def test_create__user_allowed_to_authenticate_to_by_silo(self): + """Tests the --user-allowed-to-authenticate-to-by-silo shortcut.""" + name = self.unique_name() + expected = ('O:SYG:SYD:(XA;OICI;CR;;;WD;(@USER.ad://ext/' + 'AuthenticationSilo == "QA"))') + + # Create a user with authenticate to by silo attribute. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + result, out, err = self.runcmd( + "domain", "auth", "policy", "create", "--name", name, + "--user-allowed-to-authenticate-to-by-silo", "QA") + self.assertIsNone(result, msg=err) + + # Check user allowed to authenticate to field was modified. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + desc = policy["msDS-UserAllowedToAuthenticateTo"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual(sddl, expected) + + def test_create__service_tgt_lifetime_mins(self): + """Test create a new authentication policy with --service-tgt-lifetime-mins. + + Also checks the upper and lower bounds are handled. + """ + name = self.unique_name() + + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--service-tgt-lifetime-mins", "60") + self.assertIsNone(result, msg=err) + + # Check policy fields. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + self.assertEqual(str(policy["msDS-ServiceTGTLifetime"]), "60") + + # check lower bounds (45) + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--service-tgt-lifetime-mins", "44") + self.assertEqual(result, -1) + self.assertIn("--service-tgt-lifetime-mins must be between 45 and 2147483647", + err) + + # check upper bounds (2147483647) + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--service-tgt-lifetime-mins", "2147483648") + self.assertEqual(result, -1) + self.assertIn("--service-tgt-lifetime-mins must be between 45 and 2147483647", + err) + + def test_create__service_allowed_to_authenticate_from_device_group(self): + """Tests the --service-allowed-to-authenticate-from-device-group shortcut.""" + name = self.unique_name() + expected = "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of_any {SID(%s)}))" % ( + self.device_group.object_sid) + + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--service-allowed-to-authenticate-from-device-group", + self.device_group.name) + self.assertIsNone(result, msg=err) + + # Check policy fields. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + + # Check generated SDDL. + desc = policy["msDS-ServiceAllowedToAuthenticateFrom"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual(sddl, expected) + + def test_create__service_allowed_to_authenticate_from_device_silo(self): + """Tests the --service-allowed-to-authenticate-from-device-silo shortcut.""" + name = self.unique_name() + + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--service-allowed-to-authenticate-from-device-silo", + "Managers") + self.assertIsNone(result, msg=err) + + # Check policy fields. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + desc = policy["msDS-ServiceAllowedToAuthenticateFrom"][0] + + # Check generated SDDL. + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual( + sddl, + 'O:SYG:SYD:(XA;OICI;CR;;;WD;(@USER.ad://ext/AuthenticationSilo == "Managers"))') + + def test_create__service_allowed_to_authenticate_to_by_group(self): + """Tests the --service-allowed-to-authenticate-to-by-group shortcut.""" + name = self.unique_name() + expected = "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of_any {SID(%s)}))" % ( + self.device_group.object_sid) + + # Create a user with authenticate to by group attribute. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + result, out, err = self.runcmd( + "domain", "auth", "policy", "create", "--name", name, + "--service-allowed-to-authenticate-to-by-group", + self.device_group.name) + self.assertIsNone(result, msg=err) + + # Check user allowed to authenticate to field was modified. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + desc = policy["msDS-ServiceAllowedToAuthenticateTo"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual(sddl, expected) + + def test_create__service_allowed_to_authenticate_to_by_silo(self): + """Tests the --service-allowed-to-authenticate-to-by-silo shortcut.""" + name = self.unique_name() + expected = ('O:SYG:SYD:(XA;OICI;CR;;;WD;(@USER.ad://ext/' + 'AuthenticationSilo == "Managers"))') + + # Create a user with authenticate to by silo attribute. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + result, out, err = self.runcmd( + "domain", "auth", "policy", "create", "--name", name, + "--service-allowed-to-authenticate-to-by-silo", "Managers") + self.assertIsNone(result, msg=err) + + # Check user allowed to authenticate to field was modified. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + desc = policy["msDS-ServiceAllowedToAuthenticateTo"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual(sddl, expected) + + def test_create__computer_tgt_lifetime_mins(self): + """Test create a new authentication policy with --computer-tgt-lifetime-mins. + + Also checks the upper and lower bounds are handled. + """ + name = self.unique_name() + + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--computer-tgt-lifetime-mins", "60") + self.assertIsNone(result, msg=err) + + # Check policy fields. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + self.assertEqual(str(policy["msDS-ComputerTGTLifetime"]), "60") + + # check lower bounds (45) + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name + "Lower", + "--computer-tgt-lifetime-mins", "44") + self.assertEqual(result, -1) + self.assertIn("--computer-tgt-lifetime-mins must be between 45 and 2147483647", + err) + + # check upper bounds (2147483647) + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name + "Upper", + "--computer-tgt-lifetime-mins", "2147483648") + self.assertEqual(result, -1) + self.assertIn("--computer-tgt-lifetime-mins must be between 45 and 2147483647", + err) + + def test_create__computer_allowed_to_authenticate_to_by_group(self): + """Tests the --computer-allowed-to-authenticate-to-by-group shortcut.""" + name = self.unique_name() + expected = "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of_any {SID(%s)}))" % ( + self.device_group.object_sid) + + # Create a user with authenticate to by group attribute. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + result, out, err = self.runcmd( + "domain", "auth", "policy", "create", "--name", name, + "--computer-allowed-to-authenticate-to-by-group", + self.device_group.name) + self.assertIsNone(result, msg=err) + + # Check user allowed to authenticate to field was modified. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + desc = policy["msDS-ComputerAllowedToAuthenticateTo"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual(sddl, expected) + + def test_create__computer_allowed_to_authenticate_to_by_silo(self): + """Tests the --computer-allowed-to-authenticate-to-by-silo shortcut.""" + name = self.unique_name() + expected = ('O:SYG:SYD:(XA;OICI;CR;;;WD;(@USER.ad://ext/' + 'AuthenticationSilo == "QA"))') + + # Create a user with authenticate to by silo attribute. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + result, out, err = self.runcmd( + "domain", "auth", "policy", "create", "--name", name, + "--computer-allowed-to-authenticate-to-by-silo", "QA") + self.assertIsNone(result, msg=err) + + # Check user allowed to authenticate to field was modified. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + desc = policy["msDS-ComputerAllowedToAuthenticateTo"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual(sddl, expected) + + def test_create__valid_sddl(self): + """Test creating a new authentication policy with valid SDDL in a field.""" + name = self.unique_name() + expected = "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of {SID(AO)}))" + + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--user-allowed-to-authenticate-from", + expected) + self.assertIsNone(result, msg=err) + + # Check policy fields. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + desc = policy["msDS-UserAllowedToAuthenticateFrom"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual(sddl, expected) + + def test_create__invalid_sddl(self): + """Test creating a new authentication policy with invalid SDDL in a field.""" + name = self.unique_name() + + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--user-allowed-to-authenticate-from", + "*INVALID SDDL*") + + self.assertEqual(result, -1) + self.assertIn("Unable to parse SDDL", err) + self.assertIn(" *INVALID SDDL*\n ^\n expected '[OGDS]:' section start ", err) + + def test_create__invalid_sddl_conditional_ace(self): + """Test creating a new authentication policy with invalid SDDL in a field.""" + sddl = "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of {secret club}))" + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", "invalidSDDLPolicy2", + "--user-allowed-to-authenticate-from", + sddl) + self.assertEqual(result, -1) + self.assertIn("Unable to parse SDDL", err) + self.assertIn(sddl, err) + self.assertIn(f"\n{'^':>41}", err) + self.assertIn("unexpected byte 0x73 's' parsing literal", err) + self.assertNotIn(" File ", err) + + def test_create__invalid_sddl_conditional_ace_non_ascii(self): + """Test creating a new authentication policy with invalid SDDL in a field.""" + sddl = 'O:SYG:SYD:(XA;OICI;CR;;;WD;(@User.āāēē == "łē¶ŧ¹⅓þōīŋ“đ¢ð»" && Member_of {secret club}))' + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", "invalidSDDLPolicy2", + "--user-allowed-to-authenticate-from", + sddl) + self.assertEqual(result, -1) + self.assertIn("Unable to parse SDDL", err) + self.assertIn(sddl, err) + self.assertIn(f"\n{'^':>76}\n", err) + self.assertIn(" unexpected byte 0x73 's' parsing literal", err) + self.assertNotIn(" File ", err) + + def test_create__invalid_sddl_normal_ace(self): + """Test creating a new authentication policy with invalid SDDL in a field.""" + sddl = "O:SYG:SYD:(A;;;;ZZ)(XA;OICI;CR;;;WD;(Member_of {WD}))" + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", "invalidSDDLPolicy3", + "--user-allowed-to-authenticate-from", + sddl) + self.assertEqual(result, -1) + self.assertIn("Unable to parse SDDL", err) + self.assertIn(sddl, err) + self.assertIn(f"\n{'^':>13}", err) + self.assertIn("\n malformed ACE with only 4 ';'\n", err) + self.assertNotIn(" File ", err) # traceback marker + + def test_create__device_attribute_in_sddl_allowed_to(self): + """Test creating a new authentication policy that uses + user-allowed-to-authenticate-to with a device attribute.""" + + sddl = 'O:SYG:SYD:(XA;OICI;CR;;;WD;(@Device.claim == "foo"))' + + name = self.unique_name() + self.addCleanup(self.delete_authentication_policy, name=name) + result, _, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--user-allowed-to-authenticate-to", + sddl) + self.assertIsNone(result, msg=err) + + def test_create__device_operator_in_sddl_allowed_to(self): + """Test creating a new authentication policy that uses + user-allowed-to-authenticate-to with a device operator.""" + + sddl = 'O:SYG:SYD:(XA;OICI;CR;;;WD;(Not_Device_Member_of {SID(WD)}))' + + name = self.unique_name() + self.addCleanup(self.delete_authentication_policy, name=name) + result, _, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--user-allowed-to-authenticate-to", + sddl) + self.assertIsNone(result, msg=err) + + def test_create__device_attribute_in_sddl_allowed_from(self): + """Test creating a new authentication policy that uses + user-allowed-to-authenticate-from with a device attribute.""" + + sddl = 'O:SYG:SYD:(XA;OICI;CR;;;WD;(@Device.claim == "foo"))' + + name = self.unique_name() + result, _, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--user-allowed-to-authenticate-from", + sddl) + self.assertEqual(result, -1) + self.assertIn("Unable to parse SDDL", err) + self.assertIn(sddl, err) + self.assertIn(f"\n{'^':>31}\n", err) + self.assertIn(" a device attribute is not applicable in this context " + "(did you intend a user attribute?)", + err) + self.assertNotIn(" File ", err) + + def test_create__device_operator_in_sddl_allowed_from(self): + """Test creating a new authentication policy that uses + user-allowed-to-authenticate-from with a device operator.""" + + sddl = 'O:SYG:SYD:(XA;OICI;CR;;;WD;(Not_Device_Member_of {SID(WD)}))' + + name = self.unique_name() + result, _, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--user-allowed-to-authenticate-from", + sddl) + self.assertEqual(result, -1) + self.assertIn("Unable to parse SDDL", err) + self.assertIn(sddl, err) + self.assertIn(f"\n{'^':>30}\n", err) + self.assertIn(" a device‐relative expression will never evaluate to " + "true in this context (did you intend a user‐relative " + "expression?)", + err) + self.assertNotIn(" File ", err) + + def test_create__device_attribute_in_sddl_already_exists(self): + """Test modifying an existing authentication policy that uses + user-allowed-to-authenticate-from with a device attribute.""" + + # The SDDL refers to ‘Device.claim’. + sddl = 'O:SYG:SYD:(XA;OICI;CR;;;WD;(@Device.claim == "foo"))' + domain_sid = security.dom_sid(self.samdb.get_domain_sid()) + descriptor = security.descriptor.from_sddl(sddl, domain_sid) + + # Manually create an authentication policy that refers to a device + # attribute. + + name = self.unique_name() + dn = self.get_authn_policies_dn() + dn.add_child(f"CN={name}") + message = { + 'dn': dn, + 'msDS-AuthNPolicyEnforced': b'TRUE', + 'objectClass': b'msDS-AuthNPolicy', + 'msDS-UserAllowedToAuthenticateFrom': ndr_pack(descriptor), + } + + self.addCleanup(self.delete_authentication_policy, name=name) + self.samdb.add(message) + + # Change the policy description. This should succeed, in spite of the + # policy’s referring to a device attribute when it shouldn’t. + result, _, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--description", "NewDescription") + self.assertIsNone(result, msg=err) + + def test_create__already_exists(self): + """Test creating a new authentication policy that already exists.""" + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", "User Policy") + self.assertEqual(result, -1) + self.assertIn("Authentication policy User Policy already exists", err) + + def test_create__name_missing(self): + """Test create authentication policy without --name argument.""" + result, out, err = self.runcmd("domain", "auth", "policy", "create") + self.assertEqual(result, -1) + self.assertIn("Argument --name is required.", err) + + def test_create__audit(self): + """Test create authentication policy with --audit flag.""" + name = self.unique_name() + + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--audit") + self.assertIsNone(result, msg=err) + + # fetch and check policy + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["msDS-AuthNPolicyEnforced"]), "FALSE") + + def test_create__enforce(self): + """Test create authentication policy with --enforce flag.""" + name = self.unique_name() + + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--enforce") + self.assertIsNone(result, msg=err) + + # fetch and check policy + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["msDS-AuthNPolicyEnforced"]), "TRUE") + + def test_create__audit_enforce_together(self): + """Test create auth policy using both --audit and --enforce.""" + name = self.unique_name() + + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--audit", "--enforce") + + self.assertEqual(result, -1) + self.assertIn("--audit and --enforce cannot be used together.", err) + + def test_create__protect_unprotect_together(self): + """Test create authentication policy using --protect and --unprotect.""" + name = self.unique_name() + + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--protect", "--unprotect") + + self.assertEqual(result, -1) + self.assertIn("--protect and --unprotect cannot be used together.", err) + + def test_create__user_allowed_to_authenticate_from_repeated(self): + """Test repeating similar arguments doesn't make sense to use together. + + --user-allowed-to-authenticate-from + --user-allowed-to-authenticate-from-device-silo + """ + sddl = 'O:SYG:SYD:(XA;OICI;CR;;;WD;(@USER.ad://ext/AuthenticationSilo == "Developers"))' + name = self.unique_name() + + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--user-allowed-to-authenticate-from", + sddl, + "--user-allowed-to-authenticate-from-device-silo", + "Managers") + + self.assertEqual(result, -1) + self.assertIn("--user-allowed-to-authenticate-from argument repeated 2 times.", err) + + def test_create__user_allowed_to_authenticate_to_repeated(self): + """Test repeating similar arguments doesn't make sense to use together. + + --user-allowed-to-authenticate-to + --user-allowed-to-authenticate-to-by-silo + """ + sddl = 'O:SYG:SYD:(XA;OICI;CR;;;WD;(@USER.ad://ext/AuthenticationSilo == "Developers"))' + name = self.unique_name() + + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--user-allowed-to-authenticate-to", + sddl, + "--user-allowed-to-authenticate-to-by-silo", + "Managers") + + self.assertEqual(result, -1) + self.assertIn("--user-allowed-to-authenticate-to argument repeated 2 times.", err) + + def test_create__service_allowed_to_authenticate_from_repeated(self): + """Test repeating similar arguments doesn't make sense to use together. + + --service-allowed-to-authenticate-from + --service-allowed-to-authenticate-from-device-silo + """ + sddl = 'O:SYG:SYD:(XA;OICI;CR;;;WD;(@USER.ad://ext/AuthenticationSilo == "Managers"))' + name = self.unique_name() + + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--service-allowed-to-authenticate-from", + sddl, + "--service-allowed-to-authenticate-from-device-silo", + "QA") + + self.assertEqual(result, -1) + self.assertIn("--service-allowed-to-authenticate-from argument repeated 2 times.", err) + + def test_create__service_allowed_to_authenticate_to_repeated(self): + """Test repeating similar arguments doesn't make sense to use together. + + --service-allowed-to-authenticate-to + --service-allowed-to-authenticate-to-by-silo + """ + sddl = 'O:SYG:SYD:(XA;OICI;CR;;;WD;(@USER.ad://ext/AuthenticationSilo == "Managers"))' + name = self.unique_name() + + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--service-allowed-to-authenticate-to", + sddl, + "--service-allowed-to-authenticate-to-by-silo", + "QA") + + self.assertEqual(result, -1) + self.assertIn("--service-allowed-to-authenticate-to argument repeated 2 times.", err) + + def test_create__computer_allowed_to_authenticate_to_repeated(self): + """Test repeating similar arguments doesn't make sense to use together. + + --computer-allowed-to-authenticate-to + --computer-allowed-to-authenticate-to-by-silo + """ + sddl = 'O:SYG:SYD:(XA;OICI;CR;;;WD;(@USER.ad://ext/AuthenticationSilo == "Managers"))' + name = self.unique_name() + + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--computer-allowed-to-authenticate-to", + sddl, + "--computer-allowed-to-authenticate-to-by-silo", + "QA") + + self.assertEqual(result, -1) + self.assertIn("--computer-allowed-to-authenticate-to argument repeated 2 times.", err) + + def test_create__fails(self): + """Test creating an authentication policy, but it fails.""" + name = self.unique_name() + + # Raise ModelError when ldb.add() is called. + with patch.object(SamDB, "add") as add_mock: + add_mock.side_effect = ModelError("Custom error message") + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name) + self.assertEqual(result, -1) + self.assertIn("Custom error message", err) + + def test_modify__description(self): + """Test modifying an authentication policy description.""" + name = self.unique_name() + + # Create a policy to modify for this test. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + self.runcmd("domain", "auth", "policy", "create", "--name", name) + + # Change the policy description. + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--description", "NewDescription") + self.assertIsNone(result, msg=err) + + # Verify fields were changed. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["description"]), "NewDescription") + + def test_modify__strong_ntlm_policy(self): + """Test modify strong ntlm policy on the authentication policy.""" + name = self.unique_name() + + # Create a policy to modify for this test. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + self.runcmd("domain", "auth", "policy", "create", "--name", name) + + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--strong-ntlm-policy", "Required") + self.assertIsNone(result, msg=err) + + # Verify fields were changed. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["msDS-StrongNTLMPolicy"]), "2") + + # Check an invalid choice. + with self.assertRaises((OptionValueError, SystemExit)): + self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--strong-ntlm-policy", "Invalid") + + # It is difficult to test the error message text for invalid + # choices because inside optparse it will raise OptionValueError + # followed by raising SystemExit(2). + + def test_modify__user_tgt_lifetime_mins(self): + """Test modifying an authentication policy --user-tgt-lifetime-mins. + + This includes checking the upper and lower bounds. + """ + name = self.unique_name() + + # Create a policy to modify for this test. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + self.runcmd("domain", "auth", "policy", "create", "--name", name) + + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--user-tgt-lifetime-mins", "120") + self.assertIsNone(result, msg=err) + + # Verify field was changed. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["msDS-UserTGTLifetime"]), "120") + + # check lower bounds (45) + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name + "Lower", + "--user-tgt-lifetime-mins", "44") + self.assertEqual(result, -1) + self.assertIn("--user-tgt-lifetime-mins must be between 45 and 2147483647", + err) + + # check upper bounds (2147483647) + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name + "Upper", + "--user-tgt-lifetime-mins", "2147483648") + self.assertEqual(result, -1) + self.assertIn("--user-tgt-lifetime-mins must be between 45 and 2147483647", + err) + + def test_modify__service_tgt_lifetime_mins(self): + """Test modifying an authentication policy --service-tgt-lifetime-mins. + + This includes checking the upper and lower bounds. + """ + name = self.unique_name() + + # Create a policy to modify for this test. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + self.runcmd("domain", "auth", "policy", "create", "--name", name) + + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--service-tgt-lifetime-mins", "120") + self.assertIsNone(result, msg=err) + + # Verify field was changed. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["msDS-ServiceTGTLifetime"]), "120") + + # check lower bounds (45) + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name + "Lower", + "--service-tgt-lifetime-mins", "44") + self.assertEqual(result, -1) + self.assertIn("--service-tgt-lifetime-mins must be between 45 and 2147483647", + err) + + # check upper bounds (2147483647) + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name + "Upper", + "--service-tgt-lifetime-mins", "2147483648") + self.assertEqual(result, -1) + self.assertIn("--service-tgt-lifetime-mins must be between 45 and 2147483647", + err) + + def test_modify__computer_tgt_lifetime_mins(self): + """Test modifying an authentication policy --computer-tgt-lifetime-mins. + + This includes checking the upper and lower bounds. + """ + name = self.unique_name() + + # Create a policy to modify for this test. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + self.runcmd("domain", "auth", "policy", "create", "--name", name) + + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--computer-tgt-lifetime-mins", "120") + self.assertIsNone(result, msg=err) + + # Verify field was changed. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["msDS-ComputerTGTLifetime"]), "120") + + # check lower bounds (45) + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name + "Lower", + "--computer-tgt-lifetime-mins", "44") + self.assertEqual(result, -1) + self.assertIn("--computer-tgt-lifetime-mins must be between 45 and 2147483647", + err) + + # check upper bounds (2147483647) + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name + "Upper", + "--computer-tgt-lifetime-mins", "2147483648") + self.assertEqual(result, -1) + self.assertIn("--computer-tgt-lifetime-mins must be between 45 and 2147483647", + err) + + def test_modify__user_allowed_to_authenticate_from(self): + """Modify authentication policy user allowed to authenticate from.""" + name = self.unique_name() + expected = "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of {SID(AO)}))" + + # Create a policy to modify for this test. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + self.runcmd("domain", "auth", "policy", "create", "--name", name) + + # Modify user allowed to authenticate from field + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--user-allowed-to-authenticate-from", + expected) + self.assertIsNone(result, msg=err) + + # Check user allowed to authenticate from field was modified. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + desc = policy["msDS-UserAllowedToAuthenticateFrom"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual(sddl, expected) + + def test_modify__user_allowed_to_authenticate_from_device_group(self): + """Test the --user-allowed-to-authenticate-from-device-group shortcut.""" + name = self.unique_name() + expected = "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of_any {SID(%s)}))" % ( + self.device_group.object_sid) + + # Create a policy to modify for this test. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + self.runcmd("domain", "auth", "policy", "create", "--name", name) + + # Modify user allowed to authenticate from silo field + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--user-allowed-to-authenticate-from-device-group", + self.device_group.name) + self.assertIsNone(result, msg=err) + + # Check generated SDDL. + policy = self.get_authentication_policy(name) + desc = policy["msDS-UserAllowedToAuthenticateFrom"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual(sddl, expected) + + def test_modify__user_allowed_to_authenticate_from_device_silo(self): + """Test the --user-allowed-to-authenticate-from-device-silo shortcut.""" + name = self.unique_name() + + # Create a policy to modify for this test. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + self.runcmd("domain", "auth", "policy", "create", "--name", name) + + # Modify user allowed to authenticate from silo field + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--user-allowed-to-authenticate-from-device-silo", + "QA") + self.assertIsNone(result, msg=err) + + # Check generated SDDL. + policy = self.get_authentication_policy(name) + desc = policy["msDS-UserAllowedToAuthenticateFrom"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual( + sddl, + 'O:SYG:SYD:(XA;OICI;CR;;;WD;(@USER.ad://ext/AuthenticationSilo == "QA"))') + + def test_modify__user_allowed_to_authenticate_to(self): + """Modify authentication policy user allowed to authenticate to.""" + name = self.unique_name() + expected = "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of {SID(AO)}))" + + # Create a policy to modify for this test. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + self.runcmd("domain", "auth", "policy", "create", "--name", name) + + # Modify user allowed to authenticate to field + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--user-allowed-to-authenticate-to", + expected) + self.assertIsNone(result, msg=err) + + # Check user allowed to authenticate to field was modified. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + desc = policy["msDS-UserAllowedToAuthenticateTo"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual(sddl, expected) + + def test_modify__user_allowed_to_authenticate_to_by_group(self): + """Tests the --user-allowed-to-authenticate-to-by-group shortcut.""" + name = self.unique_name() + expected = "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of_any {SID(%s)}))" % ( + self.device_group.object_sid) + + # Create a policy to modify for this test. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + self.runcmd("domain", "auth", "policy", "create", "--name", name) + + # Modify user allowed to authenticate to field + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--user-allowed-to-authenticate-to-by-group", + self.device_group.name) + self.assertIsNone(result, msg=err) + + # Check user allowed to authenticate to field was modified. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + desc = policy["msDS-UserAllowedToAuthenticateTo"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual(sddl, expected) + + def test_modify__user_allowed_to_authenticate_to_by_silo(self): + """Tests the --user-allowed-to-authenticate-to-by-silo shortcut.""" + name = self.unique_name() + expected = ('O:SYG:SYD:(XA;OICI;CR;;;WD;(@USER.ad://ext/' + 'AuthenticationSilo == "Developers"))') + + # Create a policy to modify for this test. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + self.runcmd("domain", "auth", "policy", "create", "--name", name) + + # Modify user allowed to authenticate to field + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--user-allowed-to-authenticate-to-by-silo", + "Developers") + self.assertIsNone(result, msg=err) + + # Check user allowed to authenticate to field was modified. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + desc = policy["msDS-UserAllowedToAuthenticateTo"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual(sddl, expected) + + def test_modify__service_allowed_to_authenticate_from(self): + """Modify authentication policy service allowed to authenticate from.""" + name = self.unique_name() + expected = "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of {SID(AO)}))" + + # Create a policy to modify for this test. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + self.runcmd("domain", "auth", "policy", "create", "--name", name) + + # Modify service allowed to authenticate from field + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--service-allowed-to-authenticate-from", + expected) + self.assertIsNone(result, msg=err) + + # Check service allowed to authenticate from field was modified. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + desc = policy["msDS-ServiceAllowedToAuthenticateFrom"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual(sddl, expected) + + def test_modify__service_allowed_to_authenticate_from_device_group(self): + """Test the --service-allowed-to-authenticate-from-device-group shortcut.""" + name = self.unique_name() + expected = "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of_any {SID(%s)}))" % ( + self.device_group.object_sid) + + # Create a policy to modify for this test. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + self.runcmd("domain", "auth", "policy", "create", "--name", name) + + # Modify user allowed to authenticate from silo field + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--service-allowed-to-authenticate-from-device-group", + self.device_group.name) + self.assertIsNone(result, msg=err) + + # Check generated SDDL. + policy = self.get_authentication_policy(name) + desc = policy["msDS-ServiceAllowedToAuthenticateFrom"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual(sddl, expected) + + def test_modify__service_allowed_to_authenticate_from_device_silo(self): + """Test the --service-allowed-to-authenticate-from-device-silo shortcut.""" + name = self.unique_name() + + # Create a policy to modify for this test. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + self.runcmd("domain", "auth", "policy", "create", "--name", name) + + # Modify user allowed to authenticate from silo field + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--service-allowed-to-authenticate-from-device-silo", + "Developers") + self.assertIsNone(result, msg=err) + + # Check generated SDDL. + policy = self.get_authentication_policy(name) + desc = policy["msDS-ServiceAllowedToAuthenticateFrom"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual( + sddl, + 'O:SYG:SYD:(XA;OICI;CR;;;WD;(@USER.ad://ext/AuthenticationSilo == "Developers"))') + + def test_modify__service_allowed_to_authenticate_to(self): + """Modify authentication policy service allowed to authenticate to.""" + name = self.unique_name() + expected = "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of {SID(AO)}))" + + # Create a policy to modify for this test. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + self.runcmd("domain", "auth", "policy", "create", "--name", name) + + # Modify service allowed to authenticate to field + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--service-allowed-to-authenticate-to", + expected) + self.assertIsNone(result, msg=err) + + # Check service allowed to authenticate to field was modified. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + desc = policy["msDS-ServiceAllowedToAuthenticateTo"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual(sddl, expected) + + def test_modify__service_allowed_to_authenticate_to_by_group(self): + """Tests the --service-allowed-to-authenticate-to-by-group shortcut.""" + name = self.unique_name() + expected = "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of_any {SID(%s)}))" % ( + self.device_group.object_sid) + + # Create a policy to modify for this test. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + self.runcmd("domain", "auth", "policy", "create", "--name", name) + + # Modify user allowed to authenticate to field + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--service-allowed-to-authenticate-to-by-group", + self.device_group.name) + self.assertIsNone(result, msg=err) + + # Check user allowed to authenticate to field was modified. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + desc = policy["msDS-ServiceAllowedToAuthenticateTo"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual(sddl, expected) + + def test_modify__service_allowed_to_authenticate_to_by_silo(self): + """Tests the --service-allowed-to-authenticate-to-by-silo shortcut.""" + name = self.unique_name() + expected = ('O:SYG:SYD:(XA;OICI;CR;;;WD;(@USER.ad://ext/' + 'AuthenticationSilo == "QA"))') + + # Create a policy to modify for this test. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + self.runcmd("domain", "auth", "policy", "create", "--name", name) + + # Modify user allowed to authenticate to field + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--service-allowed-to-authenticate-to-by-silo", + "QA") + self.assertIsNone(result, msg=err) + + # Check user allowed to authenticate to field was modified. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + desc = policy["msDS-ServiceAllowedToAuthenticateTo"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual(sddl, expected) + + def test_modify__computer_allowed_to_authenticate_to(self): + """Modify authentication policy computer allowed to authenticate to.""" + name = self.unique_name() + expected = "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of {SID(AO)}))" + + # Create a policy to modify for this test. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + self.runcmd("domain", "auth", "policy", "create", "--name", name) + + # Modify computer allowed to authenticate to field + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--computer-allowed-to-authenticate-to", + expected) + self.assertIsNone(result, msg=err) + + # Check computer allowed to authenticate to field was modified. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + desc = policy["msDS-ComputerAllowedToAuthenticateTo"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual(sddl, expected) + + def test_modify__computer_allowed_to_authenticate_to_by_group(self): + """Tests the --computer-allowed-to-authenticate-to-by-group shortcut.""" + name = self.unique_name() + expected = "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of_any {SID(%s)}))" % ( + self.device_group.object_sid) + + # Create a policy to modify for this test. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + self.runcmd("domain", "auth", "policy", "create", "--name", name) + + # Modify user allowed to authenticate to field + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--computer-allowed-to-authenticate-to-by-group", + self.device_group.name) + self.assertIsNone(result, msg=err) + + # Check user allowed to authenticate to field was modified. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + desc = policy["msDS-ComputerAllowedToAuthenticateTo"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual(sddl, expected) + + def test_modify__computer_allowed_to_authenticate_to_by_silo(self): + """Tests the --computer-allowed-to-authenticate-to-by-silo shortcut.""" + name = self.unique_name() + expected = ('O:SYG:SYD:(XA;OICI;CR;;;WD;(@USER.ad://ext/' + 'AuthenticationSilo == "QA"))') + + # Create a policy to modify for this test. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + self.runcmd("domain", "auth", "policy", "create", "--name", name) + + # Modify user allowed to authenticate to field + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--computer-allowed-to-authenticate-to-by-silo", + "QA") + self.assertIsNone(result, msg=err) + + # Check user allowed to authenticate to field was modified. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["cn"]), name) + desc = policy["msDS-ComputerAllowedToAuthenticateTo"][0] + sddl = ndr_unpack(security.descriptor, desc).as_sddl() + self.assertEqual(sddl, expected) + + def test_modify__name_missing(self): + """Test modify authentication but the --name argument is missing.""" + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--description", "NewDescription") + self.assertEqual(result, -1) + self.assertIn("Argument --name is required.", err) + + def test_modify__notfound(self): + """Test modify an authentication silo that doesn't exist.""" + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", "doesNotExist", + "--description", "NewDescription") + self.assertEqual(result, -1) + self.assertIn("Authentication policy doesNotExist not found.", err) + + def test_modify__audit_enforce(self): + """Test modify authentication policy using --audit and --enforce.""" + name = self.unique_name() + + # Create a policy to modify for this test. + self.addCleanup(self.delete_authentication_policy, + name=name, force=True) + self.runcmd("domain", "auth", "policy", "create", "--name", name) + + # Change to audit, the default is --enforce. + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--audit") + self.assertIsNone(result, msg=err) + + # Check that the policy was changed to --audit. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["msDS-AuthNPolicyEnforced"]), "FALSE") + + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--enforce") + self.assertIsNone(result, msg=err) + + # Check if the policy was changed back to --enforce. + policy = self.get_authentication_policy(name) + self.assertEqual(str(policy["msDS-AuthNPolicyEnforced"]), "TRUE") + + def test_modify__protect_unprotect(self): + """Test modify authentication policy using --protect and --unprotect.""" + name = self.unique_name() + + # Create a policy to modify for this test. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + self.runcmd("domain", "auth", "policy", "create", "--name", name) + + utils = SDUtils(self.samdb) + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--protect") + self.assertIsNone(result, msg=err) + + # Check that claim type was protected. + policy = self.get_authentication_policy(name) + desc = utils.get_sd_as_sddl(policy["dn"]) + self.assertIn("(D;;DTSD;;;WD)", desc) + + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", name, + "--unprotect") + self.assertIsNone(result, msg=err) + + # Check that claim type was unprotected. + policy = self.get_authentication_policy(name) + desc = utils.get_sd_as_sddl(policy["dn"]) + self.assertNotIn("(D;;DTSD;;;WD)", desc) + + def test_modify__audit_enforce_together(self): + """Test modify auth policy using both --audit and --enforce.""" + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", "User Policy", + "--audit", "--enforce") + self.assertEqual(result, -1) + self.assertIn("--audit and --enforce cannot be used together.", err) + + def test_modify__protect_unprotect_together(self): + """Test modify authentication policy using --protect and --unprotect.""" + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", "User Policy", + "--protect", "--unprotect") + self.assertEqual(result, -1) + self.assertIn("--protect and --unprotect cannot be used together.", err) + + def test_modify__fails(self): + """Test modifying an authentication policy, but it fails.""" + # Raise ModelError when ldb.add() is called. + with patch.object(SamDB, "modify") as modify_mock: + modify_mock.side_effect = ModelError("Custom error message") + result, out, err = self.runcmd("domain", "auth", "policy", "modify", + "--name", "User Policy", + "--description", "New description") + self.assertEqual(result, -1) + self.assertIn("Custom error message", err) + + def test_delete__success(self): + """Test deleting an authentication policy that is not protected.""" + # Create non-protected authentication policy. + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name=deleteTest") + self.assertIsNone(result, msg=err) + policy = self.get_authentication_policy("deleteTest") + self.assertIsNotNone(policy) + + # Do the deletion. + result, out, err = self.runcmd("domain", "auth", "policy", "delete", + "--name", "deleteTest") + self.assertIsNone(result, msg=err) + + # Authentication policy shouldn't exist anymore. + policy = self.get_authentication_policy("deleteTest") + self.assertIsNone(policy) + + def test_delete__protected(self): + """Test deleting a protected auth policy, with and without --force.""" + # Create protected authentication policy. + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name=deleteProtected", + "--protect") + self.assertIsNone(result, msg=err) + policy = self.get_authentication_policy("deleteProtected") + self.assertIsNotNone(policy) + + # Do the deletion. + result, out, err = self.runcmd("domain", "auth", "policy", "delete", + "--name=deleteProtected") + self.assertEqual(result, -1) + + # Authentication silo should still exist. + policy = self.get_authentication_policy("deleteProtected") + self.assertIsNotNone(policy) + + # Try a force delete instead. + result, out, err = self.runcmd("domain", "auth", "policy", "delete", + "--name=deleteProtected", "--force") + self.assertIsNone(result, msg=err) + + # Authentication silo shouldn't exist anymore. + policy = self.get_authentication_policy("deleteProtected") + self.assertIsNone(policy) + + def test_delete__notfound(self): + """Test deleting an authentication policy that doesn't exist.""" + result, out, err = self.runcmd("domain", "auth", "policy", "delete", + "--name", "doesNotExist") + self.assertEqual(result, -1) + self.assertIn("Authentication policy doesNotExist not found.", err) + + def test_delete__name_required(self): + """Test deleting an authentication policy without --name argument.""" + result, out, err = self.runcmd("domain", "auth", "policy", "delete") + self.assertEqual(result, -1) + self.assertIn("Argument --name is required.", err) + + def test_delete__force_fails(self): + """Test deleting an authentication policy with --force, but it fails.""" + name = self.unique_name() + + # Create protected authentication policy. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--protect") + self.assertIsNone(result, msg=err) + + # Policy exists + policy = self.get_authentication_policy(name) + self.assertIsNotNone(policy) + + # Try doing delete with --force. + # Patch SDUtils.dacl_delete_aces with a Mock that raises ModelError. + with patch.object(SDUtils, "dacl_delete_aces") as delete_mock: + delete_mock.side_effect = ModelError("Custom error message") + result, out, err = self.runcmd("domain", "auth", "policy", "delete", + "--name", name, + "--force") + self.assertEqual(result, -1) + self.assertIn("Custom error message", err) + + def test_delete__fails(self): + """Test deleting an authentication policy, but it fails.""" + name = self.unique_name() + + # Create regular authentication policy. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name) + self.assertIsNone(result, msg=err) + + # Policy exists + policy = self.get_authentication_policy(name) + self.assertIsNotNone(policy) + + # Raise ModelError when ldb.delete() is called. + with patch.object(SamDB, "delete") as delete_mock: + delete_mock.side_effect = ModelError("Custom error message") + result, out, err = self.runcmd("domain", "auth", "policy", "delete", + "--name", name) + self.assertEqual(result, -1) + self.assertIn("Custom error message", err) + + # When not using --force we get a hint. + self.assertIn("Try --force", err) + + def test_delete__protected_fails(self): + """Test deleting an authentication policy, but it fails.""" + name = self.unique_name() + + # Create protected authentication policy. + self.addCleanup(self.delete_authentication_policy, name=name, force=True) + result, out, err = self.runcmd("domain", "auth", "policy", "create", + "--name", name, + "--protect") + self.assertIsNone(result, msg=err) + + # Policy exists + policy = self.get_authentication_policy(name) + self.assertIsNotNone(policy) + + # Raise ModelError when ldb.delete() is called. + with patch.object(SamDB, "delete") as delete_mock: + delete_mock.side_effect = ModelError("Custom error message") + result, out, err = self.runcmd("domain", "auth", "policy", "delete", + "--name", name, + "--force") + self.assertEqual(result, -1) + self.assertIn("Custom error message", err) + + # When using --force we don't get the hint. + self.assertNotIn("Try --force", err) diff --git a/python/samba/tests/samba_tool/domain_auth_silo.py b/python/samba/tests/samba_tool/domain_auth_silo.py new file mode 100644 index 0000000..a1cd85c --- /dev/null +++ b/python/samba/tests/samba_tool/domain_auth_silo.py @@ -0,0 +1,618 @@ +# Unix SMB/CIFS implementation. +# +# Tests for samba-tool domain auth silo command +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import json +from unittest.mock import patch + +from samba.netcmd.domain.models.exceptions import ModelError +from samba.samdb import SamDB +from samba.sd_utils import SDUtils + +from .silo_base import SiloTest + + +class AuthSiloCmdTestCase(SiloTest): + + def test_list(self): + """Test listing authentication silos in list format.""" + result, out, err = self.runcmd("domain", "auth", "silo", "list") + self.assertIsNone(result, msg=err) + + expected_silos = ["Developers", "Managers", "QA"] + + for silo in expected_silos: + self.assertIn(silo, out) + + def test_list___json(self): + """Test listing authentication silos in JSON format.""" + result, out, err = self.runcmd("domain", "auth", "silo", + "list", "--json") + self.assertIsNone(result, msg=err) + + # we should get valid json + silos = json.loads(out) + + expected_silos = ["Developers", "Managers", "QA"] + + for name in expected_silos: + silo = silos[name] + self.assertIn("msDS-AuthNPolicySilo", list(silo["objectClass"])) + self.assertIn("description", silo) + self.assertIn("msDS-UserAuthNPolicy", silo) + self.assertIn("objectGUID", silo) + + def test_view(self): + """Test viewing a single authentication silo.""" + result, out, err = self.runcmd("domain", "auth", "silo", "view", + "--name", "Developers") + self.assertIsNone(result, msg=err) + + # we should get valid json + silo = json.loads(out) + + # check a few fields only + self.assertEqual(silo["cn"], "Developers") + self.assertEqual(silo["description"], + "Developers, Developers, Developers!") + + def test_view__notfound(self): + """Test viewing an authentication silo that doesn't exist.""" + result, out, err = self.runcmd("domain", "auth", "silo", "view", + "--name", "doesNotExist") + self.assertEqual(result, -1) + self.assertIn("Authentication silo doesNotExist not found.", err) + + def test_view__name_required(self): + """Test view authentication silo without --name argument.""" + result, out, err = self.runcmd("domain", "auth", "silo", "view") + self.assertEqual(result, -1) + self.assertIn("Argument --name is required.", err) + + def test_create__single_policy(self): + """Test creating a new authentication silo with a single policy.""" + name = self.unique_name() + + self.addCleanup(self.delete_authentication_silo, name=name, force=True) + result, out, err = self.runcmd("domain", "auth", "silo", "create", + "--name", name, + "--user-authentication-policy", "User Policy") + self.assertIsNone(result, msg=err) + + # Check silo that was created + silo = self.get_authentication_silo(name) + self.assertEqual(str(silo["cn"]), name) + self.assertIn("User Policy", str(silo["msDS-UserAuthNPolicy"])) + self.assertEqual(str(silo["msDS-AuthNPolicySiloEnforced"]), "TRUE") + + def test_create__multiple_policies(self): + """Test creating a new authentication silo with multiple policies.""" + name = self.unique_name() + + self.addCleanup(self.delete_authentication_silo, name=name, force=True) + result, out, err = self.runcmd("domain", "auth", "silo", "create", + "--name", name, + "--user-authentication-policy", + "User Policy", + "--service-authentication-policy", + "Service Policy", + "--computer-authentication-policy", + "Computer Policy") + self.assertIsNone(result, msg=err) + + # Check silo that was created. + silo = self.get_authentication_silo(name) + self.assertEqual(str(silo["cn"]), name) + self.assertIn("User Policy", str(silo["msDS-UserAuthNPolicy"])) + self.assertIn("Service Policy", str(silo["msDS-ServiceAuthNPolicy"])) + self.assertIn("Computer Policy", str(silo["msDS-ComputerAuthNPolicy"])) + self.assertEqual(str(silo["msDS-AuthNPolicySiloEnforced"]), "TRUE") + + def test_create__policy_dn(self): + """Test creating a new authentication silo when policy is a dn.""" + name = self.unique_name() + policy = self.get_authentication_policy("User Policy") + + self.addCleanup(self.delete_authentication_silo, name=name, force=True) + result, out, err = self.runcmd("domain", "auth", "silo", "create", + "--name", name, + "--user-authentication-policy", policy["dn"]) + self.assertIsNone(result, msg=err) + + # Check silo that was created + silo = self.get_authentication_silo(name) + self.assertEqual(str(silo["cn"]), name) + self.assertIn(str(policy["name"]), str(silo["msDS-UserAuthNPolicy"])) + self.assertEqual(str(silo["msDS-AuthNPolicySiloEnforced"]), "TRUE") + + def test_create__already_exists(self): + """Test creating a new authentication silo that already exists.""" + result, out, err = self.runcmd("domain", "auth", "silo", "create", + "--name", "Developers", + "--user-authentication-policy", "User Policy") + self.assertEqual(result, -1) + self.assertIn("Authentication silo Developers already exists.", err) + + def test_create__name_missing(self): + """Test create authentication silo without --name argument.""" + result, out, err = self.runcmd("domain", "auth", "silo", "create", + "--user-authentication-policy", "User Policy") + self.assertEqual(result, -1) + self.assertIn("Argument --name is required.", err) + + def test_create__audit(self): + """Test create authentication silo with --audit flag.""" + name = self.unique_name() + + self.addCleanup(self.delete_authentication_silo, name=name, force=True) + result, out, err = self.runcmd("domain", "auth", "silo", "create", + "--name", "auditPolicies", + "--user-authentication-policy", "User Policy", + "--name", name, + "--user-authentication-policy", "User Policy", + "--audit") + self.assertIsNone(result, msg=err) + + # fetch and check silo + silo = self.get_authentication_silo(name) + self.assertEqual(str(silo["msDS-AuthNPolicySiloEnforced"]), "FALSE") + + def test_create__enforce(self): + """Test create authentication silo with --enforce flag.""" + name = self.unique_name() + + self.addCleanup(self.delete_authentication_silo, name=name, force=True) + result, out, err = self.runcmd("domain", "auth", "silo", "create", + "--name", name, + "--user-authentication-policy", "User Policy", + "--enforce") + self.assertIsNone(result, msg=err) + + # fetch and check silo + silo = self.get_authentication_silo(name) + self.assertEqual(str(silo["msDS-AuthNPolicySiloEnforced"]), "TRUE") + + def test_create__audit_enforce_together(self): + """Test create authentication silo using both --audit and --enforce.""" + name = self.unique_name() + + result, out, err = self.runcmd("domain", "auth", "silo", "create", + "--name", name, + "--user-authentication-policy", "User Policy", + "--audit", "--enforce") + + self.assertEqual(result, -1) + self.assertIn("--audit and --enforce cannot be used together.", err) + + def test_create__protect_unprotect_together(self): + """Test create authentication silo using --protect and --unprotect.""" + name = self.unique_name() + + result, out, err = self.runcmd("domain", "auth", "silo", "create", + "--name", name, + "--user-authentication-policy", "User Policy", + "--protect", "--unprotect") + + self.assertEqual(result, -1) + self.assertIn("--protect and --unprotect cannot be used together.", err) + + def test_create__policy_notfound(self): + """Test create authentication silo with a policy that doesn't exist.""" + name = self.unique_name() + + result, out, err = self.runcmd("domain", "auth", "silo", "create", + "--name", name, + "--user-authentication-policy", "Invalid Policy") + + self.assertEqual(result, -1) + self.assertIn("Authentication policy Invalid Policy not found.", err) + + def test_create__fails(self): + """Test creating an authentication silo, but it fails.""" + name = self.unique_name() + + # Raise ModelError when ldb.add() is called. + with patch.object(SamDB, "add") as add_mock: + add_mock.side_effect = ModelError("Custom error message") + result, out, err = self.runcmd("domain", "auth", "silo", "create", + "--name", name, + "--user-authentication-policy", "User Policy") + self.assertEqual(result, -1) + self.assertIn("Custom error message", err) + + def test_modify__description(self): + """Test modify authentication silo changing the description field.""" + name = self.unique_name() + + # Create a silo to modify for this test. + self.addCleanup(self.delete_authentication_silo, name=name, force=True) + self.runcmd("domain", "auth", "silo", "create", "--name", name) + + result, out, err = self.runcmd("domain", "auth", "silo", "modify", + "--name", name, + "--description", "New Description") + self.assertIsNone(result, msg=err) + + # check new value + silo = self.get_authentication_silo(name) + self.assertEqual(str(silo["description"]), "New Description") + + def test_modify__audit_enforce(self): + """Test modify authentication silo setting --audit and --enforce.""" + name = self.unique_name() + + # Create a silo to modify for this test. + self.addCleanup(self.delete_authentication_silo, name=name, force=True) + self.runcmd("domain", "auth", "silo", "create", "--name", name) + + result, out, err = self.runcmd("domain", "auth", "silo", "modify", + "--name", name, + "--audit") + self.assertIsNone(result, msg=err) + + # Check silo is set to audit. + silo = self.get_authentication_silo(name) + self.assertEqual(str(silo["msDS-AuthNPolicySiloEnforced"]), "FALSE") + + result, out, err = self.runcmd("domain", "auth", "silo", "modify", + "--name", name, + "--enforce") + self.assertIsNone(result, msg=err) + + # Check is set to enforce. + silo = self.get_authentication_silo(name) + self.assertEqual(str(silo["msDS-AuthNPolicySiloEnforced"]), "TRUE") + + def test_modify__protect_unprotect(self): + """Test modify un-protecting and protecting an authentication silo.""" + name = self.unique_name() + + # Create a silo to modify for this test. + self.addCleanup(self.delete_authentication_silo, name=name, force=True) + self.runcmd("domain", "auth", "silo", "create", "--name", name) + + utils = SDUtils(self.samdb) + result, out, err = self.runcmd("domain", "auth", "silo", "modify", + "--name", name, + "--protect") + self.assertIsNone(result, msg=err) + + # Check that silo was protected. + silo = self.get_authentication_silo(name) + desc = utils.get_sd_as_sddl(silo["dn"]) + self.assertIn("(D;;DTSD;;;WD)", desc) + + result, out, err = self.runcmd("domain", "auth", "silo", "modify", + "--name", name, + "--unprotect") + self.assertIsNone(result, msg=err) + + # Check that silo was unprotected. + silo = self.get_authentication_silo(name) + desc = utils.get_sd_as_sddl(silo["dn"]) + self.assertNotIn("(D;;DTSD;;;WD)", desc) + + def test_modify__audit_enforce_together(self): + """Test modify silo doesn't allow both --audit and --enforce.""" + result, out, err = self.runcmd("domain", "auth", "silo", "modify", + "--name", "QA", + "--audit", "--enforce") + + self.assertEqual(result, -1) + self.assertIn("--audit and --enforce cannot be used together.", err) + + def test_modify__protect_unprotect_together(self): + """Test modify silo using both --protect and --unprotect.""" + result, out, err = self.runcmd("domain", "auth", "silo", "modify", + "--name", "Developers", + "--protect", "--unprotect") + self.assertEqual(result, -1) + self.assertIn("--protect and --unprotect cannot be used together.", err) + + def test_modify__notfound(self): + """Test modify an authentication silo that doesn't exist.""" + result, out, err = self.runcmd("domain", "auth", "silo", "modify", + "--name", "doesNotExist", + "--description=NewDescription") + self.assertEqual(result, -1) + self.assertIn("Authentication silo doesNotExist not found.", err) + + def test_modify__name_missing(self): + """Test modify authentication silo without --name argument.""" + result, out, err = self.runcmd("domain", "auth", "silo", "modify") + self.assertEqual(result, -1) + self.assertIn("Argument --name is required.", err) + + def test_modify__fails(self): + """Test modify authentication silo, but it fails.""" + # Raise ModelError when ldb.modify() is called. + with patch.object(SamDB, "modify") as add_mock: + add_mock.side_effect = ModelError("Custom error message") + result, out, err = self.runcmd("domain", "auth", "silo", "modify", + "--name", "Developers", + "--description", "Devs") + self.assertEqual(result, -1) + self.assertIn("Custom error message", err) + + def test_authentication_silo_delete(self): + """Test deleting an authentication silo that is not protected.""" + name = self.unique_name() + + # Create non-protected authentication silo. + result, out, err = self.runcmd("domain", "auth", "silo", "create", + "--name", name, + "--user-authentication-policy", "User Policy") + self.assertIsNone(result, msg=err) + silo = self.get_authentication_silo(name) + self.assertIsNotNone(silo) + + # Do the deletion. + result, out, err = self.runcmd("domain", "auth", "silo", "delete", + "--name", name) + self.assertIsNone(result, msg=err) + + # Authentication silo shouldn't exist anymore. + silo = self.get_authentication_silo(name) + self.assertIsNone(silo) + + def test_delete__protected(self): + """Test deleting a protected auth silo, with and without --force.""" + name = self.unique_name() + + # Create protected authentication silo. + result, out, err = self.runcmd("domain", "auth", "silo", "create", + "--name", name, + "--user-authentication-policy", "User Policy", + "--protect") + self.assertIsNone(result, msg=err) + silo = self.get_authentication_silo(name) + self.assertIsNotNone(silo) + + # Do the deletion. + result, out, err = self.runcmd("domain", "auth", "silo", "delete", + "--name", name) + self.assertEqual(result, -1) + + # Authentication silo should still exist. + silo = self.get_authentication_silo(name) + self.assertIsNotNone(silo) + + # Try a force delete instead. + result, out, err = self.runcmd("domain", "auth", "silo", "delete", + "--name", name, "--force") + self.assertIsNone(result, msg=err) + + # Authentication silo shouldn't exist anymore. + silo = self.get_authentication_silo(name) + self.assertIsNone(silo) + + def test_delete__notfound(self): + """Test deleting an authentication silo that doesn't exist.""" + result, out, err = self.runcmd("domain", "auth", "silo", "delete", + "--name", "doesNotExist") + self.assertEqual(result, -1) + self.assertIn("Authentication silo doesNotExist not found.", err) + + def test_delete__name_required(self): + """Test deleting an authentication silo without --name argument.""" + result, out, err = self.runcmd("domain", "auth", "silo", "delete") + self.assertEqual(result, -1) + self.assertIn("Argument --name is required.", err) + + def test_delete__force_fails(self): + """Test deleting an authentication silo with --force, but it fails.""" + name = self.unique_name() + + # Create protected authentication silo. + self.addCleanup(self.delete_authentication_silo, name=name, force=True) + result, out, err = self.runcmd("domain", "auth", "silo", "create", + "--name", name, + "--user-authentication-policy", "User Policy", + "--protect") + self.assertIsNone(result, msg=err) + + # Silo exists + silo = self.get_authentication_silo(name) + self.assertIsNotNone(silo) + + # Try doing delete with --force. + # Patch SDUtils.dacl_delete_aces with a Mock that raises ModelError. + with patch.object(SDUtils, "dacl_delete_aces") as delete_mock: + delete_mock.side_effect = ModelError("Custom error message") + result, out, err = self.runcmd("domain", "auth", "silo", "delete", + "--name", name, + "--force") + self.assertEqual(result, -1) + self.assertIn("Custom error message", err) + + def test_delete__fails(self): + """Test deleting an authentication silo, but it fails.""" + name = self.unique_name() + + # Create regular authentication silo. + self.addCleanup(self.delete_authentication_silo, name=name, force=True) + result, out, err = self.runcmd("domain", "auth", "silo", "create", + "--name", name, + "--user-authentication-policy", "User Policy") + self.assertIsNone(result, msg=err) + + # Silo exists + silo = self.get_authentication_silo(name) + self.assertIsNotNone(silo) + + # Raise ModelError when ldb.delete() is called. + with patch.object(SamDB, "delete") as delete_mock: + delete_mock.side_effect = ModelError("Custom error message") + result, out, err = self.runcmd("domain", "auth", "silo", "delete", + "--name", name) + self.assertEqual(result, -1) + self.assertIn("Custom error message", err) + + # When not using --force we get a hint. + self.assertIn("Try --force", err) + + def test_delete__protected_fails(self): + """Test deleting an authentication silo, but it fails.""" + name = self.unique_name() + + # Create protected authentication silo. + self.addCleanup(self.delete_authentication_silo, name=name, force=True) + result, out, err = self.runcmd("domain", "auth", "silo", "create", + "--name", name, + "--user-authentication-policy", "User Policy", + "--protect") + self.assertIsNone(result, msg=err) + + # Silo exists + silo = self.get_authentication_silo(name) + self.assertIsNotNone(silo) + + # Raise ModelError when ldb.delete() is called. + with patch.object(SamDB, "delete") as delete_mock: + delete_mock.side_effect = ModelError("Custom error message") + result, out, err = self.runcmd("domain", "auth", "silo", "delete", + "--name", name, + "--force") + self.assertEqual(result, -1) + self.assertIn("Custom error message", err) + + # When using --force we don't get the hint. + self.assertNotIn("Try --force", err) + + +class AuthSiloMemberCmdTestCase(SiloTest): + + def setUp(self): + super().setUp() + + # Create an organisational unit to test in. + self.ou = self.samdb.get_default_basedn() + self.ou.add_child("OU=Domain Auth Tests") + self.samdb.create_ou(self.ou) + self.addCleanup(self.samdb.delete, self.ou, ["tree_delete:1"]) + + # Grant member access to silos + self.grant_silo_access("Developers", "bob") + self.grant_silo_access("Developers", "jane") + self.grant_silo_access("Managers", "alice") + + def create_computer(self, name): + """Create a Computer and return the dn.""" + dn = f"CN={name},{self.ou}" + self.samdb.newcomputer(name, self.ou) + return dn + + def grant_silo_access(self, silo, member): + """Grant a member access to an authentication silo.""" + result, out, err = self.runcmd("domain", "auth", "silo", + "member", "grant", + "--name", silo, "--member", member) + + self.assertIsNone(result, msg=err) + self.assertIn( + f"User {member} granted access to the authentication silo {silo}", + out) + self.addCleanup(self.revoke_silo_access, silo, member) + + def revoke_silo_access(self, silo, member): + """Revoke a member from an authentication silo.""" + result, out, err = self.runcmd("domain", "auth", "silo", + "member", "revoke", + "--name", silo, "--member", member) + + self.assertIsNone(result, msg=err) + + def test_member_list(self): + """Test listing authentication policy members in list format.""" + alice = self.get_user("alice") + jane = self.get_user("jane") + bob = self.get_user("bob") + + result, out, err = self.runcmd("domain", "auth", "silo", + "member", "list", + "--name", "Developers") + + self.assertIsNone(result, msg=err) + self.assertIn(str(bob.dn), out) + self.assertIn(str(jane.dn), out) + self.assertNotIn(str(alice.dn), out) + + def test_member_list___json(self): + """Test listing authentication policy members list in json format.""" + alice = self.get_user("alice") + jane = self.get_user("jane") + bob = self.get_user("bob") + + result, out, err = self.runcmd("domain", "auth", "silo", + "member", "list", + "--name", "Developers", "--json") + + self.assertIsNone(result, msg=err) + members = json.loads(out) + members_dn = [member["dn"] for member in members] + self.assertIn(str(bob.dn), members_dn) + self.assertIn(str(jane.dn), members_dn) + self.assertNotIn(str(alice.dn), members_dn) + + def test_member_list__name_missing(self): + """Test list authentication policy members without the name argument.""" + result, out, err = self.runcmd("domain", "auth", "silo", + "member", "list") + + self.assertIsNotNone(result) + self.assertIn("Argument --name is required.", err) + + def test_member_grant__user(self): + """Test adding a user to an authentication silo.""" + self.grant_silo_access("Developers", "joe") + + # Check if member is in silo + user = self.get_user("joe") + silo = self.get_authentication_silo("Developers") + members = [str(member) for member in silo["msDS-AuthNPolicySiloMembers"]] + self.assertIn(str(user.dn), members) + + def test_member_grant__computer(self): + """Test adding a computer to an authentication silo""" + name = self.unique_name() + computer = self.create_computer(name) + silo = "Developers" + + # Don't use self.grant_silo_member as it will try to clean up the user. + result, out, err = self.runcmd("domain", "auth", "silo", + "member", "grant", + "--name", silo, + "--member", computer) + + self.assertIsNone(result, msg=err) + self.assertIn( + f"User {name}$ granted access to the authentication silo {silo} (unassigned).", + out) + + def test_member_grant__unknown_user(self): + """Test adding an unknown user to an authentication silo.""" + result, out, err = self.runcmd("domain", "auth", "silo", + "member", "grant", + "--name", "Developers", + "--member", "does_not_exist") + + self.assertIsNotNone(result) + self.assertIn("User does_not_exist not found.", err) diff --git a/python/samba/tests/samba_tool/domain_claim.py b/python/samba/tests/samba_tool/domain_claim.py new file mode 100644 index 0000000..96caacd --- /dev/null +++ b/python/samba/tests/samba_tool/domain_claim.py @@ -0,0 +1,608 @@ +# Unix SMB/CIFS implementation. +# +# Tests for samba-tool domain claim management +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import json +import os + +from ldb import SCOPE_ONELEVEL +from samba.sd_utils import SDUtils + +from .base import SambaToolCmdTest + +# List of claim value types we should expect to see. +VALUE_TYPES = [ + "Date Time", + "Multi-valued Choice", + "Multi-valued Text", + "Number", + "Ordered List", + "Single-valued Choice", + "Text", + "Yes/No" +] + +HOST = "ldap://{DC_SERVER}".format(**os.environ) +CREDS = "-U{DC_USERNAME}%{DC_PASSWORD}".format(**os.environ) + + +class BaseClaimCmdTest(SambaToolCmdTest): + """Base class for claim types and claim value types tests.""" + + @classmethod + def setUpClass(cls): + cls.samdb = cls.getSamDB("-H", HOST, CREDS) + super().setUpClass() + + @classmethod + def setUpTestData(cls): + cls.create_claim_type("accountExpires", name="expires", + classes=["user"]) + cls.create_claim_type("department", name="dept", classes=["user"], + protect=True) + cls.create_claim_type("carLicense", name="plate", classes=["user"], + disable=True) + + def get_services_dn(self): + """Returns Services DN.""" + services_dn = self.samdb.get_config_basedn() + services_dn.add_child("CN=Services") + return services_dn + + def get_claim_types_dn(self): + """Returns the Claim Types DN.""" + claim_types_dn = self.get_services_dn() + claim_types_dn.add_child("CN=Claim Types,CN=Claims Configuration") + return claim_types_dn + + @classmethod + def _run(cls, *argv): + """Override _run, so we don't always have to pass host and creds.""" + args = list(argv) + args.extend(["-H", HOST, CREDS]) + return super()._run(*args) + + runcmd = _run + runsubcmd = _run + + @classmethod + def create_claim_type(cls, attribute, name=None, description=None, + classes=None, disable=False, protect=False): + """Create a claim type using the samba-tool command.""" + + # if name is specified it will override the attribute name + display_name = name or attribute + + # base command for create claim-type + cmd = ["domain", "claim", "claim-type", + "create", "--attribute", attribute] + + # list of classes (applies_to) + if classes is not None: + cmd.extend([f"--class={name}" for name in classes]) + + # optional attributes + if name is not None: + cmd.append(f"--name={name}") + if description is not None: + cmd.append(f"--description={description}") + if disable: + cmd.append("--disable") + if protect: + cmd.append("--protect") + + result, out, err = cls.runcmd(*cmd) + assert result is None + assert out.startswith("Created claim type") + cls.addClassCleanup(cls.delete_claim_type, name=display_name, force=True) + return display_name + + @classmethod + def delete_claim_type(cls, name, force=False): + """Delete claim type by display name.""" + cmd = ["domain", "claim", "claim-type", "delete", "--name", name] + + # Force-delete protected claim type. + if force: + cmd.append("--force") + + result, out, err = cls.runcmd(*cmd) + assert result is None + assert "Deleted claim type" in out + + def get_claim_type(self, name): + """Get claim type by display name.""" + claim_types_dn = self.get_claim_types_dn() + + result = self.samdb.search(base=claim_types_dn, + scope=SCOPE_ONELEVEL, + expression=f"(displayName={name})") + + if len(result) == 1: + return result[0] + + +class ClaimTypeCmdTestCase(BaseClaimCmdTest): + """Tests for the claim-type command.""" + + def test_list(self): + """Test listing claim types in list format.""" + result, out, err = self.runcmd("domain", "claim", "claim-type", "list") + self.assertIsNone(result, msg=err) + + expected_claim_types = ["expires", "dept", "plate"] + + for claim_type in expected_claim_types: + self.assertIn(claim_type, out) + + def test_list__json(self): + """Test listing claim types in JSON format.""" + result, out, err = self.runcmd("domain", "claim", "claim-type", + "list", "--json") + self.assertIsNone(result, msg=err) + + # we should get valid json + json_result = json.loads(out) + claim_types = list(json_result.keys()) + + expected_claim_types = ["expires", "dept", "plate"] + + for claim_type in expected_claim_types: + self.assertIn(claim_type, claim_types) + + def test_view(self): + """Test viewing a single claim type.""" + result, out, err = self.runcmd("domain", "claim", "claim-type", + "view", "--name", "expires") + self.assertIsNone(result, msg=err) + + # we should get valid json + claim_type = json.loads(out) + + # check a few fields only + self.assertEqual(claim_type["displayName"], "expires") + self.assertEqual(claim_type["description"], "Account-Expires") + + def test_view__name_missing(self): + """Test view claim type without --name is handled.""" + result, out, err = self.runcmd("domain", "claim", "claim-type", "view") + self.assertEqual(result, -1) + self.assertIn("Argument --name is required.", err) + + def test_view__notfound(self): + """Test viewing claim type that doesn't exist is handled.""" + result, out, err = self.runcmd("domain", "claim", "claim-type", + "view", "--name", "doesNotExist") + self.assertEqual(result, -1) + self.assertIn("Claim type doesNotExist not found.", err) + + def test_create(self): + """Test creating several known attributes as claim types. + + The point is to test it against the various datatypes that could + be found, but not include every known attribute. + """ + # We just need to test a few different data types for attributes, + # there is no need to test every known attribute. + claim_types = [ + "adminCount", + "accountExpires", + "department", + "carLicense", + "msDS-PrimaryComputer", + "isDeleted", + ] + + # Each known attribute must be in the schema. + for attribute in claim_types: + # Use a different name, so we don't clash with existing attributes. + name = "test_create_" + attribute + + self.addCleanup(self.delete_claim_type, name=name, force=True) + + result, out, err = self.runcmd("domain", "claim", "claim-type", + "create", + "--attribute", attribute, + "--name", name, + "--class=user") + self.assertIsNone(result, msg=err) + + # It should have used the attribute name as displayName. + claim_type = self.get_claim_type(name) + self.assertEqual(str(claim_type["displayName"]), name) + self.assertEqual(str(claim_type["Enabled"]), "TRUE") + self.assertEqual(str(claim_type["objectClass"][-1]), "msDS-ClaimType") + self.assertEqual(str(claim_type["msDS-ClaimSourceType"]), "AD") + + def test_create__boolean(self): + """Test adding a known boolean attribute and check its type.""" + self.addCleanup(self.delete_claim_type, name="boolAttr", force=True) + + result, out, err = self.runcmd("domain", "claim", "claim-type", + "create", "--attribute=msNPAllowDialin", + "--name=boolAttr", "--class=user") + + self.assertIsNone(result, msg=err) + claim_type = self.get_claim_type("boolAttr") + self.assertEqual(str(claim_type["displayName"]), "boolAttr") + self.assertEqual(str(claim_type["msDS-ClaimValueType"]), "6") + + def test_create__number(self): + """Test adding a known numeric attribute and check its type.""" + self.addCleanup(self.delete_claim_type, name="intAttr", force=True) + + result, out, err = self.runcmd("domain", "claim", "claim-type", + "create", "--attribute=adminCount", + "--name=intAttr", "--class=user") + + self.assertIsNone(result, msg=err) + claim_type = self.get_claim_type("intAttr") + self.assertEqual(str(claim_type["displayName"]), "intAttr") + self.assertEqual(str(claim_type["msDS-ClaimValueType"]), "1") + + def test_create__text(self): + """Test adding a known text attribute and check its type.""" + self.addCleanup(self.delete_claim_type, name="textAttr", force=True) + + result, out, err = self.runcmd("domain", "claim", "claim-type", + "create", "--attribute=givenName", + "--name=textAttr", "--class=user") + + self.assertIsNone(result, msg=err) + claim_type = self.get_claim_type("textAttr") + self.assertEqual(str(claim_type["displayName"]), "textAttr") + self.assertEqual(str(claim_type["msDS-ClaimValueType"]), "3") + + def test_create__disabled(self): + """Test adding a disabled attribute.""" + self.addCleanup(self.delete_claim_type, name="disabledAttr", force=True) + + result, out, err = self.runcmd("domain", "claim", "claim-type", + "create", "--attribute=msTSHomeDrive", + "--name=disabledAttr", "--class=user", + "--disable") + + self.assertIsNone(result, msg=err) + claim_type = self.get_claim_type("disabledAttr") + self.assertEqual(str(claim_type["displayName"]), "disabledAttr") + self.assertEqual(str(claim_type["Enabled"]), "FALSE") + + def test_create__protected(self): + """Test adding a protected attribute.""" + self.addCleanup(self.delete_claim_type, name="protectedAttr", force=True) + + result, out, err = self.runcmd("domain", "claim", "claim-type", + "create", "--attribute=mobile", + "--name=protectedAttr", "--class=user", + "--protect") + + self.assertIsNone(result, msg=err) + claim_type = self.get_claim_type("protectedAttr") + self.assertEqual(str(claim_type["displayName"]), "protectedAttr") + + # Check if the claim type is protected from accidental deletion. + utils = SDUtils(self.samdb) + desc = utils.get_sd_as_sddl(claim_type["dn"]) + self.assertIn("(D;;DTSD;;;WD)", desc) + + def test_create__classes(self): + """Test adding an attribute applied to different classes.""" + schema_dn = self.samdb.get_schema_basedn() + user_dn = f"CN=User,{schema_dn}" + computer_dn = f"CN=Computer,{schema_dn}" + + # --class=user + self.addCleanup(self.delete_claim_type, name="streetName", force=True) + result, out, err = self.runcmd("domain", "claim", "claim-type", + "create", "--attribute=street", + "--name=streetName", "--class=user") + self.assertIsNone(result, msg=err) + claim_type = self.get_claim_type("streetName") + applies_to = [str(dn) for dn in claim_type["msDS-ClaimTypeAppliesToClass"]] + self.assertEqual(str(claim_type["displayName"]), "streetName") + self.assertEqual(len(applies_to), 1) + self.assertIn(user_dn, applies_to) + self.assertNotIn(computer_dn, applies_to) + + # --class=computer + self.addCleanup(self.delete_claim_type, name="ext", force=True) + result, out, err = self.runcmd("domain", "claim", "claim-type", + "create", "--attribute=extensionName", + "--name=ext", "--class=computer") + self.assertIsNone(result, msg=err) + claim_type = self.get_claim_type("ext") + applies_to = [str(dn) for dn in claim_type["msDS-ClaimTypeAppliesToClass"]] + self.assertEqual(str(claim_type["displayName"]), "ext") + self.assertEqual(len(applies_to), 1) + self.assertNotIn(user_dn, applies_to) + self.assertIn(computer_dn, applies_to) + + # --class=user --class=computer + self.addCleanup(self.delete_claim_type, + name="primaryComputer", force=True) + result, out, err = self.runcmd("domain", "claim", "claim-type", + "create", "--attribute=msDS-PrimaryComputer", + "--name=primaryComputer", "--class=user", + "--class=computer") + self.assertIsNone(result, msg=err) + claim_type = self.get_claim_type("primaryComputer") + applies_to = [str(dn) for dn in claim_type["msDS-ClaimTypeAppliesToClass"]] + self.assertEqual(str(claim_type["displayName"]), "primaryComputer") + self.assertEqual(len(applies_to), 2) + self.assertIn(user_dn, applies_to) + self.assertIn(computer_dn, applies_to) + + # No classes should raise CommandError. + result, out, err = self.runcmd("domain", "claim", "claim-type", + "create", "--attribute=wWWHomePage", + "--name=homepage") + self.assertEqual(result, -1) + self.assertIn("Argument --class is required.", err) + + def test__delete(self): + """Test deleting a claim type that is not protected.""" + # Create non-protected claim type. + result, out, err = self.runcmd("domain", "claim", "claim-type", + "create", "--attribute=msDS-SiteName", + "--name=siteName", "--class=computer") + self.assertIsNone(result, msg=err) + claim_type = self.get_claim_type("siteName") + self.assertIsNotNone(claim_type) + + # Do the deletion. + result, out, err = self.runcmd("domain", "claim", "claim-type", + "delete", "--name=siteName") + self.assertIsNone(result, msg=err) + + # Claim type shouldn't exist anymore. + claim_type = self.get_claim_type("siteName") + self.assertIsNone(claim_type) + + def test_delete__protected(self): + """Test deleting a protected claim type, with and without --force.""" + # Create protected claim type. + result, out, err = self.runcmd("domain", "claim", "claim-type", + "create", "--attribute=postalCode", + "--name=postcode", "--class=user", + "--protect") + self.assertIsNone(result, msg=err) + claim_type = self.get_claim_type("postcode") + self.assertIsNotNone(claim_type) + + # Do the deletion. + result, out, err = self.runcmd("domain", "claim", "claim-type", + "delete", "--name=postcode") + self.assertEqual(result, -1) + + # Claim type should still exist. + claim_type = self.get_claim_type("postcode") + self.assertIsNotNone(claim_type) + + # Try a force delete instead. + result, out, err = self.runcmd("domain", "claim", "claim-type", + "delete", "--name=postcode", "--force") + self.assertIsNone(result, msg=err) + + # Claim type shouldn't exist anymore. + claim_type = self.get_claim_type("siteName") + self.assertIsNone(claim_type) + + def test_delete__notfound(self): + """Test deleting a claim type that doesn't exist.""" + result, out, err = self.runcmd("domain", "claim", "claim-type", + "delete", "--name", "doesNotExist") + self.assertEqual(result, -1) + self.assertIn("Claim type doesNotExist not found.", err) + + def test_modify__description(self): + """Test modifying a claim type description.""" + self.addCleanup(self.delete_claim_type, name="company", force=True) + self.create_claim_type("company", classes=["user"]) + + result, out, err = self.runcmd("domain", "claim", "claim-type", + "modify", "--name", "company", + "--description=NewDescription") + self.assertIsNone(result, msg=err) + + # Verify fields were changed. + claim_type = self.get_claim_type("company") + self.assertEqual(str(claim_type["description"]), "NewDescription") + + def test_modify__classes(self): + """Test modify claim type classes.""" + schema_dn = self.samdb.get_schema_basedn() + user_dn = f"CN=User,{schema_dn}" + computer_dn = f"CN=Computer,{schema_dn}" + + self.addCleanup(self.delete_claim_type, name="seeAlso", force=True) + self.create_claim_type("seeAlso", classes=["user"]) + + # First try removing all classes which shouldn't be allowed. + result, out, err = self.runcmd("domain", "claim", "claim-type", + "modify", "--name", "seeAlso", + "--class=") + self.assertEqual(result, -1) + self.assertIn("Class name is required.", err) + + # Try changing it to just --class=computer first. + result, out, err = self.runcmd("domain", "claim", "claim-type", + "modify", "--name", "seeAlso", + "--class=computer") + self.assertIsNone(result, msg=err) + claim_type = self.get_claim_type("seeAlso") + applies_to = [str(dn) for dn in claim_type["msDS-ClaimTypeAppliesToClass"]] + self.assertNotIn(user_dn, applies_to) + self.assertIn(computer_dn, applies_to) + + # Now try changing it to --class=user again. + result, out, err = self.runcmd("domain", "claim", "claim-type", + "modify", "--name", "seeAlso", + "--class=user") + self.assertIsNone(result, msg=err) + claim_type = self.get_claim_type("seeAlso") + applies_to = [str(dn) for dn in claim_type["msDS-ClaimTypeAppliesToClass"]] + self.assertIn(user_dn, applies_to) + self.assertNotIn(computer_dn, applies_to) + + # Why not both? + result, out, err = self.runcmd("domain", "claim", "claim-type", + "modify", "--name", "seeAlso", + "--class=user", "--class=computer") + self.assertIsNone(result, msg=err) + claim_type = self.get_claim_type("seeAlso") + applies_to = [str(dn) for dn in claim_type["msDS-ClaimTypeAppliesToClass"]] + self.assertIn(user_dn, applies_to) + self.assertIn(computer_dn, applies_to) + + def test_modify__enable_disable(self): + """Test modify disabling and enabling a claim type.""" + self.addCleanup(self.delete_claim_type, name="catalogs", force=True) + self.create_claim_type("catalogs", classes=["user"]) + + result, out, err = self.runcmd("domain", "claim", "claim-type", + "modify", "--name", "catalogs", + "--disable") + self.assertIsNone(result, msg=err) + + # Check that claim type was disabled. + claim_type = self.get_claim_type("catalogs") + self.assertEqual(str(claim_type["Enabled"]), "FALSE") + + result, out, err = self.runcmd("domain", "claim", "claim-type", + "modify", "--name", "catalogs", + "--enable") + self.assertIsNone(result, msg=err) + + # Check that claim type was enabled. + claim_type = self.get_claim_type("catalogs") + self.assertEqual(str(claim_type["Enabled"]), "TRUE") + + def test_modify__protect_unprotect(self): + """Test modify un-protecting and protecting a claim type.""" + self.addCleanup(self.delete_claim_type, name="pager", force=True) + self.create_claim_type("pager", classes=["user"]) + + utils = SDUtils(self.samdb) + result, out, err = self.runcmd("domain", "claim", "claim-type", + "modify", "--name", "pager", + "--protect") + self.assertIsNone(result, msg=err) + + # Check that claim type was protected. + claim_type = self.get_claim_type("pager") + desc = utils.get_sd_as_sddl(claim_type["dn"]) + self.assertIn("(D;;DTSD;;;WD)", desc) + + result, out, err = self.runcmd("domain", "claim", "claim-type", + "modify", "--name", "pager", + "--unprotect") + self.assertIsNone(result, msg=err) + + # Check that claim type was unprotected. + claim_type = self.get_claim_type("pager") + desc = utils.get_sd_as_sddl(claim_type["dn"]) + self.assertNotIn("(D;;DTSD;;;WD)", desc) + + def test_modify__enable_disable_together(self): + """Test modify claim type doesn't allow both --enable and --disable.""" + self.addCleanup(self.delete_claim_type, + name="businessCategory", force=True) + self.create_claim_type("businessCategory", classes=["user"]) + + result, out, err = self.runcmd("domain", "claim", "claim-type", + "modify", "--name", "businessCategory", + "--enable", "--disable") + self.assertEqual(result, -1) + self.assertIn("--enable and --disable cannot be used together.", err) + + def test_modify__protect_unprotect_together(self): + """Test modify claim type using both --protect and --unprotect.""" + self.addCleanup(self.delete_claim_type, + name="businessCategory", force=True) + self.create_claim_type("businessCategory", classes=["user"]) + + result, out, err = self.runcmd("domain", "claim", "claim-type", + "modify", "--name", "businessCategory", + "--protect", "--unprotect") + self.assertEqual(result, -1) + self.assertIn("--protect and --unprotect cannot be used together.", err) + + def test_modify__notfound(self): + """Test modify a claim type that doesn't exist.""" + result, out, err = self.runcmd("domain", "claim", "claim-type", + "modify", "--name", "doesNotExist", + "--description=NewDescription") + self.assertEqual(result, -1) + self.assertIn("Claim type doesNotExist not found.", err) + + +class ValueTypeCmdTestCase(BaseClaimCmdTest): + """Tests for the value-type command.""" + + def test_list(self): + """Test listing claim value types in list format.""" + result, out, err = self.runcmd("domain", "claim", "value-type", "list") + self.assertIsNone(result, msg=err) + + # base list of value types is there + for value_type in VALUE_TYPES: + self.assertIn(value_type, out) + + def test_list__json(self): + """Test listing claim value types in JSON format.""" + result, out, err = self.runcmd("domain", "claim", "value-type", + "list", "--json") + self.assertIsNone(result, msg=err) + + # we should get valid json + json_result = json.loads(out) + value_types = list(json_result.keys()) + + # base list of value types is there + for value_type in VALUE_TYPES: + self.assertIn(value_type, value_types) + + def test_view(self): + """Test viewing a single claim value type.""" + result, out, err = self.runcmd("domain", "claim", "value-type", + "view", "--name", "Text") + self.assertIsNone(result, msg=err) + + # we should get valid json + value_type = json.loads(out) + + # check a few fields only + self.assertEqual(value_type["name"], "MS-DS-Text") + self.assertEqual(value_type["displayName"], "Text") + self.assertEqual(value_type["msDS-ClaimValueType"], 3) + + def test_view__name_missing(self): + """Test viewing a claim value type with missing --name is handled.""" + result, out, err = self.runcmd("domain", "claim", "value-type", "view") + self.assertEqual(result, -1) + self.assertIn("Argument --name is required.", err) + + def test_view__notfound(self): + """Test viewing a claim value type that doesn't exist is handled.""" + result, out, err = self.runcmd("domain", "claim", "value-type", + "view", "--name", "doesNotExist") + self.assertEqual(result, -1) + self.assertIn("Value type doesNotExist not found.", err) diff --git a/python/samba/tests/samba_tool/domain_models.py b/python/samba/tests/samba_tool/domain_models.py new file mode 100644 index 0000000..e0f21fe --- /dev/null +++ b/python/samba/tests/samba_tool/domain_models.py @@ -0,0 +1,416 @@ +# Unix SMB/CIFS implementation. +# +# Tests for domain models and fields +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +from datetime import datetime +from xml.etree import ElementTree + +from ldb import FLAG_MOD_ADD, MessageElement, SCOPE_ONELEVEL +from samba.dcerpc import security +from samba.dcerpc.misc import GUID +from samba.netcmd.domain.models import Group, User, fields +from samba.netcmd.domain.models.auth_policy import StrongNTLMPolicy +from samba.ndr import ndr_pack, ndr_unpack + +from .base import SambaToolCmdTest + +HOST = "ldap://{DC_SERVER}".format(**os.environ) +CREDS = "-U{DC_USERNAME}%{DC_PASSWORD}".format(**os.environ) + + +class FieldTestMixin: + """Tests a model field to ensure it behaves correctly in both directions. + + Use a mixin since TestCase can't be marked as abstract. + """ + + @classmethod + def setUpClass(cls): + cls.samdb = cls.getSamDB("-H", HOST, CREDS) + super().setUpClass() + + def get_users_dn(self): + """Returns Users DN.""" + users_dn = self.samdb.get_root_basedn() + users_dn.add_child("CN=Users") + return users_dn + + def test_to_db_value(self): + # Loop through each value and expected value combination. + # If the expected value is callable, treat it as a validation callback. + # NOTE: perhaps we should be using subtests for this. + for (value, expected) in self.to_db_value: + db_value = self.field.to_db_value(self.samdb, value, FLAG_MOD_ADD) + if callable(expected): + self.assertTrue(expected(db_value)) + else: + self.assertEqual(db_value, expected) + + def test_from_db_value(self): + # Loop through each value and expected value combination. + # NOTE: perhaps we should be using subtests for this. + for (db_value, expected) in self.from_db_value: + value = self.field.from_db_value(self.samdb, db_value) + self.assertEqual(value, expected) + + +class IntegerFieldTest(FieldTestMixin, SambaToolCmdTest): + field = fields.IntegerField("FieldName") + + to_db_value = [ + (10, MessageElement(b"10")), + ([1, 5, 10], MessageElement([b"1", b"5", b"10"])), + (None, None), + ] + + from_db_value = [ + (MessageElement(b"10"), 10), + (MessageElement([b"1", b"5", b"10"]), [1, 5, 10]), + (None, None), + ] + + +class BinaryFieldTest(FieldTestMixin, SambaToolCmdTest): + field = fields.BinaryField("FieldName") + + to_db_value = [ + (b"SAMBA", MessageElement(b"SAMBA")), + ([b"SAMBA", b"Developer"], MessageElement([b"SAMBA", b"Developer"])), + (None, None), + ] + + from_db_value = [ + (MessageElement(b"SAMBA"), b"SAMBA"), + (MessageElement([b"SAMBA", b"Developer"]), [b"SAMBA", b"Developer"]), + (None, None), + ] + + +class StringFieldTest(FieldTestMixin, SambaToolCmdTest): + field = fields.StringField("FieldName") + + to_db_value = [ + ("SAMBA", MessageElement(b"SAMBA")), + (["SAMBA", "Developer"], MessageElement([b"SAMBA", b"Developer"])), + (None, None), + ] + + from_db_value = [ + (MessageElement(b"SAMBA"), "SAMBA"), + (MessageElement([b"SAMBA", b"Developer"]), ["SAMBA", "Developer"]), + (None, None), + ] + + +class BooleanFieldTest(FieldTestMixin, SambaToolCmdTest): + field = fields.BooleanField("FieldName") + + to_db_value = [ + (True, MessageElement(b"TRUE")), + ([False, True], MessageElement([b"FALSE", b"TRUE"])), + (None, None), + ] + + from_db_value = [ + (MessageElement(b"TRUE"), True), + (MessageElement([b"FALSE", b"TRUE"]), [False, True]), + (None, None), + ] + + +class EnumFieldTest(FieldTestMixin, SambaToolCmdTest): + field = fields.EnumField("FieldName", StrongNTLMPolicy) + + to_db_value = [ + (StrongNTLMPolicy.OPTIONAL, MessageElement("1")), + ([StrongNTLMPolicy.REQUIRED, StrongNTLMPolicy.OPTIONAL], + MessageElement(["2", "1"])), + (None, None), + ] + + from_db_value = [ + (MessageElement("1"), StrongNTLMPolicy.OPTIONAL), + (MessageElement(["2", "1"]), + [StrongNTLMPolicy.REQUIRED, StrongNTLMPolicy.OPTIONAL]), + (None, None), + ] + + +class DateTimeFieldTest(FieldTestMixin, SambaToolCmdTest): + field = fields.DateTimeField("FieldName") + + to_db_value = [ + (datetime(2023, 1, 27, 22, 36, 41), MessageElement("20230127223641.0Z")), + ([datetime(2023, 1, 27, 22, 36, 41), datetime(2023, 1, 27, 22, 47, 50)], + MessageElement(["20230127223641.0Z", "20230127224750.0Z"])), + (None, None), + ] + + from_db_value = [ + (MessageElement("20230127223641.0Z"), datetime(2023, 1, 27, 22, 36, 41)), + (MessageElement(["20230127223641.0Z", "20230127224750.0Z"]), + [datetime(2023, 1, 27, 22, 36, 41), datetime(2023, 1, 27, 22, 47, 50)]), + (None, None), + ] + + +class RelatedFieldTest(FieldTestMixin, SambaToolCmdTest): + field = fields.RelatedField("FieldName", User) + + @property + def to_db_value(self): + alice = User.get(self.samdb, username="alice") + joe = User.get(self.samdb, username="joe") + return [ + (alice, MessageElement(str(alice.dn))), + ([joe, alice], MessageElement([str(joe.dn), str(alice.dn)])), + (None, None), + ] + + @property + def from_db_value(self): + alice = User.get(self.samdb, username="alice") + joe = User.get(self.samdb, username="joe") + return [ + (MessageElement(str(alice.dn)), alice), + (MessageElement([str(joe.dn), str(alice.dn)]), [joe, alice]), + (None, None), + ] + + +class DnFieldTest(FieldTestMixin, SambaToolCmdTest): + field = fields.DnField("FieldName") + + @property + def to_db_value(self): + alice = User.get(self.samdb, username="alice") + joe = User.get(self.samdb, username="joe") + return [ + (alice.dn, MessageElement(str(alice.dn))), + ([joe.dn, alice.dn], MessageElement([str(joe.dn), str(alice.dn)])), + (None, None), + ] + + @property + def from_db_value(self): + alice = User.get(self.samdb, username="alice") + joe = User.get(self.samdb, username="joe") + return [ + (MessageElement(str(alice.dn)), alice.dn), + (MessageElement([str(joe.dn), str(alice.dn)]), [joe.dn, alice.dn]), + (None, None), + ] + + +class SIDFieldTest(FieldTestMixin, SambaToolCmdTest): + field = fields.SIDField("FieldName") + + @property + def to_db_value(self): + # Create a group for testing + group = Group(name="group1") + group.save(self.samdb) + self.addCleanup(group.delete, self.samdb) + + # Get raw value to compare against + group_rec = self.samdb.search(Group.get_base_dn(self.samdb), + scope=SCOPE_ONELEVEL, + expression="(name=group1)", + attrs=["objectSid"])[0] + raw_sid = group_rec["objectSid"] + + return [ + (group.object_sid, raw_sid), + (None, None), + ] + + @property + def from_db_value(self): + # Create a group for testing + group = Group(name="group1") + group.save(self.samdb) + self.addCleanup(group.delete, self.samdb) + + # Get raw value to compare against + group_rec = self.samdb.search(Group.get_base_dn(self.samdb), + scope=SCOPE_ONELEVEL, + expression="(name=group1)", + attrs=["objectSid"])[0] + raw_sid = group_rec["objectSid"] + + return [ + (raw_sid, group.object_sid), + (None, None), + ] + + +class GUIDFieldTest(FieldTestMixin, SambaToolCmdTest): + field = fields.GUIDField("FieldName") + + @property + def to_db_value(self): + users_dn = self.get_users_dn() + + alice = self.samdb.search(users_dn, + scope=SCOPE_ONELEVEL, + expression="(sAMAccountName=alice)", + attrs=["objectGUID"])[0] + + joe = self.samdb.search(users_dn, + scope=SCOPE_ONELEVEL, + expression="(sAMAccountName=joe)", + attrs=["objectGUID"])[0] + + alice_guid = str(ndr_unpack(GUID, alice["objectGUID"][0])) + joe_guid = str(ndr_unpack(GUID, joe["objectGUID"][0])) + + return [ + (alice_guid, alice["objectGUID"]), + ( + [joe_guid, alice_guid], + MessageElement([joe["objectGUID"][0], alice["objectGUID"][0]]), + ), + (None, None), + ] + + @property + def from_db_value(self): + users_dn = self.get_users_dn() + + alice = self.samdb.search(users_dn, + scope=SCOPE_ONELEVEL, + expression="(sAMAccountName=alice)", + attrs=["objectGUID"])[0] + + joe = self.samdb.search(users_dn, + scope=SCOPE_ONELEVEL, + expression="(sAMAccountName=joe)", + attrs=["objectGUID"])[0] + + alice_guid = str(ndr_unpack(GUID, alice["objectGUID"][0])) + joe_guid = str(ndr_unpack(GUID, joe["objectGUID"][0])) + + return [ + (alice["objectGUID"], alice_guid), + ( + MessageElement([joe["objectGUID"][0], alice["objectGUID"][0]]), + [joe_guid, alice_guid], + ), + (None, None), + ] + + +class SDDLFieldTest(FieldTestMixin, SambaToolCmdTest): + field = fields.SDDLField("FieldName") + + def setUp(self): + super().setUp() + self.domain_sid = security.dom_sid(self.samdb.get_domain_sid()) + + def encode(self, value): + return ndr_pack(security.descriptor.from_sddl(value, self.domain_sid)) + + @property + def to_db_value(self): + values = [ + "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of {SID(AU)}))", + "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of {SID(AO)}))", + "O:SYG:SYD:(XA;OICI;CR;;;WD;((Member_of {SID(AO)}) || (Member_of {SID(BO)})))", + "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of {SID(%s)}))" % self.domain_sid, + ] + expected = [ + (value, MessageElement(self.encode(value))) for value in values + ] + expected.append((None, None)) + return expected + + @property + def from_db_value(self): + values = [ + "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of {SID(AU)}))", + "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of {SID(AO)}))", + "O:SYG:SYD:(XA;OICI;CR;;;WD;((Member_of {SID(AO)}) || (Member_of {SID(BO)})))", + "O:SYG:SYD:(XA;OICI;CR;;;WD;(Member_of {SID(%s)}))" % self.domain_sid, + ] + expected = [ + (MessageElement(self.encode(value)), value) for value in values + ] + expected.append((None, None)) + return expected + + +class PossibleClaimValuesFieldTest(FieldTestMixin, SambaToolCmdTest): + field = fields.PossibleClaimValuesField("FieldName") + + json_data = [{ + "ValueGUID": "1c39ed4f-0b26-4536-b963-5959c8b1b676", + "ValueDisplayName": "Alice", + "ValueDescription": "Alice Description", + "Value": "alice", + }] + + xml_data = "<?xml version='1.0' encoding='utf-16'?>" \ + "<PossibleClaimValues xmlns:xsd='http://www.w3.org/2001/XMLSchema'" \ + " xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'" \ + " xmlns='http://schemas.microsoft.com/2010/08/ActiveDirectory/PossibleValues'>" \ + "<StringList>" \ + "<Item>" \ + "<ValueGUID>1c39ed4f-0b26-4536-b963-5959c8b1b676</ValueGUID>" \ + "<ValueDisplayName>Alice</ValueDisplayName>" \ + "<ValueDescription>Alice Description</ValueDescription>" \ + "<Value>alice</Value>" \ + "</Item>" \ + "</StringList>" \ + "</PossibleClaimValues>" + + def validate_xml(self, db_field): + """Callback that compares XML strings. + + Tidying the HTMl output and adding consistent indentation was only + added to ETree in Python 3.9+ so generate a single line XML string. + + This is just based on comparing the parsed XML, converted back + to a string, then comparing those strings. + + So the expected xml_data string must have no spacing or indentation. + + :param db_field: MessageElement value returned by field.to_db_field() + """ + expected = ElementTree.fromstring(self.xml_data) + parsed = ElementTree.fromstring(str(db_field)) + return ElementTree.tostring(parsed) == ElementTree.tostring(expected) + + @property + def to_db_value(self): + return [ + (self.json_data, self.validate_xml), # callback to validate XML + (self.json_data[0], self.validate_xml), # one item wrapped as list + ([], None), # empty list clears field + (None, None), + ] + + @property + def from_db_value(self): + return [ + (MessageElement(self.xml_data), self.json_data), + (None, None), + ] diff --git a/python/samba/tests/samba_tool/drs_clone_dc_data_lmdb_size.py b/python/samba/tests/samba_tool/drs_clone_dc_data_lmdb_size.py new file mode 100644 index 0000000..1cb88d3 --- /dev/null +++ b/python/samba/tests/samba_tool/drs_clone_dc_data_lmdb_size.py @@ -0,0 +1,119 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) Catalyst IT Ltd. 2019 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +from samba.tests.samba_tool.base import SambaToolCmdTest +import os +import shutil + + +class DrsCloneDcDataLmdbSizeTestCase(SambaToolCmdTest): + """Test setting of the lmdb map size during drs clone-dc-data""" + + def setUp(self): + super().setUp() + self.tempsambadir = os.path.join(self.tempdir, "samba") + os.mkdir(self.tempsambadir) + + # clone a domain and set the lmdb map size to size + # + # returns the tuple (ret, stdout, stderr) + def clone(self, size=None): + command = ( + "samba-tool " + + "drs clone-dc-database " + + os.environ["REALM"] + " " + + ("-U%s%%%s " % (os.environ["USERNAME"], os.environ["PASSWORD"])) + + ("--targetdir=%s " % self.tempsambadir) + + "--backend-store=mdb " + ) + if size: + command += ("--backend-store-size=%s" % size) + + return self.run_command(command) + + # + # Get the lmdb map size for the specified command + # + # While there is a python lmdb package available we use the lmdb command + # line utilities to avoid introducing a dependency. + # + def get_lmdb_environment_size(self, path): + (result, out, err) = self.run_command("mdb_stat -ne %s" % path) + if result: + self.fail("Unable to run mdb_stat\n") + for line in out.split("\n"): + line = line.strip() + if line.startswith("Map size:"): + line = line.replace(" ", "") + (label, size) = line.split(":") + return int(size) + + # + # Check the lmdb files created by provision and ensure that the map size + # has been set to size. + # + # Currently this is all the *.ldb files in private/sam.ldb.d + # + def check_lmdb_environment_sizes(self, size): + directory = os.path.join(self.tempsambadir, "private", "sam.ldb.d") + for name in os.listdir(directory): + if name.endswith(".ldb"): + path = os.path.join(directory, name) + s = self.get_lmdb_environment_size(path) + if s != size: + self.fail("File %s, size=%d larger than %d" % + (name, s, size)) + + # + # Ensure that if --backend-store-size is not specified the default of + # 8Gb is used + def test_default(self): + (result, out, err) = self.clone() + self.assertEqual(0, result) + self.check_lmdb_environment_sizes(8 * 1024 * 1024 * 1024) + + def test_64Mb(self): + (result, out, err) = self.clone("64Mb") + self.assertEqual(0, result) + self.check_lmdb_environment_sizes(64 * 1024 * 1024) + + def test_no_unit_suffix(self): + (result, out, err) = self.run_command( + 'samba-tool drs clone-dc-database --backend-store-size "2"') + self.assertGreater(result, 0) + self.assertRegex(err, + r"--backend-store-size invalid suffix ''") + + def test_invalid_unit_suffix(self): + (result, out, err) = self.run_command( + 'samba-tool drs clone-dc-database --backend-store-size "2 cd"') + self.assertGreater(result, 0) + self.assertRegex(err, + r"--backend-store-size invalid suffix 'cd'") + + def test_non_numeric(self): + (result, out, err) = self.run_command( + 'samba-tool drs clone-dc-database --backend-store-size "two Gb"') + self.assertGreater(result, 0) + self.assertRegex( + err, + r"backend-store-size option requires a numeric value, with an" + " optional unit suffix") + + def tearDown(self): + super().tearDown() + shutil.rmtree(self.tempsambadir) diff --git a/python/samba/tests/samba_tool/dsacl.py b/python/samba/tests/samba_tool/dsacl.py new file mode 100644 index 0000000..8ddf37e --- /dev/null +++ b/python/samba/tests/samba_tool/dsacl.py @@ -0,0 +1,211 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) Martin Kraemer 2019 <mk.maddin@gmail.com> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +from samba.tests.samba_tool.base import SambaToolCmdTest +import re + +class DSaclSetSddlTestCase(SambaToolCmdTest): + """Tests for samba-tool dsacl set --sddl subcommand""" + sddl = "(OA;CIIO;RPWP;aaaaaaaa-1111-bbbb-2222-dddddddddddd;33333333-eeee-4444-ffff-555555555555;PS)" + sddl_lc = "(OA;CIIO;RPWP;aaaaaaaa-1111-bbbb-2222-dddddddddddd;33333333-eeee-4444-ffff-555555555555;PS)" + sddl_uc = "(OA;CIIO;RPWP;AAAAAAAA-1111-BBBB-2222-DDDDDDDDDDDD;33333333-EEEE-4444-FFFF-555555555555;PS)" + sddl_sid = "(OA;CIIO;RPWP;aaaaaaaa-1111-bbbb-2222-dddddddddddd;33333333-eeee-4444-ffff-555555555555;S-1-5-10)" + sddl_multi = "(OA;CIIO;RPWP;aaaaaaaa-1111-bbbb-2222-dddddddddddd;33333333-eeee-4444-ffff-555555555555;PS)(OA;CIIO;RPWP;cccccccc-9999-ffff-8888-eeeeeeeeeeee;77777777-dddd-6666-bbbb-555555555555;PS)" + + def setUp(self): + super().setUp() + self.samdb = self.getSamDB("-H", "ldap://%s" % os.environ["DC_SERVER"],"-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"])) + self.dn="OU=DSaclSetSddlTestCase,%s" % self.samdb.domain_dn() + self.samdb.create_ou(self.dn) + + def tearDown(self): + super().tearDown() + # clean-up the created test ou + self.samdb.delete(self.dn) + + def test_sddl(self): + """Tests if a sddl string can be added 'the normal way'""" + (result, out, err) = self.runsubcmd("dsacl", "set","--objectdn=%s" % self.dn, "--sddl=%s" % self.sddl) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + #extract only the two sddl strings from samba-tool output + acl_list=re.findall('.*descriptor for.*:\n(.*?)\n',out) + self.assertNotEqual(acl_list[0], acl_list[1], "new and old SDDL string differ") + self.assertMatch(acl_list[1], self.sddl, "new SDDL string should be contained within second sddl output") + + def test_sddl_set_get(self): + """Tests if a sddl string can be added 'the normal way' and the output of 'get' is the same""" + (result, out, err) = self.runsubcmd("dsacl", "get", + "--objectdn=%s" % self.dn) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + #extract only the two sddl strings from samba-tool output + acl_list_get=re.findall('^descriptor for.*:\n(.*?)\n', out) + + (result, out, err) = self.runsubcmd("dsacl", "set", + "--objectdn=%s" % self.dn, + "--sddl=%s" % self.sddl) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + #extract only the two sddl strings from samba-tool output + acl_list_old=re.findall('old descriptor for.*:\n(.*?)\n', out) + self.assertEqual(acl_list_old, acl_list_get, + "output of dsacl get should be the same as before set") + + acl_list=re.findall('new descriptor for.*:\n(.*?)\n', out) + + (result, out, err) = self.runsubcmd("dsacl", "get", + "--objectdn=%s" % self.dn) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + #extract only the two sddl strings from samba-tool output + acl_list_get2=re.findall('^descriptor for.*:\n(.*?)\n', out) + self.assertEqual(acl_list, acl_list_get2, + "output of dsacl get should be the same as after set") + + def test_multisddl(self): + """Tests if we can add multiple, different sddl strings at the same time""" + (result, out, err) = self.runsubcmd("dsacl", "set","--objectdn=%s" % self.dn, "--sddl=%s" % self.sddl_multi) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + #extract only the two sddl strings from samba-tool output + acl_list=re.findall('.*descriptor for.*:\n(.*?)\n',out) + for ace in re.findall(r'\(.*?\)',self.sddl_multi): + self.assertMatch(acl_list[1], ace, "new SDDL string should be contained within second sddl output") + + def test_duplicatesddl(self): + """Tests if an already existing sddl string can be added causing duplicate entry""" + acl_list = self._double_sddl_check(self.sddl,self.sddl) + self.assertEqual(acl_list[0],acl_list[1]) + + def test_casesensitivesddl(self): + """Tests if an already existing sddl string can be added in different cases causing duplicate entry""" + acl_list = self._double_sddl_check(self.sddl_lc,self.sddl_uc) + self.assertEqual(acl_list[0],acl_list[1]) + + def test_sidsddl(self): + """Tests if an already existing sddl string can be added with SID instead of SDDL SIDString causing duplicate entry""" + acl_list = self._double_sddl_check(self.sddl,self.sddl_sid) + self.assertEqual(acl_list[0],acl_list[1]) + + def test_twosddl(self): + """Tests if an already existing sddl string can be added by using it twice/in combination with non existing sddl string causing duplicate entry""" + acl_list = self._double_sddl_check(self.sddl,self.sddl + self.sddl) + self.assertEqual(acl_list[0],acl_list[1]) + + def _double_sddl_check(self,sddl1,sddl2): + """Adds two sddl strings and checks if there was an ace change after the second adding""" + (result, out, err) = self.runsubcmd("dsacl", "set","--objectdn=%s" % self.dn, "--sddl=%s" % sddl1) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + acl_list = re.findall('.*descriptor for.*:\n(.*?)\n',out) + self.assertMatch(acl_list[1], sddl1, "new SDDL string should be contained within second sddl output - is not") + #add sddl2 + (result, out, err) = self.runsubcmd("dsacl", "set","--objectdn=%s" % self.dn, "--sddl=%s" % sddl2) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + acl_list = re.findall('.*descriptor for.*:\n(.*?)\n',out) + return acl_list + + def test_add_delete_sddl(self): + """Tests if a sddl string can be added 'the normal way', deleted and + final state is the same as initial. + """ + (result, out, err) = self.runsubcmd("dsacl", "get", + "--objectdn=%s" % self.dn) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + # extract only the two sddl strings from samba-tool output + acl_list_orig = re.findall('^descriptor for.*:\n(.*?)\n', out)[0] + + (result, out, err) = self.runsubcmd("dsacl", "set", + "--objectdn=%s" % self.dn, + "--sddl=%s" % self.sddl) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + acl_list_added = re.findall('new descriptor for.*:\n(.*?)\n', out)[0] + self.assertNotEqual(acl_list_added, acl_list_orig, "After adding the SD should be different.") + self.assertMatch(acl_list_added, self.sddl, "The added ACE should be part of the new SD.") + + (result, out, err) = self.runsubcmd("dsacl", "delete", + "--objectdn=%s" % self.dn, + "--sddl=%s" % self.sddl) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + acl_list_final = re.findall('new descriptor for.*:\n(.*?)\n', out)[0] + self.assertEqual(acl_list_orig, acl_list_final, + "output of dsacl delete should be the same as before adding") + + (result, out, err) = self.runsubcmd("dsacl", "get", + "--objectdn=%s" % self.dn) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + # extract only the two sddl strings from samba-tool output + acl_list_final_get = re.findall('^descriptor for.*:\n(.*?)\n', out)[0] + self.assertEqual(acl_list_orig, acl_list_final_get, + "output of dsacl get should be the same as after adding and deleting again") + + def test_delete(self): + # add sddl_multi first + (result, out, err) = self.runsubcmd("dsacl", "set", + "--objectdn=%s" % self.dn, + "--sddl=%s" % self.sddl_multi) + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + # delete sddl + (result, out, err) = self.runsubcmd("dsacl", "delete", + "--objectdn=%s" % self.dn, + "--sddl=%s" % self.sddl) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + acl_list_deleted = re.findall('new descriptor for.*:\n(.*?)\n', out)[0] + + self.assertNotRegex(acl_list_deleted, re.escape(self.sddl)) + left_sddl = self.sddl_multi.replace(self.sddl, "") + self.assertRegex(acl_list_deleted, re.escape(left_sddl)) + + def test_delete_twice(self): + """Tests if deleting twice the same ACEs returns the expected warning.""" + # add sddl_multi first + (result, out, err) = self.runsubcmd("dsacl", "set", + "--objectdn=%s" % self.dn, + "--sddl=%s" % self.sddl_multi) + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + + # delete sddl + (result, out, err) = self.runsubcmd("dsacl", "delete", + "--objectdn=%s" % self.dn, + "--sddl=%s" % self.sddl) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + + # delete sddl_multi + (result, out, err) = self.runsubcmd("dsacl", "delete", + "--objectdn=%s" % self.dn, + "--sddl=%s" % self.sddl_multi) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertRegex(out, "WARNING", "Should throw a warning about deleting non existent ace.") + warn = re.findall("WARNING: (.*?)\n", out)[0] + left_sddl = self.sddl_multi.replace(self.sddl, "") + self.assertRegex(warn, re.escape(self.sddl), "Should point out the non existent ace.") + self.assertNotRegex(warn, re.escape(left_sddl), + "Should not complain about all aces, since one of them is not deleted twice.") diff --git a/python/samba/tests/samba_tool/forest.py b/python/samba/tests/samba_tool/forest.py new file mode 100644 index 0000000..23291ca --- /dev/null +++ b/python/samba/tests/samba_tool/forest.py @@ -0,0 +1,70 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) William Brown <william@blackhats.net.au> 2018 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +import ldb +from samba.tests.samba_tool.base import SambaToolCmdTest + + +class ForestCmdTestCase(SambaToolCmdTest): + """Tests for samba-tool dsacl subcommands""" + samdb = None + + def setUp(self): + super().setUp() + self.samdb = self.getSamDB("-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"])) + self.domain_dn = self.samdb.domain_dn() + + def tearDown(self): + super().tearDown() + # Reset the values we might have changed. + ds_dn = "CN=Directory Service,CN=Windows NT,CN=Services,CN=Configuration" + m = ldb.Message() + m.dn = ldb.Dn(self.samdb, "%s,%s" % (ds_dn, self.domain_dn)) + m['dsheuristics'] = ldb.MessageElement( + '0000000', ldb.FLAG_MOD_REPLACE, 'dsheuristics') + + self.samdb.modify(m) + + def test_display(self): + """Tests that we can display forest settings""" + (result, out, err) = self.runcmd("forest", + "directory_service", + "show", + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn("dsheuristics: <NO VALUE>", out) + + def test_modify_dsheuristics(self): + """Test that we can modify the dsheuristics setting""" + + (result, out, err) = self.runcmd("forest", + "directory_service", + "dsheuristics", + "0000002", + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn("set dsheuristics: 0000002", out) diff --git a/python/samba/tests/samba_tool/fsmo.py b/python/samba/tests/samba_tool/fsmo.py new file mode 100644 index 0000000..29fe7bf --- /dev/null +++ b/python/samba/tests/samba_tool/fsmo.py @@ -0,0 +1,52 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) Rowland Penny <rpenny@samba.org> 2016 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +import ldb +from samba.tests.samba_tool.base import SambaToolCmdTest + + +class FsmoCmdTestCase(SambaToolCmdTest): + """Test for samba-tool fsmo show subcommand""" + + def test_fsmoget(self): + """Run fsmo show to see if it errors""" + (result, out, err) = self.runsubcmd("fsmo", "show") + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + + # Check that the output is sensible + samdb = self.getSamDB("-H", "ldap://%s" % os.environ["SERVER"], + "-U%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"])) + + try: + res = samdb.search(base=ldb.Dn(samdb, "CN=Infrastructure,DC=DomainDnsZones") + samdb.get_default_basedn(), + scope=ldb.SCOPE_BASE, attrs=["fsmoRoleOwner"]) + + self.assertTrue("DomainDnsZonesMasterRole owner: " + str(res[0]["fsmoRoleOwner"][0]) in out) + except ldb.LdbError as e: + (enum, string) = e.args + if enum == ldb.ERR_NO_SUCH_OBJECT: + self.assertTrue("The 'domaindns' role is not present in this domain" in out) + else: + raise + + res = samdb.search(base=samdb.get_default_basedn(), + scope=ldb.SCOPE_BASE, attrs=["fsmoRoleOwner"]) + + self.assertTrue("DomainNamingMasterRole owner: " + str(res[0]["fsmoRoleOwner"][0]) in out) diff --git a/python/samba/tests/samba_tool/gpo.py b/python/samba/tests/samba_tool/gpo.py new file mode 100644 index 0000000..851c70e --- /dev/null +++ b/python/samba/tests/samba_tool/gpo.py @@ -0,0 +1,1847 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) Andrew Bartlett 2012 +# +# based on time.py: +# Copyright (C) Sean Dague <sdague@linux.vnet.ibm.com> 2011 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os, pwd, grp +import ldb +import samba +from samba.tests.samba_tool.base import SambaToolCmdTest +import shutil +from samba.netcmd.gpo import get_gpo_dn, get_gpo_info +from samba.param import LoadParm +from samba.tests.gpo import stage_file, unstage_file +from samba.dcerpc import preg +from samba.ndr import ndr_pack, ndr_unpack +from samba.common import get_string +from configparser import ConfigParser +import xml.etree.ElementTree as etree +from tempfile import NamedTemporaryFile +import re +from samba.gp.gpclass import check_guid +from samba.gp_parse.gp_ini import GPTIniParser + +gpo_load_json = \ +b""" +[ + { + "keyname": "Software\\\\Policies\\\\Mozilla\\\\Firefox\\\\Homepage", + "valuename": "StartPage", + "class": "USER", + "type": "REG_SZ", + "data": "homepage" + }, + { + "keyname": "Software\\\\Policies\\\\Mozilla\\\\Firefox\\\\Homepage", + "valuename": "URL", + "class": "USER", + "type": 1, + "data": "samba.org" + }, + { + "keyname": "Software\\\\Microsoft\\\\Internet Explorer\\\\Toolbar", + "valuename": "IEToolbar", + "class": "USER", + "type": "REG_BINARY", + "data": [0] + }, + { + "keyname": "Software\\\\Policies\\\\Microsoft\\\\InputPersonalization", + "valuename": "RestrictImplicitTextCollection", + "class": "USER", + "type": "REG_DWORD", + "data": 1 + }, + { + "keyname": "Software\\\\Policies\\\\Mozilla\\\\Firefox", + "valuename": "ExtensionSettings", + "class": "MACHINE", + "type": "REG_MULTI_SZ", + "data": [ + "{", + " \\"key\\": \\"value\\"", + "}" + ] + } +] +""" + +gpo_remove_json = \ +b""" +[ + { + "keyname": "Software\\\\Policies\\\\Mozilla\\\\Firefox\\\\Homepage", + "valuename": "StartPage", + "class": "USER" + }, + { + "keyname": "Software\\\\Policies\\\\Mozilla\\\\Firefox\\\\Homepage", + "valuename": "URL", + "class": "USER" + }, + { + "keyname": "Software\\\\Microsoft\\\\Internet Explorer\\\\Toolbar", + "valuename": "IEToolbar", + "class": "USER" + }, + { + "keyname": "Software\\\\Policies\\\\Microsoft\\\\InputPersonalization", + "valuename": "RestrictImplicitTextCollection", + "class": "USER" + }, + { + "keyname": "Software\\\\Policies\\\\Mozilla\\\\Firefox", + "valuename": "ExtensionSettings", + "class": "MACHINE" + } +] +""" + +def gpt_ini_version(gpo_guid): + lp = LoadParm() + lp.load(os.environ['SERVERCONFFILE']) + local_path = lp.get('path', 'sysvol') + GPT_INI = os.path.join(local_path, lp.get('realm').lower(), 'Policies', + gpo_guid, 'GPT.INI') + if os.path.exists(GPT_INI): + with open(GPT_INI, 'rb') as f: + data = f.read() + parser = GPTIniParser() + parser.parse(data) + if parser.ini_conf.has_option('General', 'Version'): + version = int(parser.ini_conf.get('General', + 'Version').encode('utf-8')) + else: + version = 0 + else: + version = 0 + return version + +# These are new GUIDs, not used elsewhere, made up for the use of testing the +# adding of extension GUIDs in `samba-tool gpo load`. +ext_guids = ['{123d2b56-7b14-4516-bbc4-763d29d57654}', + '{d000e91b-e70f-481b-9549-58de7929bcee}'] + +source_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../..")) +provision_path = os.path.join(source_path, "source4/selftest/provisions/") + +def has_difference(path1, path2, binary=True, xml=True, sortlines=False): + """Use this function to determine if the GPO backup differs from another. + + xml=True checks whether any xml files are equal + binary=True checks whether any .SAMBABACKUP files are equal + """ + if os.path.isfile(path1): + if sortlines: + file1 = open(path1).readlines() + file1.sort() + file2 = open(path1).readlines() + file2.sort() + if file1 != file2: + return path1 + + elif open(path1).read() != open(path2).read(): + return path1 + + return None + + l_dirs = [ path1 ] + r_dirs = [ path2 ] + while l_dirs: + l_dir = l_dirs.pop() + r_dir = r_dirs.pop() + + dirlist = os.listdir(l_dir) + dirlist_other = os.listdir(r_dir) + + dirlist.sort() + dirlist_other.sort() + if dirlist != dirlist_other: + return dirlist + + for e in dirlist: + l_name = os.path.join(l_dir, e) + r_name = os.path.join(r_dir, e) + + if os.path.isdir(l_name): + l_dirs.append(l_name) + r_dirs.append(r_name) + else: + if (l_name.endswith('.xml') and xml or + l_name.endswith('.SAMBABACKUP') and binary): + if open(l_name, "rb").read() != open(r_name, "rb").read(): + return l_name + + return None + + +class GpoCmdTestCase(SambaToolCmdTest): + """Tests for samba-tool time subcommands""" + + gpo_name = "testgpo" + + # This exists in the source tree to be restored + backup_gpo_guid = "{1E1DC8EA-390C-4800-B327-98B56A0AEA5D}" + + def test_gpo_list(self): + """Run gpo list against the server and make sure it looks accurate""" + (result, out, err) = self.runsubcmd("gpo", "listall", "-H", "ldap://%s" % os.environ["SERVER"]) + self.assertCmdSuccess(result, out, err, "Ensuring gpo listall ran successfully") + + def test_fetchfail(self): + """Run against a non-existent GPO, and make sure it fails (this hard-coded UUID is very unlikely to exist""" + (result, out, err) = self.runsubcmd("gpo", "fetch", "c25cac17-a02a-4151-835d-fae17446ee43", "-H", "ldap://%s" % os.environ["SERVER"]) + self.assertCmdFail(result, "check for result code") + + def test_fetch(self): + """Run against a real GPO, and make sure it passes""" + (result, out, err) = self.runsubcmd("gpo", "fetch", self.gpo_guid, "-H", "ldap://%s" % os.environ["SERVER"], "--tmpdir", self.tempdir) + self.assertCmdSuccess(result, out, err, "Ensuring gpo fetched successfully") + shutil.rmtree(os.path.join(self.tempdir, "policy")) + + def test_show(self): + """Show a real GPO, and make sure it passes""" + (result, out, err) = self.runsubcmd("gpo", "show", self.gpo_guid, "-H", "ldap://%s" % os.environ["SERVER"]) + self.assertCmdSuccess(result, out, err, "Ensuring gpo fetched successfully") + + def test_show_as_admin(self): + """Show a real GPO, and make sure it passes""" + (result, out, err) = self.runsubcmd("gpo", "show", self.gpo_guid, "-H", "ldap://%s" % os.environ["SERVER"], "-U%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Ensuring gpo fetched successfully") + + def test_aclcheck(self): + """Check all the GPOs on the remote server have correct ACLs""" + (result, out, err) = self.runsubcmd("gpo", "aclcheck", "-H", "ldap://%s" % os.environ["SERVER"], "-U%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Ensuring gpo checked successfully") + + def test_getlink_empty(self): + self.samdb = self.getSamDB("-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + + container_dn = 'OU=gpo_test_link,%s' % self.samdb.get_default_basedn() + + self.samdb.add({ + 'dn': container_dn, + 'objectClass': 'organizationalUnit' + }) + + (result, out, err) = self.runsubcmd("gpo", "getlink", container_dn, + "-H", "ldap://%s" % os.environ["SERVER"], + "-U%s%%%s" % (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Ensuring gpo link fetched successfully") + + # Microsoft appears to allow an empty space character after deletion of + # a GPO. We should be able to handle this. + m = ldb.Message() + m.dn = ldb.Dn(self.samdb, container_dn) + m['gPLink'] = ldb.MessageElement(' ', ldb.FLAG_MOD_REPLACE, 'gPLink') + self.samdb.modify(m) + + (result, out, err) = self.runsubcmd("gpo", "getlink", container_dn, + "-H", "ldap://%s" % os.environ["SERVER"], + "-U%s%%%s" % (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Ensuring gpo link fetched successfully") + + self.samdb.delete(container_dn) + + def test_backup_restore_compare_binary(self): + """Restore from a static backup and compare the binary contents""" + + if not os.path.exists(provision_path): + self.skipTest('Test requires provision data not available in ' + + 'release tarball') + + static_path = os.path.join(self.backup_path, 'policy', + self.backup_gpo_guid) + + temp_path = os.path.join(self.tempdir, 'temp') + os.mkdir(temp_path) + + new_path = os.path.join(self.tempdir, 'new') + os.mkdir(new_path) + + gpo_guid = None + try: + (result, out, err) = self.runsubcmd("gpo", "restore", "BACKUP_RESTORE1", + static_path, + "-H", "ldap://%s" % + os.environ["SERVER"], "--tmpdir", + temp_path, "--entities", + self.entity_file, "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"]), + "--restore-metadata") + + self.assertCmdSuccess(result, out, err, + "Ensure gpo restore successful") + + gpo_guid = "{%s}" % out.split("{")[1].split("}")[0] + + (result, out, err) = self.runsubcmd("gpo", "backup", gpo_guid, + "-H", "ldap://%s" % + os.environ["SERVER"], + "--tmpdir", new_path) + + self.assertCmdSuccess(result, out, err, "Ensuring gpo fetched successfully") + + # Compare the directories + self.assertIsNone(has_difference(os.path.join(new_path, 'policy', + gpo_guid), + static_path, binary=True, + xml=False)) + finally: + if gpo_guid: + (result, out, err) = self.runsubcmd("gpo", "del", gpo_guid, + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Ensuring gpo deleted successfully") + + shutil.rmtree(temp_path) + shutil.rmtree(new_path) + + def test_backup_restore_no_entities_compare_binary(self): + """Restore from a static backup (and use no entity file, resulting in + copy-restore fallback), and compare the binary contents""" + + if not os.path.exists(provision_path): + self.skipTest('Test requires provision data not available in ' + + 'release tarball') + + static_path = os.path.join(self.backup_path, 'policy', + self.backup_gpo_guid) + + temp_path = os.path.join(self.tempdir, 'temp') + os.mkdir(temp_path) + + new_path = os.path.join(self.tempdir, 'new') + os.mkdir(new_path) + + gpo_guid = None + gpo_guid1 = None + gpo_guid2 = None + try: + (result, out, err) = self.runsubcmd("gpo", "restore", "BACKUP_RESTORE1", + static_path, + "-H", "ldap://%s" % + os.environ["SERVER"], "--tmpdir", + temp_path, "--entities", + self.entity_file, "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"]), + "--restore-metadata") + + self.assertCmdSuccess(result, out, err, + "Ensure gpo restore successful") + + gpo_guid = "{%s}" % out.split("{")[1].split("}")[0] + gpo_guid1 = gpo_guid + + # Do not output entities file + (result, out, err) = self.runsubcmd("gpo", "backup", gpo_guid, + "-H", "ldap://%s" % + os.environ["SERVER"], + "--tmpdir", new_path, + "--generalize") + + self.assertCmdSuccess(result, out, err, "Ensuring gpo fetched successfully") + + # Do not use an entities file + (result, out, err) = self.runsubcmd("gpo", "restore", "BACKUP_RESTORE2", + os.path.join(new_path, 'policy', gpo_guid1), + "-H", "ldap://%s" % + os.environ["SERVER"], "--tmpdir", + temp_path, "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"]), + "--restore-metadata") + + self.assertCmdSuccess(result, out, err, + "Ensure gpo restore successful") + + gpo_guid = "{%s}" % out.split("{")[1].split("}")[0] + gpo_guid2 = gpo_guid + + self.assertCmdSuccess(result, out, err, "Ensuring gpo restored successfully") + + (result, out, err) = self.runsubcmd("gpo", "backup", gpo_guid, + "-H", "ldap://%s" % + os.environ["SERVER"], + "--tmpdir", new_path) + + # Compare the directories + self.assertIsNone(has_difference(os.path.join(new_path, 'policy', + gpo_guid1), + os.path.join(new_path, 'policy', + gpo_guid2), + binary=True, xml=False)) + finally: + if gpo_guid1: + (result, out, err) = self.runsubcmd("gpo", "del", gpo_guid1, + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Ensuring gpo deleted successfully") + + if gpo_guid2: + (result, out, err) = self.runsubcmd("gpo", "del", gpo_guid2, + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Ensuring gpo deleted successfully") + + shutil.rmtree(temp_path) + shutil.rmtree(new_path) + + def test_backup_restore_backup_compare_XML(self): + """Restore from a static backup and backup to compare XML""" + + if not os.path.exists(provision_path): + self.skipTest('Test requires provision data not available in ' + + 'release tarball') + + static_path = os.path.join(self.backup_path, 'policy', + self.backup_gpo_guid) + + temp_path = os.path.join(self.tempdir, 'temp') + os.mkdir(temp_path) + + new_path = os.path.join(self.tempdir, 'new') + os.mkdir(new_path) + + gpo_guid = None + gpo_guid1 = None + gpo_guid2 = None + try: + (result, out, err) = self.runsubcmd("gpo", "restore", "BACKUP_RESTORE1", + static_path, + "-H", "ldap://%s" % + os.environ["SERVER"], "--tmpdir", + temp_path, "--entities", + self.entity_file, "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"]), + "--restore-metadata") + + self.assertCmdSuccess(result, out, err, + "Ensure gpo restore successful") + + gpo_guid = "{%s}" % out.split("{")[1].split("}")[0] + gpo_guid1 = gpo_guid + + (result, out, err) = self.runsubcmd("gpo", "backup", gpo_guid, + "-H", "ldap://%s" % + os.environ["SERVER"], + "--tmpdir", new_path) + + self.assertCmdSuccess(result, out, err, "Ensuring gpo fetched successfully") + + (result, out, err) = self.runsubcmd("gpo", "restore", "BACKUP_RESTORE2", + os.path.join(new_path, 'policy', gpo_guid1), + "-H", "ldap://%s" % + os.environ["SERVER"], "--tmpdir", + temp_path, "--entities", + self.entity_file, "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"]), + "--restore-metadata") + + self.assertCmdSuccess(result, out, err, + "Ensure gpo restore successful") + + gpo_guid = "{%s}" % out.split("{")[1].split("}")[0] + gpo_guid2 = gpo_guid + + self.assertCmdSuccess(result, out, err, "Ensuring gpo restored successfully") + + (result, out, err) = self.runsubcmd("gpo", "backup", gpo_guid, + "-H", "ldap://%s" % + os.environ["SERVER"], + "--tmpdir", new_path) + + # Compare the directories + self.assertIsNone(has_difference(os.path.join(new_path, 'policy', + gpo_guid1), + os.path.join(new_path, 'policy', + gpo_guid2), + binary=True, xml=True)) + finally: + if gpo_guid1: + (result, out, err) = self.runsubcmd("gpo", "del", gpo_guid1, + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Ensuring gpo deleted successfully") + + if gpo_guid2: + (result, out, err) = self.runsubcmd("gpo", "del", gpo_guid2, + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Ensuring gpo deleted successfully") + + shutil.rmtree(temp_path) + shutil.rmtree(new_path) + + def test_backup_restore_generalize(self): + """Restore from a static backup with different entities, generalize it + again, and compare the XML""" + + if not os.path.exists(provision_path): + self.skipTest('Test requires provision data not available in ' + + 'release tarball') + + static_path = os.path.join(self.backup_path, 'policy', + self.backup_gpo_guid) + + temp_path = os.path.join(self.tempdir, 'temp') + os.mkdir(temp_path) + + new_path = os.path.join(self.tempdir, 'new') + os.mkdir(new_path) + + alt_entity_file = os.path.join(new_path, 'entities') + with open(alt_entity_file, 'wb') as f: + f.write(b'''<!ENTITY SAMBA__NETWORK_PATH__82419dafed126a07d6b96c66fc943735__ "\\\\samdom.example.com"> +<!ENTITY SAMBA__NETWORK_PATH__0484cd41ded45a0728333a9c5e5ef619__ "\\\\samdom"> +<!ENTITY SAMBA____SDDL_ACL____4ce8277be3f630300cbcf80a80e21cf4__ "D:PAR(A;CI;KA;;;BA)(A;CIIO;KA;;;CO)(A;CI;KA;;;SY)(A;CI;KR;;;S-1-16-0)"> +<!ENTITY SAMBA____USER_ID_____d0970f5a1e19cb803f916c203d5c39c4__ "*S-1-5-113"> +<!ENTITY SAMBA____USER_ID_____7b7bc2512ee1fedcd76bdc68926d4f7b__ "Administrator"> +<!ENTITY SAMBA____USER_ID_____a3069f5a7a6530293ad8df6abd32af3d__ "Foobaz"> +<!ENTITY SAMBA____USER_ID_____fdf60b2473b319c8c341de5f62479a7d__ "*S-1-5-32-545"> +<!ENTITY SAMBA____USER_ID_____adb831a7fdd83dd1e2a309ce7591dff8__ "Guest"> +<!ENTITY SAMBA____USER_ID_____9fa835214b4fc8b6102c991f7d97c2f8__ "*S-1-5-32-547"> +<!ENTITY SAMBA____USER_ID_____bf8caafa94a19a6262bad2e8b6d4bce6__ "*S-1-5-32-546"> +<!ENTITY SAMBA____USER_ID_____a45da96d0bf6575970f2d27af22be28a__ "System"> +<!ENTITY SAMBA____USER_ID_____171d33a63ebd67f856552940ed491ad3__ "s-1-5-32-545"> +<!ENTITY SAMBA____USER_ID_____7140932fff16ce85cc64d3caab588d0d__ "s-1-1-0"> +''') + + gen_entity_file = os.path.join(temp_path, 'entities') + + gpo_guid = None + try: + (result, out, err) = self.runsubcmd("gpo", "restore", "BACKUP_RESTORE1", + static_path, + "-H", "ldap://%s" % + os.environ["SERVER"], "--tmpdir", + temp_path, "--entities", + alt_entity_file, "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"]), + "--restore-metadata") + + self.assertCmdSuccess(result, out, err, "Ensuring gpo restored successfully") + + gpo_guid = "{%s}" % out.split("{")[1].split("}")[0] + + (result, out, err) = self.runsubcmd("gpo", "backup", gpo_guid, + "-H", "ldap://%s" % + os.environ["SERVER"], + "--tmpdir", new_path, + "--generalize", "--entities", + gen_entity_file) + + self.assertCmdSuccess(result, out, err, "Ensuring gpo fetched successfully") + + # Assert entity files are identical (except for line order) + self.assertIsNone(has_difference(alt_entity_file, + gen_entity_file, + sortlines=True)) + + # Compare the directories (XML) + self.assertIsNone(has_difference(os.path.join(new_path, 'policy', + gpo_guid), + static_path, binary=False, + xml=True)) + finally: + if gpo_guid: + (result, out, err) = self.runsubcmd("gpo", "del", gpo_guid, + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Ensuring gpo deleted successfully") + + shutil.rmtree(temp_path) + shutil.rmtree(new_path) + + def test_backup_with_extension_attributes(self): + self.samdb = self.getSamDB("-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + + temp_path = os.path.join(self.tempdir, 'temp') + os.mkdir(temp_path) + + extensions = { + # Taken from "source4/setup/provision_group_policy.ldif" on domain + 'gPCMachineExtensionNames': '[{35378EAC-683F-11D2-A89A-00C04FBBCFA2}{53D6AB1B-2488-11D1-A28C-00C04FB94F17}][{827D319E-6EAC-11D2-A4EA-00C04F79F83A}{803E14A0-B4FB-11D0-A0D0-00A0C90F574B}][{B1BE8D72-6EAC-11D2-A4EA-00C04F79F83A}{53D6AB1B-2488-11D1-A28C-00C04FB94F17}]', + 'gPCUserExtensionNames': '[{3060E8D0-7020-11D2-842D-00C04FA372D4}{3060E8CE-7020-11D2-842D-00C04FA372D4}][{35378EAC-683F-11D2-A89A-00C04FBBCFA2}{0F6B957E-509E-11D1-A7CC-0000F87571E3}]' + } + + gpo_dn = get_gpo_dn(self.samdb, self.gpo_guid) + for ext in extensions: + data = extensions[ext] + + m = ldb.Message() + m.dn = gpo_dn + m[ext] = ldb.MessageElement(data, ldb.FLAG_MOD_REPLACE, ext) + + self.samdb.modify(m) + + try: + (result, out, err) = self.runsubcmd("gpo", "backup", self.gpo_guid, + "-H", "ldap://%s" % + os.environ["SERVER"], + "--tmpdir", temp_path) + + self.assertCmdSuccess(result, out, err, "Ensuring gpo fetched successfully") + + guid = "{%s}" % out.split("{")[1].split("}")[0] + + temp_path = os.path.join(temp_path, 'policy', guid) + + (result, out, err) = self.runsubcmd("gpo", "restore", "RESTORE_EXT", + temp_path, + "-H", "ldap://%s" % + os.environ["SERVER"], "--tmpdir", + self.tempdir, "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"]), + "--restore-metadata") + + self.assertCmdSuccess(result, out, err, "Ensuring gpo restored successfully") + + gpo_guid = "{%s}" % out.split("{")[1].split("}")[0] + + msg = get_gpo_info(self.samdb, gpo_guid) + self.assertEqual(len(msg), 1) + + for ext in extensions: + self.assertTrue(ext in msg[0]) + self.assertEqual(extensions[ext], str(msg[0][ext][0])) + + finally: + if gpo_guid: + (result, out, err) = self.runsubcmd("gpo", "del", gpo_guid, + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Ensuring gpo deleted successfully") + + shutil.rmtree(os.path.join(self.tempdir, "policy")) + shutil.rmtree(os.path.join(self.tempdir, 'temp')) + + def test_admx_load(self): + lp = LoadParm() + lp.load(os.environ['SERVERCONFFILE']) + local_path = lp.get('path', 'sysvol') + admx_path = os.path.join(local_path, os.environ['REALM'].lower(), + 'Policies', 'PolicyDefinitions') + (result, out, err) = self.runsubcmd("gpo", "admxload", + "-H", "ldap://%s" % + os.environ["SERVER"], + "--admx-dir=%s" % + os.path.join(source_path, + 'libgpo/admx'), + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, + 'Filling PolicyDefinitions failed') + self.assertTrue(os.path.exists(admx_path), + 'PolicyDefinitions was not created') + self.assertTrue(os.path.exists(os.path.join(admx_path, 'samba.admx')), + 'Filling PolicyDefinitions failed') + shutil.rmtree(admx_path) + + def test_smb_conf_set(self): + lp = LoadParm() + lp.load(os.environ['SERVERCONFFILE']) + local_path = lp.get('path', 'sysvol') + reg_pol = os.path.join(local_path, lp.get('realm').lower(), 'Policies', + self.gpo_guid, 'Machine/Registry.pol') + + policy = 'apply group policies' + before_vers = gpt_ini_version(self.gpo_guid) + (result, out, err) = self.runsublevelcmd("gpo", ("manage", "smb_conf", + "set"), self.gpo_guid, + policy, "yes", + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, + 'Failed to set apply group policies') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertGreater(after_vers, before_vers, 'GPT.INI was not updated') + + self.assertTrue(os.path.exists(reg_pol), + 'The Registry.pol does not exist') + reg_data = ndr_unpack(preg.file, open(reg_pol, 'rb').read()) + ret = any([get_string(e.valuename) == policy and e.data == 1 + for e in reg_data.entries]) + self.assertTrue(ret, 'The sudoers entry was not added') + + before_vers = after_vers + # Ensure an empty set command deletes the entry + (result, out, err) = self.runsublevelcmd("gpo", ("manage", "smb_conf", + "set"), self.gpo_guid, + policy, "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, + 'Failed to unset apply group policies') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertGreater(after_vers, before_vers, 'GPT.INI was not updated') + + reg_data = ndr_unpack(preg.file, open(reg_pol, 'rb').read()) + ret = not any([get_string(e.valuename) == policy and e.data == 1 + for e in reg_data.entries]) + self.assertTrue(ret, 'The sudoers entry was not removed') + + def test_smb_conf_list(self): + lp = LoadParm() + lp.load(os.environ['SERVERCONFFILE']) + local_path = lp.get('path', 'sysvol') + reg_pol = os.path.join(local_path, lp.get('realm').lower(), 'Policies', + self.gpo_guid, 'Machine/Registry.pol') + + # Stage the Registry.pol file with test data + stage = preg.file() + e = preg.entry() + e.keyname = b'Software\\Policies\\Samba\\smb_conf' + e.valuename = b'apply group policies' + e.type = 4 + e.data = 1 + stage.num_entries = 1 + stage.entries = [e] + ret = stage_file(reg_pol, ndr_pack(stage)) + self.assertTrue(ret, 'Could not create the target %s' % reg_pol) + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", "smb_conf", + "list"), self.gpo_guid, + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertIn('%s = True' % e.valuename, out, 'The test entry was not found!') + + # Unstage the Registry.pol file + unstage_file(reg_pol) + + def test_security_set(self): + lp = LoadParm() + lp.load(os.environ['SERVERCONFFILE']) + local_path = lp.get('path', 'sysvol') + inf_pol = os.path.join(local_path, lp.get('realm').lower(), 'Policies', + self.gpo_guid, 'Machine/Microsoft/Windows NT/SecEdit/GptTmpl.inf') + + before_vers = gpt_ini_version(self.gpo_guid) + (result, out, err) = self.runsublevelcmd("gpo", ("manage", "security", + "set"), self.gpo_guid, + 'MaxTicketAge', '10', + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, + 'Failed to set MaxTicketAge') + self.assertTrue(os.path.exists(inf_pol), + '%s was not created' % inf_pol) + inf_pol_contents = open(inf_pol, 'r').read() + self.assertIn('MaxTicketAge = 10', inf_pol_contents, + 'The test entry was not found!') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertGreater(after_vers, before_vers, 'GPT.INI was not updated') + + before_vers = after_vers + # Ensure an empty set command deletes the entry + (result, out, err) = self.runsublevelcmd("gpo", ("manage", "security", + "set"), self.gpo_guid, + 'MaxTicketAge', + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, + 'Failed to unset MaxTicketAge') + inf_pol_contents = open(inf_pol, 'r').read() + self.assertNotIn('MaxTicketAge = 10', inf_pol_contents, + 'The test entry was still found!') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertGreater(after_vers, before_vers, 'GPT.INI was not updated') + + def test_security_list(self): + (result, out, err) = self.runsublevelcmd("gpo", ("manage", "security", + "set"), self.gpo_guid, + 'MaxTicketAge', '10', + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, + 'Failed to set MaxTicketAge') + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", "security", + "list"), self.gpo_guid, + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertIn('MaxTicketAge = 10', out, 'The test entry was not found!') + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", "security", + "set"), self.gpo_guid, + 'MaxTicketAge', + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, + 'Failed to unset MaxTicketAge') + + def test_security_nonempty_sections(self): + lp = LoadParm() + lp.load(os.environ['SERVERCONFFILE']) + local_path = lp.get('path', 'sysvol') + gpt_inf = os.path.join(local_path, lp.get('realm').lower(), 'Policies', + self.gpo_guid, 'Machine/Microsoft/Windows NT', + 'SecEdit/GptTmpl.inf') + + before_vers = gpt_ini_version(self.gpo_guid) + (result, out, err) = self.runsublevelcmd("gpo", ("manage", "security", + "set"), self.gpo_guid, + 'MaxTicketAge', '10', + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, + 'Failed to set MaxTicketAge') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertGreater(after_vers, before_vers, 'GPT.INI was not updated') + + before_vers = after_vers + (result, out, err) = self.runsublevelcmd("gpo", ("manage", "security", + "set"), self.gpo_guid, + 'MaxTicketAge', + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, + 'Failed to unset MaxTicketAge') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertGreater(after_vers, before_vers, 'GPT.INI was not updated') + + inf_data = ConfigParser(interpolation=None) + inf_data.read(gpt_inf) + + self.assertFalse(inf_data.has_section('Kerberos Policy')) + + def test_sudoers_add(self): + lp = LoadParm() + lp.load(os.environ['SERVERCONFFILE']) + local_path = lp.get('path', 'sysvol') + reg_pol = os.path.join(local_path, lp.get('realm').lower(), 'Policies', + self.gpo_guid, 'Machine/Registry.pol') + + # Stage the Registry.pol file with test data + stage = preg.file() + e = preg.entry() + e.keyname = b'Software\\Policies\\Samba\\Unix Settings\\Sudo Rights' + e.valuename = b'Software\\Policies\\Samba\\Unix Settings' + e.type = 1 + e.data = b'fakeu ALL=(ALL) NOPASSWD: ALL' + stage.num_entries = 1 + stage.entries = [e] + ret = stage_file(reg_pol, ndr_pack(stage)) + self.assertTrue(ret, 'Could not create the target %s' % reg_pol) + + before_vers = gpt_ini_version(self.gpo_guid) + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "sudoers", "add"), + self.gpo_guid, 'ALL', 'ALL', + 'fakeu', 'fakeg', "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'Sudoers add failed') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertGreater(after_vers, before_vers, 'GPT.INI was not updated') + + sudoer = 'fakeu,fakeg% ALL=(ALL) NOPASSWD: ALL' + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "sudoers", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertIn(sudoer, out, 'The test entry was not found!') + self.assertIn(get_string(e.data), out, 'The test entry was not found!') + + before_vers = after_vers + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "sudoers", "remove"), + self.gpo_guid, sudoer, + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'Sudoers remove failed') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertGreater(after_vers, before_vers, 'GPT.INI was not updated') + + before_vers = after_vers + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "sudoers", "remove"), + self.gpo_guid, + get_string(e.data), + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'Sudoers remove failed') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertGreater(after_vers, before_vers, 'GPT.INI was not updated') + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "sudoers", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertNotIn(sudoer, out, 'The test entry was still found!') + self.assertNotIn(get_string(e.data), out, + 'The test entry was still found!') + + # Unstage the Registry.pol file + unstage_file(reg_pol) + + def test_sudoers_list(self): + lp = LoadParm() + lp.load(os.environ['SERVERCONFFILE']) + local_path = lp.get('path', 'sysvol') + vgp_xml = os.path.join(local_path, lp.get('realm').lower(), 'Policies', + self.gpo_guid, 'Machine/VGP/VTLA/Sudo', + 'SudoersConfiguration/manifest.xml') + + stage = etree.Element('vgppolicy') + policysetting = etree.SubElement(stage, 'policysetting') + pv = etree.SubElement(policysetting, 'version') + pv.text = '1' + name = etree.SubElement(policysetting, 'name') + name.text = 'Sudo Policy' + description = etree.SubElement(policysetting, 'description') + description.text = 'Sudoers File Configuration Policy' + apply_mode = etree.SubElement(policysetting, 'apply_mode') + apply_mode.text = 'merge' + data = etree.SubElement(policysetting, 'data') + load_plugin = etree.SubElement(data, 'load_plugin') + load_plugin.text = 'true' + sudoers_entry = etree.SubElement(data, 'sudoers_entry') + command = etree.SubElement(sudoers_entry, 'command') + command.text = 'ALL' + user = etree.SubElement(sudoers_entry, 'user') + user.text = 'ALL' + listelement = etree.SubElement(sudoers_entry, 'listelement') + principal = etree.SubElement(listelement, 'principal') + principal.text = 'fakeu' + principal.attrib['type'] = 'user' + # Ensure an empty principal doesn't cause a crash + sudoers_entry = etree.SubElement(data, 'sudoers_entry') + command = etree.SubElement(sudoers_entry, 'command') + command.text = 'ALL' + user = etree.SubElement(sudoers_entry, 'user') + user.text = 'ALL' + # Ensure having dispersed principals still works + sudoers_entry = etree.SubElement(data, 'sudoers_entry') + command = etree.SubElement(sudoers_entry, 'command') + command.text = 'ALL' + user = etree.SubElement(sudoers_entry, 'user') + user.text = 'ALL' + listelement = etree.SubElement(sudoers_entry, 'listelement') + principal = etree.SubElement(listelement, 'principal') + principal.text = 'fakeu2' + principal.attrib['type'] = 'user' + listelement = etree.SubElement(sudoers_entry, 'listelement') + group = etree.SubElement(listelement, 'principal') + group.text = 'fakeg2' + group.attrib['type'] = 'group' + ret = stage_file(vgp_xml, etree.tostring(stage, 'utf-8')) + self.assertTrue(ret, 'Could not create the target %s' % vgp_xml) + + reg_pol = os.path.join(local_path, lp.get('realm').lower(), 'Policies', + self.gpo_guid, 'Machine/Registry.pol') + + # Stage the Registry.pol file with test data + stage = preg.file() + e = preg.entry() + e.keyname = b'Software\\Policies\\Samba\\Unix Settings\\Sudo Rights' + e.valuename = b'Software\\Policies\\Samba\\Unix Settings' + e.type = 1 + e.data = b'fakeu3 ALL=(ALL) NOPASSWD: ALL' + stage.num_entries = 1 + stage.entries = [e] + ret = stage_file(reg_pol, ndr_pack(stage)) + self.assertTrue(ret, 'Could not create the target %s' % reg_pol) + + sudoer = 'fakeu ALL=(ALL) NOPASSWD: ALL' + sudoer2 = 'fakeu2,fakeg2% ALL=(ALL) NOPASSWD: ALL' + sudoer_no_principal = 'ALL ALL=(ALL) NOPASSWD: ALL' + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "sudoers", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'Sudoers list failed') + self.assertIn(sudoer, out, 'The test entry was not found!') + self.assertIn(sudoer2, out, 'The test entry was not found!') + self.assertIn(get_string(e.data), out, 'The test entry was not found!') + self.assertIn(sudoer_no_principal, out, + 'The test entry was not found!') + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "sudoers", "remove"), + self.gpo_guid, sudoer2, + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'Sudoers remove failed') + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "sudoers", "remove"), + self.gpo_guid, + sudoer_no_principal, + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'Sudoers remove failed') + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "sudoers", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertNotIn(sudoer2, out, 'The test entry was still found!') + self.assertNotIn(sudoer_no_principal, out, + 'The test entry was still found!') + + # Unstage the manifest.xml file + unstage_file(vgp_xml) + # Unstage the Registry.pol file + unstage_file(reg_pol) + + def test_symlink_list(self): + lp = LoadParm() + lp.load(os.environ['SERVERCONFFILE']) + local_path = lp.get('path', 'sysvol') + vgp_xml = os.path.join(local_path, lp.get('realm').lower(), 'Policies', + self.gpo_guid, 'Machine/VGP/VTLA/Unix', + 'Symlink/manifest.xml') + stage = etree.Element('vgppolicy') + policysetting = etree.SubElement(stage, 'policysetting') + pv = etree.SubElement(policysetting, 'version') + pv.text = '1' + name = etree.SubElement(policysetting, 'name') + name.text = 'Symlink Policy' + description = etree.SubElement(policysetting, 'description') + description.text = 'Specifies symbolic link data' + apply_mode = etree.SubElement(policysetting, 'apply_mode') + apply_mode.text = 'merge' + data = etree.SubElement(policysetting, 'data') + file_properties = etree.SubElement(data, 'file_properties') + source = etree.SubElement(file_properties, 'source') + source.text = os.path.join(self.tempdir, 'test.source') + target = etree.SubElement(file_properties, 'target') + target.text = os.path.join(self.tempdir, 'test.target') + ret = stage_file(vgp_xml, etree.tostring(stage, 'utf-8')) + self.assertTrue(ret, 'Could not create the target %s' % vgp_xml) + + symlink = 'ln -s %s %s' % (source.text, target.text) + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "symlink", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertIn(symlink, out, 'The test entry was not found!') + + # Unstage the manifest.xml file + unstage_file(vgp_xml) + + def test_symlink_add(self): + source_text = os.path.join(self.tempdir, 'test.source') + target_text = os.path.join(self.tempdir, 'test.target') + symlink = 'ln -s %s %s' % (source_text, target_text) + before_vers = gpt_ini_version(self.gpo_guid) + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "symlink", "add"), + self.gpo_guid, + source_text, target_text, + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'Symlink add failed') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertGreater(after_vers, before_vers, 'GPT.INI was not updated') + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "symlink", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertIn(symlink, out, 'The test entry was not found!') + + before_vers = after_vers + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "symlink", "remove"), + self.gpo_guid, + source_text, target_text, + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'Symlink remove failed') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertGreater(after_vers, before_vers, 'GPT.INI was not updated') + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "symlink", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertNotIn(symlink, out, 'The test entry was not removed!') + + def test_files_list(self): + lp = LoadParm() + lp.load(os.environ['SERVERCONFFILE']) + local_path = lp.get('path', 'sysvol') + vgp_xml = os.path.join(local_path, lp.get('realm').lower(), 'Policies', + self.gpo_guid, 'Machine/VGP/VTLA/Unix', + 'Files/manifest.xml') + source_file = os.path.join(local_path, lp.get('realm').lower(), + 'Policies', self.gpo_guid, 'Machine/VGP', + 'VTLA/Unix/Files/test.source') + stage = etree.Element('vgppolicy') + policysetting = etree.SubElement(stage, 'policysetting') + pv = etree.SubElement(policysetting, 'version') + pv.text = '1' + name = etree.SubElement(policysetting, 'name') + name.text = 'Files' + description = etree.SubElement(policysetting, 'description') + description.text = 'Represents file data to set/copy on clients' + data = etree.SubElement(policysetting, 'data') + file_properties = etree.SubElement(data, 'file_properties') + source = etree.SubElement(file_properties, 'source') + source.text = source_file + target = etree.SubElement(file_properties, 'target') + target.text = os.path.join(self.tempdir, 'test.target') + user = etree.SubElement(file_properties, 'user') + user.text = pwd.getpwuid(os.getuid()).pw_name + group = etree.SubElement(file_properties, 'group') + group.text = grp.getgrgid(os.getgid()).gr_name + + # Request permissions of 755 + permissions = etree.SubElement(file_properties, 'permissions') + permissions.set('type', 'user') + etree.SubElement(permissions, 'read') + etree.SubElement(permissions, 'write') + etree.SubElement(permissions, 'execute') + permissions = etree.SubElement(file_properties, 'permissions') + permissions.set('type', 'group') + etree.SubElement(permissions, 'read') + etree.SubElement(permissions, 'execute') + permissions = etree.SubElement(file_properties, 'permissions') + permissions.set('type', 'other') + etree.SubElement(permissions, 'read') + etree.SubElement(permissions, 'execute') + + ret = stage_file(vgp_xml, etree.tostring(stage, 'utf-8')) + self.assertTrue(ret, 'Could not create the target %s' % vgp_xml) + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "files", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertIn(target.text, out, 'The test entry was not found!') + self.assertIn('-rwxr-xr-x', out, + 'The test entry permissions were not found') + + # Unstage the manifest.xml file + unstage_file(vgp_xml) + + def test_files_add(self): + lp = LoadParm() + lp.load(os.environ['SERVERCONFFILE']) + local_path = lp.get('path', 'sysvol') + sysvol_source = os.path.join(local_path, lp.get('realm').lower(), + 'Policies', self.gpo_guid, 'Machine/VGP', + 'VTLA/Unix/Files/test.source') + source_file = os.path.join(self.tempdir, 'test.source') + source_data = '#!/bin/sh\necho hello world' + with open(source_file, 'w') as w: + w.write(source_data) + target_file = os.path.join(self.tempdir, 'test.target') + user = pwd.getpwuid(os.getuid()).pw_name + group = grp.getgrgid(os.getgid()).gr_name + before_vers = gpt_ini_version(self.gpo_guid) + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "files", "add"), + self.gpo_guid, + source_file, + target_file, + user, group, + '755', "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'File add failed') + self.assertIn(source_data, open(sysvol_source, 'r').read(), + 'Failed to find the source file on the sysvol') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertGreater(after_vers, before_vers, 'GPT.INI was not updated') + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "files", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertIn(target_file, out, 'The test entry was not found!') + self.assertIn('-rwxr-xr-x', out, + 'The test entry permissions were not found') + + os.unlink(source_file) + + before_vers = after_vers + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "files", "remove"), + self.gpo_guid, + target_file, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'File remove failed') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertGreater(after_vers, before_vers, 'GPT.INI was not updated') + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "files", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertNotIn(target_file, out, 'The test entry was still found!') + + def test_vgp_openssh_list(self): + lp = LoadParm() + lp.load(os.environ['SERVERCONFFILE']) + local_path = lp.get('path', 'sysvol') + vgp_xml = os.path.join(local_path, lp.get('realm').lower(), 'Policies', + self.gpo_guid, 'Machine/VGP/VTLA/SshCfg', + 'SshD/manifest.xml') + + stage = etree.Element('vgppolicy') + policysetting = etree.SubElement(stage, 'policysetting') + pv = etree.SubElement(policysetting, 'version') + pv.text = '1' + name = etree.SubElement(policysetting, 'name') + name.text = 'Configuration File' + description = etree.SubElement(policysetting, 'description') + description.text = 'Represents Unix configuration file settings' + apply_mode = etree.SubElement(policysetting, 'apply_mode') + apply_mode.text = 'merge' + data = etree.SubElement(policysetting, 'data') + configfile = etree.SubElement(data, 'configfile') + etree.SubElement(configfile, 'filename') + configsection = etree.SubElement(configfile, 'configsection') + etree.SubElement(configsection, 'sectionname') + opt = etree.SubElement(configsection, 'keyvaluepair') + key = etree.SubElement(opt, 'key') + key.text = 'KerberosAuthentication' + value = etree.SubElement(opt, 'value') + value.text = 'Yes' + ret = stage_file(vgp_xml, etree.tostring(stage, 'utf-8')) + self.assertTrue(ret, 'Could not create the target %s' % vgp_xml) + + openssh = 'KerberosAuthentication Yes' + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "openssh", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertIn(openssh, out, 'The test entry was not found!') + + # Unstage the manifest.xml file + unstage_file(vgp_xml) + + def test_vgp_openssh_set(self): + before_vers = gpt_ini_version(self.gpo_guid) + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "openssh", "set"), + self.gpo_guid, + "KerberosAuthentication", + "Yes", "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'OpenSSH set failed') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertGreater(after_vers, before_vers, 'GPT.INI was not updated') + + openssh = 'KerberosAuthentication Yes' + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "openssh", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertIn(openssh, out, 'The test entry was not found!') + + before_vers = after_vers + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "openssh", "set"), + self.gpo_guid, + "KerberosAuthentication", "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'OpenSSH unset failed') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertGreater(after_vers, before_vers, 'GPT.INI was not updated') + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "openssh", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertNotIn(openssh, out, 'The test entry was still found!') + + def test_startup_script_add(self): + lp = LoadParm() + fname = None + before_vers = gpt_ini_version(self.gpo_guid) + with NamedTemporaryFile() as f: + fname = os.path.basename(f.name) + f.write(b'#!/bin/sh\necho $@ hello world') + f.flush() + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "scripts", "startup", + "add"), self.gpo_guid, + f.name, "'-n'", "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'Script add failed') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertGreater(after_vers, before_vers, 'GPT.INI was not updated') + + script_path = '\\'.join(['\\', lp.get('realm').lower(), 'Policies', + self.gpo_guid, 'MACHINE\\VGP\\VTLA\\Unix', + 'Scripts\\Startup', fname]) + entry = '@reboot root %s -n' % script_path + (result, out, err) = self.runsublevelcmd("gpo", ("manage", "scripts", + "startup", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertIn(entry, out, 'The test entry was not found!') + local_path = lp.get('path', 'sysvol') + local_script_path = os.path.join(local_path, lp.get('realm').lower(), + 'Policies', self.gpo_guid, + 'Machine/VGP/VTLA/Unix', + 'Scripts/Startup', fname) + self.assertTrue(os.path.exists(local_script_path), + 'The test script was not uploaded to the sysvol') + + before_vers = after_vers + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "scripts", "startup", + "remove"), self.gpo_guid, + f.name, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'Script remove failed') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertGreater(after_vers, before_vers, 'GPT.INI was not updated') + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", "scripts", + "startup", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertNotIn(entry, out, 'The test entry was still found!') + + def test_startup_script_list(self): + lp = LoadParm() + lp.load(os.environ['SERVERCONFFILE']) + local_path = lp.get('path', 'sysvol') + vgp_xml = os.path.join(local_path, lp.get('realm').lower(), 'Policies', + self.gpo_guid, 'Machine/VGP/VTLA/Unix', + 'Scripts/Startup/manifest.xml') + stage = etree.Element('vgppolicy') + policysetting = etree.SubElement(stage, 'policysetting') + pv = etree.SubElement(policysetting, 'version') + pv.text = '1' + name = etree.SubElement(policysetting, 'name') + name.text = 'Unix Scripts' + description = etree.SubElement(policysetting, 'description') + description.text = 'Represents Unix scripts to run on Group Policy clients' + data = etree.SubElement(policysetting, 'data') + listelement = etree.SubElement(data, 'listelement') + script = etree.SubElement(listelement, 'script') + script.text = 'test.sh' + parameters = etree.SubElement(listelement, 'parameters') + parameters.text = '-e' + ret = stage_file(vgp_xml, etree.tostring(stage, 'utf-8')) + self.assertTrue(ret, 'Could not create the target %s' % vgp_xml) + + script_path = '\\'.join(['\\', lp.get('realm').lower(), 'Policies', + self.gpo_guid, 'MACHINE\\VGP\\VTLA\\Unix', + 'Scripts\\Startup', script.text]) + entry = '@reboot root %s %s' % (script_path, parameters.text) + (result, out, err) = self.runsublevelcmd("gpo", ("manage", "scripts", + "startup", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertIn(entry, out, 'The test entry was not found!') + + # Unstage the manifest.xml file + unstage_file(vgp_xml) + + def test_vgp_motd_set(self): + text = 'This is the message of the day' + msg = '"%s\n"' % text + before_vers = gpt_ini_version(self.gpo_guid) + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "motd", "set"), + self.gpo_guid, + msg, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'MOTD set failed') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertGreater(after_vers, before_vers, 'GPT.INI was not updated') + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "motd", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertIn(text, out, 'The test entry was not found!') + + before_vers = after_vers + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "motd", "set"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'MOTD unset failed') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertGreater(after_vers, before_vers, 'GPT.INI was not updated') + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "motd", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertNotIn(text, out, 'The test entry was still found!') + + def test_vgp_motd(self): + lp = LoadParm() + lp.load(os.environ['SERVERCONFFILE']) + local_path = lp.get('path', 'sysvol') + vgp_xml = os.path.join(local_path, lp.get('realm').lower(), 'Policies', + self.gpo_guid, 'Machine/VGP/VTLA/Unix', + 'MOTD/manifest.xml') + + stage = etree.Element('vgppolicy') + policysetting = etree.SubElement(stage, 'policysetting') + pv = etree.SubElement(policysetting, 'version') + pv.text = '1' + name = etree.SubElement(policysetting, 'name') + name.text = 'Text File' + description = etree.SubElement(policysetting, 'description') + description.text = 'Represents a Generic Text File' + apply_mode = etree.SubElement(policysetting, 'apply_mode') + apply_mode.text = 'replace' + data = etree.SubElement(policysetting, 'data') + filename = etree.SubElement(data, 'filename') + filename.text = 'motd' + text = etree.SubElement(data, 'text') + text.text = 'This is a message of the day' + ret = stage_file(vgp_xml, etree.tostring(stage, 'utf-8')) + self.assertTrue(ret, 'Could not create the target %s' % vgp_xml) + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "motd", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertIn(text.text, out, 'The test entry was not found!') + + # Unstage the manifest.xml file + unstage_file(vgp_xml) + + def test_vgp_issue_list(self): + lp = LoadParm() + lp.load(os.environ['SERVERCONFFILE']) + local_path = lp.get('path', 'sysvol') + vgp_xml = os.path.join(local_path, lp.get('realm').lower(), 'Policies', + self.gpo_guid, 'Machine/VGP/VTLA/Unix', + 'Issue/manifest.xml') + + stage = etree.Element('vgppolicy') + policysetting = etree.SubElement(stage, 'policysetting') + pv = etree.SubElement(policysetting, 'version') + pv.text = '1' + name = etree.SubElement(policysetting, 'name') + name.text = 'Text File' + description = etree.SubElement(policysetting, 'description') + description.text = 'Represents a Generic Text File' + apply_mode = etree.SubElement(policysetting, 'apply_mode') + apply_mode.text = 'replace' + data = etree.SubElement(policysetting, 'data') + filename = etree.SubElement(data, 'filename') + filename.text = 'issue' + text = etree.SubElement(data, 'text') + text.text = 'Welcome to Samba!' + ret = stage_file(vgp_xml, etree.tostring(stage, 'utf-8')) + self.assertTrue(ret, 'Could not create the target %s' % vgp_xml) + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "issue", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertIn(text.text, out, 'The test entry was not found!') + + # Unstage the manifest.xml file + unstage_file(vgp_xml) + + def test_vgp_issue_set(self): + text = 'Welcome to Samba!' + msg = '"%s\n"' % text + before_vers = gpt_ini_version(self.gpo_guid) + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "issue", "set"), + self.gpo_guid, + msg, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'Issue set failed') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertGreater(after_vers, before_vers, 'GPT.INI was not updated') + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "issue", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertIn(text, out, 'The test entry was not found!') + + before_vers = after_vers + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "issue", "set"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'Issue unset failed') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertGreater(after_vers, before_vers, 'GPT.INI was not updated') + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "issue", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertNotIn(text, out, 'The test entry was still found!') + + def test_load_show_remove(self): + before_vers = gpt_ini_version(self.gpo_guid) + with NamedTemporaryFile() as f: + f.write(gpo_load_json) + f.flush() + (result, out, err) = self.runsubcmd("gpo", "load", + self.gpo_guid, + "--content=%s" % f.name, + "--machine-ext-name=%s" % + ext_guids[0], + "--user-ext-name=%s" % + ext_guids[1], + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'Loading policy failed') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertGreater(after_vers, before_vers, 'GPT.INI was not updated') + + before_vers = after_vers + # Write the default registry extension + with NamedTemporaryFile() as f: + f.write(b'[]') # Intentionally empty policy + f.flush() + # Load an empty policy, taking the default client extension + (result, out, err) = self.runsubcmd("gpo", "load", + self.gpo_guid, + "--content=%s" % f.name, + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'Loading policy failed') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertEqual(after_vers, before_vers, + 'GPT.INI changed on empty merge') + + (result, out, err) = self.runsubcmd("gpo", "show", self.gpo_guid, "-H", + "ldap://%s" % os.environ["SERVER"]) + self.assertCmdSuccess(result, out, err, 'Failed to fetch gpos') + self.assertIn('homepage', out, 'Homepage policy not loaded') + self.assertIn('samba.org', out, 'Homepage policy not loaded') + self.assertIn(ext_guids[0], out, 'Machine extension not loaded') + self.assertIn(ext_guids[1], out, 'User extension not loaded') + self.assertIn('{35378eac-683f-11d2-a89a-00c04fbbcfa2}', out, + 'Default extension not loaded') + toolbar_data = '"valuename": "IEToolbar",\n "class": "USER",' + \ + '\n "type": "REG_BINARY",' + \ + '\n "data": [\n 0\n ]' + self.assertIn(toolbar_data, out, 'Toolbar policy not loaded') + restrict_data = '"valuename": "RestrictImplicitTextCollection",' + \ + '\n "class": "USER",' + \ + '\n "type": "REG_DWORD",\n "data": 1\n' + self.assertIn(restrict_data, out, 'Restrict policy not loaded') + ext_data = '" \\"key\\": \\"value\\"",' + self.assertIn(ext_data, out, 'Extension policy not loaded') + + before_vers = after_vers + with NamedTemporaryFile() as f: + f.write(gpo_remove_json) + f.flush() + (result, out, err) = self.runsubcmd("gpo", "remove", + self.gpo_guid, + "--content=%s" % f.name, + "--machine-ext-name=%s" % + ext_guids[0], + "--user-ext-name=%s" % + ext_guids[1], + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'Removing policy failed') + after_vers = gpt_ini_version(self.gpo_guid) + self.assertGreater(after_vers, before_vers, 'GPT.INI was not updated') + + (result, out, err) = self.runsubcmd("gpo", "show", self.gpo_guid, "-H", + "ldap://%s" % os.environ["SERVER"]) + self.assertCmdSuccess(result, out, err, 'Failed to fetch gpos') + self.assertNotIn('samba.org', out, 'Homepage policy not removed') + self.assertNotIn(ext_guids[0], out, 'Machine extension not unloaded') + self.assertNotIn(ext_guids[1], out, 'User extension not unloaded') + + def test_cse_register_unregister_list(self): + with NamedTemporaryFile() as f: + (result, out, err) = self.runsublevelcmd("gpo", ("cse", + "register"), + f.name, 'gp_test_ext', + '--machine') + self.assertCmdSuccess(result, out, err, 'CSE register failed') + + (result, out, err) = self.runsublevelcmd("gpo", ("cse", + "list")) + self.assertIn(f.name, out, 'The test cse was not found') + self.assertIn('ProcessGroupPolicy : gp_test_ext', out, + 'The test cse was not found') + self.assertIn('MachinePolicy : True', out, + 'The test cse was not enabled') + self.assertIn('UserPolicy : False', out, + 'The test cse should not have User policy enabled') + cse_ext = re.findall(r'^UniqueGUID\s+:\s+(.*)', out) + self.assertEqual(len(cse_ext), 1, + 'The test cse GUID was not found') + cse_ext = cse_ext[0] + self.assertTrue(check_guid(cse_ext), + 'The test cse GUID was not formatted correctly') + + (result, out, err) = self.runsublevelcmd("gpo", ("cse", + "unregister"), + cse_ext) + self.assertCmdSuccess(result, out, err, 'CSE unregister failed') + + (result, out, err) = self.runsublevelcmd("gpo", ("cse", + "list")) + self.assertNotIn(f.name, out, 'The test cse was still found') + + def setUp(self): + """set up a temporary GPO to work with""" + super().setUp() + (result, out, err) = self.runsubcmd("gpo", "create", self.gpo_name, + "-H", "ldap://%s" % os.environ["SERVER"], + "-U%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"]), + "--tmpdir", self.tempdir) + self.assertCmdSuccess(result, out, err, "Ensuring gpo created successfully") + shutil.rmtree(os.path.join(self.tempdir, "policy")) + try: + self.gpo_guid = "{%s}" % out.split("{")[1].split("}")[0] + except IndexError: + self.fail("Failed to find GUID in output: %s" % out) + + self.backup_path = os.path.join(samba.source_tree_topdir(), 'source4', + 'selftest', 'provisions', + 'generalized-gpo-backup') + + self.entity_file = os.path.join(self.backup_path, 'entities') + + def tearDown(self): + """remove the temporary GPO to work with""" + (result, out, err) = self.runsubcmd("gpo", "del", self.gpo_guid, "-H", "ldap://%s" % os.environ["SERVER"], "-U%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Ensuring gpo deleted successfully") + super().tearDown() diff --git a/python/samba/tests/samba_tool/gpo_exts.py b/python/samba/tests/samba_tool/gpo_exts.py new file mode 100644 index 0000000..e7a24b0 --- /dev/null +++ b/python/samba/tests/samba_tool/gpo_exts.py @@ -0,0 +1,202 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) David Mulder 2021 +# +# based on gpo.py: +# Copyright (C) Andrew Bartlett 2012 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +from samba.tests.samba_tool.base import SambaToolCmdTest +import shutil +from samba.param import LoadParm +from samba.tests.gpo import stage_file, unstage_file +import xml.etree.ElementTree as etree + +class GpoCmdTestCase(SambaToolCmdTest): + """Tests for samba-tool time subcommands""" + + gpo_name = "testgpo" + + def test_vgp_access_list(self): + lp = LoadParm() + lp.load(os.environ['SERVERCONFFILE']) + local_path = lp.get('path', 'sysvol') + vgp_xml = os.path.join(local_path, lp.get('realm').lower(), 'Policies', + self.gpo_guid, 'Machine/VGP/VTLA/VAS', + 'HostAccessControl/Allow/manifest.xml') + + stage = etree.Element('vgppolicy') + policysetting = etree.SubElement(stage, 'policysetting') + pv = etree.SubElement(policysetting, 'version') + pv.text = '1' + name = etree.SubElement(policysetting, 'name') + name.text = 'Host Access Control' + description = etree.SubElement(policysetting, 'description') + description.text = 'Represents host access control data (pam_access)' + apply_mode = etree.SubElement(policysetting, 'apply_mode') + apply_mode.text = 'merge' + data = etree.SubElement(policysetting, 'data') + listelement = etree.SubElement(data, 'listelement') + etype = etree.SubElement(listelement, 'type') + etype.text = 'USER' + entry = etree.SubElement(listelement, 'entry') + entry.text = 'goodguy@%s' % lp.get('realm').lower() + adobject = etree.SubElement(listelement, 'adobject') + name = etree.SubElement(adobject, 'name') + name.text = 'goodguy' + domain = etree.SubElement(adobject, 'domain') + domain.text = lp.get('realm').lower() + etype = etree.SubElement(adobject, 'type') + etype.text = 'user' + groupattr = etree.SubElement(data, 'groupattr') + groupattr.text = 'samAccountName' + listelement = etree.SubElement(data, 'listelement') + etype = etree.SubElement(listelement, 'type') + etype.text = 'GROUP' + entry = etree.SubElement(listelement, 'entry') + entry.text = '%s\\goodguys' % lp.get('realm').lower() + adobject = etree.SubElement(listelement, 'adobject') + name = etree.SubElement(adobject, 'name') + name.text = 'goodguys' + domain = etree.SubElement(adobject, 'domain') + domain.text = lp.get('realm').lower() + etype = etree.SubElement(adobject, 'type') + etype.text = 'group' + ret = stage_file(vgp_xml, etree.tostring(stage, 'utf-8')) + self.assertTrue(ret, 'Could not create the target %s' % vgp_xml) + + uentry = '+:%s\\goodguy:ALL' % domain.text + gentry = '+:%s\\goodguys:ALL' % domain.text + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "access", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertIn(uentry, out, 'The test entry was not found!') + self.assertIn(gentry, out, 'The test entry was not found!') + + # Unstage the manifest.xml file + unstage_file(vgp_xml) + + def test_vgp_access_add(self): + lp = LoadParm() + lp.load(os.environ['SERVERCONFFILE']) + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "access", "add"), + self.gpo_guid, + "allow", self.test_user, + lp.get('realm').lower(), + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'Access add failed') + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "access", "add"), + self.gpo_guid, + "deny", self.test_group, + lp.get('realm').lower(), + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'Access add failed') + + allow_entry = '+:%s\\%s:ALL' % (lp.get('realm').lower(), self.test_user) + deny_entry = '-:%s\\%s:ALL' % (lp.get('realm').lower(), self.test_group) + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "access", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertIn(allow_entry, out, 'The test entry was not found!') + self.assertIn(deny_entry, out, 'The test entry was not found!') + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "access", "remove"), + self.gpo_guid, + "allow", self.test_user, + lp.get('realm').lower(), + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'Access remove failed') + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "access", "remove"), + self.gpo_guid, + "deny", self.test_group, + lp.get('realm').lower(), + "-H", "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'Access remove failed') + + (result, out, err) = self.runsublevelcmd("gpo", ("manage", + "access", "list"), + self.gpo_guid, "-H", + "ldap://%s" % + os.environ["SERVER"], + "-U%s%%%s" % + (os.environ["USERNAME"], + os.environ["PASSWORD"])) + self.assertNotIn(allow_entry, out, 'The test entry was still found!') + self.assertNotIn(deny_entry, out, 'The test entry was still found!') + + def setUp(self): + """set up a temporary GPO to work with""" + super().setUp() + (result, out, err) = self.runsubcmd("gpo", "create", self.gpo_name, + "-H", "ldap://%s" % os.environ["SERVER"], + "-U%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"]), + "--tmpdir", self.tempdir) + self.assertCmdSuccess(result, out, err, "Ensuring gpo created successfully") + shutil.rmtree(os.path.join(self.tempdir, "policy")) + try: + self.gpo_guid = "{%s}" % out.split("{")[1].split("}")[0] + except IndexError: + self.fail("Failed to find GUID in output: %s" % out) + + self.test_user = 'testuser' + (result, out, err) = self.runsubcmd("user", "add", self.test_user, + "--random-password") + self.assertCmdSuccess(result, out, err, 'User creation failed') + self.test_group = 'testgroup' + (result, out, err) = self.runsubcmd("group", "add", self.test_group) + self.assertCmdSuccess(result, out, err, 'Group creation failed') + + def tearDown(self): + """remove the temporary GPO to work with""" + (result, out, err) = self.runsubcmd("gpo", "del", self.gpo_guid, "-H", "ldap://%s" % os.environ["SERVER"], "-U%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Ensuring gpo deleted successfully") + (result, out, err) = self.runsubcmd("user", "delete", self.test_user) + self.assertCmdSuccess(result, out, err, 'User delete failed') + (result, out, err) = self.runsubcmd("group", "delete", self.test_group) + self.assertCmdSuccess(result, out, err, 'Group delete failed') + super().tearDown() diff --git a/python/samba/tests/samba_tool/group.py b/python/samba/tests/samba_tool/group.py new file mode 100644 index 0000000..e7a660c --- /dev/null +++ b/python/samba/tests/samba_tool/group.py @@ -0,0 +1,613 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) Michael Adam 2012 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +import time +import ldb +from samba.tests.samba_tool.base import SambaToolCmdTest +from samba import ( + nttime2unix, + dsdb + ) + + +class GroupCmdTestCase(SambaToolCmdTest): + """Tests for samba-tool group subcommands""" + groups = [] + samdb = None + + def setUp(self): + super().setUp() + self.samdb = self.getSamDB("-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"])) + self.groups = [] + self.groups.append(self._randomGroup({"name": "testgroup1"})) + self.groups.append(self._randomGroup({"name": "testgroup2"})) + self.groups.append(self._randomGroup({"name": "testgroup3"})) + self.groups.append(self._randomGroup({"name": "testgroup4"})) + self.groups.append(self._randomGroup({"name": "testgroup5 (with brackets)"})) + self.groups.append(self._randomPosixGroup({"name": "posixgroup1"})) + self.groups.append(self._randomPosixGroup({"name": "posixgroup2"})) + self.groups.append(self._randomPosixGroup({"name": "posixgroup3"})) + self.groups.append(self._randomPosixGroup({"name": "posixgroup4"})) + self.groups.append(self._randomPosixGroup({"name": "posixgroup5 (with brackets)"})) + self.groups.append(self._randomUnixGroup({"name": "unixgroup1"})) + self.groups.append(self._randomUnixGroup({"name": "unixgroup2"})) + self.groups.append(self._randomUnixGroup({"name": "unixgroup3"})) + self.groups.append(self._randomUnixGroup({"name": "unixgroup4"})) + self.groups.append(self._randomUnixGroup({"name": "unixgroup5 (with brackets)"})) + + # setup the 12 groups and ensure they are correct + for group in self.groups: + (result, out, err) = group["createGroupFn"](group) + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "There shouldn't be any error message") + + if 'unix' in group["name"]: + self.assertIn("Modified Group '%s' successfully" + % group["name"], out) + else: + self.assertIn("Added group %s" % group["name"], out) + + group["checkGroupFn"](group) + + found = self._find_group(group["name"]) + + self.assertIsNotNone(found) + + self.assertEqual("%s" % found.get("name"), group["name"]) + self.assertEqual("%s" % found.get("description"), group["description"]) + + def tearDown(self): + super().tearDown() + # clean up all the left over groups, just in case + for group in self.groups: + if self._find_group(group["name"]): + self.runsubcmd("group", "delete", group["name"]) + + def test_newgroup(self): + """This tests the "group add" and "group delete" commands""" + # try to add all the groups again, this should fail + for group in self.groups: + (result, out, err) = self._create_group(group) + self.assertCmdFail(result, "Succeeded to add existing group") + self.assertIn("LDAP error 68 LDAP_ENTRY_ALREADY_EXISTS", err) + + # try to delete all the groups we just added + for group in self.groups: + (result, out, err) = self.runsubcmd("group", "delete", group["name"]) + self.assertCmdSuccess(result, out, err, + "Failed to delete group '%s'" % group["name"]) + found = self._find_group(group["name"]) + self.assertIsNone(found, + "Deleted group '%s' still exists" % group["name"]) + + # test adding groups + for group in self.groups: + (result, out, err) = self.runsubcmd("group", "add", group["name"], + "--description=%s" % group["description"], + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "There shouldn't be any error message") + self.assertIn("Added group %s" % group["name"], out) + + found = self._find_group(group["name"]) + + self.assertEqual("%s" % found.get("samaccountname"), + "%s" % group["name"]) + + def test_list(self): + (result, out, err) = self.runsubcmd("group", "list", + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Error running list") + + search_filter = "(objectClass=group)" + + grouplist = self.samdb.search(base=self.samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter, + attrs=["samaccountname"]) + + self.assertTrue(len(grouplist) > 0, "no groups found in samdb") + + for groupobj in grouplist: + name = str(groupobj.get("samaccountname", idx=0)) + found = self.assertMatch(out, name, + "group '%s' not found" % name) + + def test_list_verbose(self): + (result, out, err) = self.runsubcmd("group", "list", "--verbose", + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Error running list --verbose") + + # use the output to build a dictionary, where key=group-name, + # value=num-members + output_memberships = {} + + # split the output by line, skipping the first 2 header lines + group_lines = out.split('\n')[2:-1] + for line in group_lines: + # split line by column whitespace (but keep the group name together + # if it contains spaces) + values = line.split(" ") + name = values[0] + num_members = int(values[-1]) + output_memberships[name] = num_members + + # build up a similar dict using an LDAP search + search_filter = "(objectClass=group)" + grouplist = self.samdb.search(base=self.samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter, + attrs=["samaccountname", "member"]) + self.assertTrue(len(grouplist) > 0, "no groups found in samdb") + + ldap_memberships = {} + for groupobj in grouplist: + name = str(groupobj.get("samaccountname", idx=0)) + num_members = len(groupobj.get("member", default=[])) + ldap_memberships[name] = num_members + + # check the command output matches LDAP + self.assertTrue(output_memberships == ldap_memberships, + "Command output doesn't match LDAP results.\n" + + "Command='%s'\nLDAP='%s'" %(output_memberships, + ldap_memberships)) + + def test_list_full_dn(self): + (result, out, err) = self.runsubcmd("group", "list", "--full-dn", + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Error running list") + + search_filter = "(objectClass=group)" + + grouplist = self.samdb.search(base=self.samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter, + attrs=[]) + + self.assertTrue(len(grouplist) > 0, "no groups found in samdb") + + for groupobj in grouplist: + name = str(groupobj.get("dn", idx=0)) + found = self.assertMatch(out, name, + "group '%s' not found" % name) + + def test_list_base_dn(self): + base_dn = "CN=Users" + (result, out, err) = self.runsubcmd("group", "list", "--base-dn", base_dn, + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Error running list") + + search_filter = "(objectClass=group)" + + grouplist = self.samdb.search(base=self.samdb.normalize_dn_in_domain(base_dn), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter, + attrs=["name"]) + + self.assertTrue(len(grouplist) > 0, "no groups found in samdb") + + for groupobj in grouplist: + name = str(groupobj.get("name", idx=0)) + found = self.assertMatch(out, name, + "group '%s' not found" % name) + + def test_listmembers(self): + (result, out, err) = self.runsubcmd("group", "listmembers", "Domain Users", + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Error running listmembers") + + search_filter = "(|(primaryGroupID=513)(memberOf=CN=Domain Users,CN=Users,%s))" % self.samdb.domain_dn() + + grouplist = self.samdb.search(base=self.samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter, + attrs=["samAccountName"]) + + self.assertTrue(len(grouplist) > 0, "no groups found in samdb") + + for groupobj in grouplist: + name = str(groupobj.get("samAccountName", idx=0)) + found = self.assertMatch(out, name, "group '%s' not found" % name) + + def test_listmembers_hide_expired(self): + expire_username = "expireUser" + expire_user = self._random_user({"name": expire_username}) + self._create_user(expire_user) + + (result, out, err) = self.runsubcmd( + "group", + "listmembers", + "Domain Users", + "--hide-expired", + "-H", + "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Error running listmembers") + self.assertTrue(expire_username in out, + "user '%s' not found" % expire_username) + + # user will be expired one second ago + self.samdb.setexpiry( + "(sAMAccountname=%s)" % expire_username, + -1, + False) + + (result, out, err) = self.runsubcmd( + "group", + "listmembers", + "Domain Users", + "--hide-expired", + "-H", + "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Error running listmembers") + self.assertFalse(expire_username in out, + "user '%s' not found" % expire_username) + + self.samdb.deleteuser(expire_username) + + def test_listmembers_hide_disabled(self): + disable_username = "disableUser" + disable_user = self._random_user({"name": disable_username}) + self._create_user(disable_user) + + (result, out, err) = self.runsubcmd( + "group", + "listmembers", + "Domain Users", + "--hide-disabled", + "-H", + "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Error running listmembers") + self.assertTrue(disable_username in out, + "user '%s' not found" % disable_username) + + self.samdb.disable_account("(sAMAccountname=%s)" % disable_username) + + (result, out, err) = self.runsubcmd( + "group", + "listmembers", + "Domain Users", + "--hide-disabled", + "-H", + "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Error running listmembers") + self.assertFalse(disable_username in out, + "user '%s' not found" % disable_username) + + self.samdb.deleteuser(disable_username) + + def test_listmembers_full_dn(self): + (result, out, err) = self.runsubcmd("group", "listmembers", "Domain Users", + "--full-dn", + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Error running listmembers") + + search_filter = "(|(primaryGroupID=513)(memberOf=CN=Domain Users,CN=Users,%s))" % self.samdb.domain_dn() + + grouplist = self.samdb.search(base=self.samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter, + attrs=["dn"]) + + self.assertTrue(len(grouplist) > 0, "no groups found in samdb") + + for groupobj in grouplist: + name = str(groupobj.get("dn", idx=0)) + found = self.assertMatch(out, name, "group '%s' not found" % name) + + + def test_move(self): + full_ou_dn = str(self.samdb.normalize_dn_in_domain("OU=movetest_grp")) + self.addCleanup(self.samdb.delete, full_ou_dn, ["tree_delete:1"]) + + (result, out, err) = self.runsubcmd("ou", "add", full_ou_dn) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "There shouldn't be any error message") + self.assertIn('Added ou "%s"' % full_ou_dn, out) + + for group in self.groups: + (result, out, err) = self.runsubcmd( + "group", "move", group["name"], full_ou_dn) + self.assertCmdSuccess(result, out, err, "Error running move") + self.assertIn('Moved group "%s" into "%s"' % + (group["name"], full_ou_dn), out) + + # Should fail as groups objects are in OU + (result, out, err) = self.runsubcmd("ou", "delete", full_ou_dn) + self.assertCmdFail(result) + self.assertIn(("subtree_delete: Unable to delete a non-leaf node " + "(it has %d children)!") % len(self.groups), err) + + for group in self.groups: + new_dn = "CN=Users,%s" % self.samdb.domain_dn() + (result, out, err) = self.runsubcmd( + "group", "move", group["name"], new_dn) + self.assertCmdSuccess(result, out, err, "Error running move") + self.assertIn('Moved group "%s" into "%s"' % + (group["name"], new_dn), out) + + def test_show(self): + """Assert that we can show a group correctly.""" + (result, out, err) = self.runsubcmd("group", "show", "Domain Users", + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn("dn: CN=Domain Users,CN=Users,DC=addom,DC=samba,DC=example,DC=com", out) + + def test_rename_samaccountname(self): + """rename the samaccountname of all groups""" + for group in self.groups: + new_name = "new_samaccountname_of_" + group["name"] + + # change samaccountname + (result, out, err) = self.runsubcmd("group", "rename", group["name"], + "--samaccountname=" + new_name) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn('successfully', out) + + found = self._find_group(new_name) + self.assertEqual("%s" % found.get("description"), group["description"]) + if not "cn" in group or str(group["cn"]) == str(group["name"]): + self.assertEqual("%s" % found.get("cn"), new_name) + else: + self.assertEqual("%s" % found.get("cn"), group["cn"]) + + # trying to remove the samaccountname throws an error + (result, out, err) = self.runsubcmd("group", "rename", new_name, + "--samaccountname=") + self.assertCmdFail(result) + self.assertIn('Failed to rename group', err) + self.assertIn('delete protected attribute', err) + + # reset changes + (result, out, err) = self.runsubcmd("group", "rename", new_name, + "--samaccountname=" + group["name"]) + self.assertCmdSuccess(result, out, err) + if "cn" in group: + (result, out, err) = self.runsubcmd("group", "rename", group["name"], + "--force-new-cn=%s" % group["cn"]) + self.assertCmdSuccess(result, out, err) + + def test_rename_cn_mail(self): + """change and remove the cn and mail attributes of all groups""" + for group in self.groups: + new_mail = "new mail of " + group["name"] + new_cn = "new cn of " + group["name"] + + # change attributes + (result, out, err) = self.runsubcmd("group", "rename", group["name"], + "--mail-address=" + new_mail, + "--force-new-cn=" + new_cn) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn('successfully', out) + + found = self._find_group(group["name"]) + self.assertEqual("%s" % found.get("mail"), new_mail) + self.assertEqual("%s" % found.get("cn"), new_cn) + + # remove mail + (result, out, err) = self.runsubcmd("group", "rename", group["name"], + "--mail-address=") + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn('successfully', out) + + found = self._find_group(group["name"]) + self.assertEqual(found.get("mail"), None) + + # trying to remove cn (throws an error) + (result, out, err) = self.runsubcmd("group", "rename", group["name"], + "--force-new-cn=") + self.assertCmdFail(result) + self.assertIn("Failed to rename group", err) + self.assertIn("delete protected attribute", err) + + # reset CN (mail is already empty) + (result, out, err) = self.runsubcmd("group", "rename", group["name"], + "--reset-cn") + self.assertCmdSuccess(result, out, err) + + def _randomGroup(self, base={}): + """create a group with random attribute values, you can specify base + attributes""" + group = { + "name": self.randomName(), + "description": self.randomName(count=100), + "createGroupFn": self._create_group, + "checkGroupFn": self._check_group, + } + group.update(base) + return group + + def _randomPosixGroup(self, base={}): + """create a group with random attribute values and additional RFC2307 + attributes, you can specify base attributes""" + group = self._randomGroup({}) + group.update(base) + posixAttributes = { + "unixdomain": self.randomName(), + "gidNumber": self.randomXid(), + "createGroupFn": self._create_posix_group, + "checkGroupFn": self._check_posix_group, + } + group.update(posixAttributes) + group.update(base) + return group + + def _randomUnixGroup(self, base={}): + """create a group with random attribute values and additional RFC2307 + attributes, you can specify base attributes""" + group = self._randomGroup({}) + group.update(base) + posixAttributes = { + "gidNumber": self.randomXid(), + "createGroupFn": self._create_unix_group, + "checkGroupFn": self._check_unix_group, + } + group.update(posixAttributes) + group.update(base) + return group + + def _check_group(self, group): + """ check if a group from SamDB has the same attributes as + its template """ + found = self._find_group(group["name"]) + + self.assertEqual("%s" % found.get("name"), group["name"]) + self.assertEqual("%s" % found.get("description"), group["description"]) + + def _check_posix_group(self, group): + """ check if a posix_group from SamDB has the same attributes as + its template """ + found = self._find_group(group["name"]) + + self.assertEqual("%s" % found.get("gidNumber"), "%s" % + group["gidNumber"]) + self._check_group(group) + + def _check_unix_group(self, group): + """ check if a unix_group from SamDB has the same attributes as its +template """ + found = self._find_group(group["name"]) + + self.assertEqual("%s" % found.get("gidNumber"), "%s" % + group["gidNumber"]) + self._check_group(group) + + def _create_group(self, group): + return self.runsubcmd("group", "add", group["name"], + "--description=%s" % group["description"], + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + + def _create_posix_group(self, group): + """ create a new group with RFC2307 attributes """ + return self.runsubcmd("group", "add", group["name"], + "--description=%s" % group["description"], + "--nis-domain=%s" % group["unixdomain"], + "--gid-number=%s" % group["gidNumber"], + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + + def _create_unix_group(self, group): + """ Add RFC2307 attributes to a group""" + self._create_group(group) + return self.runsubcmd("group", "addunixattrs", group["name"], + "%s" % group["gidNumber"], + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + + def _find_group(self, name): + search_filter = ("(&(sAMAccountName=%s)(objectCategory=%s,%s))" % + (ldb.binary_encode(name), + "CN=Group,CN=Schema,CN=Configuration", + self.samdb.domain_dn())) + grouplist = self.samdb.search(base=self.samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter) + if grouplist: + return grouplist[0] + else: + return None + + def test_stats(self): + (result, out, err) = self.runsubcmd("group", "stats", + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Error running stats") + + # sanity-check the command reports 'total groups' correctly + search_filter = "(objectClass=group)" + grouplist = self.samdb.search(base=self.samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter, + attrs=[]) + + total_groups = len(grouplist) + self.assertTrue("Total groups: {0}".format(total_groups) in out, + "Total groups not reported correctly") + + def _random_user(self, base=None): + """ + create a user with random attribute values, you can specify + base attributes + """ + if base is None: + base = {} + user = { + "name": self.randomName(), + "password": self.random_password(16), + "surname": self.randomName(), + "given-name": self.randomName(), + "job-title": self.randomName(), + "department": self.randomName(), + "company": self.randomName(), + "description": self.randomName(count=100), + "createUserFn": self._create_user, + } + user.update(base) + return user + + def _create_user(self, user): + return self.runsubcmd( + "user", + "add", + user["name"], + user["password"], + "--surname=%s" % user["surname"], + "--given-name=%s" % user["given-name"], + "--job-title=%s" % user["job-title"], + "--department=%s" % user["department"], + "--description=%s" % user["description"], + "--company=%s" % user["company"], + "-H", + "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) diff --git a/python/samba/tests/samba_tool/group_edit.sh b/python/samba/tests/samba_tool/group_edit.sh new file mode 100755 index 0000000..3db2c66 --- /dev/null +++ b/python/samba/tests/samba_tool/group_edit.sh @@ -0,0 +1,228 @@ +#!/bin/sh +# +# Test for 'samba-tool group edit' + +if [ $# -lt 3 ]; then + cat <<EOF +Usage: group_edit.sh SERVER USERNAME PASSWORD +EOF + exit 1 +fi + +SERVER="$1" +USERNAME="$2" +PASSWORD="$3" + +samba_ldbsearch=ldbsearch +if test -x $BINDIR/ldbsearch; then + samba_ldbsearch=$BINDIR/ldbsearch +fi + +STpath=$(pwd) +. $STpath/testprogs/blackbox/subunit.sh + +display_name="Users in Göttingen" +display_name_b64="VXNlcnMgaW4gR8O2dHRpbmdlbg==" +display_name_new="Users in Goettingen" +# attribute value including control character +# echo -e "test \a string" | base64 +display_name_con_b64="dGVzdCAHIHN0cmluZwo=" + +tmpeditor=$(mktemp --suffix .sh -p $SELFTEST_TMPDIR samba-tool-editor-XXXXXXXX) +chmod +x $tmpeditor + +create_test_group() +{ + $PYTHON ${STpath}/source4/scripting/bin/samba-tool \ + group add testgroup1 \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +delete_test_group() +{ + $PYTHON ${STpath}/source4/scripting/bin/samba-tool \ + group delete testgroup1 \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +create_test_user() +{ + $PYTHON ${STpath}/source4/scripting/bin/samba-tool \ + user create testuser1 --random-password \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +delete_test_user() +{ + $PYTHON ${STpath}/source4/scripting/bin/samba-tool \ + user delete testuser1 \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +add_member() +{ + user_dn=$($PYTHON ${STpath}/source4/scripting/bin/samba-tool \ + user show testuser1 --attributes=dn \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" | + grep ^dn: | cut -d' ' -f2) + + # create editor.sh + cat >$tmpeditor <<EOF +#!/usr/bin/env bash +group_ldif="\$1" + +grep -v '^$' \$group_ldif > \${group_ldif}.tmp +echo "member: $user_dn" >> \${group_ldif}.tmp + +mv \${group_ldif}.tmp \$group_ldif +EOF + + $PYTHON ${STpath}/source4/scripting/bin/samba-tool \ + group edit testgroup1 --editor=$tmpeditor \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +get_member() +{ + $PYTHON ${STpath}/source4/scripting/bin/samba-tool \ + group listmembers testgroup1 \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +# Test edit group - add base64 attributes +add_attribute_base64() +{ + # create editor.sh + cat >$tmpeditor <<EOF +#!/usr/bin/env bash +group_ldif="\$1" + +grep -v '^$' \$group_ldif > \${group_ldif}.tmp +echo "displayName:: $display_name_b64" >> \${group_ldif}.tmp + +mv \${group_ldif}.tmp \$group_ldif +EOF + + $PYTHON ${STpath}/source4/scripting/bin/samba-tool group edit \ + testgroup1 --editor=$tmpeditor \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +get_attribute_base64() +{ + $samba_ldbsearch '(sAMAccountName=testgroup1)' displayName \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +delete_attribute() +{ + # create editor.sh + cat >$tmpeditor <<EOF +#!/usr/bin/env bash +group_ldif="\$1" + +grep -v '^displayName' \$group_ldif >> \${group_ldif}.tmp +mv \${group_ldif}.tmp \$group_ldif +EOF + $PYTHON ${STpath}/source4/scripting/bin/samba-tool group edit \ + testgroup1 --editor=$tmpeditor \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +# Test edit group - add base64 attribute value including control character +add_attribute_base64_control() +{ + # create editor.sh + cat >$tmpeditor <<EOF +#!/usr/bin/env bash +group_ldif="\$1" + +grep -v '^$' \$group_ldif > \${group_ldif}.tmp +echo "displayName:: $display_name_con_b64" >> \${group_ldif}.tmp + +mv \${group_ldif}.tmp \$group_ldif +EOF + $PYTHON ${STpath}/source4/scripting/bin/samba-tool group edit \ + testgroup1 --editor=$tmpeditor \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +get_attribute_base64_control() +{ + $PYTHON ${STpath}/source4/scripting/bin/samba-tool group show \ + testgroup1 --attributes=displayName \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +get_attribute_force_no_base64() +{ + # LDB_FLAG_FORCE_NO_BASE64_LDIF should be used here. + $PYTHON ${STpath}/source4/scripting/bin/samba-tool group show \ + testgroup1 --attributes=displayName \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +# Test edit group - change base64 attribute value including control character +change_attribute_base64_control() +{ + # create editor.sh + cat >$tmpeditor <<EOF +#!/usr/bin/env bash +group_ldif="\$1" + +sed -i -e 's/displayName:: $display_name_con_b64/displayName: $display_name/' \ + \$group_ldif +EOF + $PYTHON ${STpath}/source4/scripting/bin/samba-tool group edit \ + testgroup1 --editor=$tmpeditor \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +# Test edit group - change attributes with LDB_FLAG_FORCE_NO_BASE64_LDIF +change_attribute_force_no_base64() +{ + # create editor.sh + # Expects that the original attribute is available as clear text, + # because the LDB_FLAG_FORCE_NO_BASE64_LDIF should be used here. + cat >$tmpeditor <<EOF +#!/usr/bin/env bash +group_ldif="\$1" + +sed -i -e 's/displayName: $display_name/displayName: $display_name_new/' \ + \$group_ldif +EOF + + $PYTHON ${STpath}/source4/scripting/bin/samba-tool group edit \ + testgroup1 --editor=$tmpeditor \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +get_changed_attribute_force_no_base64() +{ + $PYTHON ${STpath}/source4/scripting/bin/samba-tool group show \ + testgroup1 --attributes=displayName \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +failed=0 + +testit "create_test_group" create_test_group || failed=$(expr $failed + 1) +testit "create_test_user" create_test_user || failed=$(expr $failed + 1) +testit "add_member" add_member || failed=$(expr $failed + 1) +testit_grep "get_member" "^testuser1" get_member || failed=$(expr $failed + 1) +testit "add_attribute_base64" add_attribute_base64 || failed=$(expr $failed + 1) +testit_grep "get_attribute_base64" "^displayName:: $display_name_b64" get_attribute_base64 || failed=$(expr $failed + 1) +testit "delete_attribute" delete_attribute || failed=$(expr $failed + 1) +testit "add_attribute_base64_control" add_attribute_base64_control || failed=$(expr $failed + 1) +testit_grep "get_attribute_base64_control" "^displayName:: $display_name_con_b64" get_attribute_base64_control || failed=$(expr $failed + 1) +testit "change_attribute_base64_control" change_attribute_base64_control || failed=$(expr $failed + 1) +testit_grep "get_attribute_base64" "^displayName:: $display_name_b64" get_attribute_base64 || failed=$(expr $failed + 1) +testit_grep "get_attribute_force_no_base64" "^displayName: $display_name" get_attribute_force_no_base64 || failed=$(expr $failed + 1) +testit "change_attribute_force_no_base64" change_attribute_force_no_base64 || failed=$(expr $failed + 1) +testit_grep "get_changed_attribute_force_no_base64" "^displayName: $display_name_new" get_changed_attribute_force_no_base64 || failed=$(expr $failed + 1) +testit "delete_test_group" delete_test_group || failed=$(expr $failed + 1) +testit "delete_test_user" delete_test_user || failed=$(expr $failed + 1) + +rm -f $tmpeditor + +exit $failed diff --git a/python/samba/tests/samba_tool/help.py b/python/samba/tests/samba_tool/help.py new file mode 100644 index 0000000..fa7836d --- /dev/null +++ b/python/samba/tests/samba_tool/help.py @@ -0,0 +1,81 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) Catalyst IT Ltd 2017. +# +# Originally written by Douglas Bagnall <douglas.bagnall@catalyst.net.nz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import re +from samba.tests.samba_tool.base import SambaToolCmdTest +from samba.tests import BlackboxProcessError +from samba.tests import check_help_consistency +from samba.common import get_string + + +class HelpTestCase(SambaToolCmdTest): + """Tests for samba-tool help and --help + + We test for consistency and lack of crashes.""" + + def _find_sub_commands(self, args): + self.runcmd(*args) + + def test_help_tree(self): + # we call actual subprocesses, because we are probing the + # actual help output where there is no sub-command. Don't copy + # this if you have an actual command: for that use + # self.runcmd() or self.runsubcmd(). + known_commands = [[]] + failed_commands = [] + + for i in range(4): + new_commands = [] + for c in known_commands: + line = ' '.join(['samba-tool'] + c + ['--help']) + try: + output = self.check_output(line) + except BlackboxProcessError as e: + output = e.stdout + failed_commands.append(c) + output = get_string(output) + tail = output.partition('Available subcommands:')[2] + subcommands = re.findall(r'^\s*([\w-]+)\s+-', tail, + re.MULTILINE) + for s in subcommands: + new_commands.append(c + [s]) + + # check that `samba-tool help X Y` == `samba-tool X Y --help` + line = ' '.join(['samba-tool', 'help'] + c) + try: + output2 = self.check_output(line) + except BlackboxProcessError as e: + output2 = e.stdout + failed_commands.append(c) + + output2 = get_string(output2) + self.assertEqual(output, output2) + + err = check_help_consistency(output, + options_start='Options:', + options_end='Available subcommands:') + if err is not None: + self.fail("consistency error with %s:\n%s" % (line, err)) + + if not new_commands: + break + + known_commands = new_commands + + self.assertEqual(failed_commands, []) diff --git a/python/samba/tests/samba_tool/join.py b/python/samba/tests/samba_tool/join.py new file mode 100644 index 0000000..0cbd319 --- /dev/null +++ b/python/samba/tests/samba_tool/join.py @@ -0,0 +1,31 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) Andrew Bartlett <abartlet@samba.org> 2016 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +import ldb +from samba.tests.samba_tool.base import SambaToolCmdTest + + +class JoinCmdTestCase(SambaToolCmdTest): + """Test for samba-tool domain join subcommand""" + + def test_rejoin(self): + """Run domain join to confirm it errors because we are already joined""" + (result, out, err) = self.runsubcmd("domain", "join", os.environ["REALM"], "dc", "-U%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"])) + + self.assertCmdFail(result) + self.assertTrue("Not removing account" in err, "Should fail with exception") diff --git a/python/samba/tests/samba_tool/join_lmdb_size.py b/python/samba/tests/samba_tool/join_lmdb_size.py new file mode 100644 index 0000000..7b43c45 --- /dev/null +++ b/python/samba/tests/samba_tool/join_lmdb_size.py @@ -0,0 +1,152 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) Catalyst IT Ltd. 2019 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +from samba.tests.samba_tool.base import SambaToolCmdTest +import os +import shutil + + +class JoinLmdbSizeTestCase(SambaToolCmdTest): + """Test setting of the lmdb map size during join""" + + def setUp(self): + super().setUp() + self.tempsambadir = os.path.join(self.tempdir, "samba") + os.mkdir(self.tempsambadir) + (_, name) = os.path.split(self.tempdir) + self.netbios_name = name + + # join a domain and set the lmdb map size to size + # + # returns the tuple (ret, stdout, stderr) + def join(self, size=None, role=None): + command = ( + "samba-tool " + + "domain join " + + os.environ["REALM"] + " " + + role + " " + + ("-U%s%%%s " % (os.environ["USERNAME"], os.environ["PASSWORD"])) + + ("--targetdir=%s " % self.tempsambadir) + + ("--option=netbiosname=%s " % self.netbios_name) + + "--backend-store=mdb " + ) + if size: + command += ("--backend-store-size=%s" % size) + + (ret, stdout, stderr) = self.run_command(command) + if ret == 0: + self.cleanup_join(self.netbios_name) + + return (ret, stdout, stderr) + + def is_rodc(self): + url = "ldb://%s/private/sam.ldb" % self.tempsambadir + samdb = self.getSamDB("-H", url) + return samdb.am_rodc() + + # + # Get the lmdb map size for the specified command + # + # While there is a python lmdb package available we use the lmdb command + # line utilities to avoid introducing a dependency. + # + def get_lmdb_environment_size(self, path): + (result, out, err) = self.run_command("mdb_stat -ne %s" % path) + if result: + self.fail("Unable to run mdb_stat\n") + for line in out.split("\n"): + line = line.strip() + if line.startswith("Map size:"): + line = line.replace(" ", "") + (label, size) = line.split(":") + return int(size) + + # + # Check the lmdb files created by join and ensure that the map size + # has been set to size. + # + # Currently this is all the *.ldb files in private/sam.ldb.d + # + def check_lmdb_environment_sizes(self, size): + directory = os.path.join(self.tempsambadir, "private", "sam.ldb.d") + for name in os.listdir(directory): + if name.endswith(".ldb"): + path = os.path.join(directory, name) + s = self.get_lmdb_environment_size(path) + if s != size: + self.fail("File %s, size=%d larger than %d" % + (name, s, size)) + + # + # Ensure that if --backend-store-size is not specified the default of + # 8Gb is used + def test_join_as_dc_default(self): + (result, out, err) = self.join(role="DC") + self.assertEqual(0, result) + self.check_lmdb_environment_sizes(8 * 1024 * 1024 * 1024) + self.assertFalse(self.is_rodc()) + + # + # Join as an DC with the lmdb backend size set to 1Gb + def test_join_as_dc(self): + (result, out, err) = self.join("1Gb", "DC") + self.assertEqual(0, result) + self.check_lmdb_environment_sizes(1 * 1024 * 1024 * 1024) + self.assertFalse(self.is_rodc()) + + # + # Join as an RODC with the lmdb backend size set to 128Mb + def test_join_as_rodc(self): + (result, out, err) = self.join("128Mb", "RODC") + self.assertEqual(0, result) + self.check_lmdb_environment_sizes(128 * 1024 * 1024) + self.assertTrue(self.is_rodc()) + + # + # Join as an RODC with --backend-store-size + def test_join_as_rodc_default(self): + (result, out, err) = self.join(role="RODC") + self.assertEqual(0, result) + self.check_lmdb_environment_sizes(8 * 1024 * 1024 * 1024) + self.assertTrue(self.is_rodc()) + + def test_no_unit_suffix(self): + (result, out, err) = self.run_command( + 'samba-tool domain join --backend-store-size "2"') + self.assertGreater(result, 0) + self.assertRegex(err, + r"--backend-store-size invalid suffix ''") + + def test_invalid_unit_suffix(self): + (result, out, err) = self.run_command( + 'samba-tool domain join --backend-store-size "2 cd"') + self.assertGreater(result, 0) + self.assertRegex(err, + r"--backend-store-size invalid suffix 'cd'") + + def test_non_numeric(self): + (result, out, err) = self.run_command( + 'samba-tool domain join --backend-store-size "two Gb"') + self.assertGreater(result, 0) + self.assertRegex( + err, + r"backend-store-size option requires a numeric value, with an" + " optional unit suffix") + + def tearDown(self): + super().tearDown() + shutil.rmtree(self.tempsambadir) diff --git a/python/samba/tests/samba_tool/join_member.py b/python/samba/tests/samba_tool/join_member.py new file mode 100644 index 0000000..c2ab02f --- /dev/null +++ b/python/samba/tests/samba_tool/join_member.py @@ -0,0 +1,71 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) David Mulder <dmulder@samba.org> 2021 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +import re +from samba.tests.samba_tool.base import SambaToolCmdTest +from samba.param import LoadParm +from samba.netcmd.common import netcmd_dnsname + +class JoinMemberCmdTestCase(SambaToolCmdTest): + """Test for samba-tool domain join subcommand""" + + def test_join_member(self): + """Run a domain member join, and check that dns is updated""" + smb_conf = os.environ["SERVERCONFFILE"] + zone = os.environ["REALM"].lower() + lp = LoadParm() + lp.load(smb_conf) + dnsname = netcmd_dnsname(lp) + # Fetch the existing dns A records + (result, out, err) = self.runsubcmd("dns", "query", + os.environ["DC_SERVER"], + zone, dnsname, 'A', + "-s", smb_conf, + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'Failed to find the record') + + existing_records = re.findall('A:\s+(\d+\.\d+\.\d+\.\d+)\s', out) + + # Remove the existing records + for record in existing_records: + (result, out, err) = self.runsubcmd("dns", "delete", + os.environ["DC_SERVER"], + zone, dnsname, 'A', record, + "-s", smb_conf, + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'Failed to remove record') + + # Perform the s3 member join (net ads join) + (result, out, err) = self.runsubcmd("domain", "join", + os.environ["REALM"], "member", + "-s", smb_conf, + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, 'Failed to join member') + + # Ensure the dns A record was created + (result, out, err) = self.runsubcmd("dns", "query", + os.environ["DC_SERVER"], + zone, dnsname, 'A', + "-s", smb_conf, + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, + 'Failed to find dns host records for %s' % dnsname) diff --git a/python/samba/tests/samba_tool/ntacl.py b/python/samba/tests/samba_tool/ntacl.py new file mode 100644 index 0000000..1173101 --- /dev/null +++ b/python/samba/tests/samba_tool/ntacl.py @@ -0,0 +1,247 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) Andrew Bartlett 2012 +# +# Based on user.py: +# Copyright (C) Sean Dague <sdague@linux.vnet.ibm.com> 2011 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +from samba.tests.samba_tool.base import SambaToolCmdTest +from samba.tests import env_loadparm +import random + + +class NtACLCmdSysvolTestCase(SambaToolCmdTest): + """Tests for samba-tool ntacl sysvol* subcommands""" + + def test_ntvfs(self): + (result, out, err) = self.runsubcmd("ntacl", "sysvolreset", + "--use-ntvfs") + self.assertCmdSuccess(result, out, err) + self.assertEqual(out, "", "Shouldn't be any output messages") + self.assertIn("Please note that POSIX permissions have NOT been changed, only the stored NT ACL", err) + + def test_s3fs(self): + (result, out, err) = self.runsubcmd("ntacl", "sysvolreset", + "--use-s3fs") + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertEqual(out, "", "Shouldn't be any output messages") + + def test_ntvfs_check(self): + (result, out, err) = self.runsubcmd("ntacl", "sysvolreset", + "--use-ntvfs") + self.assertCmdSuccess(result, out, err) + self.assertEqual(out, "", "Shouldn't be any output messages") + self.assertIn("Please note that POSIX permissions have NOT been changed, only the stored NT ACL", err) + # Now check they were set correctly + (result, out, err) = self.runsubcmd("ntacl", "sysvolcheck") + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertEqual(out, "", "Shouldn't be any output messages") + + def test_s3fs_check(self): + (result, out, err) = self.runsubcmd("ntacl", "sysvolreset", + "--use-s3fs") + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertEqual(out, "", "Shouldn't be any output messages") + + # Now check they were set correctly + (result, out, err) = self.runsubcmd("ntacl", "sysvolcheck") + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertEqual(out, "", "Shouldn't be any output messages") + + def test_with_missing_files(self): + lp = env_loadparm() + sysvol = lp.get('path', 'sysvol') + realm = lp.get('realm').lower() + + src = os.path.join(sysvol, realm, 'Policies') + dest = os.path.join(sysvol, realm, 'Policies-NOT-IN-THE-EXPECTED-PLACE') + try: + os.rename(src, dest) + + for args in (["sysvolreset", "--use-s3fs"], + ["sysvolreset", "--use-ntvfs"], + ["sysvolreset"], + ["sysvolcheck"] + ): + + (result, out, err) = self.runsubcmd("ntacl", *args) + self.assertCmdFail(result, f"succeeded with {args} with missing dir") + self.assertNotIn("uncaught exception", err, + "Shouldn't be uncaught exception") + self.assertNotRegex(err, r'^\s*File [^,]+, line \d+, in', + "Shouldn't be lines of traceback") + self.assertEqual(out, "", "Shouldn't be any output messages") + finally: + os.rename(dest, src) + + +class NtACLCmdGetSetTestCase(SambaToolCmdTest): + """Tests for samba-tool ntacl get/set subcommands""" + + acl = "O:DAG:DUD:P(A;OICI;FA;;;DA)(A;OICI;FA;;;EA)(A;OICIIO;FA;;;CO)(A;OICI;FA;;;DA)(A;OICI;FA;;;SY)(A;OICI;0x1200a9;;;AU)(A;OICI;0x1200a9;;;ED)S:AI(OU;CIIDSA;WP;f30e3bbe-9ff0-11d1-b603-0000f80367c1;bf967aa5-0de6-11d0-a285-00aa003049e2;WD)(OU;CIIDSA;WP;f30e3bbf-9ff0-11d1-b603-0000f80367c1;bf967aa5-0de6-11d0-a285-00aa003049e2;WD)" + + def test_ntvfs(self): + path = os.environ['SELFTEST_PREFIX'] + tempf = os.path.join(path, "pytests" + str(int(100000 * random.random()))) + open(tempf, 'w').write("empty") + + (result, out, err) = self.runsubcmd("ntacl", "set", self.acl, tempf, + "--use-ntvfs") + self.assertCmdSuccess(result, out, err) + self.assertEqual(out, "", "Shouldn't be any output messages") + self.assertIn("Please note that POSIX permissions have NOT been changed, only the stored NT ACL", err) + + def test_s3fs(self): + path = os.environ['SELFTEST_PREFIX'] + tempf = os.path.join(path, "pytests" + str(int(100000 * random.random()))) + open(tempf, 'w').write("empty") + + (result, out, err) = self.runsubcmd("ntacl", "set", self.acl, tempf, + "--use-s3fs") + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertEqual(out, "", "Shouldn't be any output messages") + + def test_ntvfs_check(self): + path = os.environ['SELFTEST_PREFIX'] + tempf = os.path.join(path, "pytests" + str(int(100000 * random.random()))) + open(tempf, 'w').write("empty") + + (result, out, err) = self.runsubcmd("ntacl", "set", self.acl, tempf, + "--use-ntvfs") + self.assertCmdSuccess(result, out, err) + self.assertEqual(out, "", "Shouldn't be any output messages") + self.assertIn("Please note that POSIX permissions have NOT been changed, only the stored NT ACL", err) + + # Now check they were set correctly + (result, out, err) = self.runsubcmd("ntacl", "get", tempf, + "--use-ntvfs", "--as-sddl") + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertEqual(self.acl + "\n", out, "Output should be the ACL") + + def test_s3fs_check(self): + path = os.environ['SELFTEST_PREFIX'] + tempf = os.path.join(path, "pytests" + str(int(100000 * random.random()))) + open(tempf, 'w').write("empty") + + (result, out, err) = self.runsubcmd("ntacl", "set", self.acl, tempf, + "--use-s3fs") + self.assertCmdSuccess(result, out, err) + self.assertEqual(out, "", "Shouldn't be any output messages") + self.assertEqual(err, "", "Shouldn't be any error messages") + + # Now check they were set correctly + (result, out, err) = self.runsubcmd("ntacl", "get", tempf, + "--use-s3fs", "--as-sddl") + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertEqual(self.acl + "\n", out, "Output should be the ACL") + +class NtACLCmdChangedomsidTestCase(SambaToolCmdTest): + """Tests for samba-tool ntacl changedomsid subcommand""" + maxDiff = 10000 + acl = "O:DAG:DUD:P(A;OICI;0x001f01ff;;;DA)(A;OICI;0x001f01ff;;;EA)(A;OICIIO;0x001f01ff;;;CO)(A;OICI;0x001f01ff;;;DA)(A;OICI;0x001f01ff;;;SY)(A;OICI;0x001200a9;;;AU)(A;OICI;0x001200a9;;;ED)S:AI(OU;CIIDSA;WP;f30e3bbe-9ff0-11d1-b603-0000f80367c1;bf967aa5-0de6-11d0-a285-00aa003049e2;WD)(OU;CIIDSA;WP;f30e3bbf-9ff0-11d1-b603-0000f80367c1;bf967aa5-0de6-11d0-a285-00aa003049e2;WD)" + new_acl="O:S-1-5-21-2212615479-2695158682-2101375468-512G:S-1-5-21-2212615479-2695158682-2101375468-513D:P(A;OICI;FA;;;S-1-5-21-2212615479-2695158682-2101375468-512)(A;OICI;FA;;;S-1-5-21-2212615479-2695158682-2101375468-519)(A;OICIIO;FA;;;CO)(A;OICI;FA;;;S-1-5-21-2212615479-2695158682-2101375468-512)(A;OICI;FA;;;SY)(A;OICI;0x1200a9;;;AU)(A;OICI;0x1200a9;;;ED)S:AI(OU;CIIDSA;WP;f30e3bbe-9ff0-11d1-b603-0000f80367c1;bf967aa5-0de6-11d0-a285-00aa003049e2;WD)(OU;CIIDSA;WP;f30e3bbf-9ff0-11d1-b603-0000f80367c1;bf967aa5-0de6-11d0-a285-00aa003049e2;WD)" + domain_sid=os.environ['DOMSID'] + new_domain_sid="S-1-5-21-2212615479-2695158682-2101375468" + + def test_ntvfs_check(self): + path = os.environ['SELFTEST_PREFIX'] + tempf = os.path.join( + path, "pytests" + str(int(100000 * random.random()))) + open(tempf, 'w').write("empty") + + print("DOMSID: %s", self.domain_sid) + + (result, out, err) = self.runsubcmd("ntacl", + "set", + self.acl, + tempf, + "--use-ntvfs") + self.assertCmdSuccess(result, out, err) + self.assertEqual(out, "", "Shouldn't be any output messages") + self.assertIn("Please note that POSIX permissions have NOT been " + "changed, only the stored NT ACL", err) + + (result, out, err) = self.runsubcmd("ntacl", + "changedomsid", + self.domain_sid, + self.new_domain_sid, + tempf, + "--use-ntvfs") + self.assertCmdSuccess(result, out, err) + self.assertEqual(out, "", "Shouldn't be any output messages") + self.assertIn("Please note that POSIX permissions have NOT been " + "changed, only the stored NT ACL.", err) + + # Now check they were set correctly + (result, out, err) = self.runsubcmd("ntacl", + "get", + tempf, + "--use-ntvfs", + "--as-sddl") + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertEqual(self.new_acl + "\n", out, "Output should be the ACL") + + def test_s3fs_check(self): + path = os.environ['SELFTEST_PREFIX'] + tempf = os.path.join( + path, "pytests" + str(int(100000 * random.random()))) + open(tempf, 'w').write("empty") + + print("DOMSID: %s" % self.domain_sid) + + (result, out, err) = self.runsubcmd("ntacl", + "set", + self.acl, + tempf, + "--use-s3fs", + "--service=sysvol") + self.assertCmdSuccess(result, out, err) + self.assertEqual(out, "", "Shouldn't be any output messages") + self.assertEqual(err, "", "Shouldn't be any error messages") + + (result, out, err) = self.runsubcmd("ntacl", + "changedomsid", + self.domain_sid, + self.new_domain_sid, + tempf, + "--use-s3fs", + "--service=sysvol") + self.assertCmdSuccess(result, out, err) + self.assertEqual(out, "", "Shouldn't be any output messages") + self.assertEqual(err, "", "Shouldn't be any error messages") + + # Now check they were set correctly + (result, out, err) = self.runsubcmd("ntacl", + "get", + tempf, + "--use-s3fs", + "--as-sddl", + "--service=sysvol") + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertEqual(self.new_acl + "\n", out, "Output should be the ACL") diff --git a/python/samba/tests/samba_tool/ou.py b/python/samba/tests/samba_tool/ou.py new file mode 100644 index 0000000..7a84876 --- /dev/null +++ b/python/samba/tests/samba_tool/ou.py @@ -0,0 +1,291 @@ +# Unix SMB/CIFS implementation. +# +# Copyright (C) Bjoern Baumbach <bb@sernet.de> 2018 +# +# based on group.py: +# Copyright (C) Michael Adam 2012 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +import ldb +from samba.tests.samba_tool.base import SambaToolCmdTest + + +class OUCmdTestCase(SambaToolCmdTest): + """Tests for samba-tool ou subcommands""" + ous = [] + samdb = None + + def setUp(self): + super().setUp() + self.samdb = self.getSamDB("-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"])) + self.ous = [] + self.ous.append(self._randomOU({"name": "testou1"})) + self.ous.append(self._randomOU({"name": "testou2"})) + self.ous.append(self._randomOU({"name": "testou3"})) + self.ous.append(self._randomOU({"name": "testou4"})) + + # setup the 4 ous and ensure they are correct + for ou in self.ous: + (result, out, err) = self._create_ou(ou) + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "There shouldn't be any error message") + full_ou_dn = self.samdb.normalize_dn_in_domain("OU=%s" % ou["name"]) + self.assertIn('Added ou "%s"' % full_ou_dn, out) + + found = self._find_ou(ou["name"]) + + self.assertIsNotNone(found) + + self.assertEqual("%s" % found.get("name"), ou["name"]) + self.assertEqual("%s" % found.get("description"), + ou["description"]) + + def tearDown(self): + super().tearDown() + # clean up all the left over ous, just in case + for ou in self.ous: + if self._find_ou(ou["name"]): + (result, out, err) = self.runsubcmd("ou", "delete", + "OU=%s" % ou["name"]) + self.assertCmdSuccess(result, out, err, + "Failed to delete ou '%s'" % ou["name"]) + + def test_newou(self): + """This tests the "ou create" and "ou delete" commands""" + # try to create all the ous again, this should fail + for ou in self.ous: + (result, out, err) = self._create_ou(ou) + self.assertCmdFail(result, "Succeeded to add existing ou") + self.assertIn("already exists", err) + + # try to delete all the ous we just added + for ou in self.ous: + (result, out, err) = self.runsubcmd("ou", "delete", "OU=%s" % + ou["name"]) + self.assertCmdSuccess(result, out, err, + "Failed to delete ou '%s'" % ou["name"]) + found = self._find_ou(ou["name"]) + self.assertIsNone(found, + "Deleted ou '%s' still exists" % ou["name"]) + + # test creating ous + for ou in self.ous: + (result, out, err) = self.runsubcmd( + "ou", "add", "OU=%s" % ou["name"], + "--description=%s" % ou["description"]) + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "There shouldn't be any error message") + full_ou_dn = self.samdb.normalize_dn_in_domain("OU=%s" % ou["name"]) + self.assertIn('Added ou "%s"' % full_ou_dn, out) + + found = self._find_ou(ou["name"]) + + self.assertEqual("%s" % found.get("ou"), + "%s" % ou["name"]) + + # try to delete all the ous we just added (with full dn) + for ou in self.ous: + full_ou_dn = self.samdb.normalize_dn_in_domain("OU=%s" % ou["name"]) + (result, out, err) = self.runsubcmd("ou", "delete", str(full_ou_dn)) + self.assertCmdSuccess(result, out, err, + "Failed to delete ou '%s'" % ou["name"]) + found = self._find_ou(ou["name"]) + self.assertIsNone(found, + "Deleted ou '%s' still exists" % ou["name"]) + + # test creating ous (with full dn) + for ou in self.ous: + full_ou_dn = self.samdb.normalize_dn_in_domain("OU=%s" % ou["name"]) + (result, out, err) = self.runsubcmd( + "ou", "add", str(full_ou_dn), + "--description=%s" % ou["description"]) + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "There shouldn't be any error message") + full_ou_dn = self.samdb.normalize_dn_in_domain("OU=%s" % ou["name"]) + self.assertIn('Added ou "%s"' % full_ou_dn, out) + + found = self._find_ou(ou["name"]) + + self.assertEqual("%s" % found.get("ou"), + "%s" % ou["name"]) + + def test_list(self): + (result, out, err) = self.runsubcmd("ou", "list") + self.assertCmdSuccess(result, out, err, "Error running list") + + search_filter = "(objectClass=organizationalUnit)" + + oulist = self.samdb.search(base=self.samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter, + attrs=["name"]) + + self.assertTrue(len(oulist) > 0, "no ous found in samdb") + + for ouobj in oulist: + name = ouobj.get("name", idx=0) + found = self.assertMatch(out, str(name), + "ou '%s' not found" % name) + + def test_list_base_dn(self): + base_dn = str(self.samdb.domain_dn()) + (result, out, err) = self.runsubcmd("ou", "list", "-b", base_dn) + self.assertCmdSuccess(result, out, err, "Error running list") + + search_filter = "(objectClass=organizationalUnit)" + + oulist = self.samdb.search(base=base_dn, + scope=ldb.SCOPE_SUBTREE, + expression=search_filter, + attrs=["name"]) + + self.assertTrue(len(oulist) > 0, "no ous found in samdb") + + for ouobj in oulist: + name = ouobj.get("name", idx=0) + found = self.assertMatch(out, str(name), + "ou '%s' not found" % name) + + def test_rename(self): + for ou in self.ous: + ousuffix = "RenameTest" + newouname = ou["name"] + ousuffix + (result, out, err) = self.runsubcmd("ou", "rename", + "OU=%s" % ou["name"], + "OU=%s" % newouname) + self.assertCmdSuccess(result, out, err, + "Failed to rename ou '%s'" % ou["name"]) + found = self._find_ou(ou["name"]) + self.assertIsNone(found, + "Renamed ou '%s' still exists" % ou["name"]) + found = self._find_ou(newouname) + self.assertIsNotNone(found, + "Renamed ou '%s' does not exist" % newouname) + + (result, out, err) = self.runsubcmd("ou", "rename", + "OU=%s" % newouname, + "OU=%s" % ou["name"]) + self.assertCmdSuccess(result, out, err, + "Failed to rename ou '%s'" % newouname) + + def test_move(self): + parentou = self._randomOU({"name": "parentOU"}) + (result, out, err) = self._create_ou(parentou) + self.assertCmdSuccess(result, out, err) + + for ou in self.ous: + olddn = self._find_ou(ou["name"]).get("dn") + + (result, out, err) = self.runsubcmd("ou", "move", + "OU=%s" % ou["name"], + "OU=%s" % parentou["name"]) + self.assertCmdSuccess(result, out, err, + "Failed to move ou '%s'" % ou["name"]) + self.assertEqual(err, "", "There shouldn't be any error message") + full_ou_dn = self.samdb.normalize_dn_in_domain("OU=%s" % ou["name"]) + self.assertIn('Moved ou "%s"' % full_ou_dn, out) + + found = self._find_ou(ou["name"]) + self.assertNotEqual(found.get("dn"), olddn, + "Moved ou '%s' still exists with the same dn" % + ou["name"]) + newexpecteddn = ldb.Dn(self.samdb, + "OU=%s,OU=%s,%s" % + (ou["name"], parentou["name"], + self.samdb.domain_dn())) + self.assertEqual(found.get("dn"), newexpecteddn, + "Moved ou '%s' does not exist" % + ou["name"]) + + (result, out, err) = self.runsubcmd("ou", "move", + "%s" % newexpecteddn, + "%s" % olddn.parent()) + self.assertCmdSuccess(result, out, err, + "Failed to move ou '%s'" % ou["name"]) + + (result, out, err) = self.runsubcmd("ou", "delete", + "OU=%s" % parentou["name"]) + self.assertCmdSuccess(result, out, err, + "Failed to delete ou '%s'" % parentou["name"]) + + def test_listobjects(self): + (result, out, err) = self.runsubcmd("ou", "listobjects", + "%s" % self.samdb.domain_dn(), + "--full-dn") + self.assertCmdSuccess(result, out, err, + "Failed to list ou's objects") + self.assertEqual(err, "", "There shouldn't be any error message") + + objlist = self.samdb.search(base=self.samdb.domain_dn(), + scope=ldb.SCOPE_ONELEVEL, + attrs=[]) + self.assertTrue(len(objlist) > 0, "no objects found") + + for obj in objlist: + found = self.assertMatch(out, str(obj.dn), + "object '%s' not found" % obj.dn) + + def test_list_full_dn(self): + (result, out, err) = self.runsubcmd("ou", "list", + "--full-dn") + self.assertCmdSuccess(result, out, err, + "Failed to list ous") + self.assertEqual(err, "", "There shouldn't be any error message") + + filter = "(objectClass=organizationalUnit)" + objlist = self.samdb.search(base=self.samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=filter, + attrs=[]) + self.assertTrue(len(objlist) > 0, "no ou objects found") + + for obj in objlist: + found = self.assertMatch(out, str(obj.dn), + "object '%s' not found" % obj.dn) + + def _randomOU(self, base=None): + """create an ou with random attribute values, you can specify base + attributes""" + if base is None: + base = {} + ou = { + "name": self.randomName(), + "description": self.randomName(count=100), + } + ou.update(base) + return ou + + def _create_ou(self, ou): + return self.runsubcmd("ou", "add", "OU=%s" % ou["name"], + "--description=%s" % ou["description"]) + + def _find_ou(self, name): + search_filter = ("(&(name=%s)(objectCategory=%s,%s))" % + (ldb.binary_encode(name), + "CN=Organizational-Unit,CN=Schema,CN=Configuration", + self.samdb.domain_dn())) + oulist = self.samdb.search(base=self.samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter) + if oulist: + return oulist[0] + else: + return None diff --git a/python/samba/tests/samba_tool/passwordsettings.py b/python/samba/tests/samba_tool/passwordsettings.py new file mode 100644 index 0000000..6db7a58 --- /dev/null +++ b/python/samba/tests/samba_tool/passwordsettings.py @@ -0,0 +1,484 @@ +# Test 'samba-tool domain passwordsettings' sub-commands +# +# Copyright (C) Andrew Bartlett <abartlet@samba.org> 2018 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +import ldb +from samba.tests.samba_tool.base import SambaToolCmdTest +from samba.tests.pso import PasswordSettings, TestUser + + +class PwdSettingsCmdTestCase(SambaToolCmdTest): + """Tests for 'samba-tool domain passwordsettings' subcommands""" + + def setUp(self): + super().setUp() + self.server = "ldap://%s" % os.environ["DC_SERVER"] + self.user_auth = "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"]) + self.ldb = self.getSamDB("-H", self.server, self.user_auth) + system_dn = "CN=System,%s" % self.ldb.domain_dn() + self.pso_container = "CN=Password Settings Container,%s" % system_dn + self.obj_cleanup = [] + + def tearDown(self): + super().tearDown() + # clean-up any objects the test has created + for dn in self.obj_cleanup: + self.ldb.delete(dn) + + def check_pso(self, pso_name, pso): + """Checks the PSO info in the DB matches what's expected""" + + # lookup the PSO in the DB + dn = "CN=%s,%s" % (pso_name, self.pso_container) + pso_attrs = ['name', 'msDS-PasswordSettingsPrecedence', + 'msDS-PasswordReversibleEncryptionEnabled', + 'msDS-PasswordHistoryLength', + 'msDS-MinimumPasswordLength', + 'msDS-PasswordComplexityEnabled', + 'msDS-MinimumPasswordAge', + 'msDS-MaximumPasswordAge', + 'msDS-LockoutObservationWindow', + 'msDS-LockoutThreshold', 'msDS-LockoutDuration'] + res = self.ldb.search(dn, scope=ldb.SCOPE_BASE, attrs=pso_attrs) + self.assertEqual(len(res), 1, "PSO lookup failed") + + # convert types in the PSO-settings to what the search returns, i.e. + # boolean --> string, seconds --> timestamps in -100 nanosecond units + complexity_str = "TRUE" if pso.complexity else "FALSE" + plaintext_str = "TRUE" if pso.store_plaintext else "FALSE" + lockout_duration = -int(pso.lockout_duration * (1e7)) + lockout_window = -int(pso.lockout_window * (1e7)) + min_age = -int(pso.password_age_min * (1e7)) + max_age = -int(pso.password_age_max * (1e7)) + + # check the PSO's settings match the search results + self.assertEqual(str(res[0]['msDS-PasswordComplexityEnabled'][0]), + complexity_str) + plaintext_res = res[0]['msDS-PasswordReversibleEncryptionEnabled'][0] + self.assertEqual(str(plaintext_res), plaintext_str) + self.assertEqual(int(res[0]['msDS-PasswordHistoryLength'][0]), + pso.history_len) + self.assertEqual(int(res[0]['msDS-MinimumPasswordLength'][0]), + pso.password_len) + self.assertEqual(int(res[0]['msDS-MinimumPasswordAge'][0]), min_age) + self.assertEqual(int(res[0]['msDS-MaximumPasswordAge'][0]), max_age) + self.assertEqual(int(res[0]['msDS-LockoutObservationWindow'][0]), + lockout_window) + self.assertEqual(int(res[0]['msDS-LockoutDuration'][0]), + lockout_duration) + self.assertEqual(int(res[0]['msDS-LockoutThreshold'][0]), + pso.lockout_attempts) + self.assertEqual(int(res[0]['msDS-PasswordSettingsPrecedence'][0]), + pso.precedence) + + # check we can also display the PSO via the show command + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "pso", "show"), pso_name, + "-H", self.server, + self.user_auth) + self.assertTrue(len(out.split(":")) >= 10, + "Expect 10 fields displayed") + + # for a few settings, sanity-check the display is what we expect + self.assertIn("Minimum password length: %u" % pso.password_len, out) + self.assertIn("Password history length: %u" % pso.history_len, out) + lockout_str = "lockout threshold (attempts): %u" % pso.lockout_attempts + self.assertIn(lockout_str, out) + + def test_pso_create(self): + """Tests basic PSO creation using the samba-tool""" + + # we expect the PSO to take the current domain settings by default + # (we'll set precedence/complexity, the rest should be the defaults) + expected_pso = PasswordSettings(None, self.ldb) + expected_pso.complexity = False + expected_pso.precedence = 100 + + # check basic PSO creation works + pso_name = "test-create-PSO" + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "pso", "create"), pso_name, + "100", "--complexity=off", + "-H", self.server, + self.user_auth) + # make sure we clean-up after the test completes + self.obj_cleanup.append("CN=%s,%s" % (pso_name, self.pso_container)) + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn("successfully created", out) + self.check_pso(pso_name, expected_pso) + + # check creating a PSO with the same name fails + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "pso", "create"), pso_name, + "100", "--complexity=off", + "-H", self.server, + self.user_auth) + self.assertCmdFail(result, "Ensure that create for existing PSO fails") + self.assertIn("already exists", err) + + # check we need to specify at least one password policy argument + pso_name = "test-create-PSO2" + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "pso", "create"), pso_name, + "100", "-H", self.server, + self.user_auth) + self.assertCmdFail(result, "Ensure that create for existing PSO fails") + self.assertIn("specify at least one password policy setting", err) + + # create a PSO with different settings and check they match + expected_pso.complexity = True + expected_pso.store_plaintext = True + expected_pso.precedence = 50 + expected_pso.password_len = 12 + day_in_secs = 60 * 60 * 24 + expected_pso.password_age_min = 11 * day_in_secs + expected_pso.password_age_max = 50 * day_in_secs + + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "pso", "create"), pso_name, + "50", "--complexity=on", + "--store-plaintext=on", + "--min-pwd-length=12", + "--min-pwd-age=11", + "--max-pwd-age=50", + "-H", self.server, + self.user_auth) + self.obj_cleanup.append("CN=%s,%s" % (pso_name, self.pso_container)) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn("successfully created", out) + self.check_pso(pso_name, expected_pso) + + # check the PSOs we created are present in the 'list' command + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "pso", "list"), + "-H", self.server, + self.user_auth) + self.assertCmdSuccess(result, out, err) + self.assertIn("test-create-PSO", out) + self.assertIn("test-create-PSO2", out) + + def _create_pso(self, pso_name): + """Creates a PSO for use in other tests""" + # the new PSO will take the current domain settings by default + pso_settings = PasswordSettings(None, self.ldb) + pso_settings.name = pso_name + pso_settings.password_len = 10 + pso_settings.precedence = 200 + + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "pso", "create"), pso_name, + "200", "--min-pwd-length=10", + "-H", self.server, + self.user_auth) + # make sure we clean-up after the test completes + pso_settings.dn = "CN=%s,%s" % (pso_name, self.pso_container) + self.obj_cleanup.append(pso_settings.dn) + + # sanity-check the cmd was successful + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn("successfully created", out) + self.check_pso(pso_name, pso_settings) + + return pso_settings + + def test_pso_set(self): + """Tests we can modify a PSO using the samba-tool""" + + pso_name = "test-set-PSO" + pso_settings = self._create_pso(pso_name) + + # check we can update a PSO's settings + pso_settings.precedence = 99 + pso_settings.lockout_attempts = 10 + pso_settings.lockout_duration = 60 * 17 + (res, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "pso", "set"), pso_name, + "--precedence=99", + "--account-lockout-threshold=10", + "--account-lockout-duration=17", + "-H", self.server, + self.user_auth) + self.assertCmdSuccess(res, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn("Successfully updated", out) + + # check the PSO's settings now reflect the new values + self.check_pso(pso_name, pso_settings) + + def test_pso_delete(self): + """Tests we can delete a PSO using the samba-tool""" + + pso_name = "test-delete-PSO" + self._create_pso(pso_name) + + # check we can successfully delete the PSO + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "pso", "delete"), pso_name, + "-H", self.server, + self.user_auth) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn("Deleted PSO", out) + dn = "CN=%s,%s" % (pso_name, self.pso_container) + self.obj_cleanup.remove(dn) + + # check the object no longer exists in the DB + try: + self.ldb.search(dn, scope=ldb.SCOPE_BASE, attrs=['name']) + self.fail("PSO shouldn't exist") + except ldb.LdbError as e: + (enum, estr) = e.args + self.assertEqual(enum, ldb.ERR_NO_SUCH_OBJECT) + + # run the same cmd again - it should fail because PSO no longer exists + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "pso", "delete"), pso_name, + "-H", self.server, + self.user_auth) + self.assertCmdFail(result, "Deleting a non-existent PSO should fail") + self.assertIn("Unable to find PSO", err) + + def check_pso_applied(self, user, pso): + """Checks that the correct PSO is applied to a given user""" + + # first check the samba-tool output tells us the correct PSO is applied + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "pso", "show-user"), + user.name, "-H", self.server, + self.user_auth) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + if pso is None: + self.assertIn("No PSO applies to user", out) + else: + self.assertIn(pso.name, out) + + # then check the DB tells us the same thing + if pso is None: + self.assertEqual(user.get_resultant_PSO(), None) + else: + self.assertEqual(user.get_resultant_PSO(), pso.dn) + + def test_pso_apply_to_user(self): + """Checks we can apply/unapply a PSO to a user""" + + pso_name = "test-apply-PSO" + test_pso = self._create_pso(pso_name) + + # check that a new user has no PSO applied by default + user = TestUser("test-PSO-user", self.ldb) + self.obj_cleanup.append(user.dn) + self.check_pso_applied(user, pso=None) + + # add the user to a new group + group_name = "test-PSO-group" + dn = "CN=%s,%s" % (group_name, self.ldb.domain_dn()) + self.ldb.add({"dn": dn, "objectclass": "group", + "sAMAccountName": group_name}) + self.obj_cleanup.append(dn) + m = ldb.Message() + m.dn = ldb.Dn(self.ldb, dn) + m["member"] = ldb.MessageElement(user.dn, ldb.FLAG_MOD_ADD, "member") + self.ldb.modify(m) + + # check samba-tool can successfully link a PSO to a group + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "pso", "apply"), pso_name, + group_name, "-H", self.server, + self.user_auth) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.check_pso_applied(user, pso=test_pso) + + # we should fail if we try to apply the same PSO/group twice though + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "pso", "apply"), pso_name, + group_name, "-H", self.server, + self.user_auth) + self.assertCmdFail(result, "Shouldn't be able to apply PSO twice") + self.assertIn("already applies", err) + + # check samba-tool can successfully link a PSO to a user + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "pso", "apply"), pso_name, + user.name, "-H", self.server, + self.user_auth) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.check_pso_applied(user, pso=test_pso) + + # check samba-tool can successfully unlink a group from a PSO + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "pso", "unapply"), pso_name, + group_name, "-H", self.server, + self.user_auth) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + # PSO still applies directly to the user, even though group was removed + self.check_pso_applied(user, pso=test_pso) + + # check samba-tool can successfully unlink a user from a PSO + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "pso", "unapply"), pso_name, + user.name, "-H", self.server, + self.user_auth) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.check_pso_applied(user, pso=None) + + def test_pso_unpriv(self): + """Checks unprivileged users can't modify PSOs via samba-tool""" + + # create a dummy PSO and a non-admin user + pso_name = "test-unpriv-PSO" + self._create_pso(pso_name) + user = TestUser("test-unpriv-user", self.ldb) + self.obj_cleanup.append(user.dn) + unpriv_auth = "-U%s%%%s" % (user.name, user.get_password()) + + # check we need admin privileges to be able to do anything to PSOs + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "pso", "set"), pso_name, + "--complexity=off", "-H", + self.server, unpriv_auth) + self.assertCmdFail(result, "Need admin privileges to modify PSO") + self.assertIn("You may not have permission", err) + + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "pso", "create"), "bad-perm", + "250", "--complexity=off", + "-H", self.server, + unpriv_auth) + self.assertCmdFail(result, "Need admin privileges to modify PSO") + self.assertIn("Administrator permissions are needed", err) + + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "pso", "delete"), pso_name, + "-H", self.server, + unpriv_auth) + self.assertCmdFail(result, "Need admin privileges to delete PSO") + self.assertIn("You may not have permission", err) + + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "pso", "show"), pso_name, + "-H", self.server, + unpriv_auth) + self.assertCmdFail(result, "Need admin privileges to view PSO") + self.assertIn("You may not have permission", err) + + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "pso", "apply"), pso_name, + user.name, "-H", self.server, + unpriv_auth) + self.assertCmdFail(result, "Need admin privileges to modify PSO") + self.assertIn("You may not have permission", err) + + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "pso", "unapply"), pso_name, + user.name, "-H", self.server, + unpriv_auth) + self.assertCmdFail(result, "Need admin privileges to modify PSO") + self.assertIn("You may not have permission", err) + + # The 'list' command actually succeeds because it's not easy to tell + # whether we got no results due to lack of permissions, or because + # there were no PSOs to display + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "pso", "list"), "-H", + self.server, unpriv_auth) + self.assertCmdSuccess(result, out, err) + self.assertIn("No PSOs", out) + self.assertIn("permission", out) + + def test_domain_passwordsettings(self): + """Checks the 'set/show' commands for the domain settings (non-PSO)""" + + # check the 'show' cmd for the domain settings + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "show"), "-H", self.server, + self.user_auth) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + + # check an arbitrary setting is displayed correctly + min_pwd_len = self.ldb.get_minPwdLength() + self.assertIn("Minimum password length: %s" % min_pwd_len, out) + + # check we can change the domain setting + self.addCleanup(self.ldb.set_minPwdLength, min_pwd_len) + new_len = int(min_pwd_len) + 3 + min_pwd_args = "--min-pwd-length=%u" % new_len + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "set"), min_pwd_args, + "-H", self.server, + self.user_auth) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn("successful", out) + self.assertEqual(new_len, self.ldb.get_minPwdLength()) + + # check the updated value is now displayed + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "show"), "-H", self.server, + self.user_auth) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn("Minimum password length: %u" % new_len, out) + + def test_domain_passwordsettings_pwdage(self): + """Checks the 'set' command for the domain password age (non-PSO)""" + + # check we can set the domain max password age + max_pwd_age = self.ldb.get_maxPwdAge() + self.addCleanup(self.ldb.set_maxPwdAge, max_pwd_age) + max_pwd_args = "--max-pwd-age=270" + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "set"), max_pwd_args, + "-H", self.server, + self.user_auth) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn("successful", out) + self.assertNotEqual(max_pwd_age, self.ldb.get_maxPwdAge()) + + # check we can't set the domain min password age to more than the max + min_pwd_age = self.ldb.get_minPwdAge() + self.addCleanup(self.ldb.set_minPwdAge, min_pwd_age) + min_pwd_args = "--min-pwd-age=271" + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "set"), min_pwd_args, + "-H", self.server, + self.user_auth) + self.assertCmdFail(result, "minPwdAge > maxPwdAge should be rejected") + self.assertIn("Maximum password age", err) + + # check we can set the domain min password age to less than the max + min_pwd_args = "--min-pwd-age=269" + (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings", + "set"), min_pwd_args, + "-H", self.server, + self.user_auth) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn("successful", out) + self.assertNotEqual(min_pwd_age, self.ldb.get_minPwdAge()) diff --git a/python/samba/tests/samba_tool/processes.py b/python/samba/tests/samba_tool/processes.py new file mode 100644 index 0000000..4407797 --- /dev/null +++ b/python/samba/tests/samba_tool/processes.py @@ -0,0 +1,42 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) Andrew Bartlett 2012 +# +# based on time.py: +# Copyright (C) Sean Dague <sdague@linux.vnet.ibm.com> 2011 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +from samba.tests.samba_tool.base import SambaToolCmdTest + + +class ProcessCmdTestCase(SambaToolCmdTest): + """Tests for samba-tool process subcommands""" + + def test_name(self): + """Run processes command""" + (result, out, err) = self.runcmd("processes", "--name", "samba") + self.assertCmdSuccess(result, out, err, "Ensuring processes ran successfully") + + def test_unknown_name(self): + """Run processes command with an not-existing --name""" + (result, out, err) = self.runcmd("processes", "--name", "not-existing-samba") + self.assertCmdSuccess(result, out, err, "Ensuring processes ran successfully") + self.assertEqual(out, "") + + def test_all(self): + """Run processes command""" + (result, out, err) = self.runcmd("processes") + self.assertCmdSuccess(result, out, err, "Ensuring processes ran successfully") diff --git a/python/samba/tests/samba_tool/promote_dc_lmdb_size.py b/python/samba/tests/samba_tool/promote_dc_lmdb_size.py new file mode 100644 index 0000000..88e9d7c --- /dev/null +++ b/python/samba/tests/samba_tool/promote_dc_lmdb_size.py @@ -0,0 +1,174 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) Catalyst IT Ltd. 2019 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +from samba.tests.samba_tool.base import SambaToolCmdTest +import os +import shutil + + +class PromoteDcLmdbSizeTestCase(SambaToolCmdTest): + """Test setting of the lmdb map size during a promote dc""" + + def setUp(self): + super().setUp() + self.tempsambadir = os.path.join(self.tempdir, "samba") + os.mkdir(self.tempsambadir) + (_, name) = os.path.split(self.tempdir) + self.netbios_name = name + + # join a domain as a member server + # + # returns the tuple (ret, stdout, stderr) + def join_member(self): + command = ( + "samba-tool " + + "domain join " + + os.environ["REALM"] + " " + + "member " + + ("-U%s%%%s " % (os.environ["USERNAME"], os.environ["PASSWORD"])) + + ("--option=netbiosname=%s " % self.netbios_name) + + ("--targetdir=%s " % self.tempsambadir)) + return self.run_command(command) + + # + # Promote a member server to a domain controller + def promote(self, size=None, role=None): + command = ( + "samba-tool " + + "domain dcpromo " + + os.environ["REALM"] + " " + + role + " " + + ("-U%s%%%s " % (os.environ["USERNAME"], os.environ["PASSWORD"])) + + ("--option=netbiosname=%s " % self.netbios_name) + + ("--targetdir=%s " % self.tempsambadir) + + "--backend-store=mdb " + ) + if size: + command += ("--backend-store-size=%s" % size) + + (ret, stdout, stderr) = self.run_command(command) + if ret == 0: + self.cleanup_join(self.netbios_name) + + return (ret, stdout, stderr) + + def is_rodc(self): + url = "ldb://%s/private/sam.ldb" % self.tempsambadir + samdb = self.getSamDB("-H", url) + return samdb.am_rodc() + + # + # Get the lmdb map size for the specified command + # + # While there is a python lmdb package available we use the lmdb command + # line utilities to avoid introducing a dependency. + # + def get_lmdb_environment_size(self, path): + (result, out, err) = self.run_command("mdb_stat -ne %s" % path) + if result: + self.fail("Unable to run mdb_stat\n") + for line in out.split("\n"): + line = line.strip() + if line.startswith("Map size:"): + line = line.replace(" ", "") + (label, size) = line.split(":") + return int(size) + + # + # Check the lmdb files created by join and ensure that the map size + # has been set to size. + # + # Currently this is all the *.ldb files in private/sam.ldb.d + # + def check_lmdb_environment_sizes(self, size): + directory = os.path.join(self.tempsambadir, "private", "sam.ldb.d") + for name in os.listdir(directory): + if name.endswith(".ldb"): + path = os.path.join(directory, name) + s = self.get_lmdb_environment_size(path) + if s != size: + self.fail("File %s, size=%d larger than %d" % + (name, s, size)) + + # + # Ensure that if --backend-store-size is not specified the default of + # 8Gb is used + def test_promote_dc_default(self): + (result, out, err) = self.join_member() + self.assertEqual(0, result) + (result, out, err) = self.promote(role="DC") + self.assertEqual(0, result) + self.check_lmdb_environment_sizes(8 * 1024 * 1024 * 1024) + self.assertFalse(self.is_rodc()) + + # + # Ensure that if --backend-store-size is not specified the default of + # 8Gb is used + def test_promote_rodc_default(self): + (result, out, err) = self.join_member() + self.assertEqual(0, result) + (result, out, err) = self.promote(role="RODC") + self.assertEqual(0, result) + self.check_lmdb_environment_sizes(8 * 1024 * 1024 * 1024) + self.assertTrue(self.is_rodc()) + + # + # Promote to a DC with a backend size of 96Mb + def test_promote_dc_96Mb(self): + (result, out, err) = self.join_member() + self.assertEqual(0, result) + (result, out, err) = self.promote(role="DC", size="96Mb") + self.assertEqual(0, result) + self.check_lmdb_environment_sizes(96 * 1024 * 1024) + self.assertFalse(self.is_rodc()) + + # + # Promote to an RODC with a backend size of 256Mb + def test_promote_rodc_256Mb(self): + (result, out, err) = self.join_member() + self.assertEqual(0, result) + (result, out, err) = self.promote(role="RODC", size="256Mb") + self.assertEqual(0, result) + self.check_lmdb_environment_sizes(256 * 1024 * 1024) + self.assertTrue(self.is_rodc()) + + def test_no_unit_suffix(self): + (result, out, err) = self.run_command( + 'samba-tool domain dcpromo --backend-store-size "2"') + self.assertGreater(result, 0) + self.assertRegex(err, + r"--backend-store-size invalid suffix ''") + + def test_invalid_unit_suffix(self): + (result, out, err) = self.run_command( + 'samba-tool domain dcpromo --backend-store-size "2 cd"') + self.assertGreater(result, 0) + self.assertRegex(err, + r"--backend-store-size invalid suffix 'cd'") + + def test_non_numeric(self): + (result, out, err) = self.run_command( + 'samba-tool domain dcpromo --backend-store-size "two Gb"') + self.assertGreater(result, 0) + self.assertRegex( + err, + r"backend-store-size option requires a numeric value, with an" + " optional unit suffix") + + def tearDown(self): + super().tearDown() + shutil.rmtree(self.tempsambadir) diff --git a/python/samba/tests/samba_tool/provision_lmdb_size.py b/python/samba/tests/samba_tool/provision_lmdb_size.py new file mode 100644 index 0000000..3514edf --- /dev/null +++ b/python/samba/tests/samba_tool/provision_lmdb_size.py @@ -0,0 +1,132 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) Catalyst IT Ltd. 2019 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +from samba.tests.samba_tool.base import SambaToolCmdTest +import os +import shutil + + +class ProvisionLmdbSizeTestCase(SambaToolCmdTest): + """Test setting of the lmdb map size during provision""" + + def setUp(self): + super().setUp() + self.tempsambadir = os.path.join(self.tempdir, "samba") + os.mkdir(self.tempsambadir) + + # provision a domain and set the lmdb map size to size + # + # returns the tuple (ret, stdout, stderr) + def provision(self, size=None): + command = ( + "samba-tool " + + "domain provision " + + "--realm=foo.example.com " + + "--domain=FOO " + + ("--targetdir=%s " % self.tempsambadir) + + "--backend-store=mdb " + + "--use-ntvfs " + ) + if size: + command += ("--backend-store-size=%s" % size) + + return self.run_command(command) + + # + # Get the lmdb map size for the specified command + # + # While there is a python lmdb package available we use the lmdb command + # line utilities to avoid introducing a dependency. + # + def get_lmdb_environment_size(self, path): + (result, out, err) = self.run_command("mdb_stat -ne %s" % path) + if result: + self.fail("Unable to run mdb_stat\n") + for line in out.split("\n"): + line = line.strip() + if line.startswith("Map size:"): + line = line.replace(" ", "") + (label, size) = line.split(":") + return int(size) + + # + # Check the lmdb files created by provision and ensure that the map size + # has been set to size. + # + # Currently this is all the *.ldb files in private/sam.ldb.d + # + def check_lmdb_environment_sizes(self, size): + directory = os.path.join(self.tempsambadir, "private", "sam.ldb.d") + for name in os.listdir(directory): + if name.endswith(".ldb"): + path = os.path.join(directory, name) + s = self.get_lmdb_environment_size(path) + if s != size: + self.fail("File %s, size=%d larger than %d" % + (name, s, size)) + + # + # Ensure that if --backend-store-size is not specified the default of + # 8Gb is used + def test_default(self): + (result, out, err) = self.provision() + self.assertEqual(0, result) + self.check_lmdb_environment_sizes(8 * 1024 * 1024 * 1024) + + def test_64Mb(self): + (result, out, err) = self.provision("64Mb") + self.assertEqual(0, result) + self.check_lmdb_environment_sizes(64 * 1024 * 1024) + + def test_1Gb(self): + (result, out, err) = self.provision("1Gb") + self.assertEqual(0, result) + self.check_lmdb_environment_sizes(1 * 1024 * 1024 * 1024) + + # 128Mb specified in bytes. + # + def test_134217728b(self): + (result, out, err) = self.provision("134217728b") + self.assertEqual(0, result) + self.check_lmdb_environment_sizes(134217728) + + def test_no_unit_suffix(self): + (result, out, err) = self.run_command( + 'samba-tool domain provision --backend-store-size "2"') + self.assertGreater(result, 0) + self.assertRegex(err, + r"--backend-store-size invalid suffix ''") + + def test_invalid_unit_suffix(self): + (result, out, err) = self.run_command( + 'samba-tool domain provision --backend-store-size "2 cd"') + self.assertGreater(result, 0) + self.assertRegex(err, + r"--backend-store-size invalid suffix 'cd'") + + def test_non_numeric(self): + (result, out, err) = self.run_command( + 'samba-tool domain provision --backend-store-size "two Gb"') + self.assertGreater(result, 0) + self.assertRegex( + err, + r"backend-store-size option requires a numeric value, with an" + " optional unit suffix") + + def tearDown(self): + super().tearDown() + shutil.rmtree(self.tempsambadir) diff --git a/python/samba/tests/samba_tool/provision_password_check.py b/python/samba/tests/samba_tool/provision_password_check.py new file mode 100644 index 0000000..51b4a4d --- /dev/null +++ b/python/samba/tests/samba_tool/provision_password_check.py @@ -0,0 +1,57 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) Catalyst IT Ltd. 2017 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +from samba.tests.samba_tool.base import SambaToolCmdTest +import os +import shutil + + +class ProvisionPasswordTestCase(SambaToolCmdTest): + """Test for password validation in domain provision subcommand""" + + def setUp(self): + super().setUp() + self.tempsambadir = os.path.join(self.tempdir, "samba") + os.mkdir(self.tempsambadir) + + def _provision_with_password(self, password): + return self.runsubcmd( + "domain", "provision", "--realm=foo.example.com", "--domain=FOO", + "--targetdir=%s" % self.tempsambadir, "--adminpass=%s" % password, + "--use-ntvfs") + + def test_short_and_low_quality(self): + (result, out, err) = self._provision_with_password("foo") + self.assertCmdFail(result) + + def test_short(self): + (result, out, err) = self._provision_with_password("Fo0!_9") + self.assertCmdFail(result) + self.assertRegex(err, r"minimum password length") + + def test_low_quality(self): + (result, out, err) = self._provision_with_password("aaaaaaaaaaaaaaaaa") + self.assertCmdFail(result) + self.assertRegex(err, r"quality standards") + + def test_good(self): + (result, out, err) = self._provision_with_password("Fo0!_9.") + self.assertCmdSuccess(result, out, err) + + def tearDown(self): + super().tearDown() + shutil.rmtree(self.tempsambadir) diff --git a/python/samba/tests/samba_tool/provision_userPassword_crypt.py b/python/samba/tests/samba_tool/provision_userPassword_crypt.py new file mode 100644 index 0000000..2de8cdd --- /dev/null +++ b/python/samba/tests/samba_tool/provision_userPassword_crypt.py @@ -0,0 +1,67 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) Catalyst IT Ltd. 2021 +# +# based on provision_lmdb_size.py: +# Copyright (C) Catalyst IT Ltd. 2019 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +from samba.tests.samba_tool.base import SambaToolCmdTest +import os +import shutil + + +class ProvisionUserPasswordTestCase(SambaToolCmdTest): + """Test for crypt() hashed passwords""" + + def setUp(self): + super().setUp() + self.tempsambadir = os.path.join(self.tempdir, "samba") + os.mkdir(self.tempsambadir) + + # provision a domain + # + # returns the tuple (ret, stdout, stderr) + def provision(self, machinepass=None): + command = ( + "samba-tool " + + "domain provision " + + "--use-rfc230 " + + "--realm=\"EXAMPLE.COM\" " + + "--domain=\"EXAMPLE\" " + + "--adminpass=\"FooBar123\" " + + "--server-role=dc " + + "--host-ip=10.166.183.55 " + + "--option=\"password hash userPassword " + + "schemes=CryptSHA256 CryptSHA512\" " + + ("--targetdir=\"%s\" " % self.tempsambadir) + + "--use-ntvfs" + ) + if machinepass: + command += ("--machinepass=\"%s\"" % machinepass) + + return self.run_command(command) + + def test_crypt(self): + (result, out, err) = self.provision() + self.assertEqual(0, result) + + def test_length(self): + (result, out, err) = self.provision(machinepass="FooBar123" + ("a"*1024)) + self.assertNotEqual(0, result) + + def tearDown(self): + super().tearDown() + shutil.rmtree(self.tempsambadir) diff --git a/python/samba/tests/samba_tool/rodc.py b/python/samba/tests/samba_tool/rodc.py new file mode 100644 index 0000000..94e84d6 --- /dev/null +++ b/python/samba/tests/samba_tool/rodc.py @@ -0,0 +1,131 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) Catalyst IT Ltd. 2015 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +import ldb +import samba +from samba.samdb import SamDB +from samba.tests import delete_force +from samba.tests.samba_tool.base import SambaToolCmdTest +from samba.credentials import Credentials +from samba.auth import system_session + + +class RodcCmdTestCase(SambaToolCmdTest): + def setUp(self): + super().setUp() + self.lp = samba.param.LoadParm() + self.lp.load(os.environ["SMB_CONF_PATH"]) + self.creds = Credentials() + self.creds.set_username(os.environ["DC_USERNAME"]) + self.creds.set_password(os.environ["DC_PASSWORD"]) + self.creds.guess(self.lp) + self.session = system_session() + self.ldb = SamDB("ldap://" + os.environ["DC_SERVER"], + session_info=self.session, credentials=self.creds, lp=self.lp) + + self.base_dn = self.ldb.domain_dn() + + self.ldb.newuser("sambatool1", "1qazXSW@") + self.ldb.newuser("sambatool2", "2wsxCDE#") + self.ldb.newuser("sambatool3", "3edcVFR$") + self.ldb.newuser("sambatool4", "4rfvBGT%") + self.ldb.newuser("sambatool5", "5tjbNHY*") + self.ldb.newuser("sambatool6", "6yknMJU*") + + self.ldb.add_remove_group_members("Allowed RODC Password Replication Group", + ["sambatool1", "sambatool2", "sambatool3", + "sambatool4", "sambatool5"], + add_members_operation=True) + + def tearDown(self): + super().tearDown() + self.ldb.deleteuser("sambatool1") + self.ldb.deleteuser("sambatool2") + self.ldb.deleteuser("sambatool3") + self.ldb.deleteuser("sambatool4") + self.ldb.deleteuser("sambatool5") + self.ldb.deleteuser("sambatool6") + (result, out, err) = self.runsubcmd("drs", "replicate", "--local", "unused", + os.environ["DC_SERVER"], self.base_dn) + + def test_single_by_account_name(self): + (result, out, err) = self.runsubcmd("rodc", "preload", "sambatool1", + "--server", os.environ["DC_SERVER"]) + self.assertCmdSuccess(result, out, err, "ensuring rodc prefetch ran successfully") + self.assertEqual(out, "Replicating DN CN=sambatool1,CN=Users,%s\n" % self.base_dn) + self.assertEqual(err, "") + + def test_single_by_dn(self): + (result, out, err) = self.runsubcmd("rodc", "preload", "cn=sambatool2,cn=users,%s" % self.base_dn, + "--server", os.environ["DC_SERVER"]) + self.assertCmdSuccess(result, out, err, "ensuring rodc prefetch ran successfully") + self.assertEqual(out, "Replicating DN CN=sambatool2,CN=Users,%s\n" % self.base_dn) + + def test_multi_by_account_name(self): + (result, out, err) = self.runsubcmd("rodc", "preload", "sambatool1", "sambatool2", + "--server", os.environ["DC_SERVER"]) + self.assertCmdSuccess(result, out, err, "ensuring rodc prefetch ran successfully") + self.assertEqual(out, "Replicating DN CN=sambatool1,CN=Users,%s\nReplicating DN CN=sambatool2,CN=Users,%s\n" % (self.base_dn, self.base_dn)) + + def test_multi_by_dn(self): + (result, out, err) = self.runsubcmd("rodc", "preload", "cn=sambatool3,cn=users,%s" % self.base_dn, "cn=sambatool4,cn=users,%s" % self.base_dn, + "--server", os.environ["DC_SERVER"]) + self.assertCmdSuccess(result, out, err, "ensuring rodc prefetch ran successfully") + self.assertEqual(out, "Replicating DN CN=sambatool3,CN=Users,%s\nReplicating DN CN=sambatool4,CN=Users,%s\n" % (self.base_dn, self.base_dn)) + + def test_multi_in_file(self): + tempf = os.path.join(self.tempdir, "accountlist") + open(tempf, 'w').write("sambatool1\nsambatool2") + (result, out, err) = self.runsubcmd("rodc", "preload", "--file", tempf, + "--server", os.environ["DC_SERVER"]) + self.assertCmdSuccess(result, out, err, "ensuring rodc prefetch ran successfully") + self.assertEqual(out, "Replicating DN CN=sambatool1,CN=Users,%s\nReplicating DN CN=sambatool2,CN=Users,%s\n" % (self.base_dn, self.base_dn)) + os.unlink(tempf) + + def test_multi_with_missing_name_success(self): + (result, out, err) = self.runsubcmd("rodc", "preload", + "nonexistentuser1", "sambatool5", + "nonexistentuser2", + "--server", os.environ["DC_SERVER"], + "--ignore-errors") + self.assertCmdSuccess(result, out, err, "ensuring rodc prefetch ran successfully") + self.assertTrue(out.startswith("Replicating DN CN=sambatool5,CN=Users,%s\n" + % self.base_dn)) + + def test_multi_with_missing_name_failure(self): + (result, out, err) = self.runsubcmd("rodc", "preload", + "nonexistentuser1", "sambatool5", + "nonexistentuser2", + "--server", os.environ["DC_SERVER"]) + self.assertCmdFail(result, "ensuring rodc prefetch quit on missing user") + + def test_multi_without_group_success(self): + (result, out, err) = self.runsubcmd("rodc", "preload", + "sambatool6", "sambatool5", + "--server", os.environ["DC_SERVER"], + "--ignore-errors") + self.assertCmdSuccess(result, out, err, "ensuring rodc prefetch ran successfully") + self.assertTrue(out.startswith("Replicating DN CN=sambatool6,CN=Users,%s\n" + "Replicating DN CN=sambatool5,CN=Users,%s\n" + % (self.base_dn, self.base_dn))) + + def test_multi_without_group_failure(self): + (result, out, err) = self.runsubcmd("rodc", "preload", + "sambatool6", "sambatool5", + "--server", os.environ["DC_SERVER"]) + self.assertCmdFail(result, "ensuring rodc prefetch quit on non-replicated user") diff --git a/python/samba/tests/samba_tool/schema.py b/python/samba/tests/samba_tool/schema.py new file mode 100644 index 0000000..5c4ac78 --- /dev/null +++ b/python/samba/tests/samba_tool/schema.py @@ -0,0 +1,109 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) William Brown <william@blackhats.net.au> 2018 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +import ldb +from samba.tests.samba_tool.base import SambaToolCmdTest + + +class SchemaCmdTestCase(SambaToolCmdTest): + """Tests for samba-tool dsacl subcommands""" + samdb = None + + def setUp(self): + super().setUp() + self.samdb = self.getSamDB("-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"])) + + def test_display_attribute(self): + """Tests that we can display schema attributes""" + (result, out, err) = self.runsublevelcmd("schema", ("attribute", + "show"), "uid", + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn("dn: CN=uid,CN=Schema,CN=Configuration,", out) + + def test_modify_attribute_searchflags(self): + """Tests that we can modify searchFlags of an attribute""" + (result, out, err) = self.runsublevelcmd("schema", ("attribute", + "modify"), "uid", "--searchflags=9", + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + + self.assertCmdFail(result, 'Unknown flag 9, please see --help') + + (result, out, err) = self.runsublevelcmd("schema", ("attribute", + "modify"), "uid", "--searchflags=fATTINDEX", + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn("modified cn=uid,CN=Schema,CN=Configuration,", out) + + (result, out, err) = self.runsublevelcmd("schema", ("attribute", + "modify"), "uid", + "--searchflags=fATTINDEX,fSUBTREEATTINDEX", + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn("modified cn=uid,CN=Schema,CN=Configuration,", out) + + (result, out, err) = self.runsublevelcmd("schema", ("attribute", + "modify"), "uid", + "--searchflags=fAtTiNdEx,fPRESERVEONDELETE", + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn("modified cn=uid,CN=Schema,CN=Configuration,", out) + + def test_show_oc_attribute(self): + """Tests that we can modify searchFlags of an attribute""" + (result, out, err) = self.runsublevelcmd("schema", ("attribute", + "show_oc"), "cn", + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn("--- MAY contain ---", out) + self.assertIn("--- MUST contain ---", out) + + def test_display_objectclass(self): + """Tests that we can display schema objectclasses""" + (result, out, err) = self.runsublevelcmd("schema", ("objectclass", + "show"), "person", + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn("dn: CN=Person,CN=Schema,CN=Configuration,", out) diff --git a/python/samba/tests/samba_tool/silo_base.py b/python/samba/tests/samba_tool/silo_base.py new file mode 100644 index 0000000..451d330 --- /dev/null +++ b/python/samba/tests/samba_tool/silo_base.py @@ -0,0 +1,229 @@ +# Unix SMB/CIFS implementation. +# +# Base test class for samba-tool domain auth policy and silo commands. +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os + +from ldb import SCOPE_ONELEVEL + +from samba.netcmd.domain.models import Group + +from .base import SambaToolCmdTest + +HOST = "ldap://{DC_SERVER}".format(**os.environ) +CREDS = "-U{DC_USERNAME}%{DC_PASSWORD}".format(**os.environ) + + +class SiloTest(SambaToolCmdTest): + """Base test class for silo and policy related commands.""" + + @classmethod + def setUpClass(cls): + cls.samdb = cls.getSamDB("-H", HOST, CREDS) + super().setUpClass() + + @classmethod + def setUpTestData(cls): + cls.create_authentication_policy(name="User Policy") + cls.create_authentication_policy(name="Service Policy") + cls.create_authentication_policy(name="Computer Policy") + + cls.create_authentication_silo( + name="Developers", + description="Developers, Developers, Developers!", + user_authentication_policy="User Policy") + cls.create_authentication_silo( + name="Managers", + description="Managers", + user_authentication_policy="User Policy") + cls.create_authentication_silo( + name="QA", + description="Quality Assurance", + user_authentication_policy="User Policy", + service_authentication_policy="Service Policy", + computer_authentication_policy="Computer Policy") + + cls.device_group = Group(name="device-group") + cls.device_group.save(cls.samdb) + cls.addClassCleanup(cls.device_group.delete, cls.samdb) + + def get_services_dn(self): + """Returns Services DN.""" + services_dn = self.samdb.get_config_basedn() + services_dn.add_child("CN=Services") + return services_dn + + def get_authn_configuration_dn(self): + """Returns AuthN Configuration DN.""" + authn_policy_configuration = self.get_services_dn() + authn_policy_configuration.add_child("CN=AuthN Policy Configuration") + return authn_policy_configuration + + def get_authn_silos_dn(self): + """Returns AuthN Silos DN.""" + authn_silos_dn = self.get_authn_configuration_dn() + authn_silos_dn.add_child("CN=AuthN Silos") + return authn_silos_dn + + def get_authn_policies_dn(self): + """Returns AuthN Policies DN.""" + authn_policies_dn = self.get_authn_configuration_dn() + authn_policies_dn.add_child("CN=AuthN Policies") + return authn_policies_dn + + def get_users_dn(self): + """Returns Users DN.""" + users_dn = self.samdb.get_root_basedn() + users_dn.add_child("CN=Users") + return users_dn + + def get_user(self, username): + """Get a user by username.""" + users_dn = self.get_users_dn() + + result = self.samdb.search(base=users_dn, + scope=SCOPE_ONELEVEL, + expression=f"(sAMAccountName={username})") + + if len(result) == 1: + return result[0] + + @classmethod + def _run(cls, *argv): + """Override _run, so we don't always have to pass host and creds.""" + args = list(argv) + args.extend(["-H", HOST, CREDS]) + return super()._run(*args) + + runcmd = _run + runsubcmd = _run + + @classmethod + def create_authentication_policy(cls, name, description=None, audit=False, + protect=False): + """Create an authentication policy.""" + + # base command for create authentication policy + cmd = ["domain", "auth", "policy", "create", "--name", name] + + # optional attributes + if description is not None: + cmd.append(f"--description={description}") + if audit: + cmd.append("--audit") + if protect: + cmd.append("--protect") + + # Run command and store name in self.silos for tearDownClass to clean + # up. + result, out, err = cls.runcmd(*cmd) + assert result is None + assert out.startswith("Created authentication policy") + cls.addClassCleanup(cls.delete_authentication_policy, + name=name, force=True) + return name + + @classmethod + def delete_authentication_policy(cls, name, force=False): + """Delete authentication policy by name.""" + cmd = ["domain", "auth", "policy", "delete", "--name", name] + + # Force-delete protected authentication policy. + if force: + cmd.append("--force") + + result, out, err = cls.runcmd(*cmd) + assert result is None + assert "Deleted authentication policy" in out + + @classmethod + def create_authentication_silo(cls, name, description=None, + user_authentication_policy=None, + service_authentication_policy=None, + computer_authentication_policy=None, + audit=False, protect=False): + """Create an authentication silo using the samba-tool command.""" + + # Base command for create authentication policy. + cmd = ["domain", "auth", "silo", "create", "--name", name] + + # Authentication policies. + if user_authentication_policy: + cmd += ["--user-authentication-policy", + user_authentication_policy] + if service_authentication_policy: + cmd += ["--service-authentication-policy", + service_authentication_policy] + if computer_authentication_policy: + cmd += ["--computer-authentication-policy", + computer_authentication_policy] + + # Other optional attributes. + if description is not None: + cmd.append(f"--description={description}") + if protect: + cmd.append("--protect") + if audit: + cmd.append("--audit") + + # Run command and store name in self.silos for tearDownClass to clean + # up. + result, out, err = cls.runcmd(*cmd) + assert result is None + assert out.startswith("Created authentication silo") + cls.addClassCleanup(cls.delete_authentication_silo, + name=name, force=True) + return name + + @classmethod + def delete_authentication_silo(cls, name, force=False): + """Delete authentication silo by name.""" + cmd = ["domain", "auth", "silo", "delete", "--name", name] + + # Force-delete protected authentication silo. + if force: + cmd.append("--force") + + result, out, err = cls.runcmd(*cmd) + assert result is None + assert "Deleted authentication silo" in out + + def get_authentication_silo(self, name): + """Get authentication silo by name.""" + authn_silos_dn = self.get_authn_silos_dn() + + result = self.samdb.search(base=authn_silos_dn, + scope=SCOPE_ONELEVEL, + expression=f"(CN={name})") + + if len(result) == 1: + return result[0] + + def get_authentication_policy(self, name): + """Get authentication policy by name.""" + authn_policies_dn = self.get_authn_policies_dn() + + result = self.samdb.search(base=authn_policies_dn, + scope=SCOPE_ONELEVEL, + expression=f"(CN={name})") + + if len(result) == 1: + return result[0] diff --git a/python/samba/tests/samba_tool/sites.py b/python/samba/tests/samba_tool/sites.py new file mode 100644 index 0000000..4288f35 --- /dev/null +++ b/python/samba/tests/samba_tool/sites.py @@ -0,0 +1,205 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) Catalyst.Net LTD 2015 +# Copyright (C) Sean Dague <sdague@linux.vnet.ibm.com> 2011 +# +# Catalyst.Net's contribution was written by Douglas Bagnall +# <douglas.bagnall@catalyst.net.nz>. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import json +import os +import ldb +from samba.tests.samba_tool.base import SambaToolCmdTest +from samba import sites, subnets + + +class BaseSitesCmdTestCase(SambaToolCmdTest): + """Tests for samba-tool sites subnets""" + def setUp(self): + super().setUp() + self.dburl = "ldap://%s" % os.environ["DC_SERVER"] + self.creds_string = "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"]) + + self.samdb = self.getSamDB("-H", self.dburl, self.creds_string) + self.config_dn = str(self.samdb.get_config_basedn()) + + +class SitesCmdTestCase(BaseSitesCmdTestCase): + + def test_site_create(self): + sitename = 'new_site' + + result, out, err = self.runsubcmd("sites", "create", sitename, + "-H", self.dburl, self.creds_string) + self.assertCmdSuccess(result, out, err) + + dnsites = ldb.Dn(self.samdb, "CN=Sites,%s" % self.config_dn) + dnsite = ldb.Dn(self.samdb, "CN=%s,%s" % (sitename, dnsites)) + + ret = self.samdb.search(base=dnsites, scope=ldb.SCOPE_ONELEVEL, + expression='(cn=%s)' % sitename) + self.assertEqual(len(ret), 1) + + # now delete it + self.samdb.delete(dnsite, ["tree_delete:0"]) + + def test_site_list(self): + result, out, err = self.runsubcmd("sites", "list", + "-H", self.dburl, self.creds_string) + self.assertCmdSuccess(result, out, err) + self.assertIn("Default-First-Site-Name", out) + + # The same but with --json + result, out, err = self.runsubcmd("sites", "list", "--json", + "-H", self.dburl, self.creds_string) + self.assertCmdSuccess(result, out, err) + json_data = json.loads(out) + self.assertIn("Default-First-Site-Name", json_data) + + def test_site_view(self): + result, out, err = self.runsubcmd("sites", "view", + "Default-First-Site-Name", + "-H", self.dburl, self.creds_string) + self.assertCmdSuccess(result, out, err) + json_data = json.loads(out) + self.assertEqual(json_data["cn"], "Default-First-Site-Name") + + # Now try one that doesn't exist + result, out, err = self.runsubcmd("sites", "view", + "Does-Not-Exist", + "-H", self.dburl, self.creds_string) + self.assertCmdFail(result, err) + + +class SitesSubnetCmdTestCase(BaseSitesCmdTestCase): + def setUp(self): + super().setUp() + self.sitename = "testsite" + self.sitename2 = "testsite2" + self.samdb.transaction_start() + sites.create_site(self.samdb, self.config_dn, self.sitename) + sites.create_site(self.samdb, self.config_dn, self.sitename2) + self.samdb.transaction_commit() + + def tearDown(self): + self.samdb.transaction_start() + sites.delete_site(self.samdb, self.config_dn, self.sitename) + sites.delete_site(self.samdb, self.config_dn, self.sitename2) + self.samdb.transaction_commit() + super().tearDown() + + def test_site_subnet_create(self): + cidrs = (("10.9.8.0/24", self.sitename), + ("50.60.0.0/16", self.sitename2), + ("50.61.0.0/16", self.sitename2), # second subnet on the site + ("50.0.0.0/8", self.sitename), # overlapping subnet, other site + ("50.62.1.2/32", self.sitename), # single IP + ("aaaa:bbbb:cccc:dddd:eeee:ffff:2222:1100/120", + self.sitename2), + ) + + for cidr, sitename in cidrs: + result, out, err = self.runsubcmd("sites", "subnet", "create", + cidr, sitename, + "-H", self.dburl, + self.creds_string) + self.assertCmdSuccess(result, out, err) + + ret = self.samdb.search(base=self.config_dn, + scope=ldb.SCOPE_SUBTREE, + expression=('(&(objectclass=subnet)(cn=%s))' + % cidr)) + self.assertIsNotNone(ret) + self.assertEqual(len(ret), 1) + + dnsubnets = ldb.Dn(self.samdb, + "CN=Subnets,CN=Sites,%s" % self.config_dn) + + for cidr, sitename in cidrs: + dnsubnet = ldb.Dn(self.samdb, ("Cn=%s,CN=Subnets,CN=Sites,%s" % + (cidr, self.config_dn))) + + ret = self.samdb.search(base=dnsubnets, scope=ldb.SCOPE_ONELEVEL, + expression='(CN=%s)' % cidr) + self.assertIsNotNone(ret) + self.assertEqual(len(ret), 1) + self.samdb.delete(dnsubnet, ["tree_delete:0"]) + + def test_site_subnet_create_should_fail(self): + cidrs = (("10.9.8.0/33", self.sitename), # mask too big + ("50.60.0.0/8", self.sitename2), # insufficient zeros + ("50.261.0.0/16", self.sitename2), # bad octet + ("7.0.0.0.0/0", self.sitename), # insufficient zeros + ("aaaa:bbbb:cccc:dddd:eeee:ffff:2222:1100/119", + self.sitename), # insufficient zeros + ) + + for cidr, sitename in cidrs: + result, out, err = self.runsubcmd("sites", "subnet", "create", + cidr, sitename, + "-H", self.dburl, + self.creds_string) + self.assertCmdFail(result) + + ret = self.samdb.search(base=self.config_dn, + scope=ldb.SCOPE_SUBTREE, + expression=('(&(objectclass=subnet)(cn=%s))' + % cidr)) + + self.assertIsNotNone(ret) + self.assertEqual(len(ret), 0) + + def test_site_subnet_list(self): + subnet = "10.9.8.0/24" + subnets.create_subnet(self.samdb, self.samdb.get_config_basedn(), + subnet, self.sitename) + + # cleanup after test + dnsubnet = ldb.Dn(self.samdb, ("CN=%s,CN=Subnets,CN=Sites,%s" % + (subnet, self.config_dn))) + self.addCleanup(self.samdb.delete, dnsubnet, ["tree_delete:1"]) + + result, out, err = self.runsubcmd("sites", "subnet", "list", + self.sitename, + "-H", self.dburl, self.creds_string) + + self.assertCmdSuccess(result, out, err) + self.assertIn(subnet, out) + + def test_site_subnet_view(self): + subnet = "50.60.0.0/16" + subnets.create_subnet(self.samdb, self.samdb.get_config_basedn(), + subnet, self.sitename2) + + # cleanup after test + dnsubnet = ldb.Dn(self.samdb, ("CN=%s,CN=Subnets,CN=Sites,%s" % + (subnet, self.config_dn))) + self.addCleanup(self.samdb.delete, dnsubnet, ["tree_delete:1"]) + + result, out, err = self.runsubcmd("sites", "subnet", + "view", subnet, + "-H", self.dburl, self.creds_string) + + self.assertCmdSuccess(result, out, err) + json_data = json.loads(out) + self.assertEqual(json_data["cn"], subnet) + + # Now try one that doesn't exist + result, out, err = self.runsubcmd("sites", "subnet", + "view", "50.0.0.0/8", + "-H", self.dburl, self.creds_string) + self.assertCmdFail(result, err) diff --git a/python/samba/tests/samba_tool/timecmd.py b/python/samba/tests/samba_tool/timecmd.py new file mode 100644 index 0000000..8e286f6 --- /dev/null +++ b/python/samba/tests/samba_tool/timecmd.py @@ -0,0 +1,44 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) Sean Dague <sdague@linux.vnet.ibm.com> 2011 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +from time import localtime, strptime, mktime +from samba.tests.samba_tool.base import SambaToolCmdTest + + +class TimeCmdTestCase(SambaToolCmdTest): + """Tests for samba-tool time subcommands""" + + def test_timeget(self): + """Run time against the server and make sure it looks accurate""" + (result, out, err) = self.runcmd("time", os.environ["SERVER"]) + self.assertCmdSuccess(result, out, err, "Ensuring time ran successfully") + + timefmt = strptime(out, "%a %b %d %H:%M:%S %Y %Z\n") + servertime = int(mktime(timefmt)) + now = int(mktime(localtime())) + + # because there is a race here, allow up to 5 seconds difference in times + delta = 5 + self.assertTrue((servertime > (now - delta) and (servertime < (now + delta)), "Time is now")) + + def test_timefail(self): + """Run time against a non-existent server, and make sure it fails""" + (result, out, err) = self.runcmd("time", "notaserver") + self.assertEqual(result, -1, "check for result code") + self.assertNotEqual(err.strip().find("NT_STATUS_OBJECT_NAME_NOT_FOUND"), -1, "ensure right error string") + self.assertEqual(out, "", "ensure no output returned") diff --git a/python/samba/tests/samba_tool/user.py b/python/samba/tests/samba_tool/user.py new file mode 100644 index 0000000..26c9748 --- /dev/null +++ b/python/samba/tests/samba_tool/user.py @@ -0,0 +1,1246 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) Sean Dague <sdague@linux.vnet.ibm.com> 2011 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +import time +import base64 +import ldb +from samba.tests.samba_tool.base import SambaToolCmdTest +from samba import ( + credentials, + nttime2unix, + dsdb, + werror, + ) +from samba.ndr import ndr_unpack +from samba.dcerpc import drsblobs +from samba.common import get_bytes +from samba.common import get_string +from samba.tests import env_loadparm + + +class UserCmdTestCase(SambaToolCmdTest): + """Tests for samba-tool user subcommands""" + users = [] + samdb = None + + def setUp(self): + super().setUp() + self.samdb = self.getSamDB("-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"])) + + # Modify the default template homedir + lp = self.get_loadparm() + self.template_homedir = lp.get('template homedir') + lp.set('template homedir', '/home/test/%D/%U') + + self.users = [] + self.users.append(self._randomUser({"name": "sambatool1", "company": "comp1"})) + self.users.append(self._randomUser({"name": "sambatool2", "company": "comp1"})) + self.users.append(self._randomUser({"name": "sambatool3", "company": "comp2"})) + self.users.append(self._randomUser({"name": "sambatool4", "company": "comp2"})) + self.users.append(self._randomPosixUser({"name": "posixuser1"})) + self.users.append(self._randomPosixUser({"name": "posixuser2"})) + self.users.append(self._randomPosixUser({"name": "posixuser3"})) + self.users.append(self._randomPosixUser({"name": "posixuser4"})) + self.users.append(self._randomUnixUser({"name": "unixuser1"})) + self.users.append(self._randomUnixUser({"name": "unixuser2"})) + self.users.append(self._randomUnixUser({"name": "unixuser3"})) + self.users.append(self._randomUnixUser({"name": "unixuser4"})) + + # Make sure users don't exist + for user in self.users: + if self._find_user(user["name"]): + self.runsubcmd("user", "delete", user["name"]) + + # setup the 12 users and ensure they are correct + for user in self.users: + (result, out, err) = user["createUserFn"](user) + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + if 'unix' in user["name"]: + self.assertIn("Modified User '%s' successfully" % user["name"], + out) + else: + self.assertIn("User '%s' added successfully" % user["name"], + out) + + user["checkUserFn"](user) + + def tearDown(self): + super().tearDown() + # clean up all the left over users, just in case + for user in self.users: + if self._find_user(user["name"]): + self.runsubcmd("user", "delete", user["name"]) + lp = env_loadparm() + # second run of this test + # the cache is still there and '--cache-ldb-initialize' + # will fail + cachedb = lp.private_path("user-syncpasswords-cache.ldb") + if os.path.exists(cachedb): + os.remove(cachedb) + lp.set('template homedir', self.template_homedir) + + def test_newuser(self): + # try to add all the users again, this should fail + for user in self.users: + (result, out, err) = self._create_user(user) + self.assertCmdFail(result, "Ensure that create user fails") + self.assertIn("LDAP error 68 LDAP_ENTRY_ALREADY_EXISTS", err) + + # try to delete all the 4 users we just added + for user in self.users: + (result, out, err) = self.runsubcmd("user", "delete", user["name"]) + self.assertCmdSuccess(result, out, err, "Can we delete users") + found = self._find_user(user["name"]) + self.assertIsNone(found) + + # test adding users with --use-username-as-cn + for user in self.users: + (result, out, err) = self.runsubcmd("user", "create", user["name"], user["password"], + "--use-username-as-cn", + "--surname=%s" % user["surname"], + "--given-name=%s" % user["given-name"], + "--job-title=%s" % user["job-title"], + "--department=%s" % user["department"], + "--description=%s" % user["description"], + "--company=%s" % user["company"], + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"])) + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn("User '%s' added successfully" % user["name"], out) + + found = self._find_user(user["name"]) + + self.assertEqual("%s" % found.get("cn"), "%(name)s" % user) + self.assertEqual("%s" % found.get("name"), "%(name)s" % user) + + def test_newuser_weak_password(self): + # Ensure that when we try to create a user over LDAP (thus no + # transactions) and the password is too weak, we do not get a + # half-created account. + + def cleanup_user(username): + try: + self.samdb.deleteuser(username) + except Exception as err: + estr = err.args[0] + if 'Unable to find user' not in estr: + raise + + server = os.environ['DC_SERVER'] + dc_username = os.environ['DC_USERNAME'] + dc_password = os.environ['DC_PASSWORD'] + + username = self.randomName() + password = 'a' + + self.addCleanup(cleanup_user, username) + + # Try to add the user and ensure it fails. + result, out, err = self.runsubcmd('user', 'add', + username, password, + '-H', f'ldap://{server}', + f'-U{dc_username}%{dc_password}') + self.assertCmdFail(result) + self.assertIn('Failed to add user', err) + self.assertIn('LDAP_CONSTRAINT_VIOLATION', err) + self.assertIn(f'{werror.WERR_PASSWORD_RESTRICTION:08X}', err) + + # Now search for the user, and make sure we don't find anything. + res = self.samdb.search(self.samdb.domain_dn(), + expression=f'(sAMAccountName={username})', + scope=ldb.SCOPE_SUBTREE) + self.assertEqual(0, len(res), 'expected not to find the user') + + def _verify_supplementalCredentials(self, ldif, + min_packages=3, + max_packages=6): + msgs = self.samdb.parse_ldif(ldif) + (changetype, obj) = next(msgs) + + self.assertIn("supplementalCredentials", obj, "supplementalCredentials attribute required") + sc_blob = obj["supplementalCredentials"][0] + sc = ndr_unpack(drsblobs.supplementalCredentialsBlob, sc_blob) + + self.assertGreaterEqual(sc.sub.num_packages, + min_packages, "min_packages check") + self.assertLessEqual(sc.sub.num_packages, + max_packages, "max_packages check") + + if max_packages == 0: + return + + def find_package(packages, name, start_idx=0): + for i in range(start_idx, len(packages)): + if packages[i].name == name: + return (i, packages[i]) + return (None, None) + + # The ordering is this + # + # Primary:Kerberos-Newer-Keys (optional) + # Primary:Kerberos + # Primary:WDigest + # Primary:CLEARTEXT (optional) + # Primary:SambaGPG (optional) + # + # And the 'Packages' package is insert before the last + # other package. + + nidx = 0 + (pidx, pp) = find_package(sc.sub.packages, "Packages", start_idx=nidx) + self.assertIsNotNone(pp, "Packages required") + self.assertEqual(pidx + 1, sc.sub.num_packages - 1, + "Packages needs to be at num_packages - 1") + + (knidx, knp) = find_package(sc.sub.packages, "Primary:Kerberos-Newer-Keys", + start_idx=nidx) + if knidx is not None: + self.assertEqual(knidx, nidx, "Primary:Kerberos-Newer-Keys at wrong position") + nidx = nidx + 1 + if nidx == pidx: + nidx = nidx + 1 + + (kidx, kp) = find_package(sc.sub.packages, "Primary:Kerberos", + start_idx=nidx) + self.assertIsNotNone(pp, "Primary:Kerberos required") + self.assertEqual(kidx, nidx, "Primary:Kerberos at wrong position") + nidx = nidx + 1 + if nidx == pidx: + nidx = nidx + 1 + + (widx, wp) = find_package(sc.sub.packages, "Primary:WDigest", + start_idx=nidx) + self.assertIsNotNone(pp, "Primary:WDigest required") + self.assertEqual(widx, nidx, "Primary:WDigest at wrong position") + nidx = nidx + 1 + if nidx == pidx: + nidx = nidx + 1 + + (cidx, cp) = find_package(sc.sub.packages, "Primary:CLEARTEXT", + start_idx=nidx) + if cidx is not None: + self.assertEqual(cidx, nidx, "Primary:CLEARTEXT at wrong position") + nidx = nidx + 1 + if nidx == pidx: + nidx = nidx + 1 + + (gidx, gp) = find_package(sc.sub.packages, "Primary:SambaGPG", + start_idx=nidx) + if gidx is not None: + self.assertEqual(gidx, nidx, "Primary:SambaGPG at wrong position") + nidx = nidx + 1 + if nidx == pidx: + nidx = nidx + 1 + + self.assertEqual(nidx, sc.sub.num_packages, "Unknown packages found") + + def test_setpassword(self): + expect_nt_hash = bool(int(os.environ.get("EXPECT_NT_HASH", "1"))) + + for user in self.users: + newpasswd = self.random_password(16) + (result, out, err) = self.runsubcmd("user", "setpassword", + user["name"], + "--newpassword=%s" % newpasswd, + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Ensure setpassword runs") + self.assertEqual(err, "", "setpassword with url") + self.assertMatch(out, "Changed password OK", "setpassword with url") + + attributes = "sAMAccountName,unicodePwd,supplementalCredentials,virtualClearTextUTF8,virtualClearTextUTF16,virtualSSHA,virtualSambaGPG" + (result, out, err) = self.runsubcmd("user", "syncpasswords", + "--cache-ldb-initialize", + "--attributes=%s" % attributes, + "--decrypt-samba-gpg") + self.assertCmdSuccess(result, out, err, "Ensure syncpasswords --cache-ldb-initialize runs") + self.assertEqual(err, "", "getpassword without url") + cache_attrs = { + "objectClass": {"value": "userSyncPasswords"}, + "samdbUrl": {}, + "dirsyncFilter": {}, + "dirsyncAttribute": {}, + "dirsyncControl": {"value": "dirsync:1:0:0"}, + "passwordAttribute": {}, + "decryptSambaGPG": {}, + "currentTime": {}, + } + for a in cache_attrs.keys(): + v = cache_attrs[a].get("value", "") + self.assertMatch(out, "%s: %s" % (a, v), + "syncpasswords --cache-ldb-initialize: %s: %s out[%s]" % (a, v, out)) + + (result, out, err) = self.runsubcmd("user", "syncpasswords", "--no-wait") + self.assertCmdSuccess(result, out, err, "Ensure syncpasswords --no-wait runs") + self.assertEqual(err, "", "syncpasswords --no-wait") + self.assertMatch(out, "dirsync_loop(): results 0", + "syncpasswords --no-wait: 'dirsync_loop(): results 0': out[%s]" % (out)) + for user in self.users: + self.assertMatch(out, "sAMAccountName: %s" % (user["name"]), + "syncpasswords --no-wait: 'sAMAccountName': %s out[%s]" % (user["name"], out)) + + for user in self.users: + newpasswd = self.random_password(16) + creds = credentials.Credentials() + creds.set_anonymous() + creds.set_password(newpasswd) + unicodePwd = base64.b64encode(creds.get_nt_hash()).decode('utf8') + virtualClearTextUTF8 = base64.b64encode(get_bytes(newpasswd)).decode('utf8') + virtualClearTextUTF16 = base64.b64encode(get_string(newpasswd).encode('utf-16-le')).decode('utf8') + + (result, out, err) = self.runsubcmd("user", "setpassword", + user["name"], + "--newpassword=%s" % newpasswd) + self.assertCmdSuccess(result, out, err, "Ensure setpassword runs") + self.assertEqual(err, "", "setpassword without url") + self.assertMatch(out, "Changed password OK", "setpassword without url") + + (result, out, err) = self.runsubcmd("user", "syncpasswords", "--no-wait") + self.assertCmdSuccess(result, out, err, "Ensure syncpasswords --no-wait runs") + self.assertEqual(err, "", "syncpasswords --no-wait") + self.assertMatch(out, "dirsync_loop(): results 0", + "syncpasswords --no-wait: 'dirsync_loop(): results 0': out[%s]" % (out)) + self.assertMatch(out, "sAMAccountName: %s" % (user["name"]), + "syncpasswords --no-wait: 'sAMAccountName': %s out[%s]" % (user["name"], out)) + self.assertMatch(out, "# unicodePwd::: REDACTED SECRET ATTRIBUTE", + "getpassword '# unicodePwd::: REDACTED SECRET ATTRIBUTE': out[%s]" % out) + if expect_nt_hash or "virtualSambaGPG:: " in out: + self.assertMatch(out, "unicodePwd:: %s" % unicodePwd, + "getpassword unicodePwd: out[%s]" % out) + else: + self.assertNotIn("unicodePwd:: %s" % unicodePwd, out) + self.assertMatch(out, "# supplementalCredentials::: REDACTED SECRET ATTRIBUTE", + "getpassword '# supplementalCredentials::: REDACTED SECRET ATTRIBUTE': out[%s]" % out) + self.assertMatch(out, "supplementalCredentials:: ", + "getpassword supplementalCredentials: out[%s]" % out) + if "virtualSambaGPG:: " in out: + self.assertMatch(out, "virtualClearTextUTF8:: %s" % virtualClearTextUTF8, + "getpassword virtualClearTextUTF8: out[%s]" % out) + self.assertMatch(out, "virtualClearTextUTF16:: %s" % virtualClearTextUTF16, + "getpassword virtualClearTextUTF16: out[%s]" % out) + self.assertMatch(out, "virtualSSHA: ", + "getpassword virtualSSHA: out[%s]" % out) + + (result, out, err) = self.runsubcmd("user", "getpassword", + user["name"], + "--attributes=%s" % attributes, + "--decrypt-samba-gpg") + self.assertCmdSuccess(result, out, err, "Ensure getpassword runs") + self.assertEqual(err, "Got password OK\n", "getpassword without url") + self.assertMatch(out, "sAMAccountName: %s" % (user["name"]), + "getpassword: 'sAMAccountName': %s out[%s]" % (user["name"], out)) + if expect_nt_hash or "virtualSambaGPG:: " in out: + self.assertMatch(out, "unicodePwd:: %s" % unicodePwd, + "getpassword unicodePwd: out[%s]" % out) + else: + self.assertNotIn("unicodePwd:: %s" % unicodePwd, out) + self.assertMatch(out, "supplementalCredentials:: ", + "getpassword supplementalCredentials: out[%s]" % out) + self._verify_supplementalCredentials(out) + if "virtualSambaGPG:: " in out: + self.assertMatch(out, "virtualClearTextUTF8:: %s" % virtualClearTextUTF8, + "getpassword virtualClearTextUTF8: out[%s]" % out) + self.assertMatch(out, "virtualClearTextUTF16:: %s" % virtualClearTextUTF16, + "getpassword virtualClearTextUTF16: out[%s]" % out) + self.assertMatch(out, "virtualSSHA: ", + "getpassword virtualSSHA: out[%s]" % out) + + for user in self.users: + newpasswd = self.random_password(16) + (result, out, err) = self.runsubcmd("user", "setpassword", + user["name"], + "--newpassword=%s" % newpasswd, + "--must-change-at-next-login", + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Ensure setpassword runs") + self.assertEqual(err, "", "setpassword with forced change") + self.assertMatch(out, "Changed password OK", "setpassword with forced change") + + def test_setexpiry(self): + for user in self.users: + twodays = time.time() + (2 * 24 * 60 * 60) + + (result, out, err) = self.runsubcmd("user", "setexpiry", user["name"], + "--days=2", + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Can we run setexpiry with names") + self.assertIn("Expiry for user '%s' set to 2 days." % user["name"], out) + + found = self._find_user(user["name"]) + + expires = nttime2unix(int("%s" % found.get("accountExpires"))) + self.assertWithin(expires, twodays, 5, "Ensure account expires is within 5 seconds of the expected time") + + # TODO: re-enable this after the filter case is sorted out + if "filters are broken, bail now": + return + + # now run the expiration based on a filter + fourdays = time.time() + (4 * 24 * 60 * 60) + (result, out, err) = self.runsubcmd("user", "setexpiry", + "--filter", "(&(objectClass=user)(company=comp2))", + "--days=4", + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Can we run setexpiry with a filter") + + for user in self.users: + found = self._find_user(user["name"]) + if ("%s" % found.get("company")) == "comp2": + expires = nttime2unix(int("%s" % found.get("accountExpires"))) + self.assertWithin(expires, fourdays, 5, "Ensure account expires is within 5 seconds of the expected time") + else: + expires = nttime2unix(int("%s" % found.get("accountExpires"))) + self.assertWithin(expires, twodays, 5, "Ensure account expires is within 5 seconds of the expected time") + + def test_list(self): + (result, out, err) = self.runsubcmd("user", "list", + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Error running list") + + search_filter = ("(&(objectClass=user)(userAccountControl:%s:=%u))" % + (ldb.OID_COMPARATOR_AND, dsdb.UF_NORMAL_ACCOUNT)) + + userlist = self.samdb.search(base=self.samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter, + attrs=["samaccountname"]) + + self.assertTrue(len(userlist) > 0, "no users found in samdb") + + for userobj in userlist: + name = str(userobj.get("samaccountname", idx=0)) + self.assertMatch(out, name, + "user '%s' not found" % name) + + + def test_list_base_dn(self): + base_dn = "CN=Users" + (result, out, err) = self.runsubcmd("user", "list", "-b", base_dn, + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Error running list") + + search_filter = ("(&(objectClass=user)(userAccountControl:%s:=%u))" % + (ldb.OID_COMPARATOR_AND, dsdb.UF_NORMAL_ACCOUNT)) + + userlist = self.samdb.search(base=self.samdb.normalize_dn_in_domain(base_dn), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter, + attrs=["samaccountname"]) + + self.assertTrue(len(userlist) > 0, "no users found in samdb") + + for userobj in userlist: + name = str(userobj.get("samaccountname", idx=0)) + self.assertMatch(out, name, + "user '%s' not found" % name) + + def test_list_full_dn(self): + (result, out, err) = self.runsubcmd("user", "list", "--full-dn", + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Error running list") + + search_filter = ("(&(objectClass=user)(userAccountControl:%s:=%u))" % + (ldb.OID_COMPARATOR_AND, dsdb.UF_NORMAL_ACCOUNT)) + + userlist = self.samdb.search(base=self.samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter, + attrs=["dn"]) + + self.assertTrue(len(userlist) > 0, "no users found in samdb") + + for userobj in userlist: + name = str(userobj.get("dn", idx=0)) + self.assertMatch(out, name, + "user '%s' not found" % name) + + def test_list_hide_expired(self): + expire_username = "expireUser" + expire_user = self._randomUser({"name": expire_username}) + self._create_user(expire_user) + + (result, out, err) = self.runsubcmd( + "user", + "list", + "--hide-expired", + "-H", + "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Error running list") + self.assertTrue(expire_username in out, + "user '%s' not found" % expire_username) + + # user will be expired one second ago + self.samdb.setexpiry( + "(sAMAccountname=%s)" % expire_username, + -1, + False) + + (result, out, err) = self.runsubcmd( + "user", + "list", + "--hide-expired", + "-H", + "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Error running list") + self.assertFalse(expire_username in out, + "user '%s' found" % expire_username) + + self.samdb.deleteuser(expire_username) + + def test_list_hide_disabled(self): + disable_username = "disableUser" + disable_user = self._randomUser({"name": disable_username}) + self._create_user(disable_user) + + (result, out, err) = self.runsubcmd( + "user", + "list", + "--hide-disabled", + "-H", + "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Error running list") + self.assertTrue(disable_username in out, + "user '%s' not found" % disable_username) + + self.samdb.disable_account("(sAMAccountname=%s)" % disable_username) + + (result, out, err) = self.runsubcmd( + "user", + "list", + "--hide-disabled", + "-H", + "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Error running list") + self.assertFalse(disable_username in out, + "user '%s' found" % disable_username) + + self.samdb.deleteuser(disable_username) + + def test_show(self): + for user in self.users: + (result, out, err) = self.runsubcmd( + "user", "show", user["name"], + "--attributes=sAMAccountName,company", + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Error running show") + + expected_out = """dn: CN=%s %s,CN=Users,%s +company: %s +sAMAccountName: %s + +""" % (user["given-name"], user["surname"], self.samdb.domain_dn(), + user["company"], user["name"]) + + self.assertEqual(out, expected_out, + "Unexpected show output for user '%s'" % + user["name"]) + + time_attrs = [ + "name", # test that invalid values are just ignored + "whenCreated", + "whenChanged", + "accountExpires", + "badPasswordTime", + "lastLogoff", + "lastLogon", + "lastLogonTimestamp", + "lockoutTime", + "msDS-UserPasswordExpiryTimeComputed", + "pwdLastSet", + ] + + attrs = [] + for ta in time_attrs: + attrs.append(ta) + for fm in ["GeneralizedTime", "UnixTime", "TimeSpec"]: + attrs.append("%s;format=%s" % (ta, fm)) + + (result, out, err) = self.runsubcmd( + "user", "show", user["name"], + "--attributes=%s" % ",".join(attrs), + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Error running show") + + self.assertIn(";format=GeneralizedTime", out) + self.assertIn(";format=UnixTime", out) + self.assertIn(";format=TimeSpec", out) + + self.assertIn("name: ", out) + self.assertNotIn("name;format=GeneralizedTime: ", out) + self.assertNotIn("name;format=UnixTime: ", out) + self.assertNotIn("name;format=TimeSpec: ", out) + + self.assertIn("whenCreated: 20", out) + self.assertIn("whenCreated;format=GeneralizedTime: 20", out) + self.assertIn("whenCreated;format=UnixTime: 1", out) + self.assertIn("whenCreated;format=TimeSpec: 1", out) + + self.assertIn("whenChanged: 20", out) + self.assertIn("whenChanged;format=GeneralizedTime: 20", out) + self.assertIn("whenChanged;format=UnixTime: 1", out) + self.assertIn("whenChanged;format=TimeSpec: 1", out) + + self.assertIn("accountExpires: 9223372036854775807", out) + self.assertNotIn("accountExpires;format=GeneralizedTime: ", out) + self.assertNotIn("accountExpires;format=UnixTime: ", out) + self.assertNotIn("accountExpires;format=TimeSpec: ", out) + + self.assertIn("badPasswordTime: 0", out) + self.assertNotIn("badPasswordTime;format=GeneralizedTime: ", out) + self.assertNotIn("badPasswordTime;format=UnixTime: ", out) + self.assertNotIn("badPasswordTime;format=TimeSpec: ", out) + + self.assertIn("lastLogoff: 0", out) + self.assertNotIn("lastLogoff;format=GeneralizedTime: ", out) + self.assertNotIn("lastLogoff;format=UnixTime: ", out) + self.assertNotIn("lastLogoff;format=TimeSpec: ", out) + + self.assertIn("lastLogon: 0", out) + self.assertNotIn("lastLogon;format=GeneralizedTime: ", out) + self.assertNotIn("lastLogon;format=UnixTime: ", out) + self.assertNotIn("lastLogon;format=TimeSpec: ", out) + + # If a specified attribute is not available on a user object + # it's silently omitted. + self.assertNotIn("lastLogonTimestamp:", out) + self.assertNotIn("lockoutTime:", out) + + self.assertIn("msDS-UserPasswordExpiryTimeComputed: 1", out) + self.assertIn("msDS-UserPasswordExpiryTimeComputed;format=GeneralizedTime: 20", out) + self.assertIn("msDS-UserPasswordExpiryTimeComputed;format=UnixTime: 1", out) + self.assertIn("msDS-UserPasswordExpiryTimeComputed;format=TimeSpec: 1", out) + + self.assertIn("pwdLastSet: 1", out) + self.assertIn("pwdLastSet;format=GeneralizedTime: 20", out) + self.assertIn("pwdLastSet;format=UnixTime: 1", out) + self.assertIn("pwdLastSet;format=TimeSpec: 1", out) + + out_msgs = self.samdb.parse_ldif(out) + out_msg = next(out_msgs)[1] + + self.assertIn("whenCreated", out_msg) + when_created_str = str(out_msg["whenCreated"][0]) + self.assertIn("whenCreated;format=GeneralizedTime", out_msg) + self.assertEqual(str(out_msg["whenCreated;format=GeneralizedTime"][0]), when_created_str) + when_created_time = ldb.string_to_time(when_created_str) + self.assertIn("whenCreated;format=UnixTime", out_msg) + self.assertEqual(str(out_msg["whenCreated;format=UnixTime"][0]), str(when_created_time)) + self.assertIn("whenCreated;format=TimeSpec", out_msg) + self.assertEqual(str(out_msg["whenCreated;format=TimeSpec"][0]), + "%d.000000000" % (when_created_time)) + + self.assertIn("whenChanged", out_msg) + when_changed_str = str(out_msg["whenChanged"][0]) + self.assertIn("whenChanged;format=GeneralizedTime", out_msg) + self.assertEqual(str(out_msg["whenChanged;format=GeneralizedTime"][0]), when_changed_str) + when_changed_time = ldb.string_to_time(when_changed_str) + self.assertIn("whenChanged;format=UnixTime", out_msg) + self.assertEqual(str(out_msg["whenChanged;format=UnixTime"][0]), str(when_changed_time)) + self.assertIn("whenChanged;format=TimeSpec", out_msg) + self.assertEqual(str(out_msg["whenChanged;format=TimeSpec"][0]), + "%d.000000000" % (when_changed_time)) + + self.assertIn("pwdLastSet;format=GeneralizedTime", out_msg) + pwd_last_set_str = str(out_msg["pwdLastSet;format=GeneralizedTime"][0]) + pwd_last_set_time = ldb.string_to_time(pwd_last_set_str) + self.assertIn("pwdLastSet;format=UnixTime", out_msg) + self.assertEqual(str(out_msg["pwdLastSet;format=UnixTime"][0]), str(pwd_last_set_time)) + self.assertIn("pwdLastSet;format=TimeSpec", out_msg) + self.assertIn("%d." % pwd_last_set_time, str(out_msg["pwdLastSet;format=TimeSpec"][0])) + self.assertNotIn(".000000000", str(out_msg["pwdLastSet;format=TimeSpec"][0])) + + # assert that the pwd has been set in the minute after user creation + self.assertGreaterEqual(pwd_last_set_time, when_created_time) + self.assertLess(pwd_last_set_time, when_created_time + 60) + + self.assertIn("msDS-UserPasswordExpiryTimeComputed;format=GeneralizedTime", out_msg) + pwd_expires_str = str(out_msg["msDS-UserPasswordExpiryTimeComputed;format=GeneralizedTime"][0]) + pwd_expires_time = ldb.string_to_time(pwd_expires_str) + self.assertIn("msDS-UserPasswordExpiryTimeComputed;format=UnixTime", out_msg) + self.assertEqual(str(out_msg["msDS-UserPasswordExpiryTimeComputed;format=UnixTime"][0]), str(pwd_expires_time)) + self.assertIn("msDS-UserPasswordExpiryTimeComputed;format=TimeSpec", out_msg) + self.assertIn("%d." % pwd_expires_time, str(out_msg["msDS-UserPasswordExpiryTimeComputed;format=TimeSpec"][0])) + self.assertNotIn(".000000000", str(out_msg["msDS-UserPasswordExpiryTimeComputed;format=TimeSpec"][0])) + + # assert that the pwd expires after it was set + self.assertGreater(pwd_expires_time, pwd_last_set_time) + + def test_move(self): + full_ou_dn = str(self.samdb.normalize_dn_in_domain("OU=movetest_usr")) + self.addCleanup(self.samdb.delete, full_ou_dn, ["tree_delete:1"]) + + (result, out, err) = self.runsubcmd("ou", "add", full_ou_dn) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "There shouldn't be any error message") + self.assertIn('Added ou "%s"' % full_ou_dn, out) + + for user in self.users: + (result, out, err) = self.runsubcmd( + "user", "move", user["name"], full_ou_dn) + self.assertCmdSuccess(result, out, err, "Error running move") + self.assertIn('Moved user "%s" into "%s"' % + (user["name"], full_ou_dn), out) + + # Should fail as users objects are in OU + (result, out, err) = self.runsubcmd("ou", "delete", full_ou_dn) + self.assertCmdFail(result) + self.assertIn(("subtree_delete: Unable to delete a non-leaf node " + "(it has %d children)!") % len(self.users), err) + + for user in self.users: + new_dn = "CN=Users,%s" % self.samdb.domain_dn() + (result, out, err) = self.runsubcmd( + "user", "move", user["name"], new_dn) + self.assertCmdSuccess(result, out, err, "Error running move") + self.assertIn('Moved user "%s" into "%s"' % + (user["name"], new_dn), out) + + def test_rename_surname_initials_givenname(self): + """rename the existing surname and given name and add missing + initials, then remove them, for all users""" + for user in self.users: + new_givenname = "new_given_name_of_" + user["name"] + new_initials = "A" + new_surname = "new_surname_of_" + user["name"] + found = self._find_user(user["name"]) + old_cn = str(found.get("cn")) + + # rename given name, initials and surname + (result, out, err) = self.runsubcmd("user", "rename", user["name"], + "--surname=%s" % new_surname, + "--initials=%s" % new_initials, + "--given-name=%s" % new_givenname) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn('successfully', out) + + found = self._find_user(user["name"]) + self.assertEqual("%s" % found.get("givenName"), new_givenname) + self.assertEqual("%s" % found.get("initials"), new_initials) + self.assertEqual("%s" % found.get("sn"), new_surname) + self.assertEqual("%s" % found.get("name"), + "%s %s. %s" % (new_givenname, new_initials, new_surname)) + self.assertEqual("%s" % found.get("cn"), + "%s %s. %s" % (new_givenname, new_initials, new_surname)) + + # remove given name, initials and surname + (result, out, err) = self.runsubcmd("user", "rename", user["name"], + "--surname=", + "--initials=", + "--given-name=") + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn('successfully', out) + + found = self._find_user(user["name"]) + self.assertEqual(found.get("givenName"), None) + self.assertEqual(found.get("initials"), None) + self.assertEqual(found.get("sn"), None) + self.assertEqual("%s" % found.get("cn"), user["name"]) + + # reset changes (initials are removed) + (result, out, err) = self.runsubcmd("user", "rename", user["name"], + "--surname=%(surname)s" % user, + "--given-name=%(given-name)s" % user) + self.assertCmdSuccess(result, out, err) + + if old_cn: + (result, out, err) = self.runsubcmd("user", "rename", user["name"], + "--force-new-cn=%s" % old_cn) + + def test_rename_cn_samaccountname(self): + """rename and try to remove the cn and the samaccount of all users""" + for user in self.users: + new_cn = "new_cn_of_" + user["name"] + new_samaccountname = "new_samaccount_of_" + user["name"] + new_surname = "new_surname_of_" + user["name"] + + # rename cn + (result, out, err) = self.runsubcmd("user", "rename", user["name"], + "--samaccountname=%s" + % new_samaccountname, + "--force-new-cn=%s" % new_cn) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn('successfully', out) + + found = self._find_user(new_samaccountname) + self.assertEqual("%s" % found.get("cn"), new_cn) + self.assertEqual("%s" % found.get("sAMAccountName"), + new_samaccountname) + + # changing the surname has no effect to the cn + (result, out, err) = self.runsubcmd("user", "rename", new_samaccountname, + "--surname=%s" % new_surname) + self.assertCmdSuccess(result, out, err) + + found = self._find_user(new_samaccountname) + self.assertEqual("%s" % found.get("cn"), new_cn) + + # trying to remove cn (throws an error) + (result, out, err) = self.runsubcmd("user", "rename", + new_samaccountname, + "--force-new-cn=") + self.assertCmdFail(result) + self.assertIn('Failed to rename user', err) + self.assertIn("delete protected attribute", err) + + # trying to remove the samccountname (throws an error) + (result, out, err) = self.runsubcmd("user", "rename", + new_samaccountname, + "--samaccountname=") + self.assertCmdFail(result) + self.assertIn('Failed to rename user', err) + self.assertIn('delete protected attribute', err) + + # reset changes (cn must be the name) + (result, out, err) = self.runsubcmd("user", "rename", new_samaccountname, + "--samaccountname=%(name)s" + % user, + "--force-new-cn=%(name)s" % user) + self.assertCmdSuccess(result, out, err) + + def test_rename_standard_cn(self): + """reset the cn of all users to the standard""" + for user in self.users: + new_cn = "new_cn_of_" + user["name"] + new_givenname = "new_given_name_of_" + user["name"] + new_initials = "A" + new_surname = "new_surname_of_" + user["name"] + + # set different cn + (result, out, err) = self.runsubcmd("user", "rename", user["name"], + "--force-new-cn=%s" % new_cn) + self.assertCmdSuccess(result, out, err) + + # remove given name, initials and surname + (result, out, err) = self.runsubcmd("user", "rename", user["name"], + "--surname=", + "--initials=", + "--given-name=") + self.assertCmdSuccess(result, out, err) + + # reset the CN (no given name, initials or surname --> samaccountname) + (result, out, err) = self.runsubcmd("user", "rename", user["name"], + "--reset-cn") + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn('successfully', out) + + found = self._find_user(user["name"]) + self.assertEqual("%s" % found.get("cn"), user["name"]) + + # set given name, initials and surname and set different cn + (result, out, err) = self.runsubcmd("user", "rename", user["name"], + "--force-new-cn=%s" % new_cn, + "--surname=%s" % new_surname, + "--initials=%s" % new_initials, + "--given-name=%s" % new_givenname) + self.assertCmdSuccess(result, out, err) + + # reset the CN (given name, initials or surname are given --> given name) + (result, out, err) = self.runsubcmd("user", "rename", user["name"], + "--reset-cn") + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn('successfully', out) + + found = self._find_user(user["name"]) + self.assertEqual("%s" % found.get("cn"), + "%s %s. %s" % (new_givenname, new_initials, new_surname)) + + # reset changes + (result, out, err) = self.runsubcmd("user", "rename", user["name"], + "--reset-cn", + "--initials=", + "--surname=%(surname)s" % user, + "--given-name=%(given-name)s" % user) + self.assertCmdSuccess(result, out, err) + + def test_rename_mailaddress_displayname(self): + for user in self.users: + new_mail = "new_mailaddress_of_" + user["name"] + new_displayname = "new displayname of " + user["name"] + + # change mail and displayname + (result, out, err) = self.runsubcmd("user", "rename", user["name"], + "--mail-address=%s" + % new_mail, + "--display-name=%s" + % new_displayname) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn('successfully', out) + + found = self._find_user(user["name"]) + self.assertEqual("%s" % found.get("mail"), new_mail) + self.assertEqual("%s" % found.get("displayName"), new_displayname) + + # remove mail and displayname + (result, out, err) = self.runsubcmd("user", "rename", user["name"], + "--mail-address=", + "--display-name=") + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn('successfully', out) + + found = self._find_user(user["name"]) + self.assertEqual(found.get("mail"), None) + self.assertEqual(found.get("displayName"), None) + + def test_rename_upn(self): + """rename upn of all users""" + for user in self.users: + found = self._find_user(user["name"]) + old_upn = "%s" % found.get("userPrincipalName") + valid_suffix = old_upn.split('@')[1] # samba.example.com + + valid_new_upn = "new_%s@%s" % (user["name"], valid_suffix) + invalid_new_upn = "%s@invalid.suffix" + user["name"] + + # trying to set invalid upn + (result, out, err) = self.runsubcmd("user", "rename", user["name"], + "--upn=%s" + % invalid_new_upn) + self.assertCmdFail(result) + self.assertIn('is not a valid upn', err) + + # set valid upn + (result, out, err) = self.runsubcmd("user", "rename", user["name"], + "--upn=%s" + % valid_new_upn) + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn('successfully', out) + + found = self._find_user(user["name"]) + self.assertEqual("%s" % found.get("userPrincipalName"), valid_new_upn) + + # trying to remove upn + (result, out, err) = self.runsubcmd("user", "rename", user["name"], + "--upn=%s") + self.assertCmdFail(result) + self.assertIn('is not a valid upn', err) + + # reset upn + (result, out, err) = self.runsubcmd("user", "rename", user["name"], + "--upn=%s" % old_upn) + self.assertCmdSuccess(result, out, err) + + def test_getpwent(self): + try: + import pwd + except ImportError: + self.skipTest("Skipping getpwent test, no 'pwd' module available") + return + + # get the current user's data for the test + uid = os.geteuid() + try: + u = pwd.getpwuid(uid) + except KeyError: + self.skipTest("Skipping getpwent test, current EUID not found in NSS") + return + + +# samba-tool user create command didn't support users with empty gecos if none is +# specified on the command line and the user hasn't one in the passwd file it +# will fail, so let's add some contents + + gecos = u[4] + if (gecos is None or len(gecos) == 0): + gecos = "Foo GECOS" + user = self._randomPosixUser({ + "name": u[0], + "uid": u[0], + "uidNumber": u[2], + "gidNumber": u[3], + "gecos": gecos, + "loginShell": u[6], + }) + + # Remove user if it already exists + if self._find_user(u[0]): + self.runsubcmd("user", "delete", u[0]) + # check if --rfc2307-from-nss sets the same values as we got from pwd.getpwuid() + (result, out, err) = self.runsubcmd("user", "create", user["name"], user["password"], + "--surname=%s" % user["surname"], + "--given-name=%s" % user["given-name"], + "--job-title=%s" % user["job-title"], + "--department=%s" % user["department"], + "--description=%s" % user["description"], + "--company=%s" % user["company"], + "--gecos=%s" % user["gecos"], + "--rfc2307-from-nss", + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"])) + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn("User '%s' added successfully" % user["name"], out) + + self._check_posix_user(user) + self.runsubcmd("user", "delete", user["name"]) + + # Check if overriding the attributes from NSS with explicit values works + # + # get a user with all random posix attributes + user = self._randomPosixUser({"name": u[0]}) + + # Remove user if it already exists + if self._find_user(u[0]): + self.runsubcmd("user", "delete", u[0]) + # create a user with posix attributes from nss but override all of them with the + # random ones just obtained + (result, out, err) = self.runsubcmd("user", "create", user["name"], user["password"], + "--surname=%s" % user["surname"], + "--given-name=%s" % user["given-name"], + "--job-title=%s" % user["job-title"], + "--department=%s" % user["department"], + "--description=%s" % user["description"], + "--company=%s" % user["company"], + "--rfc2307-from-nss", + "--gecos=%s" % user["gecos"], + "--login-shell=%s" % user["loginShell"], + "--uid=%s" % user["uid"], + "--uid-number=%s" % user["uidNumber"], + "--gid-number=%s" % user["gidNumber"], + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"])) + + self.assertCmdSuccess(result, out, err) + self.assertEqual(err, "", "Shouldn't be any error messages") + self.assertIn("User '%s' added successfully" % user["name"], out) + + self._check_posix_user(user) + self.runsubcmd("user", "delete", user["name"]) + + # Test: samba-tool user unlock + # This test does not verify that the command unlocks the user, it just + # tests the command itself. The unlock test, which unlocks locked users, + # is located in the 'samba4.ldap.password_lockout' test in + # source4/dsdb/tests/python/password_lockout.py + def test_unlock(self): + + # try to unlock a nonexistent user, this should fail + nonexistentusername = "userdoesnotexist" + (result, out, err) = self.runsubcmd( + "user", "unlock", nonexistentusername) + self.assertCmdFail(result, "Ensure that unlock nonexistent user fails") + self.assertIn("Failed to unlock user '%s'" % nonexistentusername, err) + self.assertIn("Unable to find user", err) + + # try to unlock with insufficient permissions, this should fail + unprivileged_username = "unprivilegedunlockuser" + unlocktest_username = "usertounlock" + + self.runsubcmd("user", "add", unprivileged_username, "Passw0rd") + self.runsubcmd("user", "add", unlocktest_username, "Passw0rd") + + (result, out, err) = self.runsubcmd( + "user", "unlock", unlocktest_username, + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (unprivileged_username, + "Passw0rd")) + self.assertCmdFail(result, "Fail with LDAP_INSUFFICIENT_ACCESS_RIGHTS") + self.assertIn("Failed to unlock user '%s'" % unlocktest_username, err) + self.assertIn("LDAP error 50 LDAP_INSUFFICIENT_ACCESS_RIGHTS", err) + + self.runsubcmd("user", "delete", unprivileged_username) + self.runsubcmd("user", "delete", unlocktest_username) + + # run unlock against test users + for user in self.users: + (result, out, err) = self.runsubcmd( + "user", "unlock", user["name"]) + self.assertCmdSuccess(result, out, err, "Error running user unlock") + self.assertEqual(err, "", "Shouldn't be any error messages") + + def _randomUser(self, base=None): + """create a user with random attribute values, you can specify base attributes""" + if base is None: + base = {} + user = { + "name": self.randomName(), + "password": self.random_password(16), + "surname": self.randomName(), + "given-name": self.randomName(), + "job-title": self.randomName(), + "department": self.randomName(), + "company": self.randomName(), + "description": self.randomName(count=100), + "createUserFn": self._create_user, + "checkUserFn": self._check_user, + } + user.update(base) + return user + + def _randomPosixUser(self, base=None): + """create a user with random attribute values and additional RFC2307 + attributes, you can specify base attributes""" + if base is None: + base = {} + user = self._randomUser({}) + user.update(base) + posixAttributes = { + "uid": self.randomName(), + "loginShell": self.randomName(), + "gecos": self.randomName(), + "uidNumber": self.randomXid(), + "gidNumber": self.randomXid(), + "createUserFn": self._create_posix_user, + "checkUserFn": self._check_posix_user, + } + user.update(posixAttributes) + user.update(base) + return user + + def _randomUnixUser(self, base=None): + """create a user with random attribute values and additional RFC2307 + attributes, you can specify base attributes""" + if base is None: + base = {} + user = self._randomUser({}) + user.update(base) + posixAttributes = { + "uidNumber": self.randomXid(), + "gidNumber": self.randomXid(), + "uid": self.randomName(), + "loginShell": self.randomName(), + "gecos": self.randomName(), + "createUserFn": self._create_unix_user, + "checkUserFn": self._check_unix_user, + } + user.update(posixAttributes) + user.update(base) + return user + + def _check_user(self, user): + """ check if a user from SamDB has the same attributes as its template """ + found = self._find_user(user["name"]) + + self.assertEqual("%s" % found.get("name"), "%(given-name)s %(surname)s" % user) + self.assertEqual("%s" % found.get("title"), user["job-title"]) + self.assertEqual("%s" % found.get("company"), user["company"]) + self.assertEqual("%s" % found.get("description"), user["description"]) + self.assertEqual("%s" % found.get("department"), user["department"]) + + def _check_posix_user(self, user): + """ check if a posix_user from SamDB has the same attributes as its template """ + found = self._find_user(user["name"]) + + self.assertEqual("%s" % found.get("loginShell"), user["loginShell"]) + self.assertEqual("%s" % found.get("gecos"), user["gecos"]) + self.assertEqual("%s" % found.get("uidNumber"), "%s" % user["uidNumber"]) + self.assertEqual("%s" % found.get("gidNumber"), "%s" % user["gidNumber"]) + self.assertEqual("%s" % found.get("uid"), user["uid"]) + self._check_user(user) + + def _check_unix_user(self, user): + """ check if a unix_user from SamDB has the same attributes as its +template """ + found = self._find_user(user["name"]) + + self.assertEqual("%s" % found.get("loginShell"), user["loginShell"]) + self.assertEqual("%s" % found.get("gecos"), user["gecos"]) + self.assertEqual("%s" % found.get("uidNumber"), "%s" % + user["uidNumber"]) + self.assertEqual("%s" % found.get("gidNumber"), "%s" % + user["gidNumber"]) + self.assertEqual("%s" % found.get("uid"), user["uid"]) + self.assertIn('/home/test/', "%s" % found.get("unixHomeDirectory")) + self._check_user(user) + + def _create_user(self, user): + return self.runsubcmd("user", "add", user["name"], user["password"], + "--surname=%s" % user["surname"], + "--given-name=%s" % user["given-name"], + "--job-title=%s" % user["job-title"], + "--department=%s" % user["department"], + "--description=%s" % user["description"], + "--company=%s" % user["company"], + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"])) + + def _create_posix_user(self, user): + """ create a new user with RFC2307 attributes """ + return self.runsubcmd("user", "create", user["name"], user["password"], + "--surname=%s" % user["surname"], + "--given-name=%s" % user["given-name"], + "--job-title=%s" % user["job-title"], + "--department=%s" % user["department"], + "--description=%s" % user["description"], + "--company=%s" % user["company"], + "--gecos=%s" % user["gecos"], + "--login-shell=%s" % user["loginShell"], + "--uid=%s" % user["uid"], + "--uid-number=%s" % user["uidNumber"], + "--gid-number=%s" % user["gidNumber"], + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"])) + + def _create_unix_user(self, user): + """ Add RFC2307 attributes to a user""" + self._create_user(user) + return self.runsubcmd("user", "addunixattrs", user["name"], + "%s" % user["uidNumber"], + "--gid-number=%s" % user["gidNumber"], + "--gecos=%s" % user["gecos"], + "--login-shell=%s" % user["loginShell"], + "--uid=%s" % user["uid"], + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + + def _find_user(self, name): + search_filter = "(&(sAMAccountName=%s)(objectCategory=%s,%s))" % (ldb.binary_encode(name), "CN=Person,CN=Schema,CN=Configuration", self.samdb.domain_dn()) + userlist = self.samdb.search(base=self.samdb.domain_dn(), + scope=ldb.SCOPE_SUBTREE, + expression=search_filter) + if userlist: + return userlist[0] + else: + return None diff --git a/python/samba/tests/samba_tool/user_auth_policy.py b/python/samba/tests/samba_tool/user_auth_policy.py new file mode 100644 index 0000000..c5bdd06 --- /dev/null +++ b/python/samba/tests/samba_tool/user_auth_policy.py @@ -0,0 +1,86 @@ +# Unix SMB/CIFS implementation. +# +# Tests for samba-tool user auth policy command +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +from samba.netcmd.domain.models import AuthenticationPolicy, User + +from .silo_base import SiloTest + + +class AuthPolicyCmdTestCase(SiloTest): + def test_assign(self): + """Test assigning an authentication policy to a user.""" + self.addCleanup(self.runcmd, "user", "auth", "policy", "remove", "alice") + result, out, err = self.runcmd("user", "auth", "policy", "assign", + "alice", "--policy", "User Policy") + self.assertIsNone(result, msg=err) + + # Assigned policy should be 'Developers' + user = User.get(self.samdb, username="alice") + policy = AuthenticationPolicy.get(self.samdb, dn=user.assigned_policy) + self.assertEqual(policy.name, "User Policy") + + def test_assign__invalid_policy(self): + """Test assigning a non-existing authentication policy to a user.""" + result, out, err = self.runcmd("user", "auth", "policy", "assign", + "alice", "--policy", "doesNotExist") + self.assertEqual(result, -1) + self.assertIn("Authentication policy doesNotExist not found.", err) + + def test_remove(self): + """Test removing the assigned authentication policy from a user.""" + # First assign a policy, so we can test removing it. + self.runcmd("user", "auth", "policy", "assign", "bob", "--policy", + "User Policy") + + # Assigned policy should be set + user = User.get(self.samdb, username="bob") + self.assertIsNotNone(user.assigned_policy) + + # Now try removing it + result, out, err = self.runcmd("user", "auth", "policy", "remove", + "bob") + self.assertIsNone(result, msg=err) + + # Assigned policy should be None + user = User.get(self.samdb, username="bob") + self.assertIsNone(user.assigned_policy) + + def test_view(self): + """Test viewing the current assigned authentication policy on a user.""" + # Assign a policy on one of the users. + self.addCleanup(self.runcmd, "user", "auth", "policy", "remove", "bob") + self.runcmd("user", "auth", "policy", "assign", "bob", "--policy", + "User Policy") + + # Test user with a policy assigned. + result, out, err = self.runcmd("user", "auth", "policy", "view", + "bob") + self.assertIsNone(result, msg=err) + self.assertEqual( + out, "User bob assigned to authentication policy User Policy\n") + + # Test user without a policy assigned. + result, out, err = self.runcmd("user", "auth", "policy", "view", + "joe") + self.assertIsNone(result, msg=err) + self.assertEqual( + out, "User joe has no assigned authentication policy.\n") diff --git a/python/samba/tests/samba_tool/user_auth_silo.py b/python/samba/tests/samba_tool/user_auth_silo.py new file mode 100644 index 0000000..19cce26 --- /dev/null +++ b/python/samba/tests/samba_tool/user_auth_silo.py @@ -0,0 +1,84 @@ +# Unix SMB/CIFS implementation. +# +# Tests for samba-tool user auth silo command +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +from samba.netcmd.domain.models import AuthenticationSilo, User + +from .silo_base import SiloTest + + +class AuthPolicyCmdTestCase(SiloTest): + def test_assign(self): + """Test assigning an authentication silo to a user.""" + self.addCleanup(self.runcmd, "user", "auth", "silo", "remove", "alice") + result, out, err = self.runcmd("user", "auth", "silo", "assign", + "alice", "--silo", "Developers") + self.assertIsNone(result, msg=err) + + # Assigned silo should be 'Developers' + user = User.get(self.samdb, username="alice") + silo = AuthenticationSilo.get(self.samdb, dn=user.assigned_silo) + self.assertEqual(silo.name, "Developers") + + def test_assign__invalid_silo(self): + """Test assigning a non-existing authentication silo to a user.""" + result, out, err = self.runcmd("user", "auth", "silo", "assign", + "alice", "--silo", "doesNotExist") + self.assertEqual(result, -1) + self.assertIn("Authentication silo doesNotExist not found.", err) + + def test_remove(self): + """Test removing the assigned authentication silo from a user.""" + # First assign a silo, so we can test removing it. + self.runcmd("user", "auth", "silo", "assign", "bob", "--silo", "QA") + + # Assigned silo should be set + user = User.get(self.samdb, username="bob") + self.assertIsNotNone(user.assigned_silo) + + # Now try removing it + result, out, err = self.runcmd("user", "auth", "silo", "remove", + "bob") + self.assertIsNone(result, msg=err) + + # Assigned silo should be None + user = User.get(self.samdb, username="bob") + self.assertIsNone(user.assigned_silo) + + def test_view(self): + """Test viewing the current assigned authentication silo on a user.""" + # Assign a silo on one of the users. + self.addCleanup(self.runcmd, "user", "auth", "silo", "remove", "bob") + self.runcmd("user", "auth", "silo", "assign", "bob", "--silo", "QA") + + # Test user with a silo assigned. + result, out, err = self.runcmd("user", "auth", "silo", "view", + "bob") + self.assertIsNone(result, msg=err) + self.assertEqual( + out, "User bob assigned to authentication silo QA (revoked)\n") + + # Test user without a silo assigned. + result, out, err = self.runcmd("user", "auth", "silo", "view", + "joe") + self.assertIsNone(result, msg=err) + self.assertEqual( + out, "User joe has no assigned authentication silo.\n") diff --git a/python/samba/tests/samba_tool/user_check_password_script.py b/python/samba/tests/samba_tool/user_check_password_script.py new file mode 100644 index 0000000..183b77b --- /dev/null +++ b/python/samba/tests/samba_tool/user_check_password_script.py @@ -0,0 +1,106 @@ +# Unix SMB/CIFS implementation. +# Copyright (C) Sean Dague <sdague@linux.vnet.ibm.com> 2011 +# Copyright (C) Andrew Bartlett <abartlet@samba.org> 2016 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +from samba.tests.samba_tool.base import SambaToolCmdTest + + +class UserCheckPwdTestCase(SambaToolCmdTest): + """Tests for samba-tool user subcommands""" + users = [] + samdb = None + + def setUp(self): + super().setUp() + self.samdb = self.getSamDB("-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"])) + self.old_min_pwd_age = self.samdb.get_minPwdAge() + self.samdb.set_minPwdAge("0") + + def tearDown(self): + super().tearDown() + self.samdb.set_minPwdAge(self.old_min_pwd_age) + + def _test_checkpassword(self, user, bad_password, good_password, desc): + + (result, out, err) = self.runsubcmd("user", "add", user["name"], bad_password, + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"])) + self.assertCmdFail(result, "Should fail adding a user with %s password." % desc) + + (result, out, err) = self.runsubcmd("user", "add", user["name"], good_password, + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Should succeed adding a user with good password.") + + # Set password + (result, out, err) = self.runsubcmd("user", "setpassword", user["name"], + "--newpassword=%s" % bad_password, + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"])) + self.assertCmdFail(result, "Should fail setting a user's password to a %s password." % desc) + + (result, out, err) = self.runsubcmd("user", "setpassword", user["name"], + "--newpassword=%s" % good_password, + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], os.environ["DC_PASSWORD"])) + self.assertCmdSuccess(result, out, err, "Should succeed setting a user's password to a good one.") + + # Password= + + (result, out, err) = self.runsubcmd("user", "password", + "--newpassword=%s" % bad_password, + "--ipaddress", os.environ["DC_SERVER_IP"], + "-U%s%%%s" % (user["name"], good_password)) + self.assertCmdFail(result, "A user setting their own password to a %s password should fail." % desc) + + (result, out, err) = self.runsubcmd("user", "password", + "--newpassword=%s" % good_password + 'XYZ', + "--ipaddress", os.environ["DC_SERVER_IP"], + "-U%s%%%s" % (user["name"], good_password)) + self.assertCmdSuccess(result, out, err, "A user setting their own password to a good one should succeed.") + + def test_checkpassword_unacceptable(self): + # Add + user = self._randomUser() + bad_password = os.environ["UNACCEPTABLE_PASSWORD"] + good_password = bad_password[:-1] + return self._test_checkpassword(user, + bad_password, + good_password, + "unacceptable") + + def test_checkpassword_username(self): + # Add + user = self._randomUser() + bad_password = user["name"] + good_password = bad_password[:-1] + return self._test_checkpassword(user, + bad_password, + good_password, + "username") + + def _randomUser(self, base=None): + """create a user with random attribute values, you can specify base attributes""" + if base is None: + base = {} + user = { + "name": self.randomName(), + } + user.update(base) + return user diff --git a/python/samba/tests/samba_tool/user_edit.sh b/python/samba/tests/samba_tool/user_edit.sh new file mode 100755 index 0000000..342899f --- /dev/null +++ b/python/samba/tests/samba_tool/user_edit.sh @@ -0,0 +1,198 @@ +#!/bin/sh +# +# Test for 'samba-tool user edit' + +if [ $# -lt 3 ]; then + cat <<EOF +Usage: user_edit.sh SERVER USERNAME PASSWORD +EOF + exit 1 +fi + +SERVER="$1" +USERNAME="$2" +PASSWORD="$3" + +samba_ldbsearch=ldbsearch +if test -x $BINDIR/ldbsearch; then + samba_ldbsearch=$BINDIR/ldbsearch +fi + +STpath=$(pwd) +. $STpath/testprogs/blackbox/subunit.sh + +display_name="Björn" +display_name_b64="QmrDtnJu" +display_name_new="Renamed Bjoern" +# attribute value including control character +# echo -e "test \a string" | base64 +display_name_con_b64="dGVzdCAHIHN0cmluZwo=" + +tmpeditor=$(mktemp --suffix .sh -p ${SELFTEST_TMPDIR} samba-tool-editor-XXXXXXXX) +chmod +x $tmpeditor + +TEST_USER="$(mktemp -u sambatoolXXXXXX)" + +create_test_user() +{ + $PYTHON ${STpath}/source4/scripting/bin/samba-tool \ + user create ${TEST_USER} --random-password \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +edit_user() +{ + # create editor.sh + cat >$tmpeditor <<-'EOF' +#!/usr/bin/env bash +user_ldif="$1" +SED=$(which sed) +$SED -i -e 's/userAccountControl: 512/userAccountControl: 514/' $user_ldif + EOF + + $PYTHON ${STpath}/source4/scripting/bin/samba-tool \ + user edit ${TEST_USER} --editor=$tmpeditor \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +# Test edit user - add base64 attributes +add_attribute_base64() +{ + # create editor.sh + cat >$tmpeditor <<EOF +#!/usr/bin/env bash +user_ldif="\$1" + +grep -v '^\$' \$user_ldif > \${user_ldif}.tmp +echo "displayName:: $display_name_b64" >> \${user_ldif}.tmp + +mv \${user_ldif}.tmp \$user_ldif +EOF + + $PYTHON ${STpath}/source4/scripting/bin/samba-tool user edit \ + ${TEST_USER} --editor=$tmpeditor \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +get_attribute_base64() +{ + $samba_ldbsearch "(sAMAccountName=${TEST_USER})" displayName \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +delete_attribute() +{ + # create editor.sh + cat >$tmpeditor <<EOF +#!/usr/bin/env bash +user_ldif="\$1" + +grep -v '^displayName' \$user_ldif >> \${user_ldif}.tmp +mv \${user_ldif}.tmp \$user_ldif +EOF + $PYTHON ${STpath}/source4/scripting/bin/samba-tool user edit \ + ${TEST_USER} --editor=$tmpeditor \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +# Test edit user - add base64 attribute value including control character +add_attribute_base64_control() +{ + # create editor.sh + cat >$tmpeditor <<EOF +#!/usr/bin/env bash +user_ldif="\$1" + +grep -v '^\$' \$user_ldif > \${user_ldif}.tmp +echo "displayName:: $display_name_con_b64" >> \${user_ldif}.tmp + +mv \${user_ldif}.tmp \$user_ldif +EOF + $PYTHON ${STpath}/source4/scripting/bin/samba-tool user edit \ + ${TEST_USER} --editor=$tmpeditor \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +get_attribute_base64_control() +{ + $PYTHON ${STpath}/source4/scripting/bin/samba-tool user show \ + ${TEST_USER} --attributes=displayName \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +get_attribute_force_no_base64() +{ + # LDB_FLAG_FORCE_NO_BASE64_LDIF should be used here. + $PYTHON ${STpath}/source4/scripting/bin/samba-tool user show \ + ${TEST_USER} --attributes=displayName \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +# Test edit user - change base64 attribute value including control character +change_attribute_base64_control() +{ + # create editor.sh + cat >$tmpeditor <<EOF +#!/usr/bin/env bash +user_ldif="\$1" + +sed -i -e 's/displayName:: $display_name_con_b64/displayName: $display_name/' \ + \$user_ldif +EOF + $PYTHON ${STpath}/source4/scripting/bin/samba-tool user edit \ + ${TEST_USER} --editor=$tmpeditor \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +# Test edit user - change attributes with LDB_FLAG_FORCE_NO_BASE64_LDIF +change_attribute_force_no_base64() +{ + # create editor.sh + # Expects that the original attribute is available as clear text, + # because the LDB_FLAG_FORCE_NO_BASE64_LDIF should be used here. + cat >$tmpeditor <<EOF +#!/usr/bin/env bash +user_ldif="\$1" + +sed -i -e 's/displayName: $display_name/displayName: $display_name_new/' \ + \$user_ldif +EOF + + $PYTHON ${STpath}/source4/scripting/bin/samba-tool user edit \ + ${TEST_USER} --editor=$tmpeditor \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +get_changed_attribute_force_no_base64() +{ + $PYTHON ${STpath}/source4/scripting/bin/samba-tool user show \ + ${TEST_USER} --attributes=displayName \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +delete_user() +{ + $PYTHON ${STpath}/source4/scripting/bin/samba-tool \ + user delete ${TEST_USER} \ + -H "ldap://$SERVER" "-U$USERNAME" "--password=$PASSWORD" +} + +failed=0 + +testit "create_test_user" create_test_user || failed=$(expr $failed + 1) +testit "edit_user" edit_user || failed=$(expr $failed + 1) +testit "add_attribute_base64" add_attribute_base64 || failed=$(expr $failed + 1) +testit_grep "get_attribute_base64" "^displayName:: $display_name_b64" get_attribute_base64 || failed=$(expr $failed + 1) +testit "delete_attribute" delete_attribute || failed=$(expr $failed + 1) +testit "add_attribute_base64_control" add_attribute_base64_control || failed=$(expr $failed + 1) +testit_grep "get_attribute_base64_control" "^displayName:: $display_name_con_b64" get_attribute_base64_control || failed=$(expr $failed + 1) +testit "change_attribute_base64_control" change_attribute_base64_control || failed=$(expr $failed + 1) +testit_grep "get_attribute_base64" "^displayName:: $display_name_b64" get_attribute_base64 || failed=$(expr $failed + 1) +testit_grep "get_attribute_force_no_base64" "^displayName: $display_name" get_attribute_force_no_base64 || failed=$(expr $failed + 1) +testit "change_attribute_force_no_base64" change_attribute_force_no_base64 || failed=$(expr $failed + 1) +testit_grep "get_changed_attribute_force_no_base64" "^displayName: $display_name_new" get_changed_attribute_force_no_base64 || failed=$(expr $failed + 1) +testit "delete_user" delete_user || failed=$(expr $failed + 1) + +rm -f $tmpeditor + +exit $failed diff --git a/python/samba/tests/samba_tool/user_get_kerberos_ticket.py b/python/samba/tests/samba_tool/user_get_kerberos_ticket.py new file mode 100644 index 0000000..4ac502e --- /dev/null +++ b/python/samba/tests/samba_tool/user_get_kerberos_ticket.py @@ -0,0 +1,195 @@ +# Unix SMB/CIFS implementation. +# +# Blackbox tests for getting Kerberos tickets from Group Managed Service Account and other (local) passwords +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# Copyright Andrew Bartlett <abartlet@samba.org> 2023 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import sys +import os + +sys.path.insert(0, "bin/python") +os.environ["PYTHONUNBUFFERED"] = "1" + +from ldb import SCOPE_BASE +from samba import credentials +from samba.credentials import Credentials, MUST_USE_KERBEROS +from samba.dcerpc import security, samr +from samba.dsdb import UF_WORKSTATION_TRUST_ACCOUNT, UF_NORMAL_ACCOUNT +from samba.netcmd.domain.models import User +from samba.ndr import ndr_pack, ndr_unpack +from samba.tests import connect_samdb, connect_samdb_env, delete_force + +from samba.tests import BlackboxTestCase, BlackboxProcessError + + +# If not specified, this is None, meaning local sam.ldb +PW_READ_URL = os.environ.get("PW_READ_URL") + +# We still need to connect to a remote server to check we got the ticket +SERVER = os.environ.get("SERVER") + +PW_CHECK_URL = f"ldap://{SERVER}" + +# For authentication to PW_READ_URL if required +SERVER_USERNAME = os.environ["USERNAME"] +SERVER_PASSWORD = os.environ["PASSWORD"] + +CREDS = f"-U{SERVER_USERNAME}%{SERVER_PASSWORD}" + + +class GetKerberosTiketTest(BlackboxTestCase): + """Blackbox tests for GMSA getpassword and connecting as that user.""" + + @classmethod + def setUpClass(cls): + cls.lp = cls.get_loadparm() + cls.env_creds = cls.get_env_credentials(lp=cls.lp, + env_username="USERNAME", + env_password="PASSWORD", + env_domain="DOMAIN", + env_realm="REALM") + if PW_READ_URL is None: + url = cls.lp.private_path("sam.ldb") + else: + url = PW_CHECK_URL + cls.samdb = connect_samdb(url, lp=cls.lp, credentials=cls.env_creds) + super().setUpClass() + + @classmethod + def setUpTestData(cls): + cls.gmsa_username = "GMSA_K5Test_User$" + cls.username = "get-kerberos-ticket-test" + cls.user_base_dn = f"CN=Users,{cls.samdb.domain_dn()}" + cls.user_dn = f"CN={cls.username},{cls.user_base_dn}" + cls.gmsa_base_dn = f"CN=Managed Service Accounts,{cls.samdb.domain_dn()}" + cls.gmsa_user_dn = f"CN={cls.gmsa_username},{cls.gmsa_base_dn}" + + msg = cls.samdb.search(base="", scope=SCOPE_BASE, attrs=["tokenGroups"])[0] + connecting_user_sid = str(ndr_unpack(security.dom_sid, msg["tokenGroups"][0])) + + domain_sid = security.dom_sid(cls.samdb.get_domain_sid()) + allow_sddl = f"O:SYD:(A;;RP;;;{connecting_user_sid})" + allow_sd = ndr_pack(security.descriptor.from_sddl(allow_sddl, domain_sid)) + + details = { + "dn": str(cls.gmsa_user_dn), + "objectClass": "msDS-GroupManagedServiceAccount", + "msDS-ManagedPasswordInterval": "1", + "msDS-GroupMSAMembership": allow_sd, + "sAMAccountName": cls.gmsa_username, + "userAccountControl": str(UF_WORKSTATION_TRUST_ACCOUNT), + } + + cls.samdb.add(details) + cls.addClassCleanup(delete_force, cls.samdb, cls.gmsa_user_dn) + + user_password = "P@ssw0rd" + utf16pw = ('"' + user_password + '"').encode('utf-16-le') + user_details = { + "dn": str(cls.user_dn), + "objectClass": "user", + "sAMAccountName": cls.username, + "userAccountControl": str(UF_NORMAL_ACCOUNT), + "unicodePwd": utf16pw + } + + cls.samdb.add(user_details) + cls.addClassCleanup(delete_force, cls.samdb, cls.user_dn) + + cls.gmsa_user = User.get(cls.samdb, username=cls.gmsa_username) + cls.user = User.get(cls.samdb, username=cls.username) + + def get_ticket(self, username, options=None): + if options is None: + options = "" + ccache_path = f"{self.tempdir}/ccache" + ccache_location = f"FILE:{ccache_path}" + cmd = f"user get-kerberos-ticket --output-krb5-ccache={ccache_location} {username} {options}" + + try: + self.check_output(cmd) + except BlackboxProcessError as e: + self.fail(e) + self.addCleanup(os.unlink, ccache_path) + return ccache_location + + def test_gmsa_ticket(self): + # Get a ticket with the tool + output_ccache = self.get_ticket(self.gmsa_username) + creds = self.insta_creds(template=self.env_creds) + creds.set_kerberos_state(MUST_USE_KERBEROS) + creds.set_named_ccache(self.lp, output_ccache) + db = connect_samdb(PW_CHECK_URL, credentials=creds, lp=self.lp) + msg = db.search(base="", scope=SCOPE_BASE, attrs=["tokenGroups"])[0] + connecting_user_sid = str(ndr_unpack(security.dom_sid, msg["tokenGroups"][0])) + + self.assertEqual(self.gmsa_user.object_sid, connecting_user_sid) + + def test_user_ticket(self): + output_ccache = self.get_ticket(self.username) + # Get a ticket with the tool + creds = self.insta_creds(template=self.env_creds) + creds.set_kerberos_state(MUST_USE_KERBEROS) + + # Currently this is based on reading the unicodePwd, but this should be expanded + creds.set_named_ccache(output_ccache, credentials.SPECIFIED, self.lp) + + db = connect_samdb(PW_CHECK_URL, credentials=creds, lp=self.lp) + + msg = db.search(base="", scope=SCOPE_BASE, attrs=["tokenGroups"])[0] + connecting_user_sid = str(ndr_unpack(security.dom_sid, msg["tokenGroups"][0])) + + self.assertEqual(self.user.object_sid, connecting_user_sid) + + def test_user_ticket_gpg(self): + output_ccache = self.get_ticket(self.username, "--decrypt-samba-gpg") + # Get a ticket with the tool + creds = self.insta_creds(template=self.env_creds) + creds.set_kerberos_state(MUST_USE_KERBEROS) + creds.set_named_ccache(output_ccache, credentials.SPECIFIED, self.lp) + db = connect_samdb(PW_CHECK_URL, credentials=creds, lp=self.lp) + + msg = db.search(base="", scope=SCOPE_BASE, attrs=["tokenGroups"])[0] + connecting_user_sid = str(ndr_unpack(security.dom_sid, msg["tokenGroups"][0])) + + self.assertEqual(self.user.object_sid, connecting_user_sid) + + @classmethod + def _make_cmdline(cls, line): + """Override to pass line as samba-tool subcommand instead. + + Automatically fills in HOST and CREDS as well. + """ + if isinstance(line, list): + cmd = ["samba-tool"] + line + if PW_READ_URL is not None: + cmd += ["-H", PW_READ_URL, CREDS] + else: + cmd = f"samba-tool {line}" + if PW_READ_URL is not None: + cmd += "-H {PW_READ_URL} {CREDS}" + + return super()._make_cmdline(cmd) + + +if __name__ == "__main__": + import unittest + unittest.main() diff --git a/python/samba/tests/samba_tool/user_getpassword_gmsa.py b/python/samba/tests/samba_tool/user_getpassword_gmsa.py new file mode 100644 index 0000000..9844456 --- /dev/null +++ b/python/samba/tests/samba_tool/user_getpassword_gmsa.py @@ -0,0 +1,171 @@ +# Unix SMB/CIFS implementation. +# +# Blackbox tests for reading Group Managed Service Account passwords +# +# Copyright (C) Catalyst.Net Ltd. 2023 +# +# Written by Rob van der Linde <rob@catalyst.net.nz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import sys +import os + +sys.path.insert(0, "bin/python") +os.environ["PYTHONUNBUFFERED"] = "1" + +from ldb import SCOPE_BASE + +from samba.credentials import Credentials, MUST_USE_KERBEROS +from samba.dcerpc import security, samr +from samba.dsdb import UF_WORKSTATION_TRUST_ACCOUNT +from samba.netcmd.domain.models import User +from samba.ndr import ndr_pack, ndr_unpack +from samba.tests import connect_samdb, connect_samdb_env, delete_force + +from samba.tests import BlackboxTestCase + +DC_SERVER = os.environ["SERVER"] +SERVER = os.environ["SERVER"] +SERVER_USERNAME = os.environ["USERNAME"] +SERVER_PASSWORD = os.environ["PASSWORD"] + +HOST = f"ldap://{SERVER}" +CREDS = f"-U{SERVER_USERNAME}%{SERVER_PASSWORD}" + + +class GMSAPasswordTest(BlackboxTestCase): + """Blackbox tests for GMSA getpassword and connecting as that user.""" + + @classmethod + def setUpClass(cls): + cls.lp = cls.get_loadparm() + cls.env_creds = cls.get_env_credentials(lp=cls.lp, + env_username="USERNAME", + env_password="PASSWORD", + env_domain="DOMAIN", + env_realm="REALM") + cls.samdb = connect_samdb(HOST, lp=cls.lp, credentials=cls.env_creds) + super().setUpClass() + + @classmethod + def setUpTestData(cls): + cls.username = "GMSA_Test_User$" + cls.base_dn = f"CN=Managed Service Accounts,{cls.samdb.domain_dn()}" + cls.user_dn = f"CN={cls.username},{cls.base_dn}" + + msg = cls.samdb.search(base="", scope=SCOPE_BASE, attrs=["tokenGroups"])[0] + connecting_user_sid = str(ndr_unpack(security.dom_sid, msg["tokenGroups"][0])) + + domain_sid = security.dom_sid(cls.samdb.get_domain_sid()) + allow_sddl = f"O:SYD:(A;;RP;;;{connecting_user_sid})" + allow_sd = ndr_pack(security.descriptor.from_sddl(allow_sddl, domain_sid)) + + details = { + "dn": str(cls.user_dn), + "objectClass": "msDS-GroupManagedServiceAccount", + "msDS-ManagedPasswordInterval": "1", + "msDS-GroupMSAMembership": allow_sd, + "sAMAccountName": cls.username, + "userAccountControl": str(UF_WORKSTATION_TRUST_ACCOUNT), + } + + cls.samdb.add(details) + cls.addClassCleanup(delete_force, cls.samdb, cls.user_dn) + + cls.user = User.get(cls.samdb, username=cls.username) + + def getpassword(self, attrs): + cmd = f"user getpassword --attributes={attrs} {self.username}" + + ldif = self.check_output(cmd).decode() + res = self.samdb.parse_ldif(ldif) + _, user_message = next(res) + + # check each attr is returned + for attr in attrs.split(","): + self.assertIn(attr, user_message) + + return user_message + + def test_getpassword(self): + self.getpassword("virtualClearTextUTF16,unicodePwd") + self.getpassword("virtualClearTextUTF16") + self.getpassword("unicodePwd") + + def test_utf16_password(self): + user_msg = self.getpassword("virtualClearTextUTF16") + password = user_msg["virtualClearTextUTF16"][0] + + creds = self.insta_creds(template=self.env_creds) + creds.set_username(self.username) + creds.set_utf16_password(password) + db = connect_samdb(HOST, credentials=creds, lp=self.lp) + + msg = db.search(base="", scope=SCOPE_BASE, attrs=["tokenGroups"])[0] + connecting_user_sid = str(ndr_unpack(security.dom_sid, msg["tokenGroups"][0])) + + self.assertEqual(self.user.object_sid, connecting_user_sid) + + def test_utf8_password(self): + user_msg = self.getpassword("virtualClearTextUTF8") + password = str(user_msg["virtualClearTextUTF8"][0]) + + creds = self.insta_creds(template=self.env_creds) + # Because the password has been converted to utf-8 via UTF16_MUNGED + # the nthash is no longer valid. We need to use AES kerberos ciphers + # for this to work. + creds.set_kerberos_state(MUST_USE_KERBEROS) + creds.set_username(self.username) + creds.set_password(password) + db = connect_samdb(HOST, credentials=creds, lp=self.lp) + + msg = db.search(base="", scope=SCOPE_BASE, attrs=["tokenGroups"])[0] + connecting_user_sid = str(ndr_unpack(security.dom_sid, msg["tokenGroups"][0])) + + self.assertEqual(self.user.object_sid, connecting_user_sid) + + def test_unicode_pwd(self): + user_msg = self.getpassword("unicodePwd") + + creds = self.insta_creds(template=self.env_creds) + creds.set_username(self.username) + nt_pass = samr.Password() + nt_pass.hash = list(user_msg["unicodePwd"][0]) + creds.set_nt_hash(nt_pass) + db = connect_samdb(HOST, credentials=creds, lp=self.lp) + + msg = db.search(base="", scope=SCOPE_BASE, attrs=["tokenGroups"])[0] + connecting_user_sid = str(ndr_unpack(security.dom_sid, msg["tokenGroups"][0])) + + self.assertEqual(self.user.object_sid, connecting_user_sid) + + @classmethod + def _make_cmdline(cls, line): + """Override to pass line as samba-tool subcommand instead. + + Automatically fills in HOST and CREDS as well. + """ + if isinstance(line, list): + cmd = ["samba-tool"] + line + ["-H", SERVER, CREDS] + else: + cmd = f"samba-tool {line} -H {HOST} {CREDS}" + + return super()._make_cmdline(cmd) + + +if __name__ == "__main__": + import unittest + unittest.main() diff --git a/python/samba/tests/samba_tool/user_virtualCryptSHA.py b/python/samba/tests/samba_tool/user_virtualCryptSHA.py new file mode 100644 index 0000000..e95a4be --- /dev/null +++ b/python/samba/tests/samba_tool/user_virtualCryptSHA.py @@ -0,0 +1,516 @@ +# Tests for the samba-tool user sub command reading Primary:userPassword +# +# Copyright (C) Andrew Bartlett <abartlet@samba.org> 2017 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import ldb +import samba +from samba.tests.samba_tool.base import SambaToolCmdTest +from samba.credentials import Credentials +from samba.samdb import SamDB +from samba.auth import system_session +from samba import dsdb + +USER_NAME = "CryptSHATestUser" +HASH_OPTION = "password hash userPassword schemes" + + +class UserCmdCryptShaTestCase(SambaToolCmdTest): + """ + Tests for samba-tool user subcommands generation of the virtualCryptSHA256 + and virtualCryptSHA512 attributes + """ + users = [] + samdb = None + + def add_user(self, hashes=""): + self.lp = samba.tests.env_loadparm() + + # set the extra hashes to be calculated + self.lp.set(HASH_OPTION, hashes) + + self.creds = Credentials() + self.session = system_session() + self.ldb = SamDB( + session_info=self.session, + credentials=self.creds, + lp=self.lp) + + password = self.random_password() + self.runsubcmd("user", + "create", + USER_NAME, + password) + + def tearDown(self): + super().tearDown() + self.runsubcmd("user", "delete", USER_NAME) + + def _get_password(self, attributes, decrypt=False): + command = ["user", + "getpassword", + USER_NAME, + "--attributes", + attributes] + if decrypt: + command.append("--decrypt-samba-gpg") + + (result, out, err) = self.runsubcmd(*command) + self.assertCmdSuccess(result, + out, + err, + "Ensure getpassword runs") + self.assertEqual(err, "Got password OK\n", "getpassword") + return out + + # Change the just the NT password hash, as would happen if the password + # was updated by Windows, the userPassword values are now obsolete. + # + def _change_nt_hash(self): + res = self.ldb.search(expression = "cn=%s" % USER_NAME, + scope = ldb.SCOPE_SUBTREE) + msg = ldb.Message() + msg.dn = res[0].dn + msg["unicodePwd"] = ldb.MessageElement(b"ABCDEF1234567890", + ldb.FLAG_MOD_REPLACE, + "unicodePwd") + self.ldb.modify( + msg, + controls=["local_oid:%s:0" % + dsdb.DSDB_CONTROL_BYPASS_PASSWORD_HASH_OID]) + + # gpg decryption not enabled. + # both virtual attributes specified, no rounds option + # no hashes stored in supplementalCredentials + # Should not get values + def test_no_gpg_both_hashes_no_rounds(self): + self.add_user() + out = self._get_password("virtualCryptSHA256,virtualCryptSHA512") + + self.assertTrue("virtualCryptSHA256:" not in out) + self.assertTrue("virtualCryptSHA512:" not in out) + self.assertTrue("rounds=" not in out) + + # gpg decryption not enabled. + # SHA256 specified + # no hashes stored in supplementalCredentials + # No rounds + # + # Should not get values + def test_no_gpg_sha256_no_rounds(self): + self.add_user() + out = self._get_password("virtualCryptSHA256") + + self.assertTrue("virtualCryptSHA256:" not in out) + self.assertTrue("virtualCryptSHA512:" not in out) + self.assertTrue("rounds=" not in out) + + # gpg decryption not enabled. + # SHA512 specified + # no hashes stored in supplementalCredentials + # No rounds + # + # Should not get values + def test_no_gpg_sha512_no_rounds(self): + self.add_user() + out = self._get_password("virtualCryptSHA512") + + self.assertTrue("virtualCryptSHA256:" not in out) + self.assertTrue("virtualCryptSHA512:" not in out) + self.assertTrue("rounds=" not in out) + + # gpg decryption not enabled. + # SHA128 specified, i.e. invalid/unknown algorithm + # no hashes stored in supplementalCredentials + # No rounds + # + # Should not get values + def test_no_gpg_invalid_alg_no_rounds(self): + self.add_user() + out = self._get_password("virtualCryptSHA128") + + self.assertTrue("virtualCryptSHA256:" not in out) + self.assertTrue("virtualCryptSHA512:" not in out) + self.assertTrue("rounds=" not in out) + + # gpg decryption enabled. + # both virtual attributes specified, no rounds option + # no hashes stored in supplementalCredentials + # Should get values + def test_gpg_both_hashes_no_rounds(self): + self.add_user() + out = self._get_password("virtualCryptSHA256,virtualCryptSHA512", True) + + self.assertTrue("virtualCryptSHA256:" in out) + self.assertTrue("virtualCryptSHA512:" in out) + self.assertTrue("rounds=" not in out) + + # gpg decryption enabled. + # SHA256 specified + # no hashes stored in supplementalCredentials + # No rounds + # + # Should get values + def test_gpg_sha256_no_rounds(self): + self.add_user() + out = self._get_password("virtualCryptSHA256", True) + + self.assertTrue("virtualCryptSHA256:" in out) + self.assertTrue("virtualCryptSHA512:" not in out) + self.assertTrue("rounds=" not in out) + + # gpg decryption enabled. + # SHA512 specified + # no hashes stored in supplementalCredentials + # No rounds + # + # Should get values + def test_gpg_sha512_no_rounds(self): + self.add_user() + out = self._get_password("virtualCryptSHA512", True) + + self.assertTrue("virtualCryptSHA256:" not in out) + self.assertTrue("virtualCryptSHA512:" in out) + self.assertTrue("rounds=" not in out) + + # gpg decryption enabled. + # SHA128 specified, i.e. invalid/unknown algorithm + # no hashes stored in supplementalCredentials + # No rounds + # + # Should not get values + def test_gpg_invalid_alg_no_rounds(self): + self.add_user() + out = self._get_password("virtualCryptSHA128", True) + + self.assertTrue("virtualCryptSHA256:" not in out) + self.assertTrue("virtualCryptSHA512:" not in out) + self.assertTrue("rounds=" not in out) + + # gpg decryption enabled. + # both virtual attributes specified, no rounds option + # no hashes stored in supplementalCredentials + # underlying windows password changed, so plain text password is + # invalid. + # Should not get values + def test_gpg_both_hashes_no_rounds_pwd_changed(self): + self.add_user() + self._change_nt_hash() + out = self._get_password("virtualCryptSHA256,virtualCryptSHA512", True) + + self.assertTrue("virtualCryptSHA256:" not in out) + self.assertTrue("virtualCryptSHA512:" not in out) + self.assertTrue("rounds=" not in out) + + # gpg decryption enabled. + # SHA256 specified, no rounds option + # no hashes stored in supplementalCredentials + # underlying windows password changed, so plain text password is + # invalid. + # Should not get values + def test_gpg_sha256_no_rounds_pwd_changed(self): + self.add_user() + self._change_nt_hash() + out = self._get_password("virtualCryptSHA256", True) + + self.assertTrue("virtualCryptSHA256:" not in out) + self.assertTrue("virtualCryptSHA512:" not in out) + self.assertTrue("rounds=" not in out) + + # gpg decryption enabled. + # SHA512 specified, no rounds option + # no hashes stored in supplementalCredentials + # underlying windows password changed, so plain text password is + # invalid. + # Should not get values + def test_gpg_sha512_no_rounds_pwd_changed(self): + self.add_user() + self._change_nt_hash() + out = self._get_password("virtualCryptSHA256", True) + + self.assertTrue("virtualCryptSHA256:" not in out) + self.assertTrue("virtualCryptSHA512:" not in out) + self.assertTrue("rounds=" not in out) + + # gpg decryption enabled. + # both virtual attributes specified, rounds specified + # no hashes stored in supplementalCredentials + # Should get values reflecting the requested rounds + def test_gpg_both_hashes_both_rounds(self): + self.add_user() + out = self._get_password( + "virtualCryptSHA256;rounds=10123,virtualCryptSHA512;rounds=10456", + True) + + self.assertTrue("virtualCryptSHA256:" in out) + self.assertTrue("virtualCryptSHA512:" in out) + + sha256 = self._get_attribute(out, "virtualCryptSHA256") + self.assertTrue(sha256.startswith("{CRYPT}$5$rounds=10123$")) + + sha512 = self._get_attribute(out, "virtualCryptSHA512") + self.assertTrue(sha512.startswith("{CRYPT}$6$rounds=10456$")) + + # gpg decryption enabled. + # both virtual attributes specified, rounds specified + # invalid rounds for sha256 + # no hashes stored in supplementalCredentials + # Should get values, no rounds for sha256, rounds for sha 512 + def test_gpg_both_hashes_sha256_rounds_invalid(self): + self.add_user() + out = self._get_password( + "virtualCryptSHA256;rounds=invalid,virtualCryptSHA512;rounds=3125", + True) + + self.assertTrue("virtualCryptSHA256:" in out) + self.assertTrue("virtualCryptSHA512:" in out) + + sha256 = self._get_attribute(out, "virtualCryptSHA256") + self.assertTrue(sha256.startswith("{CRYPT}$5$")) + self.assertTrue("rounds" not in sha256) + + sha512 = self._get_attribute(out, "virtualCryptSHA512") + self.assertTrue(sha512.startswith("{CRYPT}$6$rounds=3125$")) + + # gpg decryption not enabled. + # both virtual attributes specified, no rounds option + # both hashes stored in supplementalCredentials + # Should get values + def test_no_gpg_both_hashes_no_rounds_stored_hashes(self): + self.add_user("CryptSHA512 CryptSHA256") + + out = self._get_password("virtualCryptSHA256,virtualCryptSHA512") + + self.assertTrue("virtualCryptSHA256:" in out) + self.assertTrue("virtualCryptSHA512:" in out) + self.assertTrue("rounds=" not in out) + + # Should be using the pre computed hash in supplementalCredentials + # so it should not change between calls. + sha256 = self._get_attribute(out, "virtualCryptSHA256") + sha512 = self._get_attribute(out, "virtualCryptSHA512") + + out = self._get_password("virtualCryptSHA256,virtualCryptSHA512") + self.assertEqual(sha256, self._get_attribute(out, "virtualCryptSHA256")) + self.assertEqual(sha512, self._get_attribute(out, "virtualCryptSHA512")) + + # gpg decryption not enabled. + # both virtual attributes specified, rounds specified + # both hashes stored in supplementalCredentials, with not rounds + # Should get hashes for the first matching scheme entry + def test_no_gpg_both_hashes_rounds_stored_hashes(self): + self.add_user("CryptSHA512 CryptSHA256") + + out = self._get_password("virtualCryptSHA256;rounds=2561," + + "virtualCryptSHA512;rounds=5129") + + self.assertTrue("virtualCryptSHA256:" in out) + self.assertTrue("virtualCryptSHA512:" in out) + self.assertTrue("rounds=" not in out) + + # Should be using the pre computed hash in supplementalCredentials + # so it should not change between calls. + sha256 = self._get_attribute(out, "virtualCryptSHA256") + sha512 = self._get_attribute(out, "virtualCryptSHA512") + + out = self._get_password("virtualCryptSHA256,virtualCryptSHA512") + self.assertEqual(sha256, self._get_attribute(out, "virtualCryptSHA256")) + self.assertEqual(sha512, self._get_attribute(out, "virtualCryptSHA512")) + + # gpg decryption not enabled. + # both virtual attributes specified, rounds specified + # both hashes stored in supplementalCredentials, with rounds + # Should get values + def test_no_gpg_both_hashes_rounds_stored_hashes_with_rounds(self): + self.add_user("CryptSHA512 " + + "CryptSHA256 " + + "CryptSHA512:rounds=5129 " + + "CryptSHA256:rounds=2561") + + out = self._get_password("virtualCryptSHA256;rounds=2561," + + "virtualCryptSHA512;rounds=5129") + + self.assertTrue("virtualCryptSHA256:" in out) + self.assertTrue("virtualCryptSHA512:" in out) + self.assertTrue("rounds=" in out) + + # Should be using the pre computed hash in supplementalCredentials + # so it should not change between calls. + sha256 = self._get_attribute(out, "virtualCryptSHA256") + sha512 = self._get_attribute(out, "virtualCryptSHA512") + + out = self._get_password("virtualCryptSHA256;rounds=2561," + + "virtualCryptSHA512;rounds=5129") + self.assertEqual(sha256, self._get_attribute(out, "virtualCryptSHA256")) + self.assertEqual(sha512, self._get_attribute(out, "virtualCryptSHA512")) + + # Number of rounds should match that specified + self.assertTrue(sha256.startswith("{CRYPT}$5$rounds=2561")) + self.assertTrue(sha512.startswith("{CRYPT}$6$rounds=5129")) + + # gpg decryption not enabled. + # both virtual attributes specified, rounds specified + # both hashes stored in supplementalCredentials, with rounds + # number of rounds stored/requested do not match + # Should get the precomputed hashes for CryptSHA512 and CryptSHA256 + def test_no_gpg_both_hashes_rounds_stored_hashes_with_rounds_no_match(self): + self.add_user("CryptSHA512 " + + "CryptSHA256 " + + "CryptSHA512:rounds=5129 " + + "CryptSHA256:rounds=2561") + + out = self._get_password("virtualCryptSHA256;rounds=4000," + + "virtualCryptSHA512;rounds=5000") + + self.assertTrue("virtualCryptSHA256:" in out) + self.assertTrue("virtualCryptSHA512:" in out) + self.assertTrue("rounds=" not in out) + + # Should be using the pre computed hash in supplementalCredentials + # so it should not change between calls. + sha256 = self._get_attribute(out, "virtualCryptSHA256") + sha512 = self._get_attribute(out, "virtualCryptSHA512") + + out = self._get_password("virtualCryptSHA256;rounds=4000," + + "virtualCryptSHA512;rounds=5000") + self.assertEqual(sha256, self._get_attribute(out, "virtualCryptSHA256")) + self.assertEqual(sha512, self._get_attribute(out, "virtualCryptSHA512")) + + # As the number of rounds did not match, should have returned the + # first hash of the corresponding scheme + out = self._get_password("virtualCryptSHA256," + + "virtualCryptSHA512") + self.assertEqual(sha256, self._get_attribute(out, "virtualCryptSHA256")) + self.assertEqual(sha512, self._get_attribute(out, "virtualCryptSHA512")) + + # gpg decryption enabled. + # both virtual attributes specified, no rounds option + # both hashes stored in supplementalCredentials + # Should get values + def test_gpg_both_hashes_no_rounds_stored_hashes(self): + self.add_user("CryptSHA512 CryptSHA256") + + out = self._get_password("virtualCryptSHA256,virtualCryptSHA512", True) + + self.assertTrue("virtualCryptSHA256:" in out) + self.assertTrue("virtualCryptSHA512:" in out) + self.assertTrue("rounds=" not in out) + + # Should be using the pre computed hash in supplementalCredentials + # so it should not change between calls. + sha256 = self._get_attribute(out, "virtualCryptSHA256") + sha512 = self._get_attribute(out, "virtualCryptSHA512") + + out = self._get_password("virtualCryptSHA256,virtualCryptSHA512", True) + self.assertEqual(sha256, self._get_attribute(out, "virtualCryptSHA256")) + self.assertEqual(sha512, self._get_attribute(out, "virtualCryptSHA512")) + + # gpg decryption enabled. + # both virtual attributes specified, rounds specified + # both hashes stored in supplementalCredentials, with no rounds + # Should get calculated hashed with the correct number of rounds + def test_gpg_both_hashes_rounds_stored_hashes(self): + self.add_user("CryptSHA512 CryptSHA256") + + out = self._get_password("virtualCryptSHA256;rounds=2561," + + "virtualCryptSHA512;rounds=5129", + True) + + self.assertTrue("virtualCryptSHA256:" in out) + self.assertTrue("virtualCryptSHA512:" in out) + self.assertTrue("rounds=" in out) + + # Should be calculating the hashes + # so they should change between calls. + sha256 = self._get_attribute(out, "virtualCryptSHA256") + sha512 = self._get_attribute(out, "virtualCryptSHA512") + + out = self._get_password("virtualCryptSHA256;rounds=2561," + + "virtualCryptSHA512;rounds=5129", + True) + self.assertFalse(sha256 == self._get_attribute(out, "virtualCryptSHA256")) + self.assertFalse(sha512 == self._get_attribute(out, "virtualCryptSHA512")) + + # The returned hashes should specify the correct number of rounds + self.assertTrue(sha256.startswith("{CRYPT}$5$rounds=2561")) + self.assertTrue(sha512.startswith("{CRYPT}$6$rounds=5129")) + + # gpg decryption enabled. + # both virtual attributes specified, rounds specified + # both hashes stored in supplementalCredentials, with rounds + # Should get values + def test_gpg_both_hashes_rounds_stored_hashes_with_rounds(self): + self.add_user("CryptSHA512 " + + "CryptSHA256 " + + "CryptSHA512:rounds=5129 " + + "CryptSHA256:rounds=2561") + + out = self._get_password("virtualCryptSHA256;rounds=2561," + + "virtualCryptSHA512;rounds=5129", + True) + + self.assertTrue("virtualCryptSHA256:" in out) + self.assertTrue("virtualCryptSHA512:" in out) + self.assertTrue("rounds=" in out) + + # Should be using the pre computed hash in supplementalCredentials + # so it should not change between calls. + sha256 = self._get_attribute(out, "virtualCryptSHA256") + sha512 = self._get_attribute(out, "virtualCryptSHA512") + + out = self._get_password("virtualCryptSHA256;rounds=2561," + + "virtualCryptSHA512;rounds=5129", + True) + self.assertEqual(sha256, self._get_attribute(out, "virtualCryptSHA256")) + self.assertEqual(sha512, self._get_attribute(out, "virtualCryptSHA512")) + + # The returned hashes should specify the correct number of rounds + self.assertTrue(sha256.startswith("{CRYPT}$5$rounds=2561")) + self.assertTrue(sha512.startswith("{CRYPT}$6$rounds=5129")) + + # gpg decryption enabled. + # both virtual attributes specified, rounds specified + # both hashes stored in supplementalCredentials, with rounds + # number of rounds stored/requested do not match + # Should get calculated hashes with the correct number of rounds + def test_gpg_both_hashes_rounds_stored_hashes_with_rounds_no_match(self): + self.add_user("CryptSHA512 " + + "CryptSHA256 " + + "CryptSHA512:rounds=5129 " + + "CryptSHA256:rounds=2561") + + out = self._get_password("virtualCryptSHA256;rounds=4000," + + "virtualCryptSHA512;rounds=5000", + True) + + self.assertTrue("virtualCryptSHA256:" in out) + self.assertTrue("virtualCryptSHA512:" in out) + self.assertTrue("rounds=" in out) + + # Should be calculating the hashes + # so they should change between calls. + sha256 = self._get_attribute(out, "virtualCryptSHA256") + sha512 = self._get_attribute(out, "virtualCryptSHA512") + + out = self._get_password("virtualCryptSHA256;rounds=4000," + + "virtualCryptSHA512;rounds=5000", + True) + self.assertFalse(sha256 == self._get_attribute(out, "virtualCryptSHA256")) + self.assertFalse(sha512 == self._get_attribute(out, "virtualCryptSHA512")) + + # The calculated hashes should specify the correct number of rounds + self.assertTrue(sha256.startswith("{CRYPT}$5$rounds=4000")) + self.assertTrue(sha512.startswith("{CRYPT}$6$rounds=5000")) diff --git a/python/samba/tests/samba_tool/user_virtualCryptSHA_base.py b/python/samba/tests/samba_tool/user_virtualCryptSHA_base.py new file mode 100644 index 0000000..14e3de9 --- /dev/null +++ b/python/samba/tests/samba_tool/user_virtualCryptSHA_base.py @@ -0,0 +1,99 @@ +# Tests for the samba-tool user sub command reading Primary:userPassword +# +# Copyright (C) Andrew Bartlett <abartlet@samba.org> 2017 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import ldb +import samba +from samba.tests.samba_tool.base import SambaToolCmdTest +from samba.credentials import Credentials +from samba.samdb import SamDB +from samba.auth import system_session +from samba import dsdb + +USER_NAME = "CryptSHATestUser" +HASH_OPTION = "password hash userPassword schemes" + + +class UserCmdCryptShaTestCase(SambaToolCmdTest): + """ + Tests for samba-tool user subcommands generation of the virtualCryptSHA256 + and virtualCryptSHA512 attributes + """ + users = [] + samdb = None + + def _get_attribute(self, out, name): + parsed = list(self.ldb.parse_ldif(out)) + self.assertEqual(len(parsed), 1) + changetype, msg = parsed[0] + return str(msg.get(name, "")) + + def add_user(self, hashes=""): + self.lp = samba.tests.env_loadparm() + + # set the extra hashes to be calculated + self.lp.set(HASH_OPTION, hashes) + + self.creds = Credentials() + self.session = system_session() + self.ldb = SamDB( + session_info=self.session, + credentials=self.creds, + lp=self.lp) + + password = self.random_password() + self.runsubcmd("user", + "create", + USER_NAME, + password) + + def tearDown(self): + super().tearDown() + self.runsubcmd("user", "delete", USER_NAME) + + def _get_password(self, attributes, decrypt=False): + command = ["user", + "getpassword", + USER_NAME, + "--attributes", + attributes] + if decrypt: + command.append("--decrypt-samba-gpg") + + (result, out, err) = self.runsubcmd(*command) + self.assertCmdSuccess(result, + out, + err, + "Ensure getpassword runs") + self.assertEqual(err, "Got password OK\n", "getpassword") + return out + + # Change the just the NT password hash, as would happen if the password + # was updated by Windows, the userPassword values are now obsolete. + # + def _change_nt_hash(self): + res = self.ldb.search(expression = "cn=%s" % USER_NAME, + scope = ldb.SCOPE_SUBTREE) + msg = ldb.Message() + msg.dn = res[0].dn + msg["unicodePwd"] = ldb.MessageElement(b"ABCDEF1234567890", + ldb.FLAG_MOD_REPLACE, + "unicodePwd") + self.ldb.modify( + msg, + controls=["local_oid:%s:0" % + dsdb.DSDB_CONTROL_BYPASS_PASSWORD_HASH_OID]) diff --git a/python/samba/tests/samba_tool/user_virtualCryptSHA_gpg.py b/python/samba/tests/samba_tool/user_virtualCryptSHA_gpg.py new file mode 100644 index 0000000..6517eee --- /dev/null +++ b/python/samba/tests/samba_tool/user_virtualCryptSHA_gpg.py @@ -0,0 +1,262 @@ +# Tests for the samba-tool user sub command reading Primary:userPassword +# +# Copyright (C) Andrew Bartlett <abartlet@samba.org> 2017 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +from samba.tests.samba_tool.user_virtualCryptSHA_base import UserCmdCryptShaTestCase + + +class UserCmdCryptShaTestCaseGPG(UserCmdCryptShaTestCase): + """ + Tests for samba-tool user subcommands generation of the virtualCryptSHA256 + and virtualCryptSHA512 attributes + """ + + # gpg decryption enabled. + # both virtual attributes specified, no rounds option + # no hashes stored in supplementalCredentials + # Should get values + def test_gpg_both_hashes_no_rounds(self): + self.add_user() + out = self._get_password("virtualCryptSHA256,virtualCryptSHA512", True) + + self.assertIn("virtualCryptSHA256:", out) + self.assertIn("virtualCryptSHA512:", out) + self.assertNotIn("rounds=", out) + + # gpg decryption enabled. + # SHA256 specified + # no hashes stored in supplementalCredentials + # No rounds + # + # Should get values + def test_gpg_sha256_no_rounds(self): + self.add_user() + out = self._get_password("virtualCryptSHA256", True) + + self.assertIn("virtualCryptSHA256:", out) + self.assertNotIn("virtualCryptSHA512:", out) + self.assertNotIn("rounds=", out) + + # gpg decryption enabled. + # SHA512 specified + # no hashes stored in supplementalCredentials + # No rounds + # + # Should get values + def test_gpg_sha512_no_rounds(self): + self.add_user() + out = self._get_password("virtualCryptSHA512", True) + + self.assertNotIn("virtualCryptSHA256:", out) + self.assertIn("virtualCryptSHA512:",out) + self.assertNotIn("rounds=", out) + + # gpg decryption enabled. + # SHA128 specified, i.e. invalid/unknown algorithm + # no hashes stored in supplementalCredentials + # No rounds + # + # Should not get values + def test_gpg_invalid_alg_no_rounds(self): + self.add_user() + out = self._get_password("virtualCryptSHA128", True) + + self.assertNotIn("virtualCryptSHA256:", out) + self.assertNotIn("virtualCryptSHA512:", out) + self.assertNotIn("rounds=", out) + + # gpg decryption enabled. + # both virtual attributes specified, no rounds option + # no hashes stored in supplementalCredentials + # underlying windows password changed, so plain text password is + # invalid. + # Should not get values + def test_gpg_both_hashes_no_rounds_pwd_changed(self): + self.add_user() + self._change_nt_hash() + out = self._get_password("virtualCryptSHA256,virtualCryptSHA512", True) + + self.assertNotIn("virtualCryptSHA256:", out) + self.assertNotIn("virtualCryptSHA512:", out) + self.assertNotIn("rounds=", out) + + # gpg decryption enabled. + # SHA256 specified, no rounds option + # no hashes stored in supplementalCredentials + # underlying windows password changed, so plain text password is + # invalid. + # Should not get values + def test_gpg_sha256_no_rounds_pwd_changed(self): + self.add_user() + self._change_nt_hash() + out = self._get_password("virtualCryptSHA256", True) + + self.assertNotIn("virtualCryptSHA256:", out) + self.assertNotIn("virtualCryptSHA512:", out) + self.assertNotIn("rounds=", out) + + # gpg decryption enabled. + # SHA512 specified, no rounds option + # no hashes stored in supplementalCredentials + # underlying windows password changed, so plain text password is + # invalid. + # Should not get values + def test_gpg_sha512_no_rounds_pwd_changed(self): + self.add_user() + self._change_nt_hash() + out = self._get_password("virtualCryptSHA256", True) + + self.assertNotIn("virtualCryptSHA256:", out) + self.assertNotIn("virtualCryptSHA512:", out) + self.assertNotIn("rounds=", out) + + # gpg decryption enabled. + # both virtual attributes specified, rounds specified + # no hashes stored in supplementalCredentials + # Should get values reflecting the requested rounds + def test_gpg_both_hashes_both_rounds(self): + self.add_user() + out = self._get_password( + "virtualCryptSHA256;rounds=10123,virtualCryptSHA512;rounds=10456", + True) + + self.assertIn("virtualCryptSHA256;rounds=10123:", out) + self.assertIn("virtualCryptSHA512;rounds=10456:", out) + + sha256 = self._get_attribute(out, "virtualCryptSHA256;rounds=10123") + self.assertTrue(sha256.startswith("{CRYPT}$5$rounds=10123$")) + + sha512 = self._get_attribute(out, "virtualCryptSHA512;rounds=10456") + self.assertTrue(sha512.startswith("{CRYPT}$6$rounds=10456$")) + + # gpg decryption enabled. + # both virtual attributes specified, rounds specified + # invalid rounds for sha256 + # no hashes stored in supplementalCredentials + # Should get values, no rounds for sha256, rounds for sha 512 + def test_gpg_both_hashes_sha256_rounds_invalid(self): + self.add_user() + out = self._get_password( + "virtualCryptSHA256;rounds=invalid,virtualCryptSHA512;rounds=3125", + True) + + self.assertIn("virtualCryptSHA256;rounds=invalid:", out) + self.assertIn("virtualCryptSHA512;rounds=3125:", out) + + sha256 = self._get_attribute(out, "virtualCryptSHA256;rounds=invalid") + self.assertTrue(sha256.startswith("{CRYPT}$5$")) + self.assertNotIn("rounds", sha256) + + sha512 = self._get_attribute(out, "virtualCryptSHA512;rounds=3125") + self.assertTrue(sha512.startswith("{CRYPT}$6$rounds=3125$")) + + # gpg decryption enabled. + # both virtual attributes specified, rounds specified + # both hashes stored in supplementalCredentials, with no rounds + # Should get calculated hashed with the correct number of rounds + def test_gpg_both_hashes_rounds_stored_hashes(self): + self.add_user("CryptSHA512 CryptSHA256") + + out = self._get_password("virtualCryptSHA256;rounds=2561," + + "virtualCryptSHA512;rounds=5129", + True) + + self.assertIn("virtualCryptSHA256;rounds=2561:", out) + self.assertIn("virtualCryptSHA512;rounds=5129:", out) + self.assertIn("$rounds=", out) + + # Should be calculating the hashes + # so they should change between calls. + sha256 = self._get_attribute(out, "virtualCryptSHA256;rounds=2561") + sha512 = self._get_attribute(out, "virtualCryptSHA512;rounds=5129") + + out = self._get_password("virtualCryptSHA256;rounds=2561," + + "virtualCryptSHA512;rounds=5129", + True) + self.assertNotEqual(sha256, self._get_attribute(out, "virtualCryptSHA256")) + self.assertNotEqual(sha512, self._get_attribute(out, "virtualCryptSHA512")) + + # The returned hashes should specify the correct number of rounds + self.assertTrue(sha256.startswith("{CRYPT}$5$rounds=2561")) + self.assertTrue(sha512.startswith("{CRYPT}$6$rounds=5129")) + + # gpg decryption enabled. + # both virtual attributes specified, rounds specified + # both hashes stored in supplementalCredentials, with rounds + # Should get values + def test_gpg_both_hashes_rounds_stored_hashes_with_rounds(self): + self.add_user("CryptSHA512 " + + "CryptSHA256 " + + "CryptSHA512:rounds=5129 " + + "CryptSHA256:rounds=2561") + + out = self._get_password("virtualCryptSHA256;rounds=2561," + + "virtualCryptSHA512;rounds=5129", + True) + + self.assertIn("virtualCryptSHA256;rounds=2561:", out) + self.assertIn("virtualCryptSHA512;rounds=5129:", out) + self.assertIn("$rounds=", out) + + # Should be using the pre computed hash in supplementalCredentials + # so it should not change between calls. + sha256 = self._get_attribute(out, "virtualCryptSHA256;rounds=2561") + sha512 = self._get_attribute(out, "virtualCryptSHA512;rounds=5129") + + out = self._get_password("virtualCryptSHA256;rounds=2561," + + "virtualCryptSHA512;rounds=5129", + True) + self.assertEqual(sha256, self._get_attribute(out, "virtualCryptSHA256;rounds=2561")) + self.assertEqual(sha512, self._get_attribute(out, "virtualCryptSHA512;rounds=5129")) + + # The returned hashes should specify the correct number of rounds + self.assertTrue(sha256.startswith("{CRYPT}$5$rounds=2561")) + self.assertTrue(sha512.startswith("{CRYPT}$6$rounds=5129")) + + # gpg decryption enabled. + # both virtual attributes specified, rounds specified + # both hashes stored in supplementalCredentials, with rounds + # number of rounds stored/requested do not match + # Should get calculated hashes with the correct number of rounds + def test_gpg_both_hashes_rounds_stored_hashes_with_rounds_no_match(self): + self.add_user("CryptSHA512 " + + "CryptSHA256 " + + "CryptSHA512:rounds=5129 " + + "CryptSHA256:rounds=2561") + + out = self._get_password("virtualCryptSHA256;rounds=4000," + + "virtualCryptSHA512;rounds=5000", + True) + + self.assertIn("virtualCryptSHA256;rounds=4000:", out) + self.assertIn("virtualCryptSHA512;rounds=5000:", out) + self.assertIn("$rounds=", out) + + # Should be calculating the hashes + # so they should change between calls. + sha256 = self._get_attribute(out, "virtualCryptSHA256;rounds=4000") + sha512 = self._get_attribute(out, "virtualCryptSHA512;rounds=5000") + + out = self._get_password("virtualCryptSHA256;rounds=4000," + + "virtualCryptSHA512;rounds=5000", + True) + self.assertNotEqual(sha256, self._get_attribute(out, "virtualCryptSHA256;rounds=4000")) + self.assertNotEqual(sha512, self._get_attribute(out, "virtualCryptSHA512;rounds=5000")) + + # The calculated hashes should specify the correct number of rounds + self.assertTrue(sha256.startswith("{CRYPT}$5$rounds=4000")) + self.assertTrue(sha512.startswith("{CRYPT}$6$rounds=5000")) diff --git a/python/samba/tests/samba_tool/user_virtualCryptSHA_userPassword.py b/python/samba/tests/samba_tool/user_virtualCryptSHA_userPassword.py new file mode 100644 index 0000000..1f84af0 --- /dev/null +++ b/python/samba/tests/samba_tool/user_virtualCryptSHA_userPassword.py @@ -0,0 +1,188 @@ +# Tests for the samba-tool user sub command reading Primary:userPassword +# +# Copyright (C) Andrew Bartlett <abartlet@samba.org> 2017 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +from samba.tests.samba_tool.user_virtualCryptSHA_base import UserCmdCryptShaTestCase + + +class UserCmdCryptShaTestCaseUserPassword(UserCmdCryptShaTestCase): + # gpg decryption not enabled. + # both virtual attributes specified, no rounds option + # no hashes stored in supplementalCredentials + # Should not get values + def test_no_gpg_both_hashes_no_rounds(self): + self.add_user() + out = self._get_password("virtualCryptSHA256,virtualCryptSHA512") + + self.assertTrue("virtualCryptSHA256:" not in out) + self.assertTrue("virtualCryptSHA512:" not in out) + self.assertTrue("rounds=" not in out) + + # gpg decryption not enabled. + # SHA256 specified + # no hashes stored in supplementalCredentials + # No rounds + # + # Should not get values + def test_no_gpg_sha256_no_rounds(self): + self.add_user() + out = self._get_password("virtualCryptSHA256") + + self.assertTrue("virtualCryptSHA256:" not in out) + self.assertTrue("virtualCryptSHA512:" not in out) + self.assertTrue("rounds=" not in out) + + # gpg decryption not enabled. + # SHA512 specified + # no hashes stored in supplementalCredentials + # No rounds + # + # Should not get values + def test_no_gpg_sha512_no_rounds(self): + self.add_user() + out = self._get_password("virtualCryptSHA512") + + self.assertTrue("virtualCryptSHA256:" not in out) + self.assertTrue("virtualCryptSHA512:" not in out) + self.assertTrue("rounds=" not in out) + + # gpg decryption not enabled. + # SHA128 specified, i.e. invalid/unknown algorithm + # no hashes stored in supplementalCredentials + # No rounds + # + # Should not get values + def test_no_gpg_invalid_alg_no_rounds(self): + self.add_user() + out = self._get_password("virtualCryptSHA128") + + self.assertTrue("virtualCryptSHA256:" not in out) + self.assertTrue("virtualCryptSHA512:" not in out) + self.assertTrue("rounds=" not in out) + + # gpg decryption not enabled. + # both virtual attributes specified, no rounds option + # both hashes stored in supplementalCredentials + # Should get values + def test_no_gpg_both_hashes_no_rounds_stored_hashes(self): + self.add_user("CryptSHA512 CryptSHA256") + + out = self._get_password("virtualCryptSHA256,virtualCryptSHA512") + + self.assertTrue("virtualCryptSHA256:" in out) + self.assertTrue("virtualCryptSHA512:" in out) + self.assertTrue("rounds=" not in out) + + # Should be using the pre computed hash in supplementalCredentials + # so it should not change between calls. + sha256 = self._get_attribute(out, "virtualCryptSHA256") + sha512 = self._get_attribute(out, "virtualCryptSHA512") + + out = self._get_password("virtualCryptSHA256,virtualCryptSHA512") + self.assertEqual(sha256, self._get_attribute(out, "virtualCryptSHA256")) + self.assertEqual(sha512, self._get_attribute(out, "virtualCryptSHA512")) + + # gpg decryption not enabled. + # both virtual attributes specified, rounds specified + # both hashes stored in supplementalCredentials, with not rounds + # Should get hashes for the first matching scheme entry + def test_no_gpg_both_hashes_rounds_stored_hashes(self): + self.add_user("CryptSHA512 CryptSHA256") + + out = self._get_password("virtualCryptSHA256;rounds=2561," + + "virtualCryptSHA512;rounds=5129") + + self.assertTrue("virtualCryptSHA256;rounds=2561:" in out) + self.assertTrue("virtualCryptSHA512;rounds=5129:" in out) + self.assertTrue("$rounds=" not in out) + + # Should be using the pre computed hash in supplementalCredentials + # so it should not change between calls. + sha256 = self._get_attribute(out, "virtualCryptSHA256;rounds=2561") + sha512 = self._get_attribute(out, "virtualCryptSHA512;rounds=5129") + + out = self._get_password("virtualCryptSHA256,virtualCryptSHA512") + self.assertEqual(sha256, self._get_attribute(out, + "virtualCryptSHA256")) + self.assertEqual(sha512, self._get_attribute(out, + "virtualCryptSHA512")) + + # gpg decryption not enabled. + # both virtual attributes specified, rounds specified + # both hashes stored in supplementalCredentials, with rounds + # Should get values + def test_no_gpg_both_hashes_rounds_stored_hashes_with_rounds(self): + self.add_user("CryptSHA512 " + + "CryptSHA256 " + + "CryptSHA512:rounds=5129 " + + "CryptSHA256:rounds=2561") + + out = self._get_password("virtualCryptSHA256;rounds=2561," + + "virtualCryptSHA512;rounds=5129") + + self.assertTrue("virtualCryptSHA256;rounds=2561:" in out) + self.assertTrue("virtualCryptSHA512;rounds=5129:" in out) + self.assertTrue("$rounds=" in out) + + # Should be using the pre computed hash in supplementalCredentials + # so it should not change between calls. + sha256 = self._get_attribute(out, "virtualCryptSHA256;rounds=2561") + sha512 = self._get_attribute(out, "virtualCryptSHA512;rounds=5129") + + out = self._get_password("virtualCryptSHA256;rounds=2561," + + "virtualCryptSHA512;rounds=5129") + self.assertEqual(sha256, self._get_attribute(out, "virtualCryptSHA256;rounds=2561")) + self.assertEqual(sha512, self._get_attribute(out, "virtualCryptSHA512;rounds=5129")) + + # Number of rounds should match that specified + self.assertTrue(sha256.startswith("{CRYPT}$5$rounds=2561")) + self.assertTrue(sha512.startswith("{CRYPT}$6$rounds=5129")) + + # gpg decryption not enabled. + # both virtual attributes specified, rounds specified + # both hashes stored in supplementalCredentials, with rounds + # number of rounds stored/requested do not match + # Should get the precomputed hashes for CryptSHA512 and CryptSHA256 + def test_no_gpg_both_hashes_rounds_stored_hashes_with_rounds_no_match(self): + self.add_user("CryptSHA512 " + + "CryptSHA256 " + + "CryptSHA512:rounds=5129 " + + "CryptSHA256:rounds=2561") + + out = self._get_password("virtualCryptSHA256;rounds=4000," + + "virtualCryptSHA512;rounds=5000") + + self.assertTrue("virtualCryptSHA256;rounds=4000:" in out) + self.assertTrue("virtualCryptSHA512;rounds=5000:" in out) + self.assertTrue("$rounds=" not in out) + + # Should be using the pre computed hash in supplementalCredentials + # so it should not change between calls. + sha256 = self._get_attribute(out, "virtualCryptSHA256;rounds=4000") + sha512 = self._get_attribute(out, "virtualCryptSHA512;rounds=5000") + + out = self._get_password("virtualCryptSHA256;rounds=4000," + + "virtualCryptSHA512;rounds=5000") + self.assertEqual(sha256, self._get_attribute(out, "virtualCryptSHA256;rounds=4000")) + self.assertEqual(sha512, self._get_attribute(out, "virtualCryptSHA512;rounds=5000")) + + # As the number of rounds did not match, should have returned the + # first hash of the corresponding scheme + out = self._get_password("virtualCryptSHA256," + + "virtualCryptSHA512") + self.assertEqual(sha256, self._get_attribute(out, "virtualCryptSHA256")) + self.assertEqual(sha512, self._get_attribute(out, "virtualCryptSHA512")) diff --git a/python/samba/tests/samba_tool/user_wdigest.py b/python/samba/tests/samba_tool/user_wdigest.py new file mode 100644 index 0000000..0d87762 --- /dev/null +++ b/python/samba/tests/samba_tool/user_wdigest.py @@ -0,0 +1,450 @@ +# Tests for the samba-tool user sub command reading Primary:WDigest +# +# Copyright (C) Andrew Bartlett <abartlet@samba.org> 2017 +# +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +import os +import samba +from samba.tests.samba_tool.base import SambaToolCmdTest +from hashlib import md5 + + +USER_NAME = "WdigestTestUser" + +# Calculate the MD5 password digest from the supplied user, realm and password +# + + +def calc_digest(user, realm, password): + data = "%s:%s:%s" % (user, realm, password) + if isinstance(data, str): + data = data.encode('utf8') + + return "%s:%s:%s" % (user, realm, md5(data).hexdigest()) + + +class UserCmdWdigestTestCase(SambaToolCmdTest): + """Tests for samba-tool user subcommands extraction of the wdigest values + Test results validated against Windows Server 2012 R2. + NOTE: That as at 22-05-2017 the values Documented at + 3.1.1.8.11.3.1 WDIGEST_CREDENTIALS Construction + are incorrect. + """ + users = [] + samdb = None + + def setUp(self): + super().setUp() + self.lp = samba.tests.env_loadparm() + self.samdb = self.getSamDB( + "-H", "ldap://%s" % os.environ["DC_SERVER"], + "-U%s%%%s" % (os.environ["DC_USERNAME"], + os.environ["DC_PASSWORD"])) + self.dns_domain = self.samdb.domain_dns_name() + res = self.samdb.search( + base=self.samdb.get_config_basedn(), + expression="ncName=%s" % self.samdb.get_default_basedn(), + attrs=["nETBIOSName"]) + self.netbios_domain = str(res[0]["nETBIOSName"][0]) + self.password = self.random_password() + result, out, err = self.runsubcmd("user", + "create", + USER_NAME, + self.password) + self.assertCmdSuccess(result, + out, + err, + "Ensure user is created") + + def tearDown(self): + super().tearDown() + result, out, err = self.runsubcmd("user", "delete", USER_NAME) + self.assertCmdSuccess(result, + out, + err, + "Ensure user is deleted") + + def _testWDigest(self, attribute, expected, missing=False): + + (result, out, err) = self.runsubcmd("user", + "getpassword", + USER_NAME, + "--attributes", + attribute) + self.assertCmdSuccess(result, + out, + err, + "Ensure getpassword runs") + self.assertEqual(err, "Got password OK\n", "getpassword") + + if missing: + self.assertTrue(attribute not in out) + else: + self.assertMatch(out.replace('\n ', ''), + "%s: %s" % (attribute, expected)) + + def test_Wdigest_no_suffix(self): + attribute = "virtualWDigest" + self._testWDigest(attribute, None, True) + + def test_Wdigest_non_numeric_suffix(self): + attribute = "virtualWDigestss" + self._testWDigest(attribute, None, True) + + def test_Wdigest00(self): + attribute = "virtualWDigest00" + self._testWDigest(attribute, None, True) + + # Hash01 MD5(sAMAccountName, + # NETBIOSDomainName, + # password) + # + def test_Wdigest01(self): + attribute = "virtualWDigest01" + expected = calc_digest(USER_NAME, + self.netbios_domain, + self.password) + self._testWDigest(attribute, expected) + + # Hash02 MD5(LOWER(sAMAccountName), + # LOWER(NETBIOSDomainName), + # password) + # + def test_Wdigest02(self): + attribute = "virtualWDigest02" + expected = calc_digest(USER_NAME.lower(), + self.netbios_domain.lower(), + self.password) + self._testWDigest(attribute, expected) + + # Hash03 MD5(UPPER(sAMAccountName), + # UPPER(NETBIOSDomainName), + # password) + # + def test_Wdigest03(self): + attribute = "virtualWDigest03" + expected = calc_digest(USER_NAME.upper(), + self.netbios_domain.upper(), + self.password) + self._testWDigest(attribute, expected) + + # Hash04 MD5(sAMAccountName, + # UPPER(NETBIOSDomainName), + # password) + # + def test_Wdigest04(self): + attribute = "virtualWDigest04" + expected = calc_digest(USER_NAME, + self.netbios_domain.upper(), + self.password) + self._testWDigest(attribute, expected) + + # Hash05 MD5(sAMAccountName, + # LOWER(NETBIOSDomainName), + # password) + # + def test_Wdigest05(self): + attribute = "virtualWDigest05" + expected = calc_digest(USER_NAME, + self.netbios_domain.lower(), + self.password) + self._testWDigest(attribute, expected) + + # Hash06 MD5(UPPER(sAMAccountName), + # LOWER(NETBIOSDomainName), + # password) + # + def test_Wdigest06(self): + attribute = "virtualWDigest06" + expected = calc_digest(USER_NAME.upper(), + self.netbios_domain.lower(), + self.password) + self._testWDigest(attribute, expected) + + # Hash07 MD5(LOWER(sAMAccountName), + # UPPER(NETBIOSDomainName), + # password) + # + def test_Wdigest07(self): + attribute = "virtualWDigest07" + expected = calc_digest(USER_NAME.lower(), + self.netbios_domain.upper(), + self.password) + self._testWDigest(attribute, expected) + + # Hash08 MD5(sAMAccountName, + # DNSDomainName, + # password) + # + # Note: Samba lowercases the DNSDomainName at provision time, + # Windows preserves the case. This means that the WDigest08 values + # calculated byt Samba and Windows differ. + # + def test_Wdigest08(self): + attribute = "virtualWDigest08" + expected = calc_digest(USER_NAME, + self.dns_domain, + self.password) + self._testWDigest(attribute, expected) + + # Hash09 MD5(LOWER(sAMAccountName), + # LOWER(DNSDomainName), + # password) + # + def test_Wdigest09(self): + attribute = "virtualWDigest09" + expected = calc_digest(USER_NAME.lower(), + self.dns_domain.lower(), + self.password) + self._testWDigest(attribute, expected) + + # Hash10 MD5(UPPER(sAMAccountName), + # UPPER(DNSDomainName), + # password) + # + def test_Wdigest10(self): + attribute = "virtualWDigest10" + expected = calc_digest(USER_NAME.upper(), + self.dns_domain.upper(), + self.password) + self._testWDigest(attribute, expected) + + # Hash11 MD5(sAMAccountName, + # UPPER(DNSDomainName), + # password) + # + def test_Wdigest11(self): + attribute = "virtualWDigest11" + expected = calc_digest(USER_NAME, + self.dns_domain.upper(), + self.password) + self._testWDigest(attribute, expected) + + # Hash12 MD5(sAMAccountName, + # LOWER(DNSDomainName), + # password) + # + def test_Wdigest12(self): + attribute = "virtualWDigest12" + expected = calc_digest(USER_NAME, + self.dns_domain.lower(), + self.password) + self._testWDigest(attribute, expected) + + # Hash13 MD5(UPPER(sAMAccountName), + # LOWER(DNSDomainName), + # password) + # + def test_Wdigest13(self): + attribute = "virtualWDigest13" + expected = calc_digest(USER_NAME.upper(), + self.dns_domain.lower(), + self.password) + self._testWDigest(attribute, expected) + + # Hash14 MD5(LOWER(sAMAccountName), + # UPPER(DNSDomainName), + # password) + # + + def test_Wdigest14(self): + attribute = "virtualWDigest14" + expected = calc_digest(USER_NAME.lower(), + self.dns_domain.upper(), + self.password) + self._testWDigest(attribute, expected) + + # Hash15 MD5(userPrincipalName, + # password) + # + def test_Wdigest15(self): + attribute = "virtualWDigest15" + name = "%s@%s" % (USER_NAME, self.dns_domain) + expected = calc_digest(name, + "", + self.password) + self._testWDigest(attribute, expected) + + # Hash16 MD5(LOWER(userPrincipalName), + # password) + # + def test_Wdigest16(self): + attribute = "virtualWDigest16" + name = "%s@%s" % (USER_NAME.lower(), self.dns_domain.lower()) + expected = calc_digest(name, + "", + self.password) + self._testWDigest(attribute, expected) + + # Hash17 MD5(UPPER(userPrincipalName), + # password) + # + def test_Wdigest17(self): + attribute = "virtualWDigest17" + name = "%s@%s" % (USER_NAME.upper(), self.dns_domain.upper()) + expected = calc_digest(name, + "", + self.password) + self._testWDigest(attribute, expected) + + # Hash18 MD5(NETBIOSDomainName\sAMAccountName, + # password) + # + def test_Wdigest18(self): + attribute = "virtualWDigest18" + name = "%s\\%s" % (self.netbios_domain, USER_NAME) + expected = calc_digest(name, + "", + self.password) + self._testWDigest(attribute, expected) + + # Hash19 MD5(LOWER(NETBIOSDomainName\sAMAccountName), + # password) + # + def test_Wdigest19(self): + attribute = "virtualWDigest19" + name = "%s\\%s" % (self.netbios_domain, USER_NAME) + expected = calc_digest(name.lower(), + "", + self.password) + self._testWDigest(attribute, expected) + + # Hash20 MD5(UPPER(NETBIOSDomainName\sAMAccountName), + # password) + # + def test_Wdigest20(self): + attribute = "virtualWDigest20" + name = "%s\\%s" % (self.netbios_domain, USER_NAME) + expected = calc_digest(name.upper(), + "", + self.password) + self._testWDigest(attribute, expected) + + # Hash21 MD5(sAMAccountName, + # "Digest", + # password) + # + def test_Wdigest21(self): + attribute = "virtualWDigest21" + expected = calc_digest(USER_NAME, + "Digest", + self.password) + self._testWDigest(attribute, expected) + + # Hash22 MD5(LOWER(sAMAccountName), + # "Digest", + # password) + # + def test_Wdigest22(self): + attribute = "virtualWDigest22" + expected = calc_digest(USER_NAME.lower(), + "Digest", + self.password) + self._testWDigest(attribute, expected) + + # Hash23 MD5(UPPER(sAMAccountName), + # "Digest", + # password) + # + def test_Wdigest23(self): + attribute = "virtualWDigest23" + expected = calc_digest(USER_NAME.upper(), + "Digest", + self.password) + self._testWDigest(attribute, expected) + + # Hash24 MD5(userPrincipalName), + # "Digest", + # password) + # + def test_Wdigest24(self): + attribute = "virtualWDigest24" + name = "%s@%s" % (USER_NAME, self.dns_domain) + expected = calc_digest(name, + "Digest", + self.password) + self._testWDigest(attribute, expected) + + # Hash25 MD5(LOWER(userPrincipalName), + # "Digest", + # password) + # + def test_Wdigest25(self): + attribute = "virtualWDigest25" + name = "%s@%s" % (USER_NAME, self.dns_domain.lower()) + expected = calc_digest(name.lower(), + "Digest", + self.password) + self._testWDigest(attribute, expected) + + # Hash26 MD5(UPPER(userPrincipalName), + # "Digest", + # password) + # + def test_Wdigest26(self): + attribute = "virtualWDigest26" + name = "%s@%s" % (USER_NAME, self.dns_domain.lower()) + expected = calc_digest(name.upper(), + "Digest", + self.password) + self._testWDigest(attribute, expected) + # Hash27 MD5(NETBIOSDomainName\sAMAccountName, + # "Digest", + # password) + # + + def test_Wdigest27(self): + attribute = "virtualWDigest27" + name = "%s\\%s" % (self.netbios_domain, USER_NAME) + expected = calc_digest(name, + "Digest", + self.password) + self._testWDigest(attribute, expected) + + # Hash28 MD5(LOWER(NETBIOSDomainName\sAMAccountName), + # "Digest", + # password) + # + def test_Wdigest28(self): + attribute = "virtualWDigest28" + name = "%s\\%s" % (self.netbios_domain.lower(), USER_NAME.lower()) + expected = calc_digest(name, + "Digest", + self.password) + self._testWDigest(attribute, expected) + + # Hash29 MD5(UPPER(NETBIOSDomainName\sAMAccountName), + # "Digest", + # password) + # + def test_Wdigest29(self): + attribute = "virtualWDigest29" + name = "%s\\%s" % (self.netbios_domain.upper(), USER_NAME.upper()) + expected = calc_digest(name, + "Digest", + self.password) + self._testWDigest(attribute, expected) + + def test_Wdigest30(self): + attribute = "virtualWDigest30" + self._testWDigest(attribute, None, True) + + # Check digest calculation against an known htdigest value + def test_calc_digest(self): + htdigest = "gary:fred:2204fcc247cb47ded249ef2fe0013255" + digest = calc_digest("gary", "fred", "password") + self.assertEqual(htdigest, digest) diff --git a/python/samba/tests/samba_tool/visualize.py b/python/samba/tests/samba_tool/visualize.py new file mode 100644 index 0000000..f736129 --- /dev/null +++ b/python/samba/tests/samba_tool/visualize.py @@ -0,0 +1,618 @@ +# -*- coding: utf-8 -*- +# Tests for samba-tool visualize +# Copyright (C) Andrew Bartlett 2015, 2018 +# +# by Douglas Bagnall <douglas.bagnall@catalyst.net.nz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# +"""Tests for samba-tool visualize ntdsconn using the test ldif +topologies. + +We don't test samba-tool visualize reps here because repsTo and +repsFrom are not replicated, and there are no actual remote servers to +query. +""" +import os +import tempfile +import re +from io import StringIO +from samba.tests.samba_tool.base import SambaToolCmdTest +from samba.kcc import ldif_import_export +from samba.graph import COLOUR_SETS +from samba.param import LoadParm + +MULTISITE_LDIF = os.path.join(os.environ['SRCDIR_ABS'], + "testdata/ldif-utils-test-multisite.ldif") + +# UNCONNECTED_LDIF is a single site, unconnected 5DC database that was +# created using samba-tool domain join in testenv. +UNCONNECTED_LDIF = os.path.join(os.environ['SRCDIR_ABS'], + "testdata/unconnected-intrasite.ldif") + +DOMAIN = "DC=ad,DC=samba,DC=example,DC=com" +DN_TEMPLATE = "CN=%s,CN=Servers,CN=%s,CN=Sites,CN=Configuration," + DOMAIN + +MULTISITE_LDIF_DSAS = [ + ("WIN01", "Default-First-Site-Name"), + ("WIN08", "Site-4"), + ("WIN07", "Site-4"), + ("WIN06", "Site-3"), + ("WIN09", "Site-5"), + ("WIN10", "Site-5"), + ("WIN02", "Site-2"), + ("WIN04", "Site-2"), + ("WIN03", "Site-2"), + ("WIN05", "Site-2"), +] + + +class StringIOThinksItIsATTY(StringIO): + """A StringIO that claims to be a TTY for testing --color=auto, + by switching the stringIO class attribute.""" + def isatty(self): + return True + + +def samdb_from_ldif(ldif, tempdir, lp, dsa=None, tag=''): + if dsa is None: + dsa_name = 'default-DSA' + else: + dsa_name = dsa[:5] + dburl = os.path.join(tempdir, + ("ldif-to-sambdb-%s-%s" % + (tag, dsa_name))) + samdb = ldif_import_export.ldif_to_samdb(dburl, lp, ldif, + forced_local_dsa=dsa) + return (samdb, dburl) + + +def collapse_space(s, keep_empty_lines=False): + lines = [] + for line in s.splitlines(): + line = ' '.join(line.strip().split()) + if line or keep_empty_lines: + lines.append(line) + return '\n'.join(lines) + + +class SambaToolVisualizeLdif(SambaToolCmdTest): + def setUp(self): + super().setUp() + self.lp = LoadParm() + self.samdb, self.dbfile = samdb_from_ldif(MULTISITE_LDIF, + self.tempdir, + self.lp) + self.dburl = 'tdb://' + self.dbfile + + def tearDown(self): + self.remove_files(self.dbfile) + super().tearDown() + + def remove_files(self, *files): + for f in files: + self.assertTrue(f.startswith(self.tempdir)) + os.unlink(f) + + def test_colour(self): + """Ensure the colour output is the same as the monochrome output + EXCEPT for the colours, of which the monochrome one should + know nothing.""" + colour_re = re.compile('\033' r'\[[\d;]+m') + result, monochrome, err = self.runsubcmd("visualize", "ntdsconn", + '-H', self.dburl, + '--color=no', '-S') + self.assertCmdSuccess(result, monochrome, err) + self.assertFalse(colour_re.findall(monochrome)) + + colour_args = [['--color=yes']] + colour_args += [['--color-scheme', x] for x in COLOUR_SETS + if x is not None] + + for args in colour_args: + result, out, err = self.runsubcmd("visualize", "ntdsconn", + '-H', self.dburl, + '-S', *args) + self.assertCmdSuccess(result, out, err) + self.assertTrue(colour_re.search(out), + f"'{' '.join(args)}' should be colour") + uncoloured = colour_re.sub('', out) + + self.assertStringsEqual(monochrome, uncoloured, strip=True) + + def assert_colour(self, text, has_colour=True, monochrome=None): + colour_re = re.compile('\033' r'\[[\d;]+m') + found = colour_re.search(text) + if has_colour: + self.assertTrue(found, text) + else: + self.assertFalse(found, text) + if monochrome is not None: + uncoloured = colour_re.sub('', text) + self.assertStringsEqual(monochrome, uncoloured, strip=True) + + def test_colour_auto_tty(self): + """Assert the behaviour of --colour=auto with and without + NO_COLOUR on a fake tty""" + result, monochrome, err = self.runsubcmd("visualize", "ntdsconn", + '-H', self.dburl, + '--color=no', '-S') + self.assertCmdSuccess(result, monochrome, err) + self.assert_colour(monochrome, False) + cls = self.__class__ + + try: + cls.stringIO = StringIOThinksItIsATTY + old_no_color = os.environ.pop('NO_COLOR', None) + # First with no NO_COLOR env var. There should be colour. + result, out, err = self.runsubcmd("visualize", "ntdsconn", + '-H', self.dburl, + '-S', + '--color=auto') + self.assertCmdSuccess(result, out, err) + self.assert_colour(out, True, monochrome) + + for env, opt, is_colour in [ + # NO_COLOR='' should be as if no NO_COLOR + ['', '--color=auto', True], + # NO_COLOR='1': we expect no colour + ['1', '--color=auto', False], + # NO_COLOR='no': we still expect no colour + ['no', '--color=auto', False], + # NO_COLOR=' ', alias for 'auto' + [' ', '--color=tty', False], + # NO_COLOR=' ', alias for 'auto' + [' ', '--color=if-tty', False], + # NO_COLOR='', alias for 'auto' + ['', '--color=tty', True], + # NO_COLOR='', alias for 'no' + ['', '--color=never', False], + # NO_COLOR='x', alias for 'yes' (--color=yes wins) + ['x', '--color=force', True], + ]: + os.environ['NO_COLOR'] = env + + try: + result, out, err = self.runsubcmd("visualize", "ntdsconn", + '-H', self.dburl, + '-S', + opt) + except SystemExit: + # optparse makes us do this + self.fail(f"optparse rejects {env}, {opt}, {is_colour}") + + self.assertCmdSuccess(result, out, err) + self.assert_colour(out, is_colour, monochrome) + + # with "-o -" output filename alias for stdout. + result, out, err = self.runsubcmd("visualize", "ntdsconn", + '-H', self.dburl, + '-S', + opt, + '-o', '-') + self.assertCmdSuccess(result, out, err) + self.assert_colour(out, is_colour, monochrome) + + finally: + cls.stringIO = StringIO + if old_no_color is None: + os.environ.pop('NO_COLOR', None) + else: + os.environ['NO_COLOR'] = old_no_color + + def test_import_ldif_xdot(self): + """We can't test actual xdot, but using the environment we can + persuade samba-tool that a script we write is xdot and ensure + it gets the right text. + """ + result, expected, err = self.runsubcmd("visualize", "ntdsconn", + '-H', self.dburl, + '--color=no', '-S', + '--dot') + self.assertCmdSuccess(result, expected, err) + + # not that we're expecting anything here + old_xdot_path = os.environ.get('SAMBA_TOOL_XDOT_PATH') + + tmpdir = tempfile.mkdtemp() + fake_xdot = os.path.join(tmpdir, 'fake_xdot') + content = os.path.join(tmpdir, 'content') + f = open(fake_xdot, 'w') + print('#!/bin/sh', file=f) + print('cp $1 %s' % content, file=f) + f.close() + os.chmod(fake_xdot, 0o700) + + os.environ['SAMBA_TOOL_XDOT_PATH'] = fake_xdot + result, empty, err = self.runsubcmd("visualize", "ntdsconn", + '--importldif', MULTISITE_LDIF, + '--color=no', '-S', + '--xdot') + + f = open(content) + xdot = f.read() + f.close() + os.remove(fake_xdot) + os.remove(content) + os.rmdir(tmpdir) + + if old_xdot_path is not None: + os.environ['SAMBA_TOOL_XDOT_PATH'] = old_xdot_path + else: + del os.environ['SAMBA_TOOL_XDOT_PATH'] + + self.assertCmdSuccess(result, xdot, err) + self.assertStringsEqual(expected, xdot, strip=True) + + def test_import_ldif(self): + """Make sure the samba-tool visualize --importldif option gives the + same output as using the externally generated db from the same + LDIF.""" + result, s1, err = self.runsubcmd("visualize", "ntdsconn", + '-H', self.dburl, + '--color=no', '-S') + self.assertCmdSuccess(result, s1, err) + + result, s2, err = self.runsubcmd("visualize", "ntdsconn", + '--importldif', MULTISITE_LDIF, + '--color=no', '-S') + self.assertCmdSuccess(result, s2, err) + + self.assertStringsEqual(s1, s2) + + def test_output_file(self): + """Check that writing to a file works, with and without + --color=auto.""" + # NOTE, we can't really test --color=auto works with a TTY. + colour_re = re.compile('\033' r'\[[\d;]+m') + result, expected, err = self.runsubcmd("visualize", "ntdsconn", + '-H', self.dburl, + '--color=auto', '-S') + self.assertCmdSuccess(result, expected, err) + # Not a TTY, so stdout output should be colourless + self.assertFalse(colour_re.search(expected)) + expected = expected.strip() + + color_auto_file = os.path.join(self.tempdir, 'color-auto') + + result, out, err = self.runsubcmd("visualize", "ntdsconn", + '-H', self.dburl, + '--color=auto', '-S', + '-o', color_auto_file) + self.assertCmdSuccess(result, out, err) + # We wrote to file, so stdout should be empty + self.assertEqual(out, '') + f = open(color_auto_file) + color_auto = f.read() + f.close() + self.assertStringsEqual(color_auto, expected, strip=True) + self.remove_files(color_auto_file) + + color_no_file = os.path.join(self.tempdir, 'color-no') + result, out, err = self.runsubcmd("visualize", "ntdsconn", + '-H', self.dburl, + '--color=no', '-S', + '-o', color_no_file) + self.assertCmdSuccess(result, out, err) + self.assertEqual(out, '') + f = open(color_no_file) + color_no = f.read() + f.close() + self.remove_files(color_no_file) + + self.assertStringsEqual(color_no, expected, strip=True) + + color_yes_file = os.path.join(self.tempdir, 'color-yes') + result, out, err = self.runsubcmd("visualize", "ntdsconn", + '-H', self.dburl, + '--color=yes', '-S', + '-o', color_yes_file) + self.assertCmdSuccess(result, out, err) + self.assertEqual(out, '') + f = open(color_yes_file) + colour_yes = f.read() + f.close() + self.assertNotEqual(colour_yes.strip(), expected) + + self.remove_files(color_yes_file) + + # Try the magic filename "-", meaning stdout. + # This doesn't exercise the case when stdout is a TTY + for c, equal in [('no', True), ('auto', True), ('yes', False)]: + result, out, err = self.runsubcmd("visualize", "ntdsconn", + '-H', self.dburl, + '--color', c, + '-S', '-o', '-') + self.assertCmdSuccess(result, out, err) + self.assertEqual((out.strip() == expected), equal) + + def test_utf8(self): + """Ensure that --utf8 adds at least some expected utf-8, and that it + isn't there without --utf8.""" + result, utf8, err = self.runsubcmd("visualize", "ntdsconn", + '-H', self.dburl, + '--color=no', '-S', '--utf8') + self.assertCmdSuccess(result, utf8, err) + + result, ascii, err = self.runsubcmd("visualize", "ntdsconn", + '-H', self.dburl, + '--color=no', '-S') + self.assertCmdSuccess(result, ascii, err) + for c in ('│', '─', '╭'): + self.assertTrue(c in utf8, 'UTF8 should contain %s' % c) + self.assertTrue(c not in ascii, 'ASCII should not contain %s' % c) + + def test_forced_local_dsa(self): + # the forced_local_dsa shouldn't make any difference, except + # for the title line. + result, target, err = self.runsubcmd("visualize", "ntdsconn", + '-H', self.dburl, + '--color=no', '-S') + self.assertCmdSuccess(result, target, err) + files = [] + target = target.strip().split('\n', 1)[1] + for cn, site in MULTISITE_LDIF_DSAS: + dsa = DN_TEMPLATE % (cn, site) + samdb, dbfile = samdb_from_ldif(MULTISITE_LDIF, + self.tempdir, + self.lp, dsa, + tag=cn) + + result, out, err = self.runsubcmd("visualize", "ntdsconn", + '-H', 'tdb://' + dbfile, + '--color=no', '-S') + self.assertCmdSuccess(result, out, err) + # Separate out the title line, which will differ in the DN. + title, body = out.strip().split('\n', 1) + self.assertStringsEqual(target, body) + self.assertIn(cn, title) + files.append(dbfile) + self.remove_files(*files) + + def test_short_names(self): + """Ensure the colour ones are the same as the monochrome ones EXCEPT + for the colours, of which the monochrome one should know nothing""" + result, short, err = self.runsubcmd("visualize", "ntdsconn", + '-H', self.dburl, + '--color=no', '-S', '--no-key') + self.assertCmdSuccess(result, short, err) + result, long, err = self.runsubcmd("visualize", "ntdsconn", + '-H', self.dburl, + '--color=no', '--no-key') + self.assertCmdSuccess(result, long, err) + + lines = short.split('\n') + replacements = [] + key_lines = [''] + short_without_key = [] + for line in lines: + m = re.match(r"'(.{1,2})' stands for '(.+)'", line) + if m: + a, b = m.groups() + replacements.append((len(a), a, b)) + key_lines.append(line) + else: + short_without_key.append(line) + + short = '\n'.join(short_without_key) + # we need to replace longest strings first + replacements.sort(reverse=True) + short2long = short + # we don't want to shorten the DC name in the header line. + long_header, long2short = long.strip().split('\n', 1) + for _, a, b in replacements: + short2long = short2long.replace(a, b) + long2short = long2short.replace(b, a) + + long2short = '%s\n%s' % (long_header, long2short) + + # The white space is going to be all wacky, so lets squish it down + short2long = collapse_space(short2long) + long2short = collapse_space(long2short) + short = collapse_space(short) + long = collapse_space(long) + + self.assertStringsEqual(short2long, long, strip=True) + self.assertStringsEqual(short, long2short, strip=True) + + def test_disconnected_ldif_with_key(self): + """Test that the 'unconnected' ldif shows up and exactly matches the + expected output.""" + # This is not truly a disconnected graph because the + # vampre/local/promoted DCs are in there and they have + # relationships, and SERVER2 and SERVER3 for some reason refer + # to them. + + samdb, dbfile = samdb_from_ldif(UNCONNECTED_LDIF, + self.tempdir, + self.lp, tag='disconnected') + dburl = 'tdb://' + dbfile + result, output, err = self.runsubcmd("visualize", "ntdsconn", + '-H', dburl, + '--color=no', '-S') + self.remove_files(dbfile) + self.assertCmdSuccess(result, output, err) + self.assertStringsEqual(output, + EXPECTED_DISTANCE_GRAPH_WITH_KEY) + + def test_dot_ntdsconn(self): + """Graphviz NTDS Connection output""" + result, dot, err = self.runsubcmd("visualize", "ntdsconn", + '-H', self.dburl, + '--color=no', '-S', '--dot', + '--no-key') + self.assertCmdSuccess(result, dot, err) + self.assertStringsEqual(EXPECTED_DOT_MULTISITE_NO_KEY, dot) + + def test_dot_ntdsconn_disconnected(self): + """Graphviz NTDS Connection output from disconnected graph""" + samdb, dbfile = samdb_from_ldif(UNCONNECTED_LDIF, + self.tempdir, + self.lp, tag='disconnected') + + result, dot, err = self.runsubcmd("visualize", "ntdsconn", + '-H', 'tdb://' + dbfile, + '--color=no', '-S', '--dot', + '-o', '-') + self.assertCmdSuccess(result, dot, err) + self.remove_files(dbfile) + self.assertStringsEqual(EXPECTED_DOT_NTDSCONN_DISCONNECTED, dot, + strip=True) + + def test_dot_ntdsconn_disconnected_to_file(self): + """Graphviz NTDS Connection output into a file""" + samdb, dbfile = samdb_from_ldif(UNCONNECTED_LDIF, + self.tempdir, + self.lp, tag='disconnected') + + dot_file = os.path.join(self.tempdir, 'dotfile') + + result, dot, err = self.runsubcmd("visualize", "ntdsconn", + '-H', 'tdb://' + dbfile, + '--color=no', '-S', '--dot', + '-o', dot_file) + self.assertCmdSuccess(result, dot, err) + f = open(dot_file) + dot = f.read() + f.close() + self.assertStringsEqual(EXPECTED_DOT_NTDSCONN_DISCONNECTED, dot) + + self.remove_files(dbfile, dot_file) + + +EXPECTED_DOT_MULTISITE_NO_KEY = r"""/* generated by samba */ +digraph A_samba_tool_production { +label="NTDS Connections known to CN=WIN01,CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=ad,DC=samba,DC=example,DC=com"; +fontsize=10; + +node[fontname=Helvetica; fontsize=10]; + +"CN=NTDS Settings,\nCN=WIN01,\nCN=Servers,\nCN=Default-\nFirst-Site-Name,\n..."; +"CN=NTDS Settings,\nCN=WIN02,\nCN=Servers,\nCN=Site-2,\n..."; +"CN=NTDS Settings,\nCN=WIN03,\nCN=Servers,\nCN=Site-2,\n..."; +"CN=NTDS Settings,\nCN=WIN04,\nCN=Servers,\nCN=Site-2,\n..."; +"CN=NTDS Settings,\nCN=WIN05,\nCN=Servers,\nCN=Site-2,\n..."; +"CN=NTDS Settings,\nCN=WIN06,\nCN=Servers,\nCN=Site-3,\n..."; +"CN=NTDS Settings,\nCN=WIN07,\nCN=Servers,\nCN=Site-4,\n..."; +"CN=NTDS Settings,\nCN=WIN08,\nCN=Servers,\nCN=Site-4,\n..."; +"CN=NTDS Settings,\nCN=WIN09,\nCN=Servers,\nCN=Site-5,\n..."; +"CN=NTDS Settings,\nCN=WIN10,\nCN=Servers,\nCN=Site-5,\n..."; +"CN=NTDS Settings,\nCN=WIN01,\nCN=Servers,\nCN=Default-\nFirst-Site-Name,\n..." -> "CN=NTDS Settings,\nCN=WIN03,\nCN=Servers,\nCN=Site-2,\n..." [color="#000000", ]; +"CN=NTDS Settings,\nCN=WIN01,\nCN=Servers,\nCN=Default-\nFirst-Site-Name,\n..." -> "CN=NTDS Settings,\nCN=WIN06,\nCN=Servers,\nCN=Site-3,\n..." [color="#000000", ]; +"CN=NTDS Settings,\nCN=WIN01,\nCN=Servers,\nCN=Default-\nFirst-Site-Name,\n..." -> "CN=NTDS Settings,\nCN=WIN07,\nCN=Servers,\nCN=Site-4,\n..." [color="#000000", ]; +"CN=NTDS Settings,\nCN=WIN01,\nCN=Servers,\nCN=Default-\nFirst-Site-Name,\n..." -> "CN=NTDS Settings,\nCN=WIN08,\nCN=Servers,\nCN=Site-4,\n..." [color="#000000", ]; +"CN=NTDS Settings,\nCN=WIN01,\nCN=Servers,\nCN=Default-\nFirst-Site-Name,\n..." -> "CN=NTDS Settings,\nCN=WIN10,\nCN=Servers,\nCN=Site-5,\n..." [color="#000000", ]; +"CN=NTDS Settings,\nCN=WIN02,\nCN=Servers,\nCN=Site-2,\n..." -> "CN=NTDS Settings,\nCN=WIN04,\nCN=Servers,\nCN=Site-2,\n..." [color="#000000", ]; +"CN=NTDS Settings,\nCN=WIN02,\nCN=Servers,\nCN=Site-2,\n..." -> "CN=NTDS Settings,\nCN=WIN05,\nCN=Servers,\nCN=Site-2,\n..." [color="#000000", ]; +"CN=NTDS Settings,\nCN=WIN03,\nCN=Servers,\nCN=Site-2,\n..." -> "CN=NTDS Settings,\nCN=WIN04,\nCN=Servers,\nCN=Site-2,\n..." [color="#000000", ]; +"CN=NTDS Settings,\nCN=WIN03,\nCN=Servers,\nCN=Site-2,\n..." -> "CN=NTDS Settings,\nCN=WIN05,\nCN=Servers,\nCN=Site-2,\n..." [color="#000000", ]; +"CN=NTDS Settings,\nCN=WIN04,\nCN=Servers,\nCN=Site-2,\n..." -> "CN=NTDS Settings,\nCN=WIN01,\nCN=Servers,\nCN=Default-\nFirst-Site-Name,\n..." [color="#000000", ]; +"CN=NTDS Settings,\nCN=WIN04,\nCN=Servers,\nCN=Site-2,\n..." -> "CN=NTDS Settings,\nCN=WIN02,\nCN=Servers,\nCN=Site-2,\n..." [color="#000000", ]; +"CN=NTDS Settings,\nCN=WIN04,\nCN=Servers,\nCN=Site-2,\n..." -> "CN=NTDS Settings,\nCN=WIN03,\nCN=Servers,\nCN=Site-2,\n..." [color="#000000", ]; +"CN=NTDS Settings,\nCN=WIN05,\nCN=Servers,\nCN=Site-2,\n..." -> "CN=NTDS Settings,\nCN=WIN02,\nCN=Servers,\nCN=Site-2,\n..." [color="#000000", ]; +"CN=NTDS Settings,\nCN=WIN05,\nCN=Servers,\nCN=Site-2,\n..." -> "CN=NTDS Settings,\nCN=WIN03,\nCN=Servers,\nCN=Site-2,\n..." [color="#000000", ]; +"CN=NTDS Settings,\nCN=WIN07,\nCN=Servers,\nCN=Site-4,\n..." -> "CN=NTDS Settings,\nCN=WIN01,\nCN=Servers,\nCN=Default-\nFirst-Site-Name,\n..." [color="#000000", ]; +"CN=NTDS Settings,\nCN=WIN09,\nCN=Servers,\nCN=Site-5,\n..." -> "CN=NTDS Settings,\nCN=WIN10,\nCN=Servers,\nCN=Site-5,\n..." [color="#000000", ]; +"CN=NTDS Settings,\nCN=WIN10,\nCN=Servers,\nCN=Site-5,\n..." -> "CN=NTDS Settings,\nCN=WIN01,\nCN=Servers,\nCN=Default-\nFirst-Site-Name,\n..." [color="#000000", ]; +"CN=NTDS Settings,\nCN=WIN10,\nCN=Servers,\nCN=Site-5,\n..." -> "CN=NTDS Settings,\nCN=WIN09,\nCN=Servers,\nCN=Site-5,\n..." [color="#000000", ]; +} + +""" + + +EXPECTED_DOT_NTDSCONN_DISCONNECTED = r"""/* generated by samba */ +digraph A_samba_tool_production { +label="NTDS Connections known to CN=LOCALDC,CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=samba,DC=example,DC=com"; +fontsize=10; + +node[fontname=Helvetica; fontsize=10]; + +"CN=NTDS Settings,\nCN=CLIENT,\n..."; +"CN=NTDS Settings,\nCN=LOCALDC,\n..."; +"CN=NTDS Settings,\nCN=PROMOTEDVDC,\n..."; +"CN=NTDS Settings,\nCN=SERVER1,\n..."; +"CN=NTDS Settings,\nCN=SERVER2,\n..."; +"CN=NTDS Settings,\nCN=SERVER3,\n..."; +"CN=NTDS Settings,\nCN=SERVER4,\n..."; +"CN=NTDS Settings,\nCN=SERVER5,\n..."; +"CN=NTDS Settings,\nCN=LOCALDC,\n..." -> "CN=NTDS Settings,\nCN=PROMOTEDVDC,\n..." [color="#000000", ]; +"CN=NTDS Settings,\nCN=PROMOTEDVDC,\n..." -> "CN=NTDS Settings,\nCN=LOCALDC,\n..." [color="#000000", ]; +"CN=NTDS Settings,\nCN=SERVER2,\n..." -> "CN=NTDS Settings,\nCN=PROMOTEDVDC,\n..." [color="#000000", ]; +"CN=NTDS Settings,\nCN=SERVER3,\n..." -> "CN=NTDS Settings,\nCN=LOCALDC,\n..." [color="#000000", ]; +subgraph cluster_key { +label="Key"; +subgraph cluster_key_nodes { +label=""; +color = "invis"; + +} +subgraph cluster_key_edges { +label=""; +color = "invis"; +subgraph cluster_key_0_ { +key_0_e1[label=src; color="#000000"; group="key_0__g"] +key_0_e2[label=dest; color="#000000"; group="key_0__g"] +key_0_e1 -> key_0_e2 [constraint = false; color="#000000"] +key_0__label[shape=plaintext; style=solid; width=2.000000; label="NTDS Connection\r"] +} +{key_0__label} +} + +elision0[shape=plaintext; style=solid; label="\“...” means “CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=samba,DC=example,DC=com”\r"] + +} +"CN=NTDS Settings,\nCN=CLIENT,\n..." -> key_0__label [style=invis]; +"CN=NTDS Settings,\nCN=LOCALDC,\n..." -> key_0__label [style=invis]; +"CN=NTDS Settings,\nCN=PROMOTEDVDC,\n..." -> key_0__label [style=invis]; +"CN=NTDS Settings,\nCN=SERVER1,\n..." -> key_0__label [style=invis]; +"CN=NTDS Settings,\nCN=SERVER2,\n..." -> key_0__label [style=invis]; +"CN=NTDS Settings,\nCN=SERVER3,\n..." -> key_0__label [style=invis]; +"CN=NTDS Settings,\nCN=SERVER4,\n..." -> key_0__label [style=invis]; +"CN=NTDS Settings,\nCN=SERVER5,\n..." -> key_0__label [style=invis] +key_0__label -> elision0 [style=invis; weight=9] + +} +""" + +EXPECTED_DISTANCE_GRAPH_WITH_KEY = """ +NTDS Connections known to CN=LOCALDC,CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=samba,DC=example,DC=com + + destination + ,-------- *,CN=CLIENT+ + |,------- *,CN=LOCALDC+ + ||,------ *,CN=PROMOTEDVDC+ + |||,----- *,CN=SERVER1+ + ||||,---- *,CN=SERVER2+ + |||||,--- *,CN=SERVER3+ + ||||||,-- *,CN=SERVER4+ + source |||||||,- *,CN=SERVER5+ + *,CN=CLIENT+ 0------- + *,CN=LOCALDC+ -01----- +*,CN=PROMOTEDVDC+ -10----- + *,CN=SERVER1+ ---0---- + *,CN=SERVER2+ -21-0--- + *,CN=SERVER3+ -12--0-- + *,CN=SERVER4+ ------0- + *,CN=SERVER5+ -------0 + +'*' stands for 'CN=NTDS Settings' +'+' stands for ',CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=samba,DC=example,DC=com' + +Data can get from source to destination in the indicated number of steps. +0 means zero steps (it is the same DC) +1 means a direct link +2 means a transitive link involving two steps (i.e. one intermediate DC) +- means there is no connection, even through other DCs + +""" diff --git a/python/samba/tests/samba_tool/visualize_drs.py b/python/samba/tests/samba_tool/visualize_drs.py new file mode 100644 index 0000000..64b2cdb --- /dev/null +++ b/python/samba/tests/samba_tool/visualize_drs.py @@ -0,0 +1,636 @@ +# -*- coding: utf-8 -*- +# Originally based on tests for samba.kcc.ldif_import_export. +# Copyright (C) Andrew Bartlett 2015, 2018 +# +# by Douglas Bagnall <douglas.bagnall@catalyst.net.nz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# +"""Tests for samba-tool visualize using the vampire DC and promoted DC +environments. For most tests we assume we can't assert much about what +state they are in, so we mainly check for command failure, but for +others we try to grasp control of replication and make more specific +assertions. +""" + +import os +import re +import json +import random +import subprocess +from samba.tests.samba_tool.base import SambaToolCmdTest + +VERBOSE = False + +ENV_DSAS = { + 'promoted_dc': ['CN=PROMOTEDVDC,CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=samba,DC=example,DC=com', + 'CN=LOCALDC,CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=samba,DC=example,DC=com'], + 'vampire_dc': ['CN=LOCALDC,CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=samba,DC=example,DC=com', + 'CN=LOCALVAMPIREDC,CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=samba,DC=example,DC=com'], +} + +PARTITION_NAMES = [ + "DOMAIN", + "CONFIGURATION", + "SCHEMA", + "DNSDOMAIN", + "DNSFOREST", +] + +def adjust_cmd_for_py_version(parts): + if os.getenv("PYTHON", None): + parts.insert(0, os.environ["PYTHON"]) + return parts + +def set_auto_replication(dc, allow): + credstring = '-U%s%%%s' % (os.environ["USERNAME"], os.environ["PASSWORD"]) + on_or_off = '-' if allow else '+' + + for opt in ['DISABLE_INBOUND_REPL', + 'DISABLE_OUTBOUND_REPL']: + cmd = adjust_cmd_for_py_version(['bin/samba-tool', + 'drs', 'options', + credstring, dc, + "--dsa-option=%s%s" % (on_or_off, opt)]) + + subprocess.check_call(cmd) + + +def force_replication(src, dest, base): + credstring = '-U%s%%%s' % (os.environ["USERNAME"], os.environ["PASSWORD"]) + cmd = adjust_cmd_for_py_version(['bin/samba-tool', + 'drs', 'replicate', + dest, src, base, + credstring, + '--sync-forced']) + + subprocess.check_call(cmd) + + +def get_utf8_matrix(s): + # parse the graphical table *just* well enough for our tests + # decolourise first + s = re.sub("\033" r"\[[^m]+m", '', s) + lines = s.split('\n') + # matrix rows have '·' on the diagonal + rows = [x.strip().replace('·', '0') for x in lines if '·' in x] + names = [] + values = [] + for r in rows: + parts = r.rsplit(None, len(rows)) + k, v = parts[0], parts[1:] + # we want the FOO in 'CN=FOO+' or 'CN=FOO,CN=x,DC=...' + k = re.match(r'cn=([^+,]+)', k.lower()).group(1) + names.append(k) + if len(v) == 1: # this is a single-digit matrix, no spaces + v = list(v[0]) + values.append([int(x) if x.isdigit() else 1e999 for x in v]) + + d = {} + for n1, row in zip(names, values): + d[n1] = {} + for n2, v in zip(names, row): + d[n1][n2] = v + + return d + + +class SambaToolVisualizeDrsTest(SambaToolCmdTest): + + def test_ntdsconn(self): + server = "ldap://%s" % os.environ["SERVER"] + creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"]) + (result, out, err) = self.runsubcmd("visualize", "ntdsconn", + '-H', server, + '-U', creds, + '--color=no', '-S') + self.assertCmdSuccess(result, out, err) + + def test_ntdsconn_remote(self): + server = "ldap://%s" % os.environ["SERVER"] + creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"]) + (result, out, err) = self.runsubcmd("visualize", "ntdsconn", + '-H', server, + '-U', creds, + '--color=no', '-S', '-r') + self.assertCmdSuccess(result, out, err) + + def test_reps(self): + server = "ldap://%s" % os.environ["SERVER"] + creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"]) + (result, out, err) = self.runsubcmd("visualize", "reps", + '-H', server, + '-U', creds, + '--color=no', '-S') + self.assertCmdSuccess(result, out, err) + + def test_uptodateness_all_partitions(self): + creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"]) + dc1 = os.environ["SERVER"] + dc2 = os.environ["DC_SERVER"] + # We will check that the visualisation works for the two + # stopped DCs, but we can't make assertions that the output + # will be the same because there may be replication between + # the two calls. Stopping the replication on these ones is not + # enough because there are other DCs about. + (result, out, err) = self.runsubcmd("visualize", "uptodateness", + "-r", + '-H', "ldap://%s" % dc1, + '-U', creds, + '--color=no', '-S') + self.assertCmdSuccess(result, out, err) + + (result, out, err) = self.runsubcmd("visualize", "uptodateness", + "-r", + '-H', "ldap://%s" % dc2, + '-U', creds, + '--color=no', '-S') + self.assertCmdSuccess(result, out, err) + + def test_uptodateness_partitions(self): + creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"]) + dc1 = os.environ["SERVER"] + for part in PARTITION_NAMES: + (result, out, err) = self.runsubcmd("visualize", "uptodateness", + "-r", + '-H', "ldap://%s" % dc1, + '-U', creds, + '--color=no', '-S', + '--partition', part) + self.assertCmdSuccess(result, out, err) + + def test_drs_uptodateness(self): + """ + Test cmd `drs uptodateness` + + It should print info like this: + + DNSDOMAIN failure: 4 median: 1.5 maximum: 2 + SCHEMA failure: 4 median: 220.0 maximum: 439 + DOMAIN failure: 1 median: 25 maximum: 25 + CONFIGURATION failure: 1 median: 25 maximum: 25 + DNSFOREST failure: 4 median: 1.5 maximum: 2 + + """ + creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"]) + dc1 = os.environ["SERVER"] + dc2 = os.environ["DC_SERVER"] + for dc in [dc1, dc2]: + (result, out, err) = self.runsubcmd("drs", "uptodateness", + '-H', "ldap://%s" % dc, + '-U', creds) + self.assertCmdSuccess(result, out, err) + # each partition name should be in output + for part_name in PARTITION_NAMES: + self.assertIn(part_name, out, msg=out) + + for line in out.splitlines(): + # check keyword in output + for attr in ['maximum', 'median', 'failure']: + self.assertIn(attr, line) + + def test_drs_uptodateness_partition(self): + """ + Test cmd `drs uptodateness --partition DOMAIN` + + It should print info like this: + + DOMAIN failure: 1 median: 25 maximum: 25 + + """ + creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"]) + dc1 = os.environ["SERVER"] + dc2 = os.environ["DC_SERVER"] + for dc in [dc1, dc2]: + (result, out, err) = self.runsubcmd("drs", "uptodateness", + '-H', "ldap://%s" % dc, + '-U', creds, + '--partition', 'DOMAIN') + self.assertCmdSuccess(result, out, err) + + lines = out.splitlines() + self.assertEqual(len(lines), 1) + + line = lines[0] + self.assertTrue(line.startswith('DOMAIN')) + + def test_drs_uptodateness_json(self): + """ + Test cmd `drs uptodateness --json` + + Example output: + + { + "DNSDOMAIN": { + "failure": 0, + "median": 0.0, + "maximum": 0 + }, + ... + "SCHEMA": { + "failure": 0, + "median": 0.0, + "maximum": 0 + } + } + """ + creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"]) + dc1 = os.environ["SERVER"] + dc2 = os.environ["DC_SERVER"] + for dc in [dc1, dc2]: + (result, out, err) = self.runsubcmd("drs", "uptodateness", + '-H', "ldap://%s" % dc, + '-U', creds, + '--json') + self.assertCmdSuccess(result, out, err) + # should be json format + obj = json.loads(out) + # each partition name should be in json obj + for part_name in PARTITION_NAMES: + self.assertIn(part_name, obj) + summary_obj = obj[part_name] + for attr in ['maximum', 'median', 'failure']: + self.assertIn(attr, summary_obj) + + def test_drs_uptodateness_json_median(self): + """ + Test cmd `drs uptodateness --json --median` + + drs uptodateness --json --median + + { + "DNSDOMAIN": { + "median": 0.0 + }, + ... + "SCHEMA": { + "median": 0.0 + } + } + """ + creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"]) + dc1 = os.environ["SERVER"] + dc2 = os.environ["DC_SERVER"] + for dc in [dc1, dc2]: + (result, out, err) = self.runsubcmd("drs", "uptodateness", + '-H', "ldap://%s" % dc, + '-U', creds, + '--json', '--median') + self.assertCmdSuccess(result, out, err) + # should be json format + obj = json.loads(out) + # each partition name should be in json obj + for part_name in PARTITION_NAMES: + self.assertIn(part_name, obj) + summary_obj = obj[part_name] + self.assertIn('median', summary_obj) + self.assertNotIn('maximum', summary_obj) + self.assertNotIn('failure', summary_obj) + + def assert_matrix_validity(self, matrix, dcs=()): + for dc in dcs: + self.assertIn(dc, matrix) + for k, row in matrix.items(): + self.assertEqual(row[k], 0) + + def test_uptodateness_stop_replication_domain(self): + creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"]) + dc1 = os.environ["SERVER"] + dc2 = os.environ["DC_SERVER"] + self.addCleanup(set_auto_replication, dc1, True) + self.addCleanup(set_auto_replication, dc2, True) + + def display(heading, out): + if VERBOSE: + print("========", heading, "=========") + print(out) + + samdb1 = self.getSamDB("-H", "ldap://%s" % dc1, "-U", creds) + samdb2 = self.getSamDB("-H", "ldap://%s" % dc2, "-U", creds) + + domain_dn = samdb1.domain_dn() + self.assertTrue(domain_dn == samdb2.domain_dn(), + "We expected the same domain_dn across DCs") + + ou1 = "OU=dc1.%x,%s" % (random.randrange(1 << 64), domain_dn) + ou2 = "OU=dc2.%x,%s" % (random.randrange(1 << 64), domain_dn) + samdb1.add({ + "dn": ou1, + "objectclass": "organizationalUnit" + }) + samdb2.add({ + "dn": ou2, + "objectclass": "organizationalUnit" + }) + + set_auto_replication(dc1, False) + (result, out, err) = self.runsubcmd("visualize", "uptodateness", + "-r", + '-H', "ldap://%s" % dc1, + '-U', creds, + '--color=yes', + '--utf8', '-S', + '--partition', 'DOMAIN') + display("dc1 replication is now off", out) + self.assertCmdSuccess(result, out, err) + matrix = get_utf8_matrix(out) + self.assert_matrix_validity(matrix, [dc1, dc2]) + + force_replication(dc2, dc1, domain_dn) + (result, out, err) = self.runsubcmd("visualize", "uptodateness", + "-r", + '-H', "ldap://%s" % dc1, + '-U', creds, + '--color=yes', + '--utf8', '-S', + '--partition', 'DOMAIN') + display("forced replication %s -> %s" % (dc2, dc1), out) + self.assertCmdSuccess(result, out, err) + matrix = get_utf8_matrix(out) + self.assert_matrix_validity(matrix, [dc1, dc2]) + self.assertEqual(matrix[dc1][dc2], 0) + + force_replication(dc1, dc2, domain_dn) + (result, out, err) = self.runsubcmd("visualize", "uptodateness", + "-r", + '-H', "ldap://%s" % dc1, + '-U', creds, + '--color=yes', + '--utf8', '-S', + '--partition', 'DOMAIN') + display("forced replication %s -> %s" % (dc2, dc1), out) + self.assertCmdSuccess(result, out, err) + matrix = get_utf8_matrix(out) + self.assert_matrix_validity(matrix, [dc1, dc2]) + self.assertEqual(matrix[dc2][dc1], 0) + + dn1 = 'cn=u1.%%d,%s' % (ou1) + dn2 = 'cn=u2.%%d,%s' % (ou2) + + for i in range(10): + samdb1.add({ + "dn": dn1 % i, + "objectclass": "user" + }) + + (result, out, err) = self.runsubcmd("visualize", "uptodateness", + "-r", + '-H', "ldap://%s" % dc1, + '-U', creds, + '--color=yes', + '--utf8', '-S', + '--partition', 'DOMAIN') + display("added 10 users on %s" % dc1, out) + self.assertCmdSuccess(result, out, err) + matrix = get_utf8_matrix(out) + self.assert_matrix_validity(matrix, [dc1, dc2]) + # dc2's view of dc1 should now be 10 changes out of date + self.assertEqual(matrix[dc2][dc1], 10) + + for i in range(10): + samdb2.add({ + "dn": dn2 % i, + "objectclass": "user" + }) + + (result, out, err) = self.runsubcmd("visualize", "uptodateness", + "-r", + '-H', "ldap://%s" % dc1, + '-U', creds, + '--color=yes', + '--utf8', '-S', + '--partition', 'DOMAIN') + display("added 10 users on %s" % dc2, out) + self.assertCmdSuccess(result, out, err) + matrix = get_utf8_matrix(out) + self.assert_matrix_validity(matrix, [dc1, dc2]) + # dc1's view of dc2 is probably 11 changes out of date + self.assertGreaterEqual(matrix[dc1][dc2], 10) + + for i in range(10, 101): + samdb1.add({ + "dn": dn1 % i, + "objectclass": "user" + }) + samdb2.add({ + "dn": dn2 % i, + "objectclass": "user" + }) + + (result, out, err) = self.runsubcmd("visualize", "uptodateness", + "-r", + '-H', "ldap://%s" % dc1, + '-U', creds, + '--color=yes', + '--utf8', '-S', + '--partition', 'DOMAIN') + display("added 91 users on both", out) + self.assertCmdSuccess(result, out, err) + matrix = get_utf8_matrix(out) + self.assert_matrix_validity(matrix, [dc1, dc2]) + # the difference here should be ~101. + self.assertGreaterEqual(matrix[dc1][dc2], 100) + self.assertGreaterEqual(matrix[dc2][dc1], 100) + + (result, out, err) = self.runsubcmd("visualize", "uptodateness", + "-r", + '-H', "ldap://%s" % dc1, + '-U', creds, + '--color=yes', + '--utf8', '-S', + '--partition', 'DOMAIN', + '--max-digits', '2') + display("with --max-digits 2", out) + self.assertCmdSuccess(result, out, err) + matrix = get_utf8_matrix(out) + self.assert_matrix_validity(matrix, [dc1, dc2]) + # visualising with 2 digits mean these overflow into infinity + self.assertGreaterEqual(matrix[dc1][dc2], 1e99) + self.assertGreaterEqual(matrix[dc2][dc1], 1e99) + + (result, out, err) = self.runsubcmd("visualize", "uptodateness", + "-r", + '-H', "ldap://%s" % dc1, + '-U', creds, + '--color=yes', + '--utf8', '-S', + '--partition', 'DOMAIN', + '--max-digits', '1') + display("with --max-digits 1", out) + self.assertCmdSuccess(result, out, err) + matrix = get_utf8_matrix(out) + self.assert_matrix_validity(matrix, [dc1, dc2]) + # visualising with 1 digit means these overflow into infinity + self.assertGreaterEqual(matrix[dc1][dc2], 1e99) + self.assertGreaterEqual(matrix[dc2][dc1], 1e99) + + force_replication(dc2, dc1, samdb1.domain_dn()) + (result, out, err) = self.runsubcmd("visualize", "uptodateness", + "-r", + '-H', "ldap://%s" % dc1, + '-U', creds, + '--color=yes', + '--utf8', '-S', + '--partition', 'DOMAIN') + + display("forced replication %s -> %s" % (dc2, dc1), out) + self.assertCmdSuccess(result, out, err) + matrix = get_utf8_matrix(out) + self.assert_matrix_validity(matrix, [dc1, dc2]) + self.assertEqual(matrix[dc1][dc2], 0) + + force_replication(dc1, dc2, samdb2.domain_dn()) + (result, out, err) = self.runsubcmd("visualize", "uptodateness", + "-r", + '-H', "ldap://%s" % dc1, + '-U', creds, + '--color=yes', + '--utf8', '-S', + '--partition', 'DOMAIN') + + display("forced replication %s -> %s" % (dc1, dc2), out) + self.assertCmdSuccess(result, out, err) + matrix = get_utf8_matrix(out) + self.assert_matrix_validity(matrix, [dc1, dc2]) + self.assertEqual(matrix[dc2][dc1], 0) + + samdb1.delete(ou1, ['tree_delete:1']) + samdb2.delete(ou2, ['tree_delete:1']) + + (result, out, err) = self.runsubcmd("visualize", "uptodateness", + "-r", + '-H', "ldap://%s" % dc1, + '-U', creds, + '--color=yes', + '--utf8', '-S', + '--partition', 'DOMAIN') + display("tree delete both ous on %s" % (dc1,), out) + self.assertCmdSuccess(result, out, err) + matrix = get_utf8_matrix(out) + self.assert_matrix_validity(matrix, [dc1, dc2]) + self.assertGreaterEqual(matrix[dc1][dc2], 100) + self.assertGreaterEqual(matrix[dc2][dc1], 100) + + set_auto_replication(dc1, True) + (result, out, err) = self.runsubcmd("visualize", "uptodateness", + "-r", + '-H', "ldap://%s" % dc1, + '-U', creds, + '--color=yes', + '--utf8', '-S', + '--partition', 'DOMAIN') + display("replication is now on", out) + self.assertCmdSuccess(result, out, err) + matrix = get_utf8_matrix(out) + self.assert_matrix_validity(matrix, [dc1, dc2]) + # We can't assert actual values after this because + # auto-replication is on and things will change underneath us. + + (result, out, err) = self.runsubcmd("visualize", "uptodateness", + "-r", + '-H', "ldap://%s" % dc2, + '-U', creds, + '--color=yes', + '--utf8', '-S', + '--partition', 'DOMAIN') + + display("%s's view" % dc2, out) + self.assertCmdSuccess(result, out, err) + matrix = get_utf8_matrix(out) + self.assert_matrix_validity(matrix, [dc1, dc2]) + + force_replication(dc1, dc2, samdb2.domain_dn()) + (result, out, err) = self.runsubcmd("visualize", "uptodateness", + "-r", + '-H', "ldap://%s" % dc1, + '-U', creds, + '--color=yes', + '--utf8', '-S', + '--partition', 'DOMAIN') + + display("forced replication %s -> %s" % (dc1, dc2), out) + self.assertCmdSuccess(result, out, err) + matrix = get_utf8_matrix(out) + self.assert_matrix_validity(matrix, [dc1, dc2]) + + force_replication(dc2, dc1, samdb2.domain_dn()) + (result, out, err) = self.runsubcmd("visualize", "uptodateness", + "-r", + '-H', "ldap://%s" % dc1, + '-U', creds, + '--color=yes', + '--utf8', '-S', + '--partition', 'DOMAIN') + display("forced replication %s -> %s" % (dc2, dc1), out) + self.assertCmdSuccess(result, out, err) + matrix = get_utf8_matrix(out) + self.assert_matrix_validity(matrix, [dc1, dc2]) + + (result, out, err) = self.runsubcmd("visualize", "uptodateness", + "-r", + '-H', "ldap://%s" % dc2, + '-U', creds, + '--color=yes', + '--utf8', '-S', + '--partition', 'DOMAIN') + display("%s's view" % dc2, out) + + self.assertCmdSuccess(result, out, err) + matrix = get_utf8_matrix(out) + self.assert_matrix_validity(matrix, [dc1, dc2]) + + def test_reps_remote(self): + server = "ldap://%s" % os.environ["SERVER"] + creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"]) + (result, out, err) = self.runsubcmd("visualize", "reps", + '-H', server, + '-U', creds, + '--color=no', '-S', '-r') + self.assertCmdSuccess(result, out, err) + + def test_ntdsconn_dot(self): + server = "ldap://%s" % os.environ["SERVER"] + creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"]) + (result, out, err) = self.runsubcmd("visualize", "ntdsconn", + '-H', server, + '-U', creds, '--dot', + '--color=no', '-S') + self.assertCmdSuccess(result, out, err) + + def test_ntdsconn_remote_dot(self): + server = "ldap://%s" % os.environ["SERVER"] + creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"]) + (result, out, err) = self.runsubcmd("visualize", "ntdsconn", + '-H', server, + '-U', creds, '--dot', + '--color=no', '-S', '-r') + self.assertCmdSuccess(result, out, err) + + def test_reps_dot(self): + server = "ldap://%s" % os.environ["SERVER"] + creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"]) + (result, out, err) = self.runsubcmd("visualize", "reps", + '-H', server, + '-U', creds, '--dot', + '--color=no', '-S') + self.assertCmdSuccess(result, out, err) + + def test_reps_remote_dot(self): + server = "ldap://%s" % os.environ["SERVER"] + creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"]) + (result, out, err) = self.runsubcmd("visualize", "reps", + '-H', server, + '-U', creds, '--dot', + '--color=no', '-S', '-r') + self.assertCmdSuccess(result, out, err) |