From 093b2a5073b481010ba1c07b64157f60b668c306 Mon Sep 17 00:00:00 2001 From: Ivan Dimov <78815270+idimov-keeper@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:20:41 -0500 Subject: [PATCH] Fix Windows icacls grant for KC config when COMPUTERNAME equals USERNAME Use DOMAIN\username for icacls /grant instead of bare os.getlogin(), which fails when the machine name and username are the same string. --- keepercommander/utils.py | 17 +++++- unit-tests/test_windows_file_permissions.py | 68 +++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 unit-tests/test_windows_file_permissions.py diff --git a/keepercommander/utils.py b/keepercommander/utils.py index 3a6f84119..41d3f3829 100644 --- a/keepercommander/utils.py +++ b/keepercommander/utils.py @@ -90,6 +90,19 @@ def generate_aes_key(): # type: () -> bytes return crypto.get_random_bytes(32) +def _windows_icacls_principal(): # type: () -> str + """Return a DOMAIN\\username principal suitable for icacls /grant on Windows.""" + username = os.environ.get('USERNAME', '') + if not username: + username = os.getlogin() + if '\\' in username: + return username + domain = os.environ.get('USERDOMAIN') or os.environ.get('COMPUTERNAME', '') + if domain: + return f'{domain}\\{username}' + return username + + def set_file_permissions(file_path): # type: (str) -> None """ Set secure file permissions (600) for configuration files containing sensitive data. @@ -111,10 +124,10 @@ def set_file_permissions(file_path): # type: (str) -> None os.chmod(file_path, stat.S_IRUSR | stat.S_IWUSR) logging.debug(f'Set secure permissions (600) for file: {file_path}') else: - username = os.getlogin() + principal = _windows_icacls_principal() subprocess.run(["icacls", file_path, "/inheritance:r"], check=True, capture_output=True) subprocess.run(["icacls", file_path, "/remove", "NT AUTHORITY\\SYSTEM", "BUILTIN\\Administrators"], check=False, capture_output=True) - subprocess.run(["icacls", file_path, "/grant", f"{username}:RW"], check=True, capture_output=True) + subprocess.run(["icacls", file_path, "/grant", f"{principal}:RW"], check=True, capture_output=True) logging.debug(f'Set secure permissions (owner RW only) for Windows file: {file_path}') except Exception: logging.warning(f'Failed to set file permissions for {file_path}') diff --git a/unit-tests/test_windows_file_permissions.py b/unit-tests/test_windows_file_permissions.py new file mode 100644 index 000000000..c058da26e --- /dev/null +++ b/unit-tests/test_windows_file_permissions.py @@ -0,0 +1,68 @@ +import os +import tempfile +from unittest import TestCase, mock + +from keepercommander import utils + + +class TestWindowsIcaclsPrincipal(TestCase): + def test_userdomain_and_username(self): + with mock.patch.dict(os.environ, {'USERNAME': 'ivan', 'USERDOMAIN': 'IVAN'}, clear=False): + self.assertEqual(utils._windows_icacls_principal(), 'IVAN\\ivan') + + def test_domain_user(self): + with mock.patch.dict(os.environ, {'USERNAME': 'jdoe', 'USERDOMAIN': 'CORP'}, clear=False): + self.assertEqual(utils._windows_icacls_principal(), 'CORP\\jdoe') + + def test_falls_back_to_computername(self): + env = os.environ.copy() + env.pop('USERDOMAIN', None) + with mock.patch.dict(os.environ, env, clear=True): + os.environ['USERNAME'] = 'bob' + os.environ['COMPUTERNAME'] = 'MYPC' + self.assertEqual(utils._windows_icacls_principal(), 'MYPC\\bob') + + def test_already_qualified_username(self): + with mock.patch.dict(os.environ, {'USERNAME': 'CORP\\jdoe', 'USERDOMAIN': 'CORP'}, clear=False): + self.assertEqual(utils._windows_icacls_principal(), 'CORP\\jdoe') + + def test_falls_back_to_getlogin(self): + with mock.patch.dict(os.environ, {}, clear=True): + with mock.patch('os.getlogin', return_value='localuser'): + with mock.patch.dict(os.environ, {'COMPUTERNAME': 'MYPC'}, clear=False): + self.assertEqual(utils._windows_icacls_principal(), 'MYPC\\localuser') + + +class TestSetFilePermissionsWindows(TestCase): + def _grant_principal(self, mock_run): + for call in mock_run.call_args_list: + args = call.args[0] + if '/grant' in args: + return args[args.index('/grant') + 1] + self.fail('icacls /grant was not called') + + @mock.patch('subprocess.run') + @mock.patch('platform.system', return_value='Windows') + @mock.patch('os.path.islink', return_value=False) + def test_grant_uses_qualified_principal_when_names_collide(self, _islink, _system, mock_run): + with tempfile.NamedTemporaryFile(delete=False) as tmp: + path = tmp.name + try: + with mock.patch.dict(os.environ, {'USERNAME': 'ivan', 'USERDOMAIN': 'IVAN'}, clear=False): + utils.set_file_permissions(path) + self.assertEqual(self._grant_principal(mock_run), 'IVAN\\ivan:RW') + finally: + os.unlink(path) + + @mock.patch('subprocess.run') + @mock.patch('platform.system', return_value='Windows') + @mock.patch('os.path.islink', return_value=False) + def test_grant_uses_domain_principal(self, _islink, _system, mock_run): + with tempfile.NamedTemporaryFile(delete=False) as tmp: + path = tmp.name + try: + with mock.patch.dict(os.environ, {'USERNAME': 'jdoe', 'USERDOMAIN': 'CORP'}, clear=False): + utils.set_file_permissions(path) + self.assertEqual(self._grant_principal(mock_run), 'CORP\\jdoe:RW') + finally: + os.unlink(path)