├── lib ├── external │ ├── __init__.py │ └── six │ │ └── __init__.py └── creds │ ├── plan.pyi │ ├── constants.pyi │ ├── ssh.pyi │ ├── utils.pyi │ ├── __init__.py │ ├── users.pyi │ ├── constants.py │ ├── ssh.py │ ├── plan.py │ ├── utils.py │ └── users.py ├── tests ├── json_input │ ├── invalid.json │ └── basic.json ├── yaml_input │ ├── invalid.yml │ └── basic.yml ├── __init__.py ├── test_user_1.yml ├── test_ssh.py ├── sample_data.py ├── test_users.py └── test_plan.py ├── MANIFEST.in ├── pytest.ini ├── test-requirements.txt ├── .coveragerc ├── docs ├── api │ ├── ssh.rst │ ├── plan.rst │ ├── utils.rst │ └── users.rst ├── index.rst ├── Makefile ├── make.bat └── conf.py ├── .landscape.yml ├── tox.ini ├── .circleci └── config.yml ├── CHANGELOG.rst ├── shippable.yml ├── LICENSE ├── .travis.yml ├── .gitignore ├── README.rst └── setup.py /lib/external/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/json_input/invalid.json: -------------------------------------------------------------------------------- 1 | invalid -------------------------------------------------------------------------------- /tests/yaml_input/invalid.yml: -------------------------------------------------------------------------------- 1 | invalid -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt README.rst 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --ignore=setup.py --maxfail=1 --capture=sys --cov-report term-missing --cov creds 3 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest>=3.0.3 2 | pytest-cov>=2.3.1 3 | PyYAML 4 | mock==3.0.5 5 | boto>=2.49.0 6 | boto3>=1.9.229 7 | botocore>=1.12.229 8 | moto>=1.3.10 9 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = setup.py 3 | exclude_lines = 4 | pragma: no cover 5 | pragma: no branch 6 | if PY2 7 | if PY3 8 | pragma: FreeBSD 9 | pragma: OpenBSD -------------------------------------------------------------------------------- /docs/api/ssh.rst: -------------------------------------------------------------------------------- 1 | .. _ssh: 2 | 3 | ========= 4 | creds.ssh 5 | ========= 6 | 7 | .. currentmodule:: creds.ssh 8 | .. autosummary:: 9 | PublicKey 10 | 11 | 12 | .. automodule:: creds.ssh 13 | :members: 14 | :undoc-members: 15 | 16 | -------------------------------------------------------------------------------- /.landscape.yml: -------------------------------------------------------------------------------- 1 | doc-warnings: yes 2 | test-warnings: no 3 | strictness: veryhigh 4 | max-line-length: 120 5 | autodetect: yes 6 | ignore-paths: 7 | - docs 8 | - setup.py 9 | - lib/external 10 | python-targets: 11 | - 2 12 | - 3 -------------------------------------------------------------------------------- /docs/api/plan.rst: -------------------------------------------------------------------------------- 1 | .. _plan: 2 | 3 | ========== 4 | creds.plan 5 | ========== 6 | 7 | .. currentmodule:: creds.plan 8 | .. autosummary:: 9 | create_plan 10 | 11 | 12 | .. automodule:: creds.plan 13 | :members: 14 | :undoc-members: 15 | 16 | -------------------------------------------------------------------------------- /docs/api/utils.rst: -------------------------------------------------------------------------------- 1 | .. _utils: 2 | 3 | =========== 4 | creds.utils 5 | =========== 6 | 7 | .. currentmodule:: creds.utils 8 | .. autosummary:: 9 | sudo_check 10 | check_platform 11 | execute_command 12 | random_string 13 | base64encode 14 | base64decode 15 | 16 | .. automodule:: creds.utils 17 | :members: 18 | :undoc-members: 19 | 20 | -------------------------------------------------------------------------------- /docs/api/users.rst: -------------------------------------------------------------------------------- 1 | .. _users: 2 | 3 | =========== 4 | creds.users 5 | =========== 6 | 7 | .. currentmodule:: creds.users 8 | .. autosummary:: 9 | Users 10 | User 11 | generate_add_user_command 12 | generate_modify_user_command 13 | generate_delete_user_command 14 | get_user_by_uid 15 | compare_user 16 | 17 | | 18 | 19 | .. automodule:: creds.users 20 | :members: 21 | :undoc-members: 22 | -------------------------------------------------------------------------------- /lib/creds/plan.pyi: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typing import List 3 | 4 | from creds.users import Users 5 | 6 | 7 | def create_plan(existing_users: Users, proposed_users: Users, purge_undefined: bool, 8 | protected_users: List[str], 9 | allow_non_unique_id: bool, manage_home: bool, manage_keys: bool) -> List: pass 10 | 11 | 12 | def execute_plan(plan: List[dict]) -> None: pass 13 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox:travis] 2 | 3.5 = py35 3 | 3.4 = py34 4 | 3.3 = py33 5 | 2.7 = py27 6 | 2.6 = py26 7 | pypy = pypy 8 | 9 | [testenv] 10 | adopts=--ignore=setup.py --maxfail=1 --capture=sys --cov-report term-missing --cov creds 11 | python_files=*.py 12 | python_functions=test_ 13 | norecursedirs=.tox .git 14 | commands = py.test --cov-report term-missing --cov creds 15 | deps = -rtest-requirements.txt 16 | setenv = 17 | BOTO_CONFIG=/tmp/fake -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | working_directory: ~/creds 5 | docker: 6 | - image: circleci/python:3.6.4 7 | steps: 8 | - checkout 9 | - run: 10 | command: | 11 | python -m virtualenv venv 12 | . venv/bin/activate 13 | pip install coveralls 14 | pip install tox==2.7.0 15 | pip install tox-travis==0.8 16 | tox 17 | -------------------------------------------------------------------------------- /lib/creds/constants.pyi: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typing import Tuple, List 3 | 4 | UID_MIN: int 5 | UID_MAX: int 6 | SUPPORTED_PLATFORMS: List[str] 7 | RANDOM_FILE_EXT_LENGTH: int 8 | PURGE_UNDEFINED: bool 9 | DEFAULT_UID_MIN: int 10 | DEFAULT_UID_MAX: int 11 | CMD_SUDO: str 12 | LINUX_CMD_USERADD: str 13 | LINUX_CMD_USERMOD: str 14 | LINUX_CMD_USERDEL: str 15 | LINUX_CMD_GROUP_ADD: str 16 | LINUX_CMD_GROUP_DEL: str 17 | LINUX_CMD_VISUDO: str 18 | FREEBSD_CMD_PW: str 19 | 20 | 21 | def login_defs() -> Tuple: pass 22 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | creds 3 | ===== 4 | 5 | A python library for managing users on linux. 6 | 7 | Release v\ |version|. 8 | 9 | Installation 10 | ------------ 11 | 12 | *Using pip package manager*:: 13 | 14 | $ pip install creds 15 | 16 | *From source*:: 17 | 18 | $ git clone https://github.com/jonhadfield/creds 19 | $ cd creds 20 | $ python setup.py install 21 | 22 | API 23 | --- 24 | 25 | .. toctree:: 26 | :maxdepth: 3 27 | 28 | api/users 29 | api/plan 30 | api/ssh 31 | api/utils 32 | -------------------------------------------------------------------------------- /tests/test_user_1.yml: -------------------------------------------------------------------------------- 1 | --- 2 | users: 3 | - name: dummy 4 | gecos: dummy (user) test 5 | public_keys: 6 | - "c3NoLXJzYSBBQUFBQjNOemFDMXljMkVBQUFBREFRQUJBQUFCQVFESnN4Z3IwanF6YzlrWEl2bGJCNGhIK1kzdysxNWpUL1hrZU9BTXA5a1pLUHFuaWsyYnBqZjdja0tQU0FPL1JCZk5pNThCYkp5Yks3dnlRZktRSXUyV1ZDMUZncTk0MnQwaDRNN3U4UzFpUnRvR1NyT1EzYkhmK0Y2UEY3SlkvaTNPUzN2Z2FEQ0FVWkkvcFh1TlNST2tra2d0Z2dCUVFoL1BoVlZYT2pUczB2ZXR2SFNGcWZaMjNjUWdxdXpWK1M1V256ejFTbVlSTVkxQ1dqVVhKL1NsbHpmeTR4VDBNL0s1cC96V2NENjJueFg4Zmp1RlB4QWMxZnExeWZLV2sxZUkzdUlYc1YxallMcW9GanFpVmNGTE0zSm5EUWMrak1SaVVZbGhPN3hTYnVsRFpnNFFCdnRYOGVicDRSeHdzcjFXbk9MQWZ3SXpLVTFGaVU0dzlIRkogZHVtbXkK" 7 | sudoers_entry: 'ALL=(ALL:ALL) ALL' -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Change Log 3 | ========== 4 | 5 | This lists the changes made with each release. 6 | 7 | 8 | `1.0.15`_ (2017-05-15) 9 | ---------------------- 10 | 11 | * Fix potential race condition. 12 | 13 | 14 | `1.0.14`_ (2017-01-27) 15 | ---------------------- 16 | 17 | * Validate sudoers update using visudo to prevent corruption. 18 | 19 | 20 | `1.0.13`_ (2017-01-13) 21 | ---------------------- 22 | 23 | * Add 'manage_home' parameter to plan. Setting it to True (default), means necessary changes to the home directory are applied. Note: If set to False, keys will not be managed. 24 | * Add 'manage_keys' parameter to plan. Setting it to True (default), means necessary changes to a user's keys are applied. 25 | 26 | -------------------------------------------------------------------------------- /lib/creds/ssh.pyi: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import (unicode_literals, print_function) 3 | 4 | from typing import AnyStr, Optional, List 5 | 6 | from creds.users import User 7 | 8 | 9 | class PublicKey(object): 10 | def __init__(self, raw: Optional[AnyStr], b64encoded: Optional[AnyStr]) -> None: 11 | self._raw = raw 12 | self._b64encoded = b64encoded 13 | 14 | @property 15 | def b64encoded(self) -> Optional[AnyStr]: pass 16 | 17 | @property 18 | def raw(self) -> Optional[AnyStr]: pass 19 | 20 | 21 | # TODO: Keep temporary copy so we can check for race condition. 22 | 23 | def read_authorized_keys(username: str) -> List: pass 24 | 25 | 26 | def write_authorized_keys(user: User) -> List: pass 27 | -------------------------------------------------------------------------------- /lib/creds/utils.pyi: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from typing import Tuple, List, Optional, Union 4 | 5 | 6 | def sudo_check() -> str: pass 7 | 8 | 9 | def get_platform() -> str: pass 10 | 11 | 12 | def get_missing_commands(_platform: str) -> List: pass 13 | 14 | 15 | def execute_command(command: List) -> Tuple: pass 16 | 17 | 18 | def random_string(length: Optional[int]) -> str: pass 19 | 20 | 21 | def base64encode(_input: Union[bytes, str]) -> str: pass 22 | 23 | 24 | def base64decode(_input: Union[bytes, str]) -> str: pass 25 | 26 | 27 | def remove_sudoers_entry(username=Optional[str]) -> None: pass 28 | 29 | 30 | def write_sudoers_entry(username=Optional[str], sudoers_entry=Optional[str]) -> None: pass 31 | 32 | 33 | def read_sudoers() -> List: pass 34 | 35 | 36 | def get_sudoers_entry(username=Optional[str], sudoers_entries=List) -> str: pass 37 | -------------------------------------------------------------------------------- /lib/creds/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | This package contains all of the modules utilised by the creds library. 4 | 5 | constants: Functions to define and discover OS constants. 6 | 7 | plan: Functions to generate a list of steps to transition from the current state to the desired state. 8 | 9 | ssh: Contains a class to represent a users' keys and functions to manage them. 10 | 11 | users: Functions to managing users and classes... 12 | Users - representation of a user list and methods to read, write and manage them 13 | User - representation of a single user with their associated credentials 14 | 15 | utils: Common helper functions. 16 | """ 17 | 18 | from __future__ import (unicode_literals, print_function) 19 | 20 | __title__ = 'creds' 21 | __version__ = '1.0.16' 22 | __author__ = 'Jon Hadfield' 23 | __license__ = 'MIT' 24 | __copyright__ = 'Copyright 2016 Jon Hadfield' 25 | -------------------------------------------------------------------------------- /shippable.yml: -------------------------------------------------------------------------------- 1 | #set your language below 2 | language: python 3 | 4 | #set language version. This is only required if you use the default Shippable image for your build 5 | python: 6 | - 2.7 7 | 8 | #specify which services you need. This is only valid if you use the default Shippable image for your build 9 | services: 10 | 11 | env: 12 | 13 | matrix: 14 | 15 | build: 16 | #commands in this section run on your build machine and not inside your CI container. 17 | pre_ci: 18 | 19 | pre_ci_boot: 20 | image_name: 21 | image_tag: 22 | pull: 23 | options: 24 | #commands in this section run inside your CI container. In addition, any commands such as ls, in this section can be used to skip default processing for CI. 25 | ci: 26 | 27 | post_ci: 28 | on_success: 29 | on_failure: 30 | cache: 31 | #commands in this section run on your build machine and not inside your CI container. 32 | push: 33 | 34 | 35 | -------------------------------------------------------------------------------- /tests/test_ssh.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import (absolute_import, unicode_literals, print_function) 4 | 5 | import pytest 6 | from creds.ssh import PublicKey 7 | 8 | from .sample_data import PUBLIC_KEYS 9 | 10 | 11 | def test_invalid_public_key_setup(): 12 | with pytest.raises(AttributeError): 13 | assert PublicKey() 14 | 15 | 16 | def test_create_public_key_from_encoded(): 17 | for key in PUBLIC_KEYS: 18 | public_key = PublicKey( 19 | b64encoded=key['encoded']) 20 | assert public_key.raw == key['raw'] 21 | assert public_key.b64encoded == key['encoded'] 22 | 23 | 24 | def test_create_public_key_from_raw(): 25 | for key in PUBLIC_KEYS: 26 | public_key = PublicKey(raw=key['raw']) 27 | assert public_key.b64encoded == key['encoded'] 28 | assert public_key.raw == key['raw'] 29 | 30 | 31 | def test_public_key_repr_and_str(): 32 | public_key = PublicKey(raw=PUBLIC_KEYS[0]['raw']) 33 | assert str(public_key) == public_key.__repr__() 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Jon Hadfield 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/yaml_input/basic.yml: -------------------------------------------------------------------------------- 1 | --- 2 | users: 3 | - name: peter 4 | uid: 2000 5 | gid: 2000 6 | gecos: example gecos for peter 7 | home_dir: /home/bigal 8 | shell: /bin/bash 9 | public_keys: 10 | - "c3NoLXJzYSBBQUFBQjNOemFDMXljMkVBQUFBQkpRQUFBSUVBcUVpMG9xTlpsV3FLbFhxV0h6d0E1eWRsaVhpOWlwczNCMXI3TlBDUE5pMjkzdUpHbDc0SlAvRlJmTldnY2prdytjMS9rckJwZVlacm80NDg1MWZtdjc1dVoyWmM0SFhDYkovWnBSTnFjTG15dVV2RExOK2toaFRURmVlOHliQmNXMFVvQURHanM0RXRLYkRSQ1ZMeTRzUThOdWQwaHBzdVBrOGo5WFloTWtjPQ" 11 | - name: brian 12 | uid: 2001 13 | gid: 2001 14 | gecos: example gecos for brian 15 | home_dir: /home/brian 16 | shell: /bin/sh 17 | public_keys: 18 | - "c3NoLXJzYSBBQUFBQjNOemFDMXljMkVBQUFBREFRQUJBQUFCQVFEamk1eWNXemFiSUxBbXdPN3U0YUk4Q290RnBLU2owcGNWNUV3allUN0NJTmNTK1NoMW1CSmNkcEdEYS84ZC9zU3BuVG1pelhJM1kwUmRoL1R6dmNiVW94enlJOHBja2ErV2lqSjBuVGYxZ3RRL3RoZ3UzTGdVOGxGWEZJVnRUY1h1LzlrZDBLekpFQWIwMFhzSVZOSVY2ZkVnZWYxZGJ6eG0yTGRLUDRwR0N5NC8yNWxoQ1VySEthYWpqQlBGc09lZERmWWZyV2RBYmpEVzBYMGNUMmF5UTV1b2lkditKM3JqZWpTOUVMN3plbytwSHlUR1dZcC9DdkpvaklEWm1yTWJvcGx1Sk1mYnFBOFNwTDJWeFhMRWdQNUp6MFZETkNuTEtDSzZ2SWJoTWtJWDhsRTVnQ2I5Nzh4cDVjVk52MXFWQWFYVlJ4aXhYbEFXdllWWTBIdG4" 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: required 3 | matrix: 4 | include: 5 | # - os: windows 6 | # python: '2.7' 7 | - os: linux 8 | dist: trusty 9 | sudo: false 10 | python: '2.7' 11 | - os: linux 12 | dist: trusty 13 | sudo: false 14 | python: '3.4' 15 | - os: linux 16 | dist: trusty 17 | sudo: false 18 | python: '3.5' 19 | - os: linux 20 | dist: trusty 21 | sudo: false 22 | python: '3.6' 23 | - os: linux 24 | dist: xenial 25 | sudo: required 26 | services: 27 | - docker 28 | python: '3.7' 29 | env: BUILD_SDIST=true 30 | - os: linux 31 | sudo: false 32 | python: pypy 33 | - os: linux 34 | sudo: false 35 | python: pypy3 36 | - os: osx 37 | language: objective-c 38 | env: PYENV_VERSION=2.7.12 39 | - os: osx 40 | language: objective-c 41 | env: PYENV_VERSION=3.5.5 42 | - os: osx 43 | language: objective-c 44 | env: PYENV_VERSION=3.6.5 45 | install: 46 | - pip install coveralls 47 | - pip install tox-travis 48 | script: 49 | - tox 50 | after_success: 51 | - coveralls 52 | - bash <(curl -s https://codecov.io/bash) 53 | -------------------------------------------------------------------------------- /tests/json_input/basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "users": [ 3 | { 4 | "name": "peter", 5 | "uid": 2000, 6 | "gid": 2000, 7 | "gecos": "example gecos for peter", 8 | "home_dir": "/home/bigal", 9 | "shell": "/bin/bash", 10 | "public_keys":["c3NoLXJzYSBBQUFBQjNOemFDMXljMkVBQUFBQkpRQUFBSUVBcUVpMG9xTlpsV3FLbFhxV0h6d0E1eWRsaVhpOWlwczNCMXI3TlBDUE5pMjkzdUpHbDc0SlAvRlJmTldnY2prdytjMS9rckJwZVlacm80NDg1MWZtdjc1dVoyWmM0SFhDYkovWnBSTnFjTG15dVV2RExOK2toaFRURmVlOHliQmNXMFVvQURHanM0RXRLYkRSQ1ZMeTRzUThOdWQwaHBzdVBrOGo5WFloTWtjPQ"] 11 | 12 | }, 13 | { 14 | "name": "brian", 15 | "uid": 2001, 16 | "gid": 2001, 17 | "gecos": "example gecos for brian", 18 | "home_dir": "/home/brian", 19 | "shell": "/bin/sh", 20 | "public_keys":["c3NoLXJzYSBBQUFBQjNOemFDMXljMkVBQUFBREFRQUJBQUFCQVFEamk1eWNXemFiSUxBbXdPN3U0YUk4Q290RnBLU2owcGNWNUV3allUN0NJTmNTK1NoMW1CSmNkcEdEYS84ZC9zU3BuVG1pelhJM1kwUmRoL1R6dmNiVW94enlJOHBja2ErV2lqSjBuVGYxZ3RRL3RoZ3UzTGdVOGxGWEZJVnRUY1h1LzlrZDBLekpFQWIwMFhzSVZOSVY2ZkVnZWYxZGJ6eG0yTGRLUDRwR0N5NC8yNWxoQ1VySEthYWpqQlBGc09lZERmWWZyV2RBYmpEVzBYMGNUMmF5UTV1b2lkditKM3JqZWpTOUVMN3plbytwSHlUR1dZcC9DdkpvaklEWm1yTWJvcGx1Sk1mYnFBOFNwTDJWeFhMRWdQNUp6MFZETkNuTEtDSzZ2SWJoTWtJWDhsRTVnQ2I5Nzh4cDVjVk52MXFWQWFYVlJ4aXhYbEFXdllWWTBIdG4"] 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | #lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | .idea/ 92 | *.bkp 93 | local_tests/ 94 | -------------------------------------------------------------------------------- /lib/creds/users.pyi: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typing import List, Dict, AnyStr, Optional, Any, MutableSequence 3 | 4 | from creds.ssh import PublicKey 5 | 6 | 7 | class User(object): 8 | def __init__(self, name: AnyStr, passwd: Optional[AnyStr], uid: Optional[int], gid: Optional[int], 9 | gecos: Optional[AnyStr], home_dir: Optional[AnyStr], 10 | shell: Optional[AnyStr], public_keys: Optional[List[PublicKey]], 11 | sudoers_entry: Optional[AnyStr]) -> None: 12 | self.name = name 13 | self.passwd = passwd 14 | self.uid = uid 15 | self.gid = gid 16 | self._gecos = gecos 17 | self.home_dir = home_dir 18 | self.shell = shell 19 | self.public_keys = public_keys 20 | self.sudoers_entry = sudoers_entry 21 | 22 | def gecos(self) -> str: pass 23 | 24 | def to_dict(self) -> dict: pass 25 | 26 | 27 | class Users(MutableSequence): 28 | def __init__(self) -> None: 29 | self._user_list = list() 30 | self.oktypes = User 31 | 32 | def describe_users(self, users_filter: Dict) -> List: pass 33 | 34 | def check(self, value: Any) -> None: pass 35 | 36 | def insert(self, index: int, value: User) -> None: pass 37 | 38 | def remove(self, username: str) -> None: pass 39 | 40 | def __iter__(self) -> User: pass 41 | 42 | def __len__(self) -> int: pass 43 | 44 | def __getitem__(self, index: int) -> User: pass 45 | 46 | def __setitem__(self, index: int, value: User) -> None: pass 47 | 48 | def __delitem__(self, index: int) -> None: pass 49 | 50 | @classmethod 51 | def from_dict(cls, input_dict: Dict) -> List: pass 52 | 53 | @classmethod 54 | def from_yaml(cls, file_path: str) -> List: pass 55 | 56 | @classmethod 57 | def from_json(cls, file_path: str) -> List: pass 58 | 59 | @staticmethod 60 | def from_passwd(uid_min: int, uid_max: int) -> Users: pass 61 | 62 | @staticmethod 63 | def construct_user_list(raw_users: dict) -> Users: pass 64 | 65 | 66 | def generate_add_user_command(proposed_user: User, manage_home: bool) -> List[str]: pass 67 | 68 | 69 | def generate_modify_user_command(task: dict) -> List[str]: pass 70 | 71 | 72 | def compare_user(passed_user: User, user_list=Users) -> Dict: pass 73 | 74 | 75 | def get_user_by_uid(uid: int, users: Users) -> Users: pass 76 | -------------------------------------------------------------------------------- /lib/creds/constants.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Functions to define and discover OS constants.""" 3 | from __future__ import (unicode_literals, print_function) 4 | 5 | import io 6 | import os 7 | from distutils import spawn 8 | 9 | from external.six import (text_type, PY2, PY3) 10 | 11 | SUPPORTED_PLATFORMS = ['Linux', 'FreeBSD', 'OpenBSD'] 12 | 13 | RANDOM_FILE_EXT_LENGTH = 20 # Number of random characters to add to transient file names 14 | 15 | PURGE_UNDEFINED = False # Purge any users that fall between UID_MIN and UID_MAX that are not defined 16 | 17 | DEFAULT_UID_MIN = 1000 # The lowest uid to consider safe to manage 18 | DEFAULT_UID_MAX = 60000 # The maximum uid to consider safe to manage 19 | 20 | CMD_SUDO = spawn.find_executable("sudo") 21 | 22 | # LINUX/OPENBSD COMMANDS 23 | LINUX_CMD_USERADD = spawn.find_executable("useradd") 24 | LINUX_CMD_USERMOD = spawn.find_executable("usermod") 25 | LINUX_CMD_USERDEL = spawn.find_executable("userdel") 26 | LINUX_CMD_GROUP_ADD = spawn.find_executable("groupadd") 27 | LINUX_CMD_GROUP_DEL = spawn.find_executable("groupdel") 28 | LINUX_CMD_VISUDO = spawn.find_executable("visudo") 29 | # FREEBSD COMMANDS 30 | FREEBSD_CMD_PW = spawn.find_executable("pw") 31 | 32 | 33 | def login_defs(): 34 | """Discover the minimum and maximum UID number.""" 35 | uid_min = None 36 | uid_max = None 37 | login_defs_path = '/etc/login.defs' 38 | if os.path.exists(login_defs_path): 39 | with io.open(text_type(login_defs_path), encoding=text_type('utf-8')) as log_defs_file: 40 | login_data = log_defs_file.readlines() 41 | for line in login_data: 42 | if PY3: # pragma: no cover 43 | line = str(line) 44 | if PY2: # pragma: no cover 45 | line = line.encode(text_type('utf8')) 46 | if line[:7] == text_type('UID_MIN'): 47 | uid_min = int(line.split()[1].strip()) 48 | if line[:7] == text_type('UID_MAX'): 49 | uid_max = int(line.split()[1].strip()) 50 | if not uid_min: # pragma: no cover 51 | uid_min = DEFAULT_UID_MIN 52 | if not uid_max: # pragma: no cover 53 | uid_max = DEFAULT_UID_MAX 54 | return uid_min, uid_max 55 | 56 | 57 | UID_MIN, UID_MAX = login_defs() 58 | ALLOW_NON_UNIQUE_ID = False # Allow multiple users to share uids 59 | PROTECTED_USERS = list() # Users that must not be affected 60 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://readthedocs.org/projects/creds/badge/?version=master 2 | :target: https://creds.readthedocs.io/en/master/?badge=master 3 | :alt: Documentation Status 4 | .. image:: https://coveralls.io/repos/github/jonhadfield/creds/badge.svg?branch=master 5 | :target: https://coveralls.io/github/jonhadfield/creds?branch=master 6 | .. image:: https://circleci.com/gh/jonhadfield/creds.svg?style=svg 7 | :target: https://circleci.com/gh/jonhadfield/creds 8 | .. image:: https://travis-ci.org/jonhadfield/creds.svg?branch=master 9 | :target: https://travis-ci.org/jonhadfield/creds 10 | 11 | 12 | Creds 13 | ===== 14 | 15 | Creds is a library that simplifies the management of user accounts and 16 | their credentials on Linux, FreeBSD and OpenBSD. 17 | 18 | Instead of issuing commands to create, update and delete users and their 19 | ssh keys, supply Creds with details of the users you want and it will 20 | take care of the implementation. 21 | 22 | The supported inputs are currently YAML, JSON or python dictionaries. 23 | 24 | User vs System accounts 25 | ----------------------- 26 | 27 | | Linux has a default range of user ids to provide to system and user 28 | accounts, found in /etc/login.defs. 29 | | Creds will attempt to read this file to determine which accounts are 30 | in scope for management and, if unavailable, will default to: 31 | 32 | | UID\_MIN = 1000 # User accounts will have an id of 1000 or more 33 | | UID\_MAX = 60000 # User accounts will not have an id higher than 34 | 60000 35 | 36 | Example Usage 37 | ------------- 38 | 39 | Read a list of users from users.yml and create them (if missing) or 40 | update (if existing): 41 | 42 | :: 43 | 44 | from creds.users import Users 45 | from creds.plan import (create_plan, execute_plan) 46 | 47 | existing_users = Users.from_passwd() # Get a list of existing users and their keys 48 | proposed_users = Users.from_yaml('users.yml') # Read the proposed list of users and their keys 49 | 50 | # Generate a list of operations to transition from current to existing 51 | plan = create_plan(existing_users=existing_users, proposed_users=proposed_users) 52 | execute_plan(plan=plan) # Execute the plan 53 | 54 | Deleting users 55 | 56 | 57 | If your input defines all of the user accounts you want to exist, you 58 | can choose to purge any that are undefined by adding a parameter to 59 | create\_plan: 60 | 61 | :: 62 | 63 | plan = create_plan(existing_users=existing_users, proposed_users=proposed_users, 64 | purge_undefined=True) 65 | 66 | Protecting users 67 | 68 | 69 | If there are users you want to protect from change, e.g. you want to 70 | make sure that certain users are not deleted or updated under any 71 | circumstances, then you can supply a list of usernames for Creds to 72 | ignore: 73 | 74 | :: 75 | 76 | plan = create_plan(existing_users=existing_users, proposed_users=proposed_users, 77 | purge_undefined=True, protected_users=['rod', 'jane', 'freddy']) 78 | 79 | 80 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import re 5 | import sys 6 | # from codecs import open 7 | 8 | from setuptools import (setup, find_packages) 9 | from setuptools.command.test import test as TestCommand 10 | 11 | 12 | class PyTest(TestCommand): 13 | user_options = [('pytest-args=', 'a', "Arguments to pass into py.test")] 14 | 15 | def initialize_options(self): 16 | TestCommand.initialize_options(self) 17 | self.pytest_args = [] 18 | 19 | def finalize_options(self): 20 | TestCommand.finalize_options(self) 21 | self.test_args = [] 22 | self.test_suite = True 23 | 24 | def run_tests(self): 25 | import pytest 26 | 27 | errno = pytest.main(self.pytest_args) 28 | sys.exit(errno) 29 | 30 | 31 | if sys.argv[-1] == 'publish': 32 | os.system('python setup.py sdist upload -r pypi') 33 | sys.exit() 34 | 35 | requires = [] 36 | test_requirements = ['pytest>=2.9.2', 'pytest-cov>=2.3.1', 'PyYAML>=3.11', 'boto', 'boto3', 'moto', 'mock'] 37 | 38 | with open('lib/creds/__init__.py', 'r') as fd: 39 | version = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', 40 | fd.read(), re.MULTILINE).group(1) 41 | 42 | if not version: 43 | raise RuntimeError('Cannot find version information') 44 | 45 | readme = open('README.rst').read() 46 | long_description = readme 47 | 48 | setup( 49 | name='creds', 50 | version=version, 51 | description='Creds is a library for managing Linux, FreeBSD and OpenBSD user accounts and credentials.', 52 | long_description=long_description, 53 | author='Jon Hadfield', 54 | author_email='jon@lessknown.co.uk', 55 | url='http://github.com/jonhadfield/creds', 56 | packages=find_packages('lib'), 57 | package_dir={'': 'lib'}, 58 | # package_data={'': ['LICENSE', 'NOTICE'], 'creds': ['*.pem']}, 59 | include_package_data=True, 60 | install_requires=requires, 61 | license='MIT', 62 | zip_safe=False, 63 | classifiers=( 64 | 'Development Status :: 4 - Beta', 65 | 'Intended Audience :: System Administrators', 66 | 'Natural Language :: English', 67 | 'License :: OSI Approved :: MIT License', 68 | 'Operating System :: POSIX :: BSD :: Linux', 69 | 'Operating System :: POSIX :: BSD :: FreeBSD', 70 | 'Operating System :: POSIX :: BSD :: OpenBSD', 71 | 'Programming Language :: Python', 72 | 'Programming Language :: Python :: 2.6', 73 | 'Programming Language :: Python :: 2.7', 74 | 'Programming Language :: Python :: 3', 75 | 'Programming Language :: Python :: 3.3', 76 | 'Programming Language :: Python :: 3.4', 77 | 'Programming Language :: Python :: 3.5', 78 | 'Programming Language :: Python :: Implementation :: CPython', 79 | 'Programming Language :: Python :: Implementation :: PyPy' 80 | ), 81 | cmdclass={'test': PyTest}, 82 | tests_require=test_requirements, 83 | # extras_require={ 84 | # 'security': [], 85 | # }, 86 | ) 87 | -------------------------------------------------------------------------------- /tests/sample_data.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import (absolute_import, unicode_literals, print_function) 3 | 4 | RAW_PUBLIC_KEY_1 = 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAqEi0oqNZlWqKlXqWHzwA5ydliXi9ips3B1r7NPCPNi293uJGl74JP/FRfNWgcjkw+c1/krBpeYZro44851fmv75uZ2Zc4HXCbJ/ZpRNqcLmyuUvDLN+khhTTFee8ybBcW0UoADGjs4EtKbDRCVLy4sQ8Nud0hpsuPk8j9XYhMkc=' 5 | ENCODED_PUBLIC_KEY_1 = 'c3NoLXJzYSBBQUFBQjNOemFDMXljMkVBQUFBQkpRQUFBSUVBcUVpMG9xTlpsV3FLbFhxV0h6d0E1eWRsaVhpOWlwczNCMXI3TlBDUE5pMjkzdUpHbDc0SlAvRlJmTldnY2prdytjMS9rckJwZVlacm80NDg1MWZtdjc1dVoyWmM0SFhDYkovWnBSTnFjTG15dVV2RExOK2toaFRURmVlOHliQmNXMFVvQURHanM0RXRLYkRSQ1ZMeTRzUThOdWQwaHBzdVBrOGo5WFloTWtjPQ==' 6 | 7 | RAW_PUBLIC_KEY_2 = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDji5ycWzabILAmwO7u4aI8CotFpKSj0pcV5EwjYT7CINcS+Sh1mBJcdpGDa/8d/sSpnTmizXI3Y0Rdh/TzvcbUoxzyI8pcka+WijJ0nTf1gtQ/thgu3LgU8lFXFIVtTcXu/9kd0KzJEAb00XsIVNIV6fEgef1dbzxm2LdKP4pGCy4/25lhCUrHKaajjBPFsOedDfYfrWdAbjDW0X0cT2ayQ5uoidv+J3rjejS9EL7zeo+pHyTGWYp/CvJojIDZmrMbopluJMfbqA8SpL2VxXLEgP5Jz0VDNCnLKCK6vIbhMkIX8lE5gCb978xp5cVNv1qVAaXVRxixXlAWvYVY0Htn' 8 | ENCODE_PUBLIC_KEY_2 = 'c3NoLXJzYSBBQUFBQjNOemFDMXljMkVBQUFBREFRQUJBQUFCQVFEamk1eWNXemFiSUxBbXdPN3U0YUk4Q290RnBLU2owcGNWNUV3allUN0NJTmNTK1NoMW1CSmNkcEdEYS84ZC9zU3BuVG1pelhJM1kwUmRoL1R6dmNiVW94enlJOHBja2ErV2lqSjBuVGYxZ3RRL3RoZ3UzTGdVOGxGWEZJVnRUY1h1LzlrZDBLekpFQWIwMFhzSVZOSVY2ZkVnZWYxZGJ6eG0yTGRLUDRwR0N5NC8yNWxoQ1VySEthYWpqQlBGc09lZERmWWZyV2RBYmpEVzBYMGNUMmF5UTV1b2lkditKM3JqZWpTOUVMN3plbytwSHlUR1dZcC9DdkpvaklEWm1yTWJvcGx1Sk1mYnFBOFNwTDJWeFhMRWdQNUp6MFZETkNuTEtDSzZ2SWJoTWtJWDhsRTVnQ2I5Nzh4cDVjVk52MXFWQWFYVlJ4aXhYbEFXdllWWTBIdG4=' 9 | 10 | RAW_PUBLIC_KEY_3 = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC1TOE/w1BKqh2vaGfwqJLADaHDkMPDf25W/wj0/53NB9/EWJ08EqFuny869Jpu4LA7UhXoz4aECBDvk9VzTimcDtJdLtmPmr+F5lOzVOiKUfdwVvjp1fOnme9ObDMJ/kJv/2gtzrVSNSCmt9NdFQRLMsjz4EHLAesriOVjdZcUeBKAQH8FzWZbu2EgW5z2PdJJa6UQQj4GE6R8y4/3zoh4lZ94c/6Fi4DeehgKRGO5tIY2FwhrNIGb0BKq3FwjHHSHM8rdo707uXQZWizeRorCYPhvHxkOh9G6WJIXbQUzs6Qy2EppDgv7qkzcNmTIIICnsuCI+w4uO1yynZvqsuov bob@example.com' 11 | ENCODE_PUBLIC_KEY_3 = 'c3NoLXJzYSBBQUFBQjNOemFDMXljMkVBQUFBREFRQUJBQUFCQVFDMVRPRS93MUJLcWgydmFHZndxSkxBRGFIRGtNUERmMjVXL3dqMC81M05COS9FV0owOEVxRnVueTg2OUpwdTRMQTdVaFhvejRhRUNCRHZrOVZ6VGltY0R0SmRMdG1QbXIrRjVsT3pWT2lLVWZkd1Z2anAxZk9ubWU5T2JETUova0p2LzJndHpyVlNOU0NtdDlOZEZRUkxNc2p6NEVITEFlc3JpT1ZqZFpjVWVCS0FRSDhGeldaYnUyRWdXNXoyUGRKSmE2VVFRajRHRTZSOHk0LzN6b2g0bFo5NGMvNkZpNERlZWhnS1JHTzV0SVkyRndock5JR2IwQktxM0Z3akhIU0hNOHJkbzcwN3VYUVpXaXplUm9yQ1lQaHZIeGtPaDlHNldKSVhiUVV6czZReTJFcHBEZ3Y3cWt6Y05tVElJSUNuc3VDSSt3NHVPMXl5blp2cXN1b3YgYm9iQGV4YW1wbGUuY29t' 12 | 13 | RAW_PUBLIC_KEY_4 = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDReC3ARgYneNaffqPM1wSS538gw+d5+ydZgw81EfHZEhYDr3CNa22+kMhqW11GSFBvzUJnz+gonYANNzdQqam6QviLVdKwa9nRU+AiJQuq8DOIt+bAU8fiLiPulQwK6PQH4GvZmE6l0WYRQTK7oUlhrU3wrD0mf7N1HyOwKhINLk13/xn1UXkBi57ejl7Ub4s/yzn6VwOL1yI41bd/gHkH90pWLKfVwCBCzfii7u5pdQYhoSqXFaLjCN4OtzSj4vBT88gHSUp9EdHHpC85xykkg+ixF2kWcf/Hl/Jyt0FzSmOw3reo68BIEP/VecTex1hk+EoR22y40ucVRPW73eFf jeff@example.com' 14 | ENCODE_PUBLIC_KEY_4 = 'c3NoLXJzYSBBQUFBQjNOemFDMXljMkVBQUFBREFRQUJBQUFCQVFEUmVDM0FSZ1luZU5hZmZxUE0xd1NTNTM4Z3crZDUreWRaZ3c4MUVmSFpFaFlEcjNDTmEyMitrTWhxVzExR1NGQnZ6VUpueitnb25ZQU5OemRRcWFtNlF2aUxWZEt3YTluUlUrQWlKUXVxOERPSXQrYkFVOGZpTGlQdWxRd0s2UFFINEd2Wm1FNmwwV1lSUVRLN29VbGhyVTN3ckQwbWY3TjFIeU93S2hJTkxrMTMveG4xVVhrQmk1N2VqbDdVYjRzL3l6bjZWd09MMXlJNDFiZC9nSGtIOTBwV0xLZlZ3Q0JDemZpaTd1NXBkUVlob1NxWEZhTGpDTjRPdHpTajR2QlQ4OGdIU1VwOUVkSEhwQzg1eHlra2craXhGMmtXY2YvSGwvSnl0MEZ6U21PdzNyZW82OEJJRVAvVmVjVGV4MWhrK0VvUjIyeTQwdWNWUlBXNzNlRmYgamVmZkBleGFtcGxlLmNvbQ==' 15 | 16 | PUBLIC_KEYS = ( 17 | dict(encoded=ENCODED_PUBLIC_KEY_1, raw=RAW_PUBLIC_KEY_1), dict(encoded=ENCODE_PUBLIC_KEY_2, raw=RAW_PUBLIC_KEY_2), 18 | dict(encoded=ENCODE_PUBLIC_KEY_3, raw=RAW_PUBLIC_KEY_3), dict(encoded=ENCODE_PUBLIC_KEY_4, raw=RAW_PUBLIC_KEY_4)) 19 | 20 | SAMPLE_DICT = {'users': [{'shell': '/bin/bash', 'gecos': 'example gecos for peter', 'name': 'peter', 'public_keys': [ 21 | 'c3NoLXJzYSBBQUFBQjNOemFDMXljMkVBQUFBQkpRQUFBSUVBcUVpMG9xTlpsV3FLbFhxV0h6d0E1eWRsaVhpOWlwczNCMXI3TlBDUE5pMjkzdUpHbDc0SlAvRlJmTldnY2prdytjMS9rckJwZVlacm80NDg1MWZtdjc1dVoyWmM0SFhDYkovWnBSTnFjTG15dVV2RExOK2toaFRURmVlOHliQmNXMFVvQURHanM0RXRLYkRSQ1ZMeTRzUThOdWQwaHBzdVBrOGo5WFloTWtjPQ'], 22 | 'gid': 2000, 'home_dir': '/home/bigal', 'uid': 2000}, 23 | {'shell': '/bin/sh', 'gecos': 'example gecos for brian', 'name': 'brian', 'public_keys': [ 24 | 'c3NoLXJzYSBBQUFBQjNOemFDMXljMkVBQUFBREFRQUJBQUFCQVFEamk1eWNXemFiSUxBbXdPN3U0YUk4Q290RnBLU2owcGNWNUV3allUN0NJTmNTK1NoMW1CSmNkcEdEYS84ZC9zU3BuVG1pelhJM1kwUmRoL1R6dmNiVW94enlJOHBja2ErV2lqSjBuVGYxZ3RRL3RoZ3UzTGdVOGxGWEZJVnRUY1h1LzlrZDBLekpFQWIwMFhzSVZOSVY2ZkVnZWYxZGJ6eG0yTGRLUDRwR0N5NC8yNWxoQ1VySEthYWpqQlBGc09lZERmWWZyV2RBYmpEVzBYMGNUMmF5UTV1b2lkditKM3JqZWpTOUVMN3plbytwSHlUR1dZcC9DdkpvaklEWm1yTWJvcGx1Sk1mYnFBOFNwTDJWeFhMRWdQNUp6MFZETkNuTEtDSzZ2SWJoTWtJWDhsRTVnQ2I5Nzh4cDVjVk52MXFWQWFYVlJ4aXhYbEFXdllWWTBIdG4'], 25 | 'gid': 2001, 'home_dir': '/home/brian', 'uid': 2001}]} 26 | -------------------------------------------------------------------------------- /lib/creds/ssh.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """A class to represent a users' keys and functions to manage them.""" 3 | from __future__ import (unicode_literals, print_function) 4 | 5 | import os 6 | import shlex 7 | 8 | from creds.constants import RANDOM_FILE_EXT_LENGTH 9 | from creds.utils import base64decode, base64encode 10 | from creds.utils import execute_command, random_string, sudo_check 11 | from external.six import text_type 12 | 13 | 14 | class PublicKey(object): 15 | 16 | """Representation of a public key.""" 17 | 18 | def __init__(self, raw=None, b64encoded=None): 19 | """Make a public key. 20 | 21 | args: 22 | raw (str): raw public key 23 | b64encoded (str): base64 encoded public key 24 | """ 25 | if not any((raw, b64encoded)): 26 | raise AttributeError('Key not provided') 27 | self._raw = raw 28 | self._b64encoded = b64encoded 29 | 30 | @property 31 | def b64encoded(self): 32 | """Return a base64 encoding of the key. 33 | 34 | returns: 35 | str: base64 encoding of the public key 36 | """ 37 | if self._b64encoded: 38 | return text_type(self._b64encoded).strip("\r\n") 39 | else: 40 | return base64encode(self.raw) 41 | 42 | @property 43 | def raw(self): 44 | """Return raw key. 45 | 46 | returns: 47 | str: raw key 48 | """ 49 | if self._raw: 50 | return text_type(self._raw).strip("\r\n") 51 | else: 52 | return text_type(base64decode(self._b64encoded)).strip("\r\n") 53 | 54 | def __str__(self): 55 | return self.__repr__() 56 | 57 | def __repr__(self): 58 | return 'PublicKey(raw=\"{0}\", b64encoded=\"{1}\"'.format(self.raw, self.b64encoded) 59 | 60 | 61 | # TODO: Keep temporary copy so we can check for race condition. 62 | 63 | def read_authorized_keys(username=None): 64 | """Read public keys from specified user's authorized_keys file. 65 | 66 | args: 67 | username (str): username. 68 | 69 | returns: 70 | list: Authorised keys for the specified user. 71 | """ 72 | authorized_keys_path = '{0}/.ssh/authorized_keys'.format(os.path.expanduser('~{0}'.format(username))) 73 | rnd_chars = random_string(length=RANDOM_FILE_EXT_LENGTH) 74 | tmp_authorized_keys_path = '/tmp/authorized_keys_{0}_{1}'.format(username, rnd_chars) 75 | authorized_keys = list() 76 | copy_result = execute_command( 77 | shlex.split(str('{0} cp {1} {2}'.format(sudo_check(), authorized_keys_path, tmp_authorized_keys_path)))) 78 | result_message = copy_result[0][1].decode('UTF-8') 79 | if 'you must have a tty to run sudo' in result_message: # pragma: no cover 80 | raise OSError("/etc/sudoers is blocked sudo. Remove entry: 'Defaults requiretty'.") 81 | elif 'No such file or directory' not in result_message: 82 | execute_command(shlex.split(str('{0} chmod 755 {1}'.format(sudo_check(), tmp_authorized_keys_path)))) 83 | with open(tmp_authorized_keys_path) as keys_file: 84 | for key in keys_file: 85 | authorized_keys.append(PublicKey(raw=key)) 86 | execute_command(shlex.split(str('{0} rm {1}'.format(sudo_check(), tmp_authorized_keys_path)))) 87 | return authorized_keys 88 | 89 | 90 | def write_authorized_keys(user=None): 91 | """Write public keys back to authorized_keys file. Create keys directory if it doesn't already exist. 92 | 93 | args: 94 | user (User): Instance of User containing keys. 95 | 96 | returns: 97 | list: Authorised keys for the specified user. 98 | """ 99 | authorized_keys = list() 100 | authorized_keys_dir = '{0}/.ssh'.format(os.path.expanduser('~{0}'.format(user.name))) 101 | rnd_chars = random_string(length=RANDOM_FILE_EXT_LENGTH) 102 | authorized_keys_path = '{0}/authorized_keys'.format(authorized_keys_dir) 103 | tmp_authorized_keys_path = '/tmp/authorized_keys_{0}_{1}'.format(user.name, rnd_chars) 104 | 105 | if not os.path.isdir(authorized_keys_dir): 106 | execute_command(shlex.split(str('{0} mkdir -p {1}'.format(sudo_check(), authorized_keys_dir)))) 107 | for key in user.public_keys: 108 | authorized_keys.append('{0}\n'.format(key.raw)) 109 | with open(tmp_authorized_keys_path, mode=text_type('w+')) as keys_file: 110 | keys_file.writelines(authorized_keys) 111 | execute_command( 112 | shlex.split(str('{0} cp {1} {2}'.format(sudo_check(), tmp_authorized_keys_path, authorized_keys_path)))) 113 | execute_command(shlex.split(str('{0} chown -R {1} {2}'.format(sudo_check(), user.name, authorized_keys_dir)))) 114 | execute_command(shlex.split(str('{0} chmod 700 {1}'.format(sudo_check(), authorized_keys_dir)))) 115 | execute_command(shlex.split(str('{0} chmod 600 {1}'.format(sudo_check(), authorized_keys_path)))) 116 | execute_command(shlex.split(str('{0} rm {1}'.format(sudo_check(), tmp_authorized_keys_path)))) 117 | 118 | -------------------------------------------------------------------------------- /lib/creds/plan.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Functions to generate a list of steps to transition from the current state to the desired state.""" 3 | from __future__ import (unicode_literals, print_function) 4 | 5 | from creds import constants 6 | from creds.ssh import write_authorized_keys 7 | from creds.users import (generate_add_user_command, generate_modify_user_command, 8 | generate_delete_user_command, compare_user, get_user_by_uid) 9 | from creds.utils import execute_command, write_sudoers_entry, remove_sudoers_entry 10 | from external.six import iteritems 11 | 12 | 13 | def create_plan(existing_users=None, proposed_users=None, purge_undefined=None, protected_users=None, 14 | allow_non_unique_id=None, manage_home=True, manage_keys=True): 15 | """Determine what changes are required. 16 | 17 | args: 18 | existing_users (Users): List of discovered users 19 | proposed_users (Users): List of proposed users 20 | purge_undefined (bool): Remove discovered users that have not been defined in proposed users list 21 | protected_users (list): List of users' names that should not be evaluated as part of the plan creation process 22 | allow_non_unique_id (bool): Allow more than one user to have the same uid 23 | manage_home (bool): Create/remove users' home directories 24 | manage_keys (bool): Add/update/remove users' keys (manage_home must also be true) 25 | 26 | returns: 27 | list: Differences between discovered and proposed users with a 28 | list of operations that will achieve the desired state. 29 | """ 30 | 31 | plan = list() 32 | proposed_usernames = list() 33 | 34 | if not purge_undefined: 35 | purge_undefined = constants.PURGE_UNDEFINED 36 | if not protected_users: 37 | protected_users = constants.PROTECTED_USERS 38 | if not allow_non_unique_id: 39 | allow_non_unique_id = constants.ALLOW_NON_UNIQUE_ID 40 | 41 | # Create list of modifications to make based on proposed users compared to existing users 42 | for proposed_user in proposed_users: 43 | proposed_usernames.append(proposed_user.name) 44 | user_matching_name = existing_users.describe_users(users_filter=dict(name=proposed_user.name)) 45 | user_matching_id = get_user_by_uid(uid=proposed_user.uid, users=existing_users) 46 | # If user does not exist 47 | if not allow_non_unique_id and user_matching_id and not user_matching_name: 48 | plan.append( 49 | dict(action='fail', error='uid_clash', proposed_user=proposed_user, state='existing', result=None)) 50 | elif not user_matching_name: 51 | plan.append( 52 | dict(action='add', proposed_user=proposed_user, state='missing', result=None, manage_home=manage_home, 53 | manage_keys=manage_keys)) 54 | # If they do, then compare 55 | else: 56 | user_comparison = compare_user(passed_user=proposed_user, user_list=existing_users) 57 | if user_comparison.get('result'): 58 | plan.append( 59 | dict(action='update', proposed_user=proposed_user, state='existing', 60 | user_comparison=user_comparison, manage_home=manage_home, manage_keys=manage_keys)) 61 | # Application of the proposed user list will not result in deletion of users that need to be removed 62 | # If 'PURGE_UNDEFINED' then look for existing users that are not defined in proposed usernames and mark for removal 63 | if purge_undefined: 64 | for existing_user in existing_users: 65 | if existing_user.name not in proposed_usernames: 66 | if existing_user.name not in protected_users: 67 | plan.append( 68 | dict(action='delete', username=existing_user.name, state='existing', manage_home=manage_home, 69 | manage_keys=manage_keys)) 70 | return plan 71 | 72 | 73 | def execute_plan(plan=None): 74 | """Create, Modify or Delete, depending on plan item.""" 75 | execution_result = list() 76 | for task in plan: 77 | action = task['action'] 78 | if action == 'delete': 79 | command = generate_delete_user_command(username=task.get('username'), manage_home=task['manage_home']) 80 | command_output = execute_command(command) 81 | execution_result.append(dict(task=task, command_output=command_output)) 82 | remove_sudoers_entry(username=task.get('username')) 83 | elif action == 'add': 84 | command = generate_add_user_command(proposed_user=task.get('proposed_user'), manage_home=task['manage_home']) 85 | command_output = execute_command(command) 86 | if task['proposed_user'].public_keys and task['manage_home'] and task['manage_keys']: 87 | write_authorized_keys(task['proposed_user']) 88 | if task['proposed_user'].sudoers_entry: 89 | write_sudoers_entry(username=task['proposed_user'].name, 90 | sudoers_entry=task['proposed_user'].sudoers_entry) 91 | execution_result.append(dict(task=task, command_output=command_output)) 92 | elif action == 'update': 93 | result = task['user_comparison'].get('result') 94 | # Don't modify user if only keys have changed 95 | action_count = 0 96 | for k, _ in iteritems(result): 97 | if '_action' in k: 98 | action_count += 1 99 | command_output = None 100 | if task['manage_home'] and task['manage_keys'] and action_count == 1 and 'public_keys_action' in result: 101 | write_authorized_keys(task['proposed_user']) 102 | elif action_count == 1 and 'sudoers_entry_action' in result: 103 | write_sudoers_entry(username=task['proposed_user'].name, 104 | sudoers_entry=task['user_comparison']['result']['replacement_sudoers_entry']) 105 | else: 106 | command = generate_modify_user_command(task=task) 107 | command_output = execute_command(command) 108 | if task['manage_home'] and task['manage_keys'] and result.get('public_keys_action'): 109 | write_authorized_keys(task['proposed_user']) 110 | if result.get('sudoers_entry_action'): 111 | write_sudoers_entry(username=task['proposed_user'].name, 112 | sudoers_entry=task['user_comparison']['result']['replacement_sudoers_entry']) 113 | execution_result.append(dict(task=task, command_output=command_output)) 114 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help 18 | help: 19 | @echo "Please use \`make ' where is one of" 20 | @echo " html to make standalone HTML files" 21 | @echo " dirhtml to make HTML files named index.html in directories" 22 | @echo " singlehtml to make a single large HTML file" 23 | @echo " pickle to make pickle files" 24 | @echo " json to make JSON files" 25 | @echo " htmlhelp to make HTML files and a HTML help project" 26 | @echo " qthelp to make HTML files and a qthelp project" 27 | @echo " applehelp to make an Apple Help Book" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " epub3 to make an epub3" 31 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 32 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 33 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 34 | @echo " text to make text files" 35 | @echo " man to make manual pages" 36 | @echo " texinfo to make Texinfo files" 37 | @echo " info to make Texinfo files and run them through makeinfo" 38 | @echo " gettext to make PO message catalogs" 39 | @echo " changes to make an overview of all changed/added/deprecated items" 40 | @echo " xml to make Docutils-native XML files" 41 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 42 | @echo " linkcheck to check all external links for integrity" 43 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 44 | @echo " coverage to run coverage check of the documentation (if enabled)" 45 | @echo " dummy to check syntax errors of document sources" 46 | 47 | .PHONY: clean 48 | clean: 49 | rm -rf $(BUILDDIR)/* 50 | 51 | .PHONY: html 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | .PHONY: dirhtml 58 | dirhtml: 59 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 60 | @echo 61 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 62 | 63 | .PHONY: singlehtml 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | .PHONY: pickle 70 | pickle: 71 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 72 | @echo 73 | @echo "Build finished; now you can process the pickle files." 74 | 75 | .PHONY: json 76 | json: 77 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 78 | @echo 79 | @echo "Build finished; now you can process the JSON files." 80 | 81 | .PHONY: htmlhelp 82 | htmlhelp: 83 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 84 | @echo 85 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 86 | ".hhp project file in $(BUILDDIR)/htmlhelp." 87 | 88 | .PHONY: qthelp 89 | qthelp: 90 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 91 | @echo 92 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 93 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 94 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/creds.qhcp" 95 | @echo "To view the help file:" 96 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/creds.qhc" 97 | 98 | .PHONY: applehelp 99 | applehelp: 100 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 101 | @echo 102 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 103 | @echo "N.B. You won't be able to view it unless you put it in" \ 104 | "~/Library/Documentation/Help or install it in your application" \ 105 | "bundle." 106 | 107 | .PHONY: devhelp 108 | devhelp: 109 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 110 | @echo 111 | @echo "Build finished." 112 | @echo "To view the help file:" 113 | @echo "# mkdir -p $$HOME/.local/share/devhelp/creds" 114 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/creds" 115 | @echo "# devhelp" 116 | 117 | .PHONY: epub 118 | epub: 119 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 120 | @echo 121 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 122 | 123 | .PHONY: epub3 124 | epub3: 125 | $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 126 | @echo 127 | @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." 128 | 129 | .PHONY: latex 130 | latex: 131 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 132 | @echo 133 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 134 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 135 | "(use \`make latexpdf' here to do that automatically)." 136 | 137 | .PHONY: latexpdf 138 | latexpdf: 139 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 140 | @echo "Running LaTeX files through pdflatex..." 141 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 142 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 143 | 144 | .PHONY: latexpdfja 145 | latexpdfja: 146 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 147 | @echo "Running LaTeX files through platex and dvipdfmx..." 148 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 149 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 150 | 151 | .PHONY: text 152 | text: 153 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 154 | @echo 155 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 156 | 157 | .PHONY: man 158 | man: 159 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 160 | @echo 161 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 162 | 163 | .PHONY: texinfo 164 | texinfo: 165 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 166 | @echo 167 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 168 | @echo "Run \`make' in that directory to run these through makeinfo" \ 169 | "(use \`make info' here to do that automatically)." 170 | 171 | .PHONY: info 172 | info: 173 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 174 | @echo "Running Texinfo files through makeinfo..." 175 | make -C $(BUILDDIR)/texinfo info 176 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 177 | 178 | .PHONY: gettext 179 | gettext: 180 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 181 | @echo 182 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 183 | 184 | .PHONY: changes 185 | changes: 186 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 187 | @echo 188 | @echo "The overview file is in $(BUILDDIR)/changes." 189 | 190 | .PHONY: linkcheck 191 | linkcheck: 192 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 193 | @echo 194 | @echo "Link check complete; look for any errors in the above output " \ 195 | "or in $(BUILDDIR)/linkcheck/output.txt." 196 | 197 | .PHONY: doctest 198 | doctest: 199 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 200 | @echo "Testing of doctests in the sources finished, look at the " \ 201 | "results in $(BUILDDIR)/doctest/output.txt." 202 | 203 | .PHONY: coverage 204 | coverage: 205 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 206 | @echo "Testing of coverage in the sources finished, look at the " \ 207 | "results in $(BUILDDIR)/coverage/python.txt." 208 | 209 | .PHONY: xml 210 | xml: 211 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 212 | @echo 213 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 214 | 215 | .PHONY: pseudoxml 216 | pseudoxml: 217 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 218 | @echo 219 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 220 | 221 | .PHONY: dummy 222 | dummy: 223 | $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy 224 | @echo 225 | @echo "Build finished. Dummy builder generates no files." 226 | -------------------------------------------------------------------------------- /lib/creds/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """This module contains common helper functions.""" 3 | 4 | from __future__ import unicode_literals 5 | 6 | import base64 7 | import os 8 | import platform 9 | import random 10 | import shlex 11 | import string 12 | import subprocess 13 | 14 | from creds.constants import (CMD_SUDO, RANDOM_FILE_EXT_LENGTH, LINUX_CMD_GROUP_ADD, LINUX_CMD_GROUP_DEL, 15 | LINUX_CMD_USERADD, LINUX_CMD_USERDEL, LINUX_CMD_USERMOD, FREEBSD_CMD_PW, LINUX_CMD_VISUDO) 16 | from external.six import (PY2, PY3, text_type) 17 | 18 | 19 | def sudo_check(): 20 | """Return the string 'sudo' if current user isn't root.""" 21 | sudo_cmd = '' 22 | if os.geteuid() != 0: 23 | sudo_cmd = CMD_SUDO 24 | return sudo_cmd 25 | 26 | 27 | def get_platform(): 28 | """Return platform name""" 29 | return platform.system() 30 | 31 | 32 | def get_missing_commands(_platform): 33 | """Check I can identify the necessary commands for managing users.""" 34 | missing = list() 35 | if _platform in ('Linux', 'OpenBSD'): 36 | if not LINUX_CMD_USERADD: 37 | missing.append('useradd') 38 | if not LINUX_CMD_USERMOD: 39 | missing.append('usermod') 40 | if not LINUX_CMD_USERDEL: 41 | missing.append('userdel') 42 | if not LINUX_CMD_GROUP_ADD: 43 | missing.append('groupadd') 44 | if not LINUX_CMD_GROUP_DEL: 45 | missing.append('groupdel') 46 | elif _platform == 'FreeBSD': # pragma: FreeBSD 47 | # FREEBSD COMMANDS 48 | if not FREEBSD_CMD_PW: 49 | missing.append('pw') 50 | if missing: 51 | print('\nMISSING = {0}'.format(missing)) 52 | return missing 53 | 54 | 55 | def execute_command(command=None): 56 | """Execute a command and return the stdout and stderr.""" 57 | process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 58 | stdout, stdin = process.communicate() 59 | process.wait() 60 | return (stdout, stdin), process.returncode 61 | 62 | 63 | def random_string(length=None): 64 | """Generate a random string of ASCII characters.""" 65 | return ''.join(random.SystemRandom().choice(string.ascii_uppercase + string.digits) 66 | for _ in range(length)) 67 | 68 | 69 | def base64encode(_input=None): 70 | """Return base64 encoded representation of a string.""" 71 | if PY2: # pragma: no cover 72 | return base64.b64encode(_input) 73 | elif PY3: # pragma: no cover 74 | if isinstance(_input, bytes): 75 | return base64.b64encode(_input).decode('UTF-8') 76 | elif isinstance(_input, str): 77 | return base64.b64encode(bytearray(_input, encoding='UTF-8')).decode('UTF-8') 78 | 79 | 80 | def base64decode(_input=None): 81 | """Take a base64 encoded string and return the decoded string.""" 82 | missing_padding = 4 - len(_input) % 4 83 | if missing_padding: 84 | _input += '=' * missing_padding 85 | if PY2: # pragma: no cover 86 | return base64.decodestring(_input) 87 | elif PY3: # pragma: no cover 88 | if isinstance(_input, bytes): 89 | return base64.b64decode(_input).decode('UTF-8') 90 | elif isinstance(_input, str): 91 | return base64.b64decode(bytearray(_input, encoding='UTF-8')).decode('UTF-8') 92 | 93 | 94 | def read_sudoers(): 95 | """ Read the sudoers entry for the specified user. 96 | 97 | args: 98 | username (str): username. 99 | 100 | returns:`r 101 | str: sudoers entry for the specified user. 102 | """ 103 | sudoers_path = '/etc/sudoers' 104 | rnd_chars = random_string(length=RANDOM_FILE_EXT_LENGTH) 105 | tmp_sudoers_path = '/tmp/sudoers_{0}'.format(rnd_chars) 106 | sudoers_entries = list() 107 | copy_result = execute_command( 108 | shlex.split(str('{0} cp {1} {2}'.format(sudo_check(), sudoers_path, tmp_sudoers_path)))) 109 | result_message = copy_result[0][1].decode('UTF-8') 110 | if 'No such file or directory' not in result_message: 111 | execute_command(shlex.split(str('{0} chmod 755 {1}'.format(sudo_check(), tmp_sudoers_path)))) 112 | with open(tmp_sudoers_path) as tmp_sudoers_file: 113 | for line in tmp_sudoers_file: 114 | stripped = line.strip().replace(os.linesep, '') 115 | if stripped and not stripped.startswith('#'): 116 | sudoers_entries.append(stripped) 117 | execute_command(shlex.split(str('{0} rm {1}'.format(sudo_check(), tmp_sudoers_path)))) 118 | return sudoers_entries 119 | 120 | 121 | def write_sudoers_entry(username=None, sudoers_entry=None): 122 | """Write sudoers entry. 123 | 124 | args: 125 | user (User): Instance of User containing sudoers entry. 126 | 127 | returns: 128 | str: sudoers entry for the specified user. 129 | """ 130 | 131 | sudoers_path = '/etc/sudoers' 132 | rnd_chars = random_string(length=RANDOM_FILE_EXT_LENGTH) 133 | tmp_sudoers_path = '/tmp/sudoers_{0}'.format(rnd_chars) 134 | execute_command( 135 | shlex.split(str('{0} cp {1} {2}'.format(sudo_check(), sudoers_path, tmp_sudoers_path)))) 136 | execute_command( 137 | shlex.split(str('{0} chmod 777 {1}'.format(sudo_check(), tmp_sudoers_path)))) 138 | with open(tmp_sudoers_path, mode=text_type('r')) as tmp_sudoers_file: 139 | sudoers_entries = tmp_sudoers_file.readlines() 140 | sudoers_output = list() 141 | for entry in sudoers_entries: 142 | if entry and not entry.startswith(username): 143 | sudoers_output.append(entry) 144 | if sudoers_entry: 145 | sudoers_output.append('{0} {1}'.format(username, sudoers_entry)) 146 | sudoers_output.append('\n') 147 | with open(tmp_sudoers_path, mode=text_type('w+')) as tmp_sudoers_file: 148 | tmp_sudoers_file.writelines(sudoers_output) 149 | sudoers_check_result = execute_command( 150 | shlex.split(str('{0} {1} -cf {2}'.format(sudo_check(), LINUX_CMD_VISUDO, tmp_sudoers_path)))) 151 | if sudoers_check_result[1] > 0: 152 | raise ValueError(sudoers_check_result[0][1]) 153 | execute_command( 154 | shlex.split(str('{0} cp {1} {2}'.format(sudo_check(), tmp_sudoers_path, sudoers_path)))) 155 | execute_command(shlex.split(str('{0} chown root:root {1}'.format(sudo_check(), sudoers_path)))) 156 | execute_command(shlex.split(str('{0} chmod 440 {1}'.format(sudo_check(), sudoers_path)))) 157 | execute_command(shlex.split(str('{0} rm {1}'.format(sudo_check(), tmp_sudoers_path)))) 158 | 159 | 160 | def remove_sudoers_entry(username=None): 161 | """Remove sudoers entry. 162 | 163 | args: 164 | user (User): Instance of User containing sudoers entry. 165 | 166 | returns: 167 | str: sudoers entry for the specified user. 168 | """ 169 | sudoers_path = '/etc/sudoers' 170 | rnd_chars = random_string(length=RANDOM_FILE_EXT_LENGTH) 171 | tmp_sudoers_path = '/tmp/sudoers_{0}'.format(rnd_chars) 172 | execute_command( 173 | shlex.split(str('{0} cp {1} {2}'.format(sudo_check(), sudoers_path, tmp_sudoers_path)))) 174 | execute_command( 175 | shlex.split(str('{0} chmod 777 {1}'.format(sudo_check(), tmp_sudoers_path)))) 176 | with open(tmp_sudoers_path, mode=text_type('r')) as tmp_sudoers_file: 177 | sudoers_entries = tmp_sudoers_file.readlines() 178 | sudoers_output = list() 179 | for entry in sudoers_entries: 180 | if not entry.startswith(username): 181 | sudoers_output.append(entry) 182 | with open(tmp_sudoers_path, mode=text_type('w+')) as tmp_sudoers_file: 183 | tmp_sudoers_file.writelines(sudoers_output) 184 | execute_command( 185 | shlex.split(str('{0} cp {1} {2}'.format(sudo_check(), tmp_sudoers_path, sudoers_path)))) 186 | execute_command(shlex.split(str('{0} chown root:root {1}'.format(sudo_check(), sudoers_path)))) 187 | execute_command(shlex.split(str('{0} chmod 440 {1}'.format(sudo_check(), sudoers_path)))) 188 | execute_command(shlex.split(str('{0} rm {1}'.format(sudo_check(), tmp_sudoers_path)))) 189 | 190 | 191 | def get_sudoers_entry(username=None, sudoers_entries=None): 192 | """ Find the sudoers entry in the sudoers file for the specified user. 193 | 194 | args: 195 | username (str): username. 196 | sudoers_entries (list): list of lines from the sudoers file. 197 | 198 | returns:`r 199 | str: sudoers entry for the specified user. 200 | """ 201 | for entry in sudoers_entries: 202 | if entry.startswith(username): 203 | return entry.replace(username, '').strip() 204 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. epub3 to make an epub3 31 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 32 | echo. text to make text files 33 | echo. man to make manual pages 34 | echo. texinfo to make Texinfo files 35 | echo. gettext to make PO message catalogs 36 | echo. changes to make an overview over all changed/added/deprecated items 37 | echo. xml to make Docutils-native XML files 38 | echo. pseudoxml to make pseudoxml-XML files for display purposes 39 | echo. linkcheck to check all external links for integrity 40 | echo. doctest to run all doctests embedded in the documentation if enabled 41 | echo. coverage to run coverage check of the documentation if enabled 42 | echo. dummy to check syntax errors of document sources 43 | goto end 44 | ) 45 | 46 | if "%1" == "clean" ( 47 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 48 | del /q /s %BUILDDIR%\* 49 | goto end 50 | ) 51 | 52 | 53 | REM Check if sphinx-build is available and fallback to Python version if any 54 | %SPHINXBUILD% 1>NUL 2>NUL 55 | if errorlevel 9009 goto sphinx_python 56 | goto sphinx_ok 57 | 58 | :sphinx_python 59 | 60 | set SPHINXBUILD=python -m sphinx.__init__ 61 | %SPHINXBUILD% 2> nul 62 | if errorlevel 9009 ( 63 | echo. 64 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 65 | echo.installed, then set the SPHINXBUILD environment variable to point 66 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 67 | echo.may add the Sphinx directory to PATH. 68 | echo. 69 | echo.If you don't have Sphinx installed, grab it from 70 | echo.http://sphinx-doc.org/ 71 | exit /b 1 72 | ) 73 | 74 | :sphinx_ok 75 | 76 | 77 | if "%1" == "html" ( 78 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 79 | if errorlevel 1 exit /b 1 80 | echo. 81 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 82 | goto end 83 | ) 84 | 85 | if "%1" == "dirhtml" ( 86 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 87 | if errorlevel 1 exit /b 1 88 | echo. 89 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 90 | goto end 91 | ) 92 | 93 | if "%1" == "singlehtml" ( 94 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 95 | if errorlevel 1 exit /b 1 96 | echo. 97 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 98 | goto end 99 | ) 100 | 101 | if "%1" == "pickle" ( 102 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 103 | if errorlevel 1 exit /b 1 104 | echo. 105 | echo.Build finished; now you can process the pickle files. 106 | goto end 107 | ) 108 | 109 | if "%1" == "json" ( 110 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 111 | if errorlevel 1 exit /b 1 112 | echo. 113 | echo.Build finished; now you can process the JSON files. 114 | goto end 115 | ) 116 | 117 | if "%1" == "htmlhelp" ( 118 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 119 | if errorlevel 1 exit /b 1 120 | echo. 121 | echo.Build finished; now you can run HTML Help Workshop with the ^ 122 | .hhp project file in %BUILDDIR%/htmlhelp. 123 | goto end 124 | ) 125 | 126 | if "%1" == "qthelp" ( 127 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 128 | if errorlevel 1 exit /b 1 129 | echo. 130 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 131 | .qhcp project file in %BUILDDIR%/qthelp, like this: 132 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\creds.qhcp 133 | echo.To view the help file: 134 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\creds.ghc 135 | goto end 136 | ) 137 | 138 | if "%1" == "devhelp" ( 139 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 140 | if errorlevel 1 exit /b 1 141 | echo. 142 | echo.Build finished. 143 | goto end 144 | ) 145 | 146 | if "%1" == "epub" ( 147 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 148 | if errorlevel 1 exit /b 1 149 | echo. 150 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 151 | goto end 152 | ) 153 | 154 | if "%1" == "epub3" ( 155 | %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 156 | if errorlevel 1 exit /b 1 157 | echo. 158 | echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. 159 | goto end 160 | ) 161 | 162 | if "%1" == "latex" ( 163 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 164 | if errorlevel 1 exit /b 1 165 | echo. 166 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 167 | goto end 168 | ) 169 | 170 | if "%1" == "latexpdf" ( 171 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 172 | cd %BUILDDIR%/latex 173 | make all-pdf 174 | cd %~dp0 175 | echo. 176 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 177 | goto end 178 | ) 179 | 180 | if "%1" == "latexpdfja" ( 181 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 182 | cd %BUILDDIR%/latex 183 | make all-pdf-ja 184 | cd %~dp0 185 | echo. 186 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 187 | goto end 188 | ) 189 | 190 | if "%1" == "text" ( 191 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 192 | if errorlevel 1 exit /b 1 193 | echo. 194 | echo.Build finished. The text files are in %BUILDDIR%/text. 195 | goto end 196 | ) 197 | 198 | if "%1" == "man" ( 199 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 200 | if errorlevel 1 exit /b 1 201 | echo. 202 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 203 | goto end 204 | ) 205 | 206 | if "%1" == "texinfo" ( 207 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 208 | if errorlevel 1 exit /b 1 209 | echo. 210 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 211 | goto end 212 | ) 213 | 214 | if "%1" == "gettext" ( 215 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 216 | if errorlevel 1 exit /b 1 217 | echo. 218 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 219 | goto end 220 | ) 221 | 222 | if "%1" == "changes" ( 223 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 224 | if errorlevel 1 exit /b 1 225 | echo. 226 | echo.The overview file is in %BUILDDIR%/changes. 227 | goto end 228 | ) 229 | 230 | if "%1" == "linkcheck" ( 231 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 232 | if errorlevel 1 exit /b 1 233 | echo. 234 | echo.Link check complete; look for any errors in the above output ^ 235 | or in %BUILDDIR%/linkcheck/output.txt. 236 | goto end 237 | ) 238 | 239 | if "%1" == "doctest" ( 240 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 241 | if errorlevel 1 exit /b 1 242 | echo. 243 | echo.Testing of doctests in the sources finished, look at the ^ 244 | results in %BUILDDIR%/doctest/output.txt. 245 | goto end 246 | ) 247 | 248 | if "%1" == "coverage" ( 249 | %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage 250 | if errorlevel 1 exit /b 1 251 | echo. 252 | echo.Testing of coverage in the sources finished, look at the ^ 253 | results in %BUILDDIR%/coverage/python.txt. 254 | goto end 255 | ) 256 | 257 | if "%1" == "xml" ( 258 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 259 | if errorlevel 1 exit /b 1 260 | echo. 261 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 262 | goto end 263 | ) 264 | 265 | if "%1" == "pseudoxml" ( 266 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 267 | if errorlevel 1 exit /b 1 268 | echo. 269 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 270 | goto end 271 | ) 272 | 273 | if "%1" == "dummy" ( 274 | %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy 275 | if errorlevel 1 exit /b 1 276 | echo. 277 | echo.Build finished. Dummy builder generates no files. 278 | goto end 279 | ) 280 | 281 | :end 282 | -------------------------------------------------------------------------------- /tests/test_users.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import (absolute_import, unicode_literals, print_function) 4 | 5 | import os 6 | 7 | import pytest 8 | 9 | from creds.ssh import PublicKey 10 | from creds.users import (Users, User) 11 | from creds.utils import sudo_check 12 | from tests.sample_data import PUBLIC_KEYS 13 | from .sample_data import SAMPLE_DICT 14 | 15 | 16 | def test_users_yaml_export(tmpdir): 17 | """ Test the exporting of a Users sequence to yaml. """ 18 | export_file = tmpdir.mkdir("export").join("export.yml") 19 | users = Users.from_dict(SAMPLE_DICT) 20 | assert users.export(file_path=export_file.strpath, export_format='yaml') 21 | exported_users = Users.from_yaml(export_file.strpath) 22 | for index, _ in enumerate(users): 23 | assert users[index].name == exported_users[index].name 24 | assert users[index].passwd == exported_users[index].passwd 25 | assert users[index].uid == exported_users[index].uid 26 | assert users[index].gid == exported_users[index].gid 27 | assert users[index].gecos == exported_users[index].gecos 28 | assert users[index].home_dir == exported_users[index].home_dir 29 | assert users[index].shell == exported_users[index].shell 30 | for pk_index, _ in enumerate(users[index].public_keys): 31 | assert users[index].public_keys[pk_index].raw == exported_users[index].public_keys[pk_index].raw 32 | assert users[index].public_keys[pk_index].b64encoded == exported_users[index].public_keys[ 33 | pk_index].b64encoded 34 | 35 | 36 | def test_users_json_export(tmpdir): 37 | """ Test the exporting of a Users sequence to yaml. """ 38 | export_file = tmpdir.mkdir("export").join("export.json") 39 | users = Users.from_dict(SAMPLE_DICT) 40 | assert users.export(file_path=export_file.strpath, export_format='json') 41 | exported_users = Users.from_json(export_file.strpath) 42 | for index, _ in enumerate(users): 43 | assert users[index].name == exported_users[index].name 44 | assert users[index].passwd == exported_users[index].passwd 45 | assert users[index].uid == exported_users[index].uid 46 | assert users[index].gid == exported_users[index].gid 47 | assert users[index].gecos == exported_users[index].gecos 48 | assert users[index].home_dir == exported_users[index].home_dir 49 | assert users[index].shell == exported_users[index].shell 50 | for pk_index, _ in enumerate(users[index].public_keys): 51 | assert users[index].public_keys[pk_index].raw == exported_users[index].public_keys[pk_index].raw 52 | assert users[index].public_keys[pk_index].b64encoded == exported_users[index].public_keys[ 53 | pk_index].b64encoded 54 | 55 | 56 | def test_users_instance_creation(): 57 | """ Test creation of instances of User and add to Users collection. """ 58 | input_user_list = Users() 59 | input_user_list.append( 60 | User(name='rod', uid=1001, gid=1001, gecos='rod comment', home_dir='/home/rod', shell='/bin/sh')) 61 | input_user_list.append( 62 | User(name='jane', uid=1002, gid=1002, gecos='jane comment', home_dir='/home/jane', shell='/bin/bash')) 63 | input_user_list.append( 64 | User(name='freddy', uid=1003, gid=1003, gecos='freddy comment', home_dir='/home/freddy', shell='/bin/false')) 65 | assert len(input_user_list) == 3 66 | 67 | 68 | def test_users_del_method(): 69 | users = Users() 70 | users.append( 71 | User(name='rod', uid=1001, gid=1001, gecos='rod comment', home_dir='/home/rod', shell='/bin/sh')) 72 | users.append( 73 | User(name='jane', uid=1002, gid=1002, gecos='jane comment', home_dir='/home/jane', shell='/bin/sh')) 74 | assert len(users) == 2 75 | del users[0] 76 | assert len(users) == 1 77 | 78 | 79 | def test_users_insert_method(): 80 | users = Users() 81 | users.append( 82 | User(name='rod', uid=1001, gid=1001, gecos='rod comment', home_dir='/home/rod', shell='/bin/sh')) 83 | users.append( 84 | User(name='jane', uid=1002, gid=1002, gecos='jane comment', home_dir='/home/jane', shell='/bin/sh')) 85 | users.insert(0, User(name='freddy', uid=1003, gid=1003, gecos='freddy comment', 86 | home_dir='/home/freddy', shell='/bin/false')) 87 | assert len(users) == 3 88 | with pytest.raises(TypeError): 89 | users.insert(0, dict(name='freddy', uid=1003, gid=1003, gecos='freddy comment', 90 | home_dir='/home/freddy', shell='/bin/false')) 91 | 92 | 93 | def test_get_users_from_passwd(): 94 | """ Test creation of a Users collection based on users found in the passwd file. """ 95 | users = Users.from_passwd() 96 | assert isinstance(users, Users) 97 | 98 | 99 | def test_get_users_from_dict(): 100 | """ Test creation of a Users collection based on a predefined dict. """ 101 | users = Users.from_dict(input_dict=SAMPLE_DICT) 102 | assert isinstance(users, Users) 103 | assert isinstance(users[0], User) 104 | assert isinstance(users[0].uid, int) 105 | 106 | 107 | def test_get_users_from_yaml(): 108 | """ Test creation of a Users collection based on a yaml document. """ 109 | users = Users.from_yaml(file_path='{0}/yaml_input/basic.yml'.format(os.path.dirname(os.path.abspath(__file__)))) 110 | assert isinstance(users, Users) 111 | assert isinstance(users[0], User) 112 | assert isinstance(users[0].uid, int) 113 | assert users[0].name == 'peter' 114 | assert users[0].home_dir == '/home/bigal' 115 | 116 | 117 | def test_get_users_from_json(): 118 | """ Test creation of a Users collection based on a json document. """ 119 | users = Users.from_json(file_path='{0}/json_input/basic.json'.format(os.path.dirname(os.path.abspath(__file__)))) 120 | assert isinstance(users, Users) 121 | assert isinstance(users[0], User) 122 | assert isinstance(users[0].uid, int) 123 | 124 | 125 | def test_get_users_from_invalid_yaml(): 126 | """ Test a ValueError is raised if loading a yaml file of users with invalid syntax. """ 127 | with pytest.raises(ValueError): 128 | Users.from_yaml(file_path='{0}/yaml_input/invalid.yml'.format(os.path.dirname(os.path.abspath(__file__)))) 129 | 130 | 131 | def test_get_users_from_invalid_json(): 132 | """ Test a ValueError is raised if loading a json file of users with invalid syntax. """ 133 | with pytest.raises(ValueError): 134 | Users.from_json(file_path='{0}/json_input/invalid.json'.format(os.path.dirname(os.path.abspath(__file__)))) 135 | 136 | 137 | def test_users_repr(): 138 | users = Users() 139 | users.append(User(name='rod', uid=1001, gid=1001, gecos='rod comment', home_dir='/home/rod', shell='/bin/sh')) 140 | assert str(users) == users.__repr__() 141 | 142 | 143 | def test_users_add_and_remove(): 144 | rod = User(name='rod', uid=1001, gid=1001, gecos='rod comment', home_dir='/home/rod', shell='/bin/sh') 145 | users = Users() 146 | users.append(rod) 147 | assert len(users) == 1 148 | jane = User(name='jane') 149 | users.append(jane) 150 | assert len(users) == 2 151 | users.remove(username='jane') 152 | assert len(users) == 1 153 | 154 | 155 | def test_users_set_item(): 156 | rod = User(name='rod', uid=1001, gid=1001, gecos='rod comment', home_dir='/home/rod', shell='/bin/sh') 157 | users = Users() 158 | users.append(rod) 159 | users[0] = User(name='jane', uid=1002, gid=1002, gecos='jane comment', home_dir='/home/jane', shell='/bin/sh') 160 | assert len(users) == 1 161 | assert users[0].name == 'jane' 162 | 163 | 164 | def test_users_filters(): 165 | users = Users() 166 | users.append(User(name='rod', uid=1001, gid=1001, gecos='rod comment', home_dir='/home/rod', shell='/bin/sh')) 167 | assert not users.describe_users(users_filter=dict(name='nobody')) 168 | assert not users.describe_users(users_filter=dict(uid=1000)) 169 | 170 | 171 | def test_user_instance_creation(): 172 | name = 'Fred' 173 | uid = 1024 174 | gid = 1024 175 | gecos = 'Fred Bloggs' 176 | home_dir = '/home/fred' 177 | shell = '/bin/false' 178 | public_key = PublicKey(raw=PUBLIC_KEYS[0]['raw']) 179 | test_user = User(name=name, uid=uid, gid=gid, gecos=gecos, home_dir=home_dir, shell=shell, public_keys=[public_key]) 180 | assert test_user.name == name 181 | assert test_user.uid == uid 182 | assert test_user.gid == gid 183 | # Ensure gecos is surrounded with double quotes 184 | assert test_user.gecos.startswith('\"') and test_user.gecos.endswith('\"') 185 | assert test_user.home_dir == home_dir 186 | assert test_user.shell == shell 187 | assert test_user.public_keys == [public_key] 188 | 189 | 190 | def test_user_instance_creation_precommented_gecos(): 191 | name = 'Fred' 192 | uid = 1024 193 | gid = 1024 194 | gecos = '\'Fred Bloggs\'' 195 | home_dir = '/home/fred' 196 | shell = '/bin/false' 197 | test_user = User(name=name, uid=uid, gid=gid, gecos=gecos, home_dir=home_dir, shell=shell) 198 | assert test_user.name == name 199 | assert test_user.uid == uid 200 | assert test_user.gid == gid 201 | # Ensure gecos is surrounded with double quotes 202 | assert test_user.gecos.startswith('\"') and test_user.gecos.endswith('\"') 203 | assert test_user.home_dir == home_dir 204 | assert test_user.shell == shell 205 | 206 | 207 | def test_user_instance_with_missing_gecos(): 208 | rod = User(name='rod', uid=1001, gid=1001, home_dir='/home/rod', shell='/bin/sh') 209 | assert rod.gecos == None 210 | 211 | 212 | def test_platform_detection(monkeypatch): 213 | monkeypatch.setattr("platform.system", lambda: 'Darwin') 214 | with pytest.raises(SystemExit): 215 | Users() 216 | 217 | 218 | def test_user_detection(monkeypatch): 219 | monkeypatch.setattr("os.geteuid", lambda: 1) 220 | assert sudo_check().endswith('sudo') 221 | monkeypatch.setattr("os.geteuid", lambda: 0) 222 | assert sudo_check() == '' 223 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # creds documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Aug 3 18:50:38 2016. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | import os 20 | import sys 21 | 22 | sys.path.insert(0, os.path.abspath('../lib')) 23 | from creds import __version__ 24 | 25 | 26 | 27 | # import sphinx_rtd_theme 28 | 29 | # -- General configuration ------------------------------------------------ 30 | 31 | # If your documentation needs a minimal Sphinx version, state it here. 32 | # 33 | # needs_sphinx = '1.0' 34 | 35 | # Add any Sphinx extension module names here, as strings. They can be 36 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 37 | # ones. 38 | extensions = [ 39 | 'sphinx.ext.autodoc', 40 | 'sphinx.ext.autosummary', 41 | 'sphinx.ext.todo', 42 | 'sphinx.ext.viewcode', 43 | 'sphinx.ext.napoleon', 44 | ] 45 | # Napoleon settings 46 | napoleon_google_docstring = True 47 | napoleon_numpy_docstring = True 48 | napoleon_include_private_with_doc = False 49 | napoleon_include_special_with_doc = True 50 | napoleon_use_admonition_for_examples = False 51 | napoleon_use_admonition_for_notes = False 52 | napoleon_use_admonition_for_references = False 53 | napoleon_use_ivar = False 54 | napoleon_use_param = True 55 | napoleon_use_rtype = True 56 | 57 | # Add any paths that contain templates here, relative to this directory. 58 | templates_path = ['_templates'] 59 | 60 | # The suffix(es) of source filenames. 61 | # You can specify multiple suffix as a list of string: 62 | # 63 | # source_suffix = ['.rst', '.md'] 64 | source_suffix = '.rst' 65 | 66 | # The encoding of source files. 67 | # 68 | # source_encoding = 'utf-8-sig' 69 | 70 | # The master toctree document. 71 | master_doc = 'index' 72 | 73 | # General information about the project. 74 | project = u'creds' 75 | copyright = u'2016, Jon Hadfield' 76 | author = u'Jon Hadfield ' 77 | 78 | # The version info for the project you're documenting, acts as replacement for 79 | # |version| and |release|, also used in various other places throughout the 80 | # built documents. 81 | # 82 | # The short X.Y version. 83 | version = __version__ 84 | # The full version, including alpha/beta/rc tags. 85 | release = __version__ 86 | 87 | # The language for content autogenerated by Sphinx. Refer to documentation 88 | # for a list of supported languages. 89 | # 90 | # This is also used if you do content translation via gettext catalogs. 91 | # Usually you set "language" from the command line for these cases. 92 | language = 'en' 93 | 94 | # There are two options for replacing |today|: either, you set today to some 95 | # non-false value, then it is used: 96 | # 97 | # today = '' 98 | # 99 | # Else, today_fmt is used as the format for a strftime call. 100 | # 101 | # today_fmt = '%B %d, %Y' 102 | 103 | # List of patterns, relative to source directory, that match files and 104 | # directories to ignore when looking for source files. 105 | # This patterns also effect to html_static_path and html_extra_path 106 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 107 | 108 | # The reST default role (used for this markup: `text`) to use for all 109 | # documents. 110 | # 111 | # default_role = None 112 | 113 | # If true, '()' will be appended to :func: etc. cross-reference text. 114 | # 115 | # add_function_parentheses = True 116 | 117 | # If true, the current module name will be prepended to all description 118 | # unit titles (such as .. function::). 119 | # 120 | # add_module_names = True 121 | 122 | # If true, sectionauthor and moduleauthor directives will be shown in the 123 | # output. They are ignored by default. 124 | # 125 | # show_authors = False 126 | 127 | # The name of the Pygments (syntax highlighting) style to use. 128 | pygments_style = 'sphinx' 129 | 130 | # A list of ignored prefixes for module index sorting. 131 | # modindex_common_prefix = [] 132 | 133 | # If true, keep warnings as "system message" paragraphs in the built documents. 134 | # keep_warnings = False 135 | 136 | # If true, `todo` and `todoList` produce output, else they produce nothing. 137 | todo_include_todos = True 138 | 139 | # -- Options for HTML output ---------------------------------------------- 140 | 141 | # The theme to use for HTML and HTML Help pages. See the documentation for 142 | # a list of builtin themes. 143 | # 144 | html_theme = 'nature' 145 | 146 | # Theme options are theme-specific and customize the look and feel of a theme 147 | # further. For a list of options available for each theme, see the 148 | # documentation. 149 | # 150 | html_theme_options = { 151 | # 'show_powered_by': False, 152 | # 'github_user': 'jonhadfield', 153 | # 'github_repo': 'creds', 154 | # 'github_banner': True, 155 | # 'show_related': False 156 | } 157 | 158 | # Add any paths that contain custom themes here, relative to this directory. 159 | # html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 160 | 161 | # The name for this set of Sphinx documents. 162 | # " v documentation" by default. 163 | # 164 | # html_title = u'creds v' 165 | 166 | # A shorter title for the navigation bar. Default is the same as html_title. 167 | # 168 | # html_short_title = None 169 | 170 | # The name of an image file (relative to this directory) to place at the top 171 | # of the sidebar. 172 | # 173 | # html_logo = None 174 | 175 | # The name of an image file (relative to this directory) to use as a favicon of 176 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 177 | # pixels large. 178 | # 179 | # html_favicon = None 180 | 181 | # Add any paths that contain custom static files (such as style sheets) here, 182 | # relative to this directory. They are copied after the builtin static files, 183 | # so a file named "default.css" will overwrite the builtin "default.css". 184 | html_static_path = ['_static'] 185 | 186 | # Add any extra paths that contain custom files (such as robots.txt or 187 | # .htaccess) here, relative to this directory. These files are copied 188 | # directly to the root of the documentation. 189 | # 190 | # html_extra_path = [] 191 | 192 | # If not None, a 'Last updated on:' timestamp is inserted at every page 193 | # bottom, using the given strftime format. 194 | # The empty string is equivalent to '%b %d, %Y'. 195 | # 196 | # html_last_updated_fmt = None 197 | 198 | # If true, SmartyPants will be used to convert quotes and dashes to 199 | # typographically correct entities. 200 | # 201 | # html_use_smartypants = True 202 | 203 | # Custom sidebar templates, maps document names to template names. 204 | # 205 | # html_sidebars = {} 206 | 207 | # Additional templates that should be rendered to pages, maps page names to 208 | # template names. 209 | # 210 | # html_additional_pages = {} 211 | 212 | # If false, no module index is generated. 213 | # 214 | # html_domain_indices = True 215 | 216 | # If false, no index is generated. 217 | # 218 | # html_use_index = True 219 | 220 | # If true, the index is split into individual pages for each letter. 221 | # 222 | # html_split_index = False 223 | 224 | # If true, links to the reST sources are added to the pages. 225 | # 226 | html_show_sourcelink = False 227 | 228 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 229 | # 230 | html_show_sphinx = False 231 | 232 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 233 | # 234 | # html_show_copyright = True 235 | 236 | # If true, an OpenSearch description file will be output, and all pages will 237 | # contain a tag referring to it. The value of this option must be the 238 | # base URL from which the finished HTML is served. 239 | # 240 | # html_use_opensearch = '' 241 | 242 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 243 | # html_file_suffix = None 244 | 245 | # Language to be used for generating the HTML full-text search index. 246 | # Sphinx supports the following languages: 247 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 248 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' 249 | # 250 | # html_search_language = 'en' 251 | 252 | # A dictionary with options for the search language support, empty by default. 253 | # 'ja' uses this config value. 254 | # 'zh' user can custom change `jieba` dictionary path. 255 | # 256 | # html_search_options = {'type': 'default'} 257 | 258 | # The name of a javascript file (relative to the configuration directory) that 259 | # implements a search results scorer. If empty, the default will be used. 260 | # 261 | # html_search_scorer = 'scorer.js' 262 | 263 | # Output file base name for HTML help builder. 264 | htmlhelp_basename = 'credsdoc' 265 | 266 | # -- Options for LaTeX output --------------------------------------------- 267 | 268 | latex_elements = { 269 | # The paper size ('letterpaper' or 'a4paper'). 270 | # 271 | # 'papersize': 'letterpaper', 272 | 273 | # The font size ('10pt', '11pt' or '12pt'). 274 | # 275 | # 'pointsize': '10pt', 276 | 277 | # Additional stuff for the LaTeX preamble. 278 | # 279 | # 'preamble': '', 280 | 281 | # Latex figure (float) alignment 282 | # 283 | # 'figure_align': 'htbp', 284 | } 285 | 286 | # Grouping the document tree into LaTeX files. List of tuples 287 | # (source start file, target name, title, 288 | # author, documentclass [howto, manual, or own class]). 289 | latex_documents = [ 290 | (master_doc, 'creds.tex', u'creds Documentation', 291 | u'Jon Hadfield', 'manual'), 292 | ] 293 | 294 | # The name of an image file (relative to this directory) to place at the top of 295 | # the title page. 296 | # 297 | # latex_logo = None 298 | 299 | # For "manual" documents, if this is true, then toplevel headings are parts, 300 | # not chapters. 301 | # 302 | # latex_use_parts = False 303 | 304 | # If true, show page references after internal links. 305 | # 306 | # latex_show_pagerefs = False 307 | 308 | # If true, show URL addresses after external links. 309 | # 310 | # latex_show_urls = False 311 | 312 | # Documents to append as an appendix to all manuals. 313 | # 314 | # latex_appendices = [] 315 | 316 | # It false, will not define \strong, \code, itleref, \crossref ... but only 317 | # \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added 318 | # packages. 319 | # 320 | # latex_keep_old_macro_names = True 321 | 322 | # If false, no module index is generated. 323 | # 324 | # latex_domain_indices = True 325 | 326 | 327 | # -- Options for manual page output --------------------------------------- 328 | 329 | # One entry per manual page. List of tuples 330 | # (source start file, name, description, authors, manual section). 331 | man_pages = [ 332 | (master_doc, 'creds', u'creds Documentation', 333 | [author], 1) 334 | ] 335 | 336 | # If true, show URL addresses after external links. 337 | # 338 | # man_show_urls = False 339 | 340 | 341 | # -- Options for Texinfo output ------------------------------------------- 342 | 343 | # Grouping the document tree into Texinfo files. List of tuples 344 | # (source start file, target name, title, author, 345 | # dir menu entry, description, category) 346 | texinfo_documents = [ 347 | (master_doc, 'creds', u'creds Documentation', 348 | author, 'creds', 'One line description of project.', 349 | 'Miscellaneous'), 350 | ] 351 | 352 | # Documents to append as an appendix to all manuals. 353 | # 354 | # texinfo_appendices = [] 355 | 356 | # If false, no module index is generated. 357 | # 358 | # texinfo_domain_indices = True 359 | 360 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 361 | # 362 | # texinfo_show_urls = 'footnote' 363 | 364 | # If true, do not generate a @detailmenu in the "Top" node's menu. 365 | # 366 | # texinfo_no_detailmenu = False 367 | 368 | 369 | # -- Options for Epub output ---------------------------------------------- 370 | 371 | # Bibliographic Dublin Core info. 372 | epub_title = project 373 | epub_author = author 374 | epub_publisher = author 375 | epub_copyright = copyright 376 | 377 | # The basename for the epub file. It defaults to the project name. 378 | # epub_basename = project 379 | 380 | # The HTML theme for the epub output. Since the default themes are not 381 | # optimized for small screen space, using the same theme for HTML and epub 382 | # output is usually not wise. This defaults to 'epub', a theme designed to save 383 | # visual space. 384 | # 385 | # epub_theme = 'epub' 386 | 387 | # The language of the text. It defaults to the language option 388 | # or 'en' if the language is not set. 389 | # 390 | # epub_language = '' 391 | 392 | # The scheme of the identifier. Typical schemes are ISBN or URL. 393 | # epub_scheme = '' 394 | 395 | # The unique identifier of the text. This can be a ISBN number 396 | # or the project homepage. 397 | # 398 | # epub_identifier = '' 399 | 400 | # A unique identification for the text. 401 | # 402 | # epub_uid = '' 403 | 404 | # A tuple containing the cover image and cover page html template filenames. 405 | # 406 | # epub_cover = () 407 | 408 | # A sequence of (type, uri, title) tuples for the guide element of content.opf. 409 | # 410 | # epub_guide = () 411 | 412 | # HTML files that should be inserted before the pages created by sphinx. 413 | # The format is a list of tuples containing the path and title. 414 | # 415 | # epub_pre_files = [] 416 | 417 | # HTML files that should be inserted after the pages created by sphinx. 418 | # The format is a list of tuples containing the path and title. 419 | # 420 | # epub_post_files = [] 421 | 422 | # A list of files that should not be packed into the epub file. 423 | epub_exclude_files = ['search.html'] 424 | 425 | # The depth of the table of contents in toc.ncx. 426 | # 427 | # epub_tocdepth = 3 428 | 429 | # Allow duplicate toc entries. 430 | # 431 | # epub_tocdup = True 432 | 433 | # Choose between 'default' and 'includehidden'. 434 | # 435 | # epub_tocscope = 'default' 436 | 437 | # Fix unsupported image types using the Pillow. 438 | # 439 | # epub_fix_images = False 440 | 441 | # Scale large images. 442 | # 443 | # epub_max_image_width = 0 444 | 445 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 446 | # 447 | # epub_show_urls = 'inline' 448 | 449 | # If false, no index is generated. 450 | # 451 | # epub_use_index = True 452 | 453 | 454 | def remove_module_docstring(app, what, name, obj, options, lines): 455 | if what == "module" and name.startswith('creds.'): 456 | del lines[:] 457 | 458 | 459 | def setup(app): 460 | app.connect("autodoc-process-docstring", remove_module_docstring) 461 | 462 | -------------------------------------------------------------------------------- /lib/creds/users.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """This module contains the classes for User (a user's details) and Users (a collection of User instances).""" 3 | from __future__ import unicode_literals 4 | 5 | import io 6 | import json 7 | import os 8 | import shlex 9 | import sys 10 | 11 | if sys.version_info[:2] >= (3, 8): 12 | from collections.abc import MutableSequence 13 | else: 14 | from collections import MutableSequence 15 | 16 | from creds.constants import (SUPPORTED_PLATFORMS, UID_MAX, UID_MIN, 17 | LINUX_CMD_USERADD, LINUX_CMD_USERDEL, LINUX_CMD_USERMOD, 18 | FREEBSD_CMD_PW) 19 | from creds.ssh import PublicKey 20 | from creds.ssh import read_authorized_keys 21 | from creds.utils import (get_platform, sudo_check, read_sudoers, get_sudoers_entry, get_missing_commands) 22 | from external.six import text_type 23 | 24 | 25 | class User(object): 26 | """Representation of a user and their related credentials.""" 27 | 28 | def __init__(self, name=None, passwd=None, uid=None, gid=None, gecos=None, 29 | home_dir=None, shell=None, public_keys=None, sudoers_entry=None): 30 | """Make a user. 31 | 32 | args: 33 | name (str): user name. 34 | passwd (str, optional): password 35 | uid (int, optional): user id 36 | gid (int, optional): group id 37 | gecos (str): GECOS field 38 | home_dir (str): home directory 39 | shell (str): shell 40 | public_keys (list): list of public key instances 41 | sudoers_entry (str): an entry in sudoers 42 | """ 43 | self.name = name 44 | self.passwd = passwd 45 | self.uid = uid 46 | self.gid = gid 47 | self._gecos = gecos 48 | self.home_dir = home_dir 49 | self.shell = shell 50 | self.public_keys = public_keys 51 | self.sudoers_entry = sudoers_entry 52 | 53 | @property 54 | def gecos(self): 55 | """Force double quoted gecos. 56 | 57 | returns: 58 | str: The double quoted gecos. 59 | """ 60 | if not self._gecos: 61 | return None 62 | if self._gecos.startswith(text_type('\'')) and self._gecos.endswith(text_type('\'')): 63 | self._gecos = '\"{0}\"'.format(self._gecos[1:-1]) 64 | return self._gecos 65 | elif self._gecos.startswith(text_type('\"')) and self._gecos.endswith(text_type('\"')): 66 | return self._gecos 67 | else: 68 | return '\"{0}\"'.format(self._gecos) 69 | 70 | def __str__(self): 71 | return self.__repr__() 72 | 73 | def __repr__(self): 74 | return ''.format(self.name) 75 | 76 | def to_dict(self): 77 | """ Return the user as a dict. """ 78 | public_keys = [public_key.b64encoded for public_key in self.public_keys] 79 | return dict(name=self.name, passwd=self.passwd, uid=self.uid, gid=self.gid, gecos=self.gecos, 80 | home_dir=self.home_dir, shell=self.shell, public_keys=public_keys) 81 | 82 | 83 | class Users(MutableSequence): 84 | """A collection of users and methods to manage them.""" 85 | 86 | def __init__(self, oktypes=User): 87 | """Create instance of Users collection. 88 | 89 | args: 90 | oktypes (type): The acceptable types of instances.. 91 | """ 92 | platform = get_platform() 93 | # Check platform is supported 94 | if not platform in SUPPORTED_PLATFORMS: 95 | sys.exit('Linux, FreeBSD and OpenBSD are currently the only supported platforms for this library.') 96 | # Check OS commands are available for managing users 97 | missing_commands = get_missing_commands(platform) 98 | if missing_commands: 99 | sys.exit('Unable to find commands: {0}.\nPlease check PATH.'.format(', '.join(missing_commands))) 100 | 101 | self.oktypes = oktypes 102 | self._user_list = list() 103 | 104 | def check(self, value): 105 | """Check types.""" 106 | if not isinstance(value, self.oktypes): 107 | raise TypeError 108 | 109 | def __iter__(self): 110 | for user in self._user_list: 111 | yield user 112 | 113 | def __len__(self): 114 | return len(self._user_list) 115 | 116 | def __str__(self): 117 | return self.__repr__() 118 | 119 | def __getitem__(self, index): 120 | return self._user_list[index] 121 | 122 | def insert(self, index, value): 123 | """Insert an instance of User into the collection.""" 124 | self.check(value) 125 | self._user_list.insert(index, value) 126 | 127 | def __setitem__(self, index, value): 128 | self.check(value) 129 | self._user_list[index] = value 130 | 131 | def __repr__(self): 132 | user_list = ['{0}'.format(user) for user in self._user_list] 133 | output = '\n'.join(user_list) 134 | return output 135 | 136 | def __delitem__(self, index): 137 | del self._user_list[index] 138 | 139 | def remove(self, username=None): 140 | """Remove User instance based on supplied user name.""" 141 | self._user_list = [user for user in self._user_list if user.name != username] 142 | 143 | def describe_users(self, users_filter=None): 144 | """Return a list of users matching a filter (if provided).""" 145 | user_list = Users(oktypes=User) 146 | for user in self._user_list: 147 | if users_filter and (users_filter.get('name') == user.name or users_filter.get('uid') == user.uid): 148 | user_list.append(user) 149 | return user_list 150 | 151 | @classmethod 152 | def from_dict(cls, input_dict=None): 153 | """Create collection from dictionary content.""" 154 | return cls.construct_user_list(raw_users=input_dict.get('users')) 155 | 156 | @classmethod 157 | def from_yaml(cls, file_path=None): 158 | """Create collection from a YAML file.""" 159 | try: 160 | import yaml 161 | except ImportError: # pragma: no cover 162 | yaml = None 163 | if not yaml: 164 | import sys 165 | sys.exit('PyYAML is not installed, but is required in order to parse YAML files.' 166 | '\nTo install, run:\n$ pip install PyYAML\nor visit' 167 | ' http://pyyaml.org/wiki/PyYAML for instructions.') 168 | 169 | with io.open(file_path, encoding=text_type('utf-8')) as stream: 170 | users_yaml = yaml.safe_load(stream) 171 | if isinstance(users_yaml, dict): 172 | return cls.construct_user_list(raw_users=users_yaml.get('users')) 173 | else: 174 | raise ValueError('No YAML object could be decoded') 175 | 176 | @classmethod 177 | def from_json(cls, file_path=None): 178 | """Create collection from a JSON file.""" 179 | with io.open(file_path, encoding=text_type('utf-8')) as stream: 180 | try: 181 | users_json = json.load(stream) 182 | except ValueError: 183 | raise ValueError('No JSON object could be decoded') 184 | return cls.construct_user_list(raw_users=users_json.get('users')) 185 | 186 | @staticmethod 187 | def from_passwd(uid_min=None, uid_max=None): 188 | """Create collection from locally discovered data, e.g. /etc/passwd.""" 189 | import pwd 190 | users = Users(oktypes=User) 191 | passwd_list = pwd.getpwall() 192 | if not uid_min: 193 | uid_min = UID_MIN 194 | if not uid_max: 195 | uid_max = UID_MAX 196 | sudoers_entries = read_sudoers() 197 | for pwd_entry in passwd_list: 198 | if uid_min <= pwd_entry.pw_uid <= uid_max: 199 | user = User(name=text_type(pwd_entry.pw_name), 200 | passwd=text_type(pwd_entry.pw_passwd), 201 | uid=pwd_entry.pw_uid, 202 | gid=pwd_entry.pw_gid, 203 | gecos=text_type(pwd_entry.pw_gecos), 204 | home_dir=text_type(pwd_entry.pw_dir), 205 | shell=text_type(pwd_entry.pw_shell), 206 | public_keys=read_authorized_keys(username=pwd_entry.pw_name), 207 | sudoers_entry=get_sudoers_entry(username=pwd_entry.pw_name, 208 | sudoers_entries=sudoers_entries)) 209 | users.append(user) 210 | return users 211 | 212 | @staticmethod 213 | def construct_user_list(raw_users=None): 214 | """Construct a list of User objects from a list of dicts.""" 215 | users = Users(oktypes=User) 216 | for user_dict in raw_users: 217 | public_keys = None 218 | if user_dict.get('public_keys'): 219 | public_keys = [PublicKey(b64encoded=x, raw=None) 220 | for x in user_dict.get('public_keys')] 221 | users.append(User(name=user_dict.get('name'), 222 | passwd=user_dict.get('passwd'), 223 | uid=user_dict.get('uid'), 224 | gid=user_dict.get('gid'), 225 | home_dir=user_dict.get('home_dir'), 226 | gecos=user_dict.get('gecos'), 227 | shell=user_dict.get('shell'), 228 | public_keys=public_keys, 229 | sudoers_entry=user_dict.get('sudoers_entry'))) 230 | return users 231 | 232 | def to_dict(self): 233 | """ Return a dict of the users. """ 234 | users = dict(users=list()) 235 | for user in self: 236 | users['users'].append(user.to_dict()) 237 | return users 238 | 239 | def export(self, file_path=None, export_format=None): 240 | """ Write the users to a file. """ 241 | with io.open(file_path, mode='w', encoding="utf-8") as export_file: 242 | if export_format == 'yaml': 243 | import yaml 244 | yaml.safe_dump(self.to_dict(), export_file, default_flow_style=False) 245 | elif export_format == 'json': 246 | export_file.write(text_type(json.dumps(self.to_dict(), ensure_ascii=False))) 247 | return True 248 | 249 | 250 | def generate_add_user_command(proposed_user=None, manage_home=None): 251 | """Generate command to add a user. 252 | 253 | args: 254 | proposed_user (User): User 255 | manage_home: bool 256 | 257 | returns: 258 | list: The command string split into shell-like syntax 259 | """ 260 | command = None 261 | if get_platform() in ('Linux', 'OpenBSD'): 262 | command = '{0} {1}'.format(sudo_check(), LINUX_CMD_USERADD) 263 | if proposed_user.uid: 264 | command = '{0} -u {1}'.format(command, proposed_user.uid) 265 | if proposed_user.gid: 266 | command = '{0} -g {1}'.format(command, proposed_user.gid) 267 | if proposed_user.gecos: 268 | command = '{0} -c \'{1}\''.format(command, proposed_user.gecos) 269 | if manage_home: 270 | if proposed_user.home_dir: 271 | if os.path.exists(proposed_user.home_dir): 272 | command = '{0} -d {1}'.format(command, proposed_user.home_dir) 273 | elif not os.path.exists('/home/{0}'.format(proposed_user.name)): 274 | command = '{0} -m'.format(command) 275 | if proposed_user.shell: 276 | command = '{0} -s {1}'.format(command, proposed_user.shell) 277 | command = '{0} {1}'.format(command, proposed_user.name) 278 | elif get_platform() == 'FreeBSD': # pragma: FreeBSD 279 | command = '{0} {1} useradd'.format(sudo_check(), FREEBSD_CMD_PW) 280 | if proposed_user.uid: 281 | command = '{0} -u {1}'.format(command, proposed_user.uid) 282 | if proposed_user.gid: 283 | command = '{0} -g {1}'.format(command, proposed_user.gid) 284 | if proposed_user.gecos: 285 | command = '{0} -c \'{1}\''.format(command, proposed_user.gecos) 286 | if manage_home: 287 | if proposed_user.home_dir: 288 | command = '{0} -d {1}'.format(command, proposed_user.home_dir) 289 | else: 290 | command = '{0} -m'.format(command) 291 | if proposed_user.shell: 292 | command = '{0} -s {1}'.format(command, proposed_user.shell) 293 | command = '{0} -n {1}'.format(command, proposed_user.name) 294 | 295 | if command: 296 | return shlex.split(str(command)) 297 | 298 | 299 | def generate_modify_user_command(task=None, manage_home=None): 300 | """Generate command to modify existing user to become the proposed user. 301 | 302 | args: 303 | task (dict): A proposed user and the differences between it and the existing user 304 | 305 | returns: 306 | list: The command string split into shell-like syntax 307 | """ 308 | name = task['proposed_user'].name 309 | comparison_result = task['user_comparison']['result'] 310 | command = None 311 | if get_platform() in ('Linux', 'OpenBSD'): 312 | command = '{0} {1}'.format(sudo_check(), LINUX_CMD_USERMOD) 313 | if comparison_result.get('replacement_uid_value'): 314 | command = '{0} -u {1}'.format(command, comparison_result.get('replacement_uid_value')) 315 | if comparison_result.get('replacement_gid_value'): 316 | command = '{0} -g {1}'.format(command, comparison_result.get('replacement_gid_value')) 317 | if comparison_result.get('replacement_gecos_value'): 318 | command = '{0} -c {1}'.format(command, comparison_result.get('replacement_gecos_value')) 319 | if comparison_result.get('replacement_shell_value'): 320 | command = '{0} -s {1}'.format(command, comparison_result.get('replacement_shell_value')) 321 | if manage_home and comparison_result.get('replacement_home_dir_value'): 322 | command = '{0} -d {1}'.format(command, comparison_result.get('replacement_home_dir_value')) 323 | command = '{0} {1}'.format(command, name) 324 | if get_platform() == 'FreeBSD': # pragma: FreeBSD 325 | command = '{0} {1} usermod'.format(sudo_check(), FREEBSD_CMD_PW) 326 | if comparison_result.get('replacement_uid_value'): 327 | command = '{0} -u {1}'.format(command, comparison_result.get('replacement_uid_value')) 328 | if comparison_result.get('replacement_gid_value'): 329 | command = '{0} -g {1}'.format(command, comparison_result.get('replacement_gid_value')) 330 | if comparison_result.get('replacement_gecos_value'): 331 | command = '{0} -c {1}'.format(command, comparison_result.get('replacement_gecos_value')) 332 | if comparison_result.get('replacement_shell_value'): 333 | command = '{0} -s {1}'.format(command, comparison_result.get('replacement_shell_value')) 334 | if manage_home and comparison_result.get('replacement_home_dir_value'): 335 | command = '{0} -d {1}'.format(command, comparison_result.get('replacement_home_dir_value')) 336 | command = '{0} -n {1}'.format(command, name) 337 | if command: 338 | return shlex.split(str(command)) 339 | 340 | 341 | def generate_delete_user_command(username=None, manage_home=None): 342 | """Generate command to delete a user. 343 | 344 | args: 345 | username (str): user name 346 | manage_home (bool): manage home directory 347 | 348 | returns: 349 | list: The user delete command string split into shell-like syntax 350 | """ 351 | command = None 352 | remove_home = '-r' if manage_home else '' 353 | 354 | if get_platform() in ('Linux', 'OpenBSD'): 355 | command = '{0} {1} {2} {3}'.format(sudo_check(), LINUX_CMD_USERDEL, remove_home, username) 356 | elif get_platform() == 'FreeBSD': # pragma: FreeBSD 357 | command = '{0} {1} userdel {2} -n {3}'.format(sudo_check(), FREEBSD_CMD_PW, remove_home, username) 358 | if command: 359 | return shlex.split(str(command)) 360 | 361 | 362 | def get_user_by_uid(uid=None, users=None): 363 | """Return a list of users, from a supplied list, based on their uid. 364 | 365 | args: 366 | uid (id): A user id 367 | user_list (list): An instance of Users 368 | 369 | returns: 370 | list: a list of users matching the supplied uid 371 | """ 372 | return users.describe_users(users_filter=dict(uid=uid)) 373 | 374 | 375 | def compare_user(passed_user=None, user_list=None): 376 | """Check if supplied User instance exists in supplied Users list and, if so, return the differences. 377 | 378 | args: 379 | passed_user (User): the user instance to check for differences 380 | user_list (Users): the Users instance containing a list of Users instances 381 | 382 | returns: 383 | dict: Details of the matching user and a list of differences 384 | """ 385 | # Check if user exists 386 | returned = user_list.describe_users(users_filter=dict(name=passed_user.name)) 387 | replace_keys = False 388 | # User exists, so compare attributes 389 | comparison_result = dict() 390 | if passed_user.uid and (not returned[0].uid == passed_user.uid): 391 | comparison_result['uid_action'] = 'modify' 392 | comparison_result['current_uid_value'] = returned[0].uid 393 | comparison_result['replacement_uid_value'] = passed_user.uid 394 | if passed_user.gid and (not returned[0].gid == passed_user.gid): 395 | comparison_result['gid_action'] = 'modify' 396 | comparison_result['current_gid_value'] = returned[0].gid 397 | comparison_result['replacement_gid_value'] = passed_user.gid 398 | if passed_user.gecos and (not returned[0].gecos == passed_user.gecos): 399 | comparison_result['gecos_action'] = 'modify' 400 | comparison_result['current_gecos_value'] = returned[0].gecos 401 | comparison_result['replacement_gecos_value'] = passed_user.gecos 402 | if passed_user.home_dir and (not returned[0].home_dir == passed_user.home_dir): 403 | comparison_result['home_dir_action'] = 'modify' 404 | comparison_result['current_home_dir_value'] = returned[0].home_dir 405 | comparison_result['replacement_home_dir_value'] = passed_user.home_dir 406 | # (Re)set keys if home dir changed 407 | replace_keys = True 408 | if passed_user.shell and (not returned[0].shell == passed_user.shell): 409 | comparison_result['shell_action'] = 'modify' 410 | comparison_result['current_shell_value'] = returned[0].shell 411 | comparison_result['replacement_shell_value'] = passed_user.shell 412 | if passed_user.sudoers_entry and (not returned[0].sudoers_entry == passed_user.sudoers_entry): 413 | comparison_result['sudoers_entry_action'] = 'modify' 414 | comparison_result['current_sudoers_entry'] = returned[0].sudoers_entry 415 | comparison_result['replacement_sudoers_entry'] = passed_user.sudoers_entry 416 | # if passed_user.public_keys and (not returned[0].public_keys == passed_user.public_keys): 417 | existing_keys = returned[0].public_keys 418 | passed_keys = passed_user.public_keys 419 | # Check if existing and passed keys exist, and if so, compare 420 | if all((existing_keys, passed_keys)) and len(existing_keys) == len(passed_user.public_keys): 421 | # Compare each key, and if any differences, replace 422 | existing = set(key.raw for key in existing_keys) 423 | replacement = set(key.raw for key in passed_keys) 424 | if set.difference(existing, replacement): 425 | replace_keys = True 426 | # If not existing keys but keys passed set, then 427 | elif passed_keys and not existing_keys: 428 | replace_keys = True 429 | if replace_keys: 430 | comparison_result['public_keys_action'] = 'modify' 431 | comparison_result['current_public_keys_value'] = existing_keys 432 | comparison_result['replacement_public_keys_value'] = passed_keys 433 | return dict(state='existing', result=comparison_result, existing_user=returned) 434 | -------------------------------------------------------------------------------- /tests/test_plan.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import (absolute_import, unicode_literals) 4 | 5 | import getpass 6 | import os 7 | import shlex 8 | 9 | import pytest 10 | import yaml 11 | from boto3 import Session 12 | from moto import mock_s3 13 | 14 | from creds.constants import (LINUX_CMD_USERADD, LINUX_CMD_USERDEL, 15 | LINUX_CMD_GROUP_ADD, FREEBSD_CMD_PW) 16 | from creds.plan import (create_plan, execute_plan) 17 | from creds.ssh import PublicKey 18 | from creds.users import (Users, User) 19 | from creds.utils import (execute_command, sudo_check, get_platform, remove_sudoers_entry) 20 | from external.six import text_type 21 | from .sample_data import PUBLIC_KEYS 22 | 23 | # TODO: Detect based on OS 24 | USERMOD = '/usr/sbin/usermod' 25 | USERADD = '/usr/sbin/useradd' 26 | USERDEL = '/usr/sbin/userdel' 27 | GROUPADD = '/usr/sbin/groupadd' 28 | GROUPDEL = '/usr/sbin/groupdel' 29 | 30 | PLATFORM = get_platform() 31 | 32 | CURRENT_USER = getpass.getuser() 33 | 34 | 35 | @mock_s3 36 | def test_execute_plan_to_create_user_with_downloaded_yaml(): 37 | """ Create a new user from downloaded YAML file """ 38 | delete_test_user_and_group() 39 | session = Session() 40 | s3_client = session.client('s3') 41 | test_user_1 = open(os.path.join(os.path.dirname(__file__), 'test_user_1.yml')).read() 42 | s3_client.create_bucket(Bucket='test') 43 | s3_client.put_object(Bucket='test', Key='test.yml', Body=test_user_1) 44 | response = s3_client.get_object(Bucket='test', Key='test.yml') 45 | contents = response['Body'].read() 46 | yaml_content = yaml.load(contents) 47 | current_users = Users.from_passwd() 48 | provided_users = Users.from_dict(yaml_content) 49 | plan = create_plan(existing_users=current_users, proposed_users=provided_users, manage_home=False, 50 | protected_users=['travis', 'couchdb', 'ubuntu', 'nginx', 'hadfielj', 'vagrant', CURRENT_USER]) 51 | execute_plan(plan=plan) 52 | updated_users = Users.from_passwd() 53 | updated_user = updated_users.describe_users(users_filter=dict(name='dummy')) 54 | assert len(updated_user) == 1 55 | assert updated_user[0].name == 'dummy' 56 | assert updated_user[0].gecos == '\"dummy (user) test\"' 57 | assert not updated_user[0].public_keys 58 | assert updated_user[0].sudoers_entry == 'ALL=(ALL:ALL) ALL' 59 | delete_test_user_and_group() 60 | 61 | 62 | def test_execute_plan_to_create_user_with_invalid_sudoers_entry(): 63 | """ Create a new user but specify an invalid sudoers entry """ 64 | delete_test_user_and_group() 65 | raw_public_key_2 = PUBLIC_KEYS[1].get('raw') 66 | public_key_2 = PublicKey(raw=raw_public_key_2) 67 | current_users = Users.from_passwd() 68 | provided_users = Users() 69 | provided_users.append( 70 | User(name='testuserx1234', uid=59998, gid=1, gecos='test user gecos update', 71 | shell='/bin/false', public_keys=[public_key_2], sudoers_entry='INVALID ALL=(ALL:ALL) ALL')) 72 | plan = create_plan(existing_users=current_users, proposed_users=provided_users, manage_home=False, 73 | protected_users=['travis', 'couchdb', 'ubuntu', 'nginx', 'hadfielj', 'vagrant', CURRENT_USER]) 74 | assert plan[0]['proposed_user'].gecos == '\"test user gecos update\"' 75 | with pytest.raises(ValueError): 76 | execute_plan(plan=plan) 77 | delete_test_user_and_group() 78 | 79 | 80 | def test_execute_plan_to_delete_user_ignoring_home(): 81 | """ Delete a user and ensure their home dir is untouched """ 82 | 83 | delete_test_user_and_group() 84 | pre_users = Users.from_passwd() 85 | create_test_user() 86 | plan = create_plan(existing_users=Users.from_passwd(), proposed_users=pre_users, purge_undefined=True, 87 | manage_home=False, 88 | protected_users=['travis', 'couchdb', 'ubuntu', 'nginx', 'hadfielj', 'vagrant', 89 | CURRENT_USER]) 90 | execute_plan(plan=plan) 91 | updated_users = Users.from_passwd() 92 | updated_user = updated_users.describe_users(users_filter=dict(name='testuserx1234')) 93 | assert len(updated_user) == 0 94 | assert os.path.exists('/home/testuserx1234') 95 | delete_test_user_and_group() 96 | 97 | 98 | def test_execute_plan_to_create_user_ignoring_home(): 99 | """ Create a new user without creating home directory """ 100 | 101 | delete_test_user_and_group() 102 | raw_public_key_2 = PUBLIC_KEYS[1].get('raw') 103 | public_key_2 = PublicKey(raw=raw_public_key_2) 104 | current_users = Users.from_passwd() 105 | provided_users = Users() 106 | provided_users.append( 107 | User(name='testuserx1234', uid=59998, gid=1, gecos='test user gecos update', 108 | shell='/bin/false', public_keys=[public_key_2], sudoers_entry='ALL=(ALL:ALL) ALL')) 109 | plan = create_plan(existing_users=current_users, proposed_users=provided_users, manage_home=False, 110 | protected_users=['travis', 'couchdb', 'ubuntu', 'nginx', 'hadfielj', 'vagrant', CURRENT_USER]) 111 | assert plan[0]['proposed_user'].gecos == '\"test user gecos update\"' 112 | execute_plan(plan=plan) 113 | updated_users = Users.from_passwd() 114 | updated_user = updated_users.describe_users(users_filter=dict(name='testuserx1234')) 115 | assert len(updated_user) == 1 116 | assert updated_user[0].name == 'testuserx1234' 117 | assert updated_user[0].uid == 59998 118 | assert updated_user[0].gid == 1 119 | assert updated_user[0].gecos == '\"test user gecos update\"' 120 | assert updated_user[0].shell == '/bin/false' 121 | assert not updated_user[0].public_keys 122 | assert updated_user[0].sudoers_entry == 'ALL=(ALL:ALL) ALL' 123 | delete_test_user_and_group() 124 | 125 | 126 | def test_execute_plan_to_update_existing_user_ignoring_keys(): 127 | """ Create a new user without touching keys """ 128 | 129 | delete_test_user_and_group() 130 | create_test_user() 131 | raw_public_key_2 = PUBLIC_KEYS[1].get('raw') 132 | public_key_2 = PublicKey(raw=raw_public_key_2) 133 | current_users = Users.from_passwd() 134 | provided_users = Users() 135 | provided_users.append( 136 | User(name='testuserx1234', uid=59998, gid=1, gecos='test user gecos update', 137 | shell='/bin/false', public_keys=[public_key_2], sudoers_entry='ALL=(ALL:ALL) ALL')) 138 | plan = create_plan(existing_users=current_users, proposed_users=provided_users, manage_keys=False, 139 | protected_users=['travis', 'couchdb', 'ubuntu', 'nginx', 'hadfielj', 'vagrant', CURRENT_USER]) 140 | assert plan[0]['proposed_user'].gecos == '\"test user gecos update\"' 141 | execute_plan(plan=plan) 142 | updated_users = Users.from_passwd() 143 | updated_user = updated_users.describe_users(users_filter=dict(name='testuserx1234')) 144 | assert len(updated_user) == 1 145 | assert updated_user[0].name == 'testuserx1234' 146 | assert updated_user[0].uid == 59998 147 | assert updated_user[0].gid == 1 148 | assert updated_user[0].gecos == '\"test user gecos update\"' 149 | assert updated_user[0].shell == '/bin/false' 150 | assert not updated_user[0].public_keys 151 | assert updated_user[0].sudoers_entry == 'ALL=(ALL:ALL) ALL' 152 | delete_test_user_and_group() 153 | 154 | 155 | def test_execute_plan_to_update_existing_user(): 156 | """ Create a new user and then attempt to create another user with existing id """ 157 | 158 | delete_test_user_and_group() 159 | create_test_user() 160 | raw_public_key_2 = PUBLIC_KEYS[1].get('raw') 161 | public_key_2 = PublicKey(raw=raw_public_key_2) 162 | current_users = Users.from_passwd() 163 | provided_users = Users() 164 | provided_users.append( 165 | User(name='testuserx1234', uid=59998, gid=1, gecos='test user gecos update', 166 | shell='/bin/false', public_keys=[public_key_2], sudoers_entry='ALL=(ALL:ALL) ALL')) 167 | plan = create_plan(existing_users=current_users, proposed_users=provided_users, 168 | protected_users=['travis', 'couchdb', 'ubuntu', 'nginx', 'hadfielj', 'vagrant', CURRENT_USER]) 169 | assert plan[0]['proposed_user'].gecos == '\"test user gecos update\"' 170 | execute_plan(plan=plan) 171 | updated_users = Users.from_passwd() 172 | updated_user = updated_users.describe_users(users_filter=dict(name='testuserx1234')) 173 | assert len(updated_user) == 1 174 | assert updated_user[0].name == 'testuserx1234' 175 | assert updated_user[0].uid == 59998 176 | assert updated_user[0].gid == 1 177 | assert updated_user[0].gecos == '\"test user gecos update\"' 178 | assert updated_user[0].shell == '/bin/false' 179 | assert updated_user[0].public_keys[0].raw == text_type(PUBLIC_KEYS[1]['raw']) 180 | assert updated_user[0].sudoers_entry == 'ALL=(ALL:ALL) ALL' 181 | delete_test_user_and_group() 182 | 183 | 184 | def test_create_and_execute_plan_to_create_new_user_with_sudo_all(): 185 | """ Test creation of a user instance with sudo all and then write """ 186 | delete_test_user_and_group() 187 | create_test_group() 188 | current_users = Users.from_passwd() 189 | provided_users = Users() 190 | 191 | public_keys = [PublicKey( 192 | b64encoded=PUBLIC_KEYS[0]['encoded'])] 193 | provided_users.append( 194 | User(name='testuserx1234', home_dir='/home/testuserx1234', shell='/bin/false', gid=59999, uid=59999, 195 | gecos='test user gecos', 196 | public_keys=public_keys, sudoers_entry='ALL=(ALL)\tNOPASSWD:ALL')) 197 | plan = create_plan(existing_users=current_users, proposed_users=provided_users, purge_undefined=True, 198 | protected_users=['travis', 'couchdb', 'ubuntu', 'vagrant', CURRENT_USER]) 199 | assert plan[0]['state'] == 'missing' 200 | assert plan[0]['proposed_user'].name == "testuserx1234" 201 | assert plan[0]['proposed_user'].home_dir == "/home/testuserx1234" 202 | assert plan[0]['proposed_user'].uid == 59999 203 | assert plan[0]['proposed_user'].gid == 59999 204 | assert plan[0]['proposed_user'].gecos == '\"test user gecos\"' 205 | assert plan[0]['proposed_user'].shell == '/bin/false' 206 | assert plan[0]['proposed_user'].sudoers_entry == 'ALL=(ALL)\tNOPASSWD:ALL' 207 | assert type(plan[0]['proposed_user'].public_keys[0].raw) == text_type 208 | assert plan[0]['proposed_user'].public_keys[0].raw == text_type(PUBLIC_KEYS[0]['raw']) 209 | execute_plan(plan=plan) 210 | current_users = Users.from_passwd() 211 | created_user = current_users.describe_users(users_filter=dict(name='testuserx1234')) 212 | assert created_user[0].sudoers_entry == 'ALL=(ALL)\tNOPASSWD:ALL' 213 | plan = create_plan(existing_users=current_users, proposed_users=provided_users, purge_undefined=True, 214 | protected_users=['travis', 'couchdb', 'ubuntu', 'nginx', 'vagrant', CURRENT_USER]) 215 | assert not plan 216 | 217 | 218 | def test_users_instance_creation(): 219 | """ Test creation of a user instance """ 220 | users = Users() 221 | users.append( 222 | User(name='rod', uid=1001, gid=1001, gecos='rod comment', home_dir='/home/rod', shell='/bin/sh')) 223 | users.append( 224 | User(name='jane', uid=1002, gid=1002, gecos='jane comment', home_dir='/home/jane', shell='/bin/bash')) 225 | users.append( 226 | User(name='freddy', uid=1003, gid=1003, gecos='freddy comment', home_dir='/home/freddy', shell='/bin/false')) 227 | assert len(users) == 3 228 | 229 | 230 | def test_create_and_execute_plan_to_create_new_user(): 231 | """ Test creation of user instance and then write """ 232 | delete_test_user_and_group() 233 | create_test_group() 234 | current_users = Users.from_passwd() 235 | 236 | provided_users = Users() 237 | 238 | public_keys = [PublicKey( 239 | b64encoded=PUBLIC_KEYS[0]['encoded'])] 240 | provided_users.append( 241 | User(name='testuserx1234', home_dir='/home/testuserx1234', shell='/bin/false', gid=59999, uid=59999, 242 | gecos='test user gecos', 243 | public_keys=public_keys)) 244 | plan = create_plan(existing_users=current_users, proposed_users=provided_users, purge_undefined=True, 245 | protected_users=['travis', 'couchdb', 'ubuntu', 'vagrant', CURRENT_USER]) 246 | assert plan[0]['state'] == 'missing' 247 | assert plan[0]['proposed_user'].name == "testuserx1234" 248 | assert plan[0]['proposed_user'].home_dir == "/home/testuserx1234" 249 | assert plan[0]['proposed_user'].uid == 59999 250 | assert plan[0]['proposed_user'].gid == 59999 251 | assert plan[0]['proposed_user'].gecos == '\"test user gecos\"' 252 | assert plan[0]['proposed_user'].shell == '/bin/false' 253 | assert type(plan[0]['proposed_user'].public_keys[0].raw) == text_type 254 | assert plan[0]['proposed_user'].public_keys[0].raw == text_type(PUBLIC_KEYS[0]['raw']) 255 | execute_plan(plan=plan) 256 | 257 | current_users = Users.from_passwd() 258 | plan = create_plan(existing_users=current_users, proposed_users=provided_users, purge_undefined=True, 259 | protected_users=['travis', 'couchdb', 'ubuntu', 'nginx', 'hadfielj', 'vagrant', CURRENT_USER]) 260 | assert not plan 261 | 262 | 263 | def test_create_and_execute_plan_to_create_identical_user(): 264 | delete_test_user_and_group() 265 | create_test_user() 266 | current_users = Users.from_passwd() 267 | provided_users = Users() 268 | provided_users.append(User(name='testuserx1234', uid=59999, gecos='test user gecos')) 269 | plan = create_plan(existing_users=current_users, proposed_users=provided_users, 270 | protected_users=['travis', 'couchdb', 'ubuntu', 'nginx', 'hadfielj', 'vagrant', CURRENT_USER]) 271 | execute_plan(plan=plan) 272 | current_users = Users.from_passwd() 273 | plan = create_plan(existing_users=current_users, proposed_users=provided_users, 274 | protected_users=['travis', 'couchdb', 'ubuntu', 'nginx', 'hadfielj', 'vagrant', CURRENT_USER]) 275 | assert not plan 276 | delete_test_user_and_group() 277 | 278 | 279 | def test_update_existing_user(): 280 | delete_test_user_and_group() 281 | create_test_user() 282 | current_users = Users.from_passwd() 283 | provided_users = Users() 284 | raw_public_key = PUBLIC_KEYS[0].get('raw') 285 | public_key = PublicKey(raw=raw_public_key) 286 | provided_users.append( 287 | User(name='testuserx1234', uid=59999, gecos='test user gecos update', home_dir='/tmp/temp', 288 | public_keys=[public_key])) 289 | plan = create_plan(existing_users=current_users, proposed_users=provided_users, 290 | protected_users=['travis', 'couchdb', 'ubuntu', 'nginx', 'hadfielj', 'vagrant', CURRENT_USER]) 291 | assert plan[0]['action'] == 'update' 292 | execute_plan(plan) 293 | current_users = Users.from_passwd() 294 | new_user = current_users.describe_users(users_filter=dict(name='testuserx1234')) 295 | assert new_user[0].public_keys[0].raw == raw_public_key 296 | delete_test_user_and_group() 297 | 298 | 299 | def test_execute_plan_to_create_new_user_with_clashing_uid(): 300 | """ Create a new user and then attempt to create another user with existing id """ 301 | delete_test_user_and_group() 302 | current_users = Users.from_passwd() 303 | provided_users = Users() 304 | provided_users.append(User(name='testuserx1234', uid=59999, gecos='test user gecos')) 305 | plan = create_plan(existing_users=current_users, proposed_users=provided_users, 306 | protected_users=['travis', 'couchdb', 'ubuntu', 'nginx', 'hadfielj', 'vagrant', CURRENT_USER]) 307 | assert plan[0]['action'] == 'add' 308 | assert plan[0]['proposed_user'].name == "testuserx1234" 309 | assert plan[0]['proposed_user'].uid == 59999 310 | assert plan[0]['proposed_user'].gecos == '\"test user gecos\"' 311 | execute_plan(plan=plan) 312 | current_users = Users.from_passwd() 313 | provided_users = Users() 314 | provided_users.append(User(name='testuserx12345', uid=59999, gecos='test user gecos')) 315 | plan = create_plan(existing_users=current_users, proposed_users=provided_users, purge_undefined=True, 316 | protected_users=['travis', 'couchdb', 'ubuntu', 'nginx', 'hadfielj', 'vagrant', CURRENT_USER]) 317 | assert plan[0]['error'] == 'uid_clash' 318 | execute_plan(plan=plan) 319 | delete_test_user_and_group() 320 | 321 | 322 | def test_execute_plan_to_update_existing_user_with_multiple_keys(): 323 | """ Create a new user with 2 keys and then replace with a new one """ 324 | create_test_user() 325 | raw_public_key_1 = PUBLIC_KEYS[0].get('raw') 326 | public_key_1 = PublicKey(raw=raw_public_key_1) 327 | raw_public_key_2 = PUBLIC_KEYS[1].get('raw') 328 | public_key_2 = PublicKey(raw=raw_public_key_2) 329 | raw_public_key_3 = PUBLIC_KEYS[2].get('raw') 330 | public_key_3 = PublicKey(raw=raw_public_key_3) 331 | raw_public_key_4 = PUBLIC_KEYS[3].get('raw') 332 | public_key_4 = PublicKey(raw=raw_public_key_4) 333 | current_users = Users.from_passwd() 334 | provided_users_2 = Users() 335 | provided_users_2.append(User(name='testuserx1234', uid=59998, gid=1, gecos='test user gecos update', 336 | shell='/bin/false', public_keys=[public_key_1, public_key_2])) 337 | plan = create_plan(existing_users=current_users, proposed_users=provided_users_2, 338 | protected_users=['travis', 'couchdb', 'ubuntu', 'nginx', 'hadfielj', 'vagrant', CURRENT_USER]) 339 | execute_plan(plan=plan) 340 | updated_users = Users.from_passwd() 341 | updated_user = updated_users.describe_users(users_filter=dict(name='testuserx1234')) 342 | assert updated_user[0].public_keys[0].raw == text_type(PUBLIC_KEYS[0]['raw']) 343 | assert updated_user[0].public_keys[1].raw == text_type(PUBLIC_KEYS[1]['raw']) 344 | # Replace both keys 345 | current_users = Users.from_passwd() 346 | provided_users_3 = Users() 347 | provided_users_3.append(User(name='testuserx1234', uid=59998, gid=1, gecos='test user gecos update', 348 | shell='/bin/false', public_keys=[public_key_3, public_key_4])) 349 | plan = create_plan(existing_users=current_users, proposed_users=provided_users_3, 350 | protected_users=['travis', 'couchdb', 'ubuntu', 'nginx', 'hadfielj', 'vagrant', CURRENT_USER]) 351 | execute_plan(plan=plan) 352 | updated_users = Users.from_passwd() 353 | updated_user = updated_users.describe_users(users_filter=dict(name='testuserx1234')) 354 | assert updated_user[0].public_keys[0].raw == text_type(PUBLIC_KEYS[2]['raw']) 355 | assert updated_user[0].public_keys[1].raw == text_type(PUBLIC_KEYS[3]['raw']) 356 | # Replace one key 357 | current_users = Users.from_passwd() 358 | provided_users_4 = Users() 359 | provided_users_4.append( 360 | User(name='testuserx1234', uid=59998, gid=1, gecos='test user gecos update', 361 | shell='/bin/false', public_keys=[public_key_2, public_key_4])) 362 | plan = create_plan(existing_users=current_users, proposed_users=provided_users_4, 363 | protected_users=['travis', 'couchdb', 'ubuntu', 'nginx', 'hadfielj', 'vagrant', CURRENT_USER]) 364 | execute_plan(plan=plan) 365 | updated_users = Users.from_passwd() 366 | updated_user = updated_users.describe_users(users_filter=dict(name='testuserx1234')) 367 | assert updated_user[0].public_keys[0].raw == text_type(PUBLIC_KEYS[1]['raw']) 368 | assert updated_user[0].public_keys[1].raw == text_type(PUBLIC_KEYS[3]['raw']) 369 | delete_test_user_and_group() 370 | 371 | 372 | def delete_test_user_and_group(): 373 | if PLATFORM == 'Linux': 374 | del_user_command = shlex.split(str('{0} {1} -r -f testuserx1234'.format(sudo_check(), LINUX_CMD_USERDEL))) 375 | execute_command(command=del_user_command) 376 | elif PLATFORM == 'OpenBSD': 377 | del_user_command = shlex.split(str('{0} {1} -r testuserx1234'.format(sudo_check(), LINUX_CMD_USERDEL))) 378 | execute_command(command=del_user_command) 379 | elif PLATFORM == 'FreeBSD': 380 | del_user_command = shlex.split(str('{0} {1} userdel -r -n testuserx1234'.format(sudo_check(), FREEBSD_CMD_PW))) 381 | execute_command(command=del_user_command) 382 | if PLATFORM in ('Linux', 'OpenBSD'): 383 | del_group_command = shlex.split(str('{0} {1} testuserx1234'.format(sudo_check(), GROUPDEL))) 384 | execute_command(command=del_group_command) 385 | del_user_ssh_dir_command = shlex.split(str('/bin/rm -rf /tmp/.ssh')) 386 | execute_command(command=del_user_ssh_dir_command) 387 | remove_sudoers_entry(username='testuserx1234') 388 | execute_command(command=shlex.split(str('{0} rm -rf /home/testuserx1234'.format(sudo_check())))) 389 | 390 | 391 | def create_test_user(): 392 | if PLATFORM in ('Linux', 'OpenBSD'): 393 | command = shlex.split( 394 | str('{0} {1} -u 59999 -c \"test user gecos\" -m -s /bin/bash testuserx1234'.format(sudo_check(), 395 | LINUX_CMD_USERADD))) 396 | elif PLATFORM == 'FreeBSD': 397 | command = shlex.split( 398 | str('{0} {1} useradd -u 59999 -c \"test user gecos\" -m -s /bin/bash -n testuserx1234'.format(sudo_check(), 399 | FREEBSD_CMD_PW))) 400 | assert execute_command(command=command) 401 | 402 | 403 | def create_test_group(): 404 | if PLATFORM in ('Linux', 'OpenBSD'): 405 | command = shlex.split( 406 | str('{0} {1} -g 59999 testuserx1234'.format(sudo_check(), LINUX_CMD_GROUP_ADD))) 407 | elif PLATFORM == 'FreeBSD': 408 | command = shlex.split( 409 | str('{0} {1} groupadd -g 59999 -n testuserx1234'.format(sudo_check(), FREEBSD_CMD_PW))) 410 | assert execute_command(command=command) 411 | -------------------------------------------------------------------------------- /lib/external/six/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2010-2016 Benjamin Peterson 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | 21 | """Utilities for writing code that runs on Python 2 and 3""" 22 | 23 | from __future__ import absolute_import 24 | 25 | import functools 26 | import itertools 27 | import operator 28 | import sys 29 | import types 30 | 31 | __author__ = "Benjamin Peterson " 32 | __version__ = "1.10.0" 33 | 34 | 35 | # Useful for very coarse version differentiation. 36 | PY2 = sys.version_info[0] == 2 37 | PY3 = sys.version_info[0] == 3 38 | PY34 = sys.version_info[0:2] >= (3, 4) 39 | 40 | if PY3: 41 | string_types = str, 42 | integer_types = int, 43 | class_types = type, 44 | text_type = str 45 | binary_type = bytes 46 | 47 | MAXSIZE = sys.maxsize 48 | else: 49 | string_types = basestring, 50 | integer_types = (int, long) 51 | class_types = (type, types.ClassType) 52 | text_type = unicode 53 | binary_type = str 54 | 55 | if sys.platform.startswith("java"): 56 | # Jython always uses 32 bits. 57 | MAXSIZE = int((1 << 31) - 1) 58 | else: 59 | # It's possible to have sizeof(long) != sizeof(Py_ssize_t). 60 | class X(object): 61 | 62 | def __len__(self): 63 | return 1 << 31 64 | try: 65 | len(X()) 66 | except OverflowError: 67 | # 32-bit 68 | MAXSIZE = int((1 << 31) - 1) 69 | else: 70 | # 64-bit 71 | MAXSIZE = int((1 << 63) - 1) 72 | del X 73 | 74 | 75 | def _add_doc(func, doc): 76 | """Add documentation to a function.""" 77 | func.__doc__ = doc 78 | 79 | 80 | def _import_module(name): 81 | """Import module, returning the module after the last dot.""" 82 | __import__(name) 83 | return sys.modules[name] 84 | 85 | 86 | class _LazyDescr(object): 87 | 88 | def __init__(self, name): 89 | self.name = name 90 | 91 | def __get__(self, obj, tp): 92 | result = self._resolve() 93 | setattr(obj, self.name, result) # Invokes __set__. 94 | try: 95 | # This is a bit ugly, but it avoids running this again by 96 | # removing this descriptor. 97 | delattr(obj.__class__, self.name) 98 | except AttributeError: 99 | pass 100 | return result 101 | 102 | 103 | class MovedModule(_LazyDescr): 104 | 105 | def __init__(self, name, old, new=None): 106 | super(MovedModule, self).__init__(name) 107 | if PY3: 108 | if new is None: 109 | new = name 110 | self.mod = new 111 | else: 112 | self.mod = old 113 | 114 | def _resolve(self): 115 | return _import_module(self.mod) 116 | 117 | def __getattr__(self, attr): 118 | _module = self._resolve() 119 | value = getattr(_module, attr) 120 | setattr(self, attr, value) 121 | return value 122 | 123 | 124 | class _LazyModule(types.ModuleType): 125 | 126 | def __init__(self, name): 127 | super(_LazyModule, self).__init__(name) 128 | self.__doc__ = self.__class__.__doc__ 129 | 130 | def __dir__(self): 131 | attrs = ["__doc__", "__name__"] 132 | attrs += [attr.name for attr in self._moved_attributes] 133 | return attrs 134 | 135 | # Subclasses should override this 136 | _moved_attributes = [] 137 | 138 | 139 | class MovedAttribute(_LazyDescr): 140 | 141 | def __init__(self, name, old_mod, new_mod, old_attr=None, new_attr=None): 142 | super(MovedAttribute, self).__init__(name) 143 | if PY3: 144 | if new_mod is None: 145 | new_mod = name 146 | self.mod = new_mod 147 | if new_attr is None: 148 | if old_attr is None: 149 | new_attr = name 150 | else: 151 | new_attr = old_attr 152 | self.attr = new_attr 153 | else: 154 | self.mod = old_mod 155 | if old_attr is None: 156 | old_attr = name 157 | self.attr = old_attr 158 | 159 | def _resolve(self): 160 | module = _import_module(self.mod) 161 | return getattr(module, self.attr) 162 | 163 | 164 | class _SixMetaPathImporter(object): 165 | 166 | """ 167 | A meta path importer to import six.moves and its submodules. 168 | 169 | This class implements a PEP302 finder and loader. It should be compatible 170 | with Python 2.5 and all existing versions of Python3 171 | """ 172 | 173 | def __init__(self, six_module_name): 174 | self.name = six_module_name 175 | self.known_modules = {} 176 | 177 | def _add_module(self, mod, *fullnames): 178 | for fullname in fullnames: 179 | self.known_modules[self.name + "." + fullname] = mod 180 | 181 | def _get_module(self, fullname): 182 | return self.known_modules[self.name + "." + fullname] 183 | 184 | def find_module(self, fullname, path=None): 185 | if fullname in self.known_modules: 186 | return self 187 | return None 188 | 189 | def __get_module(self, fullname): 190 | try: 191 | return self.known_modules[fullname] 192 | except KeyError: 193 | raise ImportError("This loader does not know module " + fullname) 194 | 195 | def load_module(self, fullname): 196 | try: 197 | # in case of a reload 198 | return sys.modules[fullname] 199 | except KeyError: 200 | pass 201 | mod = self.__get_module(fullname) 202 | if isinstance(mod, MovedModule): 203 | mod = mod._resolve() 204 | else: 205 | mod.__loader__ = self 206 | sys.modules[fullname] = mod 207 | return mod 208 | 209 | def is_package(self, fullname): 210 | """ 211 | Return true, if the named module is a package. 212 | 213 | We need this method to get correct spec objects with 214 | Python 3.4 (see PEP451) 215 | """ 216 | return hasattr(self.__get_module(fullname), "__path__") 217 | 218 | def get_code(self, fullname): 219 | """Return None 220 | 221 | Required, if is_package is implemented""" 222 | self.__get_module(fullname) # eventually raises ImportError 223 | return None 224 | get_source = get_code # same as get_code 225 | 226 | _importer = _SixMetaPathImporter(__name__) 227 | 228 | 229 | class _MovedItems(_LazyModule): 230 | 231 | """Lazy loading of moved objects""" 232 | __path__ = [] # mark as package 233 | 234 | 235 | _moved_attributes = [ 236 | MovedAttribute("cStringIO", "cStringIO", "io", "StringIO"), 237 | MovedAttribute("filter", "itertools", "builtins", "ifilter", "filter"), 238 | MovedAttribute("filterfalse", "itertools", "itertools", "ifilterfalse", "filterfalse"), 239 | MovedAttribute("input", "__builtin__", "builtins", "raw_input", "input"), 240 | MovedAttribute("intern", "__builtin__", "sys"), 241 | MovedAttribute("map", "itertools", "builtins", "imap", "map"), 242 | MovedAttribute("getcwd", "os", "os", "getcwdu", "getcwd"), 243 | MovedAttribute("getcwdb", "os", "os", "getcwd", "getcwdb"), 244 | MovedAttribute("range", "__builtin__", "builtins", "xrange", "range"), 245 | MovedAttribute("reload_module", "__builtin__", "importlib" if PY34 else "imp", "reload"), 246 | MovedAttribute("reduce", "__builtin__", "functools"), 247 | MovedAttribute("shlex_quote", "pipes", "shlex", "quote"), 248 | MovedAttribute("StringIO", "StringIO", "io"), 249 | MovedAttribute("UserDict", "UserDict", "collections"), 250 | MovedAttribute("UserList", "UserList", "collections"), 251 | MovedAttribute("UserString", "UserString", "collections"), 252 | MovedAttribute("xrange", "__builtin__", "builtins", "xrange", "range"), 253 | MovedAttribute("zip", "itertools", "builtins", "izip", "zip"), 254 | MovedAttribute("zip_longest", "itertools", "itertools", "izip_longest", "zip_longest"), 255 | MovedModule("builtins", "__builtin__"), 256 | MovedModule("configparser", "ConfigParser"), 257 | MovedModule("copyreg", "copy_reg"), 258 | MovedModule("dbm_gnu", "gdbm", "dbm.gnu"), 259 | MovedModule("_dummy_thread", "dummy_thread", "_dummy_thread"), 260 | MovedModule("http_cookiejar", "cookielib", "http.cookiejar"), 261 | MovedModule("http_cookies", "Cookie", "http.cookies"), 262 | MovedModule("html_entities", "htmlentitydefs", "html.entities"), 263 | MovedModule("html_parser", "HTMLParser", "html.parser"), 264 | MovedModule("http_client", "httplib", "http.client"), 265 | MovedModule("email_mime_base", "email.MIMEBase", "email.mime.base"), 266 | MovedModule("email_mime_image", "email.MIMEImage", "email.mime.image"), 267 | MovedModule("email_mime_multipart", "email.MIMEMultipart", "email.mime.multipart"), 268 | MovedModule("email_mime_nonmultipart", "email.MIMENonMultipart", "email.mime.nonmultipart"), 269 | MovedModule("email_mime_text", "email.MIMEText", "email.mime.text"), 270 | MovedModule("BaseHTTPServer", "BaseHTTPServer", "http.server"), 271 | MovedModule("CGIHTTPServer", "CGIHTTPServer", "http.server"), 272 | MovedModule("SimpleHTTPServer", "SimpleHTTPServer", "http.server"), 273 | MovedModule("cPickle", "cPickle", "pickle"), 274 | MovedModule("queue", "Queue"), 275 | MovedModule("reprlib", "repr"), 276 | MovedModule("socketserver", "SocketServer"), 277 | MovedModule("_thread", "thread", "_thread"), 278 | MovedModule("tkinter", "Tkinter"), 279 | MovedModule("tkinter_dialog", "Dialog", "tkinter.dialog"), 280 | MovedModule("tkinter_filedialog", "FileDialog", "tkinter.filedialog"), 281 | MovedModule("tkinter_scrolledtext", "ScrolledText", "tkinter.scrolledtext"), 282 | MovedModule("tkinter_simpledialog", "SimpleDialog", "tkinter.simpledialog"), 283 | MovedModule("tkinter_tix", "Tix", "tkinter.tix"), 284 | MovedModule("tkinter_ttk", "ttk", "tkinter.ttk"), 285 | MovedModule("tkinter_constants", "Tkconstants", "tkinter.constants"), 286 | MovedModule("tkinter_dnd", "Tkdnd", "tkinter.dnd"), 287 | MovedModule("tkinter_colorchooser", "tkColorChooser", 288 | "tkinter.colorchooser"), 289 | MovedModule("tkinter_commondialog", "tkCommonDialog", 290 | "tkinter.commondialog"), 291 | MovedModule("tkinter_tkfiledialog", "tkFileDialog", "tkinter.filedialog"), 292 | MovedModule("tkinter_font", "tkFont", "tkinter.font"), 293 | MovedModule("tkinter_messagebox", "tkMessageBox", "tkinter.messagebox"), 294 | MovedModule("tkinter_tksimpledialog", "tkSimpleDialog", 295 | "tkinter.simpledialog"), 296 | MovedModule("urllib_parse", __name__ + ".moves.urllib_parse", "urllib.parse"), 297 | MovedModule("urllib_error", __name__ + ".moves.urllib_error", "urllib.error"), 298 | MovedModule("urllib", __name__ + ".moves.urllib", __name__ + ".moves.urllib"), 299 | MovedModule("urllib_robotparser", "robotparser", "urllib.robotparser"), 300 | MovedModule("xmlrpc_client", "xmlrpclib", "xmlrpc.client"), 301 | MovedModule("xmlrpc_server", "SimpleXMLRPCServer", "xmlrpc.server"), 302 | ] 303 | # Add windows specific modules. 304 | if sys.platform == "win32": 305 | _moved_attributes += [ 306 | MovedModule("winreg", "_winreg"), 307 | ] 308 | 309 | for attr in _moved_attributes: 310 | setattr(_MovedItems, attr.name, attr) 311 | if isinstance(attr, MovedModule): 312 | _importer._add_module(attr, "moves." + attr.name) 313 | del attr 314 | 315 | _MovedItems._moved_attributes = _moved_attributes 316 | 317 | moves = _MovedItems(__name__ + ".moves") 318 | _importer._add_module(moves, "moves") 319 | 320 | 321 | class Module_six_moves_urllib_parse(_LazyModule): 322 | 323 | """Lazy loading of moved objects in six.moves.urllib_parse""" 324 | 325 | 326 | _urllib_parse_moved_attributes = [ 327 | MovedAttribute("ParseResult", "urlparse", "urllib.parse"), 328 | MovedAttribute("SplitResult", "urlparse", "urllib.parse"), 329 | MovedAttribute("parse_qs", "urlparse", "urllib.parse"), 330 | MovedAttribute("parse_qsl", "urlparse", "urllib.parse"), 331 | MovedAttribute("urldefrag", "urlparse", "urllib.parse"), 332 | MovedAttribute("urljoin", "urlparse", "urllib.parse"), 333 | MovedAttribute("urlparse", "urlparse", "urllib.parse"), 334 | MovedAttribute("urlsplit", "urlparse", "urllib.parse"), 335 | MovedAttribute("urlunparse", "urlparse", "urllib.parse"), 336 | MovedAttribute("urlunsplit", "urlparse", "urllib.parse"), 337 | MovedAttribute("quote", "urllib", "urllib.parse"), 338 | MovedAttribute("quote_plus", "urllib", "urllib.parse"), 339 | MovedAttribute("unquote", "urllib", "urllib.parse"), 340 | MovedAttribute("unquote_plus", "urllib", "urllib.parse"), 341 | MovedAttribute("urlencode", "urllib", "urllib.parse"), 342 | MovedAttribute("splitquery", "urllib", "urllib.parse"), 343 | MovedAttribute("splittag", "urllib", "urllib.parse"), 344 | MovedAttribute("splituser", "urllib", "urllib.parse"), 345 | MovedAttribute("uses_fragment", "urlparse", "urllib.parse"), 346 | MovedAttribute("uses_netloc", "urlparse", "urllib.parse"), 347 | MovedAttribute("uses_params", "urlparse", "urllib.parse"), 348 | MovedAttribute("uses_query", "urlparse", "urllib.parse"), 349 | MovedAttribute("uses_relative", "urlparse", "urllib.parse"), 350 | ] 351 | for attr in _urllib_parse_moved_attributes: 352 | setattr(Module_six_moves_urllib_parse, attr.name, attr) 353 | del attr 354 | 355 | Module_six_moves_urllib_parse._moved_attributes = _urllib_parse_moved_attributes 356 | 357 | _importer._add_module(Module_six_moves_urllib_parse(__name__ + ".moves.urllib_parse"), 358 | "moves.urllib_parse", "moves.urllib.parse") 359 | 360 | 361 | class Module_six_moves_urllib_error(_LazyModule): 362 | 363 | """Lazy loading of moved objects in six.moves.urllib_error""" 364 | 365 | 366 | _urllib_error_moved_attributes = [ 367 | MovedAttribute("URLError", "urllib2", "urllib.error"), 368 | MovedAttribute("HTTPError", "urllib2", "urllib.error"), 369 | MovedAttribute("ContentTooShortError", "urllib", "urllib.error"), 370 | ] 371 | for attr in _urllib_error_moved_attributes: 372 | setattr(Module_six_moves_urllib_error, attr.name, attr) 373 | del attr 374 | 375 | Module_six_moves_urllib_error._moved_attributes = _urllib_error_moved_attributes 376 | 377 | _importer._add_module(Module_six_moves_urllib_error(__name__ + ".moves.urllib.error"), 378 | "moves.urllib_error", "moves.urllib.error") 379 | 380 | 381 | class Module_six_moves_urllib_request(_LazyModule): 382 | 383 | """Lazy loading of moved objects in six.moves.urllib_request""" 384 | 385 | 386 | _urllib_request_moved_attributes = [ 387 | MovedAttribute("urlopen", "urllib2", "urllib.request"), 388 | MovedAttribute("install_opener", "urllib2", "urllib.request"), 389 | MovedAttribute("build_opener", "urllib2", "urllib.request"), 390 | MovedAttribute("pathname2url", "urllib", "urllib.request"), 391 | MovedAttribute("url2pathname", "urllib", "urllib.request"), 392 | MovedAttribute("getproxies", "urllib", "urllib.request"), 393 | MovedAttribute("Request", "urllib2", "urllib.request"), 394 | MovedAttribute("OpenerDirector", "urllib2", "urllib.request"), 395 | MovedAttribute("HTTPDefaultErrorHandler", "urllib2", "urllib.request"), 396 | MovedAttribute("HTTPRedirectHandler", "urllib2", "urllib.request"), 397 | MovedAttribute("HTTPCookieProcessor", "urllib2", "urllib.request"), 398 | MovedAttribute("ProxyHandler", "urllib2", "urllib.request"), 399 | MovedAttribute("BaseHandler", "urllib2", "urllib.request"), 400 | MovedAttribute("HTTPPasswordMgr", "urllib2", "urllib.request"), 401 | MovedAttribute("HTTPPasswordMgrWithDefaultRealm", "urllib2", "urllib.request"), 402 | MovedAttribute("AbstractBasicAuthHandler", "urllib2", "urllib.request"), 403 | MovedAttribute("HTTPBasicAuthHandler", "urllib2", "urllib.request"), 404 | MovedAttribute("ProxyBasicAuthHandler", "urllib2", "urllib.request"), 405 | MovedAttribute("AbstractDigestAuthHandler", "urllib2", "urllib.request"), 406 | MovedAttribute("HTTPDigestAuthHandler", "urllib2", "urllib.request"), 407 | MovedAttribute("ProxyDigestAuthHandler", "urllib2", "urllib.request"), 408 | MovedAttribute("HTTPHandler", "urllib2", "urllib.request"), 409 | MovedAttribute("HTTPSHandler", "urllib2", "urllib.request"), 410 | MovedAttribute("FileHandler", "urllib2", "urllib.request"), 411 | MovedAttribute("FTPHandler", "urllib2", "urllib.request"), 412 | MovedAttribute("CacheFTPHandler", "urllib2", "urllib.request"), 413 | MovedAttribute("UnknownHandler", "urllib2", "urllib.request"), 414 | MovedAttribute("HTTPErrorProcessor", "urllib2", "urllib.request"), 415 | MovedAttribute("urlretrieve", "urllib", "urllib.request"), 416 | MovedAttribute("urlcleanup", "urllib", "urllib.request"), 417 | MovedAttribute("URLopener", "urllib", "urllib.request"), 418 | MovedAttribute("FancyURLopener", "urllib", "urllib.request"), 419 | MovedAttribute("proxy_bypass", "urllib", "urllib.request"), 420 | ] 421 | for attr in _urllib_request_moved_attributes: 422 | setattr(Module_six_moves_urllib_request, attr.name, attr) 423 | del attr 424 | 425 | Module_six_moves_urllib_request._moved_attributes = _urllib_request_moved_attributes 426 | 427 | _importer._add_module(Module_six_moves_urllib_request(__name__ + ".moves.urllib.request"), 428 | "moves.urllib_request", "moves.urllib.request") 429 | 430 | 431 | class Module_six_moves_urllib_response(_LazyModule): 432 | 433 | """Lazy loading of moved objects in six.moves.urllib_response""" 434 | 435 | 436 | _urllib_response_moved_attributes = [ 437 | MovedAttribute("addbase", "urllib", "urllib.response"), 438 | MovedAttribute("addclosehook", "urllib", "urllib.response"), 439 | MovedAttribute("addinfo", "urllib", "urllib.response"), 440 | MovedAttribute("addinfourl", "urllib", "urllib.response"), 441 | ] 442 | for attr in _urllib_response_moved_attributes: 443 | setattr(Module_six_moves_urllib_response, attr.name, attr) 444 | del attr 445 | 446 | Module_six_moves_urllib_response._moved_attributes = _urllib_response_moved_attributes 447 | 448 | _importer._add_module(Module_six_moves_urllib_response(__name__ + ".moves.urllib.response"), 449 | "moves.urllib_response", "moves.urllib.response") 450 | 451 | 452 | class Module_six_moves_urllib_robotparser(_LazyModule): 453 | 454 | """Lazy loading of moved objects in six.moves.urllib_robotparser""" 455 | 456 | 457 | _urllib_robotparser_moved_attributes = [ 458 | MovedAttribute("RobotFileParser", "robotparser", "urllib.robotparser"), 459 | ] 460 | for attr in _urllib_robotparser_moved_attributes: 461 | setattr(Module_six_moves_urllib_robotparser, attr.name, attr) 462 | del attr 463 | 464 | Module_six_moves_urllib_robotparser._moved_attributes = _urllib_robotparser_moved_attributes 465 | 466 | _importer._add_module(Module_six_moves_urllib_robotparser(__name__ + ".moves.urllib.robotparser"), 467 | "moves.urllib_robotparser", "moves.urllib.robotparser") 468 | 469 | 470 | class Module_six_moves_urllib(types.ModuleType): 471 | 472 | """Create a six.moves.urllib namespace that resembles the Python 3 namespace""" 473 | __path__ = [] # mark as package 474 | parse = _importer._get_module("moves.urllib_parse") 475 | error = _importer._get_module("moves.urllib_error") 476 | request = _importer._get_module("moves.urllib_request") 477 | response = _importer._get_module("moves.urllib_response") 478 | robotparser = _importer._get_module("moves.urllib_robotparser") 479 | 480 | def __dir__(self): 481 | return ['parse', 'error', 'request', 'response', 'robotparser'] 482 | 483 | _importer._add_module(Module_six_moves_urllib(__name__ + ".moves.urllib"), 484 | "moves.urllib") 485 | 486 | 487 | def add_move(move): 488 | """Add an item to six.moves.""" 489 | setattr(_MovedItems, move.name, move) 490 | 491 | 492 | def remove_move(name): 493 | """Remove item from six.moves.""" 494 | try: 495 | delattr(_MovedItems, name) 496 | except AttributeError: 497 | try: 498 | del moves.__dict__[name] 499 | except KeyError: 500 | raise AttributeError("no such move, %r" % (name,)) 501 | 502 | 503 | if PY3: 504 | _meth_func = "__func__" 505 | _meth_self = "__self__" 506 | 507 | _func_closure = "__closure__" 508 | _func_code = "__code__" 509 | _func_defaults = "__defaults__" 510 | _func_globals = "__globals__" 511 | else: 512 | _meth_func = "im_func" 513 | _meth_self = "im_self" 514 | 515 | _func_closure = "func_closure" 516 | _func_code = "func_code" 517 | _func_defaults = "func_defaults" 518 | _func_globals = "func_globals" 519 | 520 | 521 | try: 522 | advance_iterator = next 523 | except NameError: 524 | def advance_iterator(it): 525 | return it.next() 526 | next = advance_iterator 527 | 528 | 529 | try: 530 | callable = callable 531 | except NameError: 532 | def callable(obj): 533 | return any("__call__" in klass.__dict__ for klass in type(obj).__mro__) 534 | 535 | 536 | if PY3: 537 | def get_unbound_function(unbound): 538 | return unbound 539 | 540 | create_bound_method = types.MethodType 541 | 542 | def create_unbound_method(func, cls): 543 | return func 544 | 545 | Iterator = object 546 | else: 547 | def get_unbound_function(unbound): 548 | return unbound.im_func 549 | 550 | def create_bound_method(func, obj): 551 | return types.MethodType(func, obj, obj.__class__) 552 | 553 | def create_unbound_method(func, cls): 554 | return types.MethodType(func, None, cls) 555 | 556 | class Iterator(object): 557 | 558 | def next(self): 559 | return type(self).__next__(self) 560 | 561 | callable = callable 562 | _add_doc(get_unbound_function, 563 | """Get the function out of a possibly unbound function""") 564 | 565 | 566 | get_method_function = operator.attrgetter(_meth_func) 567 | get_method_self = operator.attrgetter(_meth_self) 568 | get_function_closure = operator.attrgetter(_func_closure) 569 | get_function_code = operator.attrgetter(_func_code) 570 | get_function_defaults = operator.attrgetter(_func_defaults) 571 | get_function_globals = operator.attrgetter(_func_globals) 572 | 573 | 574 | if PY3: 575 | def iterkeys(d, **kw): 576 | return iter(d.keys(**kw)) 577 | 578 | def itervalues(d, **kw): 579 | return iter(d.values(**kw)) 580 | 581 | def iteritems(d, **kw): 582 | return iter(d.items(**kw)) 583 | 584 | def iterlists(d, **kw): 585 | return iter(d.lists(**kw)) 586 | 587 | viewkeys = operator.methodcaller("keys") 588 | 589 | viewvalues = operator.methodcaller("values") 590 | 591 | viewitems = operator.methodcaller("items") 592 | else: 593 | def iterkeys(d, **kw): 594 | return d.iterkeys(**kw) 595 | 596 | def itervalues(d, **kw): 597 | return d.itervalues(**kw) 598 | 599 | def iteritems(d, **kw): 600 | return d.iteritems(**kw) 601 | 602 | def iterlists(d, **kw): 603 | return d.iterlists(**kw) 604 | 605 | viewkeys = operator.methodcaller("viewkeys") 606 | 607 | viewvalues = operator.methodcaller("viewvalues") 608 | 609 | viewitems = operator.methodcaller("viewitems") 610 | 611 | _add_doc(iterkeys, "Return an iterator over the keys of a dictionary.") 612 | _add_doc(itervalues, "Return an iterator over the values of a dictionary.") 613 | _add_doc(iteritems, 614 | "Return an iterator over the (key, value) pairs of a dictionary.") 615 | _add_doc(iterlists, 616 | "Return an iterator over the (key, [values]) pairs of a dictionary.") 617 | 618 | 619 | if PY3: 620 | def b(s): 621 | return s.encode("latin-1") 622 | 623 | def u(s): 624 | return s 625 | unichr = chr 626 | import struct 627 | int2byte = struct.Struct(">B").pack 628 | del struct 629 | byte2int = operator.itemgetter(0) 630 | indexbytes = operator.getitem 631 | iterbytes = iter 632 | import io 633 | StringIO = io.StringIO 634 | BytesIO = io.BytesIO 635 | _assertCountEqual = "assertCountEqual" 636 | if sys.version_info[1] <= 1: 637 | _assertRaisesRegex = "assertRaisesRegexp" 638 | _assertRegex = "assertRegexpMatches" 639 | else: 640 | _assertRaisesRegex = "assertRaisesRegex" 641 | _assertRegex = "assertRegex" 642 | else: 643 | def b(s): 644 | return s 645 | # Workaround for standalone backslash 646 | 647 | def u(s): 648 | return unicode(s.replace(r'\\', r'\\\\'), "unicode_escape") 649 | unichr = unichr 650 | int2byte = chr 651 | 652 | def byte2int(bs): 653 | return ord(bs[0]) 654 | 655 | def indexbytes(buf, i): 656 | return ord(buf[i]) 657 | iterbytes = functools.partial(itertools.imap, ord) 658 | import StringIO 659 | StringIO = BytesIO = StringIO.StringIO 660 | _assertCountEqual = "assertItemsEqual" 661 | _assertRaisesRegex = "assertRaisesRegexp" 662 | _assertRegex = "assertRegexpMatches" 663 | _add_doc(b, """Byte literal""") 664 | _add_doc(u, """Text literal""") 665 | 666 | 667 | def assertCountEqual(self, *args, **kwargs): 668 | return getattr(self, _assertCountEqual)(*args, **kwargs) 669 | 670 | 671 | def assertRaisesRegex(self, *args, **kwargs): 672 | return getattr(self, _assertRaisesRegex)(*args, **kwargs) 673 | 674 | 675 | def assertRegex(self, *args, **kwargs): 676 | return getattr(self, _assertRegex)(*args, **kwargs) 677 | 678 | 679 | if PY3: 680 | exec_ = getattr(moves.builtins, "exec") 681 | 682 | def reraise(tp, value, tb=None): 683 | try: 684 | if value is None: 685 | value = tp() 686 | if value.__traceback__ is not tb: 687 | raise value.with_traceback(tb) 688 | raise value 689 | finally: 690 | value = None 691 | tb = None 692 | 693 | else: 694 | def exec_(_code_, _globs_=None, _locs_=None): 695 | """Execute code in a namespace.""" 696 | if _globs_ is None: 697 | frame = sys._getframe(1) 698 | _globs_ = frame.f_globals 699 | if _locs_ is None: 700 | _locs_ = frame.f_locals 701 | del frame 702 | elif _locs_ is None: 703 | _locs_ = _globs_ 704 | exec("""exec _code_ in _globs_, _locs_""") 705 | 706 | exec_("""def reraise(tp, value, tb=None): 707 | try: 708 | raise tp, value, tb 709 | finally: 710 | tb = None 711 | """) 712 | 713 | 714 | if sys.version_info[:2] == (3, 2): 715 | exec_("""def raise_from(value, from_value): 716 | try: 717 | if from_value is None: 718 | raise value 719 | raise value from from_value 720 | finally: 721 | value = None 722 | """) 723 | elif sys.version_info[:2] > (3, 2): 724 | exec_("""def raise_from(value, from_value): 725 | try: 726 | raise value from from_value 727 | finally: 728 | value = None 729 | """) 730 | else: 731 | def raise_from(value, from_value): 732 | raise value 733 | 734 | 735 | print_ = getattr(moves.builtins, "print", None) 736 | if print_ is None: 737 | def print_(*args, **kwargs): 738 | """The new-style print function for Python 2.4 and 2.5.""" 739 | fp = kwargs.pop("file", sys.stdout) 740 | if fp is None: 741 | return 742 | 743 | def write(data): 744 | if not isinstance(data, basestring): 745 | data = str(data) 746 | # If the file has an encoding, encode unicode with it. 747 | if (isinstance(fp, file) and 748 | isinstance(data, unicode) and 749 | fp.encoding is not None): 750 | errors = getattr(fp, "errors", None) 751 | if errors is None: 752 | errors = "strict" 753 | data = data.encode(fp.encoding, errors) 754 | fp.write(data) 755 | want_unicode = False 756 | sep = kwargs.pop("sep", None) 757 | if sep is not None: 758 | if isinstance(sep, unicode): 759 | want_unicode = True 760 | elif not isinstance(sep, str): 761 | raise TypeError("sep must be None or a string") 762 | end = kwargs.pop("end", None) 763 | if end is not None: 764 | if isinstance(end, unicode): 765 | want_unicode = True 766 | elif not isinstance(end, str): 767 | raise TypeError("end must be None or a string") 768 | if kwargs: 769 | raise TypeError("invalid keyword arguments to print()") 770 | if not want_unicode: 771 | for arg in args: 772 | if isinstance(arg, unicode): 773 | want_unicode = True 774 | break 775 | if want_unicode: 776 | newline = unicode("\n") 777 | space = unicode(" ") 778 | else: 779 | newline = "\n" 780 | space = " " 781 | if sep is None: 782 | sep = space 783 | if end is None: 784 | end = newline 785 | for i, arg in enumerate(args): 786 | if i: 787 | write(sep) 788 | write(arg) 789 | write(end) 790 | if sys.version_info[:2] < (3, 3): 791 | _print = print_ 792 | 793 | def print_(*args, **kwargs): 794 | fp = kwargs.get("file", sys.stdout) 795 | flush = kwargs.pop("flush", False) 796 | _print(*args, **kwargs) 797 | if flush and fp is not None: 798 | fp.flush() 799 | 800 | _add_doc(reraise, """Reraise an exception.""") 801 | 802 | if sys.version_info[0:2] < (3, 4): 803 | def wraps(wrapped, assigned=functools.WRAPPER_ASSIGNMENTS, 804 | updated=functools.WRAPPER_UPDATES): 805 | def wrapper(f): 806 | f = functools.wraps(wrapped, assigned, updated)(f) 807 | f.__wrapped__ = wrapped 808 | return f 809 | return wrapper 810 | else: 811 | wraps = functools.wraps 812 | 813 | 814 | def with_metaclass(meta, *bases): 815 | """Create a base class with a metaclass.""" 816 | # This requires a bit of explanation: the basic idea is to make a dummy 817 | # metaclass for one level of class instantiation that replaces itself with 818 | # the actual metaclass. 819 | class metaclass(meta): 820 | 821 | def __new__(cls, name, this_bases, d): 822 | return meta(name, bases, d) 823 | return type.__new__(metaclass, 'temporary_class', (), {}) 824 | 825 | 826 | def add_metaclass(metaclass): 827 | """Class decorator for creating a class with a metaclass.""" 828 | def wrapper(cls): 829 | orig_vars = cls.__dict__.copy() 830 | slots = orig_vars.get('__slots__') 831 | if slots is not None: 832 | if isinstance(slots, str): 833 | slots = [slots] 834 | for slots_var in slots: 835 | orig_vars.pop(slots_var) 836 | orig_vars.pop('__dict__', None) 837 | orig_vars.pop('__weakref__', None) 838 | return metaclass(cls.__name__, cls.__bases__, orig_vars) 839 | return wrapper 840 | 841 | 842 | def python_2_unicode_compatible(klass): 843 | """ 844 | A decorator that defines __unicode__ and __str__ methods under Python 2. 845 | Under Python 3 it does nothing. 846 | 847 | To support Python 2 and 3 with a single code base, define a __str__ method 848 | returning text and apply this decorator to the class. 849 | """ 850 | if PY2: 851 | if '__str__' not in klass.__dict__: 852 | raise ValueError("@python_2_unicode_compatible cannot be applied " 853 | "to %s because it doesn't define __str__()." % 854 | klass.__name__) 855 | klass.__unicode__ = klass.__str__ 856 | klass.__str__ = lambda self: self.__unicode__().encode('utf-8') 857 | return klass 858 | 859 | 860 | # Complete the moves implementation. 861 | # This code is at the end of this module to speed up module loading. 862 | # Turn this module into a package. 863 | __path__ = [] # required for PEP 302 and PEP 451 864 | __package__ = __name__ # see PEP 366 @ReservedAssignment 865 | if globals().get("__spec__") is not None: 866 | __spec__.submodule_search_locations = [] # PEP 451 @UndefinedVariable 867 | # Remove other six meta path importers, since they cause problems. This can 868 | # happen if six is removed from sys.modules and then reloaded. (Setuptools does 869 | # this for some reason.) 870 | if sys.meta_path: 871 | for i, importer in enumerate(sys.meta_path): 872 | # Here's some real nastiness: Another "instance" of the six module might 873 | # be floating around. Therefore, we can't use isinstance() to check for 874 | # the six meta path importer, since the other six instance will have 875 | # inserted an importer with different class. 876 | if (type(importer).__name__ == "_SixMetaPathImporter" and 877 | importer.name == __name__): 878 | del sys.meta_path[i] 879 | break 880 | del i, importer 881 | # Finally, add the importer to the meta path import hook. 882 | sys.meta_path.append(_importer) --------------------------------------------------------------------------------