From 30cf83b1bd0111a853163015b0ec9583992cd2df Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 6 Aug 2026 12:00:41 +0000 Subject: [PATCH 01/26] chore: update deployment image tags [skip ci] --- charts/edc-management-console/values-dev.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/edc-management-console/values-dev.yaml b/charts/edc-management-console/values-dev.yaml index 4c4e429..919e4bf 100644 --- a/charts/edc-management-console/values-dev.yaml +++ b/charts/edc-management-console/values-dev.yaml @@ -18,7 +18,7 @@ backend: image: repository: "harbor-hub-shared.arena.3ascloud.de/arena2036/emc-backend" pullPolicy: Always - tag: "06082026" + tag: "06082026-bpn" envFrom: - secretRef: name: registry-creds @@ -177,7 +177,7 @@ frontend: image: repository: "harbor-hub-shared.arena.3ascloud.de/arena2036/emc-frontend" pullPolicy: Always - tag: "06082026" + tag: "06082026-bpn" imagePullSecrets: - name: registry-creds env: From ec4be72f9b14bbf9c1cf8410323cfdce58b90554 Mon Sep 17 00:00:00 2001 From: Devaji Patil Date: Thu, 6 Aug 2026 15:14:08 +0200 Subject: [PATCH 02/26] fix backend startup failure on prod PVC --- .../templates/deployment-backend.yaml | 4 ++++ charts/edc-management-console/values-dev.yaml | 6 +++++- charts/edc-management-console/values-prod.yaml | 6 +++++- .../edc-management-console/values-staging.yaml | 6 +++++- charts/edc-management-console/values.yaml | 16 ++++++++++++++-- 5 files changed, 33 insertions(+), 5 deletions(-) diff --git a/charts/edc-management-console/templates/deployment-backend.yaml b/charts/edc-management-console/templates/deployment-backend.yaml index 573487b..a9b7e2f 100644 --- a/charts/edc-management-console/templates/deployment-backend.yaml +++ b/charts/edc-management-console/templates/deployment-backend.yaml @@ -12,6 +12,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 919e4bf..ee6a5c1 100644 --- a/charts/edc-management-console/values-dev.yaml +++ b/charts/edc-management-console/values-dev.yaml @@ -95,7 +95,11 @@ backend: minReplicas: 1 maxReplicas: 100 targetCPUUtilizationPercentage: 80 - podSecurityContext: {} + podSecurityContext: + runAsUser: 1000 + runAsGroup: 3000 + fsGroup: 3000 + fsGroupChangePolicy: OnRootMismatch securityContext: {} envFrom: [] env: diff --git a/charts/edc-management-console/values-prod.yaml b/charts/edc-management-console/values-prod.yaml index c7bd137..b812f1e 100644 --- a/charts/edc-management-console/values-prod.yaml +++ b/charts/edc-management-console/values-prod.yaml @@ -472,7 +472,11 @@ backend: minReplicas: 1 maxReplicas: 100 targetCPUUtilizationPercentage: 80 - podSecurityContext: {} + podSecurityContext: + runAsUser: 1000 + runAsGroup: 3000 + fsGroup: 3000 + fsGroupChangePolicy: OnRootMismatch securityContext: {} envFrom: [] diff --git a/charts/edc-management-console/values-staging.yaml b/charts/edc-management-console/values-staging.yaml index 6396eee..5e8990b 100644 --- a/charts/edc-management-console/values-staging.yaml +++ b/charts/edc-management-console/values-staging.yaml @@ -95,7 +95,11 @@ backend: minReplicas: 1 maxReplicas: 100 targetCPUUtilizationPercentage: 80 - podSecurityContext: {} + podSecurityContext: + runAsUser: 1000 + runAsGroup: 3000 + fsGroup: 3000 + fsGroupChangePolicy: OnRootMismatch securityContext: {} envFrom: [] env: diff --git a/charts/edc-management-console/values.yaml b/charts/edc-management-console/values.yaml index 2d64a39..03dcdab 100644 --- a/charts/edc-management-console/values.yaml +++ b/charts/edc-management-console/values.yaml @@ -12,6 +12,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 @@ -152,8 +154,18 @@ backend: maxReplicas: 100 targetCPUUtilizationPercentage: 80 # targetMemoryUtilizationPercentage: 80 - podSecurityContext: {} - # fsGroup: 2000 + ## Pod-level security context for the backend. + ## The backend image runs as USER 1000:3000 (see backend/Dockerfile) and the + ## sqlite database lives on the PVC mounted at /backend/data. A freshly + ## provisioned volume is mounted root:root, so without fsGroup the container + ## user cannot create edc_manager.db and startup fails with + ## "sqlite3.OperationalError: unable to open database file". + ## fsGroup makes the kubelet chown the volume to GID 3000 before mounting. + podSecurityContext: + runAsUser: 1000 + runAsGroup: 3000 + fsGroup: 3000 + fsGroupChangePolicy: OnRootMismatch securityContext: {} # capabilities: From bdf9a1f0930dba4a1dd05af50dc9d26a4120c013 Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 6 Aug 2026 13:20:09 +0000 Subject: [PATCH 03/26] chore: update deployment image tags [skip ci] --- charts/edc-management-console/values-dev.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/edc-management-console/values-dev.yaml b/charts/edc-management-console/values-dev.yaml index ee6a5c1..f182422 100644 --- a/charts/edc-management-console/values-dev.yaml +++ b/charts/edc-management-console/values-dev.yaml @@ -18,7 +18,7 @@ backend: image: repository: "harbor-hub-shared.arena.3ascloud.de/arena2036/emc-backend" pullPolicy: Always - tag: "06082026-bpn" + tag: "06082026-bpn-new" envFrom: - secretRef: name: registry-creds @@ -181,7 +181,7 @@ frontend: image: repository: "harbor-hub-shared.arena.3ascloud.de/arena2036/emc-frontend" pullPolicy: Always - tag: "06082026-bpn" + tag: "06082026-bpn-new" imagePullSecrets: - name: registry-creds env: From 111fd0e4c93771b53c81644a6a7be008184514b0 Mon Sep 17 00:00:00 2001 From: Devaji Patil Date: Thu, 6 Aug 2026 16:01:26 +0200 Subject: [PATCH 04/26] load keycloak config at runtime instead of baked placeholders --- frontend/.dockerignore | 5 +++++ frontend/entrypoint.sh | 2 +- frontend/index.html | 2 +- frontend/{src => public}/config.js | 0 frontend/src/auth/keycloak.ts | 14 +++++++++++++- frontend/src/runtime-config.ts | 18 +++++++++--------- 6 files changed, 29 insertions(+), 12 deletions(-) create mode 100644 frontend/.dockerignore rename frontend/{src => public}/config.js (100%) 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/entrypoint.sh b/frontend/entrypoint.sh index e0f5993..e4c4b98 100644 --- a/frontend/entrypoint.sh +++ b/frontend/entrypoint.sh @@ -4,7 +4,7 @@ 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 f5d5fca..bc197c2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,7 +7,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/auth/keycloak.ts b/frontend/src/auth/keycloak.ts index b669c66..b98f2c2 100644 --- a/frontend/src/auth/keycloak.ts +++ b/frontend/src/auth/keycloak.ts @@ -21,9 +21,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/runtime-config.ts b/frontend/src/runtime-config.ts index 5fedf7c..ffdb758 100644 --- a/frontend/src/runtime-config.ts +++ b/frontend/src/runtime-config.ts @@ -30,14 +30,14 @@ export function getRuntimeConfigValue( runtimeValue: string | undefined, fallback = '', ): string { - if (isUsableValue(envValue)) { - return envValue as string; - } - if (isUsableValue(runtimeValue)) { return runtimeValue as string; } + if (isUsableValue(envValue)) { + return envValue as string; + } + return fallback; } @@ -66,15 +66,15 @@ export function getRuntimeConfigBoolean( runtimeValue: boolean | string | undefined, fallback = false, ) { - const envBoolean = parseBoolean(envValue); - if (envBoolean !== undefined) { - return envBoolean; - } - const runtimeBoolean = parseBoolean(runtimeValue); if (runtimeBoolean !== undefined) { return runtimeBoolean; } + const envBoolean = parseBoolean(envValue); + if (envBoolean !== undefined) { + return envBoolean; + } + return fallback; } From 3f18871c86e0236d4ee23e6b4605d99f8bae1f3e Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 6 Aug 2026 14:06:03 +0000 Subject: [PATCH 05/26] chore: update deployment image tags [skip ci] --- charts/edc-management-console/values-dev.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/edc-management-console/values-dev.yaml b/charts/edc-management-console/values-dev.yaml index f182422..c478335 100644 --- a/charts/edc-management-console/values-dev.yaml +++ b/charts/edc-management-console/values-dev.yaml @@ -18,7 +18,7 @@ backend: image: repository: "harbor-hub-shared.arena.3ascloud.de/arena2036/emc-backend" pullPolicy: Always - tag: "06082026-bpn-new" + tag: "06082026-bpn-test" envFrom: - secretRef: name: registry-creds @@ -181,7 +181,7 @@ frontend: image: repository: "harbor-hub-shared.arena.3ascloud.de/arena2036/emc-frontend" pullPolicy: Always - tag: "06082026-bpn-new" + tag: "06082026-bpn-test" imagePullSecrets: - name: registry-creds env: From dbb3acd01e64dcd6fccd4e66e75c3cd1f8479739 Mon Sep 17 00:00:00 2001 From: Devaji Patil Date: Tue, 11 Aug 2026 11:10:51 +0200 Subject: [PATCH 06/26] feature: bpn from keycloak --- backend/auth/keycloak_config.py | 236 +++++++++++++++---- backend/config/configuration.yml | 11 + backend/init.py | 75 +++++- backend/tests/test_identity.py | 194 +++++++++++++++ frontend/src/AppNew.tsx | 170 +++---------- frontend/src/api/client.ts | 26 ++ frontend/src/auth/session.ts | 74 ++++++ frontend/src/components/DeploymentWizard.tsx | 38 ++- frontend/src/locales/de.json | 57 +++-- frontend/src/locales/en.json | 13 +- 10 files changed, 682 insertions(+), 212 deletions(-) create mode 100644 backend/tests/test_identity.py create mode 100644 frontend/src/auth/session.ts diff --git a/backend/auth/keycloak_config.py b/backend/auth/keycloak_config.py index 69349b0..c651f5e 100644 --- a/backend/auth/keycloak_config.py +++ b/backend/auth/keycloak_config.py @@ -1,60 +1,202 @@ -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, List, 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 or not self.is_configured: + logger.warning("[Keycloak] Signature verification is disabled; token is untrusted.") + return jwt.decode(token, key="", options={"verify_signature": False, + "verify_aud": False}) + + 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/config/configuration.yml b/backend/config/configuration.yml index c450880..1906332 100644 --- a/backend/config/configuration.yml +++ b/backend/config/configuration.yml @@ -23,6 +23,17 @@ dataspaceConfig: realm: "CX-Central" client_id: "${CENTRALIDP_CLIENT_ID}" client_secret: "${CENTRALIDP_CLIENT_SECRET}" + # Token verification and the caller's company identity. This realm is the one + # the BROWSER logs into (frontend VITE_KEYCLOAK_*), which is not necessarily + # `centralidp` above - that is the backend's own OAuth2 client for the discovery + # services and may point at another instance. Falls back to `centralidp` when + # unset; KEYCLOAK_URL / KEYCLOAK_REALM override it. + identity: + url: "https://centralidp.txcd.arena2036-x.de/auth/" + realm: "CX-Central" + # Hold every connector deployment to the caller's own BPN. Requires the + # realm's client to have a `bpn` claim mapper; off unless set here. + enforceSessionBpn: true ssi_wallet: url: "https://ssi-dim-wallet-stub.arena2036-x.de" client_id: "${SSI_WALLET_CLIENT_ID}" diff --git a/backend/init.py b/backend/init.py index 3b49742..90f0524 100644 --- a/backend/init.py +++ b/backend/init.py @@ -32,7 +32,7 @@ load_dotenv() from typing import Optional -from fastapi import FastAPI, Depends, Request +from fastapi import FastAPI, Depends, HTTPException, Request, status from fastapi.middleware.cors import CORSMiddleware from auth.keycloak_config import keycloak_openid @@ -401,6 +401,46 @@ 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 HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=( + "Your login does not provide a BPN, so a connector cannot be given a " + "dataspace identity. 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 HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=( + f"Connector '{comp.name}' was requested with BPN {requested}, but your " + f"account belongs to {session_bpn}. A connector can only be deployed " + "under your own BPN." + ), + ) + + comp.bpn = session_bpn + + async def _deploy_components(components, namespace): """Install-or-upgrade every component in the request (config-driven) and persist one DB row per component. A component with no `type`/`name` is an @@ -454,10 +494,14 @@ 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}) + except HTTPException as e: + logger.warning("[deploy] %s", e.detail) + return HttpUtils.get_error_response(status=e.status_code, message=str(e.detail)) except ComponentLimitExceeded as e: ## Expected, caller-correctable state — not a server fault, so no stack trace. logger.warning("[deploy] %s", e) @@ -476,11 +520,15 @@ 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", data={"upgraded": upgraded}) + except HTTPException as e: + logger.warning("[upgrade] %s", e.detail) + return HttpUtils.get_error_response(status=e.status_code, message=str(e.detail)) except ComponentLimitExceeded as e: logger.warning("[upgrade] %s", e) return HttpUtils.get_error_response(status=409, message=str(e)) @@ -690,14 +738,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", ""), @@ -740,6 +790,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"), @@ -771,6 +828,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/tests/test_identity.py b/backend/tests/test_identity.py new file mode 100644 index 0000000..883312f --- /dev/null +++ b/backend/tests/test_identity.py @@ -0,0 +1,194 @@ +"""Bearer-token verification and the company identity read from it. + +The case worth protecting is the one the previous implementation got wrong: it +decoded tokens with ``verify_signature: False``, so any self-made JWT was +believed — and its BPN is what a deployed connector gets stamped with. +``test_rejects_token_signed_by_another_key`` is that attack. +""" + +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 diff --git a/frontend/src/AppNew.tsx b/frontend/src/AppNew.tsx index 93a6a88..8ca4591 100644 --- a/frontend/src/AppNew.tsx +++ b/frontend/src/AppNew.tsx @@ -26,6 +26,7 @@ import DeploymentStatusModal from './components/DeploymentStatusModal'; 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'; @@ -48,6 +49,7 @@ type DeploymentFeedback = { interface DataspaceSettingsPayload { name?: string; + authority_bpn?: string; bpn?: string; realm?: string; username?: string; @@ -104,7 +106,7 @@ interface DataspaceSettingsPayload { interface DataspaceSummary { name: string; - bpn: string; + authorityBpn: string; details: DataspaceSettingsPayload | null; } @@ -126,88 +128,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) { @@ -334,14 +261,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:', error); return { name: fallbackName, - bpn: '', + authorityBpn: '', details: null, }; } @@ -431,13 +358,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); @@ -476,9 +403,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(); @@ -491,7 +418,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)) { @@ -742,7 +669,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} @@ -860,7 +787,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 } @@ -957,7 +885,7 @@ function Monitor() { const [activityLogs, setActivityLogs] = useState([]); const [dataspace, setDataspace] = useState({ name: t('dataspaceFallback'), - bpn: '', + authorityBpn: '', details: null, }); @@ -1150,7 +1078,7 @@ function Monitor() { {dataspace.name}

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

@@ -1479,10 +1407,10 @@ function ExternalAppRedirect({ function Settings({ onOpenGuide, - sessionBpn, + identity, }: { onOpenGuide: () => void; - sessionBpn: string; + identity: SessionIdentity; }) { const { t } = useI18n(); const [settingsLoaded, setSettingsLoaded] = useState(false); @@ -1492,15 +1420,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); @@ -1510,7 +1431,7 @@ function Settings({ }; loadSettings(); - }, [sessionBpn]); + }, []); const formatValue = (value?: string | boolean) => { if (typeof value === 'boolean') { @@ -1521,21 +1442,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 }, @@ -1547,7 +1474,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 }, ], }, @@ -1633,17 +1559,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 @@ -1674,26 +1598,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); } @@ -1745,7 +1653,7 @@ function AppShell() {
- } /> + } /> } /> setShowGuide(true)} - sessionBpn={sessionBpn} + identity={identity} /> )} /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e6cd551..5a8c44c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,5 +1,6 @@ import axios from 'axios'; import { getRuntimeConfigValue } from '../runtime-config'; +import keycloak, { isAuthDisabled } from '../auth/keycloak'; import type { DeployRequest } from '../types'; const backendUrl = getRuntimeConfigValue( @@ -32,6 +33,31 @@ export const apiClient = axios.create({ headers: apiClientHeaders, }); +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/session.ts b/frontend/src/auth/session.ts new file mode 100644 index 0000000..5a801c9 --- /dev/null +++ b/frontend/src/auth/session.ts @@ -0,0 +1,74 @@ +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 EMPTY_IDENTITY; + } + + try { + const response = await dataspaceApi.getDataspace(); + const session = response.data?.data?.session as Partial | undefined; + if (!session) { + return EMPTY_IDENTITY; + } + + return { + ...EMPTY_IDENTITY, + ...session, + bpn: (session.bpn ?? '').toUpperCase(), + }; + } catch (error) { + console.error('Failed to load the session identity:', error); + return EMPTY_IDENTITY; + } +} + +export function useSessionIdentity() { + const [identity, setIdentity] = useState(EMPTY_IDENTITY); + + useEffect(() => { + let active = true; + + fetchSessionIdentity().then((resolved) => { + if (!active) { + return; + } + + setIdentity(resolved); + if (!resolved.bpn) { + // Not an error — a realm may simply not map it. Each Keycloak client + // needs its own mappers, so this is the first thing to check when a + // different environment shows no company. + 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/DeploymentWizard.tsx b/frontend/src/components/DeploymentWizard.tsx index 35c1eab..8b4d482 100644 --- a/frontend/src/components/DeploymentWizard.tsx +++ b/frontend/src/components/DeploymentWizard.tsx @@ -23,6 +23,7 @@ interface Props { defaultVersion?: string; availableVersions?: string[]; prefilledBpn?: string; + bpnRequired?: boolean; defaultApiEndpoint?: string; defaultDataPlaneUrl?: string; controlPlaneHostSuffix?: string; @@ -55,6 +56,7 @@ export default function DeploymentWizard({ defaultVersion, availableVersions, prefilledBpn, + bpnRequired = false, defaultApiEndpoint, defaultDataPlaneUrl, controlPlaneHostSuffix, @@ -176,6 +178,8 @@ export default function DeploymentWizard({ [dataplaneHostnameSuffix, normalizedConnectorName], ); const connectorLimitReached = connectorCount >= MAX_CONNECTORS; + const bpnMissing = !resolvedBpn; + const blockedByMissingBpn = bpnRequired && bpnMissing; // The deployed connector lives at its own per-name host ("{name}-{suffix}"), which // is what the backend's cp_hostname/dp_hostname derive templates put on the Ingress. @@ -217,7 +221,12 @@ export default function DeploymentWizard({ const deployConnector = async () => { setSubmitted(true); - if (deploying || connectorLimitReached || Object.keys(stepErrors).length > 0) { + if ( + deploying || + connectorLimitReached || + blockedByMissingBpn || + Object.keys(stepErrors).length > 0 + ) { return; } @@ -269,6 +278,11 @@ export default function DeploymentWizard({ {t('connectorLimitReached', { max: String(MAX_CONNECTORS) })}
)} + {bpnMissing && ( +
+ {bpnRequired ? t('bpnMissingBlocking') : t('bpnMissingWarning')} +
+ )}
+
+ + +

+ {t('bpnHelp')} +

+
+