diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b8e1ead..b1dfe08 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -21,12 +21,6 @@ ############################################################### --- version: 2 -registries: - github-central-pipelines: - type: git - url: https://github.com - username: x-access-token - password: ${{ secrets.CENTRAL_PIPELINES_READ_ONLY_GH_TOKEN }} updates: # Github Actions diff --git a/backend/auth/keycloak_config.py b/backend/auth/keycloak_config.py index 3069dba..53502f7 100644 --- a/backend/auth/keycloak_config.py +++ b/backend/auth/keycloak_config.py @@ -19,63 +19,208 @@ # # SPDX-License-Identifier: Apache-2.0 ############################################################### -import os -from typing import Optional -from fastapi import Depends, HTTPException, status -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from jose import jwt, JWTError import logging +import os +import threading +import time +from typing import Any, Optional + +import requests +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jose import jwt +from jose.exceptions import ExpiredSignatureError, JWTClaimsError, JWTError logger = logging.getLogger('app') -security = HTTPBearer() +security = HTTPBearer(auto_error=False) + +JWKS_TTL_SECONDS = 300 +ALGORITHMS = ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256"] + +BPN_CLAIM = "bpn" +COMPANY_CLAIM = "organisation" + + +def _clean(value: Optional[str]) -> str: + """Trim a config value, treating un-substituted placeholders as unset.""" + if not value or not isinstance(value, str): + return "" + + stripped = value.strip() + if stripped.startswith("__") or (stripped.startswith("${") and stripped.endswith("}")): + return "" + + return stripped + + +def _first(value: Any) -> str: + """Flatten a claim to one string; Keycloak multivalued mappers emit lists.""" + if isinstance(value, str): + return value.strip() + + if isinstance(value, (list, tuple)): + for entry in value: + flattened = _first(entry) + if flattened: + return flattened + + return "" + class KeycloakOpenID: def __init__(self): - self.keycloak_url = os.getenv("KEYCLOAK_URL", "__KEYCLOAK_URL__") - self.realm = os.getenv("KEYCLOAK_REALM", "__KEYCLOAK_REALM__") - self.client_id = os.getenv("KEYCLOAK_CLIENT_ID", "__KEYCLOAK_CLIENT_ID__") - + self.keycloak_url = _clean(os.getenv("KEYCLOAK_URL")) + self.realm = _clean(os.getenv("KEYCLOAK_REALM")) + self.verify_signature = _env_flag("KEYCLOAK_VERIFY_SIGNATURE", True) + self._jwks: Optional[dict] = None + self._jwks_fetched_at = 0.0 + self._jwks_lock = threading.Lock() + + def configure(self, *, url=None, realm=None, fallback_url=None, fallback_realm=None): + """Apply configuration.yml settings. Environment variables win.""" + if not self.keycloak_url: + self.keycloak_url = _clean(url) or _clean(fallback_url) + if not self.realm: + self.realm = _clean(realm) or _clean(fallback_realm) + + logger.info("[Keycloak] Verifying tokens issued by %s", self.issuer or "") + + @property + def is_configured(self) -> bool: + return bool(self.keycloak_url and self.realm) + + @property + def issuer(self) -> str: + """``/realms/`` — the ``iss`` value Keycloak puts in tokens.""" + if not self.is_configured: + return "" + + return f"{self.keycloak_url.rstrip('/')}/realms/{self.realm}" + + @property + def jwks_uri(self) -> str: + return f"{self.issuer}/protocol/openid-connect/certs" if self.issuer else "" + def add_swagger_config(self, app): - """Add Keycloak OAuth2 to Swagger UI""" - pass - + return None + + def _fetch_jwks(self) -> Optional[dict]: + try: + response = requests.get(self.jwks_uri, timeout=10) + response.raise_for_status() + jwks = response.json() + except Exception as exception: + logger.error("[Keycloak] Could not fetch JWKS from %s: %s", self.jwks_uri, exception) + return None + + return jwks if isinstance(jwks, dict) and jwks.get("keys") else None + + def get_jwks(self, force_refresh: bool = False) -> Optional[dict]: + with self._jwks_lock: + fresh = self._jwks and (time.monotonic() - self._jwks_fetched_at) < JWKS_TTL_SECONDS + if fresh and not force_refresh: + return self._jwks + + jwks = self._fetch_jwks() + if jwks: + self._jwks = jwks + self._jwks_fetched_at = time.monotonic() + # Keep serving the previous keys on a failed refresh: a blip at the + # IdP should not log everyone out. + return self._jwks + + def decode_token(self, token: str) -> dict: + """Return the token's verified claims, or raise 401.""" + if not self.verify_signature: + logger.warning("[Keycloak] KEYCLOAK_VERIFY_SIGNATURE is off; token is untrusted.") + return jwt.decode(token, key="", options={"verify_signature": False, + "verify_aud": False}) + + if not self.is_configured: + logger.error("[Keycloak] No identity provider configured; rejecting bearer tokens.") + raise _unauthorized("Identity provider is not configured; the token cannot be trusted.") + + try: + kid = jwt.get_unverified_header(token).get("kid") + except JWTError as exception: + raise _unauthorized(f"Malformed token: {exception}") + + jwks = self.get_jwks() + if not jwks or not any(key.get("kid") == kid for key in jwks.get("keys", [])): + jwks = self.get_jwks(force_refresh=True) + if not jwks: + raise _unauthorized("Identity provider keys are unavailable") + + try: + return jwt.decode(token, key=jwks, algorithms=ALGORITHMS, + issuer=self.issuer, options={"verify_aud": False}) + except ExpiredSignatureError: + raise _unauthorized("Token has expired") + except (JWTClaimsError, JWTError) as exception: + # Nearly always the wrong realm rather than a bad token, so name both + # issuers instead of just saying "signature verification failed". + raise _unauthorized( + f"Token rejected: {exception}. Token issuer={_unverified_issuer(token)!r}, " + f"configured issuer={self.issuer!r}." + ) + + def build_user(self, claims: dict, token: str = "") -> dict: + """The caller and the company they act for, from verified claims.""" + return { + "preferred_username": _first(claims.get("preferred_username")) or "unknown", + "name": _first(claims.get("name")), + "email": _first(claims.get("email")), + "bpn": _first(claims.get(BPN_CLAIM)).upper(), + "company": _first(claims.get(COMPANY_CLAIM)), + "token": token, + } + async def get_current_user( - self, - credentials: HTTPAuthorizationCredentials = Depends(security) + self, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), ) -> dict: - """Extract and validate user from JWT token""" - token = credentials.credentials - + """Require a valid bearer token; 401 otherwise.""" + if credentials is None or not credentials.credentials: + raise _unauthorized("Missing bearer token") + + return self.build_user(self.decode_token(credentials.credentials), + credentials.credentials) + + def get_optional_user(self, request: Request) -> Optional[dict]: + """Identity when a valid token is present, ``None`` otherwise. + + An invalid token returns None rather than raising, so attaching a stale + token can never turn a working API-key call into a 401. + """ + header = request.headers.get("Authorization", "") + if not header.lower().startswith("bearer "): + return None + + token = header.split(" ", 1)[1].strip() try: - decoded = jwt.decode( - token, - key="", - options={"verify_signature": False} - ) - - username = decoded.get("preferred_username", "unknown") - logger.info(f"[Keycloak] Authenticated user: {username}") - - return { - "preferred_username": username, - "email": decoded.get("email", ""), - "given_name": decoded.get("given_name", ""), - "family_name": decoded.get("family_name", ""), - "name": decoded.get("name", ""), - "roles": decoded.get("realm_access", {}).get("roles", []), - "sub": decoded.get("sub", ""), - "token": token - } - except JWTError as e: - logger.error(f"[Keycloak] JWT validation failed: {str(e)}") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authentication credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) + return self.build_user(self.decode_token(token), token) if token else None + except HTTPException as exception: + logger.warning("[Keycloak] Ignoring unusable bearer token: %s", exception.detail) + return None -keycloak_openid = KeycloakOpenID() -# Token-Verifikation lockern (nur zum Testen) -keycloak_openid._verify_audience = False +def _unverified_issuer(token: str) -> str: + """The token's own `iss`, read without verifying. For error messages only.""" + try: + return jwt.get_unverified_claims(token).get("iss", "") + except Exception: + return "" + + +def _env_flag(name: str, default: bool) -> bool: + raw = os.getenv(name) + return default if raw is None else raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _unauthorized(detail: str) -> HTTPException: + return HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail, + headers={"WWW-Authenticate": "Bearer"}) + + +keycloak_openid = KeycloakOpenID() diff --git a/backend/init.py b/backend/init.py index 68d7276..489356d 100644 --- a/backend/init.py +++ b/backend/init.py @@ -51,7 +51,8 @@ UnknownComponentType, UnsupportedVersion, classify, new_error_id) from utilities.operators import op -from utilities.auth_utils import get_oauth2_token +from utilities.auth_utils import (assert_safe_external_url, build_external_url, + get_oauth2_token) op.make_dir("logs") @@ -444,6 +445,43 @@ def _assert_within_component_limits(components): ) +def _session_bpn_enforcement_enabled() -> bool: + identity_config = app_configuration.get("dataspaceConfig", {}).get("identity", {}) or {} + return bool(identity_config.get("enforceSessionBpn", False)) + + +def _apply_session_bpn(components, user: Optional[dict]): + if not user: + return + + session_bpn = (user.get("bpn") or "").strip().upper() + connectors = [comp for comp in components if comp.type == "connector"] + + if not session_bpn: + if connectors and _session_bpn_enforcement_enabled(): + raise EmcError( + "Your login does not provide a BPN, so a connector cannot be given a " + "dataspace identity.", + status=403, code="SESSION_BPN_MISSING", stage=Stage.AUTH, + hint="Add a 'bpn' claim mapper for this client in the identity provider, " + "or set identity.enforceSessionBpn: false.", + ) + logger.warning("[deploy] No BPN in the caller's token; leaving the requested BPN as-is.") + return + + for comp in connectors: + requested = (getattr(comp, "bpn", "") or "").strip().upper() + if requested and requested != session_bpn and _session_bpn_enforcement_enabled(): + raise EmcError( + f"Connector '{comp.name}' was requested with BPN {requested}, but your " + f"account belongs to {session_bpn}.", + status=403, code="SESSION_BPN_MISMATCH", stage=Stage.AUTH, + hint="A connector can only be deployed under your own BPN.", + ) + + comp.bpn = session_bpn + + def _plan_error(plan) -> EmcError: """Map a ``prepare_deployment`` refusal onto the right status. @@ -524,6 +562,7 @@ async def add_components(payload: DeploymentRequest, request: Request): if not authManager.is_authenticated(request=request): return HttpUtils.get_not_authorized() logger.info(payload) + _apply_session_bpn(payload.components, keycloak_openid.get_optional_user(request)) namespace = app_configuration.get("dataspaceConfig", {}).get("clusterConfig", {}).get("namespace", None) deployed = await _deploy_components(payload.components, namespace) return HttpUtils.response(status=200, data={"deployed": deployed}) @@ -541,6 +580,7 @@ async def upgrade_components(component_id: str, payload: DeploymentRequest, requ if not authManager.is_authenticated(request=request): return HttpUtils.get_not_authorized() logger.info(payload) + _apply_session_bpn(payload.components, keycloak_openid.get_optional_user(request)) namespace = app_configuration.get("dataspaceConfig", {}).get("clusterConfig", {}).get("namespace", None) upgraded = await _deploy_components(payload.components, namespace) return HttpUtils.response(status=200, message="Components upgraded", @@ -655,10 +695,17 @@ async def add_existing_submodel_service(data: dict, user=Depends(keycloak_openid status=400, code="MISSING_REQUIRED_FIELD", stage=Stage.REQUEST, message="Both a submodel service URL and a BPN are required.") + # The probe below carries whatever credentials the caller configured + # above, so the target has to be vetted first. build_external_url + # re-assembles the URL from the accepted origin plus this literal path, + # so the caller cannot steer the request elsewhere. + url = assert_safe_external_url(url, field="url") + health_url = build_external_url(url, "api/health", field="url") + import requests - health_url = f"{url.rstrip('/')}/api/health" try: - check = requests.get(health_url, headers=headers, timeout=5) + check = requests.get(health_url, headers=headers, timeout=5, + allow_redirects=False) reachable = check.status_code == 200 except Exception: reachable = False @@ -755,14 +802,16 @@ async def get_dataspace_settings(request: Request): try: dataspace_config = app_configuration.get("dataspaceConfig", {}) edc_config = app_configuration.get("connector", {}) + user = keycloak_openid.get_optional_user(request) or {} dataspace_name = dataspace_config.get("name", "Your Dataspace") - bpn = dataspace_config.get("authority_id", "BPNL000000000000") + authority_bpn = dataspace_config.get("authority_id", "BPNL000000000000") dataspace_settings = { "name": dataspace_name, - "bpn": bpn, - "realm": dataspace_config.get("name", "CX-Central"), + "authority_bpn": authority_bpn, + "bpn": authority_bpn, + "realm": dataspace_config.get("centralidp", {}).get("realm", ""), "username": dataspace_config.get("preferred_username", "user"), "centralidp": { "url": dataspace_config.get("centralidp", {}).get("url", ""), @@ -805,6 +854,13 @@ async def get_dataspace_settings(request: Request): "dataplane_host_suffix": edc_config.get("hostname", {}).get("dataplane", ""), "cluster_context": dataspace_config.get("clusterConfig", {}).get("context", "") }, + "session": { + "username": user.get("preferred_username", ""), + "name": user.get("name", ""), + "bpn": user.get("bpn", ""), + "company": user.get("company", ""), + "enforceSessionBpn": _session_bpn_enforcement_enabled(), + }, "deployment": { "connector": _component_versions("connector"), "digitalTwinRegistry": _component_versions("digitalTwinRegistry"), @@ -835,6 +891,16 @@ def init_app(host: str, port: int, log_level: str = "info"): authManager = AuthManager(api_key_header=api_key.get("key", "X-Api-Key"), configured_api_key=api_key.get("value", "password"), auth_enabled=True) + dataspace_config: dict = app_configuration.get("dataspaceConfig", {}) + centralidp_config: dict = dataspace_config.get("centralidp", {}) or {} + identity_config: dict = dataspace_config.get("identity", {}) or {} + keycloak_openid.configure( + url=identity_config.get("url"), + realm=identity_config.get("realm"), + fallback_url=centralidp_config.get("url"), + fallback_realm=centralidp_config.get("realm"), + ) + ## Get environment specific configurations connector_config: dict = app_configuration.get("connector", {}) diff --git a/backend/scripts/setup_kube.sh b/backend/scripts/setup_kube.sh index 5aaef88..dba34ef 100644 --- a/backend/scripts/setup_kube.sh +++ b/backend/scripts/setup_kube.sh @@ -1,3 +1,4 @@ +#!/bin/bash ############################################################### # Tractus-X - EDC Management Console # @@ -20,7 +21,6 @@ # SPDX-License-Identifier: Apache-2.0 ############################################################### -#!/bin/bash set -e # ---- CONFIG ---- diff --git a/backend/tests/test_identity.py b/backend/tests/test_identity.py new file mode 100644 index 0000000..b07fb2f --- /dev/null +++ b/backend/tests/test_identity.py @@ -0,0 +1,255 @@ +############################################################### +# Tractus-X - EDC Management Console +# +# Copyright (c) 2026 ARENA2036 e.V. +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License, Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0. +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# SPDX-License-Identifier: Apache-2.0 +############################################################### + +import base64 +import os +import sys +import time + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi import HTTPException +from jose import jwt + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from auth.keycloak_config import KeycloakOpenID # noqa: E402 + +ISSUER = "https://centralidp.example.de/auth/realms/CX-Central" + + +def _generate_key(kid): + """A private key plus its public half as a JWKS entry, as a realm serves it.""" + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + + def b64u(number): + raw = number.to_bytes((number.bit_length() + 7) // 8, "big") + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + numbers = key.public_key().public_numbers() + return pem, {"kty": "RSA", "kid": kid, "alg": "RS256", "use": "sig", + "n": b64u(numbers.n), "e": b64u(numbers.e)} + + +def _sign(pem, kid, claims, issuer=ISSUER): + payload = {"iss": issuer, "azp": "EMC-1", "iat": int(time.time()), + "exp": int(time.time()) + 300, **claims} + return jwt.encode(payload, pem, algorithm="RS256", headers={"kid": kid}) + + +@pytest.fixture +def keycloak(monkeypatch): + for name in ("KEYCLOAK_URL", "KEYCLOAK_REALM", "KEYCLOAK_VERIFY_SIGNATURE"): + monkeypatch.delenv(name, raising=False) + + instance = KeycloakOpenID() + instance.configure(url="https://centralidp.example.de/auth/", realm="CX-Central") + return instance + + +def test_issuer_matches_the_value_keycloak_puts_in_tokens(keycloak): + assert keycloak.issuer == ISSUER + assert keycloak.jwks_uri == f"{ISSUER}/protocol/openid-connect/certs" + + +def test_explicit_issuer_wins_over_the_centralidp_fallback(): + """The realm issuing browser tokens is configured separately from the + backend's own `centralidp` client, which may point elsewhere — conflating the + two rejected every real login.""" + instance = KeycloakOpenID() + instance.configure(url="https://centralidp.txcd.example.de/auth/", realm="CX-Central", + fallback_url="https://centralidp.example.de/auth/", + fallback_realm="CX-Central") + + assert instance.issuer == "https://centralidp.txcd.example.de/auth/realms/CX-Central" + + fallback = KeycloakOpenID() + fallback.configure(url=None, realm=None, + fallback_url="https://centralidp.example.de/auth/", + fallback_realm="CX-Central") + assert fallback.issuer == ISSUER + + +def test_reads_company_identity_from_a_valid_token(keycloak, monkeypatch): + pem, jwk = _generate_key("kid-1") + monkeypatch.setattr(keycloak, "_fetch_jwks", lambda: {"keys": [jwk]}) + + token = _sign(pem, "kid-1", {"bpn": "BPNL00000000052O", "organisation": "Delay Inc.", + "preferred_username": "devaji"}) + user = keycloak.build_user(keycloak.decode_token(token), token) + + assert user["bpn"] == "BPNL00000000052O" + assert user["company"] == "Delay Inc." + assert user["preferred_username"] == "devaji" + + +def test_reads_a_multivalued_claim(keycloak, monkeypatch): + """Keycloak multivalued mappers emit a list; treating that as "no value" + would break the exact realm configuration this targets.""" + pem, jwk = _generate_key("kid-1") + monkeypatch.setattr(keycloak, "_fetch_jwks", lambda: {"keys": [jwk]}) + + user = keycloak.build_user(keycloak.decode_token(_sign(pem, "kid-1", {"bpn": ["BPNL01"]}))) + assert user["bpn"] == "BPNL01" + + +def test_missing_claims_are_reported_empty_not_invented(keycloak, monkeypatch): + """A realm without the mappers must yield an empty company, never a + configured default standing in for the user's own.""" + pem, jwk = _generate_key("kid-1") + monkeypatch.setattr(keycloak, "_fetch_jwks", lambda: {"keys": [jwk]}) + + user = keycloak.build_user(keycloak.decode_token(_sign(pem, "kid-1", {}))) + assert user["bpn"] == "" + assert user["company"] == "" + + +def test_rejects_token_signed_by_another_key(keycloak, monkeypatch): + """A forged token claiming someone else's BPN must not authenticate.""" + _, realm_jwk = _generate_key("kid-1") + attacker_pem, _ = _generate_key("kid-1") # same kid, wrong key + monkeypatch.setattr(keycloak, "_fetch_jwks", lambda: {"keys": [realm_jwk]}) + + with pytest.raises(HTTPException) as error: + keycloak.decode_token(_sign(attacker_pem, "kid-1", {"bpn": "BPNL00000000052O"})) + assert error.value.status_code == 401 + + +def test_rejects_token_from_another_realm_and_names_both_issuers(keycloak, monkeypatch): + """The failure an operator actually hits: backend pointed at the wrong + Keycloak. The message must say so, not just "signature failed".""" + pem, jwk = _generate_key("kid-1") + monkeypatch.setattr(keycloak, "_fetch_jwks", lambda: {"keys": [jwk]}) + + other = "https://centralidp.txcd.example.de/auth/realms/CX-Central" + with pytest.raises(HTTPException) as error: + keycloak.decode_token(_sign(pem, "kid-1", {}, issuer=other)) + + assert other in error.value.detail + assert ISSUER in error.value.detail + + +def test_rejects_expired_token(keycloak, monkeypatch): + pem, jwk = _generate_key("kid-1") + monkeypatch.setattr(keycloak, "_fetch_jwks", lambda: {"keys": [jwk]}) + + expired = jwt.encode({"iss": ISSUER, "iat": 500, "exp": 1000}, pem, + algorithm="RS256", headers={"kid": "kid-1"}) + with pytest.raises(HTTPException) as error: + keycloak.decode_token(expired) + assert "expired" in error.value.detail.lower() + + +def test_unknown_kid_refreshes_the_keys_once(keycloak, monkeypatch): + """Key rotation must not log everyone out, but a bad token must not trigger + unbounded requests to the IdP either.""" + pem, rotated = _generate_key("kid-2") + _, stale = _generate_key("kid-1") + calls = {"n": 0} + + def fetch(): + calls["n"] += 1 + return {"keys": [stale]} if calls["n"] == 1 else {"keys": [rotated]} + + monkeypatch.setattr(keycloak, "_fetch_jwks", fetch) + + assert keycloak.decode_token(_sign(pem, "kid-2", {"bpn": "BPNL01"}))["bpn"] == "BPNL01" + assert calls["n"] == 2 + + +def test_fails_closed_when_the_idp_is_unreachable(keycloak, monkeypatch): + """Without keys no trust decision is possible; accepting anything here would + reintroduce the hole this replaces.""" + monkeypatch.setattr(keycloak, "_fetch_jwks", lambda: None) + pem, _ = _generate_key("kid-1") + + with pytest.raises(HTTPException) as error: + keycloak.decode_token(_sign(pem, "kid-1", {})) + assert error.value.status_code == 401 + + +def test_optional_user_ignores_a_bad_or_absent_token(keycloak, monkeypatch): + """An unusable Authorization header must not turn a working API-key call + into a 401.""" + monkeypatch.setattr(keycloak, "_fetch_jwks", lambda: None) + + class Broken: + headers = {"Authorization": "Bearer not-a-jwt"} + + class Absent: + headers = {} + + assert keycloak.get_optional_user(Broken()) is None + assert keycloak.get_optional_user(Absent()) is None + + +def test_fails_closed_when_no_identity_provider_is_configured(monkeypatch): + """A missing URL/realm is a misconfiguration, not permission to trust the + caller. Without an issuer there is nothing to verify against, so anyone + could self-sign a token carrying whatever `bpn` they liked.""" + for name in ("KEYCLOAK_URL", "KEYCLOAK_REALM", "KEYCLOAK_VERIFY_SIGNATURE"): + monkeypatch.delenv(name, raising=False) + + unconfigured = KeycloakOpenID() + assert not unconfigured.is_configured + + pem, _ = _generate_key("kid-1") + forged = _sign(pem, "kid-1", {"bpn": "BPNL000000000042", "organisation": "Someone Else"}) + + with pytest.raises(HTTPException) as error: + unconfigured.decode_token(forged) + assert error.value.status_code == 401 + + +def test_optional_user_reports_no_identity_when_unconfigured(monkeypatch): + """The fail-closed path must stay invisible to API-key callers: no identity + rather than a 401 on an otherwise working request.""" + for name in ("KEYCLOAK_URL", "KEYCLOAK_REALM", "KEYCLOAK_VERIFY_SIGNATURE"): + monkeypatch.delenv(name, raising=False) + + unconfigured = KeycloakOpenID() + pem, _ = _generate_key("kid-1") + + class Request: + headers = {"Authorization": f"Bearer {_sign(pem, 'kid-1', {'bpn': 'BPNL000000000042'})}"} + + assert unconfigured.get_optional_user(Request()) is None + + +def test_verification_can_still_be_disabled_deliberately(monkeypatch): + """KEYCLOAK_VERIFY_SIGNATURE=false remains an explicit, logged opt-out — the + point of the change is that *misconfiguration* no longer implies it.""" + monkeypatch.delenv("KEYCLOAK_URL", raising=False) + monkeypatch.delenv("KEYCLOAK_REALM", raising=False) + monkeypatch.setenv("KEYCLOAK_VERIFY_SIGNATURE", "false") + + relaxed = KeycloakOpenID() + pem, _ = _generate_key("kid-1") + + claims = relaxed.decode_token(_sign(pem, "kid-1", {"bpn": "bpnl000000000042"})) + assert relaxed.build_user(claims)["bpn"] == "BPNL000000000042" diff --git a/backend/tests/test_outbound_urls.py b/backend/tests/test_outbound_urls.py new file mode 100644 index 0000000..6307be6 --- /dev/null +++ b/backend/tests/test_outbound_urls.py @@ -0,0 +1,222 @@ +############################################################### +# Tractus-X - EDC Management Console +# +# Copyright (c) 2026 ARENA2036 e.V. +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License, Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0. +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# SPDX-License-Identifier: Apache-2.0 +############################################################### +"""Outbound URLs built from caller-supplied input. + +``add_existing_submodel_service`` lets the caller name both a submodel service +and its OAuth2 token endpoint, and this backend then calls them *with +credentials attached*. Unvalidated, that is a server-side request forgery sink +into the cluster: the metadata service, the Kubernetes API and every internal +Service are one request away, and the client secret goes with it. +""" + +import os +import socket +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from utilities.auth_utils import (assert_safe_external_url, # noqa: E402 + build_external_url, get_oauth2_token) +from utilities.errors import EmcError # noqa: E402 + + +@pytest.fixture(autouse=True) +def _https_only(monkeypatch): + monkeypatch.delenv("EMC_URL_SCHEME", raising=False) + + +def _resolves_to(monkeypatch, address): + """Pin DNS so the test does not depend on the network.""" + monkeypatch.setattr( + socket, "getaddrinfo", + lambda *args, **kwargs: [(socket.AF_INET, socket.SOCK_STREAM, + socket.IPPROTO_TCP, "", (address, 443))], + ) + + +def test_accepts_a_public_https_endpoint(monkeypatch): + _resolves_to(monkeypatch, "93.184.216.34") + url = "https://idp.example.com/realms/x/protocol/openid-connect/token" + assert assert_safe_external_url(url, field="accessTokenUrl") == url + + +@pytest.mark.parametrize("address, what", [ + ("127.0.0.1", "loopback"), + ("10.1.2.3", "private class A"), + ("172.16.5.4", "private class B"), + ("192.168.1.10", "private class C"), + ("169.254.169.254", "cloud metadata service"), + ("0.0.0.0", "unspecified"), +]) +def test_rejects_internal_addresses(monkeypatch, address, what): + """The name may be public while the record points inward — a DNS entry + resolving to 169.254.169.254 is the classic cloud-credential theft.""" + _resolves_to(monkeypatch, address) + + with pytest.raises(EmcError) as error: + assert_safe_external_url("https://looks-fine.example.com/token", + field="accessTokenUrl") + assert error.value.status == 400 + assert error.value.code == "UNSAFE_URL", what + + +@pytest.mark.parametrize("url", [ + "http://idp.example.com/token", # no TLS: credentials in clear + "file:///etc/passwd", + "gopher://idp.example.com:70/_payload", + "ftp://idp.example.com/token", +]) +def test_rejects_non_https_schemes(monkeypatch, url): + _resolves_to(monkeypatch, "93.184.216.34") + + with pytest.raises(EmcError) as error: + assert_safe_external_url(url, field="accessTokenUrl") + assert error.value.code == "UNSAFE_URL" + + +def test_rejects_embedded_credentials(monkeypatch): + _resolves_to(monkeypatch, "93.184.216.34") + + with pytest.raises(EmcError): + assert_safe_external_url("https://user:secret@idp.example.com/token", + field="accessTokenUrl") + + +def test_rejects_a_missing_url(): + for value in (None, "", " ", 42): + with pytest.raises(EmcError): + assert_safe_external_url(value, field="accessTokenUrl") + + +def test_http_is_allowed_only_when_the_deployment_already_uses_it(monkeypatch): + """`EMC_URL_SCHEME=http` is an existing, deliberate deployment choice; the + private-address check still applies.""" + _resolves_to(monkeypatch, "93.184.216.34") + monkeypatch.setenv("EMC_URL_SCHEME", "http") + url = "http://idp.example.com/token" + assert assert_safe_external_url(url, field="accessTokenUrl") == url + + _resolves_to(monkeypatch, "127.0.0.1") + with pytest.raises(EmcError): + assert_safe_external_url(url, field="accessTokenUrl") + + +def test_token_request_is_bounded_and_does_not_follow_redirects(monkeypatch): + """A vetted public host must not be able to 302 the credentialled request + onto an internal address, and it must not hang the worker.""" + _resolves_to(monkeypatch, "93.184.216.34") + seen = {} + + class Response: + status_code = 200 + + @staticmethod + def json(): + return {"access_token": "TOK"} + + def post(url, **kwargs): + seen.update(url=url, **kwargs) + return Response() + + monkeypatch.setattr("utilities.auth_utils.requests.post", post) + + token = get_oauth2_token({"accessTokenUrl": "https://idp.example.com/token", + "clientId": "cid", "clientSecret": "sec"}) + + assert token == "TOK" + assert seen["allow_redirects"] is False + assert seen["timeout"] > 0 + assert seen["auth"] == ("cid", "sec") + + +def test_token_request_refuses_an_internal_endpoint(monkeypatch): + _resolves_to(monkeypatch, "169.254.169.254") + + def post(*args, **kwargs): # pragma: no cover - must never run + raise AssertionError("the request should never have been made") + + monkeypatch.setattr("utilities.auth_utils.requests.post", post) + + with pytest.raises(EmcError) as error: + get_oauth2_token({"accessTokenUrl": "https://metadata.example.com/token", + "clientId": "cid", "clientSecret": "sec"}) + assert error.value.code == "UNSAFE_URL" + + +def test_upstream_failures_surface_as_502_not_a_keyerror(monkeypatch): + """The old code did `response.json()["access_token"]`, so an error page + became an unhandled KeyError and a 500.""" + _resolves_to(monkeypatch, "93.184.216.34") + + class Refused: + status_code = 401 + + @staticmethod + def json(): + return {"error": "invalid_client"} + + monkeypatch.setattr("utilities.auth_utils.requests.post", + lambda *args, **kwargs: Refused()) + + with pytest.raises(EmcError) as error: + get_oauth2_token({"accessTokenUrl": "https://idp.example.com/token", + "clientId": "cid", "clientSecret": "wrong"}) + assert error.value.status == 502 + + +def test_rejects_a_query_string_or_fragment_on_a_base_url(monkeypatch): + """`https://host/?x=` + `/api/health` collapses to `https://host/?x=/api/health`, + which lets the caller pick where on the host the probe lands.""" + _resolves_to(monkeypatch, "93.184.216.34") + + for url in ("https://svc.example.com/?next=", "https://svc.example.com/#frag"): + with pytest.raises(EmcError) as error: + assert_safe_external_url(url, field="url") + assert error.value.code == "UNSAFE_URL" + + +def test_token_endpoints_may_carry_a_query_string(monkeypatch): + """Some IdPs do; the token URL is used whole, never concatenated.""" + _resolves_to(monkeypatch, "93.184.216.34") + url = "https://idp.example.com/token?tenant=acme" + assert assert_safe_external_url(url, field="accessTokenUrl", allow_query=True) == url + + +@pytest.mark.parametrize("base, expected", [ + ("https://svc.example.com", "https://svc.example.com/api/health"), + ("https://svc.example.com/", "https://svc.example.com/api/health"), + ("https://svc.example.com/sub", "https://svc.example.com/sub/api/health"), + ("https://svc.example.com/sub/", "https://svc.example.com/sub/api/health"), +]) +def test_probe_url_is_the_vetted_origin_plus_a_literal_path(monkeypatch, base, expected): + _resolves_to(monkeypatch, "93.184.216.34") + assert build_external_url(base, "api/health", field="url") == expected + + +def test_probe_url_refuses_an_internal_base(monkeypatch): + _resolves_to(monkeypatch, "10.0.0.5") + + with pytest.raises(EmcError) as error: + build_external_url("https://svc.example.com", "api/health", field="url") + assert error.value.code == "UNSAFE_URL" diff --git a/backend/utilities/auth_utils.py b/backend/utilities/auth_utils.py index 87c2275..866365f 100644 --- a/backend/utilities/auth_utils.py +++ b/backend/utilities/auth_utils.py @@ -19,46 +19,151 @@ # # SPDX-License-Identifier: Apache-2.0 ############################################################### -from fastapi.responses import JSONResponse, StreamingResponse -from typing import Any, Dict, Optional -import io - - -class HttpUtils: - @staticmethod - def response(data: Any = None, status: int = 200, message: Optional[str] = None): - response_data = {} - if message: - response_data["message"] = message - if data is not None: - response_data["data"] = data - return JSONResponse(content=response_data, status_code=status) - - @staticmethod - def get_error_response(status: int, message: str): - return JSONResponse( - content={"error": message, "status": status}, - status_code=status - ) - - @staticmethod - def get_not_authorized(): - return HttpUtils.get_error_response( - status=401, - message="Not authorized. Please provide valid authentication." - ) - - @staticmethod - def proxy(response: Any): - if hasattr(response, 'json'): - return JSONResponse(content=response.json(), status_code=response.status_code) - return response - - @staticmethod - def file_response(buffer: io.BytesIO, filename: str, content_type: str): - buffer.seek(0) - return StreamingResponse( - buffer, - media_type=content_type, - headers={"Content-Disposition": f'attachment; filename="{filename}"'} - ) \ No newline at end of file +import ipaddress +import logging +import os +import socket +from urllib.parse import urlsplit, urlunsplit + +import requests + +from utilities.errors import EmcError, Stage + +logger = logging.getLogger('app') + +REQUEST_TIMEOUT_SECONDS = 10 + + +def _allowed_schemes() -> set: + """``https`` only, unless the deployment already runs plain HTTP. + + Mirrors ``EMC_URL_SCHEME`` (see ``managers/edcManager.py``) so a local or + on-cluster HTTP setup is not silently broken by this check. + """ + if os.getenv("EMC_URL_SCHEME", "https").strip().lower() == "http": + return {"https", "http"} + + return {"https"} + + +def assert_safe_external_url(raw_url, *, field: str, allow_query: bool = False) -> str: + """Return ``raw_url`` once it is known to name a public endpoint. + + Every caller here builds an outbound request out of a URL the API caller + supplied, and attaches credentials to it. Unchecked, that is a server-side + request forgery sink: this backend runs inside the cluster, so it can reach + the cloud metadata service, the Kubernetes API and every internal Service — + and it would hand the client secret or bearer token to whichever host the + caller named. + + Rejects anything that is not a plain public HTTPS endpoint: wrong scheme, + embedded credentials, and — after resolving the name — loopback, private, + link-local, multicast or otherwise non-global addresses. + """ + if not raw_url or not isinstance(raw_url, str) or not raw_url.strip(): + raise EmcError(f"{field} is required.", + status=400, code="MISSING_REQUIRED_FIELD", stage=Stage.REQUEST) + + url = raw_url.strip() + parts = urlsplit(url) + schemes = _allowed_schemes() + + if parts.scheme not in schemes: + raise EmcError(f"{field} must use {' or '.join(sorted(schemes))}.", + status=400, code="UNSAFE_URL", stage=Stage.REQUEST, + detail=f"Received scheme {parts.scheme or ''!r}.", + hint="Credentials are sent with this request, so the endpoint " + "must be reachable over TLS.") + + if parts.username or parts.password: + raise EmcError(f"{field} must not embed credentials.", + status=400, code="UNSAFE_URL", stage=Stage.REQUEST) + + if not allow_query and (parts.query or parts.fragment): + raise EmcError(f"{field} must not carry a query string or fragment.", + status=400, code="UNSAFE_URL", stage=Stage.REQUEST) + + host = parts.hostname + if not host: + raise EmcError(f"{field} must include a hostname.", + status=400, code="UNSAFE_URL", stage=Stage.REQUEST) + + try: + port = parts.port or (80 if parts.scheme == "http" else 443) + except ValueError as exception: + raise EmcError(f"{field} has an invalid port.", + status=400, code="UNSAFE_URL", stage=Stage.REQUEST, + detail=str(exception)) + + try: + resolved = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP) + except socket.gaierror as exception: + raise EmcError(f"{field} does not resolve to a reachable host.", + status=400, code="UNSAFE_URL", stage=Stage.REQUEST, + detail=str(exception)) + + for entry in resolved: + address = ipaddress.ip_address(entry[4][0]) + if not address.is_global or address.is_multicast: + logger.warning("[auth] Refused %s pointing at the internal address %s", field, address) + raise EmcError(f"{field} resolves to a non-public address.", + status=400, code="UNSAFE_URL", stage=Stage.REQUEST, + detail=f"{host} resolves to {address}.", + hint="Only endpoints reachable on the public internet " + "can be registered here.") + + return url + + +def build_external_url(base_url, path: str, *, field: str) -> str: + parts = urlsplit(assert_safe_external_url(base_url, field=field)) + base_path = parts.path.rstrip("/") + + return urlunsplit((parts.scheme, parts.netloc, + f"{base_path}/{path.lstrip('/')}", "", "")) + + +def get_oauth2_token(oauth_config: dict) -> str: + """Fetch an OAuth2 access token using the client-credentials grant.""" + token_url = assert_safe_external_url(oauth_config.get("accessTokenUrl"), + field="submodelOAuthAccessTokenUrl", + allow_query=True) + client_id = oauth_config.get("clientId") + client_secret = oauth_config.get("clientSecret") + scope = oauth_config.get("scope", "openid") + client_auth = oauth_config.get("clientAuth", "basic") + + data = {"grant_type": "client_credentials", "scope": scope} + auth = None + + if client_auth == "basic": + auth = (client_id, client_secret) + else: + data["client_id"] = client_id + data["client_secret"] = client_secret + + try: + response = requests.post(token_url, data=data, auth=auth, + timeout=REQUEST_TIMEOUT_SECONDS, allow_redirects=False) + except requests.RequestException as exception: + raise EmcError("Could not reach the submodel service's token endpoint.", + status=502, code="OAUTH_TOKEN_UNREACHABLE", stage=Stage.UPSTREAM, + detail=str(exception)) + + if response.status_code != 200: + raise EmcError("The token endpoint rejected the client credentials.", + status=502, code="OAUTH_TOKEN_REFUSED", stage=Stage.UPSTREAM, + detail=f"HTTP {response.status_code} from the token endpoint.") + + try: + token = response.json().get("access_token") + except ValueError as exception: + raise EmcError("The token endpoint did not return JSON.", + status=502, code="OAUTH_TOKEN_MALFORMED", stage=Stage.UPSTREAM, + detail=str(exception)) + + if not token: + raise EmcError("The token endpoint returned no access_token.", + status=502, code="OAUTH_TOKEN_MALFORMED", stage=Stage.UPSTREAM) + + return token diff --git a/charts/edc-management-console/Chart.yaml b/charts/edc-management-console/Chart.yaml index b47f946..3271ccc 100644 --- a/charts/edc-management-console/Chart.yaml +++ b/charts/edc-management-console/Chart.yaml @@ -37,7 +37,7 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 3.15.0 +version: 3.16.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to diff --git a/charts/edc-management-console/templates/deployment-backend.yaml b/charts/edc-management-console/templates/deployment-backend.yaml index 1a95675..a1e6d8a 100644 --- a/charts/edc-management-console/templates/deployment-backend.yaml +++ b/charts/edc-management-console/templates/deployment-backend.yaml @@ -33,6 +33,10 @@ spec: {{- if not .Values.backend.autoscaling.enabled }} replicas: {{ .Values.backend.replicaCount }} {{- end }} + {{- with .Values.backend.strategy }} + strategy: + {{- toYaml . | nindent 4 }} + {{- end }} selector: matchLabels: {{- include "chart.selectorLabels" . | nindent 6 }} diff --git a/charts/edc-management-console/values-dev.yaml b/charts/edc-management-console/values-dev.yaml index 2bf3e8c..54d30b4 100644 --- a/charts/edc-management-console/values-dev.yaml +++ b/charts/edc-management-console/values-dev.yaml @@ -40,7 +40,7 @@ backend: image: repository: "harbor-hub-shared.arena.3ascloud.de/arena2036/emc-backend" pullPolicy: Always - tag: "8505ebe061e96a4c07c5b2dde073f76bd7f2f0a0" + tag: "601f776b2e808b0f022f43e7936b52bbefee8f40" envFrom: - secretRef: name: registry-creds @@ -117,7 +117,10 @@ backend: minReplicas: 1 maxReplicas: 100 targetCPUUtilizationPercentage: 80 - podSecurityContext: {} + podSecurityContext: + runAsUser: 1000 + runAsGroup: 3000 + fsGroup: 3000 securityContext: {} envFrom: [] env: @@ -199,7 +202,7 @@ frontend: image: repository: "harbor-hub-shared.arena.3ascloud.de/arena2036/emc-frontend" pullPolicy: Always - tag: "8505ebe061e96a4c07c5b2dde073f76bd7f2f0a0" + tag: "601f776b2e808b0f022f43e7936b52bbefee8f40" imagePullSecrets: - name: registry-creds env: diff --git a/charts/edc-management-console/values-prod.yaml b/charts/edc-management-console/values-prod.yaml index c046607..0c81842 100644 --- a/charts/edc-management-console/values-prod.yaml +++ b/charts/edc-management-console/values-prod.yaml @@ -494,7 +494,10 @@ backend: minReplicas: 1 maxReplicas: 100 targetCPUUtilizationPercentage: 80 - podSecurityContext: {} + podSecurityContext: + runAsUser: 1000 + runAsGroup: 3000 + fsGroup: 3000 securityContext: {} envFrom: [] diff --git a/charts/edc-management-console/values-staging.yaml b/charts/edc-management-console/values-staging.yaml index 7bdc491..2eeefe9 100644 --- a/charts/edc-management-console/values-staging.yaml +++ b/charts/edc-management-console/values-staging.yaml @@ -117,7 +117,10 @@ backend: minReplicas: 1 maxReplicas: 100 targetCPUUtilizationPercentage: 80 - podSecurityContext: {} + podSecurityContext: + runAsUser: 1000 + runAsGroup: 3000 + fsGroup: 3000 securityContext: {} envFrom: [] env: diff --git a/charts/edc-management-console/values.yaml b/charts/edc-management-console/values.yaml index c67b0b0..5001a35 100644 --- a/charts/edc-management-console/values.yaml +++ b/charts/edc-management-console/values.yaml @@ -34,6 +34,8 @@ backend: name: "emc-backend" # This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ replicaCount: 1 + strategy: + type: Recreate # This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ image: # repository: nginx @@ -174,9 +176,10 @@ backend: maxReplicas: 100 targetCPUUtilizationPercentage: 80 # targetMemoryUtilizationPercentage: 80 - podSecurityContext: {} - # fsGroup: 2000 - + podSecurityContext: + runAsUser: 1000 + runAsGroup: 3000 + fsGroup: 3000 securityContext: {} # capabilities: # drop: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..9100a65 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,5 @@ +.env +.env.* +!.env.example +node_modules +dist diff --git a/frontend/buildAndDeploy.sh b/frontend/buildAndDeploy.sh index b6a78bc..8b1401f 100644 --- a/frontend/buildAndDeploy.sh +++ b/frontend/buildAndDeploy.sh @@ -1,3 +1,4 @@ +#!/bin/bash ############################################################### # Tractus-X - EDC Management Console # @@ -20,8 +21,6 @@ # SPDX-License-Identifier: Apache-2.0 ############################################################### -#!/bin/bash - CONTAINER_NAME=$1 IMAGE_NAME="ifs-frontend" IMAGE_TAG="latest" diff --git a/frontend/entrypoint.sh b/frontend/entrypoint.sh index 250a6e9..2a4a217 100644 --- a/frontend/entrypoint.sh +++ b/frontend/entrypoint.sh @@ -1,3 +1,4 @@ +#!/bin/bash ############################################################### # Tractus-X - EDC Management Console # @@ -20,13 +21,11 @@ # SPDX-License-Identifier: Apache-2.0 ############################################################### -#!/bin/bash - ROOT_DIR=/usr/share/nginx/html echo "Replacing docker environment constants in JavaScript files" -for file in $ROOT_DIR/assets/index-*.js* $ROOT_DIR/index.html; +for file in $ROOT_DIR/assets/index-*.js* $ROOT_DIR/index.html $ROOT_DIR/config.js; do echo "Processing $file ..."; sed -i "s|__BACKEND_URL__|${VITE_BACKEND_URL}|g" "$file" diff --git a/frontend/index.html b/frontend/index.html index 36f3563..0047b98 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -30,7 +30,7 @@ EDC Management Console - +
diff --git a/frontend/src/config.js b/frontend/public/config.js similarity index 100% rename from frontend/src/config.js rename to frontend/public/config.js diff --git a/frontend/src/AppNew.tsx b/frontend/src/AppNew.tsx index b0874eb..1bc62ac 100644 --- a/frontend/src/AppNew.tsx +++ b/frontend/src/AppNew.tsx @@ -49,6 +49,7 @@ import { ErrorBanner } from './components/ErrorDetails'; import OnboardingGuide from './components/OnboardingGuide'; import Tooltip from './components/Tooltip'; import keycloak, { isAuthDisabled } from './auth/keycloak'; +import { useSessionIdentity, type SessionIdentity } from './auth/session'; import { resolveComponentLimit } from './utils/nameRules'; const CONNECTORS_STORAGE_KEY = 'connectors'; @@ -73,6 +74,7 @@ type DeploymentFeedback = { interface DataspaceSettingsPayload { name?: string; + authority_bpn?: string; bpn?: string; realm?: string; username?: string; @@ -129,7 +131,7 @@ interface DataspaceSettingsPayload { interface DataspaceSummary { name: string; - bpn: string; + authorityBpn: string; details: DataspaceSettingsPayload | null; } @@ -151,88 +153,13 @@ function saveLocalStorage(key: string, value: T) { localStorage.setItem(key, JSON.stringify(value)); } -interface BpnCandidate { - path: string; - value: string; -} - -function collectBpnCandidates( - value: unknown, - path: string, - seen = new Set(), -): BpnCandidate[] { - if (!value || seen.has(value)) { - return []; +function readAuthorityBpn(details: DataspaceSettingsPayload | null | undefined) { + const authorityBpn = details?.authority_bpn?.trim().toUpperCase(); + if (authorityBpn) { + return authorityBpn; } - if (typeof value === 'string') { - const matches = value.toUpperCase().match(/BPNL[A-Z0-9]{12}/g) ?? []; - return matches.map((match) => ({ path, value: match })); - } - - if (Array.isArray(value)) { - seen.add(value); - return value.flatMap((entry, index) => - collectBpnCandidates(entry, `${path}[${index}]`, seen), - ); - } - - if (typeof value !== 'object') { - return []; - } - - seen.add(value); - return Object.entries(value as Record).flatMap(([key, nestedValue]) => - collectBpnCandidates(nestedValue, `${path}.${key}`, seen), - ); -} - -function decodeJwtPayload(token?: string) { - if (!token) { - return null; - } - - const parts = token.split('.'); - if (parts.length < 2) { - return null; - } - - try { - const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); - const normalized = base64.padEnd(Math.ceil(base64.length / 4) * 4, '='); - const payload = atob(normalized); - return JSON.parse(payload) as unknown; - } catch (error) { - console.error('Failed to decode JWT payload', error); - return null; - } -} - -function getSessionBpnCandidates(tokenParsed: unknown, rawToken?: string) { - const candidates = [ - ...collectBpnCandidates(tokenParsed, 'tokenParsed'), - ...collectBpnCandidates(decodeJwtPayload(rawToken), 'token'), - ]; - - const unique = new Map(); - for (const candidate of candidates) { - unique.set(`${candidate.path}:${candidate.value}`, candidate); - } - - return Array.from(unique.values()); -} - -function readSessionBpn(tokenParsed: unknown, rawToken?: string) { - return getSessionBpnCandidates(tokenParsed, rawToken)[0]?.value ?? ''; -} - -function readDataspaceBpn(details: DataspaceSettingsPayload | null | undefined) { - const explicitBpn = details?.bpn?.trim().toUpperCase(); - if (explicitBpn) { - return explicitBpn; - } - - return collectBpnCandidates(details, 'dataspace')[0]?.value ?? ''; + return details?.bpn?.trim().toUpperCase() ?? ''; } function getConnectorType(connector: DashboardConnector) { @@ -364,14 +291,14 @@ async function fetchDataspaceSummary( const data = (response.data?.data as DataspaceSettingsPayload | undefined) ?? null; return { name: data?.name || fallbackName, - bpn: readDataspaceBpn(data), + authorityBpn: readAuthorityBpn(data), details: data, }; } catch (error) { console.error('Failed to load dataspace:', toApiError(error)); return { name: fallbackName, - bpn: '', + authorityBpn: '', details: null, }; } @@ -461,13 +388,13 @@ function countComponentsByType(components: ManagedComponent[]) { }; } -function Dashboard({ sessionBpn }: { sessionBpn: string }) { +function Dashboard({ identity }: { identity: SessionIdentity }) { const { t } = useI18n(); const [connectors, setConnectors] = useState([]); const [components, setComponents] = useState([]); const [activityLogs, setActivityLogs] = useState([]); const [dataspaceName, setDataspaceName] = useState(t('dataspaceFallback')); - const [dataspaceBpn, setDataspaceBpn] = useState(''); + const [authorityBpn, setAuthorityBpn] = useState(''); const [dataspaceDetails, setDataspaceDetails] = useState(null); const [showAddDialog, setShowAddDialog] = useState(false); const [showDeploymentWizard, setShowDeploymentWizard] = useState(false); @@ -509,9 +436,9 @@ function Dashboard({ sessionBpn }: { sessionBpn: string }) { const loadDataspace = useCallback(async () => { const summary = await fetchDataspaceSummary(t('dataspaceFallback')); setDataspaceName(summary.name); - setDataspaceBpn(summary.bpn || sessionBpn); + setAuthorityBpn(summary.authorityBpn); setDataspaceDetails(summary.details); - }, [sessionBpn, t]); + }, [t]); useEffect(() => { loadDeployments(); @@ -524,7 +451,7 @@ function Dashboard({ sessionBpn }: { sessionBpn: string }) { }, 30000); return () => clearInterval(interval); - }, [loadDataspace, loadDeployments, sessionBpn, t]); + }, [loadDataspace, loadDeployments, t]); const persistConnector = async (connector: DashboardConnector) => { if (connectors.some((current) => current.name === connector.name)) { @@ -801,7 +728,7 @@ function Dashboard({ sessionBpn }: { sessionBpn: string }) { icon={} title={t('dataSpace')} value={dataspaceName} - subtitle={dataspaceBpn || t('allSourcesMonitored')} + subtitle={authorityBpn || t('allSourcesMonitored')} tooltipTitle={statsGuidance.dataSpace.title} tooltipContent={statsGuidance.dataSpace.content} tooltipFooter={statsGuidance.dataSpace.footer} @@ -919,7 +846,8 @@ function Dashboard({ sessionBpn }: { sessionBpn: string }) { existingConnectorNames={connectors.map((connector) => connector.name)} defaultVersion={dataspaceDetails?.deployment?.connector?.defaultVersion} availableVersions={dataspaceDetails?.deployment?.connector?.availableVersions} - prefilledBpn={dataspaceBpn || sessionBpn} + prefilledBpn={identity.bpn} + bpnRequired={identity.enforceSessionBpn} defaultApiEndpoint={ dataspaceDetails?.edc?.controlplane_url || dataspaceDetails?.edc?.default_url } @@ -1018,7 +946,7 @@ function Monitor() { const [activityLogs, setActivityLogs] = useState([]); const [dataspace, setDataspace] = useState({ name: t('dataspaceFallback'), - bpn: '', + authorityBpn: '', details: null, }); @@ -1211,7 +1139,7 @@ function Monitor() { {dataspace.name}

- {dataspace.bpn || t('allSourcesMonitored')} + {dataspace.authorityBpn || t('allSourcesMonitored')}

@@ -1540,10 +1468,10 @@ function ExternalAppRedirect({ function Settings({ onOpenGuide, - sessionBpn, + identity, }: { onOpenGuide: () => void; - sessionBpn: string; + identity: SessionIdentity; }) { const { t } = useI18n(); const [settingsLoaded, setSettingsLoaded] = useState(false); @@ -1553,15 +1481,8 @@ function Settings({ const loadSettings = async () => { try { const response = await dataspaceApi.getDataspace(); - const details = - (response.data?.data as DataspaceSettingsPayload | undefined) ?? null; setDataspaceDetails( - details - ? { - ...details, - bpn: readDataspaceBpn(details) || sessionBpn, - } - : null, + (response.data?.data as DataspaceSettingsPayload | undefined) ?? null, ); } catch (error) { console.error('Failed to load dataspace settings:', error); @@ -1571,7 +1492,7 @@ function Settings({ }; loadSettings(); - }, [sessionBpn]); + }, []); const formatValue = (value?: string | boolean) => { if (typeof value === 'boolean') { @@ -1582,21 +1503,27 @@ function Settings({ }; const sections = [ + { + key: 'company', + title: t('settingsSectionCompany'), + fields: [ + { label: t('settingsLabelCompanyName'), value: identity.company }, + { label: t('settingsLabelCompanyBpn'), value: identity.bpn }, + ], + }, { key: 'dataspace', title: t('settingsSectionDataspace'), fields: [ { label: t('settingsLabelDataspace'), value: dataspaceDetails?.name }, - { label: t('settingsLabelBpn'), value: dataspaceDetails?.bpn }, - { label: t('settingsLabelCompanyName'), value: dataspaceDetails?.realm }, - { label: t('settingsLabelReadonly'), value: dataspaceDetails?.readonly }, + { label: t('settingsLabelAuthorityBpn'), value: readAuthorityBpn(dataspaceDetails) }, + { label: t('settingsLabelIdpRealm'), value: dataspaceDetails?.realm }, ], }, { key: 'access', title: t('settingsSectionAccess'), fields: [ - { label: t('settingsLabelDefaultUsername'), value: dataspaceDetails?.username }, { label: t('settingsLabelCentralIdpUrl'), value: dataspaceDetails?.centralidp?.url }, { label: t('settingsLabelCentralIdpRealm'), value: dataspaceDetails?.centralidp?.realm }, { label: t('settingsLabelSsiWalletUrl'), value: dataspaceDetails?.ssi_wallet?.url }, @@ -1608,7 +1535,6 @@ function Settings({ fields: [ { label: t('settingsLabelPortalUrl'), value: dataspaceDetails?.portal?.url }, { label: t('settingsLabelSdeUrl'), value: dataspaceDetails?.sde?.url }, - { label: t('settingsLabelSdeClientId'), value: dataspaceDetails?.sde?.client_id }, { label: t('settingsLabelManufacturerId'), value: dataspaceDetails?.sde?.manufacturerId }, ], }, @@ -1694,17 +1620,15 @@ function Settings({ function AppShell() { const { t } = useI18n(); const authDisabled = isAuthDisabled(); + const { identity } = useSessionIdentity(); const firstName = keycloak.tokenParsed?.given_name || ''; const lastName = keycloak.tokenParsed?.family_name || ''; const fullName = + identity.name || `${firstName} ${lastName}`.trim() || + identity.username || keycloak.tokenParsed?.preferred_username || t('userFallback'); - const sessionBpnCandidates = getSessionBpnCandidates( - keycloak.tokenParsed, - keycloak.token, - ); - const sessionBpn = readSessionBpn(keycloak.tokenParsed, keycloak.token); // Explicit env / runtime-config values take precedence over the dataspace // config, so a deployment can point these entries somewhere else without @@ -1735,26 +1659,10 @@ function AppShell() { localStorage.setItem(THEME_STORAGE_KEY, theme); }, [theme]); - useEffect(() => { - if (sessionBpnCandidates.length > 0) { - console.info( - '[EMC] Keycloak BPNL candidates detected:', - sessionBpnCandidates, - ); - } else { - console.warn( - '[EMC] No BPNL candidate found in Keycloak token payload.', - keycloak.tokenParsed, - ); - } - }, [sessionBpnCandidates]); - useEffect(() => { const loadAppUrls = async () => { try { const response = await dataspaceApi.getDataspace(); - // Only fill in from the dataspace config when the deployment has not set - // an explicit value; otherwise the env setting would be silently ignored. if (!envSdeUrl && response.data?.data?.sde?.url) { setSdeUrl(response.data.data.sde.url); } @@ -1806,7 +1714,7 @@ function AppShell() {
- } /> + } /> } /> setShowGuide(true)} - sessionBpn={sessionBpn} + identity={identity} /> )} /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 743ac5d..149eda3 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -21,6 +21,7 @@ ********************************************************************************/ import axios from 'axios'; import { getRuntimeConfigValue } from '../runtime-config'; +import keycloak, { isAuthDisabled } from '../auth/keycloak'; import type { DeployRequest } from '../types'; import { toApiError } from './errors'; @@ -60,6 +61,31 @@ apiClient.interceptors.response.use( (error) => Promise.reject(toApiError(error)), ); +apiClient.interceptors.request.use(async (config) => { + if (isAuthDisabled()) { + return config; + } + + if (keycloak.authenticated) { + try { + const refreshed = await keycloak.updateToken(30); + if (refreshed) { + localStorage.setItem('token', keycloak.token || ''); + } + } catch (error) { + + console.warn('Failed to refresh the Keycloak token', error); + } + } + + const token = keycloak.token || localStorage.getItem('token') || ''; + if (token) { + config.headers.set('Authorization', `Bearer ${token}`); + } + + return config; +}); + export const edcClient = (name: string) => { const baseURL = edcHost ? `https://${name}-controlplane.${edcHost}` : ''; diff --git a/frontend/src/auth/keycloak.ts b/frontend/src/auth/keycloak.ts index eebbcb3..10a6426 100644 --- a/frontend/src/auth/keycloak.ts +++ b/frontend/src/auth/keycloak.ts @@ -42,9 +42,21 @@ export function getKeycloakConfig(): KeycloakConfig { }; } +function isAbsoluteHttpUrl(value: string | undefined) { + if (!value) { + return false; + } + + try { + return ['http:', 'https:'].includes(new URL(value).protocol); + } catch { + return false; + } +} + export function validateKeycloakConfig(config: KeycloakConfig) { const missingFields = [ - ['url', config.url], + ['url', isAbsoluteHttpUrl(config.url) ? config.url : ''], ['realm', config.realm], ['clientId', config.clientId], ].filter(([, value]) => !value); diff --git a/frontend/src/auth/session.ts b/frontend/src/auth/session.ts new file mode 100644 index 0000000..1c77904 --- /dev/null +++ b/frontend/src/auth/session.ts @@ -0,0 +1,93 @@ +/******************************************************************************** +# Tractus-X - EDC Management Console +# +# Copyright (c) 2026 ARENA2036 e.V. +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License, Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0. +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# SPDX-License-Identifier: Apache-2.0 +********************************************************************************/ + +import { useEffect, useState } from 'react'; +import { isAuthDisabled } from './keycloak'; +import { dataspaceApi } from '../api/client'; + +export interface SessionIdentity { + username: string; + name: string; + bpn: string; + company: string; + /** Whether the backend refuses a deployment that is not under the caller's BPN. */ + enforceSessionBpn: boolean; +} + +export const EMPTY_IDENTITY: SessionIdentity = { + username: '', + name: '', + bpn: '', + company: '', + enforceSessionBpn: false, +}; + +export async function fetchSessionIdentity(): Promise { + if (isAuthDisabled()) { + return null; + } + + try { + const response = await dataspaceApi.getDataspace(); + const session = response.data?.data?.session as Partial | undefined; + if (!session) { + return null; + } + + return { + ...EMPTY_IDENTITY, + ...session, + bpn: (session.bpn ?? '').toUpperCase(), + }; + } catch (error) { + console.error('Failed to load the session identity:', error); + return null; + } +} + +export function useSessionIdentity() { + const [identity, setIdentity] = useState(EMPTY_IDENTITY); + + useEffect(() => { + let active = true; + + fetchSessionIdentity().then((resolved) => { + if (!active) { + return; + } + + setIdentity(resolved ?? EMPTY_IDENTITY); + if (resolved && !resolved.bpn) { + console.warn( + '[EMC] No BPN in the session. Add "bpn" and "organisation" User Attribute ' + + 'mappers to this application\'s client in the identity provider.', + ); + } + }); + + return () => { + active = false; + }; + }, []); + + return { identity }; +} diff --git a/frontend/src/components/ComponentsManager.tsx b/frontend/src/components/ComponentsManager.tsx index 46ad54d..a0ab62c 100644 --- a/frontend/src/components/ComponentsManager.tsx +++ b/frontend/src/components/ComponentsManager.tsx @@ -19,7 +19,7 @@ # # SPDX-License-Identifier: Apache-2.0 ********************************************************************************/ -import { Boxes, MoreHorizontal, PencilLine, Trash2, X } from 'lucide-react'; +import { Boxes, MoreHorizontal, Trash2, X } from 'lucide-react'; import { useMemo, useState } from 'react'; import type { ManagedComponent } from '../types'; import { useI18n } from '../i18n'; diff --git a/frontend/src/components/ConnectorsManager.tsx b/frontend/src/components/ConnectorsManager.tsx index 3461881..5fe43c3 100644 --- a/frontend/src/components/ConnectorsManager.tsx +++ b/frontend/src/components/ConnectorsManager.tsx @@ -19,10 +19,12 @@ # # SPDX-License-Identifier: Apache-2.0 ********************************************************************************/ -import { Boxes, MoreHorizontal, Trash2, X } from 'lucide-react'; -import { useMemo, useState } from 'react'; -import type { ManagedComponent } from '../types'; +import { FileText, MoreHorizontal, PencilLine, Plus, Trash2, Zap } from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; +import type { DashboardConnector } from '../types'; import { useI18n } from '../i18n'; +import DeleteModal from './DeleteModal'; +import DetailsModal from './DetailsModal'; import Tooltip from './Tooltip'; import YamlViewModal from './YamlViewModal'; @@ -59,13 +61,13 @@ function StatusBadge({ status }: { status: string }) { } if (status === 'deploying') { return ( - - Active + + Deploying ); } return ( - + {status || 'Unknown'} ); @@ -113,30 +115,30 @@ export default function ConnectorsManager({
-
- +
+

- {t('componentsServices')} + {t('edcConnectors')}

- {t('componentsSectionSubtitle')} + {t('connectorsSectionSubtitle')}

{rows.length === 0 ? ( -
-
- +
+
+

- {t('noComponentsTitle')} + {t('noConnectorsTitle')}

- {t('noComponentsDescription')} + {t('noConnectorsDescription')}

) : ( @@ -145,20 +147,21 @@ export default function ConnectorsManager({ {t('tableName')} - {t('tableType')} {t('tableVersion')} + {t('tableType')} {t('tableStatus')} + {t('tableEndpoint')} {t('tableActions')} - {rows.map((component) => ( + {rows.map((connector) => ( - {component.name} + {connector.name} {connector.version || t('noValue')} @@ -169,10 +172,12 @@ export default function ConnectorsManager({ - {component.version} + - + + {connector.endpoint || t('noValue')} +
@@ -219,7 +224,7 @@ export default function ConnectorsManager({
- {selectedComponent && ( - setSelectedComponent(null)} + {selectedConnector && ( + setSelectedConnector(null)} /> )} @@ -271,4 +276,4 @@ export default function ConnectorsManager({ )} ); -} \ No newline at end of file +} diff --git a/frontend/src/components/DeploymentWizard.tsx b/frontend/src/components/DeploymentWizard.tsx index 14c2278..9a1ea88 100644 --- a/frontend/src/components/DeploymentWizard.tsx +++ b/frontend/src/components/DeploymentWizard.tsx @@ -44,6 +44,7 @@ interface Props { defaultVersion?: string; availableVersions?: string[]; prefilledBpn?: string; + bpnRequired?: boolean; defaultApiEndpoint?: string; defaultDataPlaneUrl?: string; controlPlaneHostSuffix?: string; @@ -76,6 +77,7 @@ export default function DeploymentWizard({ defaultVersion, availableVersions, prefilledBpn, + bpnRequired = false, defaultApiEndpoint, defaultDataPlaneUrl, controlPlaneHostSuffix, @@ -197,6 +199,8 @@ export default function DeploymentWizard({ [dataplaneHostnameSuffix, normalizedConnectorName], ); const connectorLimitReached = connectorCount >= MAX_CONNECTORS; + const bpnMissing = !resolvedBpn; + const blockedByMissingBpn = bpnRequired && bpnMissing; // The deployed connector lives at its own per-name host ("{name}-{suffix}"), which // is what the backend's cp_hostname/dp_hostname derive templates put on the Ingress. @@ -238,7 +242,12 @@ export default function DeploymentWizard({ const deployConnector = async () => { setSubmitted(true); - if (deploying || connectorLimitReached || Object.keys(stepErrors).length > 0) { + if ( + deploying || + connectorLimitReached || + blockedByMissingBpn || + Object.keys(stepErrors).length > 0 + ) { return; } @@ -290,6 +299,11 @@ export default function DeploymentWizard({ {t('connectorLimitReached', { max: String(MAX_CONNECTORS) })}
)} + {bpnMissing && ( +
+ {bpnRequired ? t('bpnMissingBlocking') : t('bpnMissingWarning')} +
+ )}
+
+ + +

+ {t('bpnHelp')} +

+
+