Commit 5e12acfe authored by Michael Hanselmann's avatar Michael Hanselmann

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: default avatarMichael Hanselmann <hansmi@google.com>
Reviewed-by: default avatarGuido Trotter <ultrotter@google.com>
parent 7b0476cf
......@@ -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
......
......@@ -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``
~~~~~~~
......
......@@ -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,
])
......@@ -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
......
......@@ -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:
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment