Commit 8b2b3f5a authored by Alex Kakiris's avatar Alex Kakiris
Browse files

Merge branch 'feat/provider-publish' into 'dev'

Publish APIs

See merge request !8
parents 5e60f34b ddb24452
Loading
Loading
Loading
Loading
+3 −0
Changes for README.md: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -118,6 +118,9 @@ Endpoints acting on behalf of the logged-in user. They require a valid access to
- POST /api/me/provider
  - Registers an API provider in CAPIF for the logged-in user, with one key pair and certificate per function (AEF, APF, AMF), all stored in a single Vault secret under `secret/backoffice/users/{uuid}/providers/{apiProvDomId}`.
  - Body: `{"apiProvDomInfo": "..."}`. Returns `201` `{"apiProvDomId": "..."}`.
- POST /api/me/providers/{apiProvDomId}/apis
  - Publishes an API in CAPIF as the provider's APF (mTLS with the APF certificate). The AEF is recorded as the function exposing the API.
  - Body: `apiName`, `apiVersion`, `resourceName`, `uri`, `operations`, `address`, `port` (required); `description`, `resourceDescription`, `protocol` (`HTTP_1_1`), `dataFormat` (`JSON`), `securityMethods` (`["OAUTH"]`) (optional). Returns `201` `{"apiId": "..."}`.

### Events and Monitoring

+24 −1
Changes for capif_backend/backoffice_service/controllers/me_controller.py: 24 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -3,7 +3,7 @@ from flask_jwt_extended import jwt_required

from ..core import invoker_ops, provider_ops
from ..utils.utils import get_capif_token, get_capif_urls, capif_user_required
from ..utils.vault import read_invoker, list_invokers, VaultError
from ..utils.vault import read_invoker, list_invokers, read_provider, VaultError

me_routes = Blueprint("me_routes", __name__)

@@ -75,3 +75,26 @@ def onboard_provider(user_uuid):
    ccf_provider_onboarding_url = get_capif_urls()["ccf_api_onboarding_url"]
    data, status = provider_ops.onboard(get_capif_token(), user_uuid, ccf_provider_onboarding_url, prov_dom_info)
    return jsonify(data), status

@me_routes.route("/providers/<api_prov_dom_id>/apis", methods=["POST"])
@jwt_required()
@capif_user_required
def publish_api(api_prov_dom_id, user_uuid):
    api, invalid_fields = provider_ops.parse_api_request(request.get_json(silent=True) or {})
    if invalid_fields:
        return jsonify({"error": "Missing or invalid fields.", "fields": invalid_fields}), 400

    # Same authorization-by-path design as discover: the Vault path is keyed by the uuid from
    # the JWT, so another user's apiProvDomId finds nothing and returns 404.
    try:
        provider = read_provider(user_uuid, api_prov_dom_id)
    except VaultError as e:
        print(f"ERROR: {e}")
        return jsonify({"error": "Could not read provider credentials."}), 500

    if provider is None:
        return jsonify({"error": "Provider not found."}), 404

    ccf_publish_url = get_capif_urls()["ccf_publish_url"]
    data, status = provider_ops.publish(ccf_publish_url, provider.get("functions") or {}, api)
    return jsonify(data), status
 No newline at end of file
+144 −1
Changes for capif_backend/backoffice_service/core/provider_ops.py: 144 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -3,14 +3,23 @@ import os

import requests

from ..utils.certs import generate_keypair_and_csr
from ..utils.certs import generate_keypair_and_csr, mtls_files, SecureTempUnavailable
from ..utils.vault import store_provider

PROVIDER_ROLES = ("AEF", "APF", "AMF")

_SUPP_FEAT = "fff"           # TODO: placeholder from capif-client, needs real value
_FAIL_REASON = "string"      # TODO: placeholder from capif-client, needs real value
# Publish placeholders carried over from capif-client / Postman. TODO: real values.
_PUBLISH_SUPPORTED_FEATURES = "020"  # required by CCF; keeps VendorExt off, since we send no vendor-specific fields
_API_SUPP_FEATS = "fffff"
_SERVICE_API_CATEGORY = "string"
_CCF_ID = "string"
_CAPIF_PROV_DOMS = ["string"]
_VERSION_EXPIRY = "2021-11-30T10:32:02.004Z"
_CUST_OP_NAME = "string"

_REQUIRED_TEXT_FIELDS = ("apiName", "apiVersion", "resourceName", "uri", "address")

def onboard(capif_token, user_uuid, onboarding_url, prov_dom_info):
    """Register an API provider in CCF on behalf of the logged-in user, with one key pair and
@@ -123,3 +132,137 @@ def _rollback(prov_dom_id):
        print(f"ERROR: rollback of provider {prov_dom_id} failed: {response.status_code} {response.text}")
        return False
    return True

def parse_api_request(payload):
    """Validate the flat publish request and fill defaults. Returns (api, invalid_fields)."""
    invalid = [f for f in _REQUIRED_TEXT_FIELDS
               if not isinstance(payload.get(f), str) or not payload[f].strip()]

    operations = payload.get("operations")
    if not isinstance(operations, list) or not operations or \
            not all(isinstance(op, str) and op.strip() for op in operations):
        invalid.append("operations")

    port = payload.get("port")
    if not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= 65535:
        invalid.append("port")

    security_methods = payload.get("securityMethods", ["OAUTH"])
    if not isinstance(security_methods, list) or not security_methods:
        invalid.append("securityMethods")

    if invalid:
        return None, invalid

    return {
        "apiName": payload["apiName"].strip(),
        "apiVersion": payload["apiVersion"].strip(),
        "description": (payload.get("description") or "").strip(),
        "resourceDescription": (payload.get("resourceDescription") or payload.get("description") or "").strip(),
        "resourceName": payload["resourceName"].strip(),
        "uri": payload["uri"].strip(),
        "operations": [op.strip().upper() for op in operations],
        "address": payload["address"].strip(),
        "port": port,
        "protocol": payload.get("protocol") or "HTTP_1_1",
        "dataFormat": payload.get("dataFormat") or "JSON",
        "securityMethods": security_methods,
    }, []


def _build_service_api(aef_id, api):
    """Map the flat request onto CCF's ServiceAPIDescription, with placeholders for the rest."""
    return {
        "apiName": api["apiName"],
        "aefProfiles": [{
            "aefId": aef_id,
            "versions": [{
                "apiVersion": api["apiVersion"],
                "expiry": _VERSION_EXPIRY,
                "resources": [{
                    "resourceName": api["resourceName"],
                    "commType": "REQUEST_RESPONSE",
                    "uri": api["uri"],
                    "custOpName": _CUST_OP_NAME,
                    "operations": api["operations"],
                    "description": api["resourceDescription"],
                }],
                "custOperations": [{
                    "commType": "REQUEST_RESPONSE",
                    "custOpName": _CUST_OP_NAME,
                    "operations": api["operations"],
                    "description": api["description"],
                }],
            }],
            "protocol": api["protocol"],
            "dataFormat": api["dataFormat"],
            "securityMethods": api["securityMethods"],
            "interfaceDescriptions": [{
                "ipv4Addr": api["address"],
                "port": api["port"],
                "securityMethods": api["securityMethods"],
            }],
        }],
        "description": api["description"],
        "supportedFeatures": _PUBLISH_SUPPORTED_FEATURES,
        "shareableInfo": {"isShareable": True, "capifProvDoms": _CAPIF_PROV_DOMS},
        "serviceAPICategory": _SERVICE_API_CATEGORY,
        "apiSuppFeats": _API_SUPP_FEATS,
        "pubApiPath": {"ccfIds": [_CCF_ID]},
        "ccfId": _CCF_ID,
        "apiStatus": {"aefIds": [aef_id]},
    }


def publish(publish_url_template, functions, api):
    """Publish an API in CCF as the provider's APF.

    Authenticates with the APF certificate (the gateway admits only APF-prefixed CNs to
    publish), while the body names the AEF that exposes the API. Two different functions:
    the APF id goes in the URL, the AEF id in the body. Nothing is written to Vault
    afterwards, so no rollback is needed.
    """
    apf = functions.get("apf") or {}
    aef = functions.get("aef") or {}
    if not (apf.get("api_prov_func_id") and apf.get("certificate") and apf.get("private_key")) \
            or not aef.get("api_prov_func_id"):
        print("ERROR: stored provider is missing its APF credentials or AEF id")
        return {"error": "Stored provider credentials are incomplete."}, 500

    if "<apfId>" not in publish_url_template:
        print(f"ERROR: CCF_PUBLISH_URL has no <apfId> placeholder: {publish_url_template}")
        return {"error": "Publish URL is misconfigured."}, 500

    path = publish_url_template.replace("<apfId>", apf["api_prov_func_id"])
    url = f"https://{os.getenv('CAPIF_HOSTNAME')}/{path}"
    body = _build_service_api(aef["api_prov_func_id"], api)

    try:
        with mtls_files(apf["certificate"], apf["private_key"]) as (cert_path, key_path):
            response = requests.post(
                url,
                json=body,
                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 publish (APF {apf['api_prov_func_id']}): {e}")
        return {"error": "Could not reach CCF."}, 502

    try:
        data = response.json()
    except ValueError:
        data = {"error": response.text}

    if response.status_code != 201:
        print(f"ERROR: CCF publish failed for APF {apf['api_prov_func_id']}: {response.status_code} {data}")
        return {"error": "CCF publish failed.", "ccf_status": response.status_code, "ccf_response": data}, 502

    api_id = data.get("apiId") if isinstance(data, dict) else None
    if not api_id:
        print("WARNING: CCF publish returned 201 without an apiId")
    return {"apiId": api_id}, 201
 No newline at end of file
+34 −4
Changes for capif_backend/backoffice_service/utils/vault.py: 34 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -4,10 +4,10 @@ import requests

class VaultError(Exception):
    """Vault could not be reached or returned an unexpected response"""
# invoker_id comes from the URL and becomes part of the Vault path. Restricting it to
# [A-Za-z0-9_-] prevents path traversal (e.g. "..%2f..%2fca") out of the user's subtree.
# Invoker and provider ids come from the URL and become part of the Vault path. Restricting them
# to [A-Za-z0-9_-] prevents path traversal (e.g. "..%2f..%2fca") out of the user's subtree.
# Do not relax this without another guard.
_INVOKER_ID_RE = re.compile(r"[A-Za-z0-9_-]+")
_ENTITY_ID_RE = re.compile(r"[A-Za-z0-9_-]+")

def vault_addr():
    """Base URL of Vault. VAULT_ADDR, otherwise falls back to http://VAULT_HOSTNAME:VAULT_PORT as before."""
@@ -84,7 +84,7 @@ def read_invoker(user_uuid, invoker_id):
    so callers can tell "not found" (404) apart from "Vault is down" (5xx).
    """

    if not _INVOKER_ID_RE.fullmatch(invoker_id or ""):
    if not _ENTITY_ID_RE.fullmatch(invoker_id or ""):
        return None

    path = f"secret/data/backoffice/users/{user_uuid}/invokers/{invoker_id}"
@@ -108,6 +108,36 @@ def read_invoker(user_uuid, invoker_id):
    except (ValueError, KeyError, TypeError) as e:
        raise VaultError(f"Unexpected Vault response shape for invoker {invoker_id}") from e

def read_provider(user_uuid, prov_dom_id):
    """Read a provider's stored functions (aef, apf, amf) from Vault KV v2.

    Returns the stored dict (api_prov_dom_id, functions), or None if no such provider exists
    for this user. Raises VaultError on any other failure.
    """
    if not _ENTITY_ID_RE.fullmatch(prov_dom_id or ""):
        return None

    path = f"secret/data/backoffice/users/{user_uuid}/providers/{prov_dom_id}"
    try:
        response = requests.get(
            f"{vault_addr()}/v1/{path}",
            headers=vault_headers(),
            verify=vault_verify(),
            timeout=10,
        )
    except requests.exceptions.RequestException as e:
        raise VaultError(f"Vault read failed for provider {prov_dom_id}: {e}") from e

    if response.status_code == 404:
        return None
    if not response.ok:
        raise VaultError(f"Vault read failed for provider {prov_dom_id}: {response.status_code}")

    try:
        return response.json()["data"]["data"]
    except (ValueError, KeyError, TypeError) as e:
        raise VaultError(f"Unexpected Vault response shape for provider {prov_dom_id}") from e

def list_invokers(user_uuid):
    """Return the apiInvokerIds stored for a user."""