diff --git a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts index 8349f2fc7c..e6808d0584 100644 --- a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts +++ b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-hashicorp-vault/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation("org.eclipse.tractusx.edc:retirement-evaluation-store-sql:$txVersion") implementation("org.eclipse.tractusx.edc:control-plane-migration:$txVersion") implementation("org.eclipse.tractusx.edc:tx-dcp:$txVersion") + implementation(project(":edc-extensions:dynamic-issuers")) } tasks.withType { diff --git a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts index d0cd959721..01a2621b33 100644 --- a/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts +++ b/edc-controlplane/edc-controlplane-construct-x/con-x-controlplane-postgresql-vault/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation("org.eclipse.tractusx.edc:retirement-evaluation-store-sql:$txVersion") implementation("org.eclipse.tractusx.edc:control-plane-migration:$txVersion") implementation("org.eclipse.tractusx.edc:tx-dcp:$txVersion") + implementation(project(":edc-extensions:dynamic-issuers")) } tasks.withType { diff --git a/edc-extensions/dynamic-issuers/README.md b/edc-extensions/dynamic-issuers/README.md new file mode 100644 index 0000000000..50599d1602 --- /dev/null +++ b/edc-extensions/dynamic-issuers/README.md @@ -0,0 +1,64 @@ +## Dynamic trusted issuers extension + +This extension enables you to let your controlplane fetch information about trusted issuers. In order for this to work, +your dataspace organisation needs to have set up a trust server. In essence, this extension provides an alternative +implementation of a TrustedIssuerRegistry, which replaces the DefaultTrustedIssuerRegistry. + +You can provide the trust server's URL via an environment property like in this example: + +``` +edc.iam.trustserver.url=https://my-trustserver.com/trusted-issuers +``` + +You can omit this property entirely. In that case, no attempts to fetch updates will be made and this extension will +simply behave like the DefaultTrustedIssuerRegistry from the upstream EDC. + +Also note, that this extension does in general not interfere with the TrustedIssuerConfigurationExtension. I.e. you can +still define trusted issuers purely via your env-properties. Conflicts can only arise, when an issuer-did:web-id, which +you provided via property, is also known to the remote trusted issuer server. In that case, the information from the +remote server will take precedence and override anything conflicting that may have been given in your properties. + +The rationale is, that the remote server (which is administered by the dataspace governance organization) is expected to have more recent information. + +### The information payload + +The response body from the trust server is expected to be in this format: + +```json +{ + "interval": 30, + "issuerdata": [ + { + "id": "did:web:local-issuer-wallet:con-x-issuer", + "supportedTypes": [ + "https://my-domain.com/credentials/essential/MembershipCredential", + "https://my-domain.com/credentials/other/FooCredential" + ] + } + ] +} +``` + +At first, the body may contain an "interval" field. This allows the remote server to tell this extension, after how many +seconds a new update request should be made. The idea is, that the central trust server may have load-balancing-related +reasons to lower the intensity of incoming requests from his multiple clients. This field is however optional. If the trust +server chooses not to use this field, then the default interval (usually 2 hours) will be applied. + +Beyond that, the response body must always have an "issuerdata" field, that supplies an array of objects. Each of these +objects must have an "id" field, that states the did:web-id of one particular issuer. And it needs to have a "supportedTypes" field with an array value attached. This array must contain strings of Credential-Types, for which this issuer is authorized to create credentials. + +Note that an asterisk symbol ("*") in this array would be interpreted as a wildcard. I.e. an issuer-id that is equipped with +such a wild card is assumed to be allowed to create ANY CredentialType, so caution is advised here. + +### Configuring the default update interval + +You can override the above-mentioned default interval using this property: + +``` +edc.iam.trustserver.default.interval=3600 +``` +The given value sets the amount of seconds, i.e. 3600 seconds would equal one hour. Please note that this value will only +ever be used if the remote server chooses not to use to "interval" field in his response (see above). + +Aside from that, a special initialization interval of 30 seconds will be used on bootup, as long as your controlplane has not +managed to establish a connection to the trust server at least once. diff --git a/edc-extensions/dynamic-issuers/build.gradle.kts b/edc-extensions/dynamic-issuers/build.gradle.kts new file mode 100644 index 0000000000..0fb3401cc7 --- /dev/null +++ b/edc-extensions/dynamic-issuers/build.gradle.kts @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 Fraunhofer-Gesellschaft zur Foerderung der angewandten Forschung e.V. (represented by Fraunhofer ISST) + * + * 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 + */ + +plugins { + id("java") + id("application") +} + +repositories { mavenCentral() } + +val edcVersion = "0.15.1" + +dependencies { + implementation("org.eclipse.edc:core-spi:${edcVersion}") + implementation("org.eclipse.edc:verifiable-credential-spi:${edcVersion}") + + testImplementation("org.eclipse.edc:junit:${edcVersion}") { + exclude(group = "org.junit.jupiter") + exclude(group = "org.junit.platform") + exclude(group = "org.junit") + } +} + diff --git a/edc-extensions/dynamic-issuers/src/main/java/de/fraunhofer/isst/edc/extension/dynamic_issuers/DynamicIssuersExtension.java b/edc-extensions/dynamic-issuers/src/main/java/de/fraunhofer/isst/edc/extension/dynamic_issuers/DynamicIssuersExtension.java new file mode 100644 index 0000000000..376137c5e5 --- /dev/null +++ b/edc-extensions/dynamic-issuers/src/main/java/de/fraunhofer/isst/edc/extension/dynamic_issuers/DynamicIssuersExtension.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026 Fraunhofer-Gesellschaft zur Foerderung der angewandten Forschung e.V. (represented by Fraunhofer ISST) + * + * 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 + */ + +package de.fraunhofer.isst.edc.extension.dynamic_issuers; + +import org.eclipse.edc.iam.verifiablecredentials.spi.validation.TrustedIssuerRegistry; +import org.eclipse.edc.runtime.metamodel.annotation.Extension; +import org.eclipse.edc.runtime.metamodel.annotation.Provider; +import org.eclipse.edc.runtime.metamodel.annotation.Setting; +import org.eclipse.edc.spi.system.ServiceExtension; +import org.eclipse.edc.spi.system.ServiceExtensionContext; + +import java.net.URI; + +@Extension("Dynamic Issuers Registry Extension") +public class DynamicIssuersExtension implements ServiceExtension { + + public static final long DEFAULT_UPDATE_INTERVAL = 7200L; // 2 hours + + @Setting(description = "The trust server URL to be used", required = false, key = "edc.iam.trustserver.url") + private String trustServerUrl; + + @Setting(description = "The default interval between calls to the trust server", required = false, key = "edc.iam.trustserver.default.interval") + private String trustServerDefaultInterval; + + @Provider + public TrustedIssuerRegistry provideDynamicTrustedIssuerRegistry(ServiceExtensionContext context) { + var localMonitor = context.getMonitor().withPrefix(this.getClass().getSimpleName()); + URI serverUri; + try { + serverUri = URI.create(trustServerUrl); + } catch (Exception e) { + localMonitor.warning("Could not parse value of edc.iam.trustserver.url: " + trustServerUrl); + serverUri = null; + } + localMonitor.info("Using Trust Server: " + trustServerUrl); + + long defaultInterval; + try { + defaultInterval = Long.parseLong(trustServerDefaultInterval); + } catch (NumberFormatException e) { + defaultInterval = DEFAULT_UPDATE_INTERVAL; + } + localMonitor.info("Using Default Interval: " + defaultInterval + " seconds"); + + return new DynamicTrustedIssuerRegistry(serverUri, context.getMonitor(), defaultInterval); + } + + @Override + public String name() { + return "Dynamic Issuers Registry Extension"; + } +} diff --git a/edc-extensions/dynamic-issuers/src/main/java/de/fraunhofer/isst/edc/extension/dynamic_issuers/DynamicTrustedIssuerRegistry.java b/edc-extensions/dynamic-issuers/src/main/java/de/fraunhofer/isst/edc/extension/dynamic_issuers/DynamicTrustedIssuerRegistry.java new file mode 100644 index 0000000000..f5106fdfa0 --- /dev/null +++ b/edc-extensions/dynamic-issuers/src/main/java/de/fraunhofer/isst/edc/extension/dynamic_issuers/DynamicTrustedIssuerRegistry.java @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2026 Fraunhofer-Gesellschaft zur Foerderung der angewandten Forschung e.V. (represented by Fraunhofer ISST) + * + * 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 + */ + +package de.fraunhofer.isst.edc.extension.dynamic_issuers; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.eclipse.edc.iam.verifiablecredentials.spi.model.Issuer; +import org.eclipse.edc.iam.verifiablecredentials.spi.validation.TrustedIssuerRegistry; +import org.eclipse.edc.spi.monitor.Monitor; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +public class DynamicTrustedIssuerRegistry implements TrustedIssuerRegistry { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final TypeReference> STRING_SET_TYPEREFERENCE = new TypeReference<>() { + }; + private static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient(); + private static final ScheduledExecutorService SCHEDULER = + Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "dynamic-trusted-issuer-worker"); + t.setDaemon(true); + return t; + }); + + private final Map> store = new HashMap<>(); + private final Set externalIssuers = new HashSet<>(); + private final ReentrantReadWriteLock LOCK = new ReentrantReadWriteLock(); + + private final Monitor monitor; + private final URI trustedIssuerServer; + private final long defaultInterval; + private boolean completedInitialCall = false; + + public DynamicTrustedIssuerRegistry(URI trustedIssuerServer, Monitor monitor, long defaultInterval) { + this.defaultInterval = defaultInterval; + this.monitor = monitor.withPrefix(this.getClass().getSimpleName()); + this.trustedIssuerServer = trustedIssuerServer; + if (trustedIssuerServer != null) { + SCHEDULER.schedule(this::fetchUpdateFromServer, 0, TimeUnit.SECONDS); + } else { + monitor.warning("Trust Server URL is null, no updates will be fetched."); + } + + } + + @Override + public void register(Issuer issuer, String credentialType) { + try { + LOCK.writeLock().lock(); + monitor.debug("Registering " + issuer.id()); + store.computeIfAbsent(issuer.id(), k -> new HashSet<>()).add(credentialType); + } finally { + LOCK.writeLock().unlock(); + } + } + + @Override + public Set getSupportedTypes(Issuer issuer) { + try { + LOCK.readLock().lock(); + monitor.debug("Providing supported types request for " + issuer.id()); + return store.getOrDefault(issuer.id(), Set.of()); + } finally { + LOCK.readLock().unlock(); + } + } + + private void fetchUpdateFromServer() { + long newInterval = completedInitialCall ? defaultInterval : 30; + try { + HttpRequest request = HttpRequest.newBuilder().uri(trustedIssuerServer).GET().build(); + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() == 200) { + JsonNode responseJson = MAPPER.readTree(response.body()); + monitor.debug("Got response from trusted issuer server: \n" + responseJson.toPrettyString()); + try { + newInterval = responseJson.get("interval").asLong(); + } catch (Exception e) { + } + JsonNode issuerData = responseJson.get("issuerdata"); + handleUpdate(issuerData); + completedInitialCall = true; + } else { + monitor.warning("Unexpected response status from trusted issuer server " + response.statusCode()); + } + + } catch (java.net.ConnectException ce) { + monitor.warning("Failed to reach trusted issuer server " + trustedIssuerServer); + } catch (Exception e) { + monitor.warning("Unexpected error while trying to reach trusted issuer server", e); + } finally { + SCHEDULER.schedule(this::fetchUpdateFromServer, newInterval, TimeUnit.SECONDS); + monitor.debug("Next update scheduled in " + newInterval + " seconds"); + } + } + + private void handleUpdate(JsonNode update) { + try { + LOCK.writeLock().lock(); + Set foundIssuers = new HashSet<>(); + if (update.isArray()) { + for (var item : update) { + try { + String id = item.get("id").asText(); + Set types = MAPPER.convertValue(item.get("supportedTypes"), STRING_SET_TYPEREFERENCE); + foundIssuers.add(id); + externalIssuers.add(id); + store.put(id, types); + + } catch (Exception e) { + monitor.warning("Failure while handling array item: " + item.toPrettyString(), e); + } + } + Set delta = new HashSet<>(externalIssuers); + delta.removeAll(foundIssuers); + delta.forEach(id -> { + monitor.warning("Removing trusted issuer " + id); + store.remove(id); + }); + externalIssuers.removeAll(delta); + } else { + monitor.warning("Payload from trusted issuer server is not an array!"); + } + } finally { + LOCK.writeLock().unlock(); + } + } + + +} diff --git a/edc-extensions/dynamic-issuers/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension b/edc-extensions/dynamic-issuers/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension new file mode 100644 index 0000000000..1eb23079f0 --- /dev/null +++ b/edc-extensions/dynamic-issuers/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension @@ -0,0 +1,20 @@ +################################################################################# +# Copyright (c) 2026 Fraunhofer-Gesellschaft zur Foerderung der angewandten Forschung e.V. (represented by Fraunhofer ISST) +# +# 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 +################################################################################# + +de.fraunhofer.isst.edc.extension.dynamic_issuers.DynamicIssuersExtension \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 16e7eb06b3..f109865553 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -105,6 +105,8 @@ include(":edc-extensions:agreements:retirement-evaluation-api") include(":edc-extensions:agreements:retirement-evaluation-spi") include(":edc-extensions:agreements:retirement-evaluation-store-sql") +include(":edc-extensions:dynamic-issuers") + // extensions - data plane include(":edc-extensions:dataplane:dataplane-proxy:edc-dataplane-proxy-consumer-api") include(":edc-extensions:dataplane:dataplane-util")