Merge lp:~mpontillo/maas/cli-as-sudo-uid-gid--bug-1598937--mpontillo--2.0 into lp:maas/2.0

Proposed by Mike Pontillo
Status: Merged
Approved by: Mike Pontillo
Approved revision: no longer in the source branch.
Merged at revision: 5154
Proposed branch: lp:~mpontillo/maas/cli-as-sudo-uid-gid--bug-1598937--mpontillo--2.0
Merge into: lp:maas/2.0
Diff against target: 288 lines (+185/-7)
4 files modified
src/maascli/config.py (+19/-4)
src/maascli/tests/test_config.py (+50/-2)
src/maascli/tests/test_utils.py (+76/-1)
src/maascli/utils.py (+40/-0)
To merge this branch: bzr merge lp:~mpontillo/maas/cli-as-sudo-uid-gid--bug-1598937--mpontillo--2.0
Reviewer Review Type Date Requested Status
Mike Pontillo (community) Approve
Review via email: mp+299889@code.launchpad.net

Commit message

Backport fix for bug #1598937 from trunk.

To post a comment you must log in.
Revision history for this message
Mike Pontillo (mpontillo) wrote :

Self-approve backport.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maascli/config.py'
2--- src/maascli/config.py 2015-12-01 18:12:59 +0000
3+++ src/maascli/config.py 2016-07-13 00:19:55 +0000
4@@ -1,4 +1,4 @@
5-# Copyright 2012-2015 Canonical Ltd. This software is licensed under the
6+# Copyright 2012-2016 Canonical Ltd. This software is licensed under the
7 # GNU Affero General Public License version 3 (see the file LICENSE).
8
9 """Configuration abstractions for the MAAS CLI."""
10@@ -16,6 +16,8 @@
11 from os.path import expanduser
12 import sqlite3
13
14+from maascli import utils
15+
16
17 class ProfileConfig:
18 """Store profile configurations in an sqlite3 database."""
19@@ -61,6 +63,11 @@
20 " WHERE name = ?", (name,))
21
22 @classmethod
23+ def create_database(cls, dbpath):
24+ # Initialise the database file with restrictive permissions.
25+ os.close(os.open(dbpath, os.O_CREAT | os.O_APPEND, 0o600))
26+
27+ @classmethod
28 @contextmanager
29 def open(cls, dbpath=expanduser("~/.maascli.db")):
30 """Load a profiles database.
31@@ -71,9 +78,17 @@
32 **Note** that this returns a context manager which will close the
33 database on exit, saving if the exit is clean.
34 """
35- # Initialise filename with restrictive permissions...
36- os.close(os.open(dbpath, os.O_CREAT | os.O_APPEND, 0o600))
37- # before opening it with sqlite.
38+ # As the effective UID and GID of the user invoking `sudo` (if any)...
39+ try:
40+ with utils.sudo_gid(), utils.sudo_uid():
41+ cls.create_database(dbpath)
42+ except PermissionError:
43+ # Creating the database might fail if $HOME is set to the current
44+ # effective UID's $HOME, but we have permission to change the UID
45+ # to one without permission to access $HOME. So try again without
46+ # changing the GID/UID.
47+ cls.create_database(dbpath)
48+
49 database = sqlite3.connect(dbpath)
50 try:
51 yield cls(database)
52
53=== modified file 'src/maascli/tests/test_config.py'
54--- src/maascli/tests/test_config.py 2015-12-01 18:12:59 +0000
55+++ src/maascli/tests/test_config.py 2016-07-13 00:19:55 +0000
56@@ -1,4 +1,4 @@
57-# Copyright 2012-2015 Canonical Ltd. This software is licensed under the
58+# Copyright 2012-2016 Canonical Ltd. This software is licensed under the
59 # GNU Affero General Public License version 3 (see the file LICENSE).
60
61 """Tests for `maascli.config`."""
62@@ -6,10 +6,19 @@
63 __all__ = []
64
65 import contextlib
66+from contextlib import contextmanager
67 import os.path
68 import sqlite3
69+from unittest.mock import call
70
71-from maascli import api
72+from maascli import (
73+ api,
74+ utils,
75+)
76+from maastesting.matchers import (
77+ MockCalledOnceWith,
78+ MockCallsMatch,
79+)
80 from maastesting.testcase import MAASTestCase
81 from twisted.python.filepath import FilePath
82
83@@ -98,3 +107,42 @@
84 with api.ProfileConfig.open(config_file):
85 perms = FilePath(config_file).getPermissions()
86 self.assertEqual("rw-r--r--", perms.shorthand())
87+
88+ def test_open_permissions_as_user_invoking_sudo(self):
89+ # ProfileConfig.open() touches the database as user invoking `sudo`.
90+
91+ @contextmanager
92+ def empty_context():
93+ yield # Do absolutely nothing.
94+
95+ self.patch_autospec(utils, "sudo_uid").side_effect = empty_context
96+ self.patch_autospec(utils, "sudo_gid").side_effect = empty_context
97+
98+ config_file = os.path.join(self.make_dir(), "config")
99+ with api.ProfileConfig.open(config_file):
100+ # The sudo_uid and sudo_gid contexts have been used.
101+ self.assertThat(utils.sudo_uid, MockCalledOnceWith())
102+ self.assertThat(utils.sudo_gid, MockCalledOnceWith())
103+
104+ def test_open_permissions_as_user_invoking_sudo_retries_if_failed(self):
105+ # ProfileConfig.open() touches the database as user invoking `sudo`,
106+ # but falls back to the current UID if the operation fails.
107+
108+ @contextmanager
109+ def empty_context():
110+ yield # Do absolutely nothing.
111+
112+ self.patch_autospec(utils, "sudo_uid").side_effect = empty_context
113+ self.patch_autospec(utils, "sudo_gid").side_effect = empty_context
114+ self.patch_autospec(api.ProfileConfig, "create_database")
115+ api.ProfileConfig.create_database.side_effect = (
116+ PermissionError,
117+ None
118+ )
119+ config_file = os.path.join(self.make_dir(), "config")
120+ with api.ProfileConfig.open(config_file):
121+ self.assertThat(
122+ api.ProfileConfig.create_database, MockCallsMatch(
123+ call(config_file),
124+ call(config_file)
125+ ))
126
127=== modified file 'src/maascli/tests/test_utils.py'
128--- src/maascli/tests/test_utils.py 2016-05-12 19:07:37 +0000
129+++ src/maascli/tests/test_utils.py 2016-07-13 00:19:55 +0000
130@@ -8,14 +8,21 @@
131 import collections
132 import http.client
133 import io
134+import os
135 import random
136 from unittest.mock import sentinel
137
138+from fixtures import EnvironmentVariable
139 import httplib2
140 from maascli import utils
141 from maastesting.factory import factory
142-from maastesting.matchers import MockCalledOnceWith
143+from maastesting.matchers import (
144+ MockCalledOnceWith,
145+ MockCalledWith,
146+ MockNotCalled,
147+)
148 from maastesting.testcase import MAASTestCase
149+from testtools import ExpectedException
150 from testtools.matchers import (
151 AfterPreprocessing,
152 Equals,
153@@ -298,3 +305,71 @@
154 b"Success.\n"
155 b"Machine-readable output follows:\n" +
156 response['content'] + b"\n", buf.getvalue())
157+
158+
159+class TestSudoUID(MAASTestCase):
160+ """Tests for `utils.sudo_uid`."""
161+
162+ def setUp(self):
163+ super(TestSudoUID, self).setUp()
164+ # Always ensure that SUDO_UID is not set in the environment.
165+ self.useFixture(EnvironmentVariable("SUDO_UID"))
166+ # We probably can't set EUID to anything we want in tests so we must
167+ # capture calls that attempt to do so.
168+ self.patch_autospec(utils, "seteuid")
169+
170+ def test_does_nothing_when_environ_not_set(self):
171+ with utils.sudo_uid():
172+ self.assertThat(utils.seteuid, MockNotCalled())
173+ self.assertThat(utils.seteuid, MockNotCalled())
174+
175+ def test_sets_and_resets_euid(self):
176+ original_euid = os.geteuid()
177+ example_euid = original_euid + random.randrange(500, 1000)
178+ self.useFixture(EnvironmentVariable("SUDO_UID", str(example_euid)))
179+ with utils.sudo_uid():
180+ self.assertThat(utils.seteuid, MockCalledOnceWith(example_euid))
181+ self.assertThat(utils.seteuid, MockCalledWith(original_euid))
182+
183+ def test_sets_and_resets_euid_on_crash(self):
184+ original_euid = os.geteuid()
185+ example_euid = original_euid + random.randrange(500, 1000)
186+ self.useFixture(EnvironmentVariable("SUDO_UID", str(example_euid)))
187+ with ExpectedException(ZeroDivisionError):
188+ with utils.sudo_uid():
189+ 0 / 0 # A very realistic example.
190+ self.assertThat(utils.seteuid, MockCalledWith(original_euid))
191+
192+
193+class TestSudoGID(MAASTestCase):
194+ """Tests for `utils.sudo_gid`."""
195+
196+ def setUp(self):
197+ super(TestSudoGID, self).setUp()
198+ # Always ensure that SUDO_GID is not set in the environment.
199+ self.useFixture(EnvironmentVariable("SUDO_GID"))
200+ # We probably can't set EGID to anything we want in tests so we must
201+ # capture calls that attempt to do so.
202+ self.patch_autospec(utils, "setegid")
203+
204+ def test_does_nothing_when_environ_not_set(self):
205+ with utils.sudo_gid():
206+ self.assertThat(utils.setegid, MockNotCalled())
207+ self.assertThat(utils.setegid, MockNotCalled())
208+
209+ def test_sets_and_resets_egid(self):
210+ original_egid = os.getegid()
211+ example_egid = original_egid + random.randrange(500, 1000)
212+ self.useFixture(EnvironmentVariable("SUDO_GID", str(example_egid)))
213+ with utils.sudo_gid():
214+ self.assertThat(utils.setegid, MockCalledOnceWith(example_egid))
215+ self.assertThat(utils.setegid, MockCalledWith(original_egid))
216+
217+ def test_sets_and_resets_egid_on_crash(self):
218+ original_egid = os.getegid()
219+ example_egid = original_egid + random.randrange(500, 1000)
220+ self.useFixture(EnvironmentVariable("SUDO_GID", str(example_egid)))
221+ with ExpectedException(ZeroDivisionError):
222+ with utils.sudo_gid():
223+ 0 / 0 # A very realistic example.
224+ self.assertThat(utils.setegid, MockCalledWith(original_egid))
225
226=== modified file 'src/maascli/utils.py'
227--- src/maascli/utils.py 2016-03-28 13:54:47 +0000
228+++ src/maascli/utils.py 2016-07-13 00:19:55 +0000
229@@ -13,8 +13,11 @@
230 "print_response_content",
231 "print_response_headers",
232 "safe_name",
233+ "sudo_gid",
234+ "sudo_uid",
235 ]
236
237+from contextlib import contextmanager
238 from email.message import Message
239 from functools import partial
240 from inspect import (
241@@ -22,6 +25,11 @@
242 getdoc,
243 )
244 import io
245+import os
246+from os import (
247+ setegid,
248+ seteuid,
249+)
250 import re
251 import sys
252 from urllib.parse import urlparse
253@@ -202,3 +210,35 @@
254 print(file=file)
255 print_response_headers(response, file=file)
256 print(file=file)
257+
258+
259+@contextmanager
260+def sudo_uid():
261+ """Context to revert effective UID to that of the user invoking `sudo`."""
262+ try:
263+ sudo_uid = os.environ["SUDO_UID"]
264+ except KeyError:
265+ yield # Not running under sudo.
266+ else:
267+ orig_euid = os.geteuid()
268+ seteuid(int(sudo_uid))
269+ try:
270+ yield
271+ finally:
272+ seteuid(orig_euid)
273+
274+
275+@contextmanager
276+def sudo_gid():
277+ """Context to revert effective GID to that of the user invoking `sudo`."""
278+ try:
279+ sudo_gid = os.environ["SUDO_GID"]
280+ except KeyError:
281+ yield # Not running under sudo.
282+ else:
283+ orig_egid = os.getegid()
284+ setegid(int(sudo_gid))
285+ try:
286+ yield
287+ finally:
288+ setegid(orig_egid)

Subscribers

People subscribed via source and target branches

to all changes: