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
=== modified file 'src/maascli/config.py'
--- src/maascli/config.py 2015-12-01 18:12:59 +0000
+++ src/maascli/config.py 2016-07-13 00:19:55 +0000
@@ -1,4 +1,4 @@
1# Copyright 2012-2015 Canonical Ltd. This software is licensed under the1# Copyright 2012-2016 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Configuration abstractions for the MAAS CLI."""4"""Configuration abstractions for the MAAS CLI."""
@@ -16,6 +16,8 @@
16from os.path import expanduser16from os.path import expanduser
17import sqlite317import sqlite3
1818
19from maascli import utils
20
1921
20class ProfileConfig:22class ProfileConfig:
21 """Store profile configurations in an sqlite3 database."""23 """Store profile configurations in an sqlite3 database."""
@@ -61,6 +63,11 @@
61 " WHERE name = ?", (name,))63 " WHERE name = ?", (name,))
6264
63 @classmethod65 @classmethod
66 def create_database(cls, dbpath):
67 # Initialise the database file with restrictive permissions.
68 os.close(os.open(dbpath, os.O_CREAT | os.O_APPEND, 0o600))
69
70 @classmethod
64 @contextmanager71 @contextmanager
65 def open(cls, dbpath=expanduser("~/.maascli.db")):72 def open(cls, dbpath=expanduser("~/.maascli.db")):
66 """Load a profiles database.73 """Load a profiles database.
@@ -71,9 +78,17 @@
71 **Note** that this returns a context manager which will close the78 **Note** that this returns a context manager which will close the
72 database on exit, saving if the exit is clean.79 database on exit, saving if the exit is clean.
73 """80 """
74 # Initialise filename with restrictive permissions...81 # As the effective UID and GID of the user invoking `sudo` (if any)...
75 os.close(os.open(dbpath, os.O_CREAT | os.O_APPEND, 0o600))82 try:
76 # before opening it with sqlite.83 with utils.sudo_gid(), utils.sudo_uid():
84 cls.create_database(dbpath)
85 except PermissionError:
86 # Creating the database might fail if $HOME is set to the current
87 # effective UID's $HOME, but we have permission to change the UID
88 # to one without permission to access $HOME. So try again without
89 # changing the GID/UID.
90 cls.create_database(dbpath)
91
77 database = sqlite3.connect(dbpath)92 database = sqlite3.connect(dbpath)
78 try:93 try:
79 yield cls(database)94 yield cls(database)
8095
=== modified file 'src/maascli/tests/test_config.py'
--- src/maascli/tests/test_config.py 2015-12-01 18:12:59 +0000
+++ src/maascli/tests/test_config.py 2016-07-13 00:19:55 +0000
@@ -1,4 +1,4 @@
1# Copyright 2012-2015 Canonical Ltd. This software is licensed under the1# Copyright 2012-2016 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Tests for `maascli.config`."""4"""Tests for `maascli.config`."""
@@ -6,10 +6,19 @@
6__all__ = []6__all__ = []
77
8import contextlib8import contextlib
9from contextlib import contextmanager
9import os.path10import os.path
10import sqlite311import sqlite3
12from unittest.mock import call
1113
12from maascli import api14from maascli import (
15 api,
16 utils,
17)
18from maastesting.matchers import (
19 MockCalledOnceWith,
20 MockCallsMatch,
21)
13from maastesting.testcase import MAASTestCase22from maastesting.testcase import MAASTestCase
14from twisted.python.filepath import FilePath23from twisted.python.filepath import FilePath
1524
@@ -98,3 +107,42 @@
98 with api.ProfileConfig.open(config_file):107 with api.ProfileConfig.open(config_file):
99 perms = FilePath(config_file).getPermissions()108 perms = FilePath(config_file).getPermissions()
100 self.assertEqual("rw-r--r--", perms.shorthand())109 self.assertEqual("rw-r--r--", perms.shorthand())
110
111 def test_open_permissions_as_user_invoking_sudo(self):
112 # ProfileConfig.open() touches the database as user invoking `sudo`.
113
114 @contextmanager
115 def empty_context():
116 yield # Do absolutely nothing.
117
118 self.patch_autospec(utils, "sudo_uid").side_effect = empty_context
119 self.patch_autospec(utils, "sudo_gid").side_effect = empty_context
120
121 config_file = os.path.join(self.make_dir(), "config")
122 with api.ProfileConfig.open(config_file):
123 # The sudo_uid and sudo_gid contexts have been used.
124 self.assertThat(utils.sudo_uid, MockCalledOnceWith())
125 self.assertThat(utils.sudo_gid, MockCalledOnceWith())
126
127 def test_open_permissions_as_user_invoking_sudo_retries_if_failed(self):
128 # ProfileConfig.open() touches the database as user invoking `sudo`,
129 # but falls back to the current UID if the operation fails.
130
131 @contextmanager
132 def empty_context():
133 yield # Do absolutely nothing.
134
135 self.patch_autospec(utils, "sudo_uid").side_effect = empty_context
136 self.patch_autospec(utils, "sudo_gid").side_effect = empty_context
137 self.patch_autospec(api.ProfileConfig, "create_database")
138 api.ProfileConfig.create_database.side_effect = (
139 PermissionError,
140 None
141 )
142 config_file = os.path.join(self.make_dir(), "config")
143 with api.ProfileConfig.open(config_file):
144 self.assertThat(
145 api.ProfileConfig.create_database, MockCallsMatch(
146 call(config_file),
147 call(config_file)
148 ))
101149
=== modified file 'src/maascli/tests/test_utils.py'
--- src/maascli/tests/test_utils.py 2016-05-12 19:07:37 +0000
+++ src/maascli/tests/test_utils.py 2016-07-13 00:19:55 +0000
@@ -8,14 +8,21 @@
8import collections8import collections
9import http.client9import http.client
10import io10import io
11import os
11import random12import random
12from unittest.mock import sentinel13from unittest.mock import sentinel
1314
15from fixtures import EnvironmentVariable
14import httplib216import httplib2
15from maascli import utils17from maascli import utils
16from maastesting.factory import factory18from maastesting.factory import factory
17from maastesting.matchers import MockCalledOnceWith19from maastesting.matchers import (
20 MockCalledOnceWith,
21 MockCalledWith,
22 MockNotCalled,
23)
18from maastesting.testcase import MAASTestCase24from maastesting.testcase import MAASTestCase
25from testtools import ExpectedException
19from testtools.matchers import (26from testtools.matchers import (
20 AfterPreprocessing,27 AfterPreprocessing,
21 Equals,28 Equals,
@@ -298,3 +305,71 @@
298 b"Success.\n"305 b"Success.\n"
299 b"Machine-readable output follows:\n" +306 b"Machine-readable output follows:\n" +
300 response['content'] + b"\n", buf.getvalue())307 response['content'] + b"\n", buf.getvalue())
308
309
310class TestSudoUID(MAASTestCase):
311 """Tests for `utils.sudo_uid`."""
312
313 def setUp(self):
314 super(TestSudoUID, self).setUp()
315 # Always ensure that SUDO_UID is not set in the environment.
316 self.useFixture(EnvironmentVariable("SUDO_UID"))
317 # We probably can't set EUID to anything we want in tests so we must
318 # capture calls that attempt to do so.
319 self.patch_autospec(utils, "seteuid")
320
321 def test_does_nothing_when_environ_not_set(self):
322 with utils.sudo_uid():
323 self.assertThat(utils.seteuid, MockNotCalled())
324 self.assertThat(utils.seteuid, MockNotCalled())
325
326 def test_sets_and_resets_euid(self):
327 original_euid = os.geteuid()
328 example_euid = original_euid + random.randrange(500, 1000)
329 self.useFixture(EnvironmentVariable("SUDO_UID", str(example_euid)))
330 with utils.sudo_uid():
331 self.assertThat(utils.seteuid, MockCalledOnceWith(example_euid))
332 self.assertThat(utils.seteuid, MockCalledWith(original_euid))
333
334 def test_sets_and_resets_euid_on_crash(self):
335 original_euid = os.geteuid()
336 example_euid = original_euid + random.randrange(500, 1000)
337 self.useFixture(EnvironmentVariable("SUDO_UID", str(example_euid)))
338 with ExpectedException(ZeroDivisionError):
339 with utils.sudo_uid():
340 0 / 0 # A very realistic example.
341 self.assertThat(utils.seteuid, MockCalledWith(original_euid))
342
343
344class TestSudoGID(MAASTestCase):
345 """Tests for `utils.sudo_gid`."""
346
347 def setUp(self):
348 super(TestSudoGID, self).setUp()
349 # Always ensure that SUDO_GID is not set in the environment.
350 self.useFixture(EnvironmentVariable("SUDO_GID"))
351 # We probably can't set EGID to anything we want in tests so we must
352 # capture calls that attempt to do so.
353 self.patch_autospec(utils, "setegid")
354
355 def test_does_nothing_when_environ_not_set(self):
356 with utils.sudo_gid():
357 self.assertThat(utils.setegid, MockNotCalled())
358 self.assertThat(utils.setegid, MockNotCalled())
359
360 def test_sets_and_resets_egid(self):
361 original_egid = os.getegid()
362 example_egid = original_egid + random.randrange(500, 1000)
363 self.useFixture(EnvironmentVariable("SUDO_GID", str(example_egid)))
364 with utils.sudo_gid():
365 self.assertThat(utils.setegid, MockCalledOnceWith(example_egid))
366 self.assertThat(utils.setegid, MockCalledWith(original_egid))
367
368 def test_sets_and_resets_egid_on_crash(self):
369 original_egid = os.getegid()
370 example_egid = original_egid + random.randrange(500, 1000)
371 self.useFixture(EnvironmentVariable("SUDO_GID", str(example_egid)))
372 with ExpectedException(ZeroDivisionError):
373 with utils.sudo_gid():
374 0 / 0 # A very realistic example.
375 self.assertThat(utils.setegid, MockCalledWith(original_egid))
301376
=== modified file 'src/maascli/utils.py'
--- src/maascli/utils.py 2016-03-28 13:54:47 +0000
+++ src/maascli/utils.py 2016-07-13 00:19:55 +0000
@@ -13,8 +13,11 @@
13 "print_response_content",13 "print_response_content",
14 "print_response_headers",14 "print_response_headers",
15 "safe_name",15 "safe_name",
16 "sudo_gid",
17 "sudo_uid",
16]18]
1719
20from contextlib import contextmanager
18from email.message import Message21from email.message import Message
19from functools import partial22from functools import partial
20from inspect import (23from inspect import (
@@ -22,6 +25,11 @@
22 getdoc,25 getdoc,
23)26)
24import io27import io
28import os
29from os import (
30 setegid,
31 seteuid,
32)
25import re33import re
26import sys34import sys
27from urllib.parse import urlparse35from urllib.parse import urlparse
@@ -202,3 +210,35 @@
202 print(file=file)210 print(file=file)
203 print_response_headers(response, file=file)211 print_response_headers(response, file=file)
204 print(file=file)212 print(file=file)
213
214
215@contextmanager
216def sudo_uid():
217 """Context to revert effective UID to that of the user invoking `sudo`."""
218 try:
219 sudo_uid = os.environ["SUDO_UID"]
220 except KeyError:
221 yield # Not running under sudo.
222 else:
223 orig_euid = os.geteuid()
224 seteuid(int(sudo_uid))
225 try:
226 yield
227 finally:
228 seteuid(orig_euid)
229
230
231@contextmanager
232def sudo_gid():
233 """Context to revert effective GID to that of the user invoking `sudo`."""
234 try:
235 sudo_gid = os.environ["SUDO_GID"]
236 except KeyError:
237 yield # Not running under sudo.
238 else:
239 orig_egid = os.getegid()
240 setegid(int(sudo_gid))
241 try:
242 yield
243 finally:
244 setegid(orig_egid)

Subscribers

People subscribed via source and target branches

to all changes: