Loading capif_backend/backoffice_service/controllers/me_controller.py +31 −1 Changes for capif_backend/backoffice_service/controllers/me_controller.py: 31 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -3,7 +3,7 @@ from flask_jwt_extended import jwt_required from ..core.operations import Operations from ..utils.utils import get_capif_token, get_capif_urls, get_capif_uuid from ..utils.vault import read_invoker, VaultError me_routes = Blueprint("me_routes", __name__) operations = Operations() # create operations object -> operations.py Loading @@ -30,3 +30,33 @@ def onboard_invoker(): capif_token, user_uuid, ccf_onboarding_url, invoker_info, notification_destination ) return jsonify(data), status @me_routes.route("/invokers/<api_invoker_id>/apis", methods=["GET"]) @jwt_required() def discover_apis(api_invoker_id): if not get_capif_token(): return jsonify({"error": "This account has no CAPIF credentials"}), 403 user_uuid = get_capif_uuid() if not user_uuid: return jsonify({"error": "Could not determine the CAPIF user id for this account"}), 500 # Authorization by path design: the Vault path is keyed by the uuid from the JWT, # never from the URL. If a user passes another user's apiInvokerId, the lookup runs # under their own uuid, finds nothing, and returns 404. Do not flatten the Vault path # (e.g. to .../invokers/{id}) without adding an explicit ownership check, or this # becomes an IDOR. try: creds = read_invoker(user_uuid, api_invoker_id) except VaultError as e: print(f"ERROR: {e}") return jsonify({"error": "Could not read invoker credentials."}), 500 if creds is None: return jsonify({"error": "Invoker not found."}), 404 ccf_discover_url = get_capif_urls()["ccf_discover_url"] data, status = operations.discover_apis( ccf_discover_url, api_invoker_id, creds["certificate"], creds["private_key"] ) return jsonify(data), status No newline at end of file capif_backend/backoffice_service/core/operations.py +38 −29 Changes for capif_backend/backoffice_service/core/operations.py: 38 added lines, 29 removed lines. Original line number Diff line number Diff line Loading @@ -10,9 +10,11 @@ from urllib.parse import urlencode # from ..configs.config import Config from ..config import Config from ..utils.certs import generate_keypair_and_csr from ..utils.certs import generate_keypair_and_csr, mtls_files, SecureTempUnavailable from ..utils.vault import store_invoker _DISCOVER_NO_APIS_CAUSE = "No API Published accomplish filter conditions" class Operations: def __init__(self): Loading Loading @@ -507,7 +509,8 @@ class Operations: timeout=30, ) except requests.exceptions.RequestException as e: return {"error": f"Could not reach CCF: {e}"}, 502 print(f"ERROR: could not reach CCF for invoker onboarding: {e}") return {"error": "Could not reach CCF."}, 502 try: data = response.json() Loading Loading @@ -594,7 +597,6 @@ class Operations: # Handle request error return jsonify({"error": str(e)}), 500 def get_providers(self): url = "https://{}/helper/api/getProviders".format(os.getenv('CAPIF_HOSTNAME')) Loading Loading @@ -634,7 +636,6 @@ class Operations: # Handle request errors and return the error message return jsonify({"Error when retrieving number of providers": str(e)}), 500 def get_providers_list(self): url = "https://{}/helper/api/getProviders".format(os.getenv('CAPIF_HOSTNAME')) Loading Loading @@ -723,7 +724,6 @@ class Operations: # Handle request error return jsonify({"error": str(e)}), 500 def get_users_total(self): def login2(): try: Loading Loading @@ -783,7 +783,6 @@ class Operations: except Exception as e: return "Error when retrieving number of users: ", e def delete_providers(self, provider_ids): try: responses = [] Loading Loading @@ -846,8 +845,6 @@ class Operations: except Exception as e: raise Exception("Error deleting apis: " + str(e)) def get_apis1(self): url = "https://{}/helper/api/getServices".format(os.getenv('CAPIF_HOSTNAME')) Loading Loading @@ -892,7 +889,6 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": str(e)}), 500 def get_apis_list(self): url = "https://{}/helper/api/getServices".format(os.getenv('CAPIF_HOSTNAME')) Loading Loading @@ -947,8 +943,6 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": str(e)}), 500 def get_num_apis(self): url = "https://{}/helper/api/getServices".format(os.getenv('CAPIF_HOSTNAME')) Loading @@ -969,8 +963,6 @@ class Operations: # Handle request errors and return the error message return jsonify({"Error when retrieving number of APIs": str(e)}), 500 def get_configuration(self): # Build the request URL url = "https://{}/helper/configuration/getConfiguration".format(os.getenv('CAPIF_HOSTNAME')) Loading Loading @@ -998,7 +990,6 @@ class Operations: # Handle errors in case of request failures return jsonify({"error": str(e)}), 500 def update_configuration_param(self, param_path, new_value): """ Update a single configuration parameter """ url = "https://{}/helper/configuration/updateConfigParam".format(os.getenv('CAPIF_HOSTNAME')) Loading Loading @@ -1053,7 +1044,6 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": str(e)}), 500 def add_new_category(self, category_name, category_values): """Add a new parameter category in 'settings'.""" url = "https://{}/helper/configuration/addNewConfiguration".format(os.getenv('CAPIF_HOSTNAME')) Loading Loading @@ -1094,7 +1084,6 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": str(e)}), 500 def remove_configuration_param(self, param_path): """ Delete a parameter within a category in 'settings' """ url = "https://{}/helper/configuration/removeConfigParam".format(os.getenv('CAPIF_HOSTNAME')) Loading @@ -1114,7 +1103,6 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": str(e)}), 500 def remove_configuration_category(self, category_name): """ Delete an entire category within 'settings' """ url = "https://{}/helper/configuration/removeConfigCategory".format(os.getenv('CAPIF_HOSTNAME')) Loading @@ -1134,8 +1122,6 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": str(e)}), 500 ### REGISTER CONFIGURATION ### def get_register_configuration(self): Loading Loading @@ -1195,8 +1181,6 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": f"Error when fetching register configuration: {str(e)}"}), 500 def update_register_configuration_param(self, param_path, new_value): """ Update a single parameter in the register configuration """ Loading Loading @@ -1261,8 +1245,6 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": f"Error when updating register configuration: {str(e)}"}), 500 def replace_register_configuration(self, new_config): """Replace the entire register configuration""" Loading Loading @@ -1321,9 +1303,6 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": f"Error when replacing register configuration: {str(e)}"}), 500 def add_new_config_register_category(self, category_name, category_values): """Add a new parameter category in 'settings' in register.""" def login(): Loading Loading @@ -1421,8 +1400,6 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": f"Error when adding config setting: {str(e)}"}), 500 def remove_register_configuration_param(self, param_path): """Delete a parameter within a category in 'settings' in register.""" Loading Loading @@ -1473,7 +1450,6 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": f"Error deleting configuration parameter: {str(e)}"}), 500 def remove_register_configuration_category(self, category_name): """Delete an entire category within 'settings' in register.""" Loading Loading @@ -1524,3 +1500,36 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": f"Error deleting configuration category: {str(e)}"}), 500 def discover_apis(self, discover_url, invoker_id, certificate, private_key): """Discover APIs visible to an invoker. Authenticates to CCF with the invoker's own mTLS certificate only.""" url = f"https://{os.getenv('CAPIF_HOSTNAME')}/{discover_url}{invoker_id}" try: with mtls_files(certificate, private_key) as (cert_path, key_path): response = requests.get( url, cert=(cert_path, key_path), verify="newcerts/ca_root.crt", timeout=30, ) except SecureTempUnavailable as e: print(f"ERROR: {e}") return {"error": "Secure temporary storage is unavailable."}, 500 except requests.exceptions.RequestException as e: print(f"ERROR: could not reach CCF for discover (invoker {invoker_id}): {e}") return {"error": "Could not reach CCF."}, 502 try: data = response.json() except ValueError: data = {"error": response.text} if (response.status_code == 404 and isinstance(data, dict) and data.get("cause") == _DISCOVER_NO_APIS_CAUSE): return {"serviceAPIDescriptions": []}, 200 if response.status_code != 200: print(f"ERROR: CCF discover failed for invoker {invoker_id}: {response.status_code} {data}") return {"error": "CCF discover failed.", "ccf_status": response.status_code, "ccf_response": data}, 502 return data, 200 No newline at end of file capif_backend/backoffice_service/utils/certs.py +36 −0 Changes for capif_backend/backoffice_service/utils/certs.py: 36 added lines, 0 removed lines. Original line number Diff line number Diff line import os import contextlib import tempfile from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa Loading Loading @@ -32,3 +34,37 @@ def generate_keypair_and_csr(): ) return csr_request, private_key # RAM-backed tmpfs. Private keys must never be written to the container's disk layer, # which can be persisted, snapshotted or committed into an image. SECURE_TMP_DIR = "/dev/shm" class SecureTempUnavailable(Exception): """The RAM-backed temp directory is missing; we fail closed rather than fall back to disk.""" @contextlib.contextmanager def mtls_files(cert_pem, key_pem): """Write a certificate bundle and private key to temp files for requests' `cert=`. Files are created with mkstemp (mode 0600) in /dev/shm and removed on exit, including when the request fails. """ if not os.path.isdir(SECURE_TMP_DIR): raise SecureTempUnavailable(f"{SECURE_TMP_DIR} is not available") paths = [] try: for content in (cert_pem, key_pem): fd, path = tempfile.mkstemp(dir=SECURE_TMP_DIR) paths.append(path) with os.fdopen(fd, "w") as f: f.write(content) yield paths[0], paths[1] finally: for path in paths: try: os.unlink(path) except FileNotFoundError: pass No newline at end of file Loading
capif_backend/backoffice_service/controllers/me_controller.py +31 −1 Changes for capif_backend/backoffice_service/controllers/me_controller.py: 31 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -3,7 +3,7 @@ from flask_jwt_extended import jwt_required from ..core.operations import Operations from ..utils.utils import get_capif_token, get_capif_urls, get_capif_uuid from ..utils.vault import read_invoker, VaultError me_routes = Blueprint("me_routes", __name__) operations = Operations() # create operations object -> operations.py Loading @@ -30,3 +30,33 @@ def onboard_invoker(): capif_token, user_uuid, ccf_onboarding_url, invoker_info, notification_destination ) return jsonify(data), status @me_routes.route("/invokers/<api_invoker_id>/apis", methods=["GET"]) @jwt_required() def discover_apis(api_invoker_id): if not get_capif_token(): return jsonify({"error": "This account has no CAPIF credentials"}), 403 user_uuid = get_capif_uuid() if not user_uuid: return jsonify({"error": "Could not determine the CAPIF user id for this account"}), 500 # Authorization by path design: the Vault path is keyed by the uuid from the JWT, # never from the URL. If a user passes another user's apiInvokerId, the lookup runs # under their own uuid, finds nothing, and returns 404. Do not flatten the Vault path # (e.g. to .../invokers/{id}) without adding an explicit ownership check, or this # becomes an IDOR. try: creds = read_invoker(user_uuid, api_invoker_id) except VaultError as e: print(f"ERROR: {e}") return jsonify({"error": "Could not read invoker credentials."}), 500 if creds is None: return jsonify({"error": "Invoker not found."}), 404 ccf_discover_url = get_capif_urls()["ccf_discover_url"] data, status = operations.discover_apis( ccf_discover_url, api_invoker_id, creds["certificate"], creds["private_key"] ) return jsonify(data), status No newline at end of file
capif_backend/backoffice_service/core/operations.py +38 −29 Changes for capif_backend/backoffice_service/core/operations.py: 38 added lines, 29 removed lines. Original line number Diff line number Diff line Loading @@ -10,9 +10,11 @@ from urllib.parse import urlencode # from ..configs.config import Config from ..config import Config from ..utils.certs import generate_keypair_and_csr from ..utils.certs import generate_keypair_and_csr, mtls_files, SecureTempUnavailable from ..utils.vault import store_invoker _DISCOVER_NO_APIS_CAUSE = "No API Published accomplish filter conditions" class Operations: def __init__(self): Loading Loading @@ -507,7 +509,8 @@ class Operations: timeout=30, ) except requests.exceptions.RequestException as e: return {"error": f"Could not reach CCF: {e}"}, 502 print(f"ERROR: could not reach CCF for invoker onboarding: {e}") return {"error": "Could not reach CCF."}, 502 try: data = response.json() Loading Loading @@ -594,7 +597,6 @@ class Operations: # Handle request error return jsonify({"error": str(e)}), 500 def get_providers(self): url = "https://{}/helper/api/getProviders".format(os.getenv('CAPIF_HOSTNAME')) Loading Loading @@ -634,7 +636,6 @@ class Operations: # Handle request errors and return the error message return jsonify({"Error when retrieving number of providers": str(e)}), 500 def get_providers_list(self): url = "https://{}/helper/api/getProviders".format(os.getenv('CAPIF_HOSTNAME')) Loading Loading @@ -723,7 +724,6 @@ class Operations: # Handle request error return jsonify({"error": str(e)}), 500 def get_users_total(self): def login2(): try: Loading Loading @@ -783,7 +783,6 @@ class Operations: except Exception as e: return "Error when retrieving number of users: ", e def delete_providers(self, provider_ids): try: responses = [] Loading Loading @@ -846,8 +845,6 @@ class Operations: except Exception as e: raise Exception("Error deleting apis: " + str(e)) def get_apis1(self): url = "https://{}/helper/api/getServices".format(os.getenv('CAPIF_HOSTNAME')) Loading Loading @@ -892,7 +889,6 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": str(e)}), 500 def get_apis_list(self): url = "https://{}/helper/api/getServices".format(os.getenv('CAPIF_HOSTNAME')) Loading Loading @@ -947,8 +943,6 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": str(e)}), 500 def get_num_apis(self): url = "https://{}/helper/api/getServices".format(os.getenv('CAPIF_HOSTNAME')) Loading @@ -969,8 +963,6 @@ class Operations: # Handle request errors and return the error message return jsonify({"Error when retrieving number of APIs": str(e)}), 500 def get_configuration(self): # Build the request URL url = "https://{}/helper/configuration/getConfiguration".format(os.getenv('CAPIF_HOSTNAME')) Loading Loading @@ -998,7 +990,6 @@ class Operations: # Handle errors in case of request failures return jsonify({"error": str(e)}), 500 def update_configuration_param(self, param_path, new_value): """ Update a single configuration parameter """ url = "https://{}/helper/configuration/updateConfigParam".format(os.getenv('CAPIF_HOSTNAME')) Loading Loading @@ -1053,7 +1044,6 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": str(e)}), 500 def add_new_category(self, category_name, category_values): """Add a new parameter category in 'settings'.""" url = "https://{}/helper/configuration/addNewConfiguration".format(os.getenv('CAPIF_HOSTNAME')) Loading Loading @@ -1094,7 +1084,6 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": str(e)}), 500 def remove_configuration_param(self, param_path): """ Delete a parameter within a category in 'settings' """ url = "https://{}/helper/configuration/removeConfigParam".format(os.getenv('CAPIF_HOSTNAME')) Loading @@ -1114,7 +1103,6 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": str(e)}), 500 def remove_configuration_category(self, category_name): """ Delete an entire category within 'settings' """ url = "https://{}/helper/configuration/removeConfigCategory".format(os.getenv('CAPIF_HOSTNAME')) Loading @@ -1134,8 +1122,6 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": str(e)}), 500 ### REGISTER CONFIGURATION ### def get_register_configuration(self): Loading Loading @@ -1195,8 +1181,6 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": f"Error when fetching register configuration: {str(e)}"}), 500 def update_register_configuration_param(self, param_path, new_value): """ Update a single parameter in the register configuration """ Loading Loading @@ -1261,8 +1245,6 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": f"Error when updating register configuration: {str(e)}"}), 500 def replace_register_configuration(self, new_config): """Replace the entire register configuration""" Loading Loading @@ -1321,9 +1303,6 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": f"Error when replacing register configuration: {str(e)}"}), 500 def add_new_config_register_category(self, category_name, category_values): """Add a new parameter category in 'settings' in register.""" def login(): Loading Loading @@ -1421,8 +1400,6 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": f"Error when adding config setting: {str(e)}"}), 500 def remove_register_configuration_param(self, param_path): """Delete a parameter within a category in 'settings' in register.""" Loading Loading @@ -1473,7 +1450,6 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": f"Error deleting configuration parameter: {str(e)}"}), 500 def remove_register_configuration_category(self, category_name): """Delete an entire category within 'settings' in register.""" Loading Loading @@ -1524,3 +1500,36 @@ class Operations: except requests.exceptions.RequestException as e: return jsonify({"error": f"Error deleting configuration category: {str(e)}"}), 500 def discover_apis(self, discover_url, invoker_id, certificate, private_key): """Discover APIs visible to an invoker. Authenticates to CCF with the invoker's own mTLS certificate only.""" url = f"https://{os.getenv('CAPIF_HOSTNAME')}/{discover_url}{invoker_id}" try: with mtls_files(certificate, private_key) as (cert_path, key_path): response = requests.get( url, cert=(cert_path, key_path), verify="newcerts/ca_root.crt", timeout=30, ) except SecureTempUnavailable as e: print(f"ERROR: {e}") return {"error": "Secure temporary storage is unavailable."}, 500 except requests.exceptions.RequestException as e: print(f"ERROR: could not reach CCF for discover (invoker {invoker_id}): {e}") return {"error": "Could not reach CCF."}, 502 try: data = response.json() except ValueError: data = {"error": response.text} if (response.status_code == 404 and isinstance(data, dict) and data.get("cause") == _DISCOVER_NO_APIS_CAUSE): return {"serviceAPIDescriptions": []}, 200 if response.status_code != 200: print(f"ERROR: CCF discover failed for invoker {invoker_id}: {response.status_code} {data}") return {"error": "CCF discover failed.", "ccf_status": response.status_code, "ccf_response": data}, 502 return data, 200 No newline at end of file
capif_backend/backoffice_service/utils/certs.py +36 −0 Changes for capif_backend/backoffice_service/utils/certs.py: 36 added lines, 0 removed lines. Original line number Diff line number Diff line import os import contextlib import tempfile from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa Loading Loading @@ -32,3 +34,37 @@ def generate_keypair_and_csr(): ) return csr_request, private_key # RAM-backed tmpfs. Private keys must never be written to the container's disk layer, # which can be persisted, snapshotted or committed into an image. SECURE_TMP_DIR = "/dev/shm" class SecureTempUnavailable(Exception): """The RAM-backed temp directory is missing; we fail closed rather than fall back to disk.""" @contextlib.contextmanager def mtls_files(cert_pem, key_pem): """Write a certificate bundle and private key to temp files for requests' `cert=`. Files are created with mkstemp (mode 0600) in /dev/shm and removed on exit, including when the request fails. """ if not os.path.isdir(SECURE_TMP_DIR): raise SecureTempUnavailable(f"{SECURE_TMP_DIR} is not available") paths = [] try: for content in (cert_pem, key_pem): fd, path = tempfile.mkstemp(dir=SECURE_TMP_DIR) paths.append(path) with os.fdopen(fd, "w") as f: f.write(content) yield paths[0], paths[1] finally: for path in paths: try: os.unlink(path) except FileNotFoundError: pass No newline at end of file