Merge ~peppepetra/charm-sudo-pair/+git/sudo-pair-charm:master into ~sudo-pair-charmers/charm-sudo-pair:master
- Git
- lp:~peppepetra/charm-sudo-pair/+git/sudo-pair-charm
- master
- Merge into master
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) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Chris Sanders (community) | Approve | ||
Alvaro Uria (community) | Needs Fixing | ||
Review via email:
|
Commit message
Description of the change
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
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_
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.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Chris Sanders (chris.sanders) wrote : | # |
Comments in line
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
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.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Chris Sanders (chris.sanders) wrote : | # |
Comments in-line, at least one of them would be easiest if we talk about in person.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Chris Sanders (chris.sanders) wrote : | # |
Thanks for the walk through this morning. A few comments in line.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Peter Sabaini (peter-sabaini) wrote : | # |
Minor comment added.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Giuseppe Petralia (peppepetra) wrote : | # |
Fixed. Thanks.
> Minor comment added.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
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.
Preview Diff
1 | diff --git a/README.md b/README.md |
2 | index 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 |
59 | diff --git a/config.yaml b/config.yaml |
60 | new file mode 100644 |
61 | index 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." |
86 | diff --git a/files/sudo.prompt.pair b/files/sudo.prompt.pair |
87 | new file mode 100644 |
88 | index 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 | + <[1m[34m%U[0m@[1m[31m%h[0m:[1m[34m%d[0m [31m$[0m > %C |
95 | + |
96 | +Once approved, this terminal will mirror all output from the active sudo session until its completion. |
97 | + |
98 | +[1mClosing 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.[0m |
99 | + |
100 | +Approve? y/n [n]: |
101 | diff --git a/files/sudo.prompt.user b/files/sudo.prompt.user |
102 | new file mode 100644 |
103 | index 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 '[1m[31m%h[0m' '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. [1mNote that doing so will immediately page an oncall security engineer, so this capability should only be used in the event of an emergency.[0m |
114 | \ No newline at end of file |
115 | diff --git a/files/sudo_pair.so b/files/sudo_pair.so |
116 | new file mode 100755 |
117 | index 0000000..44bcb8d |
118 | Binary files /dev/null and b/files/sudo_pair.so differ |
119 | diff --git a/files/sudoers b/files/sudoers |
120 | new file mode 100644 |
121 | index 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 |
156 | diff --git a/layer.yaml b/layer.yaml |
157 | new file mode 100644 |
158 | index 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 |
170 | diff --git a/lib/libsudopair.py b/lib/libsudopair.py |
171 | new file mode 100644 |
172 | index 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 |
282 | diff --git a/metadata.yaml b/metadata.yaml |
283 | new file mode 100644 |
284 | index 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 |
305 | diff --git a/reactive/sudo_pair.py b/reactive/sudo_pair.py |
306 | new file mode 100644 |
307 | index 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') |
352 | diff --git a/templates/91-bypass-sudopair-cmds.tmpl b/templates/91-bypass-sudopair-cmds.tmpl |
353 | new file mode 100644 |
354 | index 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 | + |
364 | diff --git a/templates/sudo.conf.tmpl b/templates/sudo.conf.tmpl |
365 | new file mode 100644 |
366 | index 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 %} |
371 | diff --git a/templates/sudo_approve.tmpl b/templates/sudo_approve.tmpl |
372 | new file mode 100755 |
373 | index 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 |
500 | diff --git a/tests/00-unit b/tests/00-unit |
501 | new file mode 100755 |
502 | index 0000000..d5f2e15 |
503 | --- /dev/null |
504 | +++ b/tests/00-unit |
505 | @@ -0,0 +1,3 @@ |
506 | +#!/bin/bash |
507 | + |
508 | +tox -v -e unit |
509 | diff --git a/tests/01-functional b/tests/01-functional |
510 | new file mode 100755 |
511 | index 0000000..946e972 |
512 | --- /dev/null |
513 | +++ b/tests/01-functional |
514 | @@ -0,0 +1,3 @@ |
515 | +#!/bin/bash |
516 | + |
517 | +tox -v -e functional |
518 | diff --git a/tests/functional/requirements.txt b/tests/functional/requirements.txt |
519 | new file mode 100644 |
520 | index 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 |
530 | diff --git a/tests/functional/test_deploy.py b/tests/functional/test_deploy.py |
531 | new file mode 100644 |
532 | index 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 |
700 | diff --git a/tests/tests.yaml b/tests/tests.yaml |
701 | new file mode 100644 |
702 | index 0000000..193e5ac |
703 | --- /dev/null |
704 | +++ b/tests/tests.yaml |
705 | @@ -0,0 +1 @@ |
706 | +tests: "[0-9]*" |
707 | diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py |
708 | new file mode 100644 |
709 | index 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 |
759 | diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt |
760 | new file mode 100644 |
761 | index 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 |
769 | diff --git a/tests/unit/test_libsudopair.py b/tests/unit/test_libsudopair.py |
770 | new file mode 100644 |
771 | index 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')) |
940 | diff --git a/tox.ini b/tox.ini |
941 | new file mode 100644 |
942 | index 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 |
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.