Skip to content

Commit f2ed5fb

Browse files
committed
Support additional policy directories
This allows multiple directories to contain qrexec policy, which allows for transient policy that disappears on reboot. Fixes: QubesOS/qubes-issues#8513
1 parent 60b54a6 commit f2ed5fb

10 files changed

+156
-84
lines changed

Makefile

+2
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ install-dom0: all-dom0
6666
install -t $(DESTDIR)/etc/qubes/policy.d/include -m 664 policy.d/include/*
6767
install -d $(DESTDIR)/lib/systemd/system -m 755
6868
install -t $(DESTDIR)/lib/systemd/system -m 644 systemd/qubes-qrexec-policy-daemon.service
69+
install -m 755 -d $(DESTDIR)/usr/lib/tmpfiles.d/
70+
install -m 0644 -t $(DESTDIR)/usr/lib/tmpfiles.d/ systemd/qrexec.conf
6971
.PHONY: install-dom0
7072

7173

qrexec/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,12 @@
3838
RPC_PATH = "/etc/qubes-rpc"
3939
POLICY_AGENT_SOCKET_PATH = "/var/run/qubes/policy-agent.sock"
4040
POLICYPATH = pathlib.Path("/etc/qubes/policy.d")
41+
RUNTIME_POLICY_PATH = pathlib.Path("/run/qubes/policy.d")
4142
POLICYSOCKET = pathlib.Path("/var/run/qubes/policy.sock")
4243
POLICY_EVAL_SOCKET = pathlib.Path("/etc/qubes-rpc/policy.EvalSimple")
4344
POLICY_GUI_SOCKET = pathlib.Path("/etc/qubes-rpc/policy.EvalGUI")
4445
INCLUDEPATH = POLICYPATH / "include"
46+
RUNTIME_INCLUDE_PATH = RUNTIME_POLICY_PATH / "include"
4547
POLICYSUFFIX = ".policy"
4648
POLICYPATH_OLD = pathlib.Path("/etc/qubes-rpc/policy")
4749

qrexec/policy/parser.py

+55-14
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
Sequence,
4848
)
4949

50-
from .. import POLICYPATH, RPCNAME_ALLOWED_CHARSET, POLICYSUFFIX
50+
from .. import POLICYPATH, RPCNAME_ALLOWED_CHARSET, POLICYSUFFIX, RUNTIME_POLICY_PATH
5151
from ..utils import FullSystemInfo
5252
from .. import exc
5353
from ..exc import (
@@ -1790,22 +1790,54 @@ class AbstractFileSystemLoader(AbstractDirectoryLoader, AbstractFileLoader):
17901790
"""This class is used when policy is stored as regular files in a directory.
17911791
17921792
Args:
1793-
policy_path (pathlib.Path): Load this directory. Paths given to
1794-
``!include`` etc. directives are interpreted relative to this path.
1793+
policy_path: Load these directories. Paths given to
1794+
``!include`` etc. directives in a file are interpreted relative to
1795+
the path from which the file was loaded.
17951796
"""
17961797

1797-
def __init__(self, *, policy_path=POLICYPATH, **kwds):
1798-
super().__init__(**kwds)
1799-
self.policy_path = pathlib.Path(policy_path)
1800-
1798+
policy_path: Optional[pathlib.Path]
1799+
def __init__(
1800+
self,
1801+
*,
1802+
policy_path: Union[None, pathlib.PurePath, Iterable[pathlib.PurePath]]
1803+
) -> None:
1804+
super().__init__()
1805+
if policy_path is None:
1806+
iterable_policy_paths = [RUNTIME_POLICY_PATH, POLICYPATH]
1807+
elif isinstance(policy_path, pathlib.Path):
1808+
iterable_policy_paths = [policy_path]
1809+
elif isinstance(policy_path, list):
1810+
iterable_policy_paths = policy_path
1811+
else:
1812+
raise TypeError("unexpected type of policy path in AbstractFileSystemLoader.__init__!")
18011813
try:
1802-
self.load_policy_dir(self.policy_path)
1814+
self.load_policy_dirs(iterable_policy_paths)
18031815
except OSError as err:
18041816
raise AccessDenied(
18051817
"failed to load {} file: {!s}".format(err.filename, err)
18061818
) from err
1807-
1808-
def resolve_path(self, included_path):
1819+
self.policy_path = None
1820+
1821+
def load_policy_dirs(self, paths: Iterable[pathlib.PurePath]) -> None:
1822+
already_seen = set()
1823+
final_list = []
1824+
for path in paths:
1825+
for file_path in filter_filepaths(pathlib.Path(path).iterdir()):
1826+
basename = file_path.name
1827+
if basename not in already_seen:
1828+
already_seen.add(basename)
1829+
final_list.append(file_path)
1830+
final_list.sort(key=lambda x: x.name)
1831+
for file_path in final_list:
1832+
with file_path.open() as file:
1833+
self.policy_path = file_path.parent
1834+
try:
1835+
self.load_policy_file(file, file_path)
1836+
finally:
1837+
self.policy_path = None
1838+
1839+
def resolve_path(self, included_path: pathlib.PurePosixPath) -> pathlib.Path:
1840+
assert self.policy_path is not None, "Tried to resolve a path when not loading policy"
18091841
return (self.policy_path / included_path).resolve()
18101842

18111843

@@ -1840,12 +1872,21 @@ class ValidateParser(FilePolicy):
18401872
"""
18411873

18421874
def __init__(
1843-
self, *args, overrides: Dict[pathlib.Path, Optional[str]], **kwds
1844-
):
1875+
self,
1876+
*,
1877+
overrides: Dict[pathlib.Path, Optional[str]],
1878+
policy_path: Union[None, pathlib.PurePath, Iterable[pathlib.PurePath]] = None,
1879+
) -> None:
18451880
self.overrides = overrides
1846-
super().__init__(*args, **kwds)
1881+
super().__init__(policy_path=policy_path)
18471882

1848-
def load_policy_dir(self, dirpath):
1883+
def load_policy_dirs(self, paths: Iterable[pathlib.PurePath]) -> None:
1884+
assert len(paths) == 1
1885+
path, = paths
1886+
self.policy_path = path
1887+
self.load_policy_dir(path)
1888+
1889+
def load_policy_dir(self, dirpath: pathlib.Path) -> None:
18491890
for path in filter_filepaths(dirpath.iterdir()):
18501891
if path not in self.overrides:
18511892
with path.open() as file:

qrexec/policy/utils.py

+15-15
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,20 @@
2020
import asyncio
2121
import os.path
2222
import pyinotify
23-
from qrexec import POLICYPATH, POLICYPATH_OLD
23+
from qrexec import POLICYPATH, POLICYPATH_OLD, RUNTIME_POLICY_PATH
2424
from . import parser
2525

2626

2727
class PolicyCache:
28-
def __init__(self, path=POLICYPATH, use_legacy=True, lazy_load=False):
29-
self.path = path
28+
def __init__(
29+
self, path=(RUNTIME_POLICY_PATH, POLICYPATH), use_legacy=True, lazy_load=False
30+
) -> None:
31+
self.paths = list(path)
3032
self.outdated = lazy_load
3133
if lazy_load:
3234
self.policy = None
3335
else:
34-
self.policy = parser.FilePolicy(policy_path=self.path)
36+
self.policy = parser.FilePolicy(policy_path=self.paths)
3537

3638
# default policy paths are listed manually, for compatibility with R4.0
3739
# to be removed in Qubes 5.0
@@ -62,22 +64,20 @@ def initialize_watcher(self):
6264
self.watch_manager, loop, default_proc_fun=PolicyWatcher(self)
6365
)
6466

65-
if str(self.path) not in self.default_policy_paths and os.path.exists(
66-
self.path
67-
):
68-
self.watches.append(
69-
self.watch_manager.add_watch(
70-
str(self.path), mask, rec=True, auto_add=True
67+
for path in self.paths:
68+
str_path = str(path)
69+
if str_path not in self.default_policy_paths and os.path.exists(str_path):
70+
self.watches.append(
71+
self.watch_manager.add_watch(
72+
str_path, mask, rec=True, auto_add=True
73+
)
7174
)
72-
)
7375

7476
for path in self.default_policy_paths:
7577
if not os.path.exists(path):
7678
continue
7779
self.watches.append(
78-
self.watch_manager.add_watch(
79-
str(path), mask, rec=True, auto_add=True
80-
)
80+
self.watch_manager.add_watch(str(path), mask, rec=True, auto_add=True)
8181
)
8282

8383
def cleanup(self):
@@ -92,7 +92,7 @@ def cleanup(self):
9292

9393
def get_policy(self):
9494
if self.outdated:
95-
self.policy = parser.FilePolicy(policy_path=self.path)
95+
self.policy = parser.FilePolicy(policy_path=self.paths)
9696
self.outdated = False
9797

9898
return self.policy

qrexec/tests/cli.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ def policy():
9696
yield policy
9797

9898
assert mock_policy.mock_calls == [
99-
mock.call(policy_path=PosixPath("/etc/qubes/policy.d"))
99+
mock.call(policy_path=[PosixPath("/run/qubes/policy.d"), PosixPath("/etc/qubes/policy.d")]),
100100
]
101101

102102

qrexec/tests/policy_cache.py

+63-44
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,20 @@
2323
import pytest
2424
import unittest
2525
import unittest.mock
26+
import pathlib
2627

2728
from ..policy import utils
2829

2930

3031
class TestPolicyCache:
32+
@pytest.fixture
33+
def tmp_paths(self, tmp_path: pathlib.Path) -> list[pathlib.Path]:
34+
path1 = tmp_path / "path1"
35+
path2 = tmp_path / "path2"
36+
path1.mkdir()
37+
path2.mkdir()
38+
return [path1, path2]
39+
3140
@pytest.fixture
3241
def mock_parser(self, monkeypatch):
3342
mock_parser = unittest.mock.Mock()
@@ -37,58 +46,60 @@ def mock_parser(self, monkeypatch):
3746
return mock_parser
3847

3948
def test_00_policy_init(self, tmp_path, mock_parser):
40-
cache = utils.PolicyCache(tmp_path)
41-
mock_parser.assert_called_once_with(policy_path=tmp_path)
49+
cache = utils.PolicyCache([tmp_path])
50+
mock_parser.assert_called_once_with(policy_path=[tmp_path])
4251

4352
@pytest.mark.asyncio
44-
async def test_10_file_created(self, tmp_path, mock_parser):
45-
cache = utils.PolicyCache(tmp_path)
46-
cache.initialize_watcher()
53+
async def test_10_file_created(self, tmp_paths, mock_parser):
54+
for i in tmp_paths:
55+
cache = utils.PolicyCache(tmp_paths)
56+
cache.initialize_watcher()
4757

48-
assert not cache.outdated
58+
assert not cache.outdated
4959

50-
file = tmp_path / "test"
51-
file.write_text("test")
60+
(i / "file").write_text("test")
5261

53-
await asyncio.sleep(1)
62+
await asyncio.sleep(1)
5463

55-
assert cache.outdated
64+
assert cache.outdated
5665

5766
@pytest.mark.asyncio
58-
async def test_11_file_changed(self, tmp_path, mock_parser):
59-
file = tmp_path / "test"
60-
file.write_text("test")
67+
async def test_11_file_changed(self, tmp_paths, mock_parser):
68+
for i in tmp_paths:
69+
file = i / "test"
70+
file.write_text("test")
6171

62-
cache = utils.PolicyCache(tmp_path)
63-
cache.initialize_watcher()
72+
cache = utils.PolicyCache(tmp_paths)
73+
cache.initialize_watcher()
6474

65-
assert not cache.outdated
75+
assert not cache.outdated
6676

67-
file.write_text("new_content")
77+
file.write_text("new_content")
6878

69-
await asyncio.sleep(1)
79+
await asyncio.sleep(1)
7080

71-
assert cache.outdated
81+
assert cache.outdated
7282

7383
@pytest.mark.asyncio
74-
async def test_12_file_deleted(self, tmp_path, mock_parser):
75-
file = tmp_path / "test"
76-
file.write_text("test")
84+
async def test_12_file_deleted(self, tmp_paths, mock_parser):
85+
for i in tmp_paths:
86+
file = i / "test"
87+
file.write_text("test")
7788

78-
cache = utils.PolicyCache(tmp_path)
79-
cache.initialize_watcher()
89+
cache = utils.PolicyCache(tmp_paths)
90+
cache.initialize_watcher()
8091

81-
assert not cache.outdated
92+
assert not cache.outdated
8293

83-
os.remove(file)
94+
os.remove(file)
8495

85-
await asyncio.sleep(1)
96+
await asyncio.sleep(1)
8697

87-
assert cache.outdated
98+
assert cache.outdated
8899

89100
@pytest.mark.asyncio
90-
async def test_13_no_change(self, tmp_path, mock_parser):
91-
cache = utils.PolicyCache(tmp_path)
101+
async def test_13_no_change(self, tmp_paths, mock_parser):
102+
cache = utils.PolicyCache(tmp_paths)
92103
cache.initialize_watcher()
93104

94105
assert not cache.outdated
@@ -101,10 +112,10 @@ async def test_13_no_change(self, tmp_path, mock_parser):
101112
async def test_14_policy_move(self, tmp_path, mock_parser):
102113
policy_path = tmp_path / "policy"
103114
policy_path.mkdir()
104-
cache = utils.PolicyCache(policy_path)
115+
cache = utils.PolicyCache([policy_path])
105116
cache.initialize_watcher()
106117

107-
mock_parser.assert_called_once_with(policy_path=policy_path)
118+
mock_parser.assert_called_once_with(policy_path=[policy_path])
108119

109120
assert not cache.outdated
110121

@@ -135,27 +146,35 @@ async def test_14_policy_move(self, tmp_path, mock_parser):
135146

136147
cache.get_policy()
137148

138-
call = unittest.mock.call(policy_path=policy_path)
149+
call = unittest.mock.call(policy_path=[policy_path])
139150
assert mock_parser.mock_calls == [call, call, call]
140151

141152
@pytest.mark.asyncio
142-
async def test_20_policy_updates(self, tmp_path, mock_parser):
143-
cache = utils.PolicyCache(tmp_path)
153+
async def test_20_policy_updates(self, tmp_paths, mock_parser):
154+
cache = utils.PolicyCache(tmp_paths)
144155
cache.initialize_watcher()
156+
count = 0
145157

146-
mock_parser.assert_called_once_with(policy_path=tmp_path)
158+
for i in tmp_paths:
159+
call = unittest.mock.call(policy_path=tmp_paths)
147160

148-
assert not cache.outdated
161+
count += 2
162+
assert mock_parser.mock_calls == [call] * (count - 1)
163+
cache = utils.PolicyCache(tmp_paths)
164+
cache.initialize_watcher()
149165

150-
file = tmp_path / "test"
151-
file.write_text("test")
166+
l = len(mock_parser.mock_calls)
167+
assert mock_parser.mock_calls == [call] * l
152168

153-
await asyncio.sleep(1)
169+
assert not cache.outdated
154170

155-
assert cache.outdated
171+
file = i / "test"
172+
file.write_text("test")
156173

157-
cache.get_policy()
174+
await asyncio.sleep(1)
175+
176+
assert cache.outdated
158177

159-
call = unittest.mock.call(policy_path=tmp_path)
178+
cache.get_policy()
160179

161-
assert mock_parser.mock_calls == [call, call]
180+
assert mock_parser.mock_calls == [call] * (count + 1)

qrexec/tools/qrexec_policy_daemon.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,17 @@
2727

2828
from ..utils import sanitize_domain_name, get_system_info
2929
from .qrexec_policy_exec import handle_request
30-
from .. import POLICYPATH, POLICYSOCKET, POLICY_EVAL_SOCKET, POLICY_GUI_SOCKET
30+
from .. import POLICYPATH, POLICYSOCKET, POLICY_EVAL_SOCKET, POLICY_GUI_SOCKET, RUNTIME_POLICY_PATH
3131
from ..policy.utils import PolicyCache
3232

3333
argparser = argparse.ArgumentParser(description="Evaluate qrexec policy daemon")
3434

3535
argparser.add_argument(
3636
"--policy-path",
3737
type=pathlib.Path,
38-
default=POLICYPATH,
38+
default=[RUNTIME_POLICY_PATH, POLICYPATH],
3939
help="Use alternative policy path",
40+
action='append',
4041
)
4142
argparser.add_argument(
4243
"--socket-path",
@@ -291,6 +292,8 @@ async def handle_qrexec_connection(
291292

292293
async def start_serving(args=None):
293294
args = argparser.parse_args(args)
295+
if len(args.policy_path) > 2:
296+
args.policy_path = args.policy_path[2:]
294297

295298
logging.basicConfig(format="%(message)s")
296299
log = logging.getLogger("policy")

0 commit comments

Comments
 (0)