Add support for rancher authentication 71/84471/5
authorPetr Ospalý <p.ospaly@partner.samsung.com>
Mon, 8 Apr 2019 02:55:47 +0000 (04:55 +0200)
committerMichal Ptacek <m.ptacek@partner.samsung.com>
Wed, 24 Apr 2019 14:03:19 +0000 (14:03 +0000)
This commit adds a new mode to the rancher1_api module, which enables
the rancher local authentication (username/password).

There is an already predefined rancher admin user called 'admin' and
that is the account, which this mode modifies. Due to the complex
API and the fact that rancher 1.6 is soon to be obsoleted, this module
is going the simpler route and it is just editing this default admin
account instead of creating a completely arbitrary username/password
credentials. For that reason is using the 'account_id', which is unique
for all accounts and the default admin account of rancher has '1a1'.

As of now this module cannot handle changed password once the auth. is
enabled.

Change-Id: Iea8923c71bdb82267c966a00d62f0f43eb5adb76
Issue-ID: OOM-1734
Signed-off-by: Petr Ospalý <p.ospaly@partner.samsung.com>
ansible/library/rancher1_api.py
ansible/roles/rancher/defaults/main.yml
ansible/roles/rancher/tasks/rancher_server.yml

index 006de9c..d49e625 100644 (file)
@@ -64,11 +64,17 @@ options:
     description:
       - Dictionary with key/value pairs. The actual names and meaning of pairs
         depends on the used mode.
-      - 'settings' mode:
-        option: Option/path in JSON API (url).
-        value: A new value to replace the current one.
-      - 'access_control' mode:
-        None - not yet implemented - placeholder only.
+      - settings mode:
+        option - Option/path in JSON API (url).
+        value - A new value to replace the current one.
+      - access_control mode:
+        account_id - The unique ID of the account - the default rancher admin
+        has '1a1'. Better way would be to just create arbitrary username and
+        set credentials for that, but due to time constraints, the route with
+        an ID is simpler. The designated '1a1' could be hardcoded and hidden
+        but if the user will want to use some other account (there are many),
+        then it can be just changed to some other ID.
+        password - A new password in a plaintext.
     required: true
   timeout:
     description:
@@ -121,19 +127,26 @@ def get_rancher_api_value(url, headers=None, timeout=default_timeout,
 
 @_decorate_rancher_api_request
 def set_rancher_api_value(url, payload, headers=None, timeout=default_timeout,
-                          username=None, password=None):
+                          username=None, password=None, method='PUT'):
+
+    if method == 'PUT':
+        request_set_method = requests.put
+    elif method == 'POST':
+        request_set_method = requests.post
+    else:
+        raise ModeError('ERROR: Wrong request method: %s' % str(method))
 
     if username and password:
-        return requests.put(url, headers=headers,
-                            timeout=timeout,
-                            allow_redirects=False,
-                            data=json.dumps(payload),
-                            auth=(username, password))
+        return request_set_method(url, headers=headers,
+                                  timeout=timeout,
+                                  allow_redirects=False,
+                                  data=json.dumps(payload),
+                                  auth=(username, password))
     else:
-        return requests.put(url, headers=headers,
-                            timeout=timeout,
-                            allow_redirects=False,
-                            data=json.dumps(payload))
+        return request_set_method(url, headers=headers,
+                                  timeout=timeout,
+                                  allow_redirects=False,
+                                  data=json.dumps(payload))
 
 
 def create_rancher_api_url(server, mode, option):
@@ -158,6 +171,176 @@ def get_keypair(keypair):
     return None, None
 
 
+def mode_access_control(api_url, data=None, headers=None,
+                        timeout=default_timeout, access_key=None,
+                        secret_key=None, dry_run=False):
+
+    # returns true if local auth was enabled or false if passwd changed
+    def is_admin_enabled(json_data, password):
+        try:
+            if json_data['type'] == "localAuthConfig" and \
+                    json_data['accessMode'] == "unrestricted" and \
+                    json_data['username'] == "admin" and \
+                    json_data['password'] == password and \
+                    json_data['enabled']:
+                return True
+        except Exception:
+            pass
+
+        try:
+            if json_data['type'] == "error" and \
+                    json_data['code'] == "IncorrectPassword":
+                return False
+        except Exception:
+            pass
+
+        # this should never happen
+        raise ModeError('ERROR: Unknown status of the local authentication')
+
+    def create_localauth_payload(password):
+        payload = {
+            "enabled": True,
+            "accessMode": "unrestricted",
+            "username": "admin",
+            "password": password
+        }
+
+        return payload
+
+    def get_admin_password_id():
+        # assemble request URL
+        request_url = api_url + 'accounts/' + data['account_id'].strip('/') \
+            + '/credentials'
+
+        # API get current value
+        try:
+            json_response = get_rancher_api_value(request_url,
+                                                  username=access_key,
+                                                  password=secret_key,
+                                                  headers=headers,
+                                                  timeout=timeout)
+        except requests.HTTPError as e:
+            raise ModeError(str(e))
+        except requests.Timeout as e:
+            raise ModeError(str(e))
+
+        if not json_response:
+            raise ModeError('ERROR: BAD RESPONSE (GET) - no json value in '
+                            + 'the response')
+
+        try:
+            for item in json_response['data']:
+                if item['type'] == 'password' and \
+                        item['accountId'] == data['account_id']:
+                    return item['id']
+        except Exception:
+            pass
+
+        return None
+
+    # check if data contains all required fields
+    try:
+        if not isinstance(data['account_id'], str) or data['account_id'] == '':
+            raise ModeError("ERROR: 'account_id' must contain an id of the "
+                            + "affected account")
+    except KeyError:
+        raise ModeError("ERROR: Mode 'access_control' requires the field: "
+                        + "'account_id': %s" % str(data))
+    try:
+        if not isinstance(data['password'], str) or data['password'] == '':
+            raise ModeError("ERROR: 'password' must contain some password")
+    except KeyError:
+        raise ModeError("ERROR: Mode 'access_control' requires the field: "
+                        + "'password': %s" % str(data))
+
+    # assemble request URL
+    request_url = api_url + 'localauthconfigs'
+
+    # API get current value
+    try:
+        json_response = get_rancher_api_value(request_url,
+                                              username=access_key,
+                                              password=secret_key,
+                                              headers=headers,
+                                              timeout=timeout)
+    except requests.HTTPError as e:
+        raise ModeError(str(e))
+    except requests.Timeout as e:
+        raise ModeError(str(e))
+
+    if not json_response:
+        raise ModeError('ERROR: BAD RESPONSE (GET) - no json value in the '
+                        + 'response')
+
+    # we will check if local auth is enabled or not
+    enabled = False
+    try:
+        for item in json_response['data']:
+            if item['type'] == 'localAuthConfig' and \
+                    item['accessMode'] == 'unrestricted' and \
+                    item['enabled']:
+                enabled = True
+                break
+    except Exception:
+        enabled = False
+
+    if dry_run:
+        # we will not set anything, only signal potential change
+        if enabled:
+            changed = False
+        else:
+            changed = True
+    else:
+        # we will try to enable again with the same password
+        localauth_payload = create_localauth_payload(data['password'])
+        json_response = None
+        try:
+            json_response = set_rancher_api_value(request_url,
+                                                  localauth_payload,
+                                                  username=access_key,
+                                                  password=secret_key,
+                                                  headers=headers,
+                                                  method='POST',
+                                                  timeout=timeout)
+        except requests.HTTPError as e:
+            raise ModeError(str(e))
+        except requests.Timeout as e:
+            raise ModeError(str(e))
+
+        if not json_response:
+            raise ModeError('ERROR: BAD RESPONSE (PUT) - no json value in '
+                            + 'the response')
+
+        # we check if the admin was already set or not...
+        if enabled and is_admin_enabled(json_response, data['password']):
+            # it was enabled before and password is the same - no change
+            changed = False
+        elif is_admin_enabled(json_response, data['password']):
+            # we enabled it for the first time
+            changed = True
+        # ...and reset password if needed
+        else:
+            # local auth is enabled but the password differs
+            # we must reset the admin's password
+            password_id = get_admin_password_id()
+
+            if password_id is None:
+                raise ModeError("ERROR: admin's password is set, but we "
+                                + "cannot identify it")
+
+            # TODO
+            raise ModeError("TODO: Reset of the admin password is not yet "
+                            + "implemented")
+
+    if changed:
+        msg = "Local authentication is enabled, admin has assigned password"
+    else:
+        msg = "Local authentication was already enabled, admin's password " \
+            + "is unchanged"
+
+    return changed, msg
+
+
 def mode_settings(api_url, data=None, headers=None, timeout=default_timeout,
                   access_key=None, secret_key=None, dry_run=False):
 
@@ -201,17 +384,17 @@ def mode_settings(api_url, data=None, headers=None, timeout=default_timeout,
     # check if data contains all required fields
     try:
         if not isinstance(data['option'], str) or data['option'] == '':
-            raise ModeError("ERROR: 'option' must contain a name of the \
-                            option")
+            raise ModeError("ERROR: 'option' must contain a name of the "
+                            + "option")
     except KeyError:
-        raise ModeError("ERROR: Mode 'settings' requires the field: 'option': \
-                        %s" % str(data))
+        raise ModeError("ERROR: Mode 'settings' requires the field: 'option': "
+                        + "%s" % str(data))
     try:
         if not isinstance(data['value'], str) or data['value'] == '':
             raise ModeError("ERROR: 'value' must contain a value")
     except KeyError:
-        raise ModeError("ERROR: Mode 'settings' requires the field: 'value': \
-                        %s" % str(data))
+        raise ModeError("ERROR: Mode 'settings' requires the field: 'value': "
+                        + "%s" % str(data))
 
     # assemble request URL
     request_url = api_url + 'settings/' + data['option'].strip('/')
@@ -229,8 +412,8 @@ def mode_settings(api_url, data=None, headers=None, timeout=default_timeout,
         raise ModeError(str(e))
 
     if not json_response:
-        raise ModeError('ERROR: BAD RESPONSE (GET) - no json value in the \
-                        response')
+        raise ModeError('ERROR: BAD RESPONSE (GET) - no json value in the '
+                        + 'response')
 
     if is_valid_rancher_api_option(json_response):
         valid = True
@@ -238,8 +421,8 @@ def mode_settings(api_url, data=None, headers=None, timeout=default_timeout,
             differs, payload = create_rancher_api_payload(json_response,
                                                           data['value'])
         except ValueError:
-            raise ModeError('ERROR: INVALID JSON - missing json values in \
-                            the response')
+            raise ModeError('ERROR: INVALID JSON - missing json values in '
+                            + 'the response')
     else:
         valid = False
 
@@ -261,8 +444,8 @@ def mode_settings(api_url, data=None, headers=None, timeout=default_timeout,
             raise ModeError(str(e))
 
         if not json_response:
-            raise ModeError('ERROR: BAD RESPONSE (PUT) - no json value in \
-                            the response')
+            raise ModeError('ERROR: BAD RESPONSE (PUT) - no json value in '
+                            + 'the response')
         else:
             changed = True
     else:
@@ -304,7 +487,12 @@ def mode_handler(server, rancher_mode, data=None, timeout=default_timeout,
                                      secret_key=secret_key,
                                      dry_run=dry_run)
     elif rancher_mode == 'access_control':
-        msg = "SKIP: 'access_control' Not yet implemented"
+        changed, msg = mode_access_control(api_url, data=data,
+                                           headers=http_headers,
+                                           timeout=timeout,
+                                           access_key=access_key,
+                                           secret_key=secret_key,
+                                           dry_run=dry_run)
 
     return changed, msg
 
index 67e581c..e4d5cb9 100644 (file)
@@ -21,3 +21,6 @@ rancher:
   service_log_purge_after_seconds: 86400  # 1 day
   # Auto-purge Audit Log entries after this long (seconds)
   audit_log_purge_after_seconds: 2592000  # 30 days
+
+  # Set this password for the rancher admin account:
+  admin_password: "admin"
index b71bf8d..e93dd0e 100644 (file)
   delay: 5
   until: env.data is defined
 
+# There is a lack of idempotency in the previous task and so there are new api
+# key-pairs created with each run.
+#
+# ToDo: fix idempotency of rancher role
+#
+# Anyway as rke will be default k8s orchestrator in Dublin, it's supposed to be
+# low prio topic. The following tasks dealing with the API are ignoring this problem
+# and they simply use the new created API key-pair, which is set as a fact here:
 - name: Set apikey values
   set_fact:
     k8s_env_id: "{{ env.data.environment.id }}"
     rancher_agent_image: "{{ env.data.registration_tokens.image }}"
     rancher_agent_reg_url: "{{ env.data.registration_tokens.reg_url }}"
 
+- name: Setup rancher admin password and enable authentication
+  rancher1_api:
+    server: "{{ rancher_server_url }}"
+    account_key: "{{ key_public }}:{{ key_private }}"
+    mode: access_control
+    data:
+      account_id: 1a1 # default rancher admin account
+      password: "{{ rancher.admin_password }}"
+
 - name: Configure the size of the rancher cattle db and logs
   block:
     - name: Main tables