diff --git a/config.py b/config.py new file mode 100644 index 0000000000000000000000000000000000000000..3f2ba86faee5130cfcec1f4479609a45d9dd7484 --- /dev/null +++ b/config.py @@ -0,0 +1,7 @@ +# Various constants and parameters + +NIFI_CONTAINERS = [ + 'soctools-nifi-1', + 'soctools-nifi-2', + 'soctools-nifi-3', +] diff --git a/main.py b/main.py index 03b4914d19bb9c4a60b635a32d12ec32ade2874a..f9ec613b227fa18baf30c1a3f1e591a0f9f9656b 100644 --- a/main.py +++ b/main.py @@ -3,7 +3,6 @@ import sys from datetime import datetime, timezone import os.path import re -import subprocess from typing import List, Dict, Optional from flask import Flask, render_template, request, make_response, redirect, flash @@ -15,6 +14,9 @@ import requests import yaml from dataclasses import dataclass, field # external package - backport of Py3.7 dataclasses to Py3.6 +from nifi import * + + app = Flask(__name__) app.secret_key = "ASDF1234 - CHANGE ME!" @@ -60,6 +62,7 @@ class UserAccount: dn: str 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, ...) def to_keycloak_representation(self) -> Dict: """ @@ -134,7 +137,7 @@ def kc_get_users() -> List[UserAccount]: except (ValueError, AssertionError): raise KeycloakError("Can't get list of users: Unexpected content of response from Keycloak") -def kc_get_user(userid) -> UserAccount: +def kc_get_user_by_id(userid: str) -> UserAccount: """ Get details of specified user account from Keycloak @@ -155,6 +158,29 @@ def kc_get_user(userid) -> UserAccount: except (ValueError, AssertionError): raise KeycloakError(f"Can't get user info: Unexpected content of response from Keycloak") +def kc_get_user_by_name(username: str) -> Optional[UserAccount]: + """ + Get details of specified user account from Keycloak + + :param username: Keycloak username (not ID) + :return UserAccount representation of the user or None is user not found + :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) + if not resp.ok: + raise KeycloakError(f"Can't get user info: ({resp.status_code}) {resp.text[:200]}") + print(resp.text) + try: + users = resp.json() + assert isinstance(users, list), "" + except (ValueError, AssertionError): + raise KeycloakError(f"Can't get user info: Unexpected content of response from Keycloak") + if len(users) == 0: + raise KeycloakError(f"No user with username '{username}'") + return UserAccount.from_keycloak_representation(users[0]) + def kc_add_user(user: UserAccount) -> None: """Add a new user to Keycloak @@ -203,6 +229,9 @@ def kc_delete_user(userid: str) -> None: raise KeycloakError(f"({resp.status_code}) {resp.text[:200]}") +# *** NiFi *** + + # *** Flask endpoints and forms *** class AddUserForm(FlaskForm): @@ -224,12 +253,20 @@ def main(): users = [] #print(users) + # Load NiFi users + try: + nifi_users = nifi_get_users() + except NifiError as e: + flash(f"ERROR: {e}", "error") + nifi_users = [] + nifi_usernames = set(nu["name"] for nu in nifi_users) + return render_template("main.html", **locals()) @app.route("/add_user", methods=["GET", "POST"]) def add_user(): - """Add a new user. On GET show new-user page, on POST create new user account.""" + """Add a new user. On GET show the new-user page, on POST create new user account.""" form_user = AddUserForm() if form_user.validate_on_submit(): # Form submitted and valid - create user account @@ -239,18 +276,29 @@ def add_user(): lastname=form_user.lastname.data, cn=form_user.cn.data, dn=f"CN={form_user.cn.data}") + # Keycloak try: kc_add_user(user) - flash(f'User "{form_user.username.data}" successfully created.', "success") - return redirect("/") # Success - go back to main page - except KeycloakError as e: - flash(f'Error when creating user: {e}', "error") + flash(f'User "{form_user.username.data}" successfully created in Keycloak.', "success") + except Exception as e: + flash(f'Error when creating user in Keycloak: {e}', "error") + # NiFi + try: + nifi_add_user(user) + flash(f'User "{form_user.username.data}" successfully created in NiFi.', "success") + except NifiUserExistsError: + flash(f'User "{user.username}" already exists in NiFi, nothing has changed.', "warning") + except Exception as e: + flash(f'Error when creating user in NiFi: {e}', "error") + return redirect("/") # Success - go back to main page + return render_template("add_edit_user.html", form_user=form_user, user=None) -@app.route("/edit_user/<userid>", methods=["GET", "POST"]) -def edit_user(userid: str): +@app.route("/edit_user/<username>", methods=["GET", "POST"]) +def edit_user(username: str): """Edit existing user. On GET show user details, on POST update user params with new values.""" + keycloak_id = kc_get_user_by_name(username).kcid # TODO catch exception if request.method == "POST": form_user = AddUserForm() # use data from POST request if form_user.validate_on_submit(): @@ -261,17 +309,17 @@ def edit_user(userid: str): lastname=form_user.lastname.data, cn=form_user.cn.data, dn=f"CN={form_user.cn.data}", - kcid=userid) + kcid=keycloak_id) try: kc_update_user(user) flash(f'User "{form_user.username.data}" successfully updated.', "success") return redirect("/") # Success - go back to main page except KeycloakError as e: flash(f'Error when updating user: {e}', "error") - return render_template("add_edit_user.html", form_user=form_user, user={"kcid": userid}) + return render_template("add_edit_user.html", form_user=form_user, user={"kcid": keycloak_id}) # else - method="GET" try: - user = kc_get_user(userid) + user = kc_get_user_by_id(keycloak_id) except KeycloakError as e: flash(f'ERROR: {e}', "error") return redirect('/') @@ -279,14 +327,24 @@ def edit_user(userid: str): return render_template("add_edit_user.html", form_user=form_user, user=user) -@app.route("/delete_user/<userid>") -def delete_user(userid: str): - """Delete user given by userid and redirect back to main page""" +@app.route("/delete_user/<username>") +def delete_user(username: str): + """Delete user given by username and redirect back to main page""" try: - kc_delete_user(userid) - flash(f'User successfully deleted.', "success") + keycloak_id = kc_get_user_by_name(username).kcid + kc_delete_user(keycloak_id) + flash('User successfully deleted from KeyCloak.', "success") except KeycloakError as e: - flash(f'Error when deleting user: {e}', "error") + flash(f'Error when deleting user from KeyCloak: {e}', "error") + + try: + nifi_delete_user(username) + flash(f'User "{username}" successfully deleted from NiFi.', "success") + except NifiUserNotFoundError: + flash(f'User "{username}" was not found in NiFi, nothing has changed.', "warning") + except NifiError as e: + flash(f'Error when deleting user from NiFi: {e}', "error") + return redirect("/") @@ -294,6 +352,8 @@ def delete_user(userid: str): # TODO other services (besides Keycloak) +# TODO authentication/authorization to this GUI + # When the script is run directly, run the application on a local development server. # Optionally pass two parameters, 'host' (IP to listen on) and 'port', # e.g.: ./main.py 0.0.0.0 8080 diff --git a/nifi.py b/nifi.py new file mode 100644 index 0000000000000000000000000000000000000000..e9e31c6e1bf892bec20e6b472dec888fcc0e4097 --- /dev/null +++ b/nifi.py @@ -0,0 +1,253 @@ +"""Functions to manage user accounts in NiFi""" + +from typing import List, Dict, Optional +import subprocess +import xml.etree.ElementTree as ET + +from config import * + +# 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. + + +class NifiError(Exception): + pass + +class NifiUserNotFoundError(NifiError): + pass + +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 + + +def _nifi_xml_add_user(config_xml: str, user_name: str, user_group: str) -> str: + """Add given user to the XML config file. + + Assumes that "user_group" already exists in the file. + + :return updated xml string + """ + 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()}') + + +def nifi_get_users() -> List[Dict]: + """ + List users defined in NiFi + """ + 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 + + +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) + + +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!). + + :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) + diff --git a/static/style.css b/static/style.css index 6ab7fa85a453ff428e496b19d1c1179ef9d391c3..583edb1454183b76ceea78e0250ddf742dd55494 100644 --- a/static/style.css +++ b/static/style.css @@ -18,6 +18,10 @@ p { padding: 0.5em; } +img.icon { + vertical-align: center; +} + .errors { background-color: #fcc; color: #c00; @@ -29,6 +33,9 @@ ul.flashes { li.flash-error { color: #900; } +li.flash-warning { + color: #990; +} li.flash-success { color: #090; } diff --git a/templates/add_edit_user.html b/templates/add_edit_user.html index 58adf033d381ac3cbce9ccd1592ee4a6aeb6ea1a..11f273beb75c49a299aa67d48f3a72985e37ba17 100644 --- a/templates/add_edit_user.html +++ b/templates/add_edit_user.html @@ -10,7 +10,7 @@ <h2>Add new user</h2> {% endif %} -<form action="{{ url_for("edit_user", userid=user.kcid) if user else url_for("add_user") }}" method="POST"> +<form action="{{ url_for("edit_user", username=user.username) if user else url_for("add_user") }}" method="POST"> {% if form_user.errors %} <ul class="errors"> {% for field, errors in form_user.errors.items() %} diff --git a/templates/base.html b/templates/base.html index 6e60ba5dbe8f55d8ecfd2ed0a76c6e56f8b0cedf..edce67482938c91890c89a1aa366ccb2efd8d3b0 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,3 +1,6 @@ +{%- macro icon(type, size='24px') -%} +<img src="{{ url_for("static", filename="icons/"+type+".svg") }}" class="icon" style="width: {{size}}; height: {{size}}"> +{%- endmacro -%} <!doctype html> <html> <head> diff --git a/templates/main.html b/templates/main.html index aa9f51b1a6e067ddf49ffb37f143cea83943d5cd..1a95b2ade677ed0c6be094153fab1edd7d83170b 100644 --- a/templates/main.html +++ b/templates/main.html @@ -1,10 +1,10 @@ {% extends "base.html" %} {% block body %} -<p><a href="{{ url_for("add_user") }}">Add new user ...</a></p> +<p><a href="{{ url_for("add_user") }}">{{ icon('plus-circle', size="1em") }} Add new user ...</a></p> <table> -<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></th> +<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> @@ -15,14 +15,30 @@ <td>{{ user.cn }}</td> <td>{{ user.dn }}</td> <td>{{ user.ts_created.isoformat() }}</td> +<td>{{ icon('check' if user.username in nifi_usernames else 'close') }}</td> <td> -<a href="{{ url_for('edit_user', userid=user.kcid) }}" title="Edit user">🖉</a> -<a href="{{ url_for('delete_user', userid=user.kcid) }}" title="Delete user" - onclick="return confirm('Are you sure you want to permanently delete user account "{{user.username}}" ({{user.cn}}, {{user.email}})?')">🗑</a> +<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> </td> </tr> {#<tr><td colspan=8>{{ user }}</td></tr>#} {% endfor %} </table> +<h2>Users in individual services</h2> + +<h3>NiFi</h3> +<table> +<tr><th>Username</th><th>Group</th><th>ID</th> + +{% for user in nifi_users %} +<tr> +<td>{{ user.name }}</td> +<td>{{ user.group }}</td> +<td>{{ user.id }}</td> +</tr> +{% endfor %} +</table> + {% endblock %} \ No newline at end of file