This was requested in issue 301. Before this patch, requests to
“/2/query/*” and “/2/instances/*/console” would require authentication
with a user with write access. Since that is not strictly necessary, a
new user option named “query” is added.
Console information can also be retrieved as a normal query, therefore
the change applies there too.
This was the first user option to be added after “write”, therefore
quite a few changes were necessary. Documentation, including NEWS, is
updated as well.
NEWS | 3 +
doc/rapi.rst | 50 +++++++++++--
lib/rapi/__init__.py | 6 ++
lib/rapi/rlib2.py | 5 +-
test/
ganeti.server.rapi_unittest.py | 133 +++++++++++++++++++----------------
5 files changed, 127 insertions(+), 70 deletions(-)
diff --git a/NEWS b/NEWS
index 45f081c..fd99f90 100644
--- a/NEWS
+++ b/NEWS
@@ -29,6 +29,9 @@ Version 2.7.0 beta1
``gnt-node add`` now invokes a new tool on the destination node, named
``prepare-node-join``, to configure the SSH daemon. Paramiko is no
longer necessary to configure nodes' SSH daemons via ``gnt-node add``.
+- A new user option, :pyeval:`rapi.RAPI_ACCESS_QUERY`, has been added
+ for RAPI users. It allows granting query permissions to a specific
+ user without giving :pyeval:`rapi.RAPI_ACCESS_WRITE` permissions.
Version 2.6.1
diff --git a/doc/rapi.rst b/doc/rapi.rst
index c46051a..e9bc17d 100644
--- a/doc/rapi.rst
+++ b/doc/rapi.rst
@@ -24,12 +24,24 @@ Users and passwords
``/var/lib/ganeti/rapi/users``) on startup. Changes to the file will be
read automatically.
-Each line consists of two or three fields separated by whitespace. The
-first two fields are for username and password. The third field is
-optional and can be used to specify per-user options. Currently,
-``write`` is the only option supported and enables the user to execute
-operations modifying the cluster. Lines starting with the hash sign
-(``#``) are treated as comments.
+Lines starting with the hash sign (``#``) are treated as comments. Each
+line consists of two or three fields separated by whitespace. The first
+two fields are for username and password. The third field is optional
+and can be used to specify per-user options (separated by comma without
+spaces). Available options:
+
+.. pyassert::
+
+ rapi.RAPI_ACCESS_ALL == set([
+ rapi.RAPI_ACCESS_WRITE,
+ rapi.RAPI_ACCESS_QUERY,
+ ])
+
+:pyeval:`rapi.RAPI_ACCESS_WRITE`
+ Enables the user to execute operations modifying the cluster. Implies
+ :pyeval:`rapi.RAPI_ACCESS_QUERY` access.
+:pyeval:`rapi.RAPI_ACCESS_QUERY`
+ Allow access to operations querying for information.
Passwords can either be written in clear text or as a hash. Clear text
passwords may not start with an opening brace (``{``) or they must be
@@ -51,6 +63,12 @@ Example::
# Hashed password for Jessica
jessica {HA1}7046452df2cbb530877058712cf17bd4 write
+ # Monitoring can query for values
+ monitoring {HA1}ec018ffe72b8e75bb4d508ed5b6d079c query
+
+ # A user who can query and write
+ superuser {HA1}ec018ffe72b8e75bb4d508ed5b6d079c query,write
+
.. [#pwhash] Using the MD5 hash of username, realm and password is
described in :rfc:`2617` ("HTTP Authentication"), sections 3.2.2.2
@@ -1121,7 +1139,15 @@ Job result:
Request information for connecting to instance's console.
-Supports the following commands: ``GET``.
+.. pyassert::
+
+ not (hasattr(rlib2.R_2_instances_name_console, "PUT") or
+ hasattr(rlib2.R_2_instances_name_console, "POST") or
+ hasattr(rlib2.R_2_instances_name_console, "DELETE"))
+
+Supports the following commands: ``GET``. Requires authentication with
+one of the following options:
+:pyeval:`utils.CommaJoin(rlib2.R_2_instances_name_console.GET_ACCESS)`.
``GET``
~~~~~~~
@@ -1640,7 +1666,15 @@ pages and using ``/2/query/[resource]/fields``. The resource is one of
:pyeval:`utils.CommaJoin(constants.QR_VIA_RAPI)`. See the :doc:`query2
design document <design-query2>` for more details.
-Supports the following commands: ``GET``, ``PUT``.
+.. pyassert::
+
+ (rlib2.R_2_query.GET_ACCESS == rlib2.R_2_query.PUT_ACCESS and
+ not (hasattr(rlib2.R_2_query, "POST") or
+ hasattr(rlib2.R_2_query, "DELETE")))
+
+Supports the following commands: ``GET``, ``PUT``. Requires
+authentication with one of the following options:
+:pyeval:`utils.CommaJoin(rlib2.R_2_query.GET_ACCESS)`.
``GET``
~~~~~~~
diff --git a/lib/rapi/__init__.py b/lib/rapi/__init__.py
index 28d6ead..49a97a8 100644
--- a/lib/rapi/__init__.py
+++ b/lib/rapi/__init__.py
@@ -21,3 +21,9 @@
"""Ganeti RAPI module"""
RAPI_ACCESS_WRITE = "write"
+RAPI_ACCESS_QUERY = "query"
+
+RAPI_ACCESS_ALL = frozenset([
+ RAPI_ACCESS_WRITE,
+ RAPI_ACCESS_QUERY,
+ ])
diff --git a/lib/rapi/rlib2.py b/lib/rapi/rlib2.py
index d31a5b4..e47cf24 100644
--- a/lib/rapi/rlib2.py
+++ b/lib/rapi/rlib2.py
@@ -1226,7 +1226,7 @@ class R_2_instances_name_console(baserlib.ResourceBase):
"""/2/instances/[instance_name]/console resource.
"""
- GET_ACCESS = [rapi.RAPI_ACCESS_WRITE]
+ GET_ACCESS = [rapi.RAPI_ACCESS_WRITE, rapi.RAPI_ACCESS_QUERY]
GET_OPCODE = opcodes.OpInstanceConsole
def GET(self):
@@ -1278,7 +1278,8 @@ class R_2_query(baserlib.ResourceBase):
"""
# Results might contain sensitive information
- GET_ACCESS = [rapi.RAPI_ACCESS_WRITE]
+ GET_ACCESS = [rapi.RAPI_ACCESS_WRITE, rapi.RAPI_ACCESS_QUERY]
+ PUT_ACCESS = GET_ACCESS
GET_OPCODE = opcodes.OpQuery
PUT_OPCODE = opcodes.OpQuery
diff --git a/test/
ganeti.server.rapi_unittest.py b/test/
ganeti.server.rapi_unittest.py
index 618a44e..cca3a55 100755
--- a/test/
ganeti.server.rapi_unittest.py
+++ b/test/
ganeti.server.rapi_unittest.py
@@ -168,67 +168,80 @@ class TestRemoteApiHandler(unittest.TestCase):
else:
return None
- def _LookupUserWithWrite(name):
- if name == username:
- return http.auth.PasswordFileUser(name, password, [
- rapi.RAPI_ACCESS_WRITE,
- ])
- else:
- return None
-
- for qr in constants.QR_VIA_RAPI:
- # The /2/query resource has somewhat special rules for authentication as
- # it can be used to retrieve critical information
- path = "/2/query/%s" % qr
-
- for method in rapi.baserlib._SUPPORTED_METHODS:
- # No authorization
- (code, _, _) = self._Test(method, path, "", "")
-
- if method in (http.HTTP_DELETE, http.HTTP_POST):
- self.assertEqual(code, http.HttpNotImplemented.code)
- continue
-
- self.assertEqual(code, http.HttpUnauthorized.code)
-
- # Incorrect user
- (code, _, _) = self._Test(method, path, header_fn(True), "",
- user_fn=self._LookupWrongUser)
- self.assertEqual(code, http.HttpUnauthorized.code)
-
- # User has no write access, but the password is correct
- (code, _, _) = self._Test(method, path, header_fn(True), "",
- user_fn=_LookupUserNoWrite)
- self.assertEqual(code, http.HttpForbidden.code)
-
- # Wrong password and no write access
- (code, _, _) = self._Test(method, path, header_fn(False), "",
- user_fn=_LookupUserNoWrite)
- self.assertEqual(code, http.HttpUnauthorized.code)
-
- # Wrong password with write access
- (code, _, _) = self._Test(method, path, header_fn(False), "",
- user_fn=_LookupUserWithWrite)
- self.assertEqual(code, http.HttpUnauthorized.code)
-
- # Prepare request information
- if method == http.HTTP_PUT:
- reqpath = path
- body = serializer.DumpJson({
- "fields": ["name"],
- })
- elif method == http.HTTP_GET:
- reqpath = "%s?fields=name" % path
- body = ""
+ for access in [rapi.RAPI_ACCESS_WRITE, rapi.RAPI_ACCESS_QUERY]:
+ def _LookupUserWithWrite(name):
+ if name == username:
+ return http.auth.PasswordFileUser(name, password, [
+ access,
+ ])
else:
- self.fail("Unknown method '%s'" % method)
-
- # User has write access, password is correct
- (code, _, data) = self._Test(method, reqpath, header_fn(True), body,
- user_fn=_LookupUserWithWrite,
- luxi_client=_FakeLuxiClientForQuery)
- self.assertEqual(code, http.HTTP_OK)
- self.assertTrue(objects.QueryResponse.FromDict(data))
+ return None
+
+ for qr in constants.QR_VIA_RAPI:
+ # The /2/query resource has somewhat special rules for authentication as
+ # it can be used to retrieve critical information
+ path = "/2/query/%s" % qr
+
+ for method in rapi.baserlib._SUPPORTED_METHODS:
+ # No authorization
+ (code, _, _) = self._Test(method, path, "", "")
+
+ if method in (http.HTTP_DELETE, http.HTTP_POST):
+ self.assertEqual(code, http.HttpNotImplemented.code)
+ continue
+
+ self.assertEqual(code, http.HttpUnauthorized.code)
+
+ # Incorrect user
+ (code, _, _) = self._Test(method, path, header_fn(True), "",
+ user_fn=self._LookupWrongUser)
+ self.assertEqual(code, http.HttpUnauthorized.code)
+
+ # User has no write access, but the password is correct
+ (code, _, _) = self._Test(method, path, header_fn(True), "",
+ user_fn=_LookupUserNoWrite)
+ self.assertEqual(code, http.HttpForbidden.code)
+
+ # Wrong password and no write access
+ (code, _, _) = self._Test(method, path, header_fn(False), "",
+ user_fn=_LookupUserNoWrite)
+ self.assertEqual(code, http.HttpUnauthorized.code)
+
+ # Wrong password with write access
+ (code, _, _) = self._Test(method, path, header_fn(False), "",
+ user_fn=_LookupUserWithWrite)
+ self.assertEqual(code, http.HttpUnauthorized.code)
+
+ # Prepare request information
+ if method == http.HTTP_PUT:
+ reqpath = path
+ body = serializer.DumpJson({
+ "fields": ["name"],
+ })
+ elif method == http.HTTP_GET:
+ reqpath = "%s?fields=name" % path
+ body = ""
+ else:
+ self.fail("Unknown method '%s'" % method)
+
+ # User has write access, password is correct
+ (code, _, data) = self._Test(method, reqpath, header_fn(True), body,
+ user_fn=_LookupUserWithWrite,
+ luxi_client=_FakeLuxiClientForQuery)
+ self.assertEqual(code, http.HTTP_OK)
+ self.assertTrue(objects.QueryResponse.FromDict(data))
+
+ def testConsole(self):
+ path = "/2/instances/
inst1.example.com/console"
+
+ for method in rapi.baserlib._SUPPORTED_METHODS:
+ # No authorization
+ (code, _, _) = self._Test(method, path, "", "")
+
+ if method == http.HTTP_GET:
+ self.assertEqual(code, http.HttpUnauthorized.code)
+ else:
+ self.assertEqual(code, http.HttpNotImplemented.code)
class _FakeLuxiClientForQuery:
--
1.7.7.3