diff --git a/requirements.txt b/requirements.txt index f0d31aba7..f39ca3b84 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,6 +18,7 @@ passlib>=1.7.1 ply>=3.11 psutil>=5.7.0 python-json-logger +python-keycloak>=2.6.0 python-magic>=0.4.15 Sphinx>=1.7.1 redis>=2.10.6 diff --git a/src/actinia_core/core/common/app.py b/src/actinia_core/core/common/app.py index ba0580369..60f48e123 100644 --- a/src/actinia_core/core/common/app.py +++ b/src/actinia_core/core/common/app.py @@ -106,10 +106,13 @@ """ from flask_httpauth import HTTPBasicAuth +from flask_httpauth import HTTPTokenAuth from flask_cors import CORS from flask import Flask from flask_restful_swagger_2 import Api +from actinia_core.core.common.config import global_config, DEFAULT_CONFIG_PATH + from actinia_api import API_VERSION, URL_PREFIX __license__ = "GPLv3" @@ -125,6 +128,7 @@ flask_app.url_map.strict_slashes = False CORS(flask_app) + flask_api = Api( flask_app, prefix=URL_PREFIX, @@ -136,10 +140,36 @@ consumes=["application/gml+xml", "application/json"], ) -# Set the security definition in an unconventional way -flask_api._swagger_object["securityDefinitions"] = { - "basicAuth": {"type": "basic"} -} -flask_api._swagger_object["security"] = [{"basicAuth": []}] - -auth = HTTPBasicAuth() +# authentication method +global_config.read(DEFAULT_CONFIG_PATH) +if global_config.KEYCLOAK_CONFIG_PATH: + auth = HTTPTokenAuth(scheme="Bearer") + flask_api._swagger_object["securityDefinitions"] = { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + } + } + # https://swagger.io/docs/specification/authentication/oauth2/ + flask_api._swagger_object["security"] = [ + { + "OAuth2": { + "type": "oauth2", + # "authorizationUrl": "http://swagger.io/api/oauth/dialog", + "tokenUrl": f"{global_config.KEYCLOAK_URL}/realms/" + f"{global_config.KEYCLOAK_REALM}/protocol/openid-connect/" + "token", + "flow": "implicit", + "scopes": {}, + } + } + ] +else: + # Set the security definition in an unconventional way + flask_api._swagger_object["securityDefinitions"] = { + "basicAuth": {"type": "basic"} + } + flask_api._swagger_object["security"] = [{"basicAuth": []}] + + auth = HTTPBasicAuth() diff --git a/src/actinia_core/core/common/config.py b/src/actinia_core/core/common/config.py index a665351e2..abacd4d5c 100644 --- a/src/actinia_core/core/common/config.py +++ b/src/actinia_core/core/common/config.py @@ -26,9 +26,10 @@ """ import ast -import os import configparser import csv +import os +from json import load as json_load __license__ = "GPLv3" __author__ = "Sören Gebbert, Anika Weinmann" @@ -320,6 +321,24 @@ def __init__(self): # ENDPOINTS_CONFIG: configuration csv file for endpoints self.ENDPOINTS_CONFIG = None + """ + KEYCLOAK: has only to be set if keycloak server is configured with + actinia client and actinia attributes for the users + """ + # Json file generated by keycloak with configuration, + # e.g. /etc/default/keycloak.json + self.KEYCLOAK_CONFIG_PATH = None + # e.g. /actinia-user/ + self.KEYCLOAK_GROUP_PREFIX = None + # Prefix to distinguish parameters inside keycloak from parameters + # used by other applications, e.g. actinia + self.KEYCLOAK_ATTR_PREFIX = None + # KEYCLOAK parameter read from json configured in KEYCLOAK_CONFIG_PATH + self.KEYCLOAK_URL = None + self.KEYCLOAK_CLIENT_ID = None + self.KEYCLOAK_REALM = None + self.KEYCLOAK_CLIENT_SECRET_KEY = None + """ REDIS """ @@ -470,6 +489,25 @@ def __str__(self): return string + def read_keycloak_config(self, key_cloak_config_path=None): + """Read keycloak configuration json""" + if key_cloak_config_path is None: + key_cloak_config_path = self.KEYCLOAK_CONFIG_PATH + if os.path.isfile(key_cloak_config_path): + with open(key_cloak_config_path) as f: + keycloak_cfg = json_load(f) + self.KEYCLOAK_URL = keycloak_cfg["auth-server-url"] + self.KEYCLOAK_REALM = keycloak_cfg["realm"] + self.KEYCLOAK_CLIENT_ID = keycloak_cfg["resource"] + self.KEYCLOAK_CLIENT_SECRET_KEY = keycloak_cfg["credentials"][ + "secret" + ] + else: + raise Exception( + "KEYCLOAK_CONFIG_PATH is not a valid keycloak configuration " + "for actinia" + ) + def write(self, path=DEFAULT_CONFIG_PATH): """Save the configuration into a file @@ -518,6 +556,43 @@ def write(self, path=DEFAULT_CONFIG_PATH): config.set("API", "PLUGINS", str(self.PLUGINS)) config.set("API", "ENDPOINTS_CONFIG", str(self.ENDPOINTS_CONFIG)) + config.add_section("KEYCLOAK") + config.set( + "KEYCLOAK", + "KEYCLOAK_CONFIG_PATH", + str(self.KEYCLOAK_CONFIG_PATH), + ) + config.set( + "KEYCLOAK", + "KEYCLOAK_GROUP_PREFIX", + str(self.KEYCLOAK_GROUP_PREFIX), + ) + config.set( + "KEYCLOAK", + "KEYCLOAK_ATTR_PREFIX", + str(self.KEYCLOAK_ATTR_PREFIX), + ) + config.set( + "KEYCLOAK", + "KEYCLOAK_URL", + str(self.KEYCLOAK_URL), + ) + config.set( + "KEYCLOAK", + "KEYCLOAK_CLIENT_ID", + str(self.KEYCLOAK_CLIENT_ID), + ) + config.set( + "KEYCLOAK", + "KEYCLOAK_REALM", + str(self.KEYCLOAK_REALM), + ) + config.set( + "KEYCLOAK", + "KEYCLOAK_CLIENT_SECRET_KEY", + str(self.KEYCLOAK_CLIENT_SECRET_KEY), + ) + config.add_section("REDIS") config.set("REDIS", "REDIS_SERVER_URL", self.REDIS_SERVER_URL) config.set("REDIS", "REDIS_SERVER_PORT", str(self.REDIS_SERVER_PORT)) @@ -726,6 +801,27 @@ def read(self, path=DEFAULT_CONFIG_PATH): "API", "ENDPOINTS_CONFIG" ) + if config.has_section("KEYCLOAK"): + if config.has_option("KEYCLOAK", "CONFIG_PATH"): + keycloak_cfg_path = config.get("KEYCLOAK", "CONFIG_PATH") + if os.path.isfile(keycloak_cfg_path): + self.KEYCLOAK_CONFIG_PATH = keycloak_cfg_path + self.read_keycloak_config() + else: + print( + "Keycloak is configured, but configfile is not " + "an existing file! Using Redis for user " + "management." + ) + if config.has_option("KEYCLOAK", "GROUP_PREFIX"): + self.KEYCLOAK_GROUP_PREFIX = config.get( + "KEYCLOAK", "GROUP_PREFIX" + ) + if config.has_option("KEYCLOAK", "ATTR_PREFIX"): + self.KEYCLOAK_ATTR_PREFIX = config.get( + "KEYCLOAK", "ATTR_PREFIX" + ) + if config.has_section("REDIS"): if config.has_option("REDIS", "REDIS_SERVER_URL"): self.REDIS_SERVER_URL = config.get( diff --git a/src/actinia_core/core/common/keycloak_user.py b/src/actinia_core/core/common/keycloak_user.py new file mode 100644 index 000000000..ccd85b852 --- /dev/null +++ b/src/actinia_core/core/common/keycloak_user.py @@ -0,0 +1,302 @@ +# -*- coding: utf-8 -*- +####### +# actinia-core - an open source REST API for scalable, distributed, high +# performance processing of geographical data that uses GRASS GIS for +# computational tasks. For details, see https://actinia.mundialis.de/ +# +# Copyright (c) 2016-2022 Sören Gebbert and mundialis GmbH & Co. KG +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +####### + +""" +Keycloak user management + +Possible TODOs: add list_all_users, delete, create_user (, exists). + In this case, keycloak admin console would be needed and actinia would + need to store keycloak admin credentials! +""" +from keycloak import KeycloakOpenID +from jose.exceptions import ExpiredSignatureError + +from actinia_core.core.common.user_base import ( + ActiniaUserBase, +) +from actinia_core.core.logging_interface import log +from actinia_core.core.common.config import global_config + +__author__ = "Anika Weinmann" +__copyright__ = "Copyright 2022, mundialis GmbH & Co. KG" +__maintainer__ = "mundialis GmbH & Co. KG" + + +def create_user_from_tokeninfo(token_info): + """ + Function to create a keycloak user from the keycloak token. + """ + attr_prefix = ( + global_config.KEYCLOAK_ATTR_PREFIX + if global_config.KEYCLOAK_ATTR_PREFIX is not None + else "" + ) + user_id = token_info["preferred_username"] + kwargs = { + "user_role": token_info["resource_access"][ + global_config.KEYCLOAK_CLIENT_ID + ]["roles"][0], + "cell_limit": token_info[f"{attr_prefix}cell_limit"], + "process_num_limit": token_info[f"{attr_prefix}process_num_limit"], + "process_time_limit": token_info[f"{attr_prefix}process_time_limit"], + } + acc_ds_name = f"{attr_prefix}accessible_datasets" + if token_info[acc_ds_name] and token_info[acc_ds_name] != "None": + kwargs["accessible_datasets"] = token_info[acc_ds_name] + acc_mod_name = f"{attr_prefix}accessible_modules" + if token_info[acc_mod_name] and token_info[acc_mod_name] != "None": + kwargs["accessible_modules"] = token_info[acc_mod_name] + groups = list() + for group in token_info["groups"]: + if group.startswith(global_config.KEYCLOAK_GROUP_PREFIX): + groups.append( + group.replace(global_config.KEYCLOAK_GROUP_PREFIX, "") + ) + if len(groups) == 0: + log.error(f"Authentication ERROR: User {user_id} has no user group.") + return None + elif len(groups) > 1: + log.warning( + f"User {user_id} has more than one group, taking {groups[0]}." + ) + kwargs["user_group"] = groups[0] + user = ActiniaKeycloakUser( + user_id, + **kwargs, + ) + user._generate_permission_dict() + # adding group members + if "group_members" in token_info and token_info["group_members"]: + user.set_group_members( + ",".join(token_info["group_members"]).split(",") + ) + return user + + +class ActiniaKeycloakUser(ActiniaUserBase): + """ + The Actinia Core keycloak user management class + + This class manages a user which is stored in keycloak + """ + + def __init__( + self, + user_id, + user_group=None, + user_role=None, + accessible_datasets={ + "nc_spm_08": ["PERMANENT", "user1", "landsat"], + "ECAD": ["PERMANENT"], + "latlong_wgs84": ["PERMANENT"], + }, + accessible_modules=global_config.MODULE_ALLOW_LIST, + cell_limit=global_config.MAX_CELL_LIMIT, + process_num_limit=global_config.PROCESS_NUM_LIMIT, + process_time_limit=global_config.PROCESS_TIME_LIMT, + ): + if isinstance(accessible_datasets, str): + datasets = dict() + lm_list = accessible_datasets.split(",") + for lm in lm_list: + if "/" in lm: + location, mapset = lm.split("/") + else: + location = lm + mapset = None + if location not in datasets: + datasets[location] = [] + datasets[location].append(mapset) + else: + datasets = accessible_datasets + if isinstance(accessible_modules, str): + modules = accessible_modules.split(",") + else: + modules = accessible_modules + super().__init__( + user_id, + user_group=user_group, + user_role=user_role, + accessible_datasets=datasets, + accessible_modules=modules, + cell_limit=cell_limit, + process_num_limit=process_num_limit, + process_time_limit=process_time_limit, + ) + self.group_members = list() + + @staticmethod + def verify_keycloak_token(token): + keycloak_openid = KeycloakOpenID( + server_url=global_config.KEYCLOAK_URL, + client_id=global_config.KEYCLOAK_CLIENT_ID, + realm_name=global_config.KEYCLOAK_REALM, + client_secret_key=global_config.KEYCLOAK_CLIENT_SECRET_KEY, + ) + KEYCLOAK_PUBLIC_KEY = ( + "-----BEGIN PUBLIC KEY-----\n" + + keycloak_openid.public_key() + + "\n-----END PUBLIC KEY-----" + ) + options = { + "verify_signature": True, + "verify_aud": True, + "verify_exp": True, + } + try: + token_info = keycloak_openid.decode_token( + token, key=KEYCLOAK_PUBLIC_KEY, options=options + ) + except ExpiredSignatureError: + return None + except Exception: + return None + return create_user_from_tokeninfo(token_info) + + def set_group_members(self, group_members): + """Set the user group_members + + Args: + group: The user group_members + + """ + self.group_members = group_members + + def check_group_members(self, user_id): + """Check if the user_id is in the group_members attribute. + + Args: + user_id (str): The id (name, email, ..) of the user that must be + unique + + Returns: + bool: + Return the if the user_id is in the group_members + """ + return user_id in self.group_members + + # def exists(self): # rest/user_management ??? + # """Check if the user exists + # + # Returns: + # bool: + # True if the user exists, False otherwise + # """ + # if self.user_id is None: + # return False + # + # return self.db.exists(self.user_id) + + def get_role(self): + """Return the role + + Returns: + str: + Return the role + """ + return self.user_role + + def get_group(self): + """Return the user group + + Returns: + str: + Return the user group + """ + return self.user_group + + def get_credentials(self): + """Return the user credentials as a dictionary + + Returns: + dict: + Return the user credentials as a dictionary + """ + self._generate_permission_dict() + credentials = { + "user_id": self.user_id, + "user_role": self.user_role, + "user_group": self.user_group, + "permissions": self.permissions, + } + return credentials + + def get_accessible_datasets(self): + """Return a dictionary of location:mapset list entries + + Returns: + dict: + Return a dictionary of location:mapset list entries + """ + self._generate_permission_dict() + + if self.permissions and "accessible_datasets" in self.permissions: + return self.permissions["accessible_datasets"] + + def get_accessible_modules(self): + """Return a list of all accessible modules + + Returns: + list: + Return a list of all accessible modules + """ + self._generate_permission_dict() + + if self.permissions and "accessible_modules" in self.permissions: + return self.permissions["accessible_modules"] + + def get_cell_limit(self): + """Return the cell limit + + Returns: + int: + The value or None if nothing was found + """ + self._generate_permission_dict() + + if self.permissions and "cell_limit" in self.permissions: + return self.permissions["cell_limit"] + + def get_process_num_limit(self): + """Return the process number limit + + Returns: + int: + The value or None if nothing was found + """ + self._generate_permission_dict() + + if self.permissions and "process_num_limit" in self.permissions: + return self.permissions["process_num_limit"] + + def get_process_time_limit(self): + """Return the process time limit + + Returns: + int: + The value or None if nothing was found + """ + self._generate_permission_dict() + + if self.permissions and "process_time_limit" in self.permissions: + return self.permissions["process_time_limit"] diff --git a/src/actinia_core/core/common/user.py b/src/actinia_core/core/common/user.py index 6747ef372..b0b33feb9 100644 --- a/src/actinia_core/core/common/user.py +++ b/src/actinia_core/core/common/user.py @@ -32,27 +32,16 @@ from datetime import datetime, timezone, timedelta from actinia_core.core.common.config import global_config from actinia_core.core.redis_user import redis_user_interface - -__author__ = "Sören Gebbert" -__copyright__ = ( - "Copyright 2016-2018, Sören Gebbert and mundialis GmbH & Co. KG" +from actinia_core.core.common.user_base import ( + ActiniaUserBase, ) -__maintainer__ = "Sören Gebbert" -__email__ = "soerengebbert@googlemail.com" - - -USER_ROLES = ["superadmin", "admin", "user", "guest"] - -class ActiniaUserError(Exception): - """Raise this exception in case a user creation error happens""" +__author__ = "Sören Gebbert, Anika Weinmann" +__copyright__ = "Copyright 2016-2022, mundialis GmbH & Co. KG" +__maintainer__ = "mundialis GmbH & Co. KG" - def __init__(self, message): - message = "%s: %s" % (str(self.__class__.__name__), message) - Exception.__init__(self, message) - -class ActiniaUser(object): +class ActiniaUser(ActiniaUserBase): """ The Actinia Core user management class @@ -62,80 +51,6 @@ class ActiniaUser(object): db = redis_user_interface - def __init__( - self, - user_id, - user_group=None, - user_role=None, - accessible_datasets={ - "nc_spm_08": ["PERMANENT", "user1", "landsat"], - "ECAD": ["PERMANENT"], - "latlong_wgs84": ["PERMANENT"], - }, - accessible_modules=global_config.MODULE_ALLOW_LIST, - cell_limit=global_config.MAX_CELL_LIMIT, - process_num_limit=global_config.PROCESS_NUM_LIMIT, - process_time_limit=global_config.PROCESS_TIME_LIMT, - ): - """Constructor - - Initialize and create a user object. To commit a new user to the - database, set all required permissions and call the commit() function. - - To read the data of an existing user, simple initialize the constructor - with the user_id and call read_from_db(). - - Args: - user_id (str): The id (name, email, ..) of the user that must be - unique - user_group (str): The group of the user - user_role (str): The user role (superadmin, admin, user, guest) - accessible_datasets (dict): Dict of location:mapset lists - accessible_modules (list): A list of modules that are allowed to - use - cell_limit (int): Maximum number of cells to process - process_num_limit (int): The maximum number of processes the user - is allowed to run in a single chain - process_time_limit (int): The maximum number of seconds a user - process is allowed to run - - """ - - self.user_id = user_id - self.user_group = user_group - self.password_hash = None - self.user_role = None - self.permissions = None - self.cell_limit = None - self.accessible_datasets = {} - self.accessible_modules = [] - self.process_num_limit = None - self.process_time_limit = None - - if user_role: - self.set_role(user_role) - if accessible_datasets is not None: - self.set_accessible_datasets(accessible_datasets) - if accessible_modules is not None: - self.set_accessible_modules(accessible_modules) - if cell_limit is not None: - self.set_cell_limit(cell_limit) - if process_num_limit is not None: - self.set_process_num_limit(process_num_limit) - if process_time_limit is not None: - self.set_process_time_limit(process_time_limit) - - def _generate_permission_dict(self): - """Create the permission dictionary""" - - self.permissions = { - "accessible_datasets": self.accessible_datasets, - "accessible_modules": self.accessible_modules, - "cell_limit": self.cell_limit, - "process_num_limit": self.process_num_limit, - "process_time_limit": self.process_time_limit, - } - def read_from_db(self): creds = self.db.get_credentials(self.user_id) @@ -150,219 +65,6 @@ def read_from_db(self): self.process_num_limit = creds["permissions"]["process_num_limit"] self.process_time_limit = creds["permissions"]["process_time_limit"] - def set_role(self, role): - """Set the user role - - Args: - role: The user role, can be admin, user or guest - - Raises: - ActiniaUserError in case the role is not supported - - The following roles are supported: - USER_ROLES = ["superadmin", - "admin", - "user", - "guest"] - """ - if role not in USER_ROLES: - raise ActiniaUserError( - "Unsupported user role <%s> supported are %s" - % (role, str(USER_ROLES)) - ) - self.user_role = role - - def has_guest_role(self): - return self.get_role() == "guest" - - def has_user_role(self): - return self.get_role() == "user" - - def has_admin_role(self): - return self.get_role() == "admin" - - def has_superadmin_role(self): - return self.get_role() == "superadmin" - - def set_group(self, group): - """Set the user group - - Args: - group: The user group - - """ - self.user_group = group - - def set_accessible_datasets(self, accessible_datasets): - """Set the accessible datasets - - Args: - accessible_datasets (dict): - - Example:: - - {"nc_spm_08":["PERMANENT", - "user1", - "landsat"], - "utm32N":["PERMANENT", - "sentinel2_bonn"] - } - - """ - self.accessible_datasets = accessible_datasets - - def add_accessible_dataset(self, location_name, mapset_list): - """Add a dataset to the accessible datasets - - If the dataset exists, the mapsets will be extended by the provided - list - - Args: - location_name (str): Location name - mapset_list (list): List of mapset names - - Example:: - - location_name="nc_spm_08" - - mapset_list = ["PERMANENT", - "user1", - "landsat"] - - """ - - if location_name not in self.accessible_datasets: - self.accessible_datasets[location_name] = mapset_list - else: - for mapset in mapset_list: - if mapset not in self.accessible_datasets[location_name]: - self.accessible_datasets[location_name].append(mapset) - - def remove_mapsets_from_location(self, location_name, mapset_list): - """Remove mapsets from an existing location - - Args: - location_name (str): Location name - mapset_list (list): List of mapset names that should be removed - - Example:: - - location_name="nc_spm_08" - - mapset_list = ["landsat",] - - """ - - if location_name in self.accessible_datasets: - for mapset in mapset_list: - if mapset in self.accessible_datasets[location_name]: - self.accessible_datasets[location_name].remove(mapset) - - def remove_location(self, location_name): - """Remove a location from the accessible datasets - - Args: - location_name (str): Location name - - Example:: - - location_name="nc_spm_08" - - """ - - if location_name in self.accessible_datasets: - self.accessible_datasets.pop(location_name) - - def set_accessible_modules(self, accessible_modules): - """Set the accessible modules - - Args: - accessible_modules (list): A list of module names - - Example:: - - ["g.region", "i.vi"] - - """ - self.accessible_modules = accessible_modules - - def add_accessible_modules(self, module_names): - """Set the accessible modules - - Args: - module_names (list): A list of module names - - Example:: - - ["g.region", "i.vi"] - - """ - for name in module_names: - if name not in self.accessible_modules: - self.accessible_modules.append(name) - - def remove_accessible_modules(self, module_names): - """Remove accessible modules from the list - - Args: - module_names (list): A list of module names that should be removed - - Example:: - - ["g.region", "i.vi"] - - """ - for name in module_names: - if name in self.accessible_modules: - self.accessible_modules.remove(name) - - def set_cell_limit(self, cell_limit): - """Set the maximum number of cells that the user is allowed to process - - Args: - cell_limit (int): - - Raises: - ActiniaUserError in case the cell_limit is not supported - """ - - try: - self.cell_limit = int(cell_limit) - except Exception: - raise ActiniaUserError("Wrong format for cell limit") - - def set_process_num_limit(self, process_num_limit): - """Set the maximum number of processes that the user is allowed to run - in a single process chain - - Args: - process_num_limit (int): - - Raises: - ActiniaUserError in case the cell_limit is not supported - """ - - try: - self.process_num_limit = int(process_num_limit) - except Exception: - raise ActiniaUserError("Wrong format for process_num_limit") - - def set_process_time_limit(self, process_time_limit): - """Set the maximum number of seconds that the user is allowed to run - a single process - - Args: - process_time_limit (int): - - Raises: - ActiniaUserError in case the process_time_limit is not supported - """ - - try: - self.process_time_limit = int(process_time_limit) - except Exception: - raise ActiniaUserError("Wrong format for process_time_limit") - def __str__(self): creds = self.get_credentials() @@ -387,9 +89,6 @@ def exists(self): return self.db.exists(self.user_id) - def get_id(self): - return self.user_id - def verify_password(self, password): """ Verify the provided password with the stored passord hash diff --git a/src/actinia_core/core/common/user_base.py b/src/actinia_core/core/common/user_base.py new file mode 100644 index 000000000..6d231f5a3 --- /dev/null +++ b/src/actinia_core/core/common/user_base.py @@ -0,0 +1,343 @@ +# -*- coding: utf-8 -*- +####### +# actinia-core - an open source REST API for scalable, distributed, high +# performance processing of geographical data that uses GRASS GIS for +# computational tasks. For details, see https://actinia.mundialis.de/ +# +# Copyright (c) 2016-2022 Sören Gebbert and mundialis GmbH & Co. KG +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +####### + +""" +User management base +""" + +from actinia_core.core.common.config import global_config + +__author__ = "Sören Gebbert, Anika Weinmann" +__copyright__ = ( + "Copyright 2016-2022, Sören Gebbert and mundialis GmbH & Co. KG" +) +__maintainer__ = "mundialis GmbH & Co. KG" + + +USER_ROLES = ["superadmin", "admin", "user", "guest"] + + +class ActiniaUserError(Exception): + """Raise this exception in case a user creation error happens""" + + def __init__(self, message): + message = "%s: %s" % (str(self.__class__.__name__), message) + Exception.__init__(self, message) + + +class ActiniaUserBase(object): + """ + The Actinia Core user management base class + + This class manages the user base where the users stored in Redis database + or keycloak are based on. + """ + + def __init__( + self, + user_id, + user_group=None, + user_role=None, + accessible_datasets={ + "nc_spm_08": ["PERMANENT", "user1", "landsat"], + "ECAD": ["PERMANENT"], + "latlong_wgs84": ["PERMANENT"], + }, + accessible_modules=global_config.MODULE_ALLOW_LIST, + cell_limit=global_config.MAX_CELL_LIMIT, + process_num_limit=global_config.PROCESS_NUM_LIMIT, + process_time_limit=global_config.PROCESS_TIME_LIMT, + ): + """Constructor + + Initialize and create a user object. + + Args: + user_id (str): The id (name, email, ..) of the user that must be + unique + user_group (str): The group of the user + user_role (str): The user role (superadmin, admin, user, guest) + accessible_datasets (dict): Dict of location:mapset lists + accessible_modules (list): A list of modules that are allowed to + use + cell_limit (int): Maximum number of cells to process + process_num_limit (int): The maximum number of processes the user + is allowed to run in a single chain + process_time_limit (int): The maximum number of seconds a user + process is allowed to run + + """ + + self.user_id = user_id + self.user_group = user_group + self.password_hash = None + self.user_role = None + self.permissions = None + self.cell_limit = None + self.accessible_datasets = {} + self.accessible_modules = [] + self.process_num_limit = None + self.process_time_limit = None + + if user_role: + self.set_role(user_role) + if accessible_datasets is not None: + self.set_accessible_datasets(accessible_datasets) + if accessible_modules is not None: + self.set_accessible_modules(accessible_modules) + if cell_limit is not None: + self.set_cell_limit(cell_limit) + if process_num_limit is not None: + self.set_process_num_limit(process_num_limit) + if process_time_limit is not None: + self.set_process_time_limit(process_time_limit) + + def _generate_permission_dict(self): + """Create the permission dictionary""" + + self.permissions = { + "accessible_datasets": self.accessible_datasets, + "accessible_modules": self.accessible_modules, + "cell_limit": self.cell_limit, + "process_num_limit": self.process_num_limit, + "process_time_limit": self.process_time_limit, + } + + def set_role(self, role): + """Set the user role + + Args: + role: The user role, can be admin, user or guest + + Raises: + ActiniaUserError in case the role is not supported + + The following roles are supported: + USER_ROLES = ["superadmin", + "admin", + "user", + "guest"] + """ + if role not in USER_ROLES: + raise ActiniaUserError( + "Unsupported user role <%s> supported are %s" + % (role, str(USER_ROLES)) + ) + self.user_role = role + + def get_role(self): + return self.user_role + + def has_guest_role(self): + return self.get_role() == "guest" + + def has_user_role(self): + return self.get_role() == "user" + + def has_admin_role(self): + return self.get_role() == "admin" + + def has_superadmin_role(self): + return self.get_role() == "superadmin" + + def set_group(self, group): + """Set the user group + + Args: + group: The user group + + """ + self.user_group = group + + def set_accessible_datasets(self, accessible_datasets): + """Set the accessible datasets + + Args: + accessible_datasets (dict): + + Example:: + + {"nc_spm_08":["PERMANENT", + "user1", + "landsat"], + "utm32N":["PERMANENT", + "sentinel2_bonn"] + } + + """ + self.accessible_datasets = accessible_datasets + + def add_accessible_dataset(self, location_name, mapset_list): + """Add a dataset to the accessible datasets + + If the dataset exists, the mapsets will be extended by the provided + list + + Args: + location_name (str): Location name + mapset_list (list): List of mapset names + + Example:: + + location_name="nc_spm_08" + + mapset_list = ["PERMANENT", + "user1", + "landsat"] + + """ + + if location_name not in self.accessible_datasets: + self.accessible_datasets[location_name] = mapset_list + else: + for mapset in mapset_list: + if mapset not in self.accessible_datasets[location_name]: + self.accessible_datasets[location_name].append(mapset) + + def remove_mapsets_from_location(self, location_name, mapset_list): + """Remove mapsets from an existing location + + Args: + location_name (str): Location name + mapset_list (list): List of mapset names that should be removed + + Example:: + + location_name="nc_spm_08" + + mapset_list = ["landsat",] + + """ + + if location_name in self.accessible_datasets: + for mapset in mapset_list: + if mapset in self.accessible_datasets[location_name]: + self.accessible_datasets[location_name].remove(mapset) + + def remove_location(self, location_name): + """Remove a location from the accessible datasets + + Args: + location_name (str): Location name + + Example:: + + location_name="nc_spm_08" + + """ + + if location_name in self.accessible_datasets: + self.accessible_datasets.pop(location_name) + + def set_accessible_modules(self, accessible_modules): + """Set the accessible modules + + Args: + accessible_modules (list): A list of module names + + Example:: + + ["g.region", "i.vi"] + + """ + self.accessible_modules = accessible_modules + + def add_accessible_modules(self, module_names): + """Set the accessible modules + + Args: + module_names (list): A list of module names + + Example:: + + ["g.region", "i.vi"] + + """ + for name in module_names: + if name not in self.accessible_modules: + self.accessible_modules.append(name) + + def remove_accessible_modules(self, module_names): + """Remove accessible modules from the list + + Args: + module_names (list): A list of module names that should be removed + + Example:: + + ["g.region", "i.vi"] + + """ + for name in module_names: + if name in self.accessible_modules: + self.accessible_modules.remove(name) + + def set_cell_limit(self, cell_limit): + """Set the maximum number of cells that the user is allowed to process + + Args: + cell_limit (int): + + Raises: + ActiniaUserError in case the cell_limit is not supported + """ + + try: + self.cell_limit = int(cell_limit) + except Exception: + raise ActiniaUserError("Wrong format for cell limit") + + def set_process_num_limit(self, process_num_limit): + """Set the maximum number of processes that the user is allowed to run + in a single process chain + + Args: + process_num_limit (int): + + Raises: + ActiniaUserError in case the cell_limit is not supported + """ + + try: + self.process_num_limit = int(process_num_limit) + except Exception: + raise ActiniaUserError("Wrong format for process_num_limit") + + def set_process_time_limit(self, process_time_limit): + """Set the maximum number of seconds that the user is allowed to run + a single process + + Args: + process_time_limit (int): + + Raises: + ActiniaUserError in case the process_time_limit is not supported + """ + + try: + self.process_time_limit = int(process_time_limit) + except Exception: + raise ActiniaUserError("Wrong format for process_time_limit") + + def get_id(self): + return self.user_id diff --git a/src/actinia_core/rest/base/user_auth.py b/src/actinia_core/rest/base/user_auth.py index 8e211d5d5..b229d775a 100644 --- a/src/actinia_core/rest/base/user_auth.py +++ b/src/actinia_core/rest/base/user_auth.py @@ -31,6 +31,7 @@ from flask import g, abort from actinia_core.core.common.config import global_config from actinia_core.core.common.app import auth +from actinia_core.core.common.keycloak_user import ActiniaKeycloakUser from actinia_core.core.common.user import ActiniaUser from actinia_core.core.messages_logger import MessageLogger @@ -42,38 +43,60 @@ __maintainer__ = "mundialis" -@auth.verify_password -def verify_password(username_or_token, password): - """Verify the user name and password. +if global_config.KEYCLOAK_CONFIG_PATH: + + @auth.verify_token + def verify_token(token): + """Verify the keycloak token. + A keycloak authentication token has to be provided. + This function is called by the + @auth.login_required decorator. + Args: + token (str): An authentication token + Returns: + bool: True if authorized or False if not + """ + user = ActiniaKeycloakUser.verify_keycloak_token(token) + if not user: + return False + g.user = user + return True - Instead of a user name an authentication token - or an API token can be provided. - This function is called by the - @auth.login_required decorator. - Args: - username_or_token (str): The username or an authentication token - password (str): The optional user password, not required in case of - token +if global_config.KEYCLOAK_CONFIG_PATH is None: - Returns: - bool: True if authorized or False if not + @auth.verify_password + def verify_password(username_or_token, password): + """Verify the user name and password. - """ - # first try to authenticate by token - user = ActiniaUser.verify_auth_token(username_or_token) + Instead of a user name an authentication token + or an API token can be provided. + This function is called by the + @auth.login_required decorator. - if not user: - user = ActiniaUser.verify_api_key(username_or_token) + Args: + username_or_token (str): The username or an authentication token + password (str): The optional user password, not required in case of + token - if not user: - # try to authenticate with username/password - user = ActiniaUser(user_id=username_or_token) - if not user.exists() or not user.verify_password(password): - return False - # Store the user globally - g.user = user - return True + Returns: + bool: True if authorized or False if not + + """ + # first try to authenticate by token + user = ActiniaUser.verify_auth_token(username_or_token) + + if not user: + user = ActiniaUser.verify_api_key(username_or_token) + + if not user: + # try to authenticate with username/password + user = ActiniaUser(user_id=username_or_token) + if not user.exists() or not user.verify_password(password): + return False + # Store the user globally + g.user = user + return True def create_dummy_user(f): diff --git a/src/actinia_core/rest/resource_management.py b/src/actinia_core/rest/resource_management.py index 26e245bfc..93257a257 100644 --- a/src/actinia_core/rest/resource_management.py +++ b/src/actinia_core/rest/resource_management.py @@ -124,6 +124,23 @@ def check_permissions(self, user_id): ), 401, ) + + if global_config.KEYCLOAK_CONFIG_PATH and self.user_role == "admin": + if self.user.check_group_members(user_id): + return None + else: + return make_response( + jsonify( + SimpleResponseModel( + status="error", + message="You do not have the permission to access " + "this resource. Wrong user group. (Maybe you can c" + "heck the group_members in the group attributes.)", + ) + ), + 401, + ) + new_user = ActiniaUser(user_id=user_id) # Check if the user exists diff --git a/src/actinia_core/rest/user_management.py b/src/actinia_core/rest/user_management.py index d1936e780..21e101d07 100644 --- a/src/actinia_core/rest/user_management.py +++ b/src/actinia_core/rest/user_management.py @@ -30,11 +30,12 @@ Implement PUT to modify existing users """ -from flask import jsonify, make_response +from flask import jsonify, make_response, g from flask_restful import reqparse from flask_restful_swagger_2 import swagger from actinia_api.swagger2.actinia_core.apidocs import user_management +from actinia_core.core.common.config import global_config from actinia_core.rest.base.endpoint_config import ( check_endpoint, endpoint_decorator, @@ -81,6 +82,18 @@ def get(self): flask.Response: A HTTP response with JSON payload containing a list of users """ + if global_config.KEYCLOAK_CONFIG_PATH: + return make_response( + jsonify( + SimpleResponseModel( + status="error", + message="The keycloak authentication does not allow " + "to request all users", + ) + ), + 400, + ) + user = ActiniaUser(None) user_list = user.list_all_users() @@ -123,18 +136,31 @@ def get(self, user_id): JSON payload containing the credentials of the user """ - user = ActiniaUser(user_id) - - if user.exists() != 1: - return make_response( - jsonify( - SimpleResponseModel( - status="error", - message="User <%s> does not exist" % user_id, - ) - ), - 400, - ) + if global_config.KEYCLOAK_CONFIG_PATH: + user = g.user + if user.user_id != user_id: + return make_response( + jsonify( + SimpleResponseModel( + status="error", + message="The keycloak authentication does not " + "allow to request another user.", + ) + ), + 400, + ) + else: + user = ActiniaUser(user_id) + if user.exists() != 1: + return make_response( + jsonify( + SimpleResponseModel( + status="error", + message="User <%s> does not exist" % user_id, + ) + ), + 400, + ) credentials = user.get_credentials() @@ -168,6 +194,17 @@ def post(self, user_id): JSON payload containing the status and messages """ + if global_config.KEYCLOAK_CONFIG_PATH: + return make_response( + jsonify( + SimpleResponseModel( + status="error", + message="The keycloak authentication does not allow " + "to create a new user", + ) + ), + 400, + ) user = ActiniaUser(user_id) @@ -244,6 +281,18 @@ def delete(self, user_id): JSON payload containing the status and messages """ + if global_config.KEYCLOAK_CONFIG_PATH: + return make_response( + jsonify( + SimpleResponseModel( + status="error", + message="The keycloak authentication does not allow " + "to delete a user", + ) + ), + 400, + ) + user = ActiniaUser(user_id) if user.exists() != 1: diff --git a/tests/unittests/keycloak.json b/tests/unittests/keycloak.json new file mode 100644 index 000000000..14fddd287 --- /dev/null +++ b/tests/unittests/keycloak.json @@ -0,0 +1,12 @@ +{ + "realm": "actinia-realm", + "auth-server-url": "http://keycloak:8080/auth/", + "ssl-required": "external", + "resource": "actinia-client", + "verify-token-audience": true, + "credentials": { + "secret": "SECRET" + }, + "use-resource-role-mappings": true, + "confidential-port": 0 +} diff --git a/tests/unittests/test_keycloak_user.py b/tests/unittests/test_keycloak_user.py new file mode 100644 index 000000000..5f9eb72cc --- /dev/null +++ b/tests/unittests/test_keycloak_user.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +####### +# actinia-core - an open source REST API for scalable, distributed, high +# performance processing of geographical data that uses GRASS GIS for +# computational tasks. For details, see https://actinia.mundialis.de/ +# +# Copyright (c) 2016-2022 Sören Gebbert and mundialis GmbH & Co. KG +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +####### + +""" +Tests: Version unittest case +""" +import os +import pytest +from datetime import datetime, timedelta + +from actinia_core.core.common.config import global_config +from actinia_core.core.common.keycloak_user import ( + ActiniaKeycloakUser, + create_user_from_tokeninfo, +) + +__license__ = "GPLv3" +__author__ = "Anika Weinmann" +__copyright__ = "Copyright 2022, mundialis GmbH & Co. KG" +__maintainer__ = "mundialis" + + +@pytest.mark.dev +@pytest.mark.unittest +def test_keycloak_superadmin(): + keycloak_config_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "keycloak.json" + ) + global_config.KEYCLOAK_GROUP_PREFIX = "/actinia-user/" + global_config.read_keycloak_config(keycloak_config_path) + time = datetime.now() + token_info = { + "iat": time.strftime("%s"), + "exp": (time + timedelta(minutes=5)).strftime("%s"), + "jti": "efa087ce-6853-49b5-9033-3d344994d779", + "iss": "http://keycloak:8080/auth/realms/actinia-realm", + "aud": ["actinia-client", "account"], + "sub": "1ddd2171-3ad5-4a8a-b37e-a46b3d98a0b1", + "typ": "Bearer", + "azp": "actinia-client", + "session_state": "ebeca9f2-653d-4ddd-a9a5-bf563b3eae7a", + "acr": "1", + "allowed-origins": ["http://actinia_core:8088"], + "realm_access": { + "roles": [ + "offline_access", + "default-roles-actinia-realm", + "uma_authorization", + ] + }, + "resource_access": { + "actinia-client": {"roles": ["superadmin"]}, + "account": { + "roles": [ + "manage-account", + "manage-account-links", + "view-profile", + ] + }, + }, + "scope": "email profile", + "sid": "ebeca9f2-653d-4ddd-a9a5-bf563b3eae7a", + "process_num_limit": 1000, + "accessible_modules": "None", + "email_verified": False, + "group_members": ["actinia-admin,actinia-superadmin,actinia-user"], + "accessible_datasets": "None", + "cell_limit": 100000000000, + "name": "actinia-superadmin actinia-superadmin", + "groups": ["/actinia-user/actinia_test_group_2"], + "preferred_username": "actinia-superadmin", + "given_name": "actinia-superadmin", + "family_name": "actinia-superadmin", + "process_time_limit": 31536000, + } + + user = create_user_from_tokeninfo(token_info) + + assert isinstance(user, ActiniaKeycloakUser), "User has wrong type" + assert user.check_group_members("actinia-admin"), ( + "'actinia-admin' not " "in group members" + ) + assert ( + user.check_group_members("actinia-admin2") is False + ), "'actinia-admin2' in group members" + assert user.get_role() == "superadmin", "Role is wrong" + assert user.get_group() == "actinia_test_group_2", "Group is wrong" + assert ( + "accessible_modules" in user.get_credentials()["permissions"] + ), "accessible_modules not in user credentials" + assert ( + len(user.get_accessible_datasets()) == 3 + ), "Accessible datasets are wrong" + assert ( + len(user.get_accessible_modules()) == 193 + ), "Accessible modules are wrong" + assert ( + user.get_cell_limit() == token_info["cell_limit"] + ), "Cell limit is wrong" + assert ( + user.get_process_num_limit() == token_info["process_num_limit"] + ), "Process number limit is wrong" + assert ( + user.get_process_time_limit() == token_info["process_time_limit"] + ), "Process time limit is wrong" + assert user.has_guest_role() is False, "Role is wrong" + assert user.has_user_role() is False, "Role is wrong" + assert user.has_admin_role() is False, "Role is wrong" + assert user.has_superadmin_role() is True, "Role is wrong"