diff --git a/nifi.py b/nifi.py
index d1dda6b9d421bf34c1f8118edfa03854a97bee11..24075189e94a141ae980e79885da4e80b20a2697 100644
--- a/nifi.py
+++ b/nifi.py
@@ -4,6 +4,7 @@ from typing import List, Dict, Optional
 import requests
 import re
 from operator import itemgetter
+import urllib.parse
 
 import config
 config.SOCTOOLSPROXY = "gn4soctools3.liberouter.org"
@@ -27,6 +28,71 @@ class NifiUserExistsError(NifiError):
 class NifiUnexpectedReplyError(NifiError):
     pass
 
+
+# =========================
+# Public interface
+
+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
+    """
+    url = NIFI_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + "/tenants/users"
+    token = _nifi_get_jwt()
+    resp = _send_request('get', url, token)
+    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
+
+    :raise NifiUnexpectedReplyError
+    """
+    user_name = user.username
+    user_group = "Administrators" # no support for other groups in NiFi, yet
+    token = _nifi_get_jwt()
+
+    new_user_spec = _add_user(user_name, token)
+
+    _add_user_to_group(new_user_spec["id"], user_group, token)
+
+    return None
+
+
+def nifi_delete_user(user_name: str):
+    """Delete a user from NiFi
+
+    :raises NifiError
+    """
+    token = _nifi_get_jwt()
+    user_spec = _get_user_by_name(user_name, token)
+    if user_spec is None:
+        raise NifiUserNotFoundError()
+    _delete_user(user_spec, token)
+
+
+# =========================
+# Auxiliary functions
+
+
 def _nifi_get_jwt() -> str:
     """
     Get OIDC token (JWT) for authenticating API requests
@@ -79,58 +145,113 @@ def _nifi_get_jwt() -> str:
     # 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 _send_request(method:str, url:str, token:str, data:Optional[dict]=None):
+    return getattr(requests, method)(
+        url,
+        headers={"Authorization": "Bearer " + token},
+        verify=config.CA_CERT_FILE,
+        json=data
+    )
 
-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
-    """
-    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)
+def _get_group_by_name(group_name: str, token=None) -> Optional[dict]:
+    """Return complete user-group specification of a group with given name (or None if not found)"""
+    if not token:
+        token = _nifi_get_jwt()
+    url = NIFI_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + "/tenants/user-groups"
+    resp = _send_request('get', url, token)
     if not resp.ok:
-        raise NifiUnexpectedReplyError(f"Can't get list of users from NiFi: Unexpected reply {resp.status_code}")
-    users = []
+        raise NifiUnexpectedReplyError(f"Can't get list of user groups from NiFi: Unexpected reply {resp.status_code}")
     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)
+        groups = resp.json()["userGroups"]
+        for g in groups:
+            if g["component"]["identity"] == group_name:
+                return g
+        else:
+            return None # user-group with given name not found
     except (ValueError, TypeError, KeyError):
-        raise NifiUnexpectedReplyError(f"Can't get list of users from NiFi: Unexpected content received")
-    return users
+        raise NifiUnexpectedReplyError(f"Can't get list of user groups from NiFi: Unexpected content received")
 
+def _get_group_id_by_name(group_name: str, token=None) -> Optional[str]:
+    """Return ID of user-group with given name (or None if not found)"""
+    return _get_group_by_name(group_name, token)["component"]["id"]
 
-def nifi_add_user(user: 'UserAccount'):
-    """Add a new user to NiFi
 
-    :raises NifiError
+
+def _add_user_to_group(user_id: str, group_name: str, token=None):
     """
-    user_name = user.username
-    user_group = "Administrators" # no support for other groups in NiFi, yet
+    Add given user-id to given user-group (identified by its name, not id)
+    """
+    if not token:
+        token = _nifi_get_jwt()
 
-    # TODO
+    # To update a group, we have to rewrite it by a new specification (users are specified just by their id) ...
 
+    # Get complete specification of the group
+    group = _get_group_by_name(group_name)
+    group_id = group["component"]["id"]
 
-def nifi_delete_user(user_name: str):
-    """Delete a user from NiFi
+    # Edit - add the user
+    group["component"]["users"].append({"id": user_id})
+
+    # Save new group
+    url = NIFI_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + "/tenants/user-groups/" + group_id
+    resp = _send_request('put', url, token, data=group)
+    if not resp.ok:
+        raise NifiUnexpectedReplyError(f"Can't assign user to group in NiFi: Unexpected reply: {resp.status_code} {resp.text[:1000]}")
+
+
+def _add_user(user_name: str, token=None):
+    if not token:
+        token = _nifi_get_jwt()
+    url = NIFI_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + "/tenants/users"
+    post_data = {
+        "revision": {"version": 0},
+        "component": {"identity": user_name}
+    }
+    resp = _send_request('post', url, token, data=post_data)
+    if not resp.ok:
+        if "already exists" in resp.text:
+            raise NifiUserExistsError()
+        raise NifiUnexpectedReplyError(f"Can't add user to NiFi: Unexpected reply: {resp.status_code} {resp.text[:1000]}")
+    user_spec = resp.json()
+    return user_spec
+
+
+def _get_user_by_name(user_name: str, token=None) -> Optional[dict]:
+    """Get user specification of the user with given name (or None if no such user found)"""
+    if not token:
+        token = _nifi_get_jwt()
+    url = NIFI_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + "/tenants/search-results?q=" + urllib.parse.quote(user_name)
+    resp = _send_request('get', url, token)
+    if not resp.ok:
+        raise NifiUnexpectedReplyError(f"Can't get user info from NiFi: Unexpected reply: {resp.status_code} {resp.text[:1000]}")
+    matched_users = resp.json()["users"]
+    for u in matched_users:
+        if u["component"]["identity"] == user_name:
+            return u
+    return None
+
+
+
+def _delete_user(user_spec: dict, token=None):
+    """Delete user (its full spec is passed)"""
+    if not token:
+        token = _nifi_get_jwt()
+
+    url = NIFI_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + "/tenants/users/" + user_spec["id"]
+    url += "?version=" + str(int(user_spec["revision"]["version"]))
+    print("DELETE " + url)
+    resp = _send_request('delete', url, token)
+    if not resp.ok:
+        raise NifiUnexpectedReplyError(f"Can't delete user from NiFi: Unexpected reply: {resp.status_code} {resp.text[:1000]}")
+    user_spec = resp.json()
+    return user_spec
 
-    :raises NifiError
-    """
 
-    # TODO