Repository: privacyidea/privacyidea

Issue:
Filter SSH keys for users and service IDs
## Management Requirement

We need a way to find all SSH keys assigned to a certain service_id. Maybe list service_ids. Inspect service_ids and list all SSH keys assigned to this service_id.

This could be situated in the UI under the tab "machine"? It could be a table of
`| key/token | applicationtype | service_id |  options |` . 

We could then also filter for options like the user to find all ssh keys, that are allowed to login as "root" (user-option) on "mailservers" (service id).

See #3533 

## API

Starting from the API view the machine-token-assignments are fetched via `GET https:///machine/token?serial=SSHK0000EB3E`.
We can start with enhancing the API endpoint machine/token to also allow filtering for other parameters and options, here:

https://github.com/privacyidea/privacyidea/blob/258a88b8a5ae07e233d40fc48fd644a4d50a7c70/privacyidea/api/machine.py#L232-L236

The unterlying library call `list_machine_tokens` already has a parameter *filter_params*, that is used to filter for service_id.

This way we could have the REST API return e.g.
* all machine token information for certain application types (like SSH)
* for all certain service_ids...

* [x] Enhance the REST API to accept filter_params
   * [x] Prepare test class for GET /machein/token 
* [x] Ensure that the response of GET /machine/token also returns the serial number, in case we are searching for application_type and service_id, we also need to know the attached serial/token.
* [x] Add tests for finding user-options
* [x] Add WebUI location to display the service ids/SSH assignments. This would use the API endpoint `GET https:///machine/token` with the corresponding filter options.
* [x] Have an extra UI for SSH with the corresponding options.
* [x] Have an extra UI for offline with the corresponding options. We can do this similar to the token-enrollment. Have a dropdown with the applications. Depending on the application we load different ssh-template or offline-template just like with token.enroll.hotp.html or token.enroll.totp.html.
* [x] Filtering in UI (Done in de648fbb)
* [x] Sorting in UI (Done in 44ca9f12)
* [x] Filtering options in the API
* [x] Filtering serial numbers in the API (done in 86d61804)
* [x] Sorting in the API. Add to API: `sortby`, `sortdir`, ~~page`, `pagesize~~  (done in 01e4d9a8)
* [x] API tests
* [x] Do we need a policy? Not an extra policy. We can use this one https://github.com/privacyidea/privacyidea/blob/ac9135f1c48449a8121afa30ea67b41dea215dea/privacyidea/lib/policy.py#L268 or `tokenlist`.  (Done in 9682f25c)
* [x] Some docs. Where can we add this?  (Done in 816af0fc)
   - https://privacyidea.readthedocs.io/en/latest/machines/index.html?highlight=application#ssh
   - https://github.com/privacyidea/privacyidea/blob/ed4d272cb8419eeb1095521ac7724252dfd79613/doc/configuration/serviceids.rst#L3

## UI

Thoughts on the UI. How important are service_ids? 
For SSH keys these are very important. But not for other applications.

Where and how should the machinetokens-applications be listed?

These would also work of offline - e.g. check which machines, with which attributes/options are available offline.
Which options should be visible in a table?

For SSH applications this would probably be

    | token serial | application  (SSH) | tokenowner | ssh-user | service_id |

But for offline this might be

    | token serial | application (offline) | tokenowner | count | .... | 

It could also be 

    | token serial | application (offline) | tokenowner | <options> |

But we need to be able filter for the "options" like the `service_id`.

@plettich: This might also connected to a similar design/display question in regards to #1291 
A similar problem would be with tokengroups. 
* How can we display all tokens, that are in one tokengroup?
* How can we list all tokens, that are in one nested token?
* How can we list tokens, that are assigned to a certain service_id/ssh?

If a user has lost a smartcard/nestedToken, the admin would probably go to the user listing.
If an administrator wants to know, which SSH keys are allowed to login to service_id "mailserver", the admin would probably go to the token view.
If the admin wants to know, which tokens are in a tokengroup, the admin would either go to tokengroup view or to token view.

Idea: There could be a submenu in the tokenview, that is: *Token*, *Nested Token/Container*, *Tokengroups*, *ServiceID*.




Discussion:

Resolving patch:
diff --git a/privacyidea/api/machine.py b/privacyidea/api/machine.py
--- a/privacyidea/api/machine.py
+++ b/privacyidea/api/machine.py
@@ -231,21 +231,46 @@ def list_machinetokens_api():
 
     :param serial: Return the MachineTokens for a the given Token
     :param hostname: Identify the machine by the hostname
-    :param machineid: Identify the machine by the machine ID and the resolver
-        name
-    :param resolver: Identify the machine by the machine ID and the resolver
-        name
-    :return:
+    :param machineid: Identify the machine by the machine ID and the resolver name
+    :param resolver: Identify the machine by the machine ID and the resolver name
+    :query sortby: sort the output by column. Can be 'serial', 'service_id'...
+    :query sortdir: asc/desc
+    :query application: The type of application like "ssh" or "offline".
+    :param <options>: You can also filter for options like the 'service_id' or 'user' for SSH applications, or
+        'count' and 'rounds' for offline applications. The filter allows the use of "*" to match substrings.
+    :return: JSON list of dicts
+
+    [{'application': 'ssh',
+      'id': 1,
+      'options': {'service_id': 'webserver',
+                  'user': 'root'},
+      'resolver': None,
+      'serial': 'SSHKEY1',
+      'type': 'sshkey'},
+       ...
+       ]
     """
     hostname = getParam(request.all_data, "hostname")
     machineid = getParam(request.all_data, "machineid")
     resolver = getParam(request.all_data, "resolver")
     serial = getParam(request.all_data, "serial")
     application = getParam(request.all_data, "application")
-
-    res = []
-
-    if not hostname and not machineid and not resolver:
+    sortby = getParam(request.all_data, "sortby", "serial")
+    sortdir = getParam(request.all_data, "sortdir", "asc")
+    filter_params = {}
+    # Use remaining params as filters
+    for key, value in {k: v for k, v in request.all_data.items() if k not in [
+        "hostname", "machineid", "resolver", "serial", "application", "client", "g",
+        "sortby", "sortdir", "page", "pagesize"
+    ]}.items():
+        filter_params[key] = value
+
+    serial_pattern = None
+    if serial and "*" in serial:
+        serial_pattern = serial.replace("*", ".*")
+        serial = None
+
+    if not hostname and not machineid and not resolver and serial and not filter_params:
         # We return the list of the machines for the given serial
         res = list_token_machines(serial)
     else:
@@ -253,8 +278,14 @@ def list_machinetokens_api():
             hostname = None
             machineid = None
             resolver = None
-        res = list_machine_tokens(hostname=hostname, machine_id=machineid,
-                                  resolver_name=resolver)
+        res = list_machine_tokens(hostname=hostname, machine_id=machineid, resolver_name=resolver,
+                                  serial=serial, application=application, filter_params=filter_params,
+                                  serial_pattern=serial_pattern)
+
+    if sortby == "serial":
+        res.sort(key=lambda x: x.get("serial"), reverse=sortdir == "desc")
+    else:
+        res.sort(key=lambda x: x.get("options", {}).get(sortby, ""), reverse=sortdir == "desc")
 
     g.audit_object.log({'success': True,
                         'info': "serial: {0!s}, hostname: {1!s}".format(serial,
@@ -290,15 +321,13 @@ def set_option_api():
     # get additional options:
     options_add = {}
     options_del = []
-    for key in request.all_data.keys():
-        if key not in ["hostname", "machineid", "resolver", "serial",
-                       "application", "mtid"]:
-            # We use the key as additional option
-            value = request.all_data.get(key)
-            if value:
-                options_add[key] = request.all_data.get(key)
-            else:
-                options_del.append(key)
+    for key, value in {k: v for k, v in request.all_data.items() if k not in [
+        "hostname", "machineid", "resolver", "serial", "application", "client", "g", "mtid"
+    ]}.items():
+        if value:
+            options_add[key] = value
+        else:
+            options_del.append(key)
 
     if mtid:
         o_add = add_option(machinetoken_id=mtid, options=options_add)
diff --git a/privacyidea/lib/machine.py b/privacyidea/lib/machine.py
--- a/privacyidea/lib/machine.py
+++ b/privacyidea/lib/machine.py
@@ -38,6 +38,7 @@
 from netaddr import IPAddress
 from sqlalchemy import and_
 import logging
+import re
 
 log = logging.getLogger(__name__)
 from privacyidea.lib.log import log_with
@@ -311,7 +312,8 @@ def list_machine_tokens(hostname=None,
                         resolver_name=None,
                         serial=None,
                         application=None,
-                        filter_params=None):
+                        filter_params=None,
+                        serial_pattern=None):
     """
     Returns a list of tokens assigned to the given machine.
 
@@ -335,6 +337,7 @@ def list_machine_tokens(hostname=None,
     if application:
         sql_query = sql_query.filter(MachineToken.application == application)
     if serial:
+        # discrete serial
         token_id = get_token_id(serial)
         sql_query = sql_query.filter(MachineToken.token_id == token_id)
 
@@ -345,10 +348,22 @@ def list_machine_tokens(hostname=None,
         for option in option_list:
             options[option.mt_key] = option.mt_value
         include_mt = True
-        for key, value in filter_params.items():
-            # Check if the machinetoken contains the correct filter values
-            if options.get(key) != value:
+        # check serial_pattern
+        if serial_pattern:
+            if not re.match(serial_pattern, row.token.serial, re.I):
                 include_mt = False
+        # we still think, it should be included
+        if include_mt:
+            for key, value in filter_params.items():
+                tokenoptionvalue = options.get(key, "")
+                if "*" in value:
+                    # Simple wildcard matching
+                    pattern = value.replace("*", ".*")
+                    # We do a case insensitive match
+                    if not re.match(pattern, tokenoptionvalue, re.I):
+                        include_mt = False
+                elif tokenoptionvalue != value:
+                    include_mt = False
         if include_mt:
             res.append({"serial": row.token.serial,
                         "machine_id": machine_id,


Test patch:
diff --git a/tests/test_api_machines_serviceid.py b/tests/test_api_machines_serviceid.py
new file mode 100644
--- /dev/null
+++ b/tests/test_api_machines_serviceid.py
@@ -0,0 +1,313 @@
+"""
+This testcase is used to test the REST API  in api/machines.py
+to fetch machine information and to attach token to machines
+"""
+import passlib
+
+from privacyidea.lib.user import User
+from .base import MyApiTestCase
+import json
+from privacyidea.lib.token import init_token, get_tokens, remove_token
+from privacyidea.lib.machine import attach_token, detach_token, ANY_MACHINE, NO_RESOLVER
+from privacyidea.lib.policy import (set_policy, delete_policy, ACTION, SCOPE)
+
+HOSTSFILE = "tests/testdata/hosts"
+
+SSHKEY = "ssh-rsa " \
+         "AAAAB3NzaC1yc2EAAAADAQABAAACAQDJy0rLoxqc8SsY8DVAFijMsQyCv" \
+         "hBu4K40hdZOacXK4O6OgnacnSKN56MP6pzz2+4svzvDzwvkFsvf34pbsgD" \
+         "F67PPSCsimmjEQjf0UfamBKh0cl181CbPYsph3UTBOCgHh3FFDXBduPK4DQz" \
+         "EVQpmqe80h+lsvQ81qPYagbRW6fpd0uWn9H7a/qiLQZsiKLL07HGB+NwWue4os" \
+         "0r9s4qxeG76K6QM7nZKyC0KRAz7CjAf+0X7YzCOu2pzyxVdj/T+KArFcMmq8V" \
+         "dz24mhcFFXTzU3wveas1A9rwamYWB+Spuohh/OrK3wDsrryStKQv7yofgnPMs" \
+         "TdaL7XxyQVPCmh2jVl5ro9BPIjTXsre9EUxZYFVr3EIECRDNWy3xEnUHk7Rzs" \
+         "734Rp6XxGSzcSLSju8/MBzUVe35iXfXDRcqTcoA0700pIb1ANYrPUO8Up05v4" \
+         "EjIyBeU61b4ilJ3PNcEVld6FHwP3Z7F068ef4DXEC/d7pibrp4Up61WYQIXV/" \
+         "utDt3NDg/Zf3iqoYcJNM/zIZx2j1kQQwqtnbGqxJMrL6LtClmeWteR4420uZx" \
+         "afLE9AtAL4nnMPuubC87L0wJ88un9teza/N02KJMHy01Yz3iJKt3Ou9eV6kqO" \
+         "ei3kvLs5dXmriTHp6g9whtnN6/Liv9SzZPJTs8YfThi34Wccrw== " \
+         "NetKnights GmbH"
+SSHKEY_ecdsa = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzd" \
+               "HAyNTYAAABBBHGCdIk0pO1HFr/mF4oLb43ZRyQJ4K7ICLrAhAiQERVa0tUvyY5TE" \
+               "zurWTqxSMx203rY77t6xnHLZBMPPpv8rk0= cornelius@puck"
+OTPKEY = "3132333435363738393031323334353637383930"
+
+
+class APIMachinesServiceIDTestCase(MyApiTestCase):
+
+    serial1 = "SSHKEY1"
+    serial2 = "SSHKEY2"
+    serviceID1 = "webserver"
+    serviceID2 = "mailserver"
+
+    def test_01_create_sshkeys(self):
+        # create two tokens
+        token_obj = init_token({"serial": self.serial1, "type": "sshkey",
+                                "sshkey": SSHKEY})
+        self.assertEqual(token_obj.type, "sshkey")
+        token_obj = init_token({"serial": self.serial2, "type": "sshkey",
+                                "sshkey": SSHKEY_ecdsa})
+        self.assertEqual(token_obj.type, "sshkey")
+
+    def test_02_attach_tokens(self):
+        # Do the following token attachemends:
+        # * S1: webserver and mailserver
+        # * S2: only mailserver
+        # Attach token S1 to webserver
+        with self.app.test_request_context('/machine/token',
+                                           method='POST',
+                                           data={"serial": self.serial1,
+                                                 "application": "ssh",
+                                                 "user": "root",
+                                                 "service_id": self.serviceID1},
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertEqual(result["status"], True)
+            self.assertTrue(result["value"] >= 1)
+            mtid = result.get("value")
+
+        # Attach S1 to mailserver
+        with self.app.test_request_context('/machine/token',
+                                           method='POST',
+                                           data={"serial": self.serial1,
+                                                 "application": "ssh",
+                                                 "user": "root",
+                                                 "service_id": self.serviceID2},
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertEqual(result["status"], True)
+            self.assertTrue(result["value"] >= 1)
+            mtid = result.get("value")
+
+        # Attach S2 to mailserver
+        with self.app.test_request_context('/machine/token',
+                                           method='POST',
+                                           data={"serial": self.serial2,
+                                                 "application": "ssh",
+                                                 "user": "root",
+                                                 "service_id": self.serviceID2},
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertEqual(result["status"], True)
+            self.assertTrue(result["value"] >= 1)
+            mtid = result.get("value")
+
+    def test_03_get_service_ids(self):
+        # Get all machinetokens
+        with self.app.test_request_context('/machine/token',
+                                           method='GET',
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertEqual(result["status"], True)
+            value = result.get("value")
+            self.assertEqual(len(value), 3)
+            self.assertEqual(value[0]["application"], "ssh")
+            self.assertEqual(value[1]["application"], "ssh")
+            self.assertEqual(value[2]["application"], "ssh")
+
+        # Get tokens for service_id self.serviceID1
+        with self.app.test_request_context('/machine/token?service_id={0!s}'.format(self.serviceID1),
+                                           method='GET',
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertEqual(result["status"], True)
+            value = result.get("value")
+            self.assertEqual(len(value), 1)
+            self.assertEqual(value[0]["application"], "ssh")
+            self.assertEqual(value[0].get("options").get("service_id"), self.serviceID1)
+            self.assertEqual(value[0].get("serial"), self.serial1)
+
+        # Get token for service_id self.serviceID2
+        with self.app.test_request_context('/machine/token?service_id={0!s}'.format(self.serviceID2),
+                                           method='GET',
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertEqual(result["status"], True)
+            value = result.get("value")
+            self.assertEqual(len(value), 2)
+            self.assertEqual(value[0]["application"], "ssh")
+            self.assertEqual(value[0].get("options").get("service_id"), self.serviceID2)
+            self.assertEqual(value[0].get("serial"), self.serial1)
+            self.assertEqual(value[1]["application"], "ssh")
+            self.assertEqual(value[1].get("options").get("service_id"), self.serviceID2)
+            self.assertEqual(value[1].get("serial"), self.serial2)
+
+        # combine filter and get service_id self.serviceID2 for serial1
+        with self.app.test_request_context('/machine/token?service_id={0!s}&serial={1!s}'.format(
+                self.serviceID2, self.serial1),
+                                           method='GET',
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertEqual(result["status"], True)
+            value = result.get("value")
+            self.assertEqual(len(value), 1)
+            self.assertEqual(value[0]["application"], "ssh")
+            self.assertEqual(value[0].get("options").get("service_id"), self.serviceID2)
+            self.assertEqual(value[0].get("serial"), self.serial1)
+
+        # Get token for service_id self.serviceID2 and the correct application
+        with self.app.test_request_context('/machine/token?service_id={0!s}&application=ssh'.format(self.serviceID2),
+                                           method='GET',
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertEqual(result["status"], True)
+            value = result.get("value")
+            self.assertEqual(len(value), 2)
+            self.assertEqual(value[0]["application"], "ssh")
+            self.assertEqual(value[0].get("options").get("service_id"), self.serviceID2)
+            self.assertEqual(value[0].get("serial"), self.serial1)
+            self.assertEqual(value[1]["application"], "ssh")
+            self.assertEqual(value[1].get("options").get("service_id"), self.serviceID2)
+            self.assertEqual(value[1].get("serial"), self.serial2)
+
+        # Get token for service_id self.serviceID2 and the wrong application
+        with self.app.test_request_context(
+                '/machine/token?service_id={0!s}&application=openssh'.format(self.serviceID2),
+                method='GET',
+                headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertEqual(result["status"], True)
+            value = result.get("value")
+            self.assertEqual(len(value), 0)
+
+    def test_04_get_service_id_for_different_users(self):
+        # Add another SSH key for user admin to self.serviceID2
+        with self.app.test_request_context('/machine/token',
+                                           method='POST',
+                                           data={"serial": self.serial1,
+                                                 "application": "ssh",
+                                                 "user": "admin",
+                                                 "service_id": self.serviceID2},
+                                           headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertEqual(result["status"], True)
+            self.assertTrue(result["value"] >= 1)
+            mtid = result.get("value")
+
+        # Get token for service_id self.serviceID2 and the application=ssh and the user=root
+        with self.app.test_request_context(
+                '/machine/token?service_id={0!s}&application=ssh&user=root'.format(self.serviceID2),
+                method='GET',
+                headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertEqual(result["status"], True)
+            value = result.get("value")
+            self.assertEqual(len(value), 2)
+            self.assertEqual(value[0]["application"], "ssh")
+            self.assertEqual(value[0].get("options").get("service_id"), self.serviceID2)
+            self.assertEqual(value[0].get("serial"), self.serial1)
+            self.assertEqual(value[1]["application"], "ssh")
+            self.assertEqual(value[1].get("options").get("service_id"), self.serviceID2)
+            self.assertEqual(value[1].get("serial"), self.serial2)
+
+        # Get token for service_id self.serviceID2 and the application=ssh and the user=admin
+        with self.app.test_request_context(
+                '/machine/token?service_id={0!s}&application=ssh&user=admin'.format(self.serviceID2),
+                method='GET',
+                headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertEqual(result["status"], True)
+            value = result.get("value")
+            self.assertEqual(len(value), 1)
+            self.assertEqual(value[0]["application"], "ssh")
+            self.assertEqual(value[0].get("options").get("service_id"), self.serviceID2)
+            self.assertEqual(value[0].get("serial"), self.serial1)
+
+        # test filter and wildcards
+
+        # Find all tokens attached to a *server
+        with self.app.test_request_context(
+                '/machine/token?service_id=*server*&application=ssh',
+                method='GET',
+                headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertEqual(result["status"], True)
+            value = result.get("value")
+            self.assertEqual(len(value), 4)
+            self.assertEqual(value[0]["application"], "ssh")
+
+        # Find all tokens attached to the user *root*
+        with self.app.test_request_context(
+                '/machine/token?application=ssh&user=*oot',
+                method='GET',
+                headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertEqual(result["status"], True)
+            value = result.get("value")
+            # 3 tokens are attached via user "root"
+            self.assertEqual(len(value), 3)
+            self.assertEqual(value[0]["application"], "ssh")
+
+        # Find all tokens attached to the user *adm*
+        with self.app.test_request_context(
+                '/machine/token?application=ssh&user=*adm*',
+                method='GET',
+                headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertEqual(result["status"], True)
+            value = result.get("value")
+            # One token is attached via user "admin"
+            self.assertEqual(len(value), 1)
+            self.assertEqual(value[0]["application"], "ssh")
+
+        # Filter vor *KEY1
+        with self.app.test_request_context(
+                '/machine/token?application=ssh&serial=*KEY1',
+                method='GET',
+                headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertEqual(result["status"], True)
+            value = result.get("value")
+            # One token is attached via user "admin"
+            self.assertEqual(len(value), 3)
+            for v in value:
+                # We only get SSHKEY1
+                self.assertEqual(v.get("serial"), self.serial1)
+
+        # sort by service_id
+        with self.app.test_request_context(
+                '/machine/token?application=ssh&serial=*KEY1&sortby=service_id',
+                method='GET',
+                headers={'Authorization': self.at}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            result = res.json.get("result")
+            self.assertEqual(result["status"], True)
+            value = result.get("value")
+            # One token is attached via user "admin"
+            self.assertEqual(len(value), 3)
+            self.assertEqual(value[0].get("options").get("user"), "root")
+            self.assertEqual(value[1].get("options").get("user"), "admin")
+            self.assertEqual(value[2].get("options").get("user"), "root")