Commit bcabf80c authored by Alex Kakiris's avatar Alex Kakiris
Browse files

store invoker credentials in Vault with rollback on failure

parent f3193e49
Loading
Loading
Loading
Loading
+8 −10
Changes for capif_backend/backoffice_service/controllers/me_controller.py: 8 added lines, 10 removed lines.
Original line number Diff line number Diff line
@@ -2,7 +2,8 @@ from flask import Blueprint, jsonify, request
from flask_jwt_extended import jwt_required

from ..core.operations import Operations
from ..utils.utils import get_capif_token, get_capif_urls
from ..utils.utils import get_capif_token, get_capif_urls, get_capif_uuid


me_routes = Blueprint("me_routes", __name__)
operations = Operations()   # create operations object -> operations.py
@@ -14,6 +15,10 @@ def onboard_invoker():
    if not 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

    payload = request.get_json(silent=True) or {}
    invoker_info = payload.get("apiInvokerInformation")
    notification_destination = payload.get("notificationDestination")
@@ -22,13 +27,6 @@ def onboard_invoker():

    ccf_onboarding_url = get_capif_urls()["ccf_onboarding_url"]
    data, status = operations.onboard_invoker(
        capif_token, ccf_onboarding_url, invoker_info, notification_destination
        capif_token, user_uuid, ccf_onboarding_url, invoker_info, notification_destination
    )

    if status != 201:
        return jsonify({"error": "CCF onboarding failed.", "ccf_status": status, "ccf_response": data}), 502

    return jsonify({
        "apiInvokerId": data["apiInvokerId"],
        "apiInvokerCertificate": data["onboardingInformation"]["apiInvokerCertificate"],
    }), 201
 No newline at end of file
    return jsonify(data), status
 No newline at end of file
+46 −14
Changes for capif_backend/backoffice_service/core/operations.py: 46 added lines, 14 removed lines.
Original line number Diff line number Diff line
@@ -11,7 +11,7 @@ from urllib.parse import urlencode
# from ..configs.config import Config
from ..config import Config
from ..utils.certs import generate_keypair_and_csr

from ..utils.vault import store_invoker

class Operations:

@@ -118,7 +118,6 @@ class Operations:
        except requests.exceptions.RequestException as e:
            return jsonify({"error": f"Error when retrieving user list: {str(e)}"}), 500


    def find_user_by_uuid(self, uuid):
        try:
            # Call retrieve_users_pages_order to get all users
@@ -142,7 +141,6 @@ class Operations:
        except Exception as e:
            return jsonify({"error": f"An error occurred: {str(e)}"}), 500


    def get_number_of_users(self):
        def login2():
            try:
@@ -258,7 +256,6 @@ class Operations:
        except requests.exceptions.RequestException as e:
            return jsonify({"error": f"Error when creating user: {str(e)}"}), 500


    def get_invokers(self):
        url = "https://{}/helper/api/getInvokers".format(os.getenv('CAPIF_HOSTNAME'))

@@ -281,7 +278,6 @@ class Operations:
        except requests.exceptions.RequestException as e:
            return jsonify({"error": str(e)}), 500


    def delete_invokers(self, invoker_ids):
        try:
            responses = []
@@ -320,7 +316,6 @@ class Operations:
                error_message += " | Response content: " + response.text
            raise Exception("Error deleting invokers: " + error_message)


    def get_num_invokers(self):
        url = "https://{}/helper/api/getInvokers".format(os.getenv('CAPIF_HOSTNAME'))

@@ -341,7 +336,6 @@ class Operations:
            # Handle request errors and return the error message
            return jsonify({"error": str(e)}), 500


    def get_invokers_pages_order1(self, page, page_size, sort_order):
        url = "https://{}/helper/api/getInvokers".format(os.getenv('CAPIF_HOSTNAME'))

@@ -439,7 +433,6 @@ class Operations:
            print(f"Error: {str(e)}")  # Log de error
            return {"error": str(e)}


    def get_security_context_of_a_invoker(self, invoker_id):
        # Construir la URL con el invoker_id como query parameter
        url = "https://{}/helper/api/getSecurity".format(os.getenv('CAPIF_HOSTNAME'))
@@ -489,16 +482,18 @@ class Operations:
            print(f"Error: {str(e)}")  # Log de error
            return {"error": str(e)}

    def onboard_invoker(self, capif_token, onboarding_url, invoker_info, notification_destination):
        """Onboard an API invoker in CCF on behalf of the logged-in user."""
        csr_request, _private_key = generate_keypair_and_csr()
    def onboard_invoker(self, capif_token, user_uuid, onboarding_url, invoker_info, notification_destination):
        """Onboard an API invoker in CCF on behalf of the logged-in user, 
        and store its private key and certificate in Vault"""
        csr_request, private_key = generate_keypair_and_csr()

        body = {
            "apiInvokerInformation": invoker_info,
            "notificationDestination": notification_destination,
            "requestTestNotification": False,
            "onboardingInformation": {"apiInvokerPublicKey": csr_request.decode("utf-8")},
            "supportedFeatures": "fffffff",
            "websockNotifConfig": {"requestWebsocketUri": True, "websocketUri": "websocketUri"},
            "supportedFeatures": "fffffff",  # TODO: placeholder from capif-client, needs real value
            "websockNotifConfig": {"requestWebsocketUri": True, "websocketUri": "websocketUri"},  # TODO: placeholder
        }

        url = "https://{}/{}".format(os.getenv('CAPIF_HOSTNAME'), onboarding_url)
@@ -519,8 +514,45 @@ class Operations:
        except ValueError:
            data = {"error": response.text}

        return data, response.status_code
        if response.status_code != 201:
            return {"error": "CCF onboarding failed.", "ccf_status": response.status_code, "ccf_response": data}, 502

        invoker_id = data.get("apiInvokerId")
        certificate = (data.get("onboardingInformation") or {}).get("apiInvokerCertificate")

        if not invoker_id or not certificate:
            print(f"ERROR: unexpected CCF onboarding response shape: {list(data.keys())}")
            if invoker_id:
                self._rollback_invoker(invoker_id)
            return {"error": "CCF returned an unexpected onboarding response."}, 502

        if not store_invoker(user_uuid, invoker_id, private_key.decode("utf-8"), certificate):
            if self._rollback_invoker(invoker_id):
                print(f"WARNING: invoker {invoker_id} rolled back in CCF after Vault write failure")
            else:
                print(f"ERROR: invoker {invoker_id} exists in CCF without stored credentials; admin cleanup needed")
            return {"error": "Onboarding failed: the invoker credentials could not be stored."}, 500

        return {"apiInvokerId": invoker_id}, 201

    def _rollback_invoker(self, invoker_id):
        """Delete an invoker in CCF. Returns True on success."""
        url = "https://{}/api-invoker-management/v1/onboardedInvokers/{}".format(
            os.getenv('CAPIF_HOSTNAME'), invoker_id)
        try:
            response = requests.delete(
                url,
                cert=("newcerts/superadmin.crt", "newcerts/superadmin.key"),
                verify="newcerts/ca_root.crt",
                timeout=30,
            )
        except requests.exceptions.RequestException as e:
            print(f"ERROR: rollback of invoker {invoker_id} failed: {e}")
            return False
        if response.status_code != 204:
            print(f"ERROR: rollback of invoker {invoker_id} failed: {response.status_code} {response.text}")
            return False
        return True
    
    def get_invokers_of_a_user(self, uuid):
        # Construir la URL base
+33 −0
Changes for capif_backend/backoffice_service/utils/vault.py: 33 added lines, 0 removed lines.
Original line number Diff line number Diff line
import os
import requests

def vault_addr():
    """Base URL of Vault. VAULT_ADDR, otherwise falls back to http://VAULT_HOSTNAME:VAULT_PORT as before."""
@@ -9,3 +10,35 @@ def vault_addr():

def vault_headers():
    return {'X-Vault-Token': os.getenv('VAULT_TOKEN')}

def vault_verify():
    """TLS verification for Vault requests: a CA bundle path from VAULT_CACERT if set,
    otherwise default verification. Has no effect on http:// addresses."""
    return os.getenv('VAULT_CACERT') or True

def store_invoker(user_uuid, invoker_id, private_key, certificate):
    """Store an invoker's private key and certificate in Vault."""
    path = f"secret/data/backoffice/users/{user_uuid}/invokers/{invoker_id}"
    payload = {"data": {
        "private_key": private_key,
        "certificate": certificate,
        "api_invoker_id": invoker_id,
    }}

    try:
        response = requests.post(
            f"{vault_addr()}/v1/{path}",
            headers=vault_headers(),
            json=payload,
            verify=vault_verify(),
            timeout=10,
        )
    except requests.exceptions.RequestException as e:
        print(f"ERROR: Vault write failed for invoker {invoker_id}: {e}")
        return False

    if not response.ok:
        print(f"ERROR: Vault write failed for invoker {invoker_id}: {response.status_code} {response.text}")
        return False

    return True
 No newline at end of file