Ver código fonte

Merge remote-tracking branch 'origin/policy-adminvm'

Marek Marczykowski-Górecki 6 anos atrás
pai
commit
51022cada5

+ 19 - 10
Makefile

@@ -8,7 +8,6 @@ OS ?= Linux
 PYTHON ?= python3
 
 ADMIN_API_METHODS_SIMPLE = \
-	admin.vm.List \
 	admin.vmclass.List \
 	admin.Events \
 	admin.backup.Execute \
@@ -17,6 +16,7 @@ ADMIN_API_METHODS_SIMPLE = \
 	admin.label.Create \
 	admin.label.Get \
 	admin.label.List \
+	admin.label.Index \
 	admin.label.Remove \
 	admin.pool.Add \
 	admin.pool.Info \
@@ -83,6 +83,8 @@ ADMIN_API_METHODS_SIMPLE = \
 	admin.vm.tag.List \
 	admin.vm.tag.Remove \
 	admin.vm.tag.Set \
+	admin.vm.volume.CloneFrom \
+	admin.vm.volume.CloneTo \
 	admin.vm.volume.Info \
 	admin.vm.volume.List \
 	admin.vm.volume.ListSnapshots \
@@ -90,10 +92,6 @@ ADMIN_API_METHODS_SIMPLE = \
 	admin.vm.volume.Revert \
 	$(null)
 
-ADMIN_API_METHODS := $(ADMIN_API_METHODS_SIMPLE) \
-	 admin.vm.volume.Import \
-	 $(null)
-
 ifeq ($(OS),Linux)
 DATADIR ?= /var/lib/qubes
 STATEDIR ?= /var/run/qubes
@@ -172,15 +170,26 @@ endif
 	install qubes-rpc/qubesd-query-fast $(DESTDIR)/usr/libexec/qubes/
 	for method in $(ADMIN_API_METHODS_SIMPLE); do \
 		ln -s ../../usr/libexec/qubes/qubesd-query-fast \
-			$(DESTDIR)/etc/qubes-rpc/$$method; \
+			$(DESTDIR)/etc/qubes-rpc/$$method || exit 1; \
 	done
 	install qubes-rpc/admin.vm.volume.Import $(DESTDIR)/etc/qubes-rpc/
-	for method in $(ADMIN_API_METHODS); do \
-		install -m 0644 qubes-rpc-policy/admin-default \
-			$(DESTDIR)/etc/qubes-rpc/policy/$$method; \
+	PYTHONPATH=.:test-packages qubes-rpc-policy/generate-admin-policy \
+		--destdir=$(DESTDIR)/etc/qubes-rpc/policy \
+		--exclude admin.vm.Create.AdminVM \
+				  admin.vm.CreateInPool.AdminVM \
+		          admin.vm.device.testclass.Attach \
+				  admin.vm.device.testclass.Detach \
+				  admin.vm.device.testclass.List \
+				  admin.vm.device.testclass.Available
+	# sanity check
+	for method in $(DESTDIR)/etc/qubes-rpc/policy/admin.*; do \
+		ls $(DESTDIR)/etc/qubes-rpc/$$(basename $$method) >/dev/null || exit 1; \
 	done
 	install -d $(DESTDIR)/etc/qubes-rpc/policy/include
-	install -m 0644 qubes-rpc-policy/admin-all \
+	install -m 0644 qubes-rpc-policy/admin-local-ro \
+		qubes-rpc-policy/admin-local-rwx \
+		qubes-rpc-policy/admin-global-ro \
+		qubes-rpc-policy/admin-global-rwx \
 		$(DESTDIR)/etc/qubes-rpc/policy/include/
 
 	mkdir -p "$(DESTDIR)$(FILESDIR)"

+ 2 - 2
ci/coveragerc

@@ -1,3 +1,3 @@
 [run]
-source = qubes
-omit = qubes/tests/*
+source = qubes, qubespolicy
+omit = qubes/tests/*, qubespolicy/tests/*

+ 2 - 0
doc/conf.py

@@ -243,6 +243,8 @@ _man_pages_author = []
 man_pages = [
     ('manpages/qubesd-query', 'qubesd-query',
         u'Low-level qubesd interrogation tool', _man_pages_author, 1),
+    ('manpages/qrexec-policy-graph', 'qrexec-policy-graph',
+        u'Graph qrexec policy', _man_pages_author, 1),
 ]
 
 if os.path.exists('sandbox.rst'):

+ 73 - 0
doc/manpages/qrexec-policy-graph.rst

@@ -0,0 +1,73 @@
+.. program:: qrexec-policy-graph
+
+:program:`qrexec-policy-graph` -- Graph qrexec policy
+=====================================================
+
+Synopsis
+--------
+
+:command:`qrexec-policy-graph` [-h] [--include-ask] [--source *SOURCE* [*SOURCE* ...]] [--target *TARGET* [*TARGET* ...]] [--service *SERVICE* [*SERVICE* ...]] [--output *OUTPUT*] [--policy-dir POLICY_DIR] [--system-info SYSTEM_INFO]
+
+
+Options
+-------
+
+.. option:: --help, -h
+
+   show this help message and exit
+
+.. option:: --include-ask
+
+   Include `ask` action in graph. In most cases produce unreadable graphs
+   because many services contains `$anyvm $anyvm ask` rules. It's recommended to
+   limit graph using other options.
+
+.. option:: --source
+
+   Limit graph to calls from *source*. You can specify multiple names.
+
+.. option:: --target
+
+   Limit graph to calls to *target*. You can specify multiple names.
+
+.. option:: --service
+
+   Limit graph to *service*. You can specify multiple names. This can be either
+   bare service name, or service with argument (joined with `+`). If bare
+   service name is given, output will contain also policies for specific
+   arguments.
+
+.. option:: --output
+
+   Write to *output* instead of stdout. The file will be overwritten without
+   confirmation.
+
+.. option:: --policy-dir
+
+   Look for policy in *policy-dir*. This can be useful to process policy
+   extracted from other system. This option adjust only base directory, if any
+   policy file contains `$include:path` with absolute path, it will try to load
+   the file from that location.
+   See also --system-info option.
+
+.. option:: --system-info
+
+   Load system information from file instead of querying local qubesd instance.
+   The file should be in json format, as returned by `internal.GetSystemInfo`
+   qubesd method. This can be obtained by running in dom0:
+
+        qubesd-query -e -c /var/run/qubesd.internal.sock dom0 \
+        internal.GetSystemInfo dom0 | cut -b 3-
+
+.. option:: --skip-labels
+
+   Do not include service names on the graph. Also, include only a single
+   connection between qubes if any service call is allowed there.
+
+
+Authors
+-------
+
+| Marek Marczykowski-Górecki <marmarek at invisiblethingslab dot com>
+
+.. vim: ts=3 sw=3 et tw=80

+ 9 - 2
doc/qubes-policy.rst

@@ -11,13 +11,14 @@ Policy consists of a file, which is parsed line-by-line. First matching line
 is used as an action.
 
 Each line consist of three values separated by white characters (space(s), tab(s)):
+
 1. Source specification, which is one of:
 
   - domain name
   - `$anyvm` - any domain
   - `$tag:some-tag` - VM having tag `some-tag`
   - `$type:vm-type` - VM of `vm-type` type, available types:
-      AppVM, TemplateVM, StandaloneVM, DispVM
+    AppVM, TemplateVM, StandaloneVM, DispVM
 
 2. Target specification, one of:
 
@@ -25,15 +26,20 @@ Each line consist of three values separated by white characters (space(s), tab(s
   - `$anyvm` - any domain, excluding dom0
   - `$tag:some-tag` - domain having tag `some-tag`
   - `$type:vm-type` - domain of `vm-type` type, available types:
-      AppVM, TemplateVM, StandaloneVM, DispVM
+    AppVM, TemplateVM, StandaloneVM, DispVM
   - `$default` - used when caller did not specified any VM
   - `$dispvm:vm-name` - _new_ Disposable VM created from AppVM `vm-name`
   - `$dispvm` - _new_ Disposable VM created from AppVM pointed by caller
     property `default_dispvm`, which defaults to global property `default_dispvm`
+  - `$adminvm` - Admin VM aka dom0
+
+  Dom0 can only be matched explicitly - either as `dom0` or `$adminvm` keyword.
+  None of `$anyvm`, `$tag:some-tag`, `$type:AdminVM` will match.
 
 3. Action and optional action parameters, one of:
 
   - `allow` - allow the call, without further questions; optional parameters:
+
     - `target=` - override caller provided call target -
       possible values are: domain name, `$dispvm` or `$dispvm:vm-name`
     - `user=` - call the service using this user, instead of the user
@@ -41,6 +47,7 @@ Each line consist of three values separated by white characters (space(s), tab(s
   - `deny` - deny the call, without further questions; no optional
     parameters are supported
   - `ask` - ask the user for confirmation; optional parameters:
+
     - `target=` - override user provided call target
     - `user=` - call the service using this user, instead of the user
       pointed by target VM's `default_user` property

+ 0 - 13
qubes-rpc-policy/admin-default

@@ -1,13 +0,0 @@
-## Note that policy parsing stops at the first match,
-## so adding anything below "$anyvm $anyvm action" line will have no effect
-
-## Please use a single # to start your custom comments
-
-## Add your entries here, make sure to append ",target=dom0" to all allow/ask actions
-
-## Include a single file for all admin.* methods to ease setting up Management VM.
-## To allow only specific actions, edit specific policy file, like this one. To
-## allow all of them, edit /etc/qubes-rpc/include/admin-all.
-$include:/etc/qubes-rpc/policy/include/admin-all
-
-$anyvm $anyvm deny

+ 13 - 0
qubes-rpc-policy/admin-global-ro

@@ -0,0 +1,13 @@
+## This file is included from all global read-only admin.* policy files
+## _in default configuration_. To allow only specific action,
+## edit specific policy file.
+
+## Note that policy parsing stops at the first match,
+
+## Please use a single # to start your custom comments
+
+## Include all already having write access
+$include:include/admin-global-rwx
+
+## Add your entries here, make sure to append ",target=dom0" to all allow/ask actions
+

+ 11 - 0
qubes-rpc-policy/admin-global-rwx

@@ -0,0 +1,11 @@
+## This file is included from all global read-write admin.* policy files
+## _in default configuration_. To allow only specific action,
+## edit specific policy file.
+
+## Note that policy parsing stops at the first match,
+## so adding anything below "$anyvm $anyvm action" line will have no effect
+
+## Please use a single # to start your custom comments
+
+## Add your entries here, make sure to append ",target=dom0" to all allow/ask actions
+

+ 14 - 0
qubes-rpc-policy/admin-local-ro

@@ -0,0 +1,14 @@
+## This file is included from all local read-only admin.* policy files
+## _in default configuration_. To allow only specific action,
+## edit specific policy file.
+
+## Note that policy parsing stops at the first match,
+## so adding anything below "$anyvm $anyvm action" line will have no effect
+
+## Please use a single # to start your custom comments
+
+## Include all already having write access
+$include:include/admin-local-rwx
+
+## Add your entries here, make sure to append ",target=dom0" to all allow/ask actions
+

+ 3 - 3
qubes-rpc-policy/admin-all → qubes-rpc-policy/admin-local-rwx

@@ -1,5 +1,6 @@
-## This file is included from all admin.* policy files _in default
-## configuration_. To allow only specific action, edit specific policy file.
+## This file is included from all local read-write admin.* policy files
+## _in default configuration_. To allow only specific action,
+## edit specific policy file.
 
 ## Note that policy parsing stops at the first match,
 ## so adding anything below "$anyvm $anyvm action" line will have no effect
@@ -8,4 +9,3 @@
 
 ## Add your entries here, make sure to append ",target=dom0" to all allow/ask actions
 
-$anyvm $anyvm deny

+ 96 - 0
qubes-rpc-policy/generate-admin-policy

@@ -0,0 +1,96 @@
+#!/usr/bin/python3
+# coding=utf-8
+# The Qubes OS Project, https://www.qubes-os.org/
+#
+# Copyright (C) 2017  Marek Marczykowski-Górecki
+#                                   <marmarek@invisiblethingslab.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+import argparse
+import os
+
+import sys
+
+import qubes.api.admin
+
+parser = argparse.ArgumentParser(
+    description='Generate default Admin API policy')
+parser.add_argument('--include-base', action='store',
+    default='include',
+    help='Base path for included paths (default: %(default)s)')
+parser.add_argument('--destdir', action='store',
+    default='/etc/qubes-rpc/policy',
+    help='Directory where write output files to (default: %(default)s)')
+parser.add_argument('--verbose', action='store_true', default=False,
+    help='Be verbose')
+parser.add_argument('--exclude', action='store', nargs='*',
+    help='Exclude service')
+parser.add_argument('service', nargs='*', action='store',
+    help='Generate policy for those services (default: all)')
+
+default_policy_header = '''\
+## Note that policy parsing stops at the first match.
+## Anything not specifically allowed here (or in included file) will be denied.
+
+## Please use a single # to start your custom comments
+
+## Add your entries here, make sure to append ",target=dom0" to all allow/ask actions
+
+## Include a common file for all admin.* methods to ease setting up
+## Management VM.
+## To allow only specific actions, edit specific policy file, like this one. To
+## allow all of them, edit appropriate /etc/qubes-rpc/include/admin-*.
+
+'''
+
+def write_default_policy(args, apiname, clasifiers):
+    ''' Write single default policy for given API call '''
+    assert 'scope' in clasifiers, \
+        'Method {} lack scope classifier'.format(apiname)
+    assert any(attr in clasifiers for attr in ('read', 'write', 'execute')), \
+        'Method {} lack read/write/execute classifier'.format(apiname)
+    assert clasifiers['scope'] in ('local', 'global'), \
+        'Method {} have invalid scope: {}'.format(apiname, clasifiers['scope'])
+
+    file_to_include = 'admin-{scope}-{rwx}'.format(
+        scope=clasifiers['scope'],
+        rwx=('rwx' if clasifiers.get('write', False) or
+                      clasifiers.get('execute', False)
+        else 'ro'))
+
+    if args.verbose:
+        print('Service {}: include {}'.format(apiname, file_to_include),
+            file=sys.stderr)
+    with open(os.path.join(args.destdir, apiname), 'w') as f:
+        f.write(default_policy_header)
+        f.write('$include:{}\n'.format(
+            os.path.join(args.include_base, file_to_include)))
+
+
+def main(args=None):
+    ''' Main function of default-admin-policy tool'''
+    args = parser.parse_args(args)
+
+    for func, apiname, _ in qubes.api.admin.QubesAdminAPI.list_methods():
+        if args.service and apiname not in args.service:
+            continue
+        if args.exclude and apiname in args.exclude:
+            continue
+        write_default_policy(args, apiname, func.classifiers)
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 2 - 2
qubes/__init__.py

@@ -329,8 +329,8 @@ class property(object):  # pylint: disable=redefined-builtin,invalid-name
         setter. Can raise QubesValueError if the value is invalid.
 
         :param untrusted_newvalue: value to be validated
-        :return sanitized value
-        :raises qubes.exc.QubesValueError
+        :return: sanitized value
+        :raises: qubes.exc.QubesValueError
         '''
         # do not treat type='str' as sufficient validation
         if self.type is not None and self.type is not str:

+ 29 - 22
qubes/api/__init__.py

@@ -41,7 +41,7 @@ class PermissionDenied(Exception):
     pass
 
 
-def method(name, *, no_payload=False, endpoints=None):
+def method(name, *, no_payload=False, endpoints=None, **classifiers):
     '''Decorator factory for methods intended to appear in API.
 
     The decorated method can be called from public API using a child of
@@ -51,6 +51,8 @@ def method(name, *, no_payload=False, endpoints=None):
     :param str name: qrexec rpc method name
     :param bool no_payload: if :py:obj:`True`, will barf on non-empty payload; \
         also will not pass payload at all to the method
+    :param iterable endpoints: if specified, method serve multiple API calls
+        generated by replacing `{endpoint}` with each value in this iterable
 
     The expected function method should have one argument (other than usual
     *self*), ``untrusted_payload``, which will contain the payload.
@@ -75,11 +77,14 @@ def method(name, *, no_payload=False, endpoints=None):
 
         # pylint: disable=protected-access
         if endpoints is None:
-            func._rpcname = ((name, None),)
+            func.rpcnames = ((name, None),)
         else:
-            func._rpcname = tuple(
+            func.rpcnames = tuple(
                 (name.format(endpoint=endpoint), endpoint)
                 for endpoint in endpoints)
+
+        func.classifiers = classifiers
+
         return func
 
     return decorator
@@ -133,43 +138,45 @@ class AbstractQubesAPI(object):
         #: is this operation cancellable?
         self.cancellable = False
 
-        untrusted_candidates = []
-        for attr in dir(self):
-            func = getattr(self, attr)
+        candidates = list(self.list_methods(self.method))
+
+        if not candidates:
+            raise ProtocolError('no such method: {!r}'.format(self.method))
+
+        assert len(candidates) == 1, \
+            'multiple candidates for method {!r}'.format(self.method)
 
+        #: the method to execute
+        self._handler = candidates[0]
+        self._running_handler = None
+
+    @classmethod
+    def list_methods(cls, select_method=None):
+        for attr in dir(cls):
+            func = getattr(cls, attr)
             if not callable(func):
                 continue
 
             try:
                 # pylint: disable=protected-access
-                for mname, endpoint in func._rpcname:
-                    if mname != self.method:
-                        continue
-                    untrusted_candidates.append((func, endpoint))
+                rpcnames = func.rpcnames
             except AttributeError:
                 continue
 
-        if not untrusted_candidates:
-            raise ProtocolError('no such method: {!r}'.format(self.method))
-
-        assert len(untrusted_candidates) == 1, \
-            'multiple candidates for method {!r}'.format(self.method)
-
-        #: the method to execute
-        self._handler = untrusted_candidates[0]
-        self._running_handler = None
-        del untrusted_candidates
+            for mname, endpoint in rpcnames:
+                if select_method is None or mname == select_method:
+                    yield (func, mname, endpoint)
 
     def execute(self, *, untrusted_payload):
         '''Execute management operation.
 
         This method is a coroutine.
         '''
-        handler, endpoint = self._handler
+        handler, _, endpoint = self._handler
         kwargs = {}
         if endpoint is not None:
             kwargs['endpoint'] = endpoint
-        self._running_handler = asyncio.ensure_future(handler(
+        self._running_handler = asyncio.ensure_future(handler(self,
             untrusted_payload=untrusted_payload, **kwargs))
         return self._running_handler
 

+ 118 - 55
qubes/api/admin.py

@@ -76,7 +76,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
 
     SOCKNAME = '/var/run/qubesd.sock'
 
-    @qubes.api.method('admin.vmclass.List', no_payload=True)
+    @qubes.api.method('admin.vmclass.List', no_payload=True,
+        scope='global', read=True)
     @asyncio.coroutine
     def vmclass_list(self):
         '''List all VM classes'''
@@ -89,7 +90,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
         return ''.join('{}\n'.format(ep.name)
             for ep in entrypoints)
 
-    @qubes.api.method('admin.vm.List', no_payload=True)
+    @qubes.api.method('admin.vm.List', no_payload=True,
+        scope='global', read=True)
     @asyncio.coroutine
     def vm_list(self):
         '''List all the domains'''
@@ -106,13 +108,15 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
                 vm.get_power_state())
             for vm in sorted(domains))
 
-    @qubes.api.method('admin.vm.property.List', no_payload=True)
+    @qubes.api.method('admin.vm.property.List', no_payload=True,
+        scope='local', read=True)
     @asyncio.coroutine
     def vm_property_list(self):
         '''List all properties on a qube'''
         return self._property_list(self.dest)
 
-    @qubes.api.method('admin.property.List', no_payload=True)
+    @qubes.api.method('admin.property.List', no_payload=True,
+        scope='global', read=True)
     @asyncio.coroutine
     def property_list(self):
         '''List all global properties'''
@@ -126,13 +130,15 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
 
         return ''.join('{}\n'.format(prop.__name__) for prop in properties)
 
-    @qubes.api.method('admin.vm.property.Get', no_payload=True)
+    @qubes.api.method('admin.vm.property.Get', no_payload=True,
+        scope='local', read=True)
     @asyncio.coroutine
     def vm_property_get(self):
         '''Get a value of one property'''
         return self._property_get(self.dest)
 
-    @qubes.api.method('admin.property.Get', no_payload=True)
+    @qubes.api.method('admin.property.Get', no_payload=True,
+        scope='global', read=True)
     @asyncio.coroutine
     def property_get(self):
         '''Get a value of one global property'''
@@ -168,14 +174,16 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
                 property_type,
                 str(value) if value is not None else '')
 
-    @qubes.api.method('admin.vm.property.Set')
+    @qubes.api.method('admin.vm.property.Set',
+        scope='local', write=True)
     @asyncio.coroutine
     def vm_property_set(self, untrusted_payload):
         '''Set property value'''
         return self._property_set(self.dest,
             untrusted_payload=untrusted_payload)
 
-    @qubes.api.method('admin.property.Set')
+    @qubes.api.method('admin.property.Set',
+        scope='global', write=True)
     @asyncio.coroutine
     def property_set(self, untrusted_payload):
         '''Set property value'''
@@ -195,13 +203,15 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
         setattr(dest, self.arg, newvalue)
         self.app.save()
 
-    @qubes.api.method('admin.vm.property.Help', no_payload=True)
+    @qubes.api.method('admin.vm.property.Help', no_payload=True,
+        scope='local', read=True)
     @asyncio.coroutine
     def vm_property_help(self):
         '''Get help for one property'''
         return self._property_help(self.dest)
 
-    @qubes.api.method('admin.property.Help', no_payload=True)
+    @qubes.api.method('admin.property.Help', no_payload=True,
+        scope='global', read=True)
     @asyncio.coroutine
     def property_help(self):
         '''Get help for one property'''
@@ -221,13 +231,15 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
 
         return qubes.utils.format_doc(doc)
 
-    @qubes.api.method('admin.vm.property.Reset', no_payload=True)
+    @qubes.api.method('admin.vm.property.Reset', no_payload=True,
+        scope='local', write=True)
     @asyncio.coroutine
     def vm_property_reset(self):
         '''Reset a property to a default value'''
         return self._property_reset(self.dest)
 
-    @qubes.api.method('admin.property.Reset', no_payload=True)
+    @qubes.api.method('admin.property.Reset', no_payload=True,
+        scope='global', write=True)
     @asyncio.coroutine
     def property_reset(self):
         '''Reset a property to a default value'''
@@ -243,7 +255,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
         delattr(dest, self.arg)
         self.app.save()
 
-    @qubes.api.method('admin.vm.volume.List', no_payload=True)
+    @qubes.api.method('admin.vm.volume.List', no_payload=True,
+        scope='local', read=True)
     @asyncio.coroutine
     def vm_volume_list(self):
         assert not self.arg
@@ -251,7 +264,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
         volume_names = self.fire_event_for_filter(self.dest.volumes.keys())
         return ''.join('{}\n'.format(name) for name in volume_names)
 
-    @qubes.api.method('admin.vm.volume.Info', no_payload=True)
+    @qubes.api.method('admin.vm.volume.Info', no_payload=True,
+        scope='local', read=True)
     @asyncio.coroutine
     def vm_volume_info(self):
         assert self.arg in self.dest.volumes.keys()
@@ -266,7 +280,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
         return ''.join('{}={}\n'.format(key, getattr(volume, key)) for key in
             volume_properties)
 
-    @qubes.api.method('admin.vm.volume.ListSnapshots', no_payload=True)
+    @qubes.api.method('admin.vm.volume.ListSnapshots', no_payload=True,
+        scope='local', read=True)
     @asyncio.coroutine
     def vm_volume_listsnapshots(self):
         assert self.arg in self.dest.volumes.keys()
@@ -277,7 +292,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
 
         return ''.join('{}\n'.format(revision) for revision in revisions)
 
-    @qubes.api.method('admin.vm.volume.Revert')
+    @qubes.api.method('admin.vm.volume.Revert',
+        scope='local', write=True)
     @asyncio.coroutine
     def vm_volume_revert(self, untrusted_payload):
         assert self.arg in self.dest.volumes.keys()
@@ -294,7 +310,10 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
         self.dest.storage.get_pool(volume).revert(revision)
         self.app.save()
 
-    @qubes.api.method('admin.vm.volume.CloneFrom', no_payload=True)
+    # write=True because this allow to clone VM - and most likely modify that
+    # one - still having the same data
+    @qubes.api.method('admin.vm.volume.CloneFrom', no_payload=True,
+        scope='local', write=True)
     @asyncio.coroutine
     def vm_volume_clone_from(self):
         assert self.arg in self.dest.volumes.keys()
@@ -314,7 +333,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
         self.app.api_admin_pending_clone[token] = volume
         return token
 
-    @qubes.api.method('admin.vm.volume.CloneTo')
+    @qubes.api.method('admin.vm.volume.CloneTo',
+        scope='local', write=True)
     @asyncio.coroutine
     def vm_volume_clone_to(self, untrusted_payload):
         assert self.arg in self.dest.volumes.keys()
@@ -347,7 +367,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
         self.dest.volumes[self.arg] = op_retval
         self.app.save()
 
-    @qubes.api.method('admin.vm.volume.Resize')
+    @qubes.api.method('admin.vm.volume.Resize',
+        scope='local', write=True)
     @asyncio.coroutine
     def vm_volume_resize(self, untrusted_payload):
         assert self.arg in self.dest.volumes.keys()
@@ -363,7 +384,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
         self.dest.storage.resize(self.arg, size)
         self.app.save()
 
-    @qubes.api.method('admin.vm.volume.Import', no_payload=True)
+    @qubes.api.method('admin.vm.volume.Import', no_payload=True,
+        scope='local', write=True)
     @asyncio.coroutine
     def vm_volume_import(self):
         '''Import volume data.
@@ -392,7 +414,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
 
         return '{} {}'.format(size, path)
 
-    @qubes.api.method('admin.vm.tag.List', no_payload=True)
+    @qubes.api.method('admin.vm.tag.List', no_payload=True,
+        scope='local', read=True)
     @asyncio.coroutine
     def vm_tag_list(self):
         assert not self.arg
@@ -403,7 +426,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
 
         return ''.join('{}\n'.format(tag) for tag in sorted(tags))
 
-    @qubes.api.method('admin.vm.tag.Get', no_payload=True)
+    @qubes.api.method('admin.vm.tag.Get', no_payload=True,
+        scope='local', read=True)
     @asyncio.coroutine
     def vm_tag_get(self):
         qubes.vm.Tags.validate_tag(self.arg)
@@ -412,7 +436,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
 
         return '1' if self.arg in self.dest.tags else '0'
 
-    @qubes.api.method('admin.vm.tag.Set', no_payload=True)
+    @qubes.api.method('admin.vm.tag.Set', no_payload=True,
+        scope='local', write=True)
     @asyncio.coroutine
     def vm_tag_set(self):
         qubes.vm.Tags.validate_tag(self.arg)
@@ -422,7 +447,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
         self.dest.tags.add(self.arg)
         self.app.save()
 
-    @qubes.api.method('admin.vm.tag.Remove', no_payload=True)
+    @qubes.api.method('admin.vm.tag.Remove', no_payload=True,
+        scope='local', write=True)
     @asyncio.coroutine
     def vm_tag_remove(self):
         qubes.vm.Tags.validate_tag(self.arg)
@@ -435,7 +461,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
             raise qubes.exc.QubesTagNotFoundError(self.dest, self.arg)
         self.app.save()
 
-    @qubes.api.method('admin.pool.List', no_payload=True)
+    @qubes.api.method('admin.pool.List', no_payload=True,
+        scope='global', read=True)
     @asyncio.coroutine
     def pool_list(self):
         assert not self.arg
@@ -445,7 +472,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
 
         return ''.join('{}\n'.format(pool) for pool in pools)
 
-    @qubes.api.method('admin.pool.ListDrivers', no_payload=True)
+    @qubes.api.method('admin.pool.ListDrivers', no_payload=True,
+        scope='global', read=True)
     @asyncio.coroutine
     def pool_listdrivers(self):
         assert self.dest.name == 'dom0'
@@ -458,7 +486,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
             ' '.join(qubes.storage.driver_parameters(driver)))
             for driver in drivers)
 
-    @qubes.api.method('admin.pool.Info', no_payload=True)
+    @qubes.api.method('admin.pool.Info', no_payload=True,
+        scope='global', read=True)
     @asyncio.coroutine
     def pool_info(self):
         assert self.dest.name == 'dom0'
@@ -471,7 +500,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
         return ''.join('{}={}\n'.format(prop, val)
             for prop, val in sorted(pool.config.items()))
 
-    @qubes.api.method('admin.pool.Add')
+    @qubes.api.method('admin.pool.Add',
+        scope='global', write=True)
     @asyncio.coroutine
     def pool_add(self, untrusted_payload):
         assert self.dest.name == 'dom0'
@@ -506,7 +536,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
         self.app.add_pool(name=pool_name, driver=self.arg, **pool_config)
         self.app.save()
 
-    @qubes.api.method('admin.pool.Remove', no_payload=True)
+    @qubes.api.method('admin.pool.Remove', no_payload=True,
+        scope='global', write=True)
     @asyncio.coroutine
     def pool_remove(self):
         assert self.dest.name == 'dom0'
@@ -517,7 +548,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
         self.app.remove_pool(self.arg)
         self.app.save()
 
-    @qubes.api.method('admin.label.List', no_payload=True)
+    @qubes.api.method('admin.label.List', no_payload=True,
+        scope='global', read=True)
     @asyncio.coroutine
     def label_list(self):
         assert self.dest.name == 'dom0'
@@ -527,7 +559,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
 
         return ''.join('{}\n'.format(label.name) for label in labels)
 
-    @qubes.api.method('admin.label.Get', no_payload=True)
+    @qubes.api.method('admin.label.Get', no_payload=True,
+        scope='global', read=True)
     @asyncio.coroutine
     def label_get(self):
         assert self.dest.name == 'dom0'
@@ -541,7 +574,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
 
         return label.color
 
-    @qubes.api.method('admin.label.Index', no_payload=True)
+    @qubes.api.method('admin.label.Index', no_payload=True,
+        scope='global', read=True)
     @asyncio.coroutine
     def label_index(self):
         assert self.dest.name == 'dom0'
@@ -555,7 +589,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
 
         return str(label.index)
 
-    @qubes.api.method('admin.label.Create')
+    @qubes.api.method('admin.label.Create',
+        scope='global', write=True)
     @asyncio.coroutine
     def label_create(self, untrusted_payload):
         assert self.dest.name == 'dom0'
@@ -591,7 +626,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
         self.app.labels[new_index] = label
         self.app.save()
 
-    @qubes.api.method('admin.label.Remove', no_payload=True)
+    @qubes.api.method('admin.label.Remove', no_payload=True,
+        scope='global', write=True)
     @asyncio.coroutine
     def label_remove(self):
         assert self.dest.name == 'dom0'
@@ -613,7 +649,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
         del self.app.labels[label.index]
         self.app.save()
 
-    @qubes.api.method('admin.vm.Start', no_payload=True)
+    @qubes.api.method('admin.vm.Start', no_payload=True,
+        scope='local', execute=True)
     @asyncio.coroutine
     def vm_start(self):
         assert not self.arg
@@ -625,35 +662,40 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
             raise qubes.exc.QubesException('Start failed: ' + str(e))
 
 
-    @qubes.api.method('admin.vm.Shutdown', no_payload=True)
+    @qubes.api.method('admin.vm.Shutdown', no_payload=True,
+        scope='local', execute=True)
     @asyncio.coroutine
     def vm_shutdown(self):
         assert not self.arg
         self.fire_event_for_permission()
         yield from self.dest.shutdown()
 
-    @qubes.api.method('admin.vm.Pause', no_payload=True)
+    @qubes.api.method('admin.vm.Pause', no_payload=True,
+        scope='local', execute=True)
     @asyncio.coroutine
     def vm_pause(self):
         assert not self.arg
         self.fire_event_for_permission()
         yield from self.dest.pause()
 
-    @qubes.api.method('admin.vm.Unpause', no_payload=True)
+    @qubes.api.method('admin.vm.Unpause', no_payload=True,
+        scope='local', execute=True)
     @asyncio.coroutine
     def vm_unpause(self):
         assert not self.arg
         self.fire_event_for_permission()
         yield from self.dest.unpause()
 
-    @qubes.api.method('admin.vm.Kill', no_payload=True)
+    @qubes.api.method('admin.vm.Kill', no_payload=True,
+        scope='local', execute=True)
     @asyncio.coroutine
     def vm_kill(self):
         assert not self.arg
         self.fire_event_for_permission()
         yield from self.dest.kill()
 
-    @qubes.api.method('admin.Events', no_payload=True)
+    @qubes.api.method('admin.Events', no_payload=True,
+        scope='global', read=True)
     @asyncio.coroutine
     def events(self):
         assert not self.arg
@@ -694,14 +736,16 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
         else:
             self.dest.remove_handler('*', dispatcher.vm_handler)
 
-    @qubes.api.method('admin.vm.feature.List', no_payload=True)
+    @qubes.api.method('admin.vm.feature.List', no_payload=True,
+        scope='local', read=True)
     @asyncio.coroutine
     def vm_feature_list(self):
         assert not self.arg
         features = self.fire_event_for_filter(self.dest.features.keys())
         return ''.join('{}\n'.format(feature) for feature in features)
 
-    @qubes.api.method('admin.vm.feature.Get', no_payload=True)
+    @qubes.api.method('admin.vm.feature.Get', no_payload=True,
+        scope='local', read=True)
     @asyncio.coroutine
     def vm_feature_get(self):
         # validation of self.arg done by qrexec-policy is enough
@@ -713,7 +757,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
             raise qubes.exc.QubesFeatureNotFoundError(self.dest, self.arg)
         return value
 
-    @qubes.api.method('admin.vm.feature.CheckWithTemplate', no_payload=True)
+    @qubes.api.method('admin.vm.feature.CheckWithTemplate', no_payload=True,
+        scope='local', read=True)
     @asyncio.coroutine
     def vm_feature_checkwithtemplate(self):
         # validation of self.arg done by qrexec-policy is enough
@@ -725,7 +770,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
             raise qubes.exc.QubesFeatureNotFoundError(self.dest, self.arg)
         return value
 
-    @qubes.api.method('admin.vm.feature.Remove', no_payload=True)
+    @qubes.api.method('admin.vm.feature.Remove', no_payload=True,
+        scope='local', write=True)
     @asyncio.coroutine
     def vm_feature_remove(self):
         # validation of self.arg done by qrexec-policy is enough
@@ -737,7 +783,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
             raise qubes.exc.QubesFeatureNotFoundError(self.dest, self.arg)
         self.app.save()
 
-    @qubes.api.method('admin.vm.feature.Set')
+    @qubes.api.method('admin.vm.feature.Set',
+        scope='local', write=True)
     @asyncio.coroutine
     def vm_feature_set(self, untrusted_payload):
         # validation of self.arg done by qrexec-policy is enough
@@ -749,14 +796,16 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
         self.app.save()
 
     @qubes.api.method('admin.vm.Create.{endpoint}', endpoints=(ep.name
-            for ep in pkg_resources.iter_entry_points(qubes.vm.VM_ENTRY_POINT)))
+            for ep in pkg_resources.iter_entry_points(qubes.vm.VM_ENTRY_POINT)),
+        scope='global', write=True)
     @asyncio.coroutine
     def vm_create(self, endpoint, untrusted_payload=None):
         return self._vm_create(endpoint, allow_pool=False,
             untrusted_payload=untrusted_payload)
 
     @qubes.api.method('admin.vm.CreateInPool.{endpoint}', endpoints=(ep.name
-            for ep in pkg_resources.iter_entry_points(qubes.vm.VM_ENTRY_POINT)))
+            for ep in pkg_resources.iter_entry_points(qubes.vm.VM_ENTRY_POINT)),
+        scope='global', write=True)
     @asyncio.coroutine
     def vm_create_in_pool(self, endpoint, untrusted_payload=None):
         return self._vm_create(endpoint, allow_pool=True,
@@ -846,7 +895,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
             raise
         self.app.save()
 
-    @qubes.api.method('admin.vm.Remove', no_payload=True)
+    @qubes.api.method('admin.vm.Remove', no_payload=True,
+        scope='global', write=True)
     @asyncio.coroutine
     def vm_remove(self):
         assert not self.arg
@@ -867,7 +917,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
 
     @qubes.api.method('admin.vm.device.{endpoint}.Available', endpoints=(ep.name
             for ep in pkg_resources.iter_entry_points('qubes.devices')),
-            no_payload=True)
+            no_payload=True,
+        scope='local', read=True)
     @asyncio.coroutine
     def vm_device_available(self, endpoint):
         devclass = endpoint
@@ -901,7 +952,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
 
     @qubes.api.method('admin.vm.device.{endpoint}.List', endpoints=(ep.name
             for ep in pkg_resources.iter_entry_points('qubes.devices')),
-            no_payload=True)
+            no_payload=True,
+        scope='local', read=True)
     @asyncio.coroutine
     def vm_device_list(self, endpoint):
         devclass = endpoint
@@ -932,8 +984,12 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
         return ''.join('{} {}\n'.format(ident, dev_info[ident])
             for ident in sorted(dev_info))
 
+    # Attach/Detach action can both modify persistent state (with
+    # persistent=True) and volatile state of running VM (with persistent=False).
+    # For this reason, write=True + execute=True
     @qubes.api.method('admin.vm.device.{endpoint}.Attach', endpoints=(ep.name
-            for ep in pkg_resources.iter_entry_points('qubes.devices')))
+            for ep in pkg_resources.iter_entry_points('qubes.devices')),
+        scope='local', write=True, execute=True)
     @asyncio.coroutine
     def vm_device_attach(self, endpoint, untrusted_payload):
         devclass = endpoint
@@ -972,9 +1028,13 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
         yield from self.dest.devices[devclass].attach(assignment)
         self.app.save()
 
+    # Attach/Detach action can both modify persistent state (with
+    # persistent=True) and volatile state of running VM (with persistent=False).
+    # For this reason, write=True + execute=True
     @qubes.api.method('admin.vm.device.{endpoint}.Detach', endpoints=(ep.name
             for ep in pkg_resources.iter_entry_points('qubes.devices')),
-            no_payload=True)
+            no_payload=True,
+        scope='local', write=True, execute=True)
     @asyncio.coroutine
     def vm_device_detach(self, endpoint):
         devclass = endpoint
@@ -994,7 +1054,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
         yield from self.dest.devices[devclass].detach(assignment)
         self.app.save()
 
-    @qubes.api.method('admin.vm.firewall.Get', no_payload=True)
+    @qubes.api.method('admin.vm.firewall.Get', no_payload=True,
+            scope='local', read=True)
     @asyncio.coroutine
     def vm_firewall_get(self):
         assert not self.arg
@@ -1004,7 +1065,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
         return ''.join('{}\n'.format(rule.api_rule)
             for rule in self.dest.firewall.rules)
 
-    @qubes.api.method('admin.vm.firewall.Set')
+    @qubes.api.method('admin.vm.firewall.Set',
+            scope='local', write=True)
     @asyncio.coroutine
     def vm_firewall_set(self, untrusted_payload):
         assert not self.arg
@@ -1020,7 +1082,8 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
         self.dest.firewall.rules = rules
         self.dest.firewall.save()
 
-    @qubes.api.method('admin.vm.firewall.Reload', no_payload=True)
+    @qubes.api.method('admin.vm.firewall.Reload', no_payload=True,
+            scope='local', execute=True)
     @asyncio.coroutine
     def vm_firewall_reload(self):
         assert not self.arg

+ 3 - 2
qubes/backup.py

@@ -119,6 +119,7 @@ class BackupHeader(object):
 
         :param untrusted_header_text: header content
         :type untrusted_header_text: basestring
+
         .. warning::
             This function may be exposed to not yet verified header,
             so is security critical.
@@ -864,9 +865,9 @@ def handle_streams(stream_in, streams_out, processes, size_limit=None,
     :param processes: dict of subprocess.Popen objects to monitor
     :param size_limit: int maximum data amount to process
     :param progress_callback: callable function to report progress, will be
-    given copied data size (it should accumulate internally)
+        given copied data size (it should accumulate internally)
     :return: failed process name, failed stream name, "size_limit" or None (
-    no error)
+        no error)
     '''
     buffer_size = 409600
     bytes_copied = 0

+ 2 - 2
qubes/dochelpers.py

@@ -249,7 +249,7 @@ class OptionsCheckVisitor(docutils.nodes.SparseNodeVisitor):
             While the documentation talks about a
             'SparseNodeVisitor.depart_document()' function, this function does
             not exists. (For details see implementation of
-            :py:method:`NodeVisitor.dispatch_departure()`) So we need to
+            :py:meth:`NodeVisitor.dispatch_departure()`) So we need to
             manually call this.
         '''
         if ignored_options is None:
@@ -316,7 +316,7 @@ class CommandCheckVisitor(docutils.nodes.SparseNodeVisitor):
             While the documentation talks about a
             'SparseNodeVisitor.depart_document()' function, this function does
             not exists. (For details see implementation of
-            :py:method:`NodeVisitor.dispatch_departure()`) So we need to
+            :py:meth:`NodeVisitor.dispatch_departure()`) So we need to
             manually call this.
         '''
         if self.sub_commands:

+ 1 - 1
qubes/tests/__init__.py

@@ -177,7 +177,7 @@ def expectedFailureIfTemplate(templates):
     Decorator for marking specific test as expected to fail only for some
     templates. Template name is compared as substring, so 'whonix' will
     handle both 'whonix-ws' and 'whonix-gw'.
-     templates can be either a single string, or an iterable
+    templates can be either a single string, or an iterable
     """
     def decorator(func):
         @functools.wraps(func)

+ 7 - 5
qubes/tools/__init__.py

@@ -303,11 +303,13 @@ class QubesArgumentParser(argparse.ArgumentParser):
         :py:class:`qubes.Qubes` object, just add argument for custom xml file
     :param bool want_force_root: add ``--force-root`` option
     :param mixed vmname_nargs: The number of ``VMNAME`` arguments that should be
-            consumed. Values include:
-                - N (an integer) consumes N arguments (and produces a list)
-                - '?' consumes zero or one arguments
-                - '*' consumes zero or more arguments (and produces a list)
-                - '+' consumes one or more arguments (and produces a list)
+        consumed. Values include:
+
+            - N (an integer) consumes N arguments (and produces a list)
+            - '?' consumes zero or one arguments
+            - '*' consumes zero or more arguments (and produces a list)
+            - '+' consumes one or more arguments (and produces a list)
+
     *kwargs* are passed to :py:class:`argparser.ArgumentParser`.
 
     Currenty supported options:

+ 4 - 4
qubes/vm/qubesvm.py

@@ -594,8 +594,8 @@ class QubesVM(qubes.vm.mix.net.NetVMMixin, qubes.vm.BaseVM):
 
     @property
     def block_devices(self):
-        ''' Return all :py:class:`qubes.storage.BlockDevice`s for current domain
-            for serialization in the libvirt XML template as <disk>.
+        ''' Return all :py:class:`qubes.storage.BlockDevice` for current domain
+        for serialization in the libvirt XML template as <disk>.
         '''
         for v in self.volumes.values():
             block_dev = v.block_device()
@@ -1050,7 +1050,7 @@ class QubesVM(qubes.vm.mix.net.NetVMMixin, qubes.vm.BaseVM):
 
         :param str service: service name
         :param qubes.vm.qubesvm.QubesVM source: source domain as presented to
-        this VM
+            this VM
         :param str user: username to run service as
         :param bool filter_esc: filter escape sequences to protect terminal \
             emulator
@@ -1221,7 +1221,7 @@ class QubesVM(qubes.vm.mix.net.NetVMMixin, qubes.vm.BaseVM):
         This function take care to run it as appropriate user.
 
         :param command: command to run (array for
-        :py:meth:`subprocess.check_call`)
+            :py:meth:`subprocess.check_call`)
         :param kwargs: args for :py:meth:`subprocess.check_call`
         :return: None
         '''  # pylint: disable=redefined-builtin

+ 56 - 22
qubespolicy/__init__.py

@@ -66,6 +66,8 @@ def verify_target_value(system_info, value):
     '''
     if value == '$dispvm':
         return True
+    elif value == '$adminvm':
+        return True
     elif value.startswith('$dispvm:'):
         dispvm_base = value.split(':', 1)[1]
         if dispvm_base not in system_info['domains']:
@@ -82,7 +84,7 @@ def verify_special_value(value, for_target=True):
 
     :param value: value to verify
     :param for_target: should classify target-only values as valid (
-    '$default', '$dispvm')
+        '$default', '$dispvm')
     :return: True or False
     '''
     # pylint: disable=too-many-return-statements
@@ -93,6 +95,8 @@ def verify_special_value(value, for_target=True):
         return True
     elif value == '$anyvm':
         return True
+    elif value == '$adminvm':
+        return True
     elif value.startswith('$dispvm:') and for_target:
         return True
     elif value == '$dispvm' and for_target:
@@ -121,11 +125,11 @@ class PolicyRule(object):
         self.filename = filename
 
         try:
-            self.source, self.target, self.full_action = line.split()
+            self.source, self.target, self.full_action = line.split(maxsplit=2)
         except ValueError:
             raise PolicySyntaxError(filename, lineno, 'wrong number of fields')
 
-        (action, *params) = self.full_action.split(',')
+        (action, *params) = self.full_action.replace(',', ' ').split()
         try:
             self.action = Action[action]
         except KeyError:
@@ -184,8 +188,9 @@ class PolicyRule(object):
                 'allow action for $default rule must specify target= option')
 
         if self.override_target is not None:
-            if self.override_target.startswith('$') and not \
-                    self.override_target.startswith('$dispvm'):
+            if self.override_target.startswith('$') and \
+                    not self.override_target.startswith('$dispvm') and \
+                    self.override_target != '$adminvm':
                 raise PolicySyntaxError(filename, lineno,
                     'target= option needs to name specific target')
 
@@ -197,7 +202,7 @@ class PolicyRule(object):
 
         :param system_info: information about the system
         :param policy_value: value from qrexec policy (either self.source or
-        self.target)
+            self.target)
         :param value: value to be compared (source or target)
         :return: True or False
         '''
@@ -216,11 +221,19 @@ class PolicyRule(object):
         if not verify_target_value(system_info, value):
             return False
 
+        # handle $adminvm keyword
+        if policy_value == 'dom0':
+            # TODO: log a warning in Qubes 4.1
+            policy_value = '$adminvm'
+
+        if value == 'dom0':
+            value = '$adminvm'
+
         # allow any _valid_, non-dom0 target
         if policy_value == '$anyvm':
-            return value != 'dom0'
+            return value != '$adminvm'
 
-        # exact match, including $dispvm*
+        # exact match, including $dispvm* and $adminvm
         if value == policy_value:
             return True
 
@@ -229,6 +242,11 @@ class PolicyRule(object):
         if value.startswith('$dispvm'):
             return False
 
+        # require $adminvm to be matched explicitly (not through $tag or $type)
+        # - if not matched already, reject it
+        if value == '$adminvm':
+            return False
+
         # at this point, value name a specific target
         domain_info = system_info['domains'][value]
 
@@ -247,8 +265,8 @@ class PolicyRule(object):
         Check if given (source, target) matches this policy line.
 
         :param system_info: information about the system - available VMs,
-        their types, labels, tags etc. as returned by
-        :py:func:`app_to_system_info`
+            their types, labels, tags etc. as returned by
+            :py:func:`app_to_system_info`
         :param source: name of the source VM
         :param target: name of the target VM, or None if not specified
         :return: True or False
@@ -293,6 +311,8 @@ class PolicyRule(object):
             except KeyError:
                 # TODO log a warning?
                 pass
+        elif self.target == '$adminvm':
+            yield self.target
         elif self.target == '$dispvm':
             yield self.target
         else:
@@ -372,12 +392,14 @@ class PolicyAction(object):
     def execute(self, caller_ident):
         ''' Execute allowed service call
 
-        :param caller_ident: Service caller ident (`process_ident,source_name,
-        source_id`)
+        :param caller_ident: Service caller ident
+            (`process_ident,source_name, source_id`)
         '''
         assert self.action == Action.allow
         assert self.target is not None
 
+        if self.target == '$adminvm':
+            self.target = 'dom0'
         if self.target == 'dom0':
             cmd = '{multiplexer} {service} {source} {original_target}'.format(
                 multiplexer=QUBES_RPC_MULTIPLEXER_PATH,
@@ -451,17 +473,20 @@ class Policy(object):
     >>> policy = Policy('some-service')
     >>> action = policy.evaluate(system_info, 'source-name', 'target-name')
     >>> if action.action == Action.ask:
-            (... ask the user, see action.targets_for_ask ...)
+    >>>     # ... ask the user, see action.targets_for_ask ...
     >>>     action.handle_user_response(response, target_chosen_by_user)
     >>> action.execute('process-ident')
 
     '''
 
-    def __init__(self, service):
-        policy_file = os.path.join(POLICY_DIR, service)
+    def __init__(self, service, policy_dir=POLICY_DIR):
+        policy_file = os.path.join(policy_dir, service)
         if not os.path.exists(policy_file):
             # fallback to policy without specific argument set (if any)
-            policy_file = os.path.join(POLICY_DIR, service.split('+')[0])
+            policy_file = os.path.join(policy_dir, service.split('+')[0])
+
+        #: policy storage directory
+        self.policy_dir = policy_dir
 
         #: service name
         self.service = service
@@ -493,7 +518,7 @@ class Policy(object):
                     include_path = line.split(':', 1)[1]
                     # os.path.join will leave include_path unchanged if it's
                     # already absolute
-                    include_path = os.path.join(POLICY_DIR, include_path)
+                    include_path = os.path.join(self.policy_dir, include_path)
                     self.load_policy_file(include_path)
                 else:
                     self.policy_rules.append(PolicyRule(line, path, lineno))
@@ -582,6 +607,15 @@ class Policy(object):
                     'policy define \'allow\' action at {}:{} but no target is '
                     'specified by caller or policy'.format(
                         rule.filename, rule.lineno))
+            if actual_target == '$dispvm':
+                if system_info['domains'][source]['default_dispvm'] is None:
+                    raise AccessDenied(
+                        'policy define \'allow\' action to $dispvm at {}:{} '
+                        'but no DispVM base is set for this VM'.format(
+                            rule.filename, rule.lineno))
+                actual_target = '$dispvm:' + \
+                    system_info['domains'][source]['default_dispvm']
+
             return PolicyAction(self.service, source,
                 actual_target, rule, target)
         else:
@@ -634,11 +668,11 @@ def get_system_info():
     data is nested dict structure with this structure:
 
     - domains:
-      - <domain name>:
-        - tags: list of tags
-        - type: domain type
-        - dispvm_allowed: should DispVM based on this VM be allowed
-        - default_dispvm: name of default AppVM for DispVMs started from here
+       - `<domain name>`:
+          - tags: list of tags
+          - type: domain type
+          - dispvm_allowed: should DispVM based on this VM be allowed
+          - default_dispvm: name of default AppVM for DispVMs started from here
 
     '''
 

+ 122 - 0
qubespolicy/graph.py

@@ -0,0 +1,122 @@
+# -*- encoding: utf8 -*-
+#
+# The Qubes OS Project, http://www.qubes-os.org
+#
+# Copyright (C) 2017 Marek Marczykowski-Górecki
+#                               <marmarek@invisiblethingslab.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, see <http://www.gnu.org/licenses/>.
+
+import argparse
+import json
+import os
+
+import sys
+
+import qubespolicy
+
+parser = argparse.ArgumentParser(description='Graph qrexec policy')
+parser.add_argument('--include-ask', action='store_true',
+    help='Include `ask` action in graph')
+parser.add_argument('--source', action='store', nargs='+',
+    help='Limit graph to calls from *source*')
+parser.add_argument('--target', action='store', nargs='+',
+    help='Limit graph to calls to *target*')
+parser.add_argument('--service', action='store', nargs='+',
+    help='Limit graph to *service*')
+parser.add_argument('--output', action='store',
+    help='Write to *output* instead of stdout')
+parser.add_argument('--policy-dir', action='store',
+    default=qubespolicy.POLICY_DIR,
+    help='Look for policy in *policy-dir*')
+parser.add_argument('--system-info', action='store',
+    help='Load system information from file instead of querying qubesd')
+parser.add_argument('--skip-labels', action='store_true',
+    help='Do not include service names on the graph, also deduplicate '
+         'connections.')
+
+def handle_single_action(args, action):
+    '''Get single policy action and output (or not) a line to add'''
+    if args.skip_labels:
+        service = ''
+    else:
+        service = action.service
+    if action.action == qubespolicy.Action.ask:
+        if args.include_ask:
+            # handle forced target=
+            if len(action.targets_for_ask) == 1:
+                return '  "{}" -> "{}" [label="{}" color=orange];\n'.format(
+                    action.source, action.targets_for_ask[0], service)
+            return '  "{}" -> "{}" [label="{}" color=orange];\n'.format(
+                action.source, action.original_target, service)
+    elif action.action == qubespolicy.Action.allow:
+        return '  "{}" -> "{}" [label="{}" color=red];\n'.format(
+                action.source, action.target, service)
+    return ''
+
+def main(args=None):
+    args = parser.parse_args(args)
+
+    output = sys.stdout
+    if args.output:
+        output = open(args.output, 'w')
+
+    if args.system_info:
+        with open(args.system_info) as f_system_info:
+            system_info = json.load(f_system_info)
+    else:
+        system_info = qubespolicy.get_system_info()
+
+    sources = list(system_info['domains'].keys())
+    if args.source:
+        sources = args.source
+
+    targets = list(system_info['domains'].keys())
+    if args.target:
+        targets = args.target
+    else:
+        targets.append('$dispvm')
+        targets.extend('$dispvm:' + dom for dom in system_info['domains']
+            if system_info['domains'][dom]['dispvm_allowed'])
+
+    connections = set()
+
+    output.write('digraph g {\n')
+    for service in os.listdir(args.policy_dir):
+        if os.path.isdir(os.path.join(args.policy_dir, service)):
+            continue
+        if args.service and service not in args.service and \
+                not any(service.startswith(srv + '+') for srv in args.service):
+            continue
+
+        policy = qubespolicy.Policy(service, args.policy_dir)
+        for source in sources:
+            for target in targets:
+                try:
+                    action = policy.evaluate(system_info, source, target)
+                    line = handle_single_action(args, action)
+                    if line in connections:
+                        continue
+                    if line:
+                        output.write(line)
+                    connections.add(line)
+                except qubespolicy.AccessDenied:
+                    continue
+
+    output.write('}\n')
+    if args.output:
+        output.close()
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 63 - 13
qubespolicy/tests/__init__.py

@@ -31,7 +31,7 @@ tmp_policy_dir = '/tmp/policy'
 system_info = {
     'domains': {
         'dom0': {
-            'tags': [],
+            'tags': ['dom0-tag'],
             'type': 'AdminVM',
             'default_dispvm': 'default-dvm',
             'dispvm_allowed': False,
@@ -102,6 +102,8 @@ class TC_00_PolicyRule(qubes.tests.QubesTestCase):
             qubespolicy.verify_target_value(system_info, 'test-template'))
         self.assertTrue(
             qubespolicy.verify_target_value(system_info, 'test-standalone'))
+        self.assertTrue(
+            qubespolicy.verify_target_value(system_info, '$adminvm'))
         self.assertFalse(
             qubespolicy.verify_target_value(system_info, 'no-such-vm'))
         self.assertFalse(
@@ -127,6 +129,8 @@ class TC_00_PolicyRule(qubes.tests.QubesTestCase):
             for_target=False))
         self.assertTrue(qubespolicy.verify_special_value('$type:AppVM',
             for_target=False))
+        self.assertTrue(qubespolicy.verify_special_value('$adminvm',
+            for_target=False))
         self.assertFalse(qubespolicy.verify_special_value('$default',
             for_target=False))
         self.assertFalse(qubespolicy.verify_special_value('$dispvm',
@@ -155,15 +159,16 @@ class TC_00_PolicyRule(qubes.tests.QubesTestCase):
         self.assertIsNone(line.default_target)
 
     def test_021_line_simple(self):
+        # also check spaces in action field
         line = qubespolicy.PolicyRule(
-            '$tag:tag1 $type:AppVM ask,target=test-vm2,user=user',
+            '$tag:tag1 $type:AppVM ask, target=test-vm2, user=user',
             'filename', 12)
         self.assertEqual(line.filename, 'filename')
         self.assertEqual(line.lineno, 12)
         self.assertEqual(line.action, qubespolicy.Action.ask)
         self.assertEqual(line.source, '$tag:tag1')
         self.assertEqual(line.target, '$type:AppVM')
-        self.assertEqual(line.full_action, 'ask,target=test-vm2,user=user')
+        self.assertEqual(line.full_action, 'ask, target=test-vm2, user=user')
         self.assertEqual(line.override_target, 'test-vm2')
         self.assertEqual(line.override_user, 'user')
         self.assertIsNone(line.default_target)
@@ -196,6 +201,20 @@ class TC_00_PolicyRule(qubes.tests.QubesTestCase):
         self.assertIsNone(line.override_user)
         self.assertEqual(line.default_target, 'test-vm1')
 
+    def test_024_line_simple(self):
+        line = qubespolicy.PolicyRule(
+            '$anyvm $adminvm ask,default_target=$adminvm',
+            'filename', 12)
+        self.assertEqual(line.filename, 'filename')
+        self.assertEqual(line.lineno, 12)
+        self.assertEqual(line.action, qubespolicy.Action.ask)
+        self.assertEqual(line.source, '$anyvm')
+        self.assertEqual(line.target, '$adminvm')
+        self.assertEqual(line.full_action, 'ask,default_target=$adminvm')
+        self.assertIsNone(line.override_target)
+        self.assertIsNone(line.override_user)
+        self.assertEqual(line.default_target, '$adminvm')
+
     def test_030_line_invalid(self):
         invalid_lines = [
             '$dispvm $default allow',  # $dispvm can't be a source
@@ -235,6 +254,9 @@ class TC_00_PolicyRule(qubes.tests.QubesTestCase):
         self.assertTrue(is_match_single(system_info,
             '$anyvm', '$dispvm:default-dvm'))
         self.assertTrue(is_match_single(system_info, '$dispvm', '$dispvm'))
+        self.assertTrue(is_match_single(system_info, '$adminvm', '$adminvm'))
+        self.assertTrue(is_match_single(system_info, '$adminvm', 'dom0'))
+        self.assertTrue(is_match_single(system_info, 'dom0', '$adminvm'))
         self.assertTrue(is_match_single(system_info, 'dom0', 'dom0'))
         self.assertTrue(is_match_single(system_info,
             '$dispvm:default-dvm', '$dispvm:default-dvm'))
@@ -253,6 +275,15 @@ class TC_00_PolicyRule(qubes.tests.QubesTestCase):
         self.assertFalse(is_match_single(system_info,
             '$dispvm:test-vm1', '$dispvm:test-vm1'))
         self.assertFalse(is_match_single(system_info, '$anyvm', 'dom0'))
+        self.assertFalse(is_match_single(system_info, '$anyvm', '$adminvm'))
+        self.assertFalse(is_match_single(system_info,
+            '$tag:dom0-tag', '$adminvm'))
+        self.assertFalse(is_match_single(system_info,
+            '$type:AdminVM', '$adminvm'))
+        self.assertFalse(is_match_single(system_info,
+            '$tag:dom0-tag', 'dom0'))
+        self.assertFalse(is_match_single(system_info,
+            '$type:AdminVM', 'dom0'))
         self.assertFalse(is_match_single(system_info, '$tag:tag1', 'dom0'))
         self.assertFalse(is_match_single(system_info, '$anyvm', '$tag:tag1'))
         self.assertFalse(is_match_single(system_info, '$anyvm', '$type:AppVM'))
@@ -338,6 +369,13 @@ class TC_00_PolicyRule(qubes.tests.QubesTestCase):
             line.expand_override_target(system_info, 'test-no-dvm'),
             'dom0')
 
+    def test_075_expand_override_target_dom0(self):
+        line = qubespolicy.PolicyRule(
+            '$anyvm $anyvm allow,target=$adminvm')
+        self.assertEqual(
+            line.expand_override_target(system_info, 'test-no-dvm'),
+            '$adminvm')
+
 
 class TC_10_PolicyAction(qubes.tests.QubesTestCase):
     def test_000_init(self):
@@ -485,7 +523,6 @@ class TC_10_PolicyAction(qubes.tests.QubesTestCase):
             [unittest.mock.call('test-vm2', 'internal.vm.Start')])
         self.assertEqual(mock_subprocess.mock_calls, [])
 
-@unittest.mock.patch('qubespolicy.POLICY_DIR', tmp_policy_dir)
 class TC_20_Policy(qubes.tests.QubesTestCase):
 
     def setUp(self):
@@ -505,7 +542,7 @@ class TC_20_Policy(qubes.tests.QubesTestCase):
             f.write('test-vm2 test-vm3 ask\n')
             f.write('   # comment  \n')
             f.write('$anyvm $anyvm ask\n')
-        policy = qubespolicy.Policy('test.service')
+        policy = qubespolicy.Policy('test.service', tmp_policy_dir)
         self.assertEqual(policy.service, 'test.service')
         self.assertEqual(len(policy.policy_rules), 3)
         self.assertEqual(policy.policy_rules[0].source, 'test-vm1')
@@ -515,7 +552,7 @@ class TC_20_Policy(qubes.tests.QubesTestCase):
 
     def test_001_not_existent(self):
         with self.assertRaises(qubespolicy.AccessDenied):
-            qubespolicy.Policy('no-such.service')
+            qubespolicy.Policy('no-such.service', tmp_policy_dir)
 
     def test_002_include(self):
         with open(os.path.join(tmp_policy_dir, 'test.service'), 'w') as f:
@@ -524,7 +561,7 @@ class TC_20_Policy(qubes.tests.QubesTestCase):
             f.write('$anyvm $anyvm deny\n')
         with open(os.path.join(tmp_policy_dir, 'test.service2'), 'w') as f:
             f.write('test-vm3 $default allow,target=test-vm2\n')
-        policy = qubespolicy.Policy('test.service')
+        policy = qubespolicy.Policy('test.service', tmp_policy_dir)
         self.assertEqual(policy.service, 'test.service')
         self.assertEqual(len(policy.policy_rules), 3)
         self.assertEqual(policy.policy_rules[0].source, 'test-vm1')
@@ -557,7 +594,7 @@ class TC_20_Policy(qubes.tests.QubesTestCase):
             f.write('test-vm2 $tag:tag2 allow\n')
             f.write('$type:AppVM $default allow,target=test-vm3\n')
             f.write('$tag:tag1 $type:AppVM allow\n')
-        policy = qubespolicy.Policy('test.service')
+        policy = qubespolicy.Policy('test.service', tmp_policy_dir)
         self.assertEqual(policy.find_matching_rule(
             system_info, 'test-vm1', 'test-vm2'), policy.policy_rules[0])
         self.assertEqual(policy.find_matching_rule(
@@ -593,7 +630,7 @@ class TC_20_Policy(qubes.tests.QubesTestCase):
             f.write('$tag:tag1 $type:AppVM allow\n')
             f.write('test-no-dvm $dispvm allow\n')
             f.write('test-standalone $dispvm allow\n')
-        policy = qubespolicy.Policy('test.service')
+        policy = qubespolicy.Policy('test.service', tmp_policy_dir)
         self.assertCountEqual(policy.collect_targets_for_ask(system_info,
             'test-vm1'), ['test-vm1', 'test-vm2', 'test-vm3',
                 '$dispvm:test-vm3',
@@ -614,7 +651,7 @@ class TC_20_Policy(qubes.tests.QubesTestCase):
         with open(os.path.join(tmp_policy_dir, 'test.service'), 'w') as f:
             f.write('test-vm1 test-vm2 allow\n')
 
-        policy = qubespolicy.Policy('test.service')
+        policy = qubespolicy.Policy('test.service', tmp_policy_dir)
         action = policy.evaluate(system_info, 'test-vm1', 'test-vm2')
         self.assertEqual(action.rule, policy.policy_rules[0])
         self.assertEqual(action.action, qubespolicy.Action.allow)
@@ -633,7 +670,7 @@ class TC_20_Policy(qubes.tests.QubesTestCase):
             f.write('$tag:tag2 $anyvm allow\n')
             f.write('test-vm3 $anyvm deny\n')
 
-        policy = qubespolicy.Policy('test.service')
+        policy = qubespolicy.Policy('test.service', tmp_policy_dir)
         action = policy.evaluate(system_info, 'test-vm1', '$default')
         self.assertEqual(action.rule, policy.policy_rules[1])
         self.assertEqual(action.action, qubespolicy.Action.allow)
@@ -655,7 +692,7 @@ class TC_20_Policy(qubes.tests.QubesTestCase):
             f.write('$tag:tag2 $anyvm allow\n')
             f.write('test-vm3 $anyvm deny\n')
 
-        policy = qubespolicy.Policy('test.service')
+        policy = qubespolicy.Policy('test.service', tmp_policy_dir)
         action = policy.evaluate(system_info, 'test-standalone', 'test-vm2')
         self.assertEqual(action.rule, policy.policy_rules[2])
         self.assertEqual(action.action, qubespolicy.Action.ask)
@@ -676,7 +713,7 @@ class TC_20_Policy(qubes.tests.QubesTestCase):
             f.write('$tag:tag2 $anyvm allow\n')
             f.write('test-vm3 $anyvm deny\n')
 
-        policy = qubespolicy.Policy('test.service')
+        policy = qubespolicy.Policy('test.service', tmp_policy_dir)
         action = policy.evaluate(system_info, 'test-standalone', 'test-vm3')
         self.assertEqual(action.rule, policy.policy_rules[3])
         self.assertEqual(action.action, qubespolicy.Action.ask)
@@ -688,6 +725,19 @@ class TC_20_Policy(qubes.tests.QubesTestCase):
                 'default-dvm', '$dispvm:default-dvm', 'test-invalid-dvm',
                 'test-no-dvm', 'test-template', 'test-standalone'])
 
+    def test_034_eval_resolve_dispvm(self):
+        with open(os.path.join(tmp_policy_dir, 'test.service'), 'w') as f:
+            f.write('test-vm3 $dispvm allow\n')
+
+        policy = qubespolicy.Policy('test.service', tmp_policy_dir)
+        action = policy.evaluate(system_info, 'test-vm3', '$dispvm')
+        self.assertEqual(action.rule, policy.policy_rules[0])
+        self.assertEqual(action.action, qubespolicy.Action.allow)
+        self.assertEqual(action.target, '$dispvm:default-dvm')
+        self.assertEqual(action.original_target, '$dispvm')
+        self.assertEqual(action.service, 'test.service')
+        self.assertIsNone(action.targets_for_ask)
+
 
 class TC_30_Misc(qubes.tests.QubesTestCase):
     @unittest.mock.patch('socket.socket')

+ 7 - 1
rpm_spec/core-dom0.spec

@@ -213,7 +213,9 @@ fi
 /usr/bin/qubesd*
 /usr/bin/qrexec-policy
 /usr/bin/qrexec-policy-agent
+/usr/bin/qrexec-policy-graph
 
+%{_mandir}/man1/qrexec-policy-graph.1*
 %{_mandir}/man1/qubes*.1*
 
 %dir %{python3_sitelib}/qubes-*.egg-info
@@ -372,6 +374,7 @@ fi
 %{python3_sitelib}/qubespolicy/gtkhelpers.py
 %{python3_sitelib}/qubespolicy/rpcconfirmation.py
 %{python3_sitelib}/qubespolicy/utils.py
+%{python3_sitelib}/qubespolicy/graph.py
 
 %dir %{python3_sitelib}/qubespolicy/tests
 %dir %{python3_sitelib}/qubespolicy/tests/__pycache__
@@ -410,7 +413,10 @@ fi
 /etc/xen/scripts/block-origin
 /etc/xen/scripts/vif-route-qubes
 %attr(0664,root,qubes) %config(noreplace) /etc/qubes-rpc/policy/admin.*
-%attr(0664,root,qubes) %config(noreplace) /etc/qubes-rpc/policy/include/admin-all
+%attr(0664,root,qubes) %config(noreplace) /etc/qubes-rpc/policy/include/admin-local-ro
+%attr(0664,root,qubes) %config(noreplace) /etc/qubes-rpc/policy/include/admin-local-rwx
+%attr(0664,root,qubes) %config(noreplace) /etc/qubes-rpc/policy/include/admin-global-ro
+%attr(0664,root,qubes) %config(noreplace) /etc/qubes-rpc/policy/include/admin-global-rwx
 %attr(0664,root,qubes) %config(noreplace) /etc/qubes-rpc/policy/qubes.FeaturesRequest
 %attr(0664,root,qubes) %config(noreplace) /etc/qubes-rpc/policy/qubes.Filecopy
 %attr(0664,root,qubes) %config(noreplace) /etc/qubes-rpc/policy/qubes.GetImageRGBA

+ 1 - 0
setup.py

@@ -34,6 +34,7 @@ if __name__ == '__main__':
             'console_scripts': list(get_console_scripts()) + [
                 'qrexec-policy = qubespolicy.cli:main',
                 'qrexec-policy-agent = qubespolicy.agent:main',
+                'qrexec-policy-graph = qubespolicy.graph:main',
             ],
             'qubes.vm': [
                 'AppVM = qubes.vm.appvm:AppVM',