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
1diff --git a/README.md b/README.md
2index 9cc2dad..0804cca 100644
3--- a/README.md
4+++ b/README.md
5@@ -1,2 +1,51 @@
6-# Sudo_pair Charm
7-TODO
8+# Overview
9+sudo_pair is a sudo plugin that ensure that no user
10+can act entirely on their own authority within these systems.
11+Once configured if a user tries to get root privileges, he will need
12+an authorization from a pair that will monitor over his session.
13+
14+# Build
15+```
16+cd sudo-pair
17+charm build
18+```
19+
20+# Usage
21+Add to an existing application using juju-info relation.
22+
23+Example:
24+```
25+juju deploy ubuntu
26+juju deploy ./sudo-pair
27+juju add-unit ubuntu
28+juju add-relation ubuntu sudo-pair
29+```
30+
31+# Configuration
32+The user can configure the following parameters:
33+* ```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.
34+* ```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.
35+* ```bypass_cmds``` (default: ```none```): This is a comma-separated list of full path commands that have to be bypassed from sudo pairing
36+* ```bypass_group``` (default: ```none```): This is the unix group for which the commands specified through bypass_cmds will be bypassed from sudo pairing approval
37+* ```auto_approve``` (default: ```true```): If true, auto approval is permitted.
38+
39+# Testing
40+Unit tests has been developed to test templates rendering for ```sudo.conf```, ```sudoers.d/91-bypass-sudopair-cmds```, ```sudo_approve```
41+
42+To run unit tests:
43+```bash
44+tox -e unit
45+```
46+Deploy tests has been developed using python-libjuju
47+
48+To run tests using python-libjuju:
49+```bash
50+tox -e functional
51+```
52+
53+
54+# Contact Information
55+Giuseppe Petralia <giuseppe.petralia@canonical.com>
56+
57+[service]: https://github.com/square/sudo_pair
58+[icon guidelines]: https://jujucharms.com/docs/stable/authors-charm-icon
59diff --git a/config.yaml b/config.yaml
60new file mode 100644
61index 0000000..795269b
62--- /dev/null
63+++ b/config.yaml
64@@ -0,0 +1,21 @@
65+options:
66+ groups_enforced:
67+ type: string
68+ default: "root"
69+ description: "This is a comma-separated list of group names that sudo_pair will gate access to."
70+ groups_exempted:
71+ type: string
72+ default: ""
73+ description: "This is a comma-separated list of group names whose users will be exempted from the requirements of sudo_pair"
74+ bypass_cmds:
75+ type: string
76+ default: ""
77+ description: "This is a comma-separated list of full path commands that have to be bypassed from sudo pairing"
78+ bypass_group:
79+ type: string
80+ default: ""
81+ description: "This is the unix group for which the commands will be bypassed from sudo pairing approval"
82+ auto_approve:
83+ type: boolean
84+ default: true
85+ description: "If true, auto approval is permitted."
86diff --git a/files/sudo.prompt.pair b/files/sudo.prompt.pair
87new file mode 100644
88index 0000000..69c5c6a
89--- /dev/null
90+++ b/files/sudo.prompt.pair
91@@ -0,0 +1,9 @@
92+]0;sudo: <%U@%h:%d $ > %C[8;%H;%WtYou have been asked to approve and monitor the following sudo session:
93+
94+ <%U@%h:%d $ > %C
95+
96+Once approved, this terminal will mirror all output from the active sudo session until its completion.
97+
98+Closing 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.
99+
100+Approve? y/n [n]:
101diff --git a/files/sudo.prompt.user b/files/sudo.prompt.user
102new file mode 100644
103index 0000000..4bc034f
104--- /dev/null
105+++ b/files/sudo.prompt.user
106@@ -0,0 +1,7 @@
107+Due 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.
108+
109+To continue, another engineer must run:
110+
111+ ssh -t '%h' 'sh -l -c "sudo %b %u %p"'
112+
113+If 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.
114\ No newline at end of file
115diff --git a/files/sudo_pair.so b/files/sudo_pair.so
116new file mode 100755
117index 0000000..44bcb8d
118Binary files /dev/null and b/files/sudo_pair.so differ
119diff --git a/files/sudoers b/files/sudoers
120new file mode 100644
121index 0000000..e3a34ef
122--- /dev/null
123+++ b/files/sudoers
124@@ -0,0 +1,31 @@
125+#
126+# This file MUST be edited with the 'visudo' command as root.
127+#
128+# Please consider adding local content in /etc/sudoers.d/ instead of
129+# directly modifying this file.
130+#
131+# See the man page for details on how to write a sudoers file.
132+#
133+Defaults env_reset
134+Defaults mail_badpass
135+Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin"
136+Defaults log_output
137+
138+# Host alias specification
139+
140+# User alias specification
141+
142+# Cmnd alias specification
143+
144+# User privilege specification
145+root ALL=(ALL:ALL) ALL
146+
147+# Members of the admin group may gain root privileges
148+%admin ALL=(ALL) ALL
149+
150+# Allow members of group sudo to execute any command
151+%sudo ALL=(ALL:ALL) ALL
152+
153+# See sudoers(5) for more information on "#include" directives:
154+
155+#includedir /etc/sudoers.d
156diff --git a/layer.yaml b/layer.yaml
157new file mode 100644
158index 0000000..7473bae
159--- /dev/null
160+++ b/layer.yaml
161@@ -0,0 +1,8 @@
162+includes:
163+ - layer:basic
164+ - layer:apt
165+options:
166+ apt:
167+ packages:
168+ - socat
169+repo: https://git.launchpad.net/sudo-pair-charm
170diff --git a/lib/libsudopair.py b/lib/libsudopair.py
171new file mode 100644
172index 0000000..aa24250
173--- /dev/null
174+++ b/lib/libsudopair.py
175@@ -0,0 +1,106 @@
176+import grp
177+import os
178+from charmhelpers.core import host, hookenv, templating
179+
180+
181+def check_valid_group(group_name):
182+ try:
183+ grp.getgrnam(group_name)
184+ return True
185+ except KeyError:
186+ return False
187+
188+
189+def group_id(group_name):
190+ return grp.getgrnam(group_name).gr_gid
191+
192+
193+def group_names_to_group_ids(group_names):
194+ """
195+ From Group Names comma-separated list to Group Ids
196+ :param group_names: i.e. "root,user1,user2"
197+ :return gids: i.e. "0,1001,1002"
198+ """
199+ group_names = list(filter(check_valid_group, group_names.split(',')))
200+ return ','.join(map(str, (map(group_id, group_names))))
201+
202+
203+def copy_file(source, destination, owner, group, perms):
204+ if destination is not None:
205+ target_dir = os.path.dirname(destination)
206+ if not os.path.exists(target_dir):
207+ # This is a terrible default directory permission, as the file
208+ # or its siblings will often contain secrets.
209+ host.mkdir(os.path.dirname(destination), owner, group, perms=0o755)
210+ with open(source, 'rb') as source_f:
211+ host.write_file(destination, source_f.read(), perms=perms, owner=owner, group=group)
212+
213+
214+class SudoPairHelper(object):
215+ def __init__(self):
216+ self.charm_config = hookenv.config()
217+ self.binary_path = '/usr/bin/sudo_approve'
218+ self.sudo_conf_path = '/etc/sudo.conf'
219+ self.sudoers_path = '/etc/sudoers'
220+ self.sudo_lib_path = '/usr/lib/sudo/sudo_pair.so'
221+ self.sudoers_bypass_path = "/etc/sudoers.d/91-bypass-sudopair-cmds"
222+ self.user_prompt_path = '/etc/sudo_pair.prompt.user'
223+ self.pair_prompt_path = '/etc/sudo_pair.prompt.pair'
224+ self.socket_dir = '/var/run/sudo_pair'
225+ self.owner = 'root'
226+ self.group = 'root'
227+ self.socket_dir_perms = 0o644
228+ self.sudo_pair_so_perms = 0o644
229+ self.prompt_perms = 0o644
230+ self.sudoers_perms = 0o440
231+ self.sudo_conf_perms = 0o440
232+ self.sudo_approve_perms = 0o755
233+
234+ def get_config(self):
235+ config = {
236+ 'binary_path' : self.binary_path,
237+ 'user_prompt_path' : self.user_prompt_path,
238+ 'pair_prompt_path' : self.pair_prompt_path,
239+ 'socket_dir': self.socket_dir,
240+ 'gids_enforced': group_names_to_group_ids(self.charm_config['groups_enforced']),
241+ 'gids_exempted' : group_names_to_group_ids(self.charm_config['groups_exempted']),
242+ }
243+
244+ config.update(self.charm_config)
245+ return config
246+
247+ def set_charm_config(self, charm_config):
248+ self.charm_config = charm_config
249+
250+ def render_sudo_conf(self):
251+ return templating.render('sudo.conf.tmpl', self.sudo_conf_path, self.get_config(),
252+ perms=self.sudo_conf_perms, owner=self.owner, group=self.group)
253+
254+ def create_socket_dir(self):
255+ host.mkdir(self.socket_dir, perms=self.socket_dir_perms, owner=self.owner, group=self.group)
256+
257+ def install_sudo_pair_so(self):
258+ sudo_pair_lib = os.path.join(hookenv.charm_dir(), 'files', 'sudo_pair.so')
259+ copy_file(sudo_pair_lib, self.sudo_lib_path, self.owner, self.group, self.sudo_pair_so_perms)
260+
261+ def copy_user_prompt(self):
262+ prompt_file = os.path.join(hookenv.charm_dir(), 'files', 'sudo.prompt.user')
263+ copy_file(prompt_file, self.user_prompt_path, self.owner, self.group, self.prompt_perms)
264+
265+ def copy_pair_prompt(self):
266+ prompt_file = os.path.join(hookenv.charm_dir(), 'files', 'sudo.prompt.pair')
267+ copy_file(prompt_file, self.pair_prompt_path, self.owner, self.group, self.prompt_perms)
268+
269+ def copy_sudoers(self):
270+ sudoers_file = os.path.join(hookenv.charm_dir(), 'files', 'sudoers')
271+ copy_file(sudoers_file, self.sudoers_path, self.owner, self.group, self.sudoers_perms)
272+
273+ def render_sudo_approve(self):
274+ return templating.render('sudo_approve.tmpl', self.binary_path, self.get_config(),
275+ perms=self.sudo_approve_perms, owner=self.owner, group=self.group)
276+
277+ def render_bypass_cmds(self):
278+ if self.get_config()['bypass_cmds'] != "" and self.get_config()['bypass_group'] != "":
279+ return templating.render('91-bypass-sudopair-cmds.tmpl', self.sudoers_bypass_path,
280+ self.get_config(), perms=0o440, owner=self.owner, group=self.group)
281+ return None
282diff --git a/metadata.yaml b/metadata.yaml
283new file mode 100644
284index 0000000..5c24d8d
285--- /dev/null
286+++ b/metadata.yaml
287@@ -0,0 +1,17 @@
288+name: sudo-pair
289+display-name: sudo-pair
290+summary: sudo_pair is a sudo plugin to manage root privileges
291+maintainer: Giuseppe Petralia <giuseppe.petralia@canonical.com>
292+description: |
293+ sudo_pair is a sudo plugin that ensure that if a user tries to get root privileges,
294+ he will need an authorization from a pair
295+tags:
296+ - ops
297+series:
298+ - xenial
299+ - bionic
300+subordinate: true
301+requires:
302+ juju-info:
303+ interface: juju-info
304+ scope: container
305diff --git a/reactive/sudo_pair.py b/reactive/sudo_pair.py
306new file mode 100644
307index 0000000..a5de1e5
308--- /dev/null
309+++ b/reactive/sudo_pair.py
310@@ -0,0 +1,41 @@
311+from charms.reactive import when, when_not, set_state, remove_state, hook
312+from charmhelpers.core import hookenv
313+
314+
315+from libsudopair import SudoPairHelper
316+
317+sph = SudoPairHelper()
318+
319+
320+@when('apt.installed.socat')
321+@when_not('sudo-pair.configured')
322+def install_sudo_pair():
323+ # Install sudo_pair.so, create socket dir, copy sudo_approve to /usr/bin, copy prompts to /etc
324+ sph.install_sudo_pair_so()
325+
326+ sph.create_socket_dir()
327+
328+ sph.copy_user_prompt()
329+
330+ sph.copy_pair_prompt()
331+
332+ sph.render_sudo_approve()
333+
334+ # Add "Defaults log_output to /etc/sudoers
335+ sph.copy_sudoers()
336+
337+ # If there are cmds to bypass sudo pairing create file unders sudoers.d
338+ sph.render_bypass_cmds()
339+
340+ # Add Plugin sudo_pair sudo_pair.so to sudo.conf
341+ sph.render_sudo_conf()
342+
343+ set_state('sudo-pair.installed')
344+ set_state('sudo-pair.configured')
345+ hookenv.status_set('active', 'sudo pairing for users groups: [{}]'.format(sph.get_config()['gids_enforced']))
346+
347+
348+@hook('config-changed')
349+def reconfigure_sudo_pair_charm():
350+ sph.set_charm_config(hookenv.config())
351+ remove_state('sudo-pair.configured')
352diff --git a/templates/91-bypass-sudopair-cmds.tmpl b/templates/91-bypass-sudopair-cmds.tmpl
353new file mode 100644
354index 0000000..cb54e77
355--- /dev/null
356+++ b/templates/91-bypass-sudopair-cmds.tmpl
357@@ -0,0 +1,6 @@
358+# Created by sudo-pair
359+
360+# Bypass Sudo Pair commands
361+%{{ bypass_group }} ALL = (ALL) NOLOG_OUTPUT: {{ bypass_cmds }}
362+
363+
364diff --git a/templates/sudo.conf.tmpl b/templates/sudo.conf.tmpl
365new file mode 100644
366index 0000000..db9a689
367--- /dev/null
368+++ b/templates/sudo.conf.tmpl
369@@ -0,0 +1 @@
370+Plugin 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 %}
371diff --git a/templates/sudo_approve.tmpl b/templates/sudo_approve.tmpl
372new file mode 100755
373index 0000000..7164b5a
374--- /dev/null
375+++ b/templates/sudo_approve.tmpl
376@@ -0,0 +1,123 @@
377+#!/usr/bin/env bash
378+#
379+# Copyright 2018 Square Inc.
380+#
381+# Licensed under the Apache License, Version 2.0 (the "License");
382+# you may not use this file except in compliance with the License.
383+# You may obtain a copy of the License at
384+#
385+# http://www.apache.org/licenses/LICENSE-2.0
386+#
387+# Unless required by applicable law or agreed to in writing, software
388+# distributed under the License is distributed on an "AS IS" BASIS,
389+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
390+# implied. See the License for the specific language governing
391+# permissions and limitations under the License.
392+
393+#set -o errexit # quit on first error
394+set -o pipefail # quit on failures in pipes
395+set -o nounset # quit on unset variables
396+
397+[[ ${TRACE:-} ]] && set -o xtrace # output subcommands if TRACE is set
398+
399+declare -r SUDO_SOCKET_PATH="{{ socket_dir }}"
400+
401+pair() {
402+ declare -r socket="${1}"
403+
404+ # restore TTY settings on exit
405+ # shellcheck disable=SC2064
406+ trap "stty $(stty -g)" EXIT
407+
408+ # disable line-buffering and local echo, so the pairer doesn't
409+ # get confused that their typing in the shell isn't doing
410+ # anything
411+ stty cbreak -echo
412+
413+ # send SIGINT on Ctrl-D
414+ stty intr "^D"
415+
416+ clear
417+
418+ # prompt the user to approve
419+ socat STDIO unix-connect:"${socket}"
420+}
421+
422+usage() {
423+ echo "Usage: $(basename -- "$0") uid pid"
424+ exit 1
425+}
426+
427+main() {
428+ declare -r socket_path="${1}"
429+ declare -ri uid="${2}"
430+ declare -ri pid="${3}"
431+
432+ # if we're running this under `sudo`, we want to know the original
433+ # user's `uid` from `SUDO_UID`; if not, it's jsut their normal `uid`
434+ declare -i ruid
435+ ruid="${SUDO_UID:-$(id -u)}"
436+ declare -r ruid
437+
438+ declare -r socket="${socket_path}/${uid}.${pid}.sock"
439+
440+ declare -i socket_uid socket_gid
441+ socket_uid="$(stat -c '%u' "${socket}")"
442+ socket_gid="$(stat -c '%g' "${socket}")"
443+ declare -r socket_uid socket_gid
444+
445+ declare socket_user socket_group socket_mode
446+ socket_user="$(getent passwd "${socket_uid}" | cut -d: -f1)"
447+ socket_group="$(getent group "${socket_gid}" | cut -d: -f1)"
448+ socket_mode="$(stat -c '%a' "${socket}")"
449+ declare -r socket_user socket_group socket_mode
450+
451+ # if the user approving the command is the same as the user who
452+ # invoked `sudo` in the first place, abort
453+ #
454+ # another option would be to allow the session, but log it in a way
455+ # that it immediately pages oncall security engineers; such an
456+ # approach is useful in production systems in that it allows for a
457+ # in-case-of-fire-break-glass workaround so engineers can respond to
458+ # a outage in the middle of the night
459+ #
460+ # this responsibility will be moved into the plugin itself when time
461+ # allots
462+ declare username
463+ username="$(getent passwd "${uid}" | cut -d: -f1)"
464+ declare -r username
465+
466+ declare log_line
467+ log_line="$(date "+[%b %d %H:%M:%S] WARNING: ${username} approved is own sudo session.")"
468+ declare -r log_line
469+
470+ if [[ "${uid}" -eq "${ruid}" ]]; then
471+ {% if not auto_approve %}
472+ echo "You can't approve your own session."
473+ exit 1
474+ {% else %}
475+ echo "You are approving your own session. The incident will be logged."
476+ echo ${log_line} >> /var/log/sudo_pair.log
477+ {% endif %}
478+ fi
479+
480+ # if we can write: pair
481+ # if user-owner can write: sudo to them and try again
482+ # if group-owner can write: sudo to them and try again
483+ # if none, die
484+ if [ -w "${socket}" ]; then
485+ pair "${socket}"
486+ elif [[ $(( 8#${socket_mode} & 8#200 )) -ne 0 ]]; then
487+ sudo -u "${socket_user}" "${0}" "${uid}" "${pid}"
488+ elif [[ $(( 8#${socket_mode} & 8#020 )) -ne 0 ]]; then
489+ sudo -g "${socket_group}" "${0}" "${uid}" "${pid}"
490+ else
491+ echo "The socket for this sudo session is neither user- nor group-writable."
492+ exit 2
493+ fi
494+}
495+
496+case "$#" in
497+ 2) main "${SUDO_SOCKET_PATH}" "$1" "$2" ;;
498+ *) usage ;;
499+esac
500diff --git a/tests/00-unit b/tests/00-unit
501new file mode 100755
502index 0000000..d5f2e15
503--- /dev/null
504+++ b/tests/00-unit
505@@ -0,0 +1,3 @@
506+#!/bin/bash
507+
508+tox -v -e unit
509diff --git a/tests/01-functional b/tests/01-functional
510new file mode 100755
511index 0000000..946e972
512--- /dev/null
513+++ b/tests/01-functional
514@@ -0,0 +1,3 @@
515+#!/bin/bash
516+
517+tox -v -e functional
518diff --git a/tests/functional/requirements.txt b/tests/functional/requirements.txt
519new file mode 100644
520index 0000000..b9815c2
521--- /dev/null
522+++ b/tests/functional/requirements.txt
523@@ -0,0 +1,6 @@
524+juju
525+requests
526+pytest
527+pytest-asyncio
528+mock
529+flake8
530diff --git a/tests/functional/test_deploy.py b/tests/functional/test_deploy.py
531new file mode 100644
532index 0000000..28ac196
533--- /dev/null
534+++ b/tests/functional/test_deploy.py
535@@ -0,0 +1,164 @@
536+#!/usr/bin/python3.6
537+
538+from juju.model import Model
539+import asyncio
540+import json
541+import pytest
542+
543+
544+STAT_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}))\""
545+
546+FILE_CONTENT = "python3 -c \"print(open('%s').read())\""
547+
548+pytestmark = pytest.mark.asyncio
549+
550+
551+@pytest.fixture
552+async def model():
553+ model = Model()
554+ await model.connect_current()
555+ yield model
556+ await model.disconnect()
557+
558+
559+@pytest.fixture
560+async def deploy_app(model):
561+ await model.deploy(
562+ 'ubuntu',
563+ application_name='ubuntu',
564+ series='bionic',
565+ channel='stable'
566+ )
567+ sudo_pair_app = await model.deploy(
568+ 'local:',
569+ application_name='sudo-pair',
570+ series='bionic',
571+ num_units=0,
572+ config={
573+ 'bypass_cmds': '/bin/ls',
574+ 'groups_enforced': 'ubuntu',
575+ 'bypass_group': 'warthogs',
576+ }
577+ )
578+ await model.add_relation(
579+ 'ubuntu',
580+ 'sudo-pair'
581+ )
582+
583+ await model.block_until(lambda: sudo_pair_app.status == 'active')
584+ return sudo_pair_app
585+
586+
587+@pytest.fixture
588+async def get_app(model):
589+ for app in model.applications:
590+ if app == 'sudo-pair':
591+ return model.applications[app]
592+
593+
594+@pytest.fixture
595+async def get_unit(model):
596+ for app in model.applications:
597+ if app == 'ubuntu':
598+ return model.applications[app].units[0]
599+
600+
601+@pytest.fixture
602+async def run_command(get_unit):
603+ async def make_run_command(cmd):
604+ action = await get_unit.run(cmd)
605+ return action.results
606+ return make_run_command
607+
608+
609+@pytest.fixture
610+async def file_stat(run_command):
611+ async def make_file_stat(path):
612+ cmd = STAT_FILE % path
613+ results = await run_command(cmd)
614+ return json.loads(results['Stdout'])
615+ return make_file_stat
616+
617+
618+@pytest.fixture
619+async def file_contents(run_command):
620+ async def make_file_contents(path):
621+ cmd = FILE_CONTENT % path
622+ results = await run_command(cmd)
623+ return results['Stdout']
624+ return make_file_contents
625+
626+
627+@pytest.fixture
628+async def reconfigure_app(get_app):
629+ async def make_reconfigure_app(cfg):
630+ await get_app.set_config(cfg)
631+ await get_app.get_config()
632+ await asyncio.sleep(10)
633+ return make_reconfigure_app
634+
635+
636+@pytest.fixture
637+async def create_group(run_command):
638+ async def make_create_group(group_name):
639+ cmd = "sudo groupadd %s" % group_name
640+ await run_command(cmd)
641+ return make_create_group
642+
643+
644+async def test_deploy(deploy_app):
645+ status = deploy_app.status
646+ assert status == 'active'
647+
648+
649+async def test_sudo_pair_lib(file_stat):
650+ sudo_pair_lib_stat = await file_stat("/usr/lib/sudo/sudo_pair.so")
651+ assert sudo_pair_lib_stat['size'] > 0
652+ assert sudo_pair_lib_stat['gid'] == 0
653+ assert sudo_pair_lib_stat['uid'] == 0
654+ assert sudo_pair_lib_stat['mode'] == '0o100644'
655+
656+
657+async def test_sudo_approve(file_stat, file_contents):
658+ sudo_approve_path = '/usr/bin/sudo_approve'
659+ sudo_approve_stat = await file_stat(sudo_approve_path)
660+ assert sudo_approve_stat['size'] > 0
661+ assert sudo_approve_stat['gid'] == 0
662+ assert sudo_approve_stat['uid'] == 0
663+ assert sudo_approve_stat['mode'] == '0o100755'
664+
665+
666+async def test_sudo_prompt(file_stat):
667+ for prompt_type in ['user', 'pair']:
668+ sudo_prompt_stat = await file_stat('/etc/sudo_pair.prompt.' + prompt_type)
669+ assert sudo_prompt_stat['size'] > 0
670+ assert sudo_prompt_stat['gid'] == 0
671+ assert sudo_prompt_stat['uid'] == 0
672+ assert sudo_prompt_stat['mode'] == '0o100644'
673+
674+
675+async def test_socket_dir(file_stat):
676+ dir_stat = await file_stat('/var/run/sudo_pair')
677+ assert dir_stat['gid'] == 0
678+ assert dir_stat['uid'] == 0
679+ assert dir_stat['mode'] == '0o40644'
680+
681+
682+async def test_sudoers(file_contents):
683+ sudoers_content = await file_contents("/etc/sudoers")
684+ assert 'Defaults log_output' in sudoers_content
685+
686+
687+async def test_sudoers_bypass_conf(file_contents):
688+ sudoers_bypass_content = await file_contents("/etc/sudoers.d/91-bypass-sudopair-cmds")
689+ content = '%warthogs ALL = (ALL) NOLOG_OUTPUT: /bin/ls'
690+ assert content in sudoers_bypass_content
691+
692+
693+async def test_reconfigure(reconfigure_app, file_contents, file_stat):
694+ auto_approve = "false"
695+ sudo_approve_path = '/usr/bin/sudo_approve'
696+ await reconfigure_app({'auto_approve': auto_approve})
697+ sudo_approve_content = await file_contents(sudo_approve_path)
698+ new_content = 'echo "You can\'t approve your own session."'
699+ assert new_content in sudo_approve_content
700diff --git a/tests/tests.yaml b/tests/tests.yaml
701new file mode 100644
702index 0000000..193e5ac
703--- /dev/null
704+++ b/tests/tests.yaml
705@@ -0,0 +1 @@
706+tests: "[0-9]*"
707diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
708new file mode 100644
709index 0000000..11d53a9
710--- /dev/null
711+++ b/tests/unit/conftest.py
712@@ -0,0 +1,46 @@
713+#!/usr/bin/python3
714+
715+import pytest
716+import pwd
717+import grp
718+import os
719+
720+
721+@pytest.fixture
722+def mock_hookenv_config(monkeypatch):
723+ import yaml
724+
725+ def mock_config():
726+ cfg = {}
727+ yml = yaml.load(open('./config.yaml'))
728+
729+ # Load all defaults
730+ for key, value in yml['options'].items():
731+ cfg[key] = value['default']
732+
733+ return cfg
734+
735+ monkeypatch.setattr('charmhelpers.core.hookenv.config', mock_config)
736+
737+
738+@pytest.fixture
739+def mock_charm_dir(monkeypatch):
740+ monkeypatch.setattr('charmhelpers.core.hookenv.charm_dir', lambda: '.')
741+
742+
743+@pytest.fixture
744+def sph(mock_hookenv_config, mock_charm_dir, tmpdir):
745+ from libsudopair import SudoPairHelper
746+ sph = SudoPairHelper()
747+ sph.owner = pwd.getpwuid(os.getuid()).pw_name
748+ sph.group = grp.getgrgid(os.getgid()).gr_name
749+ sph.sudo_conf_path = tmpdir.join(sph.sudo_conf_path)
750+ sph.socket_dir = tmpdir.join(sph.socket_dir)
751+ sph.sudo_lib_path = tmpdir.join(sph.sudo_lib_path)
752+ sph.user_prompt_path = tmpdir.join(sph.user_prompt_path)
753+ sph.pair_prompt_path = tmpdir.join(sph.pair_prompt_path)
754+ sph.sudoers_path = tmpdir.join(sph.sudoers_path)
755+ sph.binary_path = tmpdir.join(sph.binary_path)
756+ sph.sudoers_bypass_path = tmpdir.join(sph.sudoers_bypass_path)
757+ sph.socket_dir_perms = 0o775
758+ return sph
759diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt
760new file mode 100644
761index 0000000..081ed97
762--- /dev/null
763+++ b/tests/unit/requirements.txt
764@@ -0,0 +1,4 @@
765+charmhelpers
766+charms.reactive
767+pytest
768+mock
769diff --git a/tests/unit/test_libsudopair.py b/tests/unit/test_libsudopair.py
770new file mode 100644
771index 0000000..5abb33b
772--- /dev/null
773+++ b/tests/unit/test_libsudopair.py
774@@ -0,0 +1,165 @@
775+import os
776+import grp
777+import filecmp
778+
779+from libsudopair import (
780+ check_valid_group,
781+ group_id
782+)
783+
784+
785+def test_check_valid_group():
786+ assert not check_valid_group('fake_group')
787+ assert check_valid_group(grp.getgrgid(os.getgid()).gr_name)
788+
789+
790+def test_group_id():
791+ assert group_id(grp.getgrgid(os.getgid()).gr_name) == os.getgid()
792+
793+
794+class TestSudoPairHelper():
795+ def test_pytest(self):
796+ assert True
797+
798+ def test_sph(self, sph):
799+ ''' See if the ph fixture works to load charm configs '''
800+ assert isinstance(sph.charm_config, dict)
801+
802+ def test_get_config(self, sph):
803+ default_keywords = [
804+ 'binary_path',
805+ 'user_prompt_path',
806+ 'pair_prompt_path',
807+ 'socket_dir',
808+ 'gids_enforced',
809+ 'gids_exempted',
810+ 'extra_packages'
811+ ]
812+ config = sph.get_config()
813+ for option in default_keywords:
814+ assert option in config
815+
816+ def test_set_charm_config(self, sph):
817+ charm_config = {
818+ 'groups_enforced': 'root',
819+ 'groups_exempted': '',
820+ 'bypass_cmds': '',
821+ 'bypass_group': '',
822+ 'auto_approve': True
823+ }
824+
825+ sph.set_charm_config(charm_config)
826+
827+ for option in charm_config:
828+ assert option in sph.get_config()
829+ assert sph.get_config()[option] == charm_config[option]
830+
831+ def test_render_sudo_conf(self, sph, tmpdir):
832+ # Default config
833+ content = sph.render_sudo_conf()
834+ expected_content = 'Plugin sudo_pair sudo_pair.so binary_path={} ' \
835+ 'user_prompt_path={} ' \
836+ 'pair_prompt_path={} socket_dir={} gids_enforced={}'.format(tmpdir.join('/usr/bin/sudo_approve'),
837+ tmpdir.join('/etc/sudo_pair.prompt.user'),
838+ tmpdir.join('/etc/sudo_pair.prompt.pair'),
839+ tmpdir.join('/var/run/sudo_pair'),
840+ '0')
841+ assert expected_content in content
842+
843+
844+ # Gid exempted
845+ groups_exempted = grp.getgrgid(os.getgid()).gr_name
846+ charm_config = {
847+ 'groups_enforced': 'root',
848+ 'groups_exempted': groups_exempted,
849+ 'bypass_cmds': '',
850+ 'bypass_group': '',
851+ 'auto_approve': True
852+ }
853+
854+ sph.set_charm_config(charm_config)
855+ expected_content = \
856+ 'Plugin sudo_pair sudo_pair.so binary_path={} user_prompt_path={} ' \
857+ 'pair_prompt_path={} socket_dir={} gids_enforced={} gids_exempted={}'.format(
858+ tmpdir.join('/usr/bin/sudo_approve'),
859+ tmpdir.join('/etc/sudo_pair.prompt.user'),
860+ tmpdir.join('/etc/sudo_pair.prompt.pair'),
861+ tmpdir.join('/var/run/sudo_pair'), '0', os.getgid())
862+
863+ content = sph.render_sudo_conf()
864+ assert expected_content in content
865+
866+ # Groups enforced
867+ groups_enforced = 'root,' + grp.getgrgid(os.getgid()).gr_name
868+ charm_config = {
869+ 'groups_enforced': groups_enforced,
870+ 'groups_exempted': '',
871+ 'bypass_cmds': '',
872+ 'bypass_group': '',
873+ 'auto_approve': True
874+ }
875+ sph.set_charm_config(charm_config)
876+ expected_content = 'Plugin sudo_pair sudo_pair.so binary_path={} user_prompt_path={} ' \
877+ 'pair_prompt_path={} socket_dir={} gids_enforced={}'.format(
878+ tmpdir.join('/usr/bin/sudo_approve'),
879+ tmpdir.join('/etc/sudo_pair.prompt.user'),
880+ tmpdir.join('/etc/sudo_pair.prompt.pair'),
881+ tmpdir.join('/var/run/sudo_pair'), '0,' + str(os.getgid()))
882+ content = sph.render_sudo_conf()
883+ assert expected_content in content
884+
885+ def test_render_bypass_cmds(self, sph, tmpdir):
886+ # Root bypass /bin/ls
887+ expected_content = '%root ALL = (ALL) NOLOG_OUTPUT: /bin/ls'
888+ charm_config = {
889+ 'groups_enforced': 'root',
890+ 'groups_exempted': '',
891+ 'bypass_cmds': '/bin/ls',
892+ 'bypass_group': 'root',
893+ 'auto_approve': True
894+ }
895+ sph.set_charm_config(charm_config)
896+ content = sph.render_bypass_cmds()
897+ assert expected_content in content
898+
899+ def test_render_sudo_approve(self, sph, tmpdir):
900+ # Auto Approve true
901+ expected_content = 'echo ${log_line} >> /var/log/sudo_pair.log'
902+ socket_dir = tmpdir.join('/var/run/sudo_pair')
903+ expected_content_socket_dir = 'declare -r SUDO_SOCKET_PATH="{}"'.format(socket_dir)
904+ content = sph.render_sudo_approve()
905+ assert expected_content in content
906+ assert expected_content_socket_dir in content
907+
908+ # Auto Approve false
909+ expected_content = 'echo "You can\'t approve your own session."'
910+ charm_config = {
911+ 'groups_enforced': 'root',
912+ 'groups_exempted': '',
913+ 'bypass_cmds': '/bin/ls',
914+ 'bypass_group': 'root',
915+ 'auto_approve': False
916+ }
917+ sph.set_charm_config(charm_config)
918+ content = sph.render_sudo_approve()
919+ assert expected_content in content
920+
921+ def test_create_socket_dir(self, sph, tmpdir):
922+ sph.create_socket_dir()
923+ assert os.path.exists(tmpdir.join('/var/run/sudo_pair'))
924+
925+ def test_install_sudo_pair_so(self, sph, tmpdir):
926+ sph.install_sudo_pair_so()
927+ assert filecmp.cmp('./files/sudo_pair.so', tmpdir.join('/usr/lib/sudo/sudo_pair.so'))
928+
929+ def test_copy_user_prompt(self, sph, tmpdir):
930+ sph.copy_user_prompt()
931+ assert filecmp.cmp('./files/sudo.prompt.user', tmpdir.join('/etc/sudo_pair.prompt.user'))
932+
933+ def test_copy_pair_prompt(self, sph, tmpdir):
934+ sph.copy_pair_prompt()
935+ assert filecmp.cmp('./files/sudo.prompt.pair', tmpdir.join('/etc/sudo_pair.prompt.pair'))
936+
937+ def test_copy_sudoers(self, sph, tmpdir):
938+ sph.copy_sudoers()
939+ assert filecmp.cmp('./files/sudoers', tmpdir.join('/etc/sudoers'))
940diff --git a/tox.ini b/tox.ini
941new file mode 100644
942index 0000000..3750c4a
943--- /dev/null
944+++ b/tox.ini
945@@ -0,0 +1,17 @@
946+[tox]
947+skipsdist=True
948+envlist = unit, functional
949+skip_missing_interpreters = True
950+
951+[testenv]
952+basepython = python3.6
953+
954+[testenv:unit]
955+commands = pytest -v --ignore {toxinidir}/tests/amulet --ignore {toxinidir}/tests/functional
956+deps = -r{toxinidir}/tests/unit/requirements.txt
957+setenv = PYTHONPATH={toxinidir}/lib
958+
959+[testenv:functional]
960+passenv = HOME
961+commands = pytest -v --ignore {toxinidir}/tests/unit --ignore {toxinidir}/tests/amulet
962+deps = -r{toxinidir}/tests/functional/requirements.txt

Subscribers

People subscribed via source and target branches