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"