diff --git a/config.py b/config.py index 3f2ba86faee5130cfcec1f4479609a45d9dd7484..3ea36edff4b39a234de6fa25472ff092928465ed 100644 --- a/config.py +++ b/config.py @@ -1,7 +1,28 @@ -# Various constants and parameters +# Global configuration parameters +# Some are filled during initialization by load_config() in main.py -NIFI_CONTAINERS = [ - 'soctools-nifi-1', - 'soctools-nifi-2', - 'soctools-nifi-3', -] +import os.path + +# *** Configuration of file paths *** +SOCTOOLS_BASE = ".." # path to the root of soctools files +VARIABLES_FILE = os.path.join(SOCTOOLS_BASE, "group_vars/all/variables.yml") +CA_CERT_FILE = os.path.join(SOCTOOLS_BASE, "secrets/CA/ca.crt") +KEYCLOAK_ADMIN_PASSWORD_FILE = os.path.join(SOCTOOLS_BASE, "secrets/passwords/keykloak_admin") # Note: should be keycloak, not keykloak + +# Credentials of the special user for account management +# Cert and key should be in .pem format, unencrypted +MGMT_USER_NAME = "soctools-user-mgmt" +MGMT_USER_CERT_PATH = os.path.join(SOCTOOLS_BASE, "secrets/CA/issued/soctools-user-mgmt.crt") +MGMT_USER_KEY_PATH = os.path.join(SOCTOOLS_BASE, "secrets/CA/private/soctools-user-mgmt.key") +# TODO FIXME "SOC_Admin" used instead for initial testing +# MGMT_USER_NAME = "SOC_Admin" +# MGMT_USER_CERT_PATH = os.path.join(SOCTOOLS_BASE, "secrets/certificates/SOC_Admin.crt.pem") +# MGMT_USER_KEY_PATH = os.path.join(SOCTOOLS_BASE, "secrets/certificates/SOC_Admin.key.pem") + + +# Following parameters are set up dynamically by load_config() in main.py +SOCTOOLSPROXY = None + +KEYCLOAK_BASE_URL = None +KEYCLOAK_USERS_URL = None +KEYCLOAK_ADMIN_PASSWORD = None diff --git a/main.py b/main.py index f9ec613b227fa18baf30c1a3f1e591a0f9f9656b..523fba00830657bd64fd43832e4ad5d978c486d8 100644 --- a/main.py +++ b/main.py @@ -15,32 +15,26 @@ import yaml from dataclasses import dataclass, field # external package - backport of Py3.7 dataclasses to Py3.6 from nifi import * - +import config app = Flask(__name__) app.secret_key = "ASDF1234 - CHANGE ME!" -# *** Configuration of file paths *** -SOCTOOLS_BASE = ".." # path to the root of soctools files -VARIABLES_FILE = os.path.join(SOCTOOLS_BASE, "group_vars/all/variables.yml") -CA_CERT_FILE = os.path.join(SOCTOOLS_BASE, "secrets/CA/ca.crt") -KEYCLOAK_ADMIN_PASSWORD_FILE = os.path.join(SOCTOOLS_BASE, "secrets/passwords/keykloak_admin") # Note: should be keycloak, not keykloak @app.before_first_request def load_config(): """Load various variables, api keys, etc. and set configuration parameters""" - global SOCTOOLSPROXY, KEYCLOAK_BASE_URL, KEYCLOAK_USERS_URL, KEYCLOAK_ADMIN_PASSWORD - variables = yaml.safe_load(open(VARIABLES_FILE, "r")) + variables = yaml.safe_load(open(config.VARIABLES_FILE, "r")) # Get FQDN of the main server - SOCTOOLSPROXY = variables["soctoolsproxy"] - assert re.match('[a-zA-Z0-9.-]+', SOCTOOLSPROXY), f"ERROR: The 'soctoolsproxy' variable loaded from '{VARIABLES_FILE}' is not a valid domain name." + config.SOCTOOLSPROXY = variables["soctoolsproxy"] + assert re.match('[a-zA-Z0-9.-]+', config.SOCTOOLSPROXY), f"ERROR: The 'soctoolsproxy' variable loaded from '{config.VARIABLES_FILE}' is not a valid domain name." # Set base URL to Keycloak - KEYCLOAK_BASE_URL = f"https://{SOCTOOLSPROXY}:12443" - KEYCLOAK_USERS_URL = KEYCLOAK_BASE_URL + "/auth/admin/realms/SOCTOOLS1/users" + config.KEYCLOAK_BASE_URL = f"https://{config.SOCTOOLSPROXY}:12443" + config.KEYCLOAK_USERS_URL = config.KEYCLOAK_BASE_URL + "/auth/admin/realms/SOCTOOLS1/users" # Load API key for Keycloak - KEYCLOAK_ADMIN_PASSWORD = open(KEYCLOAK_ADMIN_PASSWORD_FILE, "r").read(100).strip() # read max 100 B, the key should never be so long - print(f"Config loaded:\nSOCTOOLSPROXY={SOCTOOLSPROXY}\nKEYCLOAK_BASE_URL={KEYCLOAK_BASE_URL}\n" - f"KEYCLOAK_ADMIN_PASSWORD={KEYCLOAK_ADMIN_PASSWORD[:3]}...{KEYCLOAK_ADMIN_PASSWORD[-4:]}") + config.KEYCLOAK_ADMIN_PASSWORD = open(config.KEYCLOAK_ADMIN_PASSWORD_FILE, "r").read(100).strip() # read max 100 B, the key should never be so long + print(f"Config loaded:\nSOCTOOLSPROXY={config.SOCTOOLSPROXY}\nKEYCLOAK_BASE_URL={config.KEYCLOAK_BASE_URL}\n" + f"KEYCLOAK_ADMIN_PASSWORD={config.KEYCLOAK_ADMIN_PASSWORD[:3]}...{config.KEYCLOAK_ADMIN_PASSWORD[-4:]}") # *** Custom Jinja filters *** @@ -63,6 +57,7 @@ class UserAccount: kcid: Optional[str] = field(default=None) # keycloak ID ts_created: Optional[datetime] = field(default=None) # timezone-aware datetime in UTC components: Optional[Dict[str, bool]] = field(default_factory=dict) # Presence of the account in SOCtools components that don't use Keycloak directly (NiFi, MISP, TheHive, ...) + internal: bool = False def to_keycloak_representation(self) -> Dict: """ @@ -85,9 +80,9 @@ class UserAccount: @classmethod def from_keycloak_representation(cls, kc_user: dict) -> "UserAccount": try: - return cls(kc_user['username'], kc_user['email'], kc_user['firstName'], kc_user['lastName'], - kc_user['attributes'].get('CN',[''])[0], kc_user['attributes'].get('DN',[''])[0], - kc_user['id'], + return cls(kc_user['username'], kc_user.get('email', ''), kc_user.get('firstName', ''), + kc_user.get('lastName', ''), kc_user.get('attributes', {}).get('CN',[''])[0], + kc_user.get('attributes', {}).get('DN',[''])[0], kc_user['id'], datetime.utcfromtimestamp(int(kc_user['createdTimestamp']/1000)).replace(tzinfo=timezone.utc)) except KeyError as e: raise KeycloakError(f"User representation received from Keycloak is missing attribute '{e}'") @@ -104,15 +99,15 @@ def kc_get_token() -> str: Return the token or raise KeycloakError """ - url = KEYCLOAK_BASE_URL + "/auth/realms/master/protocol/openid-connect/token" + url = config.KEYCLOAK_BASE_URL + "/auth/realms/master/protocol/openid-connect/token" data = { "client_id": "admin-cli", "username": "admin", - "password": KEYCLOAK_ADMIN_PASSWORD, + "password": config.KEYCLOAK_ADMIN_PASSWORD, "grant_type": "password" } try: - resp = requests.post(url, data, verify=CA_CERT_FILE) + resp = requests.post(url, data, verify=config.CA_CERT_FILE) if resp.status_code != 200: raise KeycloakError(f"Can't get OIDC token for API access: ({resp.status_code}) {resp.text[:200]}") return str(resp.json()['access_token']) @@ -127,7 +122,7 @@ def kc_get_users() -> List[UserAccount]: :raise KeycloakError """ token = kc_get_token() - resp = requests.get(KEYCLOAK_USERS_URL, headers={'Authorization': 'Bearer ' + token}, verify=CA_CERT_FILE) + resp = requests.get(config.KEYCLOAK_USERS_URL, headers={'Authorization': 'Bearer ' + token}, verify=config.CA_CERT_FILE) if not resp.ok: raise KeycloakError(f"Can't get list of users: ({resp.status_code}) {resp.text[:200]}") try: @@ -147,8 +142,8 @@ def kc_get_user_by_id(userid: str) -> UserAccount: """ assert re.match(r'[0-9a-z-]*', userid), "Invalid user ID" token = kc_get_token() - url = KEYCLOAK_USERS_URL + "/" + userid - resp = requests.get(url, headers={'Authorization': 'Bearer ' + token}, verify=CA_CERT_FILE) + url = config.KEYCLOAK_USERS_URL + "/" + userid + resp = requests.get(url, headers={'Authorization': 'Bearer ' + token}, verify=config.CA_CERT_FILE) if not resp.ok: raise KeycloakError(f"Can't get user info: ({resp.status_code}) {resp.text[:200]}") try: @@ -167,8 +162,8 @@ def kc_get_user_by_name(username: str) -> Optional[UserAccount]: :raise KeycloakError """ token = kc_get_token() - url = KEYCLOAK_USERS_URL - resp = requests.get(url, params={'username': username, 'exact': 'true'}, headers={'Authorization': 'Bearer ' + token}, verify=CA_CERT_FILE) + url = config.KEYCLOAK_USERS_URL + resp = requests.get(url, params={'username': username, 'exact': 'true'}, headers={'Authorization': 'Bearer ' + token}, verify=config.CA_CERT_FILE) if not resp.ok: raise KeycloakError(f"Can't get user info: ({resp.status_code}) {resp.text[:200]}") print(resp.text) @@ -192,8 +187,8 @@ def kc_add_user(user: UserAccount) -> None: user_data = user.to_keycloak_representation() user_data["enabled"] = True # add "enable" key, since a new user must be explicitly enabled (default is False) - resp = requests.post(KEYCLOAK_USERS_URL, json=user_data, - headers={'Authorization': 'Bearer ' + token}, verify=CA_CERT_FILE) + resp = requests.post(config.KEYCLOAK_USERS_URL, json=user_data, + headers={'Authorization': 'Bearer ' + token}, verify=config.CA_CERT_FILE) if not resp.ok: raise KeycloakError(f"({resp.status_code}) {resp.text[:200]}") @@ -208,9 +203,9 @@ def kc_update_user(user: UserAccount) -> None: token = kc_get_token() user_data = user.to_keycloak_representation() - url = KEYCLOAK_USERS_URL + "/" + user.kcid + url = config.KEYCLOAK_USERS_URL + "/" + user.kcid resp = requests.put(url, json=user_data, - headers={'Authorization': 'Bearer ' + token}, verify=CA_CERT_FILE) + headers={'Authorization': 'Bearer ' + token}, verify=config.CA_CERT_FILE) if not resp.ok: raise KeycloakError(f"({resp.status_code}) {resp.text[:200]}") @@ -223,8 +218,8 @@ def kc_delete_user(userid: str) -> None: """ assert re.match(r'[0-9a-z-]*', userid), "Invalid user ID" token = kc_get_token() - url = KEYCLOAK_USERS_URL + "/" + userid - resp = requests.delete(url, headers={'Authorization': 'Bearer ' + token}, verify=CA_CERT_FILE) + url = config.KEYCLOAK_USERS_URL + "/" + userid + resp = requests.delete(url, headers={'Authorization': 'Bearer ' + token}, verify=config.CA_CERT_FILE) if not resp.ok: raise KeycloakError(f"({resp.status_code}) {resp.text[:200]}") @@ -251,6 +246,10 @@ def main(): except KeycloakError as e: flash(f"ERROR: {e}", "error") users = [] + # Mark "internal" users + for u in users: + if u.username == config.MGMT_USER_NAME: + u.internal = True #print(users) # Load NiFi users @@ -259,6 +258,11 @@ def main(): except NifiError as e: flash(f"ERROR: {e}", "error") nifi_users = [] + # Mark "internal" users + for u in nifi_users: + if u["name"].startswith("CN=soctools-nifi-") or u["name"] == config.MGMT_USER_NAME: + u["internal"] = True + # List of usernames only (for easier cross-check with Keycloak users) nifi_usernames = set(nu["name"] for nu in nifi_users) return render_template("main.html", **locals()) diff --git a/nifi.py b/nifi.py index e9e31c6e1bf892bec20e6b472dec888fcc0e4097..d1dda6b9d421bf34c1f8118edfa03854a97bee11 100644 --- a/nifi.py +++ b/nifi.py @@ -1,20 +1,19 @@ """Functions to manage user accounts in NiFi""" from typing import List, Dict, Optional -import subprocess -import xml.etree.ElementTree as ET +import requests +import re +from operator import itemgetter -from config import * +import config +config.SOCTOOLSPROXY = "gn4soctools3.liberouter.org" -# Path to user configuration in NiFi containers -NIFI_USER_CONFIG_PATH = "/opt/nifi/nifi-current/conf/users.xml" - -# Shell command to restart NiFi in the container (simple "supervisorctl restart" doesn't work, since there is another -# nifi process which supervisord doesn't see and which stops only after some time after the main one; so we need to -# wait until it stops as well by calling "ps" in a loop) -#NIFI_RESTART_COMMAND = "supervisorctl stop nifi ; while (ps aux | grep '^nifi' >/dev/null); do sleep 1; done; supervisorctl start nifi" -NIFI_RESTART_COMMAND = "bin/nifi.sh stop" # stop properly by sending a stop command. It is then restarted automatically by supervisord. +# URL to initial login process +NIFI_LOGIN_URL = "https://{soctools_proxy}:9443/nifi/login" +# Base URL to NiFi API endpoints +NIFI_API_BASE_URL = "https://{soctools_proxy}:9443/nifi-api" +# NiFi API documentation: https://nifi.apache.org/docs/nifi-docs/rest-api/index.html class NifiError(Exception): pass @@ -25,229 +24,113 @@ class NifiUserNotFoundError(NifiError): class NifiUserExistsError(NifiError): pass -# For reference, an example NiFi user-config file looks like this: -""" -<?xml version="1.0" encoding="UTF-8" standalone="yes"?> -<tenants> - <groups> - <group identifier="c78caf19-016f-1000-0000-000000000001" name="NiFi nodes"> - <user identifier="c78caf19-016f-1000-0001-000000000001"/> - <user identifier="c78caf19-016f-1000-0001-000000000002"/> - <user identifier="c78caf19-016f-1000-0001-000000000003"/> - </group> - <group identifier="c78caf19-016f-1000-0000-000000000002" name="Administrators"> - <user identifier="c78caf19-016f-1000-0002-000000000001"/> - <user identifier="c78caf19-016f-1000-0002-000000000002"/> - </group> - </groups> - <users> - <user identifier="c78caf19-016f-1000-0001-000000000001" identity="CN=soctools-nifi-1"/> - <user identifier="c78caf19-016f-1000-0001-000000000002" identity="CN=soctools-nifi-2"/> - <user identifier="c78caf19-016f-1000-0001-000000000003" identity="CN=soctools-nifi-3"/> - <user identifier="c78caf19-016f-1000-0002-000000000001" identity="user1"/> - <user identifier="c78caf19-016f-1000-0002-000000000002" identity="user2"/> - </users> -</tenants> -""" - - -def _nifi_xml_get_users(config_xml: str) -> List[Dict]: - """Parse the XML file and return list of users. - - Return: list of dicts with keys 'name', 'id', 'group' - """ - config_root = ET.fromstring(config_xml) - # Load groups and store group name for each user ID - user_to_group = {} # dict: user_id -> group_name - for group_elem in config_root.find("groups").findall("group"): - g_name = group_elem.get("name") - for user_elem in group_elem.findall("user"): - user_to_group[user_elem.get("identifier")] = g_name - # Load users, find the group for each one - users = [] # list of dicts with keys 'name' (identity), 'id' (identifier), 'group' - for user_elem in config_root.findall("./users/user"): - users.append({ - "name": user_elem.get("identity"), - "id": user_elem.get("identifier"), - "group": user_to_group[user_elem.get("identifier")], - }) - return users - +class NifiUnexpectedReplyError(NifiError): + pass -def _nifi_xml_add_user(config_xml: str, user_name: str, user_group: str) -> str: - """Add given user to the XML config file. +def _nifi_get_jwt() -> str: + """ + Get OIDC token (JWT) for authenticating API requests - Assumes that "user_group" already exists in the file. + Simulate standard login process like a user would do via browser. + It would be better to use "Resource Owner Password Credentials Grant" flow (or "Direct Access Grant" as Keycloak + calls it), which is more suitable for machine clients. However, when such flow is used, Keycloak generates + a differently formatted JWT token (don't know why), which NiFi doesn't accept. + I tried everything to make it work, but unsuccessfully. Therefore, the standard flow is used, which is more + complicated, but works whenever the normal user login works. - :return updated xml string + :return JWT token + :raise NifiUnexpectedReplyError """ - config_root = ET.fromstring(config_xml) - # Get info about the given group - group_node = config_root.find(f"./groups/group[@name='{user_group}']") - group_user_nodes = group_node.findall("user") - # Get list of users - users_node = config_root.find("./users") - # check that there is not a user with the same username - if any(u.get("name") == user_name for u in users_node): - raise NifiUserExistsError(f"Username '{user_name}' already exists!") - - # Generate new user identifier as the max id of the group plus one - # ids look like: abcd1234-01ab-1000-0002-000000000001 (the last part seems to be the index of the user within the group) - user_ids = [u.get("identifier") for u in group_user_nodes] - max_id = max(user_ids) - group_id,last_user_id = max_id.rsplit("-", 1) - new_id = f"{group_id}-{int(last_user_id)+1:012d}" - - # Add a new element the list of users in the group and the list of users - group_node.append(ET.Element("user", identifier=new_id)) - users_node.append(ET.Element("user", identifier=new_id, identity=user_name)) - - return ET.tostring(config_root, encoding='utf-8') - -def _nifi_xml_delete_user(config_xml: str, user_name: str) -> str: - """Remove given user from the XML config file. - - Assumes that "user_group" already exists in the file. - - :return updated xml string - """ - config_root = ET.fromstring(config_xml) - # Find user with given name - user_node = config_root.find(f"./users/user[@identity='{user_name}']") - if user_node is None: - raise NifiUserNotFoundError(f"Can't delete user '{user_name}' from NiFi: User with such a name doesn't exist.") - # Get user's numerical id - identifier = user_node.get('identifier') - - # Remove the user from <users> and from any groups - print("identifier:", identifier) - print("user_node:", user_node) - config_root.find("./users").remove(user_node) - for group_node in config_root.findall("./groups/"): - print("group_node:", group_node) - user_node = group_node.find(f"user[@identifier='{identifier}']") - print(" user_node:", user_node) - if user_node is not None: - group_node.remove(user_node) - - return ET.tostring(config_root, encoding='utf-8') - - -def _nifi_load_user_config(cont_name: str) -> str: - """Get the current user-config file from a NiFi docker container""" - #print("Getting NiFi config...") - result = subprocess.run(["docker", "exec", cont_name, "cat", NIFI_USER_CONFIG_PATH], - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - if result.returncode == 0: - config_xml = result.stdout.decode('utf-8') - #print(config_xml) - return config_xml - else: - raise NifiError(f'Error when trying to get the current config of NiFi users from container "{cont_name}": {result.stderr.decode()}') - - -def _nifi_write_user_config(new_xml: str): - """Write given XML string to the user-config file in all NiFi containers, restart NiFi in all containers.""" - # Write new file into all containers - for i,cont_name in enumerate(NIFI_CONTAINERS): - # Run a command to write the new user-config file inside the NiFi docker container - # The file contents are passed via stdin ("docker exec -i" must be used for stdin to work), "tee" is used to - # store it to a file (stdout is ignored) - print(f'Writing new NiFi user config in container "{cont_name}"') - result = subprocess.run(["docker", "exec", "-i", cont_name, "tee", NIFI_USER_CONFIG_PATH], - input=new_xml, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) - if result.returncode != 0: - raise NifiError(f'Error when trying to write the updated list of NiFi users into container "{cont_name}": {result.stderr.decode()}') - # Restart all containers (may take a long time!) - for i,cont_name in enumerate(NIFI_CONTAINERS): - print(f'Restarting NiFi in container "{cont_name}"') - result = subprocess.run(["docker", "exec", cont_name, "bash", "-c", NIFI_RESTART_COMMAND], - stderr=subprocess.PIPE) - if result.returncode != 0: - raise NifiError(f'Error when trying to restart NiFi after config update in container "{cont_name}": {result.stderr.decode()}') + # We will need to store some cookies - create a session which will automatically handle it + # Also, set path to certificates + session = requests.Session() + session.verify = config.CA_CERT_FILE + session.cert = (config.MGMT_USER_CERT_PATH, config.MGMT_USER_KEY_PATH) + + # Initiate login process by querying the NiFi login page. + # NiFi should set the 'nifi-oidc-request-identifier' cookie (stored into session, will be needed later) and + # redirect us to Keycloak. The redirection is automatically followed by requests.get(). + # Keycloak should authenticate us using the provided certificate and present a web page with confirmation form. + url = NIFI_LOGIN_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + resp = session.get(url, allow_redirects=True) + if not resp.ok: + raise NifiUnexpectedReplyError(f"_nifi_get_jwt: Received unexpected HTTP status code ({resp.status_code}) from URL '{url}'.") + # Parse the returned web page, find the form with id="kc-x509-login-info" and takes URL from its "action" attribute. + re_get_from_url = r'<form [^>]*id="kc-x509-login-info"[^>]* action="([^"]*)"' + match = re.search(re_get_from_url, resp.text) + if not match: + # try to get error message + match2 = re.search(r'<span class="kc-feedback-text">(.*?)</span>', resp.text) + if match2: + raise NifiUnexpectedReplyError(f"_nifi_get_jwt: Received unexpected HTTP reply content (can't find the x509 login form). It contains the following, probably an error message: {match2.group(1)}.") + else: + raise NifiUnexpectedReplyError(f"_nifi_get_jwt: Received unexpected HTTP reply content (can't find the x509 login from). Queried URL: {resp.url}") + url = match.group(1) + url = url.replace("&", "&") + # Send POST request to the URL to simulate clicking the "Continue" button + # Keycloak should redirect us to NiFi's callback URL. Requests automatically follow this redirection, so we should + # receive 200 OK from NiFi, whose content is the JWT + data = "login=Continue" + headers = {"Content-Type": "application/x-www-form-urlencoded"} + resp = session.post(url, data=data, headers=headers, allow_redirects=True) + if not resp.ok: + raise NifiUnexpectedReplyError(f"_nifi_get_jwt: Received unexpected HTTP status code ({resp.status_code}) from URL '{url}'.") + + # Now, we are authenticated to NiFi, identified by a cookie (stored within our session object). + # Use the cookie and ask for the JWT token we need for API requests + url = NIFI_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + "/access/oidc/exchange" + print(f"_nifi_get_jwt: POST request to: {url}") + resp = session.post(url) # POST must be used even though no data are being sent + if not resp.ok: + raise NifiUnexpectedReplyError(f"_nifi_get_jwt: Received unexpected HTTP status code ({resp.status_code}) from URL '{url}'.") + return resp.text + def nifi_get_users() -> List[Dict]: """ List users defined in NiFi + + :return List of dicts with keys 'id', 'name', 'groups' (list of group names) + :raise NifiUnexpectedReplyError """ - prev_users = None - for i,cont_name in enumerate(NIFI_CONTAINERS): - # Get the current user-config file - config_xml = _nifi_load_user_config(cont_name) - if config_xml is False: - return [] - - # Parse the list of users from the config file - try: - users = _nifi_xml_get_users(config_xml) - except Exception as e: - raise NifiError(f'Can\'t parse NiFi user config file ("{NIFI_USER_CONFIG_PATH}" in container "{cont_name}"): {e}') - - # Remove "internal" users from the "NiFi nodes" group - users = [u for u in users if u["group"] != "NiFi nodes"] - # TODO this way, the comparison forces the order of users to be the same, which is not needed (although - # it is always the same in normal situation) - # Check that the list is the same as in the previous container (all should be the same) - if prev_users is not None and users != prev_users: - raise NifiError('Error when trying to get the list of NiFi users: The lists of users differ in at least ' - f'two NiFi nodes ({NIFI_CONTAINERS[i-1]},{NIFI_CONTAINERS[i]}). Check the file ' - f'"{NIFI_USER_CONFIG_PATH}" in each NiFi container, they all must be the same.') - prev_users = users - return prev_users + url = NIFI_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + "/tenants/users" + token = _nifi_get_jwt() + resp = requests.get(url, headers={'Authorization': 'Bearer ' + token}, verify=config.CA_CERT_FILE) + if not resp.ok: + raise NifiUnexpectedReplyError(f"Can't get list of users from NiFi: Unexpected reply {resp.status_code}") + users = [] + try: + raw_users = resp.json()['users'] + #print(raw_users) + for user in raw_users: + users.append({ + 'id': user["component"]["id"], + 'name': user["component"]["identity"], + 'groups': [g["component"]["identity"] for g in user["component"]["userGroups"]], + }) + users.sort(key=itemgetter('name')) + #print(users) + except (ValueError, TypeError, KeyError): + raise NifiUnexpectedReplyError(f"Can't get list of users from NiFi: Unexpected content received") + return users def nifi_add_user(user: 'UserAccount'): """Add a new user to NiFi - Read user config from a NiFi container (the first one), add a new user it, and write it in all containers (overwrite!). - :raises NifiError """ user_name = user.username user_group = "Administrators" # no support for other groups in NiFi, yet - # Get the current user-config file (use the first container, the content should be the same in all) - cont_name = NIFI_CONTAINERS[0] - config_xml = _nifi_load_user_config(cont_name) - - #Add new user to the XML - try: - new_xml = _nifi_xml_add_user(config_xml, user_name, user_group) - except NifiUserExistsError: - raise - except Exception as e: - raise NifiError(f'Can\'t add user to the config file ("{NIFI_USER_CONFIG_PATH}" in container "{cont_name}"): {e}') - - #print("XML with added user:") - #print(new_xml) - - # Write the updated config and restart NiFi nodes - _nifi_write_user_config(new_xml) + # TODO def nifi_delete_user(user_name: str): - """Add a new user to NiFi - - Read user config from a NiFi container (the first one), remove the specified user, and write new version in all containers (overwrite!). + """Delete a user from NiFi :raises NifiError """ - # Get the current user-config file (use the first container, the content should be the same in all) - cont_name = NIFI_CONTAINERS[0] - config_xml = _nifi_load_user_config(cont_name) - - # Remove user from the XML - try: - new_xml = _nifi_xml_delete_user(config_xml, user_name) - except NifiUserNotFoundError: - raise - except Exception as e: - raise NifiError(f'Can\'t remove user from config file ("{NIFI_USER_CONFIG_PATH}" in container "{cont_name}"): {e}') - - #print("XML with deleted user:") - #print(new_xml) - - # Write the updated config and restart NiFi nodes - _nifi_write_user_config(new_xml) + # TODO diff --git a/static/style.css b/static/style.css index 583edb1454183b76ceea78e0250ddf742dd55494..fc7ab0e6a56153250dde8a2fb43d326fb098b873 100644 --- a/static/style.css +++ b/static/style.css @@ -42,4 +42,8 @@ li.flash-success { input[readonly] { background-color: #ddd; -} \ No newline at end of file +} + +.internal-user { + color: #ccc; +} diff --git a/templates/main.html b/templates/main.html index 1a95b2ade677ed0c6be094153fab1edd7d83170b..ec7de66efc0b9ab8691850a799e104e5759a889c 100644 --- a/templates/main.html +++ b/templates/main.html @@ -7,7 +7,7 @@ <tr><th>Username</th><th>First name</th><th>Last name</th><th>email</th><th>CN</th><th>DN</th><th>Time created (UTC)</th><th>NiFi</th><th></th> {% for user in users %} -<tr> +<tr{% if user.internal %} class="internal-user"{% endif %}> <td>{{ user.username }}</td> <td>{{ user.firstname }}</td> <td>{{ user.lastname }}</td> @@ -17,9 +17,11 @@ <td>{{ user.ts_created.isoformat() }}</td> <td>{{ icon('check' if user.username in nifi_usernames else 'close') }}</td> <td> +{% if not user.internal -%} <a href="{{ url_for('edit_user', username=user.username) }}" title="Edit user">{{ icon('pencil') }}</a> <a href="{{ url_for('delete_user', username=user.username) }}" title="Delete user" onclick="return confirm('Are you sure you want to permanently delete user account "{{user.username}}" ({{user.cn}}, {{user.email}})?')">{{ icon('trash') }}</a> +{%- endif %} </td> </tr> {#<tr><td colspan=8>{{ user }}</td></tr>#} @@ -33,9 +35,9 @@ <tr><th>Username</th><th>Group</th><th>ID</th> {% for user in nifi_users %} -<tr> +<tr{% if user.internal %} class="internal-user"{% endif %}> <td>{{ user.name }}</td> -<td>{{ user.group }}</td> +<td>{{ user.groups|join(',') }}</td> <td>{{ user.id }}</td> </tr> {% endfor %}