diff --git a/.clog.toml b/.clog.toml old mode 100644 new mode 100755 diff --git a/.github/workflows/dummy-renovate.yml b/.github/workflows/dummy-renovate.yml old mode 100644 new mode 100755 diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/.env.in b/FastapiOpenRestyConfigurator/.env.in old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/.requirements.txt.kate-swp b/FastapiOpenRestyConfigurator/.requirements.txt.kate-swp deleted file mode 100644 index 7692843..0000000 Binary files a/FastapiOpenRestyConfigurator/.requirements.txt.kate-swp and /dev/null differ diff --git a/FastapiOpenRestyConfigurator/app/__init__.py b/FastapiOpenRestyConfigurator/app/__init__.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/app/main/__init__.py b/FastapiOpenRestyConfigurator/app/main/__init__.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/app/main/config.py b/FastapiOpenRestyConfigurator/app/main/config.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/app/main/model/__init__.py b/FastapiOpenRestyConfigurator/app/main/model/__init__.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/app/main/model/serializers.py b/FastapiOpenRestyConfigurator/app/main/model/serializers.py old mode 100644 new mode 100755 index 8afbb40..b359e29 --- a/FastapiOpenRestyConfigurator/app/main/model/serializers.py +++ b/FastapiOpenRestyConfigurator/app/main/model/serializers.py @@ -28,9 +28,11 @@ owner_regex = r'^[a-zA-Z0-9@.-]{30,}$' user_key_url_regex = r"^[a-zA-Z0-9_-]{3,25}$" -upstream_url_regex = r"^(https?)://(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d{1,5})$" +upstream_url_regex = r"^(https?)://(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d{1,5})(/[a-zA-Z0-9_-]+/)?$" +# TODO: needs refactoring to comply with python 3.10 and pydantic v2 + class BackendBase(BaseModel): """ Base class for backend. @@ -53,6 +55,12 @@ class BackendBase(BaseModel): description="Version of the template the backend refers to.", example="v04" ) + auth_enabled: bool = Field( + True, + title="Authorization for the research environment", + description="If set to true, only the owner of the backend is allowed to access it.", + example=False + ) @validator("owner") def owner_validation(cls, owner): @@ -82,7 +90,7 @@ class BackendIn(BackendBase): ..., title="Upstream URL", description="Inject the full url (with protocol) for the real location of the backend service in the template.", - example="http://192.168.0.1:8787/" + example="http://192.168.0.1:8787/guacamole/" ) @validator("user_key_url") @@ -113,7 +121,7 @@ class BackendOut(BackendBase): """ Backend class which holds information needed when returning a backend. """ - id: int = Field( + id: int = Field( # TODO: needs refactoring: change type to int and rename to backend_id ..., title="ID", description="ID of the backend.", @@ -136,13 +144,15 @@ class BackendTemp(BackendIn, BackendOut): """ Backend class to temporarily save information. Links BackendIn with BackendOut. """ - id: int = None + id: int = None # TODO: also needs refactoring: change type to int and rename to backend_id owner: str = None location_url: str = None template: str = None template_version: str = None user_key_url: str = None upstream_url: str = None + auth_enabled: bool = None + file_path: str = None class Template(BaseModel): diff --git a/FastapiOpenRestyConfigurator/app/main/service/__init__.py b/FastapiOpenRestyConfigurator/app/main/service/__init__.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/app/main/service/backend.py b/FastapiOpenRestyConfigurator/app/main/service/backend.py old mode 100644 new mode 100755 index 51e425f..7f747ed --- a/FastapiOpenRestyConfigurator/app/main/service/backend.py +++ b/FastapiOpenRestyConfigurator/app/main/service/backend.py @@ -15,58 +15,124 @@ from ..config import get_settings logger = logging.getLogger("service") -settings = get_settings() -file_regex = r"(\d*)%([a-z0-9\-\@.]*?)%([^%]*)%([^%]*)%([^%]*)\.conf" +# format of filename saves information of BackendOut by this schema: +# {id}%{owner}%{location_url}%{template}%{template_version}%{auth_enabled}.conf +filename_regex = r"(\d*)%([a-z0-9\-\@.]*?)%([^%]*)%([^%]*)%([^%]*)%([01])\.conf" -async def random_with_n_digits(n): + + +# HELPER FUNCTIONS + +def random_with_n_digits(n): + # used for backend id generation (n=10), never starts with 0 range_start = 10 ** (n - 1) range_end = (10 ** n) - 1 return randint(range_start, range_end) +# TODO: unlikely but potential error cause, if two users have same randomly generated user_key_url! +async def generate_suffix_number(location_url: str | None = None) -> int: + if location_url is None: + return 100 + # extract current suffix number, check validity + current_suffix_number: int = int(location_url.split("_")[1]) + if current_suffix_number < 100 or current_suffix_number > 999: + logger.error("Invalid user_key_url provided for suffix generation: " + location_url) + raise InternalServerError("Invalid user_key_url provided for suffix generation.") + # look for backends with same user_key_url + backends: List[BackendOut] = await get_backends() + same_name_backend_suffixes: List[int] = [] + for backend in backends: + if backend.location_url == location_url: + suffix: int = int(backend.location_url.split("_")[1]) + same_name_backend_suffixes.append(suffix) + if not same_name_backend_suffixes: + return 100 + + # return highest found suffix number + 1 to iterate + same_name_backend_suffixes.sort() + highest_id: int = same_name_backend_suffixes[-1] + if highest_id == 999: + logger.warning("Reached max index number for requested user_key_url: " + location_url) + raise InternalServerError("Reached max index number for requested user_key_url (limit=999).") + return highest_id + 1 + + +def generate_backend_filename(backend: BackendOut) -> str: + b: BackendOut = backend + if b.id and b.owner and b.location_url and b.template and b.template_version and b.auth_enabled is not None: + return f"{str(b.id)}%{b.owner}%{b.location_url}%{b.template}%{b.template_version}%{str(int(b.auth_enabled))}.conf" + else: + logger.error("Not all necessary backend fields are set for filename generation: " + str(backend)) + raise InternalServerError("Filename generation failed.") + + + +# CORE GETTER FUNCTIONS + async def get_backends() -> List[BackendOut]: + settings = get_settings() if not os.path.exists(settings.FORC_BACKEND_PATH) and not os.access(settings.FORC_BACKEND_PATH, os.W_OK): logger.error("Not able to access configured backend path.") return [] if len(os.listdir(settings.FORC_BACKEND_PATH)) == 0: return [] - backend_path_files = os.listdir(settings.FORC_BACKEND_PATH) - logger.info(backend_path_files) + backend_path_filenames = os.listdir(settings.FORC_BACKEND_PATH) + logger.info(f"Files in backend_path: {backend_path_filenames}") valid_backends = [] - for file in backend_path_files: - match = re.fullmatch(file_regex, file) + for filename in backend_path_filenames: + match = re.fullmatch(filename_regex, filename) if not match: - logger.warning("Found a backend file with wrong naming, skipping it: " + str(file)) + if filename == "users" or filename == "scripts": + continue + logger.warning("Found a backend file with wrong naming, skipping it: " + str(filename)) continue backend: BackendOut = BackendOut( - id=match.group(1), - owner=match.group(2), - location_url=match.group(3), - template=match.group(4), - template_version=match.group(5), - file_path=os.path.join(settings.FORC_BACKEND_PATH, file) + id = int(match.group(1)), + owner = match.group(2), + location_url = match.group(3), + template = match.group(4), + template_version = match.group(5), + auth_enabled = bool(int(match.group(6))), + file_path = os.path.join(settings.FORC_BACKEND_PATH, filename) ) valid_backends.append(backend) return valid_backends -async def get_backends_upstream_urls() -> Dict[str, List[BackendOut]]: +async def get_backend_by_id(backend_id: int) -> BackendOut: valid_backends: List[BackendOut] = await get_backends() - upstream_urls = {} + for backend in valid_backends: + if int(backend.id) == int(backend_id): # @reviewer: are we sure that there is only one backend with this backend_id? + return backend + raise NotFound(f"Backend with id {backend_id} was not found.") + + +async def get_backends_proxy_pass() -> Dict[str, List[BackendOut]]: + """ + Returns a dictionary mapping proxy_pass values to lists of BackendOuts that use them. + """ + valid_backends: List[BackendOut] = await get_backends() + proxy_passes = {} for backend in valid_backends: - upstream_url = extract_proxy_pass(backend.file_path) - if upstream_url: - if upstream_url not in upstream_urls: - upstream_urls[upstream_url] = [] - upstream_urls[upstream_url].append(backend) + proxy_pass = extract_proxy_pass(backend.file_path) + if proxy_pass: + if proxy_pass not in proxy_passes: + proxy_passes[proxy_pass] = [] + proxy_passes[proxy_pass].append(backend) + + return proxy_passes + - return upstream_urls +# FURTHER GETTER FUNCTIONS -def extract_proxy_pass(file_path): +def extract_proxy_pass(file_path) -> str | None: + # proxy_pass consists of upstream_url with potential trailing path, see guacamole template + # TODO: add error handling, e.g. file_path = None with open(file_path, 'r') as file: content = file.read() @@ -75,79 +141,286 @@ def extract_proxy_pass(file_path): if match: return match.group(1) else: + logger.error(f"Could not extract proxy_pass from {file_path}") return None -async def generate_suffix_number(user_key_url): - current_backends: List[BackendOut] = await get_backends() - same_name_backend_ids = [] +def get_upstream_url(file_path) -> str | None: + """ + Extracts upstream_url from proxy_pass and removes trailing path, see guacamole template + """ + proxy_pass = extract_proxy_pass(file_path) + if proxy_pass is None or proxy_pass == "": + return None - for backend in current_backends: - if backend.location_url.split("_")[0] == user_key_url: - same_name_backend_ids.append(int(backend.location_url.split("_")[1])) + # split and rejoin to remove trailing path to remove potential trailing path, unaffected if no trailing path + upstream_url = "/".join(proxy_pass.split("/", 3)[:3]) + return upstream_url + + +def get_basekey_from_backend(backend: BackendOut) -> str | None: + """ + Extracts basekey from location_url by removing suffix pattern. + """ + try: + if backend is None or backend.location_url is None: + logger.error("backend or backend.location_url is None.") + return None + if "_" not in backend.location_url: + logger.error(f"location_url has no suffix pattern: {backend.location_url}") + return None + # split and return basekey + base_key = backend.location_url.split("_")[0] + return base_key + except ValueError: + logger.error(f"could not get basekey from location_url: {backend.location_url}") + return None - if not same_name_backend_ids: - return "100" - same_name_backend_ids.sort() - highest_id = same_name_backend_ids[-1] - if highest_id == 999: - logger.warning("Reached max index number for requested user_key_url: " + user_key_url) - raise InternalServerError("Reached max index number for requested user_key_url (limit=999).") - return str(highest_id + 1) +# CORE MUTATOR AND SERVICE FUNCTIONS +async def create_backend(payload_input: BackendIn, **kwargs) -> BackendTemp: + settings = get_settings() -async def create_backend(payload: BackendIn): - payload: BackendTemp = BackendTemp(**payload.dict()) - suffix_number = await generate_suffix_number(payload.user_key_url) + # overwrite payload as BackendTemp for generate_backend_by_template() + payload: BackendTemp = BackendTemp(**payload_input.model_dump()) - payload.id = str(await random_with_n_digits(10)) + # generate or reuse backend id and suffix number + payload, suffix_number = await set_backend_id_and_suffix(payload, **kwargs) + # generate backend and location_url backend_file_contents = await generate_backend_by_template(payload, suffix_number) if not backend_file_contents: raise InternalServerError("Server was not able to template a new backend.") payload.location_url = f"{payload.user_key_url}_{suffix_number}" - # check for duplicated in ip and port: - upstream_urls: Dict[str, List[BackendOut]] = await get_backends_upstream_urls() - matching_urls_backends: List[BackendOut] = upstream_urls.get(payload.upstream_url, []) - for backend in matching_urls_backends: - logger.info(f"Deleting existing Backend with same Upstream Url - {payload.upstream_url}") - await delete_backend(backend.id) + # check for duplicates and delete them before creating new backend + if not await delete_duplicate_backends(payload): + raise InternalServerError("Server was not able to delete duplicate backends before creating a new one.") - # create backend file in filesystem - filename = f"{payload.id}%{payload.owner}%{payload.location_url}%{payload.template}%{payload.template_version}.conf" + # save BackendOut info in filename, create backend file in filesystem + filename = generate_backend_filename(payload) with open(f"{settings.FORC_BACKEND_PATH}/{filename}", 'w') as backend_file: backend_file.write(backend_file_contents) - # attempt to reload openrest + # attempt to reload openresty await reload_openresty() return payload async def delete_backend(backend_id) -> bool: - if not os.path.exists(settings.FORC_BACKEND_PATH) and not os.access(settings.FORC_BACKEND_PATH, os.W_OK): + settings = get_settings() + backend_path_filenames = get_valid_backend_filenames() + if not backend_path_filenames: + return False + + matching_backend_filenames = filter_backend_filenames_by_id(backend_path_filenames, backend_id) + + amount_of_files = len(matching_backend_filenames) + if amount_of_files == 0: + raise NotFound(f"Backend {backend_id} was not found.") + if amount_of_files > 1: + logger.error(f"Found multiple backend files for backend id: {backend_id}, cannot delete.") + raise InternalServerError("Server found multiple backend files, cannot delete.") + + filename = matching_backend_filenames[0] + + logger.info(f"Attempting to delete backend with id: {backend_id} as file: {settings.FORC_BACKEND_PATH}/{filename}") + try: + os.remove(f"{settings.FORC_BACKEND_PATH}/{filename}") + logger.info(f"Deleted backend with id: {backend_id}") + await reload_openresty() + return True + except OSError as e: + logger.warning(f"Was not able to delete backend with id: {backend_id} ERROR: {e}") + raise InternalServerError("Server was not able to delete this backend. Contact the admin.") + + +async def update_backend_authorization(backend_id: int, auth_enabled: bool) -> BackendOut | None: + backend = await get_backend_by_id(backend_id) + if not backend: + return None + + # build temporary payload as BackendIn for create_backend() + temp_payload = build_payload_for_auth_update(backend, auth_enabled) + if not temp_payload: + return None + + # generate new backend contents with temp_payload, additional kwargs to persist backend_id and location_url + new_contents = await create_backend(temp_payload, id=str(backend_id), location_url=backend.location_url) + if not new_contents: + logger.error("Templating returned empty result.") + return None + + logger.info(f"Updated backend authorization to {temp_payload.auth_enabled} for backend id: {backend_id}") + + # convert BackendTemp to BackendOut for returning + returning_backend = await convert_backend_temp_to_out(new_contents) + + return returning_backend + + + +# HELPER FUNCTIONS FOR MUTATORS + +async def set_backend_id_and_suffix(backend: BackendTemp, **kwargs) -> tuple[BackendTemp, int]: + """ + Sets backend id and suffix number for a new backend. + If id and location_url are provided in kwargs, they are used to override. + Otherwise, a new id and suffix number are generated. + """ + # override id and suffix if provided from update_backend_authorization() + if 'id' in kwargs and 'location_url' in kwargs: + id: str = str(kwargs.get('id')) + suffix: int = int(str(kwargs.get('location_url')).split("_")[1]) + if not isinstance(id, str) or not isinstance(suffix, int): + logger.error("Provided id or location_url have wrong type.") + raise InternalServerError("Provided id or location_url have wrong type.") + + backend = backend.model_copy(update={'id': id}) + suffix_number = suffix + # if no id provided, generate id and suffix + else: + if kwargs != {}: + logger.warning(f"set_backend_id_and_suffix() received unexpected kwargs: {kwargs}") + raise InternalServerError("Unexpected kwargs provided to set_backend_id_and_suffix().") # @reviewer: should we really error here? + backend = backend.model_copy(update={'id': str(random_with_n_digits(10))}) + suffix_number = await generate_suffix_number(backend.location_url) + return backend, suffix_number + + +# TODO: handle cases where same proxy_pass exists with and without trailing path (guacamole) +async def delete_duplicate_backends(backend_with_proxy_pass: BackendIn) -> bool: + """ + Checks for duplicates in proxy_pass (namely upstream_url) for a given BackendIn and deletes all of them. + Returns only False, if a deletion failed. Returns True if no backends were found and thus deleted. + """ + # get proxy_pass from provided backend + proxy_pass = backend_with_proxy_pass.upstream_url + # get all backends linked to the given proxy_pass + backends_proxy_passes: Dict[str, List[BackendOut]] = await get_backends_proxy_pass() + matching_backends: List[BackendOut] = backends_proxy_passes.get(proxy_pass, []) + # delete all matching backends + success: bool = True + if len(matching_backends) == 0: + logger.warning("No backends found for matching, proxy_pass: " + str(proxy_pass)) + else: + for backend in matching_backends: + logger.info(f"Deleting existing backend with same proxy_pass: {proxy_pass}, backend id: {backend.id}") + if not await delete_backend(backend_id = backend.id): + return False + return success + + +async def convert_backend_temp_to_out(backend_temp: BackendTemp) -> BackendOut | None: + """ + Converts a given BackendTemp to BackendOut. Returns None otherwise. + """ + # needed for returning backends to client + try: + backend_out = BackendOut( + id = backend_temp.id, + owner = backend_temp.owner, + location_url = backend_temp.location_url, + template = backend_temp.template, + template_version = backend_temp.template_version, + auth_enabled = backend_temp.auth_enabled, + file_path = (await get_backend_by_id(backend_temp.id)).file_path + ) + return backend_out + except Exception as e: + logger.error(f"Error converting BackendTemp to BackendOut: {e}") + return None + + +def check_backend_path() -> bool: + """ + Checks whether a backend path exists, is accessible and has files + """ + forc_backend_path = get_settings().FORC_BACKEND_PATH + if not os.path.exists(forc_backend_path) or not os.access(forc_backend_path, os.W_OK): logger.error("Not able to access configured backend path.") return False - if len(os.listdir(settings.FORC_BACKEND_PATH)) == 0: + if len(os.listdir(forc_backend_path)) == 0: + logger.error("No files found in backend path.") return False - backend_path_files = os.listdir(settings.FORC_BACKEND_PATH) - for file in backend_path_files: - match = re.fullmatch(file_regex, file) - if not match: - logger.warning(f"Found a backend file with wrong naming, skipping it: {file}") + return True + + +def check_backend_file_naming(backend_path_filename: str) -> bool: + """ + Checks for correct naming of the file in the backend path + """ + match = re.fullmatch(filename_regex, backend_path_filename) + # skip files with wrong naming and log warning + if match: + return True + else: + # exclude expected files from warning + if backend_path_filename == "users" or backend_path_filename == "scripts": + return True + logger.warning(f"Found a backend file with wrong naming, skipping it: {backend_path_filename}") + return False + + +def get_backend_path_filenames() -> List[str] | None: + """ + Returns a list of all the filenames in the backend path, or None if failed. + """ + if not check_backend_path(): + return None + + return os.listdir(get_settings().FORC_BACKEND_PATH) + + +def get_valid_backend_filenames() -> List[str] | None: + """ + Returns a list of all valid filenames in the backend path, or None if failed. + """ + + # get list of valid backend filenames in backend path + backend_path_filenames = get_backend_path_filenames() + if not backend_path_filenames: + return None + + # check naming, skip invalid filenames + valid_backend_filenames = [] + for filename in backend_path_filenames: + if not check_backend_file_naming(filename): continue - if int(match.group(1)) == int(backend_id): - logger.info(f"Attempting to delete backend with id: {backend_id} as file: {settings.FORC_BACKEND_PATH}/{file}") - try: - os.remove(f"{settings.FORC_BACKEND_PATH}/{file}") - logger.info(f"Deleted backend with id: {backend_id}") - await reload_openresty() - return True - except OSError as e: - logger.warning(f"Was not able to delete backend with id: {backend_id} ERROR: {e}") - raise InternalServerError("Server was not able to delete this backend. Contact the admin.") - raise NotFound("Backend was not found.") + + # add valid filenames to list and return them + valid_backend_filenames.append(filename) + return valid_backend_filenames + + +def filter_backend_filenames_by_id(backend_path_filenames: List[str], backend_id: int) -> List[str]: + # filter a list of backend filenames for matching backend id + return [filename for filename in backend_path_filenames if int(filename.split("%")[0]) == int(backend_id)] + + +def build_payload_for_auth_update(backend: BackendOut, auth_enabled: bool) -> BackendIn | None: + # fetch necessary info from existing BackendOut and build BackendIn payload for create_backend(), see update_backend_authorization() + upstream_url = get_upstream_url(backend.file_path) + base_key = get_basekey_from_backend(backend) + if not upstream_url or not base_key: + logger.error(f"Could not extract necessary info (upstream_url and base_key) from backend id: {backend.id} for updating authorization") + return None + + # build new temporary payload as BackendIn + try: + temp_payload = BackendIn( + owner = backend.owner, + template = backend.template, + template_version = backend.template_version, + user_key_url = base_key, # add fetched fields + upstream_url = upstream_url, + auth_enabled = auth_enabled, # set new auth flag + ) + return temp_payload + except Exception as e: + logger.error(f"Error building temp payload for backend update: {e}") + return None diff --git a/FastapiOpenRestyConfigurator/app/main/service/openresty.py b/FastapiOpenRestyConfigurator/app/main/service/openresty.py old mode 100644 new mode 100755 index e5053f4..67d13e4 --- a/FastapiOpenRestyConfigurator/app/main/service/openresty.py +++ b/FastapiOpenRestyConfigurator/app/main/service/openresty.py @@ -12,6 +12,6 @@ async def reload_openresty(): logger.info("Reloading openresty config after backend change.") try: os.popen("sudo openresty -s reload") - logger.info("Reload succesful.") + logger.info("Reload successful.") except OSError as e: logger.exception(f"Was not able to reload OpenResty: {e}") diff --git a/FastapiOpenRestyConfigurator/app/main/service/template.py b/FastapiOpenRestyConfigurator/app/main/service/template.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/app/main/service/user.py b/FastapiOpenRestyConfigurator/app/main/service/user.py old mode 100644 new mode 100755 index d0da976..d785bf5 --- a/FastapiOpenRestyConfigurator/app/main/service/user.py +++ b/FastapiOpenRestyConfigurator/app/main/service/user.py @@ -11,10 +11,10 @@ from ..model.serializers import User logger = logging.getLogger("service") -settings = get_settings() async def get_users(backend_id): + settings = get_settings() backend_id = secure_filename(str(backend_id)) user_id_path = f"{settings.FORC_USER_PATH}/{backend_id}" if not os.path.exists(user_id_path) and not os.access(user_id_path, os.R_OK): @@ -28,6 +28,7 @@ async def get_users(backend_id): async def add_user(backend_id, user_id): + settings = get_settings() backend_id = secure_filename(str(backend_id)) if "@" in user_id: user_id_parts = user_id.split("@") @@ -59,6 +60,7 @@ async def add_user(backend_id, user_id): async def delete_user(backend_id, user_id): + settings = get_settings() backend_id = secure_filename(str(backend_id)) if "@" in user_id: user_id_parts = user_id.split("@") @@ -93,6 +95,7 @@ async def delete_user(backend_id, user_id): async def delete_all(backend_id): + settings = get_settings() backend_id = secure_filename(str(backend_id)) user_id_path = f"{settings.FORC_USER_PATH}/{backend_id}" if not os.path.exists(user_id_path): diff --git a/FastapiOpenRestyConfigurator/app/main/tests/conftest.py b/FastapiOpenRestyConfigurator/app/main/tests/conftest.py new file mode 100644 index 0000000..b0c5e1d --- /dev/null +++ b/FastapiOpenRestyConfigurator/app/main/tests/conftest.py @@ -0,0 +1,16 @@ +import pytest +import os + +@pytest.fixture(scope="session", autouse=True) +def test_dirs(tmp_path_factory): + """ + See config.get_settings(). Creates the path structure and sets environment variables. + """ + backend = tmp_path_factory.mktemp("backend") + templates = tmp_path_factory.mktemp("templates") + + os.environ["FORC_BACKEND_PATH"] = str(backend) + os.environ["FORC_TEMPLATE_PATH"] = str(templates) + os.environ["FORC_API_KEY"] = "test-api-key" + + yield diff --git a/FastapiOpenRestyConfigurator/app/main/tests/test_service_backend.py b/FastapiOpenRestyConfigurator/app/main/tests/test_service_backend.py new file mode 100644 index 0000000..02f0958 --- /dev/null +++ b/FastapiOpenRestyConfigurator/app/main/tests/test_service_backend.py @@ -0,0 +1,610 @@ +import pytest +from unittest.mock import call, patch + + +from app.main.model.serializers import BackendIn, BackendOut, BackendTemp + +from app.main.service import backend as backend_service + +file_path_example_1 = "1234567890%testuser%animal_100%testtemplate%v01%0.conf" +file_path_example_2 = "9876543210%otheruser%cat_200%othertemplate%v02%1.conf" + +# TEST DATA + +# backend test cases: (exception_expected, filename, backend), see test_generate_backend_filename() +test_backends_for_generate_backend_filename = [ + (False, "123%testuser%dog_100%testtemplate%v01%0.conf", + BackendOut.model_construct( + id = 123, + owner = "testuser", + location_url = "dog_100", + template = "testtemplate", + template_version = "v01", + auth_enabled = False + )), + (False, "456%otheruser%ant_300%anothertemplate%v03%1.conf", + BackendOut.model_construct( + id = 456, + owner = "otheruser", + location_url = "ant_300", + template = "anothertemplate", + template_version = "v03", + auth_enabled = True + )), + (True, None, + BackendOut.model_construct( + id = None, + owner = "otheruser", + location_url = "cat_200", + template = "othertemplate", + template_version = "v02", + auth_enabled = True + )), + (True, None, + BackendOut.model_construct( + id = 123, + owner = None, + location_url = "cat_200", + template = "othertemplate", + template_version = "v02", + auth_enabled = True + )), + (True, None, + BackendOut.model_construct( + id = 123, + owner = "otheruser", + location_url = None, + template = "othertemplate", + template_version = "v02", + auth_enabled = True + )), + (True, None, + BackendOut.model_construct( + id = 123, + owner = "otheruser", + location_url = "cat_200", + template = None, + template_version = "v02", + auth_enabled = True + )), + (True, None, + BackendOut.model_construct( + id = 123, + owner = "otheruser", + location_url = "cat_200", + template = "othertemplate", + template_version = None, + auth_enabled = True + )), + (True, None, + BackendOut.model_construct( + id = 123, + owner = "otheruser", + location_url = "cat_200", + template = "othertemplate", + template_version = "v02", + auth_enabled = None + )) + ] +# backend test cases: (exception_expected, backend), see test_convert_backend_temp_to_out() +test_backends_for_convert_backend_temp_to_out = [ + (False, + BackendTemp.model_construct( + id = 123, + owner = "4d2e5e17-a378-4df0-ba9e-4fb710f0eeb", + location_url = "dog_100", + template = "testtemplate", + template_version = "v01", + user_key_url = "myRstudio", + upstream_url = "http://192.168.0.1:4000", + auth_enabled = False, + file_path = "file_path" + )), + (False, + BackendTemp.model_construct( + id = 456, + owner = "4d2e5e17-a378-4df0-ba9e-4fb710f0eeb", + location_url = "ant_300", + template = "anothertemplate", + template_version = "v03", + user_key_url = "myRstudio", + upstream_url = "http://1.1.1.1:8002", + auth_enabled = True, + file_path = "another_file_path" + )), + (True, + BackendTemp.model_construct( + id = None, + owner = "4d2e5e17-a378-4df0-ba9e-4fb710f0eeb", + location_url = "dog_100", + template = "testtemplate", + template_version = "v01", + user_key_url = "myRstudio", + upstream_url = "http://192.168.0.1:4000", + auth_enabled = False, + file_path = "file_path" + )), + (True, + BackendTemp.model_construct( + id = 123, + owner = None, + location_url = "4d2e5e17-a378-4df0-ba9e-4fb710f0eeb", + template = "testtemplate", + template_version = "v01", + user_key_url = "myRstudio", + upstream_url = "http://192.168.0.1:4000", + auth_enabled = False, + file_path = "file_path" + )), + (True, + BackendTemp.model_construct( + id = 123, + owner = "4d2e5e17-a378-4df0-ba9e-4fb710f0eeb", + location_url = None, + template = "testtemplate", + template_version = "v01", + user_key_url = "myRstudio", + upstream_url = "http://192.168.0.1:4000", + auth_enabled = False, + file_path = "file_path" + )), + (True, + BackendTemp.model_construct( + id = 123, + owner = "4d2e5e17-a378-4df0-ba9e-4fb710f0eeb", + location_url = "dog_100", + template = None, + template_version = "v01", + user_key_url = "myRstudio", + upstream_url = "http://192.168.0.1:4000", + auth_enabled = False, + file_path = "file_path" + )), + (True, + BackendTemp.model_construct( + id = 123, + owner = "4d2e5e17-a378-4df0-ba9e-4fb710f0eeb", + location_url = "dog_100", + template = "testtemplate", + template_version = None, + user_key_url = "myRstudio", + upstream_url = "http://192.168.0.1:4000", + auth_enabled = False, + file_path = "file_path" + )), + (True, + BackendTemp.model_construct( + id = 123, + owner = "4d2e5e17-a378-4df0-ba9e-4fb710f0eeb", + location_url = "dog_100", + template = "testtemplate", + template_version = "v01", + user_key_url = None, + upstream_url = "http://192.168.0.1:4000", + auth_enabled = False, + file_path = "file_path" + )), + (True, + BackendTemp.model_construct( + id = 123, + owner = "4d2e5e17-a378-4df0-ba9e-4fb710f0eeb", + location_url = "dog_100", + template = "testtemplate", + template_version = "v01", + user_key_url = "myRstudio", + upstream_url = None, + auth_enabled = False, + file_path = "file_path" + )), + (True, + BackendTemp.model_construct( + id = 123, + owner = "4d2e5e17-a378-4df0-ba9e-4fb710f0eeb", + location_url = "dog_100", + template = "testtemplate", + template_version = "v01", + user_key_url = "myRstudio", + upstream_url = "http://192.168.0.1:4000", + auth_enabled = None, + file_path = None + )), + ] + + +# HELPER FUNCTIONS + +@pytest.mark.parametrize( + "exception_expected, expected_suffix, user_key_url", + [ + (False, 100, None), + (False, 201, "test_200"), + (True, None, "test_-10"), + (True, None, "test_999"), + (True, None, "test_1000"), + (True, None, "test_150.5"), + (True, None, "test_not-an-int") + ] +) +@pytest.mark.asyncio +async def test_generate_suffix_number(exception_expected, expected_suffix, user_key_url): + + with patch( + "app.main.service.backend.get_backends", + return_value = [BackendOut.model_construct(location_url = user_key_url), BackendOut.model_construct(location_url = "animal_100")] + ) as mock_get_backends: + + try: + response_suffix: int = await backend_service.generate_suffix_number(user_key_url) + # success case + if not exception_expected: + assert response_suffix == expected_suffix + if user_key_url is not None: + mock_get_backends.assert_awaited_once() + # fail case + except Exception as e: + if not exception_expected: + raise e + + +@pytest.mark.parametrize( + "exception_expected, filename, backend", + test_backends_for_generate_backend_filename +) +@pytest.mark.asyncio +async def test_generate_backend_filename(exception_expected, filename, backend): + try: + response_filename: str = backend_service.generate_backend_filename(backend) + # success case + if not exception_expected: + assert response_filename == filename + # fail case + except Exception as e: + if not exception_expected: + raise e + + + +# CORE GETTER FUNCTIONS + +""" +async def test_get_backends(): +""" + + +@pytest.mark.parametrize( + "exception_expected, backend_id", + [ + (False, 123), + (True, None) + ] +) +@pytest.mark.asyncio +async def test_get_backend_by_id(exception_expected, backend_id): + + with patch( + "app.main.service.backend.get_backends", + return_value = [BackendOut.model_construct(id = backend_id), BackendOut.model_construct(id = 456)] + ) as mock_get_backends: + + try: + response_backend: BackendOut = await backend_service.get_backend_by_id(backend_id) + # success case + if not exception_expected: + assert response_backend.id == backend_id + mock_get_backends.assert_awaited_once() + # fail case + except Exception as e: + if not exception_expected: + raise e + + + +# FURTHER GETTER FUNCTIONS + +@pytest.mark.parametrize( + "exception_expected, proxy_pass, expected_upstream_url", + [ + (False, "http://1.1.1.1:1000/guacamole/", "http://1.1.1.1:1000"), + (False, "http://200.100.50.10:4200/other/", "http://200.100.50.10:4200"), + (True, None, None) + ] +) +@pytest.mark.asyncio +async def test_get_upstream_url(exception_expected, proxy_pass, expected_upstream_url): + + with patch( + "app.main.service.backend.extract_proxy_pass", + return_value = proxy_pass + ) as mock_extract_proxy_pass: + + response_upstream_url = backend_service.get_upstream_url("test_path") + mock_extract_proxy_pass.assert_called_once() + + # success case + if not exception_expected: + assert response_upstream_url == expected_upstream_url + # fail case + else: + assert response_upstream_url is None + + +@pytest.mark.parametrize( + "exception_expected, backend, expected_basekey", + [ + (False, BackendOut.model_construct(location_url = "test_100"), "test"), + (False, BackendOut.model_construct(location_url = "example_200"), "example"), + (True, BackendOut.model_construct(location_url = "corrupted"), None), + (True, BackendOut.model_construct(location_url = "123"), None), + (True, None, None) + ] +) +def test_get_basekey_from_backend(exception_expected, backend, expected_basekey): + + response_basekey = backend_service.get_basekey_from_backend(backend) + # success case + if not exception_expected: + assert response_basekey == expected_basekey + # fail case + else: + assert response_basekey is None + + +""" +# CORE MUTATOR AND SERVICE FUNCTIONS + +@pytest.mark.asyncio +async def test_create_backend(): + ... + +@pytest.mark.asyncio +async def test_delete_backend(): + ... + +@pytest.mark.asyncio +async def test_update_backend_authorization(): + ... +""" + + + +# HELPER FUNCTIONS FOR MUTATORS + +@pytest.mark.parametrize( + "exception_expected, kwargs", + [ + # SUCESS CASES + (False, {}), + (False, {"id": 123, "location_url": "test_200"}), + # FAIL CASES + # one param is missing + (True, {"id": 123}), + (True, {"location_url": "test_200"}), + # required param is None + (True, {"id": None, "location_url": "test_200"}), + (True, {"id": 123, "location_url": None}), + # wrong type param + (True, {"id": "not-an-int", "location_url": "test_200"}), + (True, {"id": 123, "location_url": 111}) + ] +) +@pytest.mark.asyncio +async def test_set_backend_id_and_suffix(exception_expected, kwargs): + with patch( + "app.main.service.backend.generate_suffix_number", + ) as mock_generate_suffix_number: + + try: + backend, suffix_number = await backend_service.set_backend_id_and_suffix(BackendTemp.model_construct(), **kwargs) + backend: BackendTemp + suffix_number: int + # success case + if not exception_expected: + assert backend.id + assert suffix_number + if kwargs == {}: + mock_generate_suffix_number.assert_awaited_once() + else: + mock_generate_suffix_number.assert_not_awaited() + assert int(backend.id) == int(kwargs["id"]) + # fail case + except Exception as e: + if not exception_expected: + raise e + + +@pytest.mark.parametrize( + "delete_succeeded, proxy_pass, expected_delete_backend_ids", + [ + (True, "http://192.168.0.1:8787/guacamole/", [12, 34]), + (True, "http://192.168.0.1:8787", [56, 78]), + (False, "http://192.168.0.1:8787", [56, 78]), + (True, "http://1.1.1.1:4000", []) + ] + # not able to test cases like None, "", "not_valid" without a validator, TODO: can we use serializer.BackendIn validator? +) +@pytest.mark.asyncio +async def test_delete_duplicate_backends(delete_succeeded, proxy_pass, expected_delete_backend_ids): + with patch( + "app.main.service.backend.get_backends_proxy_pass", + return_value = { + "http://192.168.0.1:8787/guacamole/": [ + BackendOut.model_construct( + id = 12, + upstream_url = "http://192.168.0.1:8787/guacamole/", + ), + BackendOut.model_construct( + id = 34, + upstream_url = "http://192.168.0.1:8787/guacamole/", + ), + ], + "http://192.168.0.1:8787": [ + BackendOut.model_construct( + id = 56, + upstream_url = "http://192.168.0.1:8787", + ), + BackendOut.model_construct( + id = 78, + upstream_url = "http://192.168.0.1:8787", + ), + ], + "http://1.1.1.1:4000": [ + BackendOut.model_construct( + id = 90, + upstream_url = "http://1.1.1.1:4000/", + ), + ], + } + ) as mock_get_backends_upstream_urls, patch( + "app.main.service.backend.delete_backend", + return_value = delete_succeeded + ) as mock_delete_backend: + + response_success: bool = await backend_service.delete_duplicate_backends(BackendIn.model_construct(upstream_url = proxy_pass)) + mock_get_backends_upstream_urls.assert_awaited_once() + # success case + if delete_succeeded: + assert response_success is True + if len(expected_delete_backend_ids) != 0: + mock_delete_backend.assert_has_awaits( + [call(backend_id=id) for id in expected_delete_backend_ids], + any_order = True + ) + # fail case + elif not delete_succeeded: + assert response_success is False + mock_delete_backend.assert_awaited() + + +@pytest.mark.parametrize( + "exception_expected, backend", + test_backends_for_convert_backend_temp_to_out +) +@pytest.mark.asyncio +async def test_convert_backend_temp_to_out(exception_expected, backend): + with patch( + "app.main.service.backend.get_backend_by_id", + return_value = BackendOut.model_construct(file_path = backend.file_path) + ) as mock_get_backend_by_id: + try: # @reviewer: is there an easier way? + backend_out = await backend_service.convert_backend_temp_to_out(backend) + if not exception_expected: + assert isinstance(backend_out, BackendOut) + mock_get_backend_by_id.assert_awaited_once_with(backend.id) + assert backend_out.id == backend.id + assert backend_out.owner == backend.owner + assert backend_out.location_url == backend.location_url + assert backend_out.template == backend.template + assert backend_out.template_version == backend.template_version + assert backend_out.auth_enabled == backend.auth_enabled + assert backend_out.file_path == backend.file_path + except Exception as e: + if not exception_expected: + raise e + + +@pytest.mark.parametrize( + "expected, path_exists, access, listdir", + [ + (True, True, True, ["first"]), + (True, True, True, ["first", "second"]), + (True, True, True, ["first", "second", "third"]), + (False, False, False, ["first"]), + (False, False, True, ["first"]), + (False, True, False, ["first"]), + (False, True, True, []), + + ] +) +def test_check_backend_path(expected, path_exists, access, listdir): + + with patch( + "app.main.service.backend.get_settings" # imported + ) as mock_get_settings, patch( + "os.path.exists", + return_value = path_exists + ) as mock_os_path_exists, patch( + "os.access", + return_value = access + ) as mock_os_access, patch( + "os.listdir", + return_value = listdir + ) as mock_os_listdir: + assert backend_service.check_backend_path() == expected + mock_get_settings.assert_called_once() + mock_os_path_exists.assert_called_once() + if path_exists: + mock_os_access.assert_called_once() + if access: + mock_os_listdir.assert_called_once() + + +@pytest.mark.parametrize( + "expected, filename", + [ + (True, "users"), + (True, "scripts"), + (True, file_path_example_1), + (True, file_path_example_2), + (False, "obviously_wrong"), + (False, 37) + ] +) +def test_check_backend_file_naming(expected, filename): + try: + assert backend_service.check_backend_file_naming(filename) is expected + except TypeError as e: + assert not expected + if expected: + raise e + +@pytest.mark.parametrize( + "returning, backend_check", + [ + (["something"], True), + (None, False) + ] +) +def test_get_backend_path_filenames(returning, backend_check): + + with patch( + "app.main.service.backend.get_settings" # imported + ) as mock_get_settings, patch( + "app.main.service.backend.check_backend_path", + return_value = backend_check + ) as mock_check_backend_path, patch( + "os.listdir", + return_value = returning + ) as mock_os_listdir: + assert backend_service.get_backend_path_filenames() == returning + mock_check_backend_path.assert_called_once() + if backend_check: + mock_os_listdir.assert_called_once() + mock_get_settings.assert_called_once() + + + + + + + + + + + + + + + + +""" + + +def test_get_valid_backend_filenames(): + ... + +def test_filter_backend_filenames_by_id(): + ... + +def test_build_payload_for_auth_update(): + ... +""" \ No newline at end of file diff --git a/FastapiOpenRestyConfigurator/app/main/tests/test_views_backend.py b/FastapiOpenRestyConfigurator/app/main/tests/test_views_backend.py new file mode 100644 index 0000000..ba799f8 --- /dev/null +++ b/FastapiOpenRestyConfigurator/app/main/tests/test_views_backend.py @@ -0,0 +1,46 @@ +import pytest +from unittest.mock import patch +from fastapi import HTTPException + +from app.main.views import backend as backend_views + + +@pytest.mark.parametrize( + "exception_expected, backend_id, body", + [ + # success cases + (False, 123, {"auth_enabled": True}), + (False, 123, {"auth_enabled": 1}), + (False, 123, {"auth_enabled": 0}), + (False, 123, {"auth_enabled": False}), + # corrupted backend_id + # TODO: more tests when there is further validation on backend_id + (True, "not an int", {"auth_enabled": True}), + (True, None, {"auth_enabled": True}), + # corrupted body + (True, 123, {"auth_enabled": "not a boolean"}), + (True, 123, {"auth_enabled": ""}), + (True, 123, {"differrent_value": True}), + (True, 123, None), + (True, 123, {}), + ] +) +@pytest.mark.asyncio +async def test_backend_update_auth(exception_expected, backend_id, body): + + with patch( + "app.main.service.backend.update_backend_authorization" + ) as mock_update_backend_authorization: + try: + await backend_views.backend_update_auth( + backend_id = backend_id, + body = body, + api_key = object() # type: ignore[reportArgumentType] + ) + # success case + if not exception_expected: + mock_update_backend_authorization.assert_called_once_with(backend_id = 123, auth_enabled = True) + # fail case + except Exception: + if exception_expected: + mock_update_backend_authorization.assert_not_awaited() diff --git a/FastapiOpenRestyConfigurator/app/main/util/__init__.py b/FastapiOpenRestyConfigurator/app/main/util/__init__.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/app/main/util/auth.py b/FastapiOpenRestyConfigurator/app/main/util/auth.py old mode 100644 new mode 100755 index b69a5a4..711daba --- a/FastapiOpenRestyConfigurator/app/main/util/auth.py +++ b/FastapiOpenRestyConfigurator/app/main/util/auth.py @@ -10,12 +10,12 @@ API_KEY_NAME = "X-API-KEY" api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False) -settings = get_settings() async def get_api_key( api_key_header_in: str = Security(api_key_header), ): + settings = get_settings() if api_key_header_in == settings.FORC_API_KEY.get_secret_value(): return api_key_header_in else: diff --git a/FastapiOpenRestyConfigurator/app/main/util/logging.py b/FastapiOpenRestyConfigurator/app/main/util/logging.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/app/main/util/templating.py b/FastapiOpenRestyConfigurator/app/main/util/templating.py old mode 100644 new mode 100755 index 34d143c..7877f8b --- a/FastapiOpenRestyConfigurator/app/main/util/templating.py +++ b/FastapiOpenRestyConfigurator/app/main/util/templating.py @@ -4,37 +4,65 @@ import jinja2 import logging import os +from functools import lru_cache from ..model.serializers import BackendTemp from ..config import get_settings logger = logging.getLogger("util") -settings = get_settings() -logger.info("Loading the templating engine.") -try: - templateLoader = jinja2.FileSystemLoader(searchpath=settings.FORC_TEMPLATE_PATH) - templateEnv = jinja2.Environment(loader=templateLoader, autoescape=True) -except jinja2.exceptions.TemplatesNotFound: - logger.error("Was not able to load template engine. Adjust the templates_path in the config.") +@lru_cache +def _template_env(): + """ + Lazily initialize and cache the Jinja environment. + Called only at runtime, never at import time. + """ + settings = get_settings() + logger.info("Loading the templating engine.") -async def generate_backend_by_template(backend_temp: BackendTemp, suffix_number): - if not templateLoader or not templateEnv: - logger.error("The template engine is not loaded. Can't generate backend.") - return None - assembled_template_file_name = f"{backend_temp.template}%{backend_temp.template_version}.conf" - if not os.path.isfile(f"{settings.FORC_TEMPLATE_PATH}/{assembled_template_file_name}"): - logger.error(f"Not able to find {settings.FORC_TEMPLATE_PATH}/{assembled_template_file_name}") + loader = jinja2.FileSystemLoader( + searchpath=settings.FORC_TEMPLATE_PATH + ) + + env = jinja2.Environment( + loader=loader, + autoescape=True + ) + + return env, settings + + +async def generate_backend_by_template( + backend_temp: BackendTemp, + suffix_number: int +) -> str | None: + + env, settings = _template_env() + + assembled_template_filename = ( + f"{backend_temp.template}%{backend_temp.template_version}.conf" + ) + + template_path = os.path.join( + settings.FORC_TEMPLATE_PATH, + assembled_template_filename + ) + + if not os.path.isfile(template_path): + logger.error(f"Template not found: {template_path}") return None - template = templateEnv.get_template(assembled_template_file_name) + + template = env.get_template(assembled_template_filename) rendered_backend = template.render( key_url=f"{backend_temp.user_key_url}_{suffix_number}", owner=backend_temp.owner, backend_id=backend_temp.id, forc_backend_path=settings.FORC_BACKEND_PATH, - location_url=backend_temp.upstream_url + location_url=backend_temp.upstream_url, + auth_enabled=backend_temp.auth_enabled, ) + return rendered_backend diff --git a/FastapiOpenRestyConfigurator/app/main/views/__init__.py b/FastapiOpenRestyConfigurator/app/main/views/__init__.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/app/main/views/backend.py b/FastapiOpenRestyConfigurator/app/main/views/backend.py old mode 100644 new mode 100755 index 662064b..e021889 --- a/FastapiOpenRestyConfigurator/app/main/views/backend.py +++ b/FastapiOpenRestyConfigurator/app/main/views/backend.py @@ -4,7 +4,7 @@ import logging from typing import List -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Body from fastapi.responses import JSONResponse from fastapi.openapi.models import APIKey from werkzeug.exceptions import NotFound, InternalServerError @@ -46,6 +46,36 @@ async def create_backend(backend_in: BackendIn, api_key: APIKey = Depends(get_ap raise HTTPException(status_code=400) +@router.post( + "/backends/{backend_id}/auth/", + response_model=BackendOut, + tags=["Backends"], + summary="Set owner authorization to true/false for an existing backend." +) +async def backend_update_auth(backend_id: int, body: dict = Body(...), api_key: APIKey = Depends(get_api_key)): + # process inputs TODO: should we validate further? + backend_id = int(secure_filename(str(backend_id))) # TODO: are secure_filename and str necessary? validation? + enable_auth = bool(body.get("auth_enabled", None)) + logger.debug(f"Attempting to update backend authorization to {enable_auth} for backend id: {backend_id}") + + # check inputs and raise error + if backend_id is None or enable_auth is None or not isinstance(enable_auth, bool): + logger.error( + f"Received faulty data. backend_id: {backend_id}, auth_enabled: {enable_auth}, {type(enable_auth)}") + raise HTTPException(status_code=422, detail = + f"auth_enabled is required and must be a boolean, \ + backend_id: {backend_id}, auth_enabled: {enable_auth}, {type(enable_auth)}") + # forward to service layer + else: + try: + return await backend_service.update_backend_authorization(backend_id, enable_auth) + # TODO: the exceptions are raised in the service layer already, do we still need this? + except NotFound: + raise HTTPException(status_code=404, detail=f"Backend with id {backend_id} not found.") + except InternalServerError: + raise HTTPException(status_code=500, detail="Internal server error.") + + @router.get( "/backends/{backend_id}", response_model=BackendOut, diff --git a/FastapiOpenRestyConfigurator/app/main/views/template.py b/FastapiOpenRestyConfigurator/app/main/views/template.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/app/main/views/user.py b/FastapiOpenRestyConfigurator/app/main/views/user.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/app/main/views/utils.py b/FastapiOpenRestyConfigurator/app/main/views/utils.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/gunicorn_conf.py b/FastapiOpenRestyConfigurator/gunicorn_conf.py new file mode 100755 index 0000000..d3db129 --- /dev/null +++ b/FastapiOpenRestyConfigurator/gunicorn_conf.py @@ -0,0 +1,11 @@ +# Socket Path +bind = "unix:/var/run/forc.sock" + +# Worker Options +workers = 5 +worker_class = "uvicorn.workers.UvicornWorker" + +# Logging Options +loglevel = "info" +accesslog = "/var/log/forc.access.log" +errorlog = "/var/log/forc.error.log" diff --git a/FastapiOpenRestyConfigurator/main.py b/FastapiOpenRestyConfigurator/main.py old mode 100644 new mode 100755 diff --git a/FastapiOpenRestyConfigurator/pytest.ini b/FastapiOpenRestyConfigurator/pytest.ini new file mode 100644 index 0000000..5ee6477 --- /dev/null +++ b/FastapiOpenRestyConfigurator/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/FastapiOpenRestyConfigurator/requirements.txt b/FastapiOpenRestyConfigurator/requirements.txt index 9cb12ee..526fcc0 100644 --- a/FastapiOpenRestyConfigurator/requirements.txt +++ b/FastapiOpenRestyConfigurator/requirements.txt @@ -5,3 +5,7 @@ Jinja2==3.1.6 python-dotenv==1.2.1 gunicorn==23.0.0 pydantic-settings +factory-boy==3.3.3 +# testing +pytest==8.4.2 +pytest-asyncio # TODO: @reviewer: which version? diff --git a/LICENSE b/LICENSE old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/docker/Dockerfile b/docker/Dockerfile old mode 100644 new mode 100755 diff --git a/docker/README.md b/docker/README.md old mode 100644 new mode 100755 diff --git a/docker/launch.sh b/docker/launch.sh old mode 100644 new mode 100755 diff --git a/docker/uwsgi.ini b/docker/uwsgi.ini old mode 100644 new mode 100755 diff --git a/examples/openresty_configuration.md b/examples/openresty_configuration.md old mode 100644 new mode 100755 diff --git a/examples/scripts/user_service.lua b/examples/scripts/user_service.lua old mode 100644 new mode 100755 diff --git a/examples/templates/cwlab%v01.conf b/examples/templates/cwlab%v01.conf old mode 100644 new mode 100755 index f46e933..374631e --- a/examples/templates/cwlab%v01.conf +++ b/examples/templates/cwlab%v01.conf @@ -14,9 +14,11 @@ end -- Protect this location and allow only one specific ELIXIR User - if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then - ngx.exit(ngx.HTTP_FORBIDDEN) - end + {% if only_allow_owner %} + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + {% endif %} } rewrite /{{ key_url }}/(.*) /$1 break; diff --git a/examples/templates/cwlab%v02.conf b/examples/templates/cwlab%v02.conf old mode 100644 new mode 100755 index 69f7243..4f3a3f1 --- a/examples/templates/cwlab%v02.conf +++ b/examples/templates/cwlab%v02.conf @@ -31,9 +31,11 @@ end -- Protect this location and allow only one specific ELIXIR User - if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then - ngx.exit(ngx.HTTP_FORBIDDEN) - end + {% if only_allow_owner %} + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + {% endif %} ngx.req.set_header("X-Auth-Audience", res.id_token.aud) ngx.req.set_header("X-Auth-Email", res.id_token.email) diff --git a/examples/templates/emgb%v01.conf b/examples/templates/emgb%v01.conf old mode 100644 new mode 100755 index e70ea0e..61412b6 --- a/examples/templates/emgb%v01.conf +++ b/examples/templates/emgb%v01.conf @@ -30,10 +30,11 @@ end -- Protect this location and allow only one specific ELIXIR User - if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then - - ngx.exit(ngx.HTTP_FORBIDDEN) - end + {% if only_allow_owner %} + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + {% endif %} ngx.req.set_header("X-Auth-Audience", res.id_token.aud) ngx.req.set_header("X-Auth-Email", res.id_token.email) diff --git a/examples/templates/guacamole%v01.conf b/examples/templates/guacamole%v01.conf old mode 100644 new mode 100755 index 87b8bc3..2e734ea --- a/examples/templates/guacamole%v01.conf +++ b/examples/templates/guacamole%v01.conf @@ -14,9 +14,11 @@ location /{{ key_url }}/ { end -- Protect this location and allow only one specific ELIXIR User - if res.id_token.sub ~= "{{ owner }}" then - ngx.exit(ngx.HTTP_FORBIDDEN) - end + {% if only_allow_owner %} + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + {% endif %} } diff --git a/examples/templates/guacamole%v02.conf b/examples/templates/guacamole%v02.conf old mode 100644 new mode 100755 index a6322a7..4a28b1e --- a/examples/templates/guacamole%v02.conf +++ b/examples/templates/guacamole%v02.conf @@ -15,9 +15,11 @@ location /{{ key_url }}/ { end -- Protect this location and allow only one specific ELIXIR User - if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then - ngx.exit(ngx.HTTP_FORBIDDEN) - end + {% if only_allow_owner %} + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + {% endif %} } diff --git a/examples/templates/guacamole%v03.conf b/examples/templates/guacamole%v03.conf old mode 100644 new mode 100755 index 34d8286..75f1ab2 --- a/examples/templates/guacamole%v03.conf +++ b/examples/templates/guacamole%v03.conf @@ -29,9 +29,12 @@ end -- Protect this location and allow only one specific ELIXIR User - if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then - ngx.exit(ngx.HTTP_FORBIDDEN) - end + {% if only_allow_owner %} + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + {% endif %} + ngx.req.set_header("X-Auth-Audience", res.id_token.aud) ngx.req.set_header("X-Auth-Email", res.id_token.email) ngx.req.set_header("X-Auth-ExpiresIn", res.id_token.exp) diff --git a/examples/templates/jupyterlab%v01.conf b/examples/templates/jupyterlab%v01.conf old mode 100644 new mode 100755 index 152bc08..6517135 --- a/examples/templates/jupyterlab%v01.conf +++ b/examples/templates/jupyterlab%v01.conf @@ -13,9 +13,11 @@ location /{{ key_url }} { end -- Protect this location and allow only one specific ELIXIR User - if res.id_token.sub ~= "{{ owner }}" then - ngx.exit(ngx.HTTP_FORBIDDEN) - end + {% if only_allow_owner %} + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + {% endif %} } proxy_pass {{ location_url }}; diff --git a/examples/templates/jupyterlab%v02.conf b/examples/templates/jupyterlab%v02.conf old mode 100644 new mode 100755 index 709c054..874267c --- a/examples/templates/jupyterlab%v02.conf +++ b/examples/templates/jupyterlab%v02.conf @@ -14,9 +14,11 @@ location /{{ key_url }} { end -- Protect this location and allow only one specific ELIXIR User - if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then - ngx.exit(ngx.HTTP_FORBIDDEN) - end + {% if only_allow_owner %} + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + {% endif %} } # After check via lua-oidc is done, start reverse proxying this backend by configuring a billion headers. diff --git a/examples/templates/jupyterlab%v03.conf b/examples/templates/jupyterlab%v03.conf old mode 100644 new mode 100755 index bd6a428..9684fcd --- a/examples/templates/jupyterlab%v03.conf +++ b/examples/templates/jupyterlab%v03.conf @@ -29,9 +29,11 @@ end -- Protect this location and allow only one specific ELIXIR User - if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then - ngx.exit(ngx.HTTP_FORBIDDEN) - end + {% if only_allow_owner %} + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + {% endif %} ngx.req.set_header("X-Auth-Audience", res.id_token.aud) ngx.req.set_header("X-Auth-Email", res.id_token.email) diff --git a/examples/templates/rstudio%v01.conf b/examples/templates/rstudio%v01.conf old mode 100644 new mode 100755 index ccd0f96..a939e9f --- a/examples/templates/rstudio%v01.conf +++ b/examples/templates/rstudio%v01.conf @@ -11,9 +11,11 @@ end -- Protect this location and allow only one specific ELIXIR User - if res.id_token.sub ~= "{{ owner }}" then - ngx.exit(ngx.HTTP_FORBIDDEN) - end + {% if only_allow_owner %} + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + {% endif %} } diff --git a/examples/templates/rstudio%v02.conf b/examples/templates/rstudio%v02.conf old mode 100644 new mode 100755 index c4de14e..8e2d0b6 --- a/examples/templates/rstudio%v02.conf +++ b/examples/templates/rstudio%v02.conf @@ -10,17 +10,19 @@ ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) end - -- Protect this location and allow only one specific ELIXIR User - if res.id_token.sub ~= "{{ owner }}" then - ngx.exit(ngx.HTTP_FORBIDDEN) - end + -- Protect this location and allow only one specific ELIXIR User + {% if only_allow_owner %} + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + {% endif %} } rewrite ^/{{ key_url }}/(.*)$ /$1 break; proxy_pass {{ location_url }}; proxy_redirect {{ location_url }} $scheme://$http_host/{{ key_url }}/; - proxy_http_version 1.1; + proxy_http_version 1.1; @ reviewer: we have the same here. is this necessary? proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 20d; diff --git a/examples/templates/rstudio%v03.conf b/examples/templates/rstudio%v03.conf old mode 100644 new mode 100755 index ab10668..7975047 --- a/examples/templates/rstudio%v03.conf +++ b/examples/templates/rstudio%v03.conf @@ -13,10 +13,12 @@ ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) end - -- Protect this location and allow only one specific ELIXIR User - if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then - ngx.exit(ngx.HTTP_FORBIDDEN) - end + -- Protect this location and allow only one specific ELIXIR User + {% if only_allow_owner %} + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + {% endif %} } diff --git a/examples/templates/rstudio%v04.conf b/examples/templates/rstudio%v04.conf old mode 100644 new mode 100755 index b2adfce..25f4cfc --- a/examples/templates/rstudio%v04.conf +++ b/examples/templates/rstudio%v04.conf @@ -29,9 +29,11 @@ end -- Protect this location and allow only one specific ELIXIR User - if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then - ngx.exit(ngx.HTTP_FORBIDDEN) - end + {% if only_allow_owner %} + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + {% endif %} ngx.req.set_header("X-Auth-Audience", res.id_token.aud) ngx.req.set_header("X-Auth-Email", res.id_token.email) diff --git a/examples/templates/theiaide%v01.conf b/examples/templates/theiaide%v01.conf old mode 100644 new mode 100755 index 814e0e2..3be18f7 --- a/examples/templates/theiaide%v01.conf +++ b/examples/templates/theiaide%v01.conf @@ -12,9 +12,11 @@ end -- Protect this location and allow only one specific ELIXIR User - if res.id_token.sub ~= "{{ owner }}" then - ngx.exit(ngx.HTTP_FORBIDDEN) - end + {% if only_allow_owner %} + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + {% endif %} } # After check via lua-oidc is done, start reverse proxying this backend by configuring a billion headers. diff --git a/examples/templates/theiaide%v02.conf b/examples/templates/theiaide%v02.conf old mode 100644 new mode 100755 index 3e8492d..be88617 --- a/examples/templates/theiaide%v02.conf +++ b/examples/templates/theiaide%v02.conf @@ -14,9 +14,11 @@ end -- Protect this location and allow only one specific ELIXIR User - if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then - ngx.exit(ngx.HTTP_FORBIDDEN) - end + {% if only_allow_owner %} + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + {% endif %} } # After check via lua-oidc is done, start reverse proxying this backend by configuring a billion headers. diff --git a/examples/templates/theiaide%v03.conf b/examples/templates/theiaide%v03.conf old mode 100644 new mode 100755 index 7e079a1..8d24ae9 --- a/examples/templates/theiaide%v03.conf +++ b/examples/templates/theiaide%v03.conf @@ -1,5 +1,4 @@ - # PROTECT FIRST THEIA CONTAINER - location /{{ key_url }}/ { + location /{{ key_url }} { set $session_cipher none; # don't need to encrypt the session content, it's an opaque identifier set $session_storage shm; # use shared memory set $session_cookie_persistent on; # persist cookie between browser sessions @@ -30,9 +29,13 @@ end -- Protect this location and allow only one specific ELIXIR User - if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then - ngx.exit(ngx.HTTP_FORBIDDEN) - end + {% if only_allow_owner is true %} + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + {% else %} + -- AUTH DISABLED, ALLOW ANY USER WITH A VALID TOKEN + {% endif %} ngx.req.set_header("X-Auth-Audience", res.id_token.aud) ngx.req.set_header("X-Auth-Email", res.id_token.email) @@ -47,16 +50,19 @@ # After check via lua-oidc is done, start reverse proxying this backend by configuring a billion headers. rewrite /{{ key_url }}/(.*) /$1 break; proxy_pass {{ location_url }}; + proxy_http_version 1.1; # @reviewer: is this needed? + proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 20d; + proxy_set_header X-Scheme $scheme; client_max_body_size 0; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; add_header Referrer-Policy "same-origin" always; - access_log logs/code.access.log; - error_log logs/code.error.log; - } \ No newline at end of file + + } diff --git a/examples/templates/vscode%v03.conf b/examples/templates/vscode%v03.conf old mode 100644 new mode 100755 index aae021b..61412b6 --- a/examples/templates/vscode%v03.conf +++ b/examples/templates/vscode%v03.conf @@ -30,9 +30,11 @@ end -- Protect this location and allow only one specific ELIXIR User - if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then - ngx.exit(ngx.HTTP_FORBIDDEN) - end + {% if only_allow_owner %} + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + {% endif %} ngx.req.set_header("X-Auth-Audience", res.id_token.aud) ngx.req.set_header("X-Auth-Email", res.id_token.email) diff --git a/examples/templating_guide.md b/examples/templating_guide.md old mode 100644 new mode 100755 index 2911786..647c8a3 --- a/examples/templating_guide.md +++ b/examples/templating_guide.md @@ -29,19 +29,21 @@ This is an example Template for the research environment [RStudio](https://rstud location /{{ key_url }}/ { # Run this lua block, which checks if we are authenticated (again) und filters request by JWT (via id_token.sub) access_by_lua_block { - -- Start actual openid authentication procedure - local res, err = require("resty.openidc").authenticate(opts2) - -- If it fails for some reason, escape via HTTP 500 - if err then - ngx.status = 500 - ngx.say(err) - ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) - end - - -- Protect this location and allow only one specific ELIXIR User - if res.id_token.sub ~= "{{ owner }}" then - ngx.exit(ngx.HTTP_FORBIDDEN) - end + -- Start actual openid authentication procedure + local res, err = require("resty.openidc").authenticate(opts2) + -- If it fails for some reason, escape via HTTP 500 + if err then + ngx.status = 500 + ngx.say(err) + ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) + end + + -- Protect this location and allow only one specific ELIXIR User + {% if only_allow_owner %} + if (res.id_token.sub ~= "{{ owner }}" and not user_service.file_exists(ngx.var.user_path .. res.id_token.sub)) then + ngx.exit(ngx.HTTP_FORBIDDEN) + end + {% endif %} } diff --git a/gfx/forc_overview.drawio b/gfx/forc_overview.drawio old mode 100644 new mode 100755 diff --git a/gfx/forc_overview.png b/gfx/forc_overview.png old mode 100644 new mode 100755 diff --git a/renovate.json b/renovate.json old mode 100644 new mode 100755