rapi: Add new user option for querying
authorMichael Hanselmann <hansmi@google.com>
Thu, 8 Nov 2012 18:31:14 +0000 (19:31 +0100)
committerMichael Hanselmann <hansmi@google.com>
Fri, 16 Nov 2012 13:57:51 +0000 (14:57 +0100)
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 “read” 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.

Signed-off-by: Michael Hanselmann <hansmi@google.com>
Reviewed-by: Guido Trotter <ultrotter@google.com>

NEWS
doc/rapi.rst
lib/rapi/__init__.py
lib/rapi/rlib2.py
test/ganeti.server.rapi_unittest.py

diff --git a/NEWS b/NEWS
index 45f081c..0e4fa66 100644 (file)
--- a/NEWS
+++ b/NEWS
@@ -29,6 +29,10 @@ 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_READ`, has been added
+  for RAPI users. It allows granting permissions to query for
+  information to a specific user without giving
+  :pyeval:`rapi.RAPI_ACCESS_WRITE` permissions.
 
 
 Version 2.6.1
index c46051a..179edfd 100644 (file)
@@ -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_READ,
+    ])
+
+:pyeval:`rapi.RAPI_ACCESS_WRITE`
+  Enables the user to execute operations modifying the cluster. Implies
+  :pyeval:`rapi.RAPI_ACCESS_READ` access.
+:pyeval:`rapi.RAPI_ACCESS_READ`
+  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``
 ~~~~~~~
index 28d6ead..be08b2a 100644 (file)
@@ -21,3 +21,9 @@
 """Ganeti RAPI module"""
 
 RAPI_ACCESS_WRITE = "write"
+RAPI_ACCESS_READ = "read"
+
+RAPI_ACCESS_ALL = frozenset([
+  RAPI_ACCESS_WRITE,
+  RAPI_ACCESS_READ,
+  ])
index d31a5b4..3a042a8 100644 (file)
@@ -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_READ]
   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_READ]
+  PUT_ACCESS = GET_ACCESS
   GET_OPCODE = opcodes.OpQuery
   PUT_OPCODE = opcodes.OpQuery
 
index 618a44e..735c300 100755 (executable)
@@ -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_READ]:
+      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: