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..3c15756 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: "69514e64fce952878e0c584798f848a8db41407d"
envFrom:
- secretRef:
name: registry-creds
@@ -110,7 +110,197 @@ backend:
type: ClusterIP
port: 8001
configuration:
- yml: "appConfig:\n name: \"ARENA2036 EDC Management Console\"\n client_id: \"EMC\"\n version: \"1.0.0\"\n port: 5000\n host: \"0.0.0.0\"\n\n authorization:\n enabled: true\n apiKey:\n key: \"X-Api-Key\"\n value: \"emc-api-key\"\n\n startup:\n checks: true\n refresh_interval: 10\n\ndataspaceConfig:\n name: \"ARENA2036-X\"\n authority_id: \"BPNL00000003CRHK\"\n centralidp:\n url: \"https://centralidp.txcd.arena2036-x.de/auth/\"\n realm: \"CX-Central\"\n client_id: \"${CENTRALIDP_CLIENT_ID}\"\n client_secret: \"${CENTRALIDP_CLIENT_SECRET}\"\n ssi_wallet:\n url: \"https://ssi-dim-wallet-stub.txcd.arena2036-x.de\"\n client_id: \"${SSI_WALLET_CLIENT_ID}\"\n client_secret: \"${SSI_WALLET_CLIENT_SECRET}\"\n portal:\n url: \"https://portal.txcd.arena2036-x.de\"\n client_id: \"${PORTAL_CLIENT_ID}\"\n client_secret: \"${PORTAL_CLIENT_SECRET}\"\n discovery:\n semantics:\n url: \"https://semantics.txcd.arena2036-x.de\"\n discoveryFinder:\n endpoint: \"/discoveryfinder/api/v1.0/administration/connectors/discovery/search\"\n keys:\n edc_discovery: \"bpn\"\n client_id: \"${DISCOVERY_FINDER_CLIENT_ID}\"\n client_secret: \"${DISCOVERY_FINDER_CLIENT_SECRET}\"\n bpnDiscovery:\n endpoint: \"/bpndiscovery/api/v1.0/administration/connectors/bpnDiscovery/search\"\n client_id: \"${BPN_DISCOVERY_CLIENT_ID}\"\n client_secret: \"${BPN_DISCOVERY_CLIENT_SECRET}\"\n clusterConfig:\n context: \"k8s-ap6-01\"\n namespace: \"edc-management-console\"\n\n\nexternalApps:\n sde:\n url: https://sde.txcd.arena2036.app\n client_id: \"sde\"\n manufacturerId: BPNL00000000052O\n providerEDC: https://connector-2-controlplane.txcd.arena2036.app\n consumerEDC: https://connector-2-controlplane.txcd.arena2036.app\n registryUrl: https://connector-2-dtr.txcd.arena2036.app/semantics/registry\n\n\n# Dataspace/EDC query config + the inputs to the deployment derivation context\n# (wallet/sts/bdrs/hostname). Helm repos here are registered at startup so any\n# component's chart + subchart dependencies can be resolved.\nconnector:\n default_url: \"https://connector-2-controlplane.txcd.arena2036.app\"\n # DID method used to derive connector and issuer identities (did:::).\n didMethod: \"did:web\"\n helmRepositories:\n - name: tractusx-dev\n url: https://eclipse-tractusx.github.io/charts/dev\n - name: hashicorp\n url: https://helm.releases.hashicorp.com\n - name: bitnami\n url: https://charts.bitnami.com/bitnami\n endpoints:\n assets: \"/v3/assets\"\n policies: \"/v3/policydefinitions\"\n contracts: \"/v3/contractdefinitions\"\n readiness: \"/api/check/readiness\"\n liveness: \"/api/check/liveness\"\n catalog: \"/management/v3/catalog/request\"\n edr_prefix: \"/management/v3/edrs\"\n view_edr: \"/request\"\n transfer_edr: \"/dataaddress?auto_refresh=true\"\n dsp: \"/api/v1/dsp\"\n hostname:\n controlplane: controlplane.txcd.arena2036.app\n dataplane: dataplane.txcd.arena2036.app\n # STS/OAuth paths appended to the SSI wallet url + the vault secret alias.\n sts:\n dimEndpoint: \"/api/sts\"\n oauthTokenEndpoint: \"/oauth/token\"\n secretAlias: \"edc-wallet-secret\"\n # BPN Directory Service (BDRS) path appended to the SSI wallet url.\n bdrs:\n directoryEndpoint: \"/api/v1/directory\"\n\n# Generic, component-agnostic deployment definitions. Each component describes\n# its chart, supported versions (base values templates), `derive` rules (computed\n# attributes) and `valueMappings`. Adding a new component is a config change only.\n#\n# valueMappings entry:\n# path: dotted target in the values, supports list indices e.g. a.b[0].c\n# from: read the value from a source field, OR\n# value: use this literal, OR\n# template: render this string with {name} (and any source field)\n# transform: optional, e.g. \"urlencode\"\n# mode: \"set\" (default) or \"append\" (for list-valued leaves)\n# when: optional flag gate, e.g. \"registry\" / \"submodel\"\ncomponents:\n # The EDC connector itself. Deployed when present in the request's `components`\n # array (with type+name). Each field below reads from THAT component's object.\n connector:\n releaseName: \"{name}\" # the component's own name\n version: \"{version}\" # the component's own version, validated against `versions`\n chart:\n name: tractusx-connector\n repo: tractusx-dev # name resolved against connector.helmRepositories\n templatesDir: config/connector-templates\n versions:\n - version: \"0.12.1\"\n valuesYaml: /values-0.12.1.yaml\n - version: \"0.11.2\"\n valuesYaml: /values-0.11.2.yaml\n # - version: \"0.10.3\"\n # valuesYaml: /values-0.10.3.yaml\n # Computed attributes (templated against request fields + dataspace context),\n # set on the request and referenced below via `from`.\n derive:\n iatp_id: \"{did_method}:{wallet_host}:{bpn}\"\n trustedIssuers: \"{did_method}:{wallet_host}:{authority_id}\"\n sts_dim_url: \"{wallet_url}{sts_dim_endpoint}\"\n sts_oauth_tokenUrl: \"{wallet_url}{sts_oauth_token_endpoint}\"\n sts_oauth_client_id: \"{bpn}\"\n sts_oauth_secretAlias: \"{sts_secret_alias}\"\n cp_bdrs_server_url: \"{wallet_url}{bdrs_endpoint}\"\n cp_hostname: \"{name}-{controlplane_hostname}\"\n dp_hostname: \"{name}-{dataplane_hostname}\"\n # Per-plane ingress annotations — each points external-dns at that plane's\n # OWN host. Authored as maps (the shape Helm ingress annotations expect),\n # with templated values resolved against the derivation context.\n controlplane_annotations:\n external-dns.alpha.kubernetes.io/hostname: \"{name}-{controlplane_hostname}\"\n dataplane_annotations:\n external-dns.alpha.kubernetes.io/hostname: \"{name}-{dataplane_hostname}\"\n # Image pull secrets — the tractusx-connector chart uses ONE top-level\n # `imagePullSecrets` for both the control plane and data plane (it has no\n # per-plane override), so this single list applies to both.\n imagePullSecrets:\n - name: registry-creds\n\n valueMappings:\n - path: participant.id\n from: bpn\n transform: urlencode\n - path: iatp.id\n from: iatp_id\n - path: iatp.trustedIssuers\n from: trustedIssuers\n mode: append\n - path: iatp.sts.dim.url\n from: sts_dim_url\n - path: iatp.sts.oauth.token_url\n from: sts_oauth_tokenUrl\n - path: iatp.sts.oauth.client.id\n from: sts_oauth_client_id\n - path: iatp.sts.oauth.client.secret_alias\n from: sts_oauth_secretAlias\n - path: imagePullSecrets\n from: imagePullSecrets\n - path: controlplane.bdrs.server.url\n from: cp_bdrs_server_url\n - path: controlplane.ingresses[0].enabled\n value: true\n - path: controlplane.ingresses[0].endpoints[0]\n value: 'protocol'\n - path: controlplane.ingresses[0].endpoints[1]\n value: 'management'\n - path: controlplane.ingresses[0].hostname\n from: cp_hostname\n - path: controlplane.ingresses[0].certManager.clusterIssuer\n value: 'letsencrypt-int'\n - path: controlplane.ingresses[0].className\n value: 'nginx'\n - path: controlplane.ingresses[0].annotations\n from: controlplane_annotations\n - path: controlplane.ingresses[0].tls.enabled\n value: true\n - path: controlplane.ingresses[0].tls.secretName\n template: \"{name}-cp-tls\"\n - path: dataplane.ingresses[0].enabled\n value: true\n - path: dataplane.ingresses[0].hostname\n from: dp_hostname\n - path: dataplane.ingresses[0].certManager.clusterIssuer\n value: 'letsencrypt-int'\n - path: dataplane.ingresses[0].className\n value: 'nginx'\n - path: dataplane.ingresses[0].annotations\n from: dataplane_annotations\n - path: dataplane.ingresses[0].tls.enabled\n value: true\n - path: dataplane.ingresses[0].tls.secretName\n template: \"{name}-dp-tls\"\n - path: dataplane.token.signer.privatekey_alias\n value: \"tokenSignerPrivateKey\"\n - path: dataplane.token.verifier.publickey_alias\n value: \"tokenSignerPublicKey\"\n # The chart's jdbcUrl hardcodes \"/edc\"; override it so the control plane\n # connects to the SAME database Bitnami creates from auth.database below.\n # {name}-postgresql == \"{{ .Release.Name }}-postgresql\" (releaseName is {name}).\n - path: postgresql.jdbcUrl\n template: \"jdbc:postgresql://{name}-postgresql:5432/{db_name}\"\n - path: postgresql.auth.database\n from: db_name\n - path: postgresql.auth.username\n from: auth.db_username\n - path: postgresql.auth.password\n from: auth.db_password\n\n # Submodel Server — its own chart + release. Deployed when present in the\n # request's `components` array. `url` is its ingress host; `auth.*` its DB creds.\n submodelServer:\n releaseName: \"{name}\" # the component's own name\n version: \"{version}\" # the component's own version\n healthPath: \"/actuator/health/liveness\" # reachability probe path appended to the ingress url\n chart:\n # This config would be used for local deployment without Docker\n # name: simple-data-backend\n # repo: tractusx-dev\n\n # Deploy from the LOCAL chart copy in config/ (patched with probe enable\n # toggles) instead of pulling simple-data-backend from the repo. Having path issues on windows, \n # but should be working inside container\n directory: config/simple-data-backend\n # Base values come from config/submodelserver-templates (NOT the chart default).\n templatesDir: config/submodelserver-templates\n versions:\n - version: \"0.1.0\"\n valuesYaml: /values-0.1.0.yaml\n derive:\n # simple-data-backend has NO certManager field — TLS is issued via the\n # cert-manager annotation. external-dns points at this component's own host.\n annotations:\n external-dns.alpha.kubernetes.io/hostname: \"{url}\"\n #cert-manager.io/cluster-issuer: \"letsencrypt-int\"\n valueMappings:\n - path: fullnameOverride\n template: \"{name}\"\n - path: ingress.enabled # chart default is false\n value: true\n - path: ingress.className\n value: 'nginx'\n - path: ingress.annotations\n from: annotations\n - path: ingress.hosts[0].host\n from: url\n - path: ingress.hosts[0].paths[0].path\n value: '/'\n - path: ingress.hosts[0].paths[0].pathType\n value: 'Prefix'\n - path: ingress.tls[0].hosts[0]\n from: url\n - path: ingress.tls[0].secretName\n template: \"{name}-submodel-tls\"\n # Liveness/readiness probes — the local chart gates them on `.enabled`.\n # The image's /actuator/health/* paths 404, so keep them off here.\n - path: livenessProbe.enabled\n value: false\n - path: readinessProbe.enabled\n value: false\n\n # Digital Twin Registry — its own chart + release. Deployed when present in the\n # request's `components` array. `url` is its ingress host; `auth.*` its DB creds.\n digitalTwinRegistry:\n releaseName: \"{name}\" # the component's own name\n version: \"{version}\" # the component's own version\n healthPath: \"/semantics/registry/\" # reachability probe path appended to the ingress url\n chart:\n name: digital-twin-registry\n repo: tractusx-dev\n # Base values per version come from the mounted registry-templates\n # ConfigMaps (backend.registry.templates), like connector-templates.\n templatesDir: config/registry-templates\n # Last 2 stable releases (latest first). Source: tractusx charts/dev index.\n versions:\n - version: \"0.12.0\"\n valuesYaml: /values-0.12.0.yaml\n - version: \"0.11.0\"\n valuesYaml: /values-0.11.0.yaml\n derive:\n # digital-twin-registry issues TLS via the cert-manager annotation (no\n # certManager field). external-dns points at this component's own host.\n annotations:\n external-dns.alpha.kubernetes.io/hostname: \"{url}\"\n #cert-manager.io/cluster-issuer: \"letsencrypt-int\"\n nginx.ingress.kubernetes.io/rewrite-target: /$2\n nginx.ingress.kubernetes.io/use-regex: \"true\"\n nginx.ingress.kubernetes.io/cors-allow-credentials: \"true\"\n nginx.ingress.kubernetes.io/x-forwarded-prefix: /semantics/registry\n valueMappings:\n - path: fullnameOverride # top-level (the chart has no registry.fullnameOverride)\n template: \"{name}\"\n - path: enableKeycloak\n value: false\n # The chart ALWAYS renders templates/keycloak/init-script-configmap.yaml as\n # \"{keycloak.fullnameOverride}-configmap\" (not gated on enableKeycloak). The\n # chart default fullnameOverride is the fixed \"registry-keycloak\", so two DTR\n # releases in one namespace collide on \"registry-keycloak-configmap\". Make it\n # release-unique so each DTR owns its own configmap.\n - path: keycloak.fullnameOverride\n template: \"{name}-keycloak\"\n # Bundled postgres (like the connector). With enablePostgres=true the chart\n # deploys its own DB and wires the registry to it via postgres-init, so the\n # registry-secret's external `dataSource` block is skipped entirely.\n - path: enablePostgres\n value: true\n - path: postgresql.auth.username\n from: auth.db_username # from the component's auth block\n - path: postgresql.auth.password\n from: auth.db_password # empty -> chart generates a random one\n - path: postgresql.auth.database\n from: db_name\n # The init secret name (chart default \"secret-dtr-postgres-init\") is shared\n # by the postgres-init hook, the registry deployment and the postgres\n # subchart. It's a fixed literal, so two DTR releases collide on it — make\n # it release-unique (all three consumers read this same value).\n - path: postgresql.auth.existingSecret\n template: \"{name}-postgres-init\"\n - path: registry.host # the externally-resolvable ingress host\n from: url\n - path: registry.ingress.enabled\n value: true\n - path: registry.ingress.urlPrefix\n value: '/semantics/registry'\n - path: registry.ingress.className\n value: 'nginx'\n - path: registry.ingress.annotations\n from: annotations\n - path: registry.ingress.tls.enabled # tls is a MAP here, not a list\n value: true\n - path: registry.ingress.tls.secretName\n template: \"{name}-dtr-tls\"\n\n # IdentityHub — its own chart + release, deployed for every connector\n # (it is the connector's own DID/credential service, not an optional add-on).\n # identityhub:\n # deployWhen: always\n # releaseName: \"{name}-idhub\"\n # chart:\n # name: tractusx-identityhub\n # repo: tractusx-dev\n # # Last 3 stable releases (latest first; note the chart tags carry a 'v').\n # # Source: tractusx charts/dev index.\n # versions:\n # - version: \"v0.3.2\"\n # - version: \"v0.3.1\"\n # - version: \"v0.3.0\"\n # valueMappings:\n # - path: fullnameOverride\n # template: \"{name}-idhub\"\n # - path: ingress.hosts[0].host\n # template: \"{name}.int.construct-x.net\"\n # - path: ingress.tls[0].hosts[0]\n # template: \"{name}.int.construct-x.net\"\n # - path: ingress.tls[0].secretName\n # template: \"{name}-idhub-tls\"\n\ndatabase:\n url: \"${DATABASE_URL}\"\n echo: false"
+ yml: "appConfig:\n name: \"ARENA2036 EDC Management Console\"\n client_id: \"EMC\"\n
+ \ version: \"1.0.0\"\n port: 5000\n host: \"0.0.0.0\"\n\n authorization:\n
+ \ enabled: true\n apiKey:\n key: \"X-Api-Key\"\n value: \"emc-api-key\"\n\n
+ \ startup:\n checks: true\n refresh_interval: 10\n\ndataspaceConfig:\n
+ \ name: \"ARENA2036-X\"\n authority_id: \"BPNL00000003CRHK\"\n centralidp:\n
+ \ url: \"https://centralidp.txcd.arena2036-x.de/auth/\"\n realm: \"CX-Central\"\n
+ \ client_id: \"${CENTRALIDP_CLIENT_ID}\"\n client_secret: \"${CENTRALIDP_CLIENT_SECRET}\"\n
+ \ ssi_wallet:\n url: \"https://ssi-dim-wallet-stub.txcd.arena2036-x.de\"\n
+ \ client_id: \"${SSI_WALLET_CLIENT_ID}\"\n client_secret: \"${SSI_WALLET_CLIENT_SECRET}\"\n
+ \ portal:\n url: \"https://portal.txcd.arena2036-x.de\"\n client_id: \"${PORTAL_CLIENT_ID}\"\n
+ \ client_secret: \"${PORTAL_CLIENT_SECRET}\"\n discovery:\n semantics:\n
+ \ url: \"https://semantics.txcd.arena2036-x.de\"\n discoveryFinder:\n
+ \ endpoint: \"/discoveryfinder/api/v1.0/administration/connectors/discovery/search\"\n
+ \ keys:\n edc_discovery: \"bpn\"\n client_id: \"${DISCOVERY_FINDER_CLIENT_ID}\"\n
+ \ client_secret: \"${DISCOVERY_FINDER_CLIENT_SECRET}\"\n bpnDiscovery:\n
+ \ endpoint: \"/bpndiscovery/api/v1.0/administration/connectors/bpnDiscovery/search\"\n
+ \ client_id: \"${BPN_DISCOVERY_CLIENT_ID}\"\n client_secret: \"${BPN_DISCOVERY_CLIENT_SECRET}\"\n
+ \ clusterConfig:\n context: \"k8s-ap6-01\"\n namespace: \"edc-management-console\"\n\n\nexternalApps:\n
+ \ sde:\n url: https://sde.txcd.arena2036.app\n client_id: \"sde\"\n manufacturerId:
+ BPNL00000000052O\n providerEDC: https://connector-2-controlplane.txcd.arena2036.app\n
+ \ consumerEDC: https://connector-2-controlplane.txcd.arena2036.app\n registryUrl:
+ https://connector-2-dtr.txcd.arena2036.app/semantics/registry\n\n\n# Dataspace/EDC
+ query config + the inputs to the deployment derivation context\n# (wallet/sts/bdrs/hostname).
+ Helm repos here are registered at startup so any\n# component's chart + subchart
+ dependencies can be resolved.\nconnector:\n default_url: \"https://connector-2-controlplane.txcd.arena2036.app\"\n
+ \ # DID method used to derive connector and issuer identities (did:::).\n
+ \ didMethod: \"did:web\"\n helmRepositories:\n - name: tractusx-dev\n url:
+ https://eclipse-tractusx.github.io/charts/dev\n - name: hashicorp\n url:
+ https://helm.releases.hashicorp.com\n - name: bitnami\n url: https://charts.bitnami.com/bitnami\n
+ \ endpoints:\n assets: \"/v3/assets\"\n policies: \"/v3/policydefinitions\"\n
+ \ contracts: \"/v3/contractdefinitions\"\n readiness: \"/api/check/readiness\"\n
+ \ liveness: \"/api/check/liveness\"\n catalog: \"/management/v3/catalog/request\"\n
+ \ edr_prefix: \"/management/v3/edrs\"\n view_edr: \"/request\"\n transfer_edr:
+ \"/dataaddress?auto_refresh=true\"\n dsp: \"/api/v1/dsp\"\n hostname:\n
+ \ controlplane: controlplane.txcd.arena2036.app\n dataplane: dataplane.txcd.arena2036.app\n
+ \ # STS/OAuth paths appended to the SSI wallet url + the vault secret alias.\n
+ \ sts:\n dimEndpoint: \"/api/sts\"\n oauthTokenEndpoint: \"/oauth/token\"\n
+ \ secretAlias: \"edc-wallet-secret\"\n # BPN Directory Service (BDRS) path
+ appended to the SSI wallet url.\n bdrs:\n directoryEndpoint: \"/api/v1/directory\"\n\n#
+ Generic, component-agnostic deployment definitions. Each component describes\n#
+ its chart, supported versions (base values templates), `derive` rules (computed\n#
+ attributes) and `valueMappings`. Adding a new component is a config change only.\n#\n#
+ valueMappings entry:\n# path: dotted target in the values, supports list
+ indices e.g. a.b[0].c\n# from: read the value from a source field, OR\n#
+ \ value: use this literal, OR\n# template: render this string with {name}
+ (and any source field)\n# transform: optional, e.g. \"urlencode\"\n# mode:
+ \ \"set\" (default) or \"append\" (for list-valued leaves)\n# when: optional
+ flag gate, e.g. \"registry\" / \"submodel\"\ncomponents:\n # The EDC connector
+ itself. Deployed when present in the request's `components`\n # array (with
+ type+name). Each field below reads from THAT component's object.\n connector:\n
+ \ releaseName: \"{name}\" # the component's own name\n version:
+ \"{version}\" # the component's own version, validated against `versions`\n
+ \ chart:\n name: tractusx-connector\n repo: tractusx-dev # name
+ resolved against connector.helmRepositories\n templatesDir: config/connector-templates\n
+ \ versions:\n - version: \"0.12.1\"\n valuesYaml: /values-0.12.1.yaml\n
+ \ - version: \"0.11.2\"\n valuesYaml: /values-0.11.2.yaml\n #
+ - version: \"0.10.3\"\n # valuesYaml: /values-0.10.3.yaml\n # Computed
+ attributes (templated against request fields + dataspace context),\n # set
+ on the request and referenced below via `from`.\n derive:\n iatp_id:
+ \"{did_method}:{wallet_host}:{bpn}\"\n trustedIssuers: \"{did_method}:{wallet_host}:{authority_id}\"\n
+ \ sts_dim_url: \"{wallet_url}{sts_dim_endpoint}\"\n sts_oauth_tokenUrl:
+ \"{wallet_url}{sts_oauth_token_endpoint}\"\n sts_oauth_client_id: \"{bpn}\"\n
+ \ sts_oauth_secretAlias: \"{sts_secret_alias}\"\n cp_bdrs_server_url:
+ \"{wallet_url}{bdrs_endpoint}\"\n cp_hostname: \"{name}-{controlplane_hostname}\"\n
+ \ dp_hostname: \"{name}-{dataplane_hostname}\"\n # Per-plane ingress
+ annotations — each points external-dns at that plane's\n # OWN host. Authored
+ as maps (the shape Helm ingress annotations expect),\n # with templated
+ values resolved against the derivation context.\n controlplane_annotations:\n
+ \ external-dns.alpha.kubernetes.io/hostname: \"{name}-{controlplane_hostname}\"\n
+ \ dataplane_annotations:\n external-dns.alpha.kubernetes.io/hostname:
+ \"{name}-{dataplane_hostname}\"\n # Image pull secrets — the tractusx-connector
+ chart uses ONE top-level\n # `imagePullSecrets` for both the control plane
+ and data plane (it has no\n # per-plane override), so this single list
+ applies to both.\n imagePullSecrets:\n - name: registry-creds\n\n
+ \ valueMappings:\n - path: participant.id\n from: bpn\n transform:
+ urlencode\n - path: iatp.id\n from: iatp_id\n - path: iatp.trustedIssuers\n
+ \ from: trustedIssuers\n mode: append\n - path: iatp.sts.dim.url\n
+ \ from: sts_dim_url\n - path: iatp.sts.oauth.token_url\n from:
+ sts_oauth_tokenUrl\n - path: iatp.sts.oauth.client.id\n from: sts_oauth_client_id\n
+ \ - path: iatp.sts.oauth.client.secret_alias\n from: sts_oauth_secretAlias\n
+ \ - path: imagePullSecrets\n from: imagePullSecrets\n - path:
+ controlplane.bdrs.server.url\n from: cp_bdrs_server_url\n - path:
+ controlplane.ingresses[0].enabled\n value: true\n - path: controlplane.ingresses[0].endpoints[0]\n
+ \ value: 'protocol'\n - path: controlplane.ingresses[0].endpoints[1]\n
+ \ value: 'management'\n - path: controlplane.ingresses[0].hostname\n
+ \ from: cp_hostname\n - path: controlplane.ingresses[0].certManager.clusterIssuer\n
+ \ value: 'letsencrypt-int'\n - path: controlplane.ingresses[0].className\n
+ \ value: 'nginx'\n - path: controlplane.ingresses[0].annotations\n
+ \ from: controlplane_annotations\n - path: controlplane.ingresses[0].tls.enabled\n
+ \ value: true\n - path: controlplane.ingresses[0].tls.secretName\n
+ \ template: \"{name}-cp-tls\"\n - path: dataplane.ingresses[0].enabled\n
+ \ value: true\n - path: dataplane.ingresses[0].hostname\n from:
+ dp_hostname\n - path: dataplane.ingresses[0].certManager.clusterIssuer\n
+ \ value: 'letsencrypt-int'\n - path: dataplane.ingresses[0].className\n
+ \ value: 'nginx'\n - path: dataplane.ingresses[0].annotations\n from:
+ dataplane_annotations\n - path: dataplane.ingresses[0].tls.enabled\n value:
+ true\n - path: dataplane.ingresses[0].tls.secretName\n template:
+ \"{name}-dp-tls\"\n - path: dataplane.token.signer.privatekey_alias\n value:
+ \"tokenSignerPrivateKey\"\n - path: dataplane.token.verifier.publickey_alias\n
+ \ value: \"tokenSignerPublicKey\"\n # The chart's jdbcUrl hardcodes
+ \"/edc\"; override it so the control plane\n # connects to the SAME database
+ Bitnami creates from auth.database below.\n # {name}-postgresql == \"{{
+ .Release.Name }}-postgresql\" (releaseName is {name}).\n - path: postgresql.jdbcUrl\n
+ \ template: \"jdbc:postgresql://{name}-postgresql:5432/{db_name}\"\n -
+ path: postgresql.auth.database\n from: db_name\n - path: postgresql.auth.username\n
+ \ from: auth.db_username\n - path: postgresql.auth.password\n from:
+ auth.db_password\n\n # Submodel Server — its own chart + release. Deployed
+ when present in the\n # request's `components` array. `url` is its ingress
+ host; `auth.*` its DB creds.\n submodelServer:\n releaseName: \"{name}\"
+ \ # the component's own name\n version: \"{version}\" #
+ the component's own version\n healthPath: \"/actuator/health/liveness\" #
+ reachability probe path appended to the ingress url\n chart:\n # This
+ config would be used for local deployment without Docker\n # name: simple-data-backend\n
+ \ # repo: tractusx-dev\n\n # Deploy from the LOCAL chart copy in config/
+ (patched with probe enable\n # toggles) instead of pulling simple-data-backend
+ from the repo. Having path issues on windows, \n # but should be working
+ inside container\n directory: config/simple-data-backend\n # Base values
+ come from config/submodelserver-templates (NOT the chart default).\n templatesDir:
+ config/submodelserver-templates\n versions:\n - version: \"0.1.0\"\n
+ \ valuesYaml: /values-0.1.0.yaml\n derive:\n # simple-data-backend
+ has NO certManager field — TLS is issued via the\n # cert-manager annotation.
+ external-dns points at this component's own host.\n annotations:\n external-dns.alpha.kubernetes.io/hostname:
+ \"{url}\"\n #cert-manager.io/cluster-issuer: \"letsencrypt-int\"\n valueMappings:\n
+ \ - path: fullnameOverride\n template: \"{name}\"\n - path:
+ ingress.enabled # chart default is false\n value: true\n -
+ path: ingress.className\n value: 'nginx'\n - path: ingress.annotations\n
+ \ from: annotations\n - path: ingress.hosts[0].host\n from:
+ url\n - path: ingress.hosts[0].paths[0].path\n value: '/'\n -
+ path: ingress.hosts[0].paths[0].pathType\n value: 'Prefix'\n - path:
+ ingress.tls[0].hosts[0]\n from: url\n - path: ingress.tls[0].secretName\n
+ \ template: \"{name}-submodel-tls\"\n # Liveness/readiness probes
+ — the local chart gates them on `.enabled`.\n # The image's /actuator/health/*
+ paths 404, so keep them off here.\n - path: livenessProbe.enabled\n value:
+ false\n - path: readinessProbe.enabled\n value: false\n\n # Digital
+ Twin Registry — its own chart + release. Deployed when present in the\n # request's
+ `components` array. `url` is its ingress host; `auth.*` its DB creds.\n digitalTwinRegistry:\n
+ \ releaseName: \"{name}\" # the component's own name\n version:
+ \"{version}\" # the component's own version\n healthPath: \"/semantics/registry/\"
+ \ # reachability probe path appended to the ingress url\n chart:\n name:
+ digital-twin-registry\n repo: tractusx-dev\n # Base values per version
+ come from the mounted registry-templates\n # ConfigMaps (backend.registry.templates),
+ like connector-templates.\n templatesDir: config/registry-templates\n #
+ Last 2 stable releases (latest first). Source: tractusx charts/dev index.\n
+ \ versions:\n - version: \"0.12.0\"\n valuesYaml: /values-0.12.0.yaml\n
+ \ - version: \"0.11.0\"\n valuesYaml: /values-0.11.0.yaml\n derive:\n
+ \ # digital-twin-registry issues TLS via the cert-manager annotation (no\n
+ \ # certManager field). external-dns points at this component's own host.\n
+ \ annotations:\n external-dns.alpha.kubernetes.io/hostname: \"{url}\"\n
+ \ #cert-manager.io/cluster-issuer: \"letsencrypt-int\"\n nginx.ingress.kubernetes.io/rewrite-target:
+ /$2\n nginx.ingress.kubernetes.io/use-regex: \"true\"\n nginx.ingress.kubernetes.io/cors-allow-credentials:
+ \"true\"\n nginx.ingress.kubernetes.io/x-forwarded-prefix: /semantics/registry\n
+ \ valueMappings:\n - path: fullnameOverride # top-level (the
+ chart has no registry.fullnameOverride)\n template: \"{name}\"\n -
+ path: enableKeycloak\n value: false\n # The chart ALWAYS renders
+ templates/keycloak/init-script-configmap.yaml as\n # \"{keycloak.fullnameOverride}-configmap\"
+ (not gated on enableKeycloak). The\n # chart default fullnameOverride is
+ the fixed \"registry-keycloak\", so two DTR\n # releases in one namespace
+ collide on \"registry-keycloak-configmap\". Make it\n # release-unique
+ so each DTR owns its own configmap.\n - path: keycloak.fullnameOverride\n
+ \ template: \"{name}-keycloak\"\n # Bundled postgres (like the connector).
+ With enablePostgres=true the chart\n # deploys its own DB and wires the
+ registry to it via postgres-init, so the\n # registry-secret's external
+ `dataSource` block is skipped entirely.\n - path: enablePostgres\n value:
+ true\n - path: postgresql.auth.username\n from: auth.db_username
+ \ # from the component's auth block\n - path: postgresql.auth.password\n
+ \ from: auth.db_password # empty -> chart generates a random
+ one\n - path: postgresql.auth.database\n from: db_name\n #
+ The init secret name (chart default \"secret-dtr-postgres-init\") is shared\n
+ \ # by the postgres-init hook, the registry deployment and the postgres\n
+ \ # subchart. It's a fixed literal, so two DTR releases collide on it —
+ make\n # it release-unique (all three consumers read this same value).\n
+ \ - path: postgresql.auth.existingSecret\n template: \"{name}-postgres-init\"\n
+ \ - path: registry.host # the externally-resolvable ingress
+ host\n from: url\n - path: registry.ingress.enabled\n value:
+ true\n - path: registry.ingress.urlPrefix\n value: '/semantics/registry'\n
+ \ - path: registry.ingress.className\n value: 'nginx'\n - path:
+ registry.ingress.annotations\n from: annotations\n - path: registry.ingress.tls.enabled
+ \ # tls is a MAP here, not a list\n value: true\n - path:
+ registry.ingress.tls.secretName\n template: \"{name}-dtr-tls\"\n\n #
+ IdentityHub — its own chart + release, deployed for every connector\n # (it
+ is the connector's own DID/credential service, not an optional add-on).\n #
+ identityhub:\n # deployWhen: always\n # releaseName: \"{name}-idhub\"\n
+ \ # chart:\n # name: tractusx-identityhub\n # repo: tractusx-dev\n
+ \ # # Last 3 stable releases (latest first; note the chart tags carry a 'v').\n
+ \ # # Source: tractusx charts/dev index.\n # versions:\n # - version:
+ \"v0.3.2\"\n # - version: \"v0.3.1\"\n # - version: \"v0.3.0\"\n #
+ \ valueMappings:\n # - path: fullnameOverride\n # template: \"{name}-idhub\"\n
+ \ # - path: ingress.hosts[0].host\n # template: \"{name}.int.construct-x.net\"\n
+ \ # - path: ingress.tls[0].hosts[0]\n # template: \"{name}.int.construct-x.net\"\n
+ \ # - path: ingress.tls[0].secretName\n # template: \"{name}-idhub-tls\"\n\ndatabase:\n
+ \ url: \"${DATABASE_URL}\"\n echo: false"
resources: {}
autoscaling:
enabled: false
@@ -199,7 +389,7 @@ frontend:
image:
repository: "harbor-hub-shared.arena.3ascloud.de/arena2036/emc-frontend"
pullPolicy: Always
- tag: "8505ebe061e96a4c07c5b2dde073f76bd7f2f0a0"
+ tag: "69514e64fce952878e0c584798f848a8db41407d"
imagePullSecrets:
- name: registry-creds
env:
diff --git a/charts/edc-management-console/values.yaml b/charts/edc-management-console/values.yaml
index c67b0b0..34090d5 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
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({
)}
+ {bpnMissing && (
+
+ {bpnRequired ? t('bpnMissingBlocking') : t('bpnMissingWarning')}
+
+ )}
+
+
+
+
+ {t('bpnHelp')}
+
+
+
|