From: Michael Hanselmann Date: Thu, 8 Nov 2012 18:31:14 +0000 (+0100) Subject: rapi: Add new user option for querying X-Git-Tag: v2.7.0beta1~642 X-Git-Url: https://code.grnet.gr/git/ganeti-local/commitdiff_plain/5e12acfe2778307723a1f768508dbcc1ca911d10 rapi: Add new user option for querying 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 Reviewed-by: Guido Trotter --- diff --git a/NEWS b/NEWS index 45f081c..0e4fa66 100644 --- 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 diff --git a/doc/rapi.rst b/doc/rapi.rst index c46051a..179edfd 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_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 ` 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..be08b2a 100644 --- a/lib/rapi/__init__.py +++ b/lib/rapi/__init__.py @@ -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, + ]) diff --git a/lib/rapi/rlib2.py b/lib/rapi/rlib2.py index d31a5b4..3a042a8 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_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 diff --git a/test/ganeti.server.rapi_unittest.py b/test/ganeti.server.rapi_unittest.py index 618a44e..735c300 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_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: