Skip to content

Commit

Permalink
cumin: add support for Kerberos auth
Browse files Browse the repository at this point in the history
* In an environment where when running Cumin the user authenticates the
  SSH connection to the remote hosts via Kerberos, in case the user
  doesn't have a valid Kerberos ticket, it would get a cryptic
  authentication failure message like:

  user@host.example.org: Permission denied
  (publickey,gssapi-keyex,gssapi-with-mic,keyboard-interactive).

* In order to present the user a more meaningful error, a new
  configuration stanza named 'kerberos' is added, see
  doc/examples/config.yaml for all the details.
* When configured to do so Cumin will ensure that the running user has
  a valid Kerberos ticket before trying to SSH to the target hosts,
  and present the user a nicer error message otherwise.

Bug: T244840
Change-Id: I92fb2450c3d7c24abbb6b1257f7fa5c83e92fb1a
  • Loading branch information
Moritz Mühlenhoff authored and volans- committed Jun 21, 2021
1 parent e7f9ca9 commit a74c62f
Show file tree
Hide file tree
Showing 6 changed files with 141 additions and 5 deletions.
27 changes: 27 additions & 0 deletions cumin/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"""Automation and orchestration framework written in Python."""
import logging
import os
import subprocess

import yaml

from ClusterShell.NodeSet import NodeSet, RESOLVER_NOGROUP
from pkg_resources import DistributionNotFound, get_distribution


KERBEROS_KLIST = '/usr/bin/klist'
try:
__version__ = get_distribution(__name__).version
""":py:class:`str`: the version of the current Cumin module."""
Expand Down Expand Up @@ -140,3 +142,28 @@ def nodeset_fromlist(nodelist):
"""
return NodeSet.fromlist(nodelist, resolver=RESOLVER_NOGROUP)


def ensure_kerberos_ticket(config: Config) -> None:
"""Ensure that there is a valid Kerberos ticket for the current user, according to the given configuration.
Arguments:
config (cumin.Config): the Cumin's configuration dictionary.
"""
kerberos_config = config.get('kerberos', {})
if not kerberos_config or not kerberos_config.get('ensure_ticket', False):
return

if not kerberos_config.get('ensure_ticket_root', False) and os.geteuid() == 0:
return

if not os.access(KERBEROS_KLIST, os.X_OK):
raise CuminError('The Kerberos config ensure_ticket is set to true, but {klist} executable was '
'not found.'.format(klist=KERBEROS_KLIST))

try:
subprocess.run([KERBEROS_KLIST, '-s'], check=True) # nosec
except subprocess.CalledProcessError as e:
raise CuminError('The Kerberos config ensure_ticket is set to true, but no active Kerberos ticket was found, '
"please run 'kinit' and retry.") from e
3 changes: 2 additions & 1 deletion cumin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

import cumin

from cumin import backends, query, transport, transports
from cumin import backends, ensure_kerberos_ticket, query, transport, transports
from cumin.color import Colored
from cumin.transports.clustershell import TqdmQuietReporter

Expand Down Expand Up @@ -390,6 +390,7 @@ def run(args, config):
if not hosts:
return 0

ensure_kerberos_ticket(config)
target = transports.Target(hosts, batch_size=args.batch_size['value'], batch_size_ratio=args.batch_size['ratio'],
batch_sleep=args.batch_sleep)
worker = transport.Transport.new(config, target)
Expand Down
95 changes: 95 additions & 0 deletions cumin/tests/unit/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
import logging
import os

from subprocess import CalledProcessError, CompletedProcess
from unittest import mock

import pytest

from ClusterShell.NodeSet import NodeSet
Expand Down Expand Up @@ -159,3 +162,95 @@ def test_nodeset_fromlist_empty():
assert isinstance(nodeset, NodeSet)
assert nodeset == NodeSet()
assert nodeset._resolver is None # pylint: disable=protected-access


@pytest.mark.parametrize('config', (
{},
{'kerberos': {}},
{'kerberos': {'ensure_ticket': False}},
))
def test_ensure_kerberos_ticket_config_disabled(config):
"""It should return without raising an exception if it's disabled from the config."""
cumin.ensure_kerberos_ticket(config)


@pytest.mark.parametrize('config', (
{'kerberos': {'ensure_ticket': True}},
{'kerberos': {'ensure_ticket': True, 'ensure_ticket_root': False}},
))
@mock.patch('cumin.os.geteuid')
def test_ensure_kerberos_ticket_root_excluded(mocked_geteuid, config):
"""It should not check the Kerberos ticket when running as root if ensure_ticket_root is not set."""
mocked_geteuid.return_value = 0
cumin.ensure_kerberos_ticket(config)
mocked_geteuid.assert_called_once_with()


@pytest.mark.parametrize('uid, ensure_ticket_root', (
(1000, False),
(0, True),
))
@mock.patch('cumin.os.access')
@mock.patch('cumin.os.geteuid')
def test_ensure_kerberos_ticket_no_klist(mocked_geteuid, mocked_os_access, uid, ensure_ticket_root):
"""It should raise CuminError if the klist executable is not found."""
mocked_geteuid.return_value = uid
mocked_os_access.return_value = False
with pytest.raises(cumin.CuminError, match='klist executable was not found'):
cumin.ensure_kerberos_ticket({'kerberos': {'ensure_ticket': True, 'ensure_ticket_root': ensure_ticket_root}})

if ensure_ticket_root:
assert not mocked_geteuid.called
else:
mocked_geteuid.assert_called_once_with()

mocked_os_access.assert_called_once_with(cumin.KERBEROS_KLIST, os.X_OK)


@pytest.mark.parametrize('uid, ensure_ticket_root', (
(1000, False),
(0, True),
))
@mock.patch('cumin.subprocess.run')
@mock.patch('cumin.os.access')
@mock.patch('cumin.os.geteuid')
def test_ensure_kerberos_ticket_no_ticket(mocked_geteuid, mocked_os_access, mocked_run, uid, ensure_ticket_root):
"""It should raise CuminError if there is no valid Kerberos ticket."""
run_command = [cumin.KERBEROS_KLIST, '-s']
mocked_run.side_effect = CalledProcessError(1, run_command)
mocked_geteuid.return_value = uid
mocked_os_access.return_value = True
with pytest.raises(cumin.CuminError, match='but no active Kerberos ticket was found'):
cumin.ensure_kerberos_ticket({'kerberos': {'ensure_ticket': True, 'ensure_ticket_root': ensure_ticket_root}})

if ensure_ticket_root:
assert not mocked_geteuid.called
else:
mocked_geteuid.assert_called_once_with()

mocked_os_access.assert_called_once_with(cumin.KERBEROS_KLIST, os.X_OK)
mocked_run.assert_called_once_with(run_command, check=True)


@pytest.mark.parametrize('uid, ensure_ticket_root', (
(1000, False),
(0, True),
))
@mock.patch('cumin.subprocess.run')
@mock.patch('cumin.os.access')
@mock.patch('cumin.os.geteuid')
def test_ensure_kerberos_ticket_valid(mocked_geteuid, mocked_os_access, mocked_run, uid, ensure_ticket_root):
"""It should return without raising any error if there is a valid Kerberos ticket."""
run_command = [cumin.KERBEROS_KLIST, '-s']
mocked_run.return_value = CompletedProcess(run_command, 0)
mocked_geteuid.return_value = uid
mocked_os_access.return_value = True
cumin.ensure_kerberos_ticket({'kerberos': {'ensure_ticket': True, 'ensure_ticket_root': ensure_ticket_root}})

if ensure_ticket_root:
assert not mocked_geteuid.called
else:
mocked_geteuid.assert_called_once_with()

mocked_os_access.assert_called_once_with(cumin.KERBEROS_KLIST, os.X_OK)
mocked_run.assert_called_once_with(run_command, check=True)
9 changes: 9 additions & 0 deletions doc/examples/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ clustershell:
- 'some_option'
fanout: 16 # Max size of the sliding window of concurrent workers active at any given time [optional, default: 64]

# Kerberos specific configuration [optional]
kerberos:
# Whether the SSH authentication to the hosts will be done via Kerberos. If true ensures that a valid Kerberos
# ticket is present for the current user. [optional, default: false]
ensure_ticket: false
# Whether the check for a valid Kerberos ticket should be performed also when Cumin is run as root.
# [optional, default: false]
ensure_ticket_root: false

# Plugins-specific configuration
plugins:
backends: # External backends. Each module must define GRAMMAR_PREFIX and query_class, and be in Python PATH
Expand Down
7 changes: 5 additions & 2 deletions doc/source/introduction.rst
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ The transport layer is the one used to convey the commands to be executed into t
abstraction allow to specify different execution strategies. Those are the available backends:

* **ClusterShell**: SSH transport using the `ClusterShell <https://github.com/cea-hpc/clustershell>`__ Python library.
See the :py:class:`cumin.transports.clustershell.ClusterShellWorker` class documentation for the details. The root
user must be able to SSH into the target hosts. It's possible to set SSH-related options in the configuration.
See the :py:class:`cumin.transports.clustershell.ClusterShellWorker` class documentation for the details. It's
possible to set all SSH-related options in the configuration file, also passing directly an existing ssh_config file.

Examples
--------
Expand Down Expand Up @@ -158,6 +158,9 @@ More complex example fine-tuning many of the parameters::

config = cumin.Config(config='/path/to/custom/cumin/config.yaml')
hosts = query.Query(config).execute('A:nginx') # Match hosts defined by the query alias named 'nginx'.
# Needed only if SSH is authenticated via Kerberos and the related configuration flags are set
# (see also the example configuration).
cumin.ensure_kerberos_ticket(config)
# Moving window of 5 hosts a time with 30s sleep before adding a new host once the previous one has finished.
target = transports.Target(hosts, batch_size=5, batch_sleep=30.0)
worker = transport.Transport.new(config, target)
Expand Down
5 changes: 3 additions & 2 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ commands =
mypy: mypy cumin/
unit: py.test -p no:logging --strict-markers --cov-report=term-missing --cov=cumin cumin/tests/unit {posargs}
unitbase: py.test -p no:logging --strict-markers --cov-report=term-missing --cov=cumin --ignore=cumin/tests/unit/backends/test_openstack.py cumin/tests/unit {posargs}
# Avoid bandit import_subprocess (B404) overall, the import itself it not unsafe
bandit: bandit -l -i -r --skip B404 --exclude './cumin/tests' ./cumin/
# Avoid bandit assert_used (B101) in tests
bandit: bandit -l -i -r --exclude './cumin/tests' ./cumin/
bandit: bandit -l -i -r --skip B101 cumin/tests
bandit: bandit -l -i -r --skip B101,B404 cumin/tests
prospector: prospector --profile "{toxinidir}/prospector.yaml" cumin/
sphinx: python setup.py build_sphinx -b html
man: python setup.py build_sphinx -b man
Expand Down

0 comments on commit a74c62f

Please sign in to comment.