Merge lp:~allenap/maas/atomic-delete-and-write-scripts into lp:~maas-committers/maas/trunk

Proposed by Gavin Panella
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: 5849
Proposed branch: lp:~allenap/maas/atomic-delete-and-write-scripts
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 311 lines (+240/-1)
4 files modified
scripts/maas-delete-file (+47/-0)
scripts/maas-write-file (+64/-0)
setup.py (+3/-1)
src/provisioningserver/utils/tests/test_fs.py (+126/-0)
To merge this branch: bzr merge lp:~allenap/maas/atomic-delete-and-write-scripts
Reviewer Review Type Date Requested Status
Blake Rouse (community) Approve
Review via email: mp+320654@code.launchpad.net

Commit message

New scripts maas-delete-file and maas-write-file which will be installed to /usr/lib/maas.

Description of the change

MAAS's access to writing and deleting files is <cough> not great. These scripts are an improvement in that they enforce access controls that are not possible to express using sudo.

These scripts are not yet actually used — that will come in a later branch — but landing them like this allows me to then get the packaging right before switching over.

To post a comment you must log in.
Revision history for this message
Blake Rouse (blake-rouse) wrote :

Looks good. Getting the whitelist wrong will break MAAS, so I hope your sure that all files are in the whitelist.

review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote :
Download full text (1.8 MiB)

The attempt to merge lp:~allenap/maas/atomic-delete-and-write-scripts into lp:maas failed. Below is the output from the failed tests.

Hit:1 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial InRelease
Get:2 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates InRelease [102 kB]
Get:3 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-backports InRelease [102 kB]
Get:4 http://security.ubuntu.com/ubuntu xenial-security InRelease [102 kB]
Fetched 306 kB in 0s (612 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
    --no-install-recommends install apache2 archdetect-deb authbind avahi-utils bash bind9 bind9utils build-essential bzr bzr-builddeb chromium-browser chromium-chromedriver curl daemontools debhelper dh-apport dh-systemd distro-info dnsutils firefox freeipmi-tools git gjs ipython isc-dhcp-common isc-dhcp-server libjs-angularjs libjs-jquery libjs-jquery-hotkeys libjs-yui3-full libjs-yui3-min libnss-wrapper libpq-dev make nodejs-legacy npm postgresql psmisc pxelinux python3-all python3-apt python3-attr python3-bson python3-convoy python3-crochet python3-cssselect python3-curtin python3-dev python3-distro-info python3-django python3-django-nose python3-django-piston3 python3-dnspython python3-docutils python3-formencode python3-hivex python3-httplib2 python3-jinja2 python3-jsonschema python3-lxml python3-netaddr python3-netifaces python3-novaclient python3-oauth python3-oauthlib python3-openssl python3-paramiko python3-petname python3-pexpect python3-psycopg2 python3-pyinotify python3-pyparsing python3-pyvmomi python3-requests python3-seamicroclient python3-setuptools python3-simplestreams python3-sphinx python3-tempita python3-twisted python3-txtftp python3-tz python3-yaml python3-zope.interface python-bson python-crochet python-django python-django-piston python-djorm-ext-pgarray python-formencode python-lxml python-netaddr python-netifaces python-pocket-lint python-psycopg2 python-simplejson python-tempita python-twisted python-yaml socat syslinux-common tgt ubuntu-cloudimage-keyring wget xvfb
Reading package lists...
Building dependency tree...
Reading state information...
authbind is already the newest version (2.1.1+nmu1).
avahi-utils is already the newest version (0.6.32~rc+dfsg-1ubuntu2).
build-essential is already the newest version (12.1ubuntu2).
debhelper is already the newest version (9.20160115ubuntu3).
distro-info is already the newest version (0.14build1).
git is already the newest version (1:2.7.4-0ubuntu1).
libjs-angularjs is already the newest version (1.2.28-1ubuntu2).
libjs-jquery is already the newest version (1.11.3+dfsg-4).
libjs-yui3-full is already the newest version (3.5.1-1ubuntu3).
libjs-yui3-min is already the newest version (3.5.1-1ubuntu3).
make is already the newest version (4.1-6).
postgresql is already the newest version (9.5+173).
psmisc is already the newest version (22.21-2.1build1).
pxelinux is already the newest version (3:6.03+dfsg-11ubuntu1).
python-formencode is already the newest version (1.3.0-0ubuntu5).
python-lxml is already the newest version (3.5.0-1build1).
python-netaddr is already the newest version (0.7.18-1).
py...

Revision history for this message
Gavin Panella (allenap) wrote :

Spurious test failure fixed in another branch.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'scripts/maas-delete-file'
2--- scripts/maas-delete-file 1970-01-01 00:00:00 +0000
3+++ scripts/maas-delete-file 2017-03-22 16:56:50 +0000
4@@ -0,0 +1,47 @@
5+#!/usr/bin/env python3.5
6+# Copyright 2017 Canonical Ltd. This software is licensed under the
7+# GNU Affero General Public License version 3 (see the file LICENSE).
8+
9+"""Atomically delete a file.
10+
11+The filename is checked against an internal white list. As such it's intended
12+to be used behind `sudo`.
13+"""
14+
15+import argparse
16+import pipes
17+
18+from provisioningserver.utils.fs import atomic_delete
19+
20+
21+whitelist = {
22+ "/var/lib/maas/dhcpd.conf",
23+ "/var/lib/maas/dhcpd6.conf",
24+}
25+
26+
27+arg_parser = argparse.ArgumentParser(description=__doc__)
28+arg_parser.add_argument("filename", help="The file to delete.")
29+
30+
31+def main(args):
32+
33+ # Validate the filename here because using a `choices` argument in the
34+ # parser results in ugly help and error text.
35+ if args.filename not in whitelist:
36+ arg_parser.error(
37+ "Given filename %s is not in the white list. "
38+ "Choose from: %s." % (
39+ pipes.quote(args.filename), ", ".join(
40+ map(pipes.quote, sorted(whitelist)))))
41+
42+ # Okay, good to go.
43+ else:
44+ try:
45+ atomic_delete(args.filename)
46+ except FileNotFoundError:
47+ pass # Ignore; it's already gone.
48+
49+
50+if __name__ == "__main__":
51+ main(arg_parser.parse_args())
52
53=== added file 'scripts/maas-write-file'
54--- scripts/maas-write-file 1970-01-01 00:00:00 +0000
55+++ scripts/maas-write-file 2017-03-22 16:56:50 +0000
56@@ -0,0 +1,64 @@
57+#!/usr/bin/env python3.5
58+# Copyright 2017 Canonical Ltd. This software is licensed under the
59+# GNU Affero General Public License version 3 (see the file LICENSE).
60+
61+"""Atomically write a file.
62+
63+Reads the byte content from standard-in and writes to the specified file. The
64+filename is checked against an internal white list. As such it's intended to
65+be used behind `sudo`.
66+"""
67+
68+import argparse
69+import pipes
70+import sys
71+
72+from provisioningserver.utils.fs import atomic_write
73+
74+
75+whitelist = {
76+ "/etc/ntp.conf",
77+ "/etc/ntp/maas.conf",
78+ "/var/lib/maas/dhcpd-interfaces",
79+ "/var/lib/maas/dhcpd.conf",
80+ "/var/lib/maas/dhcpd6-interfaces",
81+ "/var/lib/maas/dhcpd6.conf",
82+}
83+
84+
85+def octal(string):
86+ """Parse `string` as an octal number."""
87+ return int(string, 8)
88+
89+
90+arg_parser = argparse.ArgumentParser(description=__doc__)
91+arg_parser.add_argument("filename", help="The file to write.")
92+arg_parser.add_argument("mode", type=octal, help="The octal file mode.")
93+
94+
95+def main(args, fin):
96+
97+ # Validate the filename here because using a `choices` argument in the
98+ # parser results in ugly help and error text.
99+ if args.filename not in whitelist:
100+ arg_parser.error(
101+ "Given filename %s is not in the white list. "
102+ "Choose from: %s." % (
103+ pipes.quote(args.filename), ", ".join(
104+ map(pipes.quote, sorted(whitelist)))))
105+
106+ # Do not allow "high" bits in the mode, especially setuid and setgid.
107+ elif args.mode & 0o777 != args.mode:
108+ arg_parser.error(
109+ "Given file mode 0o%o is not permitted; only "
110+ "permission bits may be set." % args.mode)
111+
112+ # Okay, good to go.
113+ else:
114+ atomic_write(
115+ fin.read(), args.filename, overwrite=True,
116+ mode=args.mode)
117+
118+
119+if __name__ == "__main__":
120+ main(arg_parser.parse_args(), sys.stdin.buffer)
121
122=== modified file 'setup.py'
123--- setup.py 2016-12-07 12:46:14 +0000
124+++ setup.py 2017-03-22 16:56:50 +0000
125@@ -118,7 +118,9 @@
126 ['scripts/maas-dhcp-helper']),
127 ('/usr/lib/maas',
128 ['scripts/maas-dhcp-monitor',
129- 'scripts/maas-network-monitor']),
130+ 'scripts/maas-network-monitor',
131+ 'scripts/maas-delete-file',
132+ 'scripts/maas-write-file']),
133 ],
134
135 classifiers=[
136
137=== modified file 'src/provisioningserver/utils/tests/test_fs.py'
138--- src/provisioningserver/utils/tests/test_fs.py 2017-01-28 00:51:47 +0000
139+++ src/provisioningserver/utils/tests/test_fs.py 2017-03-22 16:56:50 +0000
140@@ -5,8 +5,10 @@
141
142 __all__ = []
143
144+import io
145 import os
146 import os.path
147+import random
148 from shutil import rmtree
149 import stat
150 from subprocess import (
151@@ -15,16 +17,21 @@
152 )
153 import tempfile
154 import time
155+import tokenize
156+import types
157 from unittest.mock import (
158 ANY,
159 call,
160+ create_autospec,
161 Mock,
162 sentinel,
163 )
164
165 from maastesting import root
166 from maastesting.factory import factory
167+from maastesting.fixtures import CaptureStandardIO
168 from maastesting.matchers import (
169+ DocTestMatches,
170 FileContains,
171 MockCalledOnceWith,
172 MockCallsMatch,
173@@ -53,12 +60,15 @@
174 )
175 import provisioningserver.utils.fs as fs_module
176 from testtools.matchers import (
177+ AllMatch,
178 DirContains,
179 DirExists,
180 EndsWith,
181 Equals,
182 FileExists,
183+ GreaterThan,
184 HasLength,
185+ IsInstance,
186 MatchesRegex,
187 Not,
188 SamePath,
189@@ -433,6 +443,122 @@
190 sudo_delete_file, self.make_file())
191
192
193+def load_script(filename):
194+ """Load the Python script at `filename` into a new module."""
195+ modname = os.path.relpath(filename, root).replace(os.sep, ".")
196+ module = types.ModuleType(modname)
197+ with tokenize.open(filename) as fd:
198+ code = compile(fd.read(), filename, "exec", dont_inherit=True)
199+ exec(code, module.__dict__, module.__dict__)
200+ return module
201+
202+
203+class TestSudoWriteFileScript(MAASTestCase):
204+ """Tests for `scripts/maas-write-file`."""
205+
206+ def setUp(self):
207+ super(TestSudoWriteFileScript, self).setUp()
208+ self.script_path = os.path.join(root, "scripts/maas-write-file")
209+ self.script = load_script(self.script_path)
210+ self.script.atomic_write = create_autospec(self.script.atomic_write)
211+
212+ def test__white_list_is_a_non_empty_set_of_file_names(self):
213+ self.assertThat(self.script.whitelist, IsInstance(set))
214+ self.assertThat(self.script.whitelist, Not(HasLength(0)))
215+ self.assertThat(self.script.whitelist, AllMatch(IsInstance(str)))
216+
217+ def test__accepts_file_names_on_white_list(self):
218+ calls_expected = []
219+ for filename in self.script.whitelist:
220+ content = factory.make_bytes() # It's binary safe.
221+ mode = random.randint(0o000, 0o777) # Inclusive of endpoints.
222+ args = self.script.arg_parser.parse_args([filename, oct(mode)])
223+ self.script.main(args, io.BytesIO(content))
224+ calls_expected.append(call(
225+ content, filename, overwrite=True, mode=mode))
226+ self.assertThat(
227+ self.script.atomic_write,
228+ MockCallsMatch(*calls_expected))
229+
230+ def test__rejects_file_name_not_on_white_list(self):
231+ filename = factory.make_name("/some/where", sep="/")
232+ mode = random.randint(0o000, 0o777) # Inclusive of endpoints.
233+ args = self.script.arg_parser.parse_args([filename, oct(mode)])
234+ with CaptureStandardIO() as stdio:
235+ error = self.assertRaises(
236+ SystemExit, self.script.main, args, io.BytesIO())
237+ self.assertThat(error.code, GreaterThan(0))
238+ self.assertThat(self.script.atomic_write, MockNotCalled())
239+ self.assertThat(stdio.getOutput(), Equals(""))
240+ self.assertThat(stdio.getError(), DocTestMatches(
241+ "usage: ... Given filename ... is not in the "
242+ "white list. Choose from: ..."
243+ ))
244+
245+ def test__rejects_file_mode_with_high_bits_set(self):
246+ filename = random.choice(list(self.script.whitelist))
247+ mode = random.randint(0o1000, 0o7777) # Inclusive of endpoints.
248+ args = self.script.arg_parser.parse_args([filename, oct(mode)])
249+ with CaptureStandardIO() as stdio:
250+ error = self.assertRaises(
251+ SystemExit, self.script.main, args, io.BytesIO())
252+ self.assertThat(error.code, GreaterThan(0))
253+ self.assertThat(self.script.atomic_write, MockNotCalled())
254+ self.assertThat(stdio.getOutput(), Equals(""))
255+ self.assertThat(stdio.getError(), DocTestMatches(
256+ "usage: ... Given file mode 0o... is not permitted; "
257+ "only permission bits may be set."
258+ ))
259+
260+
261+class TestSudoDeleteFileScript(MAASTestCase):
262+ """Tests for `scripts/maas-delete-file`."""
263+
264+ def setUp(self):
265+ super(TestSudoDeleteFileScript, self).setUp()
266+ self.script_path = os.path.join(root, "scripts/maas-delete-file")
267+ self.script = load_script(self.script_path)
268+ self.script.atomic_delete = create_autospec(self.script.atomic_delete)
269+
270+ def test__white_list_is_a_non_empty_set_of_file_names(self):
271+ self.assertThat(self.script.whitelist, IsInstance(set))
272+ self.assertThat(self.script.whitelist, Not(HasLength(0)))
273+ self.assertThat(self.script.whitelist, AllMatch(IsInstance(str)))
274+
275+ def test__accepts_file_names_on_white_list(self):
276+ calls_expected = []
277+ for filename in self.script.whitelist:
278+ args = self.script.arg_parser.parse_args([filename])
279+ self.script.main(args)
280+ calls_expected.append(call(filename))
281+ self.assertThat(
282+ self.script.atomic_delete,
283+ MockCallsMatch(*calls_expected))
284+
285+ def test__is_okay_when_the_file_does_not_exist(self):
286+ filename = random.choice(list(self.script.whitelist))
287+ args = self.script.arg_parser.parse_args([filename])
288+ self.script.atomic_delete.side_effect = FileNotFoundError
289+ self.script.main(args)
290+ self.assertThat(
291+ self.script.atomic_delete,
292+ MockCalledOnceWith(filename))
293+
294+ def test__rejects_file_name_not_on_white_list(self):
295+ filename = factory.make_name("/some/where", sep="/")
296+ args = self.script.arg_parser.parse_args([filename])
297+ with CaptureStandardIO() as stdio:
298+ error = self.assertRaises(
299+ SystemExit, self.script.main, args)
300+ self.assertThat(error.code, GreaterThan(0))
301+ self.assertThat(self.script.atomic_delete, MockNotCalled())
302+ self.assertThat(stdio.getOutput(), Equals(""))
303+ self.assertThat(stdio.getError(), DocTestMatches(
304+ "usage: ... Given filename ... is not in the "
305+ "white list. Choose from: ..."
306+ ))
307+
308+
309 class TestTempDir(MAASTestCase):
310
311 def test_creates_real_fresh_directory(self):