diff --git a/changelogs/fragments/49262-user.yml b/changelogs/fragments/49262-user.yml new file mode 100644 index 0000000000..21d9cf9deb --- /dev/null +++ b/changelogs/fragments/49262-user.yml @@ -0,0 +1,6 @@ +bugfixes: +- "user - fixed the fallback mechanism for creating a user home directory when + the directory isn't created with `useradd` command. Home directory will now + have a correct mode and it won't be created in a rare situation when a local + user is being deleted but it exists on a central user system + (https://github.com/ansible/ansible/pull/49262)." diff --git a/lib/ansible/modules/system/user.py b/lib/ansible/modules/system/user.py index d7d52e7a80..03a7ce5be6 100644 --- a/lib/ansible/modules/system/user.py +++ b/lib/ansible/modules/system/user.py @@ -421,6 +421,7 @@ class User(object): distribution = None SHADOWFILE = '/etc/shadow' SHADOWFILE_EXPIRE_INDEX = 7 + LOGIN_DEFS = '/etc/login.defs' DATE_FORMAT = '%Y-%m-%d' def __new__(cls, *args, **kwargs): @@ -854,10 +855,11 @@ class User(object): elif self.SHADOWFILE: # Read shadow file for user's encrypted password string if os.path.exists(self.SHADOWFILE) and os.access(self.SHADOWFILE, os.R_OK): - for line in open(self.SHADOWFILE).readlines(): - if line.startswith('%s:' % self.name): - passwd = line.split(':')[1] - expires = line.split(':')[self.SHADOWFILE_EXPIRE_INDEX] or -1 + with open(self.SHADOWFILE, 'r') as f: + for line in f: + if line.startswith('%s:' % self.name): + passwd = line.split(':')[1] + expires = line.split(':')[self.SHADOWFILE_EXPIRE_INDEX] or -1 return passwd, expires def get_ssh_key_path(self): @@ -970,9 +972,8 @@ class User(object): def get_ssh_public_key(self): ssh_public_key_file = '%s.pub' % self.get_ssh_key_path() try: - f = open(ssh_public_key_file) - ssh_public_key = f.read().strip() - f.close() + with open(ssh_public_key_file, 'r') as f: + ssh_public_key = f.read().strip() except IOError: return None return ssh_public_key @@ -1001,11 +1002,23 @@ class User(object): shutil.copytree(skeleton, path, symlinks=True) except OSError as e: self.module.exit_json(failed=True, msg="%s" % to_native(e)) - else: - try: - os.makedirs(path) - except OSError as e: - self.module.exit_json(failed=True, msg="%s" % to_native(e)) + else: + try: + os.makedirs(path) + except OSError as e: + self.module.exit_json(failed=True, msg="%s" % to_native(e)) + # get umask from /etc/login.defs and set correct home mode + if os.path.exists(self.LOGIN_DEFS): + with open(self.LOGIN_DEFS, 'r') as f: + for line in f: + m = re.match(r'^UMASK\s+(\d+)$', line) + if m: + umask = int(m.group(1), 8) + mode = 0o777 & ~umask + try: + os.chmod(path, mode) + except OSError as e: + self.module.exit_json(failed=True, msg="%s" % to_native(e)) def chown_homedir(self, uid, gid, path): try: @@ -1173,9 +1186,10 @@ class FreeBsdUser(User): # find current login class user_login_class = None if os.path.exists(self.SHADOWFILE) and os.access(self.SHADOWFILE, os.R_OK): - for line in open(self.SHADOWFILE).readlines(): - if line.startswith('%s:' % self.name): - user_login_class = line.split(':')[4] + with open(self.SHADOWFILE, 'r') as f: + for line in f: + if line.startswith('%s:' % self.name): + user_login_class = line.split(':')[4] # act only if login_class change if self.login_class != user_login_class: @@ -1632,20 +1646,21 @@ class SunOS(User): minweeks = '' maxweeks = '' warnweeks = '' - for line in open("/etc/default/passwd", 'r'): - line = line.strip() - if (line.startswith('#') or line == ''): - continue - m = re.match(r'^([^#]*)#(.*)$', line) - if m: # The line contains a hash / comment - line = m.group(1) - key, value = line.split('=') - if key == "MINWEEKS": - minweeks = value.rstrip('\n') - elif key == "MAXWEEKS": - maxweeks = value.rstrip('\n') - elif key == "WARNWEEKS": - warnweeks = value.rstrip('\n') + with open("/etc/default/passwd", 'r') as f: + for line in f: + line = line.strip() + if (line.startswith('#') or line == ''): + continue + m = re.match(r'^([^#]*)#(.*)$', line) + if m: # The line contains a hash / comment + line = m.group(1) + key, value = line.split('=') + if key == "MINWEEKS": + minweeks = value.rstrip('\n') + elif key == "MAXWEEKS": + maxweeks = value.rstrip('\n') + elif key == "WARNWEEKS": + warnweeks = value.rstrip('\n') except Exception as err: self.module.fail_json(msg="failed to read /etc/default/passwd: %s" % to_native(err)) @@ -1724,35 +1739,37 @@ class SunOS(User): minweeks, maxweeks, warnweeks = self.get_password_defaults() try: lines = [] - for line in open(self.SHADOWFILE, 'rb').readlines(): - line = to_native(line, errors='surrogate_or_strict') - fields = line.strip().split(':') - if not fields[0] == self.name: - lines.append(line) - continue - fields[1] = self.password - fields[2] = str(int(time.time() // 86400)) - if minweeks: - try: - fields[3] = str(int(minweeks) * 7) - except ValueError: - # mirror solaris, which allows for any value in this field, and ignores anything that is not an int. - pass - if maxweeks: - try: - fields[4] = str(int(maxweeks) * 7) - except ValueError: - # mirror solaris, which allows for any value in this field, and ignores anything that is not an int. - pass - if warnweeks: - try: - fields[5] = str(int(warnweeks) * 7) - except ValueError: - # mirror solaris, which allows for any value in this field, and ignores anything that is not an int. - pass - line = ':'.join(fields) - lines.append('%s\n' % line) - open(self.SHADOWFILE, 'w+').writelines(lines) + with open(self.SHADOWFILE, 'rb') as f: + for line in f: + line = to_native(line, errors='surrogate_or_strict') + fields = line.strip().split(':') + if not fields[0] == self.name: + lines.append(line) + continue + fields[1] = self.password + fields[2] = str(int(time.time() // 86400)) + if minweeks: + try: + fields[3] = str(int(minweeks) * 7) + except ValueError: + # mirror solaris, which allows for any value in this field, and ignores anything that is not an int. + pass + if maxweeks: + try: + fields[4] = str(int(maxweeks) * 7) + except ValueError: + # mirror solaris, which allows for any value in this field, and ignores anything that is not an int. + pass + if warnweeks: + try: + fields[5] = str(int(warnweeks) * 7) + except ValueError: + # mirror solaris, which allows for any value in this field, and ignores anything that is not an int. + pass + line = ':'.join(fields) + lines.append('%s\n' % line) + with open(self.SHADOWFILE, 'w+') as f: + f.writelines(lines) except Exception as err: self.module.fail_json(msg="failed to update users password: %s" % to_native(err)) @@ -1843,23 +1860,25 @@ class SunOS(User): minweeks, maxweeks, warnweeks = self.get_password_defaults() try: lines = [] - for line in open(self.SHADOWFILE, 'rb').readlines(): - line = to_native(line, errors='surrogate_or_strict') - fields = line.strip().split(':') - if not fields[0] == self.name: - lines.append(line) - continue - fields[1] = self.password - fields[2] = str(int(time.time() // 86400)) - if minweeks: - fields[3] = str(int(minweeks) * 7) - if maxweeks: - fields[4] = str(int(maxweeks) * 7) - if warnweeks: - fields[5] = str(int(warnweeks) * 7) - line = ':'.join(fields) - lines.append('%s\n' % line) - open(self.SHADOWFILE, 'w+').writelines(lines) + with open(self.SHADOWFILE, 'rb') as f: + for line in f: + line = to_native(line, errors='surrogate_or_strict') + fields = line.strip().split(':') + if not fields[0] == self.name: + lines.append(line) + continue + fields[1] = self.password + fields[2] = str(int(time.time() // 86400)) + if minweeks: + fields[3] = str(int(minweeks) * 7) + if maxweeks: + fields[4] = str(int(maxweeks) * 7) + if warnweeks: + fields[5] = str(int(warnweeks) * 7) + line = ':'.join(fields) + lines.append('%s\n' % line) + with open(self.SHADOWFILE, 'w+'): + f.writelines(lines) rc = 0 except Exception as err: self.module.fail_json(msg="failed to update users password: %s" % to_native(err)) @@ -2638,7 +2657,7 @@ def main(): if err: result['stderr'] = err - if user.user_exists(): + if user.user_exists() and user.state == 'present': info = user.user_info() if info is False: result['msg'] = "failed to look up user name: %s" % user.name diff --git a/test/integration/targets/user/tasks/main.yml b/test/integration/targets/user/tasks/main.yml index c9c4fb0dd6..bdaad16bf5 100644 --- a/test/integration/targets/user/tasks/main.yml +++ b/test/integration/targets/user/tasks/main.yml @@ -229,6 +229,62 @@ - '"ansibulluser" not in user_names2.stdout_lines' +## create user without home and test fallback home dir create + +- block: + - name: create the user + user: + name: ansibulluser + + - name: delete the user and home dir + user: + name: ansibulluser + state: absent + force: true + remove: true + + - name: create the user without home + user: + name: ansibulluser + create_home: no + + - name: create the user home dir + user: + name: ansibulluser + register: user_create_home_fallback + + - name: stat home dir + stat: + path: '{{ user_create_home_fallback.home }}' + register: user_create_home_fallback_dir + + - name: read UMASK from /etc/login.defs and return mode + shell: | + import re + import os + try: + for line in open('/etc/login.defs').readlines(): + m = re.match(r'^UMASK\s+(\d+)$', line) + if m: + umask = int(m.group(1), 8) + except: + umask = os.umask(0) + mode = oct(0o777 & ~umask) + print(str(mode).replace('o', '')) + args: + executable: python + register: user_login_defs_umask + + - name: validate that user home dir is created + assert: + that: + - user_create_home_fallback is changed + - user_create_home_fallback_dir.stat.exists + - user_create_home_fallback_dir.stat.isdir + - user_create_home_fallback_dir.stat.pw_name == 'ansibulluser' + - user_create_home_fallback_dir.stat.mode == user_login_defs_umask.stdout + when: ansible_facts.system != 'Darwin' + - block: - name: create non-system user on macOS to test the shell is set to /bin/bash user: