Merge ~peppepetra/charm-sudo-pair/+git/sudo-pair-charm:master into ~sudo-pair-charmers/charm-sudo-pair:master

Proposed by Giuseppe Petralia
Status: Merged
Approved by: Chris Sanders
Approved revision: 4fa4c2aae2b2bf19bd0d1e51a7f32e7686ca3ed6
Merge reported by: Jeremy Lounder
Merged at revision: 4fa4c2aae2b2bf19bd0d1e51a7f32e7686ca3ed6
Proposed branch: ~peppepetra/charm-sudo-pair/+git/sudo-pair-charm:master
Merge into: ~sudo-pair-charmers/charm-sudo-pair:master
Diff against target: 962 lines (+830/-2)
21 files modified
README.md (+51/-2)
config.yaml (+21/-0)
files/sudo.prompt.pair (+9/-0)
files/sudo.prompt.user (+7/-0)
files/sudoers (+31/-0)
layer.yaml (+8/-0)
lib/libsudopair.py (+106/-0)
metadata.yaml (+17/-0)
reactive/sudo_pair.py (+41/-0)
templates/91-bypass-sudopair-cmds.tmpl (+6/-0)
templates/sudo.conf.tmpl (+1/-0)
templates/sudo_approve.tmpl (+123/-0)
tests/00-unit (+3/-0)
tests/01-functional (+3/-0)
tests/functional/requirements.txt (+6/-0)
tests/functional/test_deploy.py (+164/-0)
tests/tests.yaml (+1/-0)
tests/unit/conftest.py (+46/-0)
tests/unit/requirements.txt (+4/-0)
tests/unit/test_libsudopair.py (+165/-0)
tox.ini (+17/-0)
Reviewer Review Type Date Requested Status
Chris Sanders (community) Approve
Alvaro Uria (community) Needs Fixing
Review via email: mp+357701@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Alvaro Uria (aluria) wrote :

Please find comments inline.

OTOH, what is the use case for this charm? When a unit is deployed, "juju ssh" will always use the ubuntu user. So, no matter a colleague will approve the sudo_pair request that it will look it comes from the same person (because they use the same local user). I see auto_approval is allowed, by action will trigger a PD alert.

As a minor request, could no_auto_approve be changed to "auto_approve"? I think it is easier to understand "auto_approve=true" than "no_auto_approve=false", although both would do the same.

review: Needs Fixing
Revision history for this message
Giuseppe Petralia (peppepetra) wrote :

> Please find comments inline.
>
> OTOH, what is the use case for this charm? When a unit is deployed, "juju ssh"
> will always use the ubuntu user. So, no matter a colleague will approve the
> sudo_pair request that it will look it comes from the same person (because
> they use the same local user). I see auto_approval is allowed, by action will
> trigger a PD alert.
>
> As a minor request, could no_auto_approve be changed to "auto_approve"? I
> think it is easier to understand "auto_approve=true" than
> "no_auto_approve=false", although both would do the same.

Comments accepted. Thanks for the review.

About the "juju ssh as me" I am still investigating a possible implementation for that but it's not strictly related to sudo-pair functionality. Any suggestion there is more than welcome. In any case it is in my to do list. At least the investigation. Right now it's not clear to me how to implement it and juju model sharing is not trivial in an automated way.

Revision history for this message
Chris Sanders (chris.sanders) wrote :

Comments in line

review: Needs Fixing
Revision history for this message
Chris Sanders (chris.sanders) wrote :

Added another in-line comment, please be sure to check the previous in-line comments as well this is in addition to not a replacement for the previous comments.

Revision history for this message
Chris Sanders (chris.sanders) wrote :

Comments in-line, at least one of them would be easiest if we talk about in person.

review: Needs Fixing
Revision history for this message
Chris Sanders (chris.sanders) wrote :

Thanks for the walk through this morning. A few comments in line.

review: Needs Fixing
Revision history for this message
Peter Sabaini (peter-sabaini) wrote :

Minor comment added.

Revision history for this message
Giuseppe Petralia (peppepetra) wrote :

Fixed. Thanks.
> Minor comment added.

Revision history for this message
Chris Sanders (chris.sanders) wrote :

looks good to me, these functional tests are really clean some of those fixtures would probably be nice to include in the template for re-use on new charms.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/README.md b/README.md
index 9cc2dad..0804cca 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,51 @@
1# Sudo_pair Charm1# Overview
2TODO2sudo_pair is a sudo plugin that ensure that no user
3can act entirely on their own authority within these systems.
4Once configured if a user tries to get root privileges, he will need
5an authorization from a pair that will monitor over his session.
6
7# Build
8```
9cd sudo-pair
10charm build
11```
12
13# Usage
14Add to an existing application using juju-info relation.
15
16Example:
17```
18juju deploy ubuntu
19juju deploy ./sudo-pair
20juju add-unit ubuntu
21juju add-relation ubuntu sudo-pair
22```
23
24# Configuration
25The user can configure the following parameters:
26* ```groups_enforced``` (default: ```root```): This is a comma-separated list of group names that sudo_pair will gate access to. If a user is sudoing to a user that is a member of one of these groups, they will be required to have a pair approve their session.
27* ```groups_exempted```(default: ```none```): This is a comma-separated list of group names whose users will be exempted from the requirements of sudo_pair. Note that this is not the opposite of the groups_enforced flag. Whereas groups_enforced gates access to groups, groups_exempted exempts users sudoing from groups. For instance, this setting can be used to ensure that oncall sysadmins can respond to outages without needing to find a pair.
28* ```bypass_cmds``` (default: ```none```): This is a comma-separated list of full path commands that have to be bypassed from sudo pairing
29* ```bypass_group``` (default: ```none```): This is the unix group for which the commands specified through bypass_cmds will be bypassed from sudo pairing approval
30* ```auto_approve``` (default: ```true```): If true, auto approval is permitted.
31
32# Testing
33Unit tests has been developed to test templates rendering for ```sudo.conf```, ```sudoers.d/91-bypass-sudopair-cmds```, ```sudo_approve```
34
35To run unit tests:
36```bash
37tox -e unit
38```
39Deploy tests has been developed using python-libjuju
40
41To run tests using python-libjuju:
42```bash
43tox -e functional
44```
45
46
47# Contact Information
48Giuseppe Petralia <giuseppe.petralia@canonical.com>
49
50[service]: https://github.com/square/sudo_pair
51[icon guidelines]: https://jujucharms.com/docs/stable/authors-charm-icon
diff --git a/config.yaml b/config.yaml
3new file mode 10064452new file mode 100644
index 0000000..795269b
--- /dev/null
+++ b/config.yaml
@@ -0,0 +1,21 @@
1options:
2 groups_enforced:
3 type: string
4 default: "root"
5 description: "This is a comma-separated list of group names that sudo_pair will gate access to."
6 groups_exempted:
7 type: string
8 default: ""
9 description: "This is a comma-separated list of group names whose users will be exempted from the requirements of sudo_pair"
10 bypass_cmds:
11 type: string
12 default: ""
13 description: "This is a comma-separated list of full path commands that have to be bypassed from sudo pairing"
14 bypass_group:
15 type: string
16 default: ""
17 description: "This is the unix group for which the commands will be bypassed from sudo pairing approval"
18 auto_approve:
19 type: boolean
20 default: true
21 description: "If true, auto approval is permitted."
diff --git a/files/sudo.prompt.pair b/files/sudo.prompt.pair
0new file mode 10064422new file mode 100644
index 0000000..69c5c6a
--- /dev/null
+++ b/files/sudo.prompt.pair
@@ -0,0 +1,9 @@
1]0;sudo: <%U@%h:%d $ > %C[8;%H;%WtYou have been asked to approve and monitor the following sudo session:
2
3 <%U@%h:%d $ > %C
4
5Once approved, this terminal will mirror all output from the active sudo session until its completion.
6
7Closing this terminal, losing your network connection to this host, or explicitly ending the session by typing <Ctrl-D> will cause the command being run under elevated privileges to terminate immediately.
8
9Approve? y/n [n]:
diff --git a/files/sudo.prompt.user b/files/sudo.prompt.user
0new file mode 10064410new file mode 100644
index 0000000..4bc034f
--- /dev/null
+++ b/files/sudo.prompt.user
@@ -0,0 +1,7 @@
1Due to security and compliance requirements, this `sudo` session will require approval and monitoring from another engineer. When finished, this session will be archived permanently for later retrieval and analysis.
2
3To continue, another engineer must run:
4
5 ssh -t '%h' 'sh -l -c "sudo %b %u %p"'
6
7If a suitable engineer is not available and you have an immediate and urgent need to run this command (e.g., a payments outage or other serious system issue), you may run the above command to approve your own session. Note that doing so will immediately page an oncall security engineer, so this capability should only be used in the event of an emergency.
0\ No newline at end of file8\ No newline at end of file
diff --git a/files/sudo_pair.so b/files/sudo_pair.so
1new file mode 1007559new file mode 100755
index 0000000..44bcb8d
2Binary files /dev/null and b/files/sudo_pair.so differ10Binary files /dev/null and b/files/sudo_pair.so differ
diff --git a/files/sudoers b/files/sudoers
3new file mode 10064411new file mode 100644
index 0000000..e3a34ef
--- /dev/null
+++ b/files/sudoers
@@ -0,0 +1,31 @@
1#
2# This file MUST be edited with the 'visudo' command as root.
3#
4# Please consider adding local content in /etc/sudoers.d/ instead of
5# directly modifying this file.
6#
7# See the man page for details on how to write a sudoers file.
8#
9Defaults env_reset
10Defaults mail_badpass
11Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin"
12Defaults log_output
13
14# Host alias specification
15
16# User alias specification
17
18# Cmnd alias specification
19
20# User privilege specification
21root ALL=(ALL:ALL) ALL
22
23# Members of the admin group may gain root privileges
24%admin ALL=(ALL) ALL
25
26# Allow members of group sudo to execute any command
27%sudo ALL=(ALL:ALL) ALL
28
29# See sudoers(5) for more information on "#include" directives:
30
31#includedir /etc/sudoers.d
diff --git a/layer.yaml b/layer.yaml
0new file mode 10064432new file mode 100644
index 0000000..7473bae
--- /dev/null
+++ b/layer.yaml
@@ -0,0 +1,8 @@
1includes:
2 - layer:basic
3 - layer:apt
4options:
5 apt:
6 packages:
7 - socat
8repo: https://git.launchpad.net/sudo-pair-charm
diff --git a/lib/libsudopair.py b/lib/libsudopair.py
0new file mode 1006449new file mode 100644
index 0000000..aa24250
--- /dev/null
+++ b/lib/libsudopair.py
@@ -0,0 +1,106 @@
1import grp
2import os
3from charmhelpers.core import host, hookenv, templating
4
5
6def check_valid_group(group_name):
7 try:
8 grp.getgrnam(group_name)
9 return True
10 except KeyError:
11 return False
12
13
14def group_id(group_name):
15 return grp.getgrnam(group_name).gr_gid
16
17
18def group_names_to_group_ids(group_names):
19 """
20 From Group Names comma-separated list to Group Ids
21 :param group_names: i.e. "root,user1,user2"
22 :return gids: i.e. "0,1001,1002"
23 """
24 group_names = list(filter(check_valid_group, group_names.split(',')))
25 return ','.join(map(str, (map(group_id, group_names))))
26
27
28def copy_file(source, destination, owner, group, perms):
29 if destination is not None:
30 target_dir = os.path.dirname(destination)
31 if not os.path.exists(target_dir):
32 # This is a terrible default directory permission, as the file
33 # or its siblings will often contain secrets.
34 host.mkdir(os.path.dirname(destination), owner, group, perms=0o755)
35 with open(source, 'rb') as source_f:
36 host.write_file(destination, source_f.read(), perms=perms, owner=owner, group=group)
37
38
39class SudoPairHelper(object):
40 def __init__(self):
41 self.charm_config = hookenv.config()
42 self.binary_path = '/usr/bin/sudo_approve'
43 self.sudo_conf_path = '/etc/sudo.conf'
44 self.sudoers_path = '/etc/sudoers'
45 self.sudo_lib_path = '/usr/lib/sudo/sudo_pair.so'
46 self.sudoers_bypass_path = "/etc/sudoers.d/91-bypass-sudopair-cmds"
47 self.user_prompt_path = '/etc/sudo_pair.prompt.user'
48 self.pair_prompt_path = '/etc/sudo_pair.prompt.pair'
49 self.socket_dir = '/var/run/sudo_pair'
50 self.owner = 'root'
51 self.group = 'root'
52 self.socket_dir_perms = 0o644
53 self.sudo_pair_so_perms = 0o644
54 self.prompt_perms = 0o644
55 self.sudoers_perms = 0o440
56 self.sudo_conf_perms = 0o440
57 self.sudo_approve_perms = 0o755
58
59 def get_config(self):
60 config = {
61 'binary_path' : self.binary_path,
62 'user_prompt_path' : self.user_prompt_path,
63 'pair_prompt_path' : self.pair_prompt_path,
64 'socket_dir': self.socket_dir,
65 'gids_enforced': group_names_to_group_ids(self.charm_config['groups_enforced']),
66 'gids_exempted' : group_names_to_group_ids(self.charm_config['groups_exempted']),
67 }
68
69 config.update(self.charm_config)
70 return config
71
72 def set_charm_config(self, charm_config):
73 self.charm_config = charm_config
74
75 def render_sudo_conf(self):
76 return templating.render('sudo.conf.tmpl', self.sudo_conf_path, self.get_config(),
77 perms=self.sudo_conf_perms, owner=self.owner, group=self.group)
78
79 def create_socket_dir(self):
80 host.mkdir(self.socket_dir, perms=self.socket_dir_perms, owner=self.owner, group=self.group)
81
82 def install_sudo_pair_so(self):
83 sudo_pair_lib = os.path.join(hookenv.charm_dir(), 'files', 'sudo_pair.so')
84 copy_file(sudo_pair_lib, self.sudo_lib_path, self.owner, self.group, self.sudo_pair_so_perms)
85
86 def copy_user_prompt(self):
87 prompt_file = os.path.join(hookenv.charm_dir(), 'files', 'sudo.prompt.user')
88 copy_file(prompt_file, self.user_prompt_path, self.owner, self.group, self.prompt_perms)
89
90 def copy_pair_prompt(self):
91 prompt_file = os.path.join(hookenv.charm_dir(), 'files', 'sudo.prompt.pair')
92 copy_file(prompt_file, self.pair_prompt_path, self.owner, self.group, self.prompt_perms)
93
94 def copy_sudoers(self):
95 sudoers_file = os.path.join(hookenv.charm_dir(), 'files', 'sudoers')
96 copy_file(sudoers_file, self.sudoers_path, self.owner, self.group, self.sudoers_perms)
97
98 def render_sudo_approve(self):
99 return templating.render('sudo_approve.tmpl', self.binary_path, self.get_config(),
100 perms=self.sudo_approve_perms, owner=self.owner, group=self.group)
101
102 def render_bypass_cmds(self):
103 if self.get_config()['bypass_cmds'] != "" and self.get_config()['bypass_group'] != "":
104 return templating.render('91-bypass-sudopair-cmds.tmpl', self.sudoers_bypass_path,
105 self.get_config(), perms=0o440, owner=self.owner, group=self.group)
106 return None
diff --git a/metadata.yaml b/metadata.yaml
0new file mode 100644107new file mode 100644
index 0000000..5c24d8d
--- /dev/null
+++ b/metadata.yaml
@@ -0,0 +1,17 @@
1name: sudo-pair
2display-name: sudo-pair
3summary: sudo_pair is a sudo plugin to manage root privileges
4maintainer: Giuseppe Petralia <giuseppe.petralia@canonical.com>
5description: |
6 sudo_pair is a sudo plugin that ensure that if a user tries to get root privileges,
7 he will need an authorization from a pair
8tags:
9 - ops
10series:
11 - xenial
12 - bionic
13subordinate: true
14requires:
15 juju-info:
16 interface: juju-info
17 scope: container
diff --git a/reactive/sudo_pair.py b/reactive/sudo_pair.py
0new file mode 10064418new file mode 100644
index 0000000..a5de1e5
--- /dev/null
+++ b/reactive/sudo_pair.py
@@ -0,0 +1,41 @@
1from charms.reactive import when, when_not, set_state, remove_state, hook
2from charmhelpers.core import hookenv
3
4
5from libsudopair import SudoPairHelper
6
7sph = SudoPairHelper()
8
9
10@when('apt.installed.socat')
11@when_not('sudo-pair.configured')
12def install_sudo_pair():
13 # Install sudo_pair.so, create socket dir, copy sudo_approve to /usr/bin, copy prompts to /etc
14 sph.install_sudo_pair_so()
15
16 sph.create_socket_dir()
17
18 sph.copy_user_prompt()
19
20 sph.copy_pair_prompt()
21
22 sph.render_sudo_approve()
23
24 # Add "Defaults log_output to /etc/sudoers
25 sph.copy_sudoers()
26
27 # If there are cmds to bypass sudo pairing create file unders sudoers.d
28 sph.render_bypass_cmds()
29
30 # Add Plugin sudo_pair sudo_pair.so to sudo.conf
31 sph.render_sudo_conf()
32
33 set_state('sudo-pair.installed')
34 set_state('sudo-pair.configured')
35 hookenv.status_set('active', 'sudo pairing for users groups: [{}]'.format(sph.get_config()['gids_enforced']))
36
37
38@hook('config-changed')
39def reconfigure_sudo_pair_charm():
40 sph.set_charm_config(hookenv.config())
41 remove_state('sudo-pair.configured')
diff --git a/templates/91-bypass-sudopair-cmds.tmpl b/templates/91-bypass-sudopair-cmds.tmpl
0new file mode 10064442new file mode 100644
index 0000000..cb54e77
--- /dev/null
+++ b/templates/91-bypass-sudopair-cmds.tmpl
@@ -0,0 +1,6 @@
1# Created by sudo-pair
2
3# Bypass Sudo Pair commands
4%{{ bypass_group }} ALL = (ALL) NOLOG_OUTPUT: {{ bypass_cmds }}
5
6
diff --git a/templates/sudo.conf.tmpl b/templates/sudo.conf.tmpl
0new file mode 1006447new file mode 100644
index 0000000..db9a689
--- /dev/null
+++ b/templates/sudo.conf.tmpl
@@ -0,0 +1 @@
1Plugin sudo_pair sudo_pair.so binary_path={{ binary_path }} user_prompt_path={{ user_prompt_path }} pair_prompt_path={{ pair_prompt_path }} socket_dir={{ socket_dir }} gids_enforced={{ gids_enforced }} {% if gids_exempted != "" %}gids_exempted={{ gids_exempted }} {% endif %}
diff --git a/templates/sudo_approve.tmpl b/templates/sudo_approve.tmpl
0new file mode 1007552new file mode 100755
index 0000000..7164b5a
--- /dev/null
+++ b/templates/sudo_approve.tmpl
@@ -0,0 +1,123 @@
1#!/usr/bin/env bash
2#
3# Copyright 2018 Square Inc.
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
14# implied. See the License for the specific language governing
15# permissions and limitations under the License.
16
17#set -o errexit # quit on first error
18set -o pipefail # quit on failures in pipes
19set -o nounset # quit on unset variables
20
21[[ ${TRACE:-} ]] && set -o xtrace # output subcommands if TRACE is set
22
23declare -r SUDO_SOCKET_PATH="{{ socket_dir }}"
24
25pair() {
26 declare -r socket="${1}"
27
28 # restore TTY settings on exit
29 # shellcheck disable=SC2064
30 trap "stty $(stty -g)" EXIT
31
32 # disable line-buffering and local echo, so the pairer doesn't
33 # get confused that their typing in the shell isn't doing
34 # anything
35 stty cbreak -echo
36
37 # send SIGINT on Ctrl-D
38 stty intr "^D"
39
40 clear
41
42 # prompt the user to approve
43 socat STDIO unix-connect:"${socket}"
44}
45
46usage() {
47 echo "Usage: $(basename -- "$0") uid pid"
48 exit 1
49}
50
51main() {
52 declare -r socket_path="${1}"
53 declare -ri uid="${2}"
54 declare -ri pid="${3}"
55
56 # if we're running this under `sudo`, we want to know the original
57 # user's `uid` from `SUDO_UID`; if not, it's jsut their normal `uid`
58 declare -i ruid
59 ruid="${SUDO_UID:-$(id -u)}"
60 declare -r ruid
61
62 declare -r socket="${socket_path}/${uid}.${pid}.sock"
63
64 declare -i socket_uid socket_gid
65 socket_uid="$(stat -c '%u' "${socket}")"
66 socket_gid="$(stat -c '%g' "${socket}")"
67 declare -r socket_uid socket_gid
68
69 declare socket_user socket_group socket_mode
70 socket_user="$(getent passwd "${socket_uid}" | cut -d: -f1)"
71 socket_group="$(getent group "${socket_gid}" | cut -d: -f1)"
72 socket_mode="$(stat -c '%a' "${socket}")"
73 declare -r socket_user socket_group socket_mode
74
75 # if the user approving the command is the same as the user who
76 # invoked `sudo` in the first place, abort
77 #
78 # another option would be to allow the session, but log it in a way
79 # that it immediately pages oncall security engineers; such an
80 # approach is useful in production systems in that it allows for a
81 # in-case-of-fire-break-glass workaround so engineers can respond to
82 # a outage in the middle of the night
83 #
84 # this responsibility will be moved into the plugin itself when time
85 # allots
86 declare username
87 username="$(getent passwd "${uid}" | cut -d: -f1)"
88 declare -r username
89
90 declare log_line
91 log_line="$(date "+[%b %d %H:%M:%S] WARNING: ${username} approved is own sudo session.")"
92 declare -r log_line
93
94 if [[ "${uid}" -eq "${ruid}" ]]; then
95 {% if not auto_approve %}
96 echo "You can't approve your own session."
97 exit 1
98 {% else %}
99 echo "You are approving your own session. The incident will be logged."
100 echo ${log_line} >> /var/log/sudo_pair.log
101 {% endif %}
102 fi
103
104 # if we can write: pair
105 # if user-owner can write: sudo to them and try again
106 # if group-owner can write: sudo to them and try again
107 # if none, die
108 if [ -w "${socket}" ]; then
109 pair "${socket}"
110 elif [[ $(( 8#${socket_mode} & 8#200 )) -ne 0 ]]; then
111 sudo -u "${socket_user}" "${0}" "${uid}" "${pid}"
112 elif [[ $(( 8#${socket_mode} & 8#020 )) -ne 0 ]]; then
113 sudo -g "${socket_group}" "${0}" "${uid}" "${pid}"
114 else
115 echo "The socket for this sudo session is neither user- nor group-writable."
116 exit 2
117 fi
118}
119
120case "$#" in
121 2) main "${SUDO_SOCKET_PATH}" "$1" "$2" ;;
122 *) usage ;;
123esac
diff --git a/tests/00-unit b/tests/00-unit
0new file mode 100755124new file mode 100755
index 0000000..d5f2e15
--- /dev/null
+++ b/tests/00-unit
@@ -0,0 +1,3 @@
1#!/bin/bash
2
3tox -v -e unit
diff --git a/tests/01-functional b/tests/01-functional
0new file mode 1007554new file mode 100755
index 0000000..946e972
--- /dev/null
+++ b/tests/01-functional
@@ -0,0 +1,3 @@
1#!/bin/bash
2
3tox -v -e functional
diff --git a/tests/functional/requirements.txt b/tests/functional/requirements.txt
0new file mode 1006444new file mode 100644
index 0000000..b9815c2
--- /dev/null
+++ b/tests/functional/requirements.txt
@@ -0,0 +1,6 @@
1juju
2requests
3pytest
4pytest-asyncio
5mock
6flake8
diff --git a/tests/functional/test_deploy.py b/tests/functional/test_deploy.py
0new file mode 1006447new file mode 100644
index 0000000..28ac196
--- /dev/null
+++ b/tests/functional/test_deploy.py
@@ -0,0 +1,164 @@
1#!/usr/bin/python3.6
2
3from juju.model import Model
4import asyncio
5import json
6import pytest
7
8
9STAT_FILE = "python3 -c \"import json; import os; s=os.stat('%s'); print(json.dumps({'uid': s.st_uid, 'gid': s.st_gid, 'mode': oct(s.st_mode), 'size': s.st_size}))\""
10
11FILE_CONTENT = "python3 -c \"print(open('%s').read())\""
12
13pytestmark = pytest.mark.asyncio
14
15
16@pytest.fixture
17async def model():
18 model = Model()
19 await model.connect_current()
20 yield model
21 await model.disconnect()
22
23
24@pytest.fixture
25async def deploy_app(model):
26 await model.deploy(
27 'ubuntu',
28 application_name='ubuntu',
29 series='bionic',
30 channel='stable'
31 )
32 sudo_pair_app = await model.deploy(
33 'local:',
34 application_name='sudo-pair',
35 series='bionic',
36 num_units=0,
37 config={
38 'bypass_cmds': '/bin/ls',
39 'groups_enforced': 'ubuntu',
40 'bypass_group': 'warthogs',
41 }
42 )
43 await model.add_relation(
44 'ubuntu',
45 'sudo-pair'
46 )
47
48 await model.block_until(lambda: sudo_pair_app.status == 'active')
49 return sudo_pair_app
50
51
52@pytest.fixture
53async def get_app(model):
54 for app in model.applications:
55 if app == 'sudo-pair':
56 return model.applications[app]
57
58
59@pytest.fixture
60async def get_unit(model):
61 for app in model.applications:
62 if app == 'ubuntu':
63 return model.applications[app].units[0]
64
65
66@pytest.fixture
67async def run_command(get_unit):
68 async def make_run_command(cmd):
69 action = await get_unit.run(cmd)
70 return action.results
71 return make_run_command
72
73
74@pytest.fixture
75async def file_stat(run_command):
76 async def make_file_stat(path):
77 cmd = STAT_FILE % path
78 results = await run_command(cmd)
79 return json.loads(results['Stdout'])
80 return make_file_stat
81
82
83@pytest.fixture
84async def file_contents(run_command):
85 async def make_file_contents(path):
86 cmd = FILE_CONTENT % path
87 results = await run_command(cmd)
88 return results['Stdout']
89 return make_file_contents
90
91
92@pytest.fixture
93async def reconfigure_app(get_app):
94 async def make_reconfigure_app(cfg):
95 await get_app.set_config(cfg)
96 await get_app.get_config()
97 await asyncio.sleep(10)
98 return make_reconfigure_app
99
100
101@pytest.fixture
102async def create_group(run_command):
103 async def make_create_group(group_name):
104 cmd = "sudo groupadd %s" % group_name
105 await run_command(cmd)
106 return make_create_group
107
108
109async def test_deploy(deploy_app):
110 status = deploy_app.status
111 assert status == 'active'
112
113
114async def test_sudo_pair_lib(file_stat):
115 sudo_pair_lib_stat = await file_stat("/usr/lib/sudo/sudo_pair.so")
116 assert sudo_pair_lib_stat['size'] > 0
117 assert sudo_pair_lib_stat['gid'] == 0
118 assert sudo_pair_lib_stat['uid'] == 0
119 assert sudo_pair_lib_stat['mode'] == '0o100644'
120
121
122async def test_sudo_approve(file_stat, file_contents):
123 sudo_approve_path = '/usr/bin/sudo_approve'
124 sudo_approve_stat = await file_stat(sudo_approve_path)
125 assert sudo_approve_stat['size'] > 0
126 assert sudo_approve_stat['gid'] == 0
127 assert sudo_approve_stat['uid'] == 0
128 assert sudo_approve_stat['mode'] == '0o100755'
129
130
131async def test_sudo_prompt(file_stat):
132 for prompt_type in ['user', 'pair']:
133 sudo_prompt_stat = await file_stat('/etc/sudo_pair.prompt.' + prompt_type)
134 assert sudo_prompt_stat['size'] > 0
135 assert sudo_prompt_stat['gid'] == 0
136 assert sudo_prompt_stat['uid'] == 0
137 assert sudo_prompt_stat['mode'] == '0o100644'
138
139
140async def test_socket_dir(file_stat):
141 dir_stat = await file_stat('/var/run/sudo_pair')
142 assert dir_stat['gid'] == 0
143 assert dir_stat['uid'] == 0
144 assert dir_stat['mode'] == '0o40644'
145
146
147async def test_sudoers(file_contents):
148 sudoers_content = await file_contents("/etc/sudoers")
149 assert 'Defaults log_output' in sudoers_content
150
151
152async def test_sudoers_bypass_conf(file_contents):
153 sudoers_bypass_content = await file_contents("/etc/sudoers.d/91-bypass-sudopair-cmds")
154 content = '%warthogs ALL = (ALL) NOLOG_OUTPUT: /bin/ls'
155 assert content in sudoers_bypass_content
156
157
158async def test_reconfigure(reconfigure_app, file_contents, file_stat):
159 auto_approve = "false"
160 sudo_approve_path = '/usr/bin/sudo_approve'
161 await reconfigure_app({'auto_approve': auto_approve})
162 sudo_approve_content = await file_contents(sudo_approve_path)
163 new_content = 'echo "You can\'t approve your own session."'
164 assert new_content in sudo_approve_content
diff --git a/tests/tests.yaml b/tests/tests.yaml
0new file mode 100644165new file mode 100644
index 0000000..193e5ac
--- /dev/null
+++ b/tests/tests.yaml
@@ -0,0 +1 @@
1tests: "[0-9]*"
diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
0new file mode 1006442new file mode 100644
index 0000000..11d53a9
--- /dev/null
+++ b/tests/unit/conftest.py
@@ -0,0 +1,46 @@
1#!/usr/bin/python3
2
3import pytest
4import pwd
5import grp
6import os
7
8
9@pytest.fixture
10def mock_hookenv_config(monkeypatch):
11 import yaml
12
13 def mock_config():
14 cfg = {}
15 yml = yaml.load(open('./config.yaml'))
16
17 # Load all defaults
18 for key, value in yml['options'].items():
19 cfg[key] = value['default']
20
21 return cfg
22
23 monkeypatch.setattr('charmhelpers.core.hookenv.config', mock_config)
24
25
26@pytest.fixture
27def mock_charm_dir(monkeypatch):
28 monkeypatch.setattr('charmhelpers.core.hookenv.charm_dir', lambda: '.')
29
30
31@pytest.fixture
32def sph(mock_hookenv_config, mock_charm_dir, tmpdir):
33 from libsudopair import SudoPairHelper
34 sph = SudoPairHelper()
35 sph.owner = pwd.getpwuid(os.getuid()).pw_name
36 sph.group = grp.getgrgid(os.getgid()).gr_name
37 sph.sudo_conf_path = tmpdir.join(sph.sudo_conf_path)
38 sph.socket_dir = tmpdir.join(sph.socket_dir)
39 sph.sudo_lib_path = tmpdir.join(sph.sudo_lib_path)
40 sph.user_prompt_path = tmpdir.join(sph.user_prompt_path)
41 sph.pair_prompt_path = tmpdir.join(sph.pair_prompt_path)
42 sph.sudoers_path = tmpdir.join(sph.sudoers_path)
43 sph.binary_path = tmpdir.join(sph.binary_path)
44 sph.sudoers_bypass_path = tmpdir.join(sph.sudoers_bypass_path)
45 sph.socket_dir_perms = 0o775
46 return sph
diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt
0new file mode 10064447new file mode 100644
index 0000000..081ed97
--- /dev/null
+++ b/tests/unit/requirements.txt
@@ -0,0 +1,4 @@
1charmhelpers
2charms.reactive
3pytest
4mock
diff --git a/tests/unit/test_libsudopair.py b/tests/unit/test_libsudopair.py
0new file mode 1006445new file mode 100644
index 0000000..5abb33b
--- /dev/null
+++ b/tests/unit/test_libsudopair.py
@@ -0,0 +1,165 @@
1import os
2import grp
3import filecmp
4
5from libsudopair import (
6 check_valid_group,
7 group_id
8)
9
10
11def test_check_valid_group():
12 assert not check_valid_group('fake_group')
13 assert check_valid_group(grp.getgrgid(os.getgid()).gr_name)
14
15
16def test_group_id():
17 assert group_id(grp.getgrgid(os.getgid()).gr_name) == os.getgid()
18
19
20class TestSudoPairHelper():
21 def test_pytest(self):
22 assert True
23
24 def test_sph(self, sph):
25 ''' See if the ph fixture works to load charm configs '''
26 assert isinstance(sph.charm_config, dict)
27
28 def test_get_config(self, sph):
29 default_keywords = [
30 'binary_path',
31 'user_prompt_path',
32 'pair_prompt_path',
33 'socket_dir',
34 'gids_enforced',
35 'gids_exempted',
36 'extra_packages'
37 ]
38 config = sph.get_config()
39 for option in default_keywords:
40 assert option in config
41
42 def test_set_charm_config(self, sph):
43 charm_config = {
44 'groups_enforced': 'root',
45 'groups_exempted': '',
46 'bypass_cmds': '',
47 'bypass_group': '',
48 'auto_approve': True
49 }
50
51 sph.set_charm_config(charm_config)
52
53 for option in charm_config:
54 assert option in sph.get_config()
55 assert sph.get_config()[option] == charm_config[option]
56
57 def test_render_sudo_conf(self, sph, tmpdir):
58 # Default config
59 content = sph.render_sudo_conf()
60 expected_content = 'Plugin sudo_pair sudo_pair.so binary_path={} ' \
61 'user_prompt_path={} ' \
62 'pair_prompt_path={} socket_dir={} gids_enforced={}'.format(tmpdir.join('/usr/bin/sudo_approve'),
63 tmpdir.join('/etc/sudo_pair.prompt.user'),
64 tmpdir.join('/etc/sudo_pair.prompt.pair'),
65 tmpdir.join('/var/run/sudo_pair'),
66 '0')
67 assert expected_content in content
68
69
70 # Gid exempted
71 groups_exempted = grp.getgrgid(os.getgid()).gr_name
72 charm_config = {
73 'groups_enforced': 'root',
74 'groups_exempted': groups_exempted,
75 'bypass_cmds': '',
76 'bypass_group': '',
77 'auto_approve': True
78 }
79
80 sph.set_charm_config(charm_config)
81 expected_content = \
82 'Plugin sudo_pair sudo_pair.so binary_path={} user_prompt_path={} ' \
83 'pair_prompt_path={} socket_dir={} gids_enforced={} gids_exempted={}'.format(
84 tmpdir.join('/usr/bin/sudo_approve'),
85 tmpdir.join('/etc/sudo_pair.prompt.user'),
86 tmpdir.join('/etc/sudo_pair.prompt.pair'),
87 tmpdir.join('/var/run/sudo_pair'), '0', os.getgid())
88
89 content = sph.render_sudo_conf()
90 assert expected_content in content
91
92 # Groups enforced
93 groups_enforced = 'root,' + grp.getgrgid(os.getgid()).gr_name
94 charm_config = {
95 'groups_enforced': groups_enforced,
96 'groups_exempted': '',
97 'bypass_cmds': '',
98 'bypass_group': '',
99 'auto_approve': True
100 }
101 sph.set_charm_config(charm_config)
102 expected_content = 'Plugin sudo_pair sudo_pair.so binary_path={} user_prompt_path={} ' \
103 'pair_prompt_path={} socket_dir={} gids_enforced={}'.format(
104 tmpdir.join('/usr/bin/sudo_approve'),
105 tmpdir.join('/etc/sudo_pair.prompt.user'),
106 tmpdir.join('/etc/sudo_pair.prompt.pair'),
107 tmpdir.join('/var/run/sudo_pair'), '0,' + str(os.getgid()))
108 content = sph.render_sudo_conf()
109 assert expected_content in content
110
111 def test_render_bypass_cmds(self, sph, tmpdir):
112 # Root bypass /bin/ls
113 expected_content = '%root ALL = (ALL) NOLOG_OUTPUT: /bin/ls'
114 charm_config = {
115 'groups_enforced': 'root',
116 'groups_exempted': '',
117 'bypass_cmds': '/bin/ls',
118 'bypass_group': 'root',
119 'auto_approve': True
120 }
121 sph.set_charm_config(charm_config)
122 content = sph.render_bypass_cmds()
123 assert expected_content in content
124
125 def test_render_sudo_approve(self, sph, tmpdir):
126 # Auto Approve true
127 expected_content = 'echo ${log_line} >> /var/log/sudo_pair.log'
128 socket_dir = tmpdir.join('/var/run/sudo_pair')
129 expected_content_socket_dir = 'declare -r SUDO_SOCKET_PATH="{}"'.format(socket_dir)
130 content = sph.render_sudo_approve()
131 assert expected_content in content
132 assert expected_content_socket_dir in content
133
134 # Auto Approve false
135 expected_content = 'echo "You can\'t approve your own session."'
136 charm_config = {
137 'groups_enforced': 'root',
138 'groups_exempted': '',
139 'bypass_cmds': '/bin/ls',
140 'bypass_group': 'root',
141 'auto_approve': False
142 }
143 sph.set_charm_config(charm_config)
144 content = sph.render_sudo_approve()
145 assert expected_content in content
146
147 def test_create_socket_dir(self, sph, tmpdir):
148 sph.create_socket_dir()
149 assert os.path.exists(tmpdir.join('/var/run/sudo_pair'))
150
151 def test_install_sudo_pair_so(self, sph, tmpdir):
152 sph.install_sudo_pair_so()
153 assert filecmp.cmp('./files/sudo_pair.so', tmpdir.join('/usr/lib/sudo/sudo_pair.so'))
154
155 def test_copy_user_prompt(self, sph, tmpdir):
156 sph.copy_user_prompt()
157 assert filecmp.cmp('./files/sudo.prompt.user', tmpdir.join('/etc/sudo_pair.prompt.user'))
158
159 def test_copy_pair_prompt(self, sph, tmpdir):
160 sph.copy_pair_prompt()
161 assert filecmp.cmp('./files/sudo.prompt.pair', tmpdir.join('/etc/sudo_pair.prompt.pair'))
162
163 def test_copy_sudoers(self, sph, tmpdir):
164 sph.copy_sudoers()
165 assert filecmp.cmp('./files/sudoers', tmpdir.join('/etc/sudoers'))
diff --git a/tox.ini b/tox.ini
0new file mode 100644166new file mode 100644
index 0000000..3750c4a
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,17 @@
1[tox]
2skipsdist=True
3envlist = unit, functional
4skip_missing_interpreters = True
5
6[testenv]
7basepython = python3.6
8
9[testenv:unit]
10commands = pytest -v --ignore {toxinidir}/tests/amulet --ignore {toxinidir}/tests/functional
11deps = -r{toxinidir}/tests/unit/requirements.txt
12setenv = PYTHONPATH={toxinidir}/lib
13
14[testenv:functional]
15passenv = HOME
16commands = pytest -v --ignore {toxinidir}/tests/unit --ignore {toxinidir}/tests/amulet
17deps = -r{toxinidir}/tests/functional/requirements.txt

Subscribers

People subscribed via source and target branches