From 570b296b6e8c85109d009d1b4267738b352a349b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20K=C3=BCnzel?= <simonk@fsmpi.rwth-aachen.de> Date: Thu, 23 May 2024 00:25:50 +0200 Subject: [PATCH] Use live config in routes --- src/api/authentication.py | 31 ++++++++++++++--------- src/api/miscellaneous/__init__.py | 2 +- src/api/miscellaneous/errors.py | 34 ++++++++++++++++---------- src/api/routes/authentication.py | 10 ++++---- src/api/routes/courses.py | 8 +++--- src/api/routes/jobs.py | 2 +- src/api/routes/miscellaneous.py | 2 +- src/api/routes/object_modifications.py | 8 +++--- src/api/routes/route.py | 11 ++++++++- src/api/routes/site.py | 6 ++--- src/api/routes/sorter.py | 2 +- src/api/routes/user.py | 6 +++-- 12 files changed, 76 insertions(+), 46 deletions(-) diff --git a/src/api/authentication.py b/src/api/authentication.py index 4b63490..f4eb559 100644 --- a/src/api/authentication.py +++ b/src/api/authentication.py @@ -397,7 +397,7 @@ elif "LDAP_HOST" in api.config: return None else: def __ldap_authenticate(user: str, password: str) -> tuple[str, str, str, list[str]] or None: - raise ApiClientException(ERROR_AUTHENTICATION_SERVERS_NOT_AVAILABLE) + raise ApiClientException(ERROR_AUTHENTICATION_NOT_AVAILABLE()) _SQL_GET_USER_BY_NAME = PreparedStatement(""" @@ -424,16 +424,25 @@ def authenticate_fsmpi(username: str, password: str) -> {}: if not __ldap_is_moderator(groups): raise ApiClientException(ERROR_AUTHENTICATION_FAILED) - with db_pool.start_write_transaction() as trans: - user_list_db = trans.execute_statement(_SQL_GET_USER_BY_NAME, user_id) - if len(user_list_db) < 1: - result = trans.execute_statement_and_commit( - _SQL_INSERT_USER, user_id, given_name, user_id) - user_db_id = result[0]["id"] - else: - trans.commit() + if api.live_config.is_readonly(): + with db_pool.start_read_transaction() as trans: + user_list_db = trans.execute_statement_and_close(_SQL_GET_USER_BY_NAME, user_id) + if len(user_list_db) < 1: + raise ApiClientException(ERROR_AUTHENTICATION_NOT_AVAILABLE( + "Site is read-only and we can not create a new account for you in the database")) user_db = user_list_db[0] user_db_id = user_db["id"] + else: + with db_pool.start_write_transaction() as trans: + user_list_db = trans.execute_statement(_SQL_GET_USER_BY_NAME, user_id) + if len(user_list_db) < 1: + result = trans.execute_statement_and_commit( + _SQL_INSERT_USER, user_id, given_name, user_id) + user_db_id = result[0]["id"] + else: + trans.commit() + user_db = user_list_db[0] + user_db_id = user_db["id"] session["user"] = { "uid": user_id, @@ -638,7 +647,7 @@ def __make_moodle_request(endpoint, token, **args): def __make_oauth_request(endpoint, **args): if "RWTH_API_KEY" not in api.config: - raise ApiClientException(ERROR_AUTHENTICATION_SERVERS_NOT_AVAILABLE) + raise ApiClientException(ERROR_AUTHENTICATION_NOT_AVAILABLE()) args["client_id"] = api.config["RWTH_API_KEY"] return __handle_request_result(requests.request('POST', OAUTH_BASE + endpoint, data=args, timeout=30)) @@ -655,5 +664,5 @@ def __handle_request_result(response: requests.Response): pass if response.status_code >= 500: - raise ApiClientException(ERROR_AUTHENTICATION_SERVERS_NOT_AVAILABLE) + raise ApiClientException(ERROR_AUTHENTICATION_NOT_AVAILABLE()) raise Exception("Error during request. Status: " + str(response.status_code) + " Response: " + str(response)) diff --git a/src/api/miscellaneous/__init__.py b/src/api/miscellaneous/__init__.py index 48441a1..7374751 100644 --- a/src/api/miscellaneous/__init__.py +++ b/src/api/miscellaneous/__init__.py @@ -15,7 +15,7 @@ from api.miscellaneous.errors import (ApiError, ApiClientException, api_on_error ERROR_INTERNAL_SERVER_ERROR, ERROR_LECTURE_HAS_NO_PASSWORD, ERROR_AUTHENTICATION_FAILED, - ERROR_AUTHENTICATION_SERVERS_NOT_AVAILABLE, + ERROR_AUTHENTICATION_NOT_AVAILABLE, ERROR_UNAUTHORIZED, ERROR_INVALID_CSRF_TOKEN, ERROR_ACCESS_FORBIDDEN, diff --git a/src/api/miscellaneous/errors.py b/src/api/miscellaneous/errors.py index d2cdb08..be40bed 100644 --- a/src/api/miscellaneous/errors.py +++ b/src/api/miscellaneous/errors.py @@ -1,7 +1,5 @@ import json -import traceback from flask.wrappers import Response -from functools import wraps from api.miscellaneous.constants import * @@ -30,26 +28,39 @@ def api_on_error(error: ApiError): def ERROR_BAD_REQUEST(message: str = "The request is invalid") -> ApiError: return ApiError("bad_request", HTTP_400_BAD_REQUEST, message) -ERROR_METHOD_NOT_ALLOWED = ApiError("method_not_allowed", HTTP_405_METHOD_NOT_ALLOWED, - "The specified request method is not allowed") -ERROR_MALFORMED_JSON = ApiError("malformed_json", HTTP_400_BAD_REQUEST, - "The request json is malformed or missing (Or Content-Type is not application/json)") + + def ERROR_REQUEST_MISSING_PARAMETER(parameter_name: str) -> ApiError: return ApiError("request_missing_parameter", HTTP_400_BAD_REQUEST, "Missing parameter " + parameter_name + " in request") + + def ERROR_REQUEST_INVALID_PARAMETER(parameter_name: str, invalid_message: str) -> ApiError: return ApiError("request_invalid_parameter", HTTP_400_BAD_REQUEST, "Parameter " + parameter_name + " in request is invalid: " + invalid_message) + + +def ERROR_AUTHENTICATION_NOT_AVAILABLE(error_message: str = "Authentication servers are currently not available") -> ApiError: + return ApiError("authentication_not_available", HTTP_503_SERVICE_UNAVAILABLE, error_message) + + +def ERROR_OBJECT_ERROR(message: str) -> ApiError: + return ApiError("object_error", HTTP_400_BAD_REQUEST, + message) + + +ERROR_METHOD_NOT_ALLOWED = ApiError("method_not_allowed", HTTP_405_METHOD_NOT_ALLOWED, + "The specified request method is not allowed") +ERROR_MALFORMED_JSON = ApiError("malformed_json", HTTP_400_BAD_REQUEST, + "The request json is malformed or missing (Or Content-Type is not application/json)") ERROR_UNKNOWN_REQUEST_PATH = ApiError("unknown_request_path", HTTP_400_BAD_REQUEST, - "The specified request path does not exist") + "The specified request path does not exist") ERROR_INTERNAL_SERVER_ERROR = ApiError("internal_server_error", HTTP_500_INTERNAL_SERVER_ERROR, "An unknown internal server error occurred") ERROR_LECTURE_HAS_NO_PASSWORD = ApiError("lecture_has_no_password", HTTP_400_BAD_REQUEST, "The specified lecture has no password authentication") ERROR_AUTHENTICATION_FAILED = ApiError("authentication_failed", HTTP_403_FORBIDDEN, "Authentication failed") -ERROR_AUTHENTICATION_SERVERS_NOT_AVAILABLE = ApiError("authentication_servers_not_available", HTTP_503_SERVICE_UNAVAILABLE, - "Authentication servers are currently not available") ERROR_UNAUTHORIZED = ApiError("unauthorized", HTTP_401_UNAUTHORIZED, "You are not authorized") ERROR_INVALID_CSRF_TOKEN = ApiError("invalid_csrf_token", HTTP_403_FORBIDDEN, @@ -62,9 +73,6 @@ ERROR_UNKNOWN_OBJECT = ApiError("unknown_object", HTTP_404_NOT_FOUND, "Cannot find the specified object") ERROR_MODIFICATION_UNEXPECTED_CURRENT_VALUE = ApiError("modification_unexpected_current_value", HTTP_409_CONFLICT, "The current value does not match the expected last known value") -def ERROR_OBJECT_ERROR(message: str) -> ApiError: - return ApiError("object_error", HTTP_400_BAD_REQUEST, - message) ERROR_TOO_MANY_SUGGESTIONS = ApiError("too_many_suggestions", HTTP_403_FORBIDDEN, "Due to too many suggestion, no more are accepted") ERROR_SITE_IS_READONLY = ApiError("site_is_readonly", HTTP_503_SERVICE_UNAVAILABLE, @@ -82,7 +90,7 @@ ALL_ERRORS_RANDOM = [ ERROR_INTERNAL_SERVER_ERROR, ERROR_LECTURE_HAS_NO_PASSWORD, ERROR_AUTHENTICATION_FAILED, - ERROR_AUTHENTICATION_SERVERS_NOT_AVAILABLE, + ERROR_AUTHENTICATION_NOT_AVAILABLE(), ERROR_UNAUTHORIZED, ERROR_INVALID_CSRF_TOKEN, ERROR_ACCESS_FORBIDDEN, diff --git a/src/api/routes/authentication.py b/src/api/routes/authentication.py index d7b916a..cfb5f62 100644 --- a/src/api/routes/authentication.py +++ b/src/api/routes/authentication.py @@ -13,7 +13,7 @@ _API_AUTH_RATE_LIMITERS = create_configured_host_rate_limiters("authentication", @api_add_route("/authentication/password", ["POST"]) -@api_function(rate_limiters=_API_AUTH_RATE_LIMITERS) +@api_function(rate_limiters=_API_AUTH_RATE_LIMITERS, allow_while_readonly=True) def api_route_authentication_password(): json_request = get_client_json(request) lecture_id: int = json_request.get_sint32("lecture_id",) @@ -24,7 +24,7 @@ def api_route_authentication_password(): @api_add_route("/authentication/fsmpi", ["POST"]) -@api_function(rate_limiters=_API_AUTH_RATE_LIMITERS) +@api_function(rate_limiters=_API_AUTH_RATE_LIMITERS, allow_while_readonly=True) def api_route_authentication_fsmpi(): json_request = get_client_json(request) username: str = json_request.get_string("username", max_length=256) @@ -43,7 +43,7 @@ def api_route_authentication_fsmpi(): @api_add_route("/authentication/start_oauth", ["POST"]) -@api_function(rate_limiters=_API_AUTH_RATE_LIMITERS) +@api_function(rate_limiters=_API_AUTH_RATE_LIMITERS, allow_while_readonly=True) def api_route_authentication_start_oauth(): json_request = get_client_json(request) login_type: str = json_request.get_string("type", max_length=10) @@ -58,7 +58,7 @@ def api_route_authentication_start_oauth(): }, HTTP_202_ACCEPTED -@api_route("/authentication/status", ["POST"]) +@api_route("/authentication/status", ["POST"], allow_while_readonly=True) def api_route_authentication_status(): json_request = get_client_json(request) @@ -95,7 +95,7 @@ def _set_moderator_cookies(response: ApiResponse): response.response.set_cookie("moderator", "#", max_age=None, httponly=True, samesite="Strict") -@api_route("/authentication/moderator_logout", ["POST"]) +@api_route("/authentication/moderator_logout", ["POST"], allow_while_readonly=True) def api_route_authentication_moderator_logout(): logout_moderator() response = ApiResponse({}) diff --git a/src/api/routes/courses.py b/src/api/routes/courses.py index 45b025d..acafc9a 100644 --- a/src/api/routes/courses.py +++ b/src/api/routes/courses.py @@ -30,7 +30,7 @@ ORDER BY "courses"."id" ASC """) -@api_route("/courses", ["GET"]) +@api_route("/courses", ["GET"], allow_while_readonly=True) def api_route_courses(): is_mod: bool = is_moderator() with db_pool.start_read_transaction() as trans: @@ -102,7 +102,7 @@ ORDER BY "lectures"."id" ASC @api_add_route("/course/<int:course_id>", ["GET"]) @api_add_route("/course/<string:course_id_string>", ["GET"]) -@api_function() +@api_function(allow_while_readonly=True) def api_route_course(course_id: int = None, course_id_string: str = None): is_mod: bool = is_moderator() with db_pool.start_read_transaction() as trans: @@ -194,7 +194,7 @@ WHERE %s _SQL_GET_LECTURE_MEDIA = PreparedStatement(_SQL_GET_LECTURE_MEDIA_NO_ID_COND % '"videos"."lecture_id" = ?') -@api_route("/lecture/<int:lecture_id>", ["GET"]) +@api_route("/lecture/<int:lecture_id>", ["GET"], allow_while_readonly=True) def api_route_lecture(lecture_id: int): is_mod: bool = is_moderator() with db_pool.start_read_transaction() as trans: @@ -280,7 +280,7 @@ def api_route_lecture_chapter_suggestion(lecture_id: int): return {}, HTTP_201_CREATED -@api_route("/search", ["GET"]) +@api_route("/search", ["GET"], allow_while_readonly=True) def api_route_search(): is_mod = is_moderator() diff --git a/src/api/routes/jobs.py b/src/api/routes/jobs.py index ab630d6..e1598a7 100644 --- a/src/api/routes/jobs.py +++ b/src/api/routes/jobs.py @@ -7,7 +7,7 @@ from api.job import (JOBS_MIN_PAGE_SIZE, JOBS_MAX_PAGE_SIZE, WORKER_ID_PATTERN, from api.miscellaneous import * -@api_route("/jobs", ["GET"]) +@api_route("/jobs", ["GET"], allow_while_readonly=True) @api_moderator_route() def api_route_jobs(): entries_per_page = api_request_get_query_int("entries_per_page", 50, JOBS_MIN_PAGE_SIZE, JOBS_MAX_PAGE_SIZE) diff --git a/src/api/routes/miscellaneous.py b/src/api/routes/miscellaneous.py index 45c4290..9f1ed65 100644 --- a/src/api/routes/miscellaneous.py +++ b/src/api/routes/miscellaneous.py @@ -43,7 +43,7 @@ def api_route_lecture_import_rwth_online(course_id: int): # Not in API specification on purpose -@api_route("/diagnostics", ["GET"]) +@api_route("/diagnostics", ["GET"], allow_while_readonly=True) @api_moderator_route() def api_route_diagnostics(): response = { diff --git a/src/api/routes/object_modifications.py b/src/api/routes/object_modifications.py index 3722f13..b3fa54f 100644 --- a/src/api/routes/object_modifications.py +++ b/src/api/routes/object_modifications.py @@ -9,10 +9,12 @@ from api.authentication import check_csrf_token /object_management\ /<any({','.join(key for key, obj in object_classes_by_id.items())}):object_type>\ /<int:object_id>/configuration\ -""", ["GET", "PATCH"]) +""", ["GET", "PATCH"], allow_while_readonly=True) @api_moderator_route() def api_route_object_management_configuration(object_type: str, object_id: int): if request.method == "PATCH": + if api.live_config.is_readonly(): + raise ApiClientException(ERROR_SITE_IS_READONLY) check_csrf_token() check_client_int(object_id, "URL.object_id") json_request = get_client_json(request) @@ -35,7 +37,7 @@ def api_route_object_management_configuration(object_type: str, object_id: int): /object_management\ /<any({','.join(key for key, obj in object_classes_by_id.items() if obj.is_creation_allowed())}):object_type>\ /new/configuration\ -""", ["GET"]) +""", ["GET"], allow_while_readonly=True) @api_moderator_route() def api_route_object_management_new_configuration(object_type: str): return object_classes_by_id[object_type].get_creation_config() @@ -124,7 +126,7 @@ def api_route_object_management_resurrect(object_type: str, object_id: int): return {} -@api_route("/object_management/changelog", ["GET"]) +@api_route("/object_management/changelog", ["GET"], allow_while_readonly=True) @api_moderator_route() def api_route_object_management_changelog(): entries_per_page = api_request_get_query_int("entries_per_page", 50, CHANGELOG_MIN_PAGE_SIZE, CHANGELOG_MAX_PAGE_SIZE) diff --git a/src/api/routes/route.py b/src/api/routes/route.py index 491b664..122ebac 100644 --- a/src/api/routes/route.py +++ b/src/api/routes/route.py @@ -107,10 +107,12 @@ class ApiResponse: def api_route(path: str, methods: list[str], + allow_while_readonly: bool = False, + allow_while_disabled: bool = False, min_api_version: int = API_OLDEST_ACTIVE_VERSION, max_api_version: int = API_LATEST_VERSION): def decorator(func): - func = api_function()(func) + func = api_function(allow_while_readonly=allow_while_readonly, allow_while_disabled=allow_while_disabled)(func) func = api_add_route(path, methods, min_api_version, max_api_version)(func) return func @@ -132,6 +134,8 @@ def api_add_route(path: str, methods: list[str], def api_function(track_in_diagnostics: bool = True, + allow_while_readonly: bool = False, + allow_while_disabled: bool = False, rate_limiters: tuple[HostBasedCounterRateLimiter, ...] = _API_GLOBAL_RATE_LIMITERS): def decorator(func): call_counter = None @@ -149,6 +153,11 @@ def api_function(track_in_diagnostics: bool = True, @wraps(func) def wrapper(*args, **kwargs): try: + if api.live_config.is_disabled() and not allow_while_disabled: + raise ApiClientException(ERROR_SITE_IS_DISABLED) + if api.live_config.is_readonly() and not allow_while_readonly: + raise ApiClientException(ERROR_SITE_IS_READONLY) + if call_counter is not None: call_counter.trigger() diff --git a/src/api/routes/site.py b/src/api/routes/site.py index 57992c7..308190a 100644 --- a/src/api/routes/site.py +++ b/src/api/routes/site.py @@ -12,7 +12,7 @@ if _GIT_COMMIT_HASH is not None and len(_GIT_COMMIT_HASH) == 0: _GIT_COMMIT_HASH = None -@api_route("/status", ["GET"]) +@api_route("/status", ["GET"], allow_while_readonly=True, allow_while_disabled=True) def api_route_status(): status = "available" announcements = [] @@ -38,7 +38,7 @@ def api_route_status(): return response -@api_route("/is_running", ["GET"]) +@api_route("/is_running", ["GET"], allow_while_readonly=True, allow_while_disabled=True) def api_route_is_running(): return {}, HTTP_200_OK @@ -158,7 +158,7 @@ ORDER BY CASE WHEN "order" IS NULL THEN 0 ELSE "order" END ASC, "id" ASC """) -@api_route("/homepage", ["GET"]) +@api_route("/homepage", ["GET"], allow_while_readonly=True) def api_route_homepage(): is_mod: bool = is_moderator() upcoming_start: date = date.today() diff --git a/src/api/routes/sorter.py b/src/api/routes/sorter.py index b991226..462cb2f 100644 --- a/src/api/routes/sorter.py +++ b/src/api/routes/sorter.py @@ -29,7 +29,7 @@ WHERE "id" = ? """) -@api_route("/sorter/log", ["GET"]) +@api_route("/sorter/log", ["GET"], allow_while_readonly=True) @api_moderator_route() def api_route_sorter_log(): with db_pool.start_read_transaction() as trans: diff --git a/src/api/routes/user.py b/src/api/routes/user.py index a0ae3bd..323d428 100644 --- a/src/api/routes/user.py +++ b/src/api/routes/user.py @@ -8,7 +8,7 @@ _SQL_GET_USERS = PreparedStatement(""" """) -@api_route("/users", ["GET"]) +@api_route("/users", ["GET"], allow_while_readonly=True) @api_moderator_route() def api_route_users(): with db_pool.start_read_transaction() as trans: @@ -18,10 +18,12 @@ def api_route_users(): } -@api_route("/user/me/settings", ["GET", "PATCH"]) +@api_route("/user/me/settings", ["GET", "PATCH"], allow_while_readonly=True) @api_moderator_route() def api_route_user_me_settings(): if flask.request.method == "GET": + if api.live_config.is_readonly(): + raise ApiClientException(ERROR_SITE_IS_READONLY) settings = get_user_settings(get_user_id()) if settings is None: raise RuntimeError("No db user for moderator") # pragma: no cover -- GitLab