diff --git a/.github/workflows/publish-context.yaml b/.github/workflows/publish-context.yaml index 08ef36f4345..57ed3d945d4 100644 --- a/.github/workflows/publish-context.yaml +++ b/.github/workflows/publish-context.yaml @@ -26,7 +26,7 @@ on: push: branches: [ main ] paths: - - 'core/common/lib/json-ld-lib/src/main/resources/document/**' + - 'core/common/lib/jsonld-lib/src/main/resources/document/**' - 'extensions/common/api/management-api-schema-validator/src/main/resources/schema/management/**' jobs: @@ -40,9 +40,9 @@ jobs: - name: copy contexts into public folder run: | mkdir -p public/context - cp core/common/lib/json-ld-lib/src/main/resources/document/management-context-v1.jsonld public/context/ - cp core/common/lib/json-ld-lib/src/main/resources/document/management-context-v2.jsonld public/context/ - cp core/common/lib/json-ld-lib/src/main/resources/document/dspace-edc-context-v1.jsonld public/context/ + cp core/common/lib/jsonld-lib/src/main/resources/document/management-context-v1.jsonld public/context/ + cp core/common/lib/jsonld-lib/src/main/resources/document/management-context-v2.jsonld public/context/ + cp core/common/lib/jsonld-lib/src/main/resources/document/dspace-edc-context-v1.jsonld public/context/ mkdir -p public/schema cp -r extensions/common/api/management-api-schema-validator/src/main/resources/schema/management public/schema/ - name: deploy to gh-pages diff --git a/core/common/lib/core-lib/src/main/java/org/eclipse/edc/api/management/schema/ManagementApiJsonSchema.java b/core/common/lib/core-lib/src/main/java/org/eclipse/edc/api/management/schema/ManagementApiJsonSchema.java index 4aefe350db7..a54cbfbb6eb 100644 --- a/core/common/lib/core-lib/src/main/java/org/eclipse/edc/api/management/schema/ManagementApiJsonSchema.java +++ b/core/common/lib/core-lib/src/main/java/org/eclipse/edc/api/management/schema/ManagementApiJsonSchema.java @@ -55,6 +55,7 @@ interface V4 { String ASSOCIATE_DATASPACE_PROFILE_CONTEXT = EDC_MGMT_V4_SCHEMA_PREFIX + "/associate-dataspace-profile-schema.json"; String DISCOVERY_REQUEST = EDC_MGMT_V4_SCHEMA_PREFIX + "/discovery-request-schema.json"; String DISCOVERY_RESPONSE = EDC_MGMT_V4_SCHEMA_PREFIX + "/discovery-response-schema.json"; + String DCP_SCOPE = EDC_MGMT_V4_SCHEMA_PREFIX + "/dcp-scope-schema.json"; static String version() { diff --git a/core/common/lib/jsonld-lib/src/main/resources/document/management-context-v2.jsonld b/core/common/lib/jsonld-lib/src/main/resources/document/management-context-v2.jsonld index 36d4a1279b9..0709c5cb6df 100644 --- a/core/common/lib/jsonld-lib/src/main/resources/document/management-context-v2.jsonld +++ b/core/common/lib/jsonld-lib/src/main/resources/document/management-context-v2.jsonld @@ -612,6 +612,15 @@ } } }, + "DcpScope": { + "@id": "edc:DcpScope", + "@context": { + "type": "edc:type", + "value": "edc:value", + "profile": "edc:profile", + "prefixMapping": "edc:prefixMapping" + } + }, "inForceDate": "edc:inForceDate", "ruleFunctions": { "@id": "edc:ruleFunctions", diff --git a/dist/bom/controlplane-virtual-feature-dcp-bom/build.gradle.kts b/dist/bom/controlplane-virtual-feature-dcp-bom/build.gradle.kts index 2571516325c..7c621fcc350 100644 --- a/dist/bom/controlplane-virtual-feature-dcp-bom/build.gradle.kts +++ b/dist/bom/controlplane-virtual-feature-dcp-bom/build.gradle.kts @@ -19,6 +19,7 @@ plugins { dependencies { api(project(":dist:bom:controlplane-feature-dcp-bom")) api(project(":extensions:common:iam:decentralized-claims:decentralized-claims-cel")) + api(project(":extensions:control-plane:api:management-api-v5:dcp-scope-api-v5")) } edcBuild { diff --git a/extensions/common/api/management-api-configuration/src/main/resources/management-api-version.json b/extensions/common/api/management-api-configuration/src/main/resources/management-api-version.json index 4ac46222223..922286e7848 100644 --- a/extensions/common/api/management-api-configuration/src/main/resources/management-api-version.json +++ b/extensions/common/api/management-api-configuration/src/main/resources/management-api-version.json @@ -14,7 +14,7 @@ { "version": "5.0.0-beta", "urlPath": "/v5beta", - "lastUpdated": "2026-06-11T16:00:00Z", + "lastUpdated": "2026-06-25T16:00:00Z", "maturity": "beta" } ] diff --git a/extensions/common/api/management-api-schema-validator/src/main/java/org/eclipse/edc/connector/api/management/schema/ManagementApiSchemaValidatorExtension.java b/extensions/common/api/management-api-schema-validator/src/main/java/org/eclipse/edc/connector/api/management/schema/ManagementApiSchemaValidatorExtension.java index 3a75178b480..582ddc9d12e 100644 --- a/extensions/common/api/management-api-schema-validator/src/main/java/org/eclipse/edc/connector/api/management/schema/ManagementApiSchemaValidatorExtension.java +++ b/extensions/common/api/management-api-schema-validator/src/main/java/org/eclipse/edc/connector/api/management/schema/ManagementApiSchemaValidatorExtension.java @@ -121,6 +121,7 @@ public class ManagementApiSchemaValidatorExtension implements ServiceExtension { put(CEL_EXPRESSION_TEST_REQUEST_TYPE_TERM, V4.CEL_EXPRESSION_TEST_REQUEST); put("AssociateDataspaceProfile", V4.ASSOCIATE_DATASPACE_PROFILE_CONTEXT); put("DiscoveryRequest", V4.DISCOVERY_REQUEST); + put("DcpScope", V4.DCP_SCOPE); } }; diff --git a/extensions/common/api/management-api-schema-validator/src/main/resources/schema/management/v4/dcp-scope-schema.json b/extensions/common/api/management-api-schema-validator/src/main/resources/schema/management/v4/dcp-scope-schema.json new file mode 100644 index 00000000000..6f2e2039726 --- /dev/null +++ b/extensions/common/api/management-api-schema-validator/src/main/resources/schema/management/v4/dcp-scope-schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "title": "DcpScopeSchema", + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/DcpScope" + } + ], + "$id": "https://w3id.org/edc/connector/management/schema/v4/dcp-scope-schema.json", + "definitions": { + "DcpScope": { + "type": "object", + "properties": { + "@context": { + "$ref": "https://w3id.org/edc/connector/management/schema/v4/context-schema.json" + }, + "@type": { + "type": "string", + "const": "DcpScope" + }, + "@id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "DEFAULT", + "POLICY" + ] + }, + "value": { + "type": "string" + }, + "profile": { + "type": "string" + }, + "prefixMapping": { + "type": "string" + } + }, + "required": [ + "@context", + "@type", + "value" + ] + } + } +} diff --git a/extensions/common/iam/decentralized-claims/decentralized-claims-core/src/main/java/org/eclipse/edc/iam/decentralizedclaims/core/scope/DcpScopeRegistryImpl.java b/extensions/common/iam/decentralized-claims/decentralized-claims-core/src/main/java/org/eclipse/edc/iam/decentralizedclaims/core/scope/DcpScopeRegistryImpl.java index 6fa39a162c1..64ee37c359c 100644 --- a/extensions/common/iam/decentralized-claims/decentralized-claims-core/src/main/java/org/eclipse/edc/iam/decentralizedclaims/core/scope/DcpScopeRegistryImpl.java +++ b/extensions/common/iam/decentralized-claims/decentralized-claims-core/src/main/java/org/eclipse/edc/iam/decentralizedclaims/core/scope/DcpScopeRegistryImpl.java @@ -20,10 +20,13 @@ import org.eclipse.edc.spi.query.Criterion; import org.eclipse.edc.spi.query.QuerySpec; import org.eclipse.edc.spi.result.ServiceResult; +import org.eclipse.edc.spi.result.StoreResult; import org.eclipse.edc.transaction.spi.TransactionContext; import java.util.List; +import static org.eclipse.edc.spi.query.Criterion.criterion; + /** * Implementation of {@link DcpScopeRegistry}. */ @@ -42,11 +45,41 @@ public ServiceResult register(DcpScope scope) { return transactionContext.execute(() -> store.save(scope).flatMap(ServiceResult::from)); } + @Override + public ServiceResult create(DcpScope scope) { + return transactionContext.execute(() -> findById(scope.getId()) + .compose(existing -> existing.isEmpty() + ? store.save(scope) + : StoreResult.alreadyExists("DcpScope with id %s already exists".formatted(scope.getId()))) + .flatMap(ServiceResult::from)); + } + + @Override + public ServiceResult update(DcpScope scope) { + return transactionContext.execute(() -> findById(scope.getId()) + .compose(existing -> existing.isEmpty() + ? StoreResult.notFound("DcpScope with id %s does not exist".formatted(scope.getId())) + : store.save(scope)) + .flatMap(ServiceResult::from)); + } + + @Override + public ServiceResult> query(QuerySpec spec) { + return transactionContext.execute(() -> store.query(spec).flatMap(ServiceResult::from)); + } + @Override public ServiceResult remove(String scopeId) { return transactionContext.execute(() -> store.delete(scopeId).flatMap(ServiceResult::from)); } + private StoreResult> findById(String id) { + var query = QuerySpec.Builder.newInstance() + .filter(criterion("id", "=", id)) + .build(); + return store.query(query); + } + @Override public ServiceResult> getDefaultScopes() { var query = QuerySpec.Builder.newInstance() diff --git a/extensions/common/iam/decentralized-claims/decentralized-claims-core/src/main/java/org/eclipse/edc/iam/decentralizedclaims/core/scope/defaults/InMemoryDcpScopeStore.java b/extensions/common/iam/decentralized-claims/decentralized-claims-core/src/main/java/org/eclipse/edc/iam/decentralizedclaims/core/scope/defaults/InMemoryDcpScopeStore.java index 8330a9cd273..fe8937377f2 100644 --- a/extensions/common/iam/decentralized-claims/decentralized-claims-core/src/main/java/org/eclipse/edc/iam/decentralizedclaims/core/scope/defaults/InMemoryDcpScopeStore.java +++ b/extensions/common/iam/decentralized-claims/decentralized-claims-core/src/main/java/org/eclipse/edc/iam/decentralizedclaims/core/scope/defaults/InMemoryDcpScopeStore.java @@ -48,10 +48,12 @@ public StoreResult save(DcpScope scope) { @Override public StoreResult delete(String scopeId) { - scopes.remove(scopeId); - return StoreResult.success(); + return scopes.remove(scopeId) != null + ? StoreResult.success() + : StoreResult.notFound(notFoundErrorMessage(scopeId)); } + @Override public StoreResult> query(QuerySpec spec) { return StoreResult.success(queryResolver.query(scopes.values().stream(), spec) diff --git a/extensions/common/iam/decentralized-claims/decentralized-claims-core/src/test/java/org/eclipse/edc/iam/decentralizedclaims/core/scope/DcpScopeRegistryImplTest.java b/extensions/common/iam/decentralized-claims/decentralized-claims-core/src/test/java/org/eclipse/edc/iam/decentralizedclaims/core/scope/DcpScopeRegistryImplTest.java index 8ee2ac2a588..6390d52c943 100644 --- a/extensions/common/iam/decentralized-claims/decentralized-claims-core/src/test/java/org/eclipse/edc/iam/decentralizedclaims/core/scope/DcpScopeRegistryImplTest.java +++ b/extensions/common/iam/decentralized-claims/decentralized-claims-core/src/test/java/org/eclipse/edc/iam/decentralizedclaims/core/scope/DcpScopeRegistryImplTest.java @@ -16,6 +16,7 @@ import org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope; import org.eclipse.edc.iam.decentralizedclaims.spi.scope.store.DcpScopeStore; +import org.eclipse.edc.spi.query.QuerySpec; import org.eclipse.edc.spi.result.StoreResult; import org.eclipse.edc.transaction.spi.NoopTransactionContext; import org.eclipse.edc.transaction.spi.TransactionContext; @@ -26,6 +27,7 @@ import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; import static org.mockito.Mockito.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -71,6 +73,69 @@ void register_should_return_failure_when_store_fails() { verify(store).save(scope); } + @Test + void create_should_save_when_scope_does_not_exist() { + var scope = DcpScope.Builder.newInstance().id("s1").value("v").profile("p").build(); + when(store.query(any())).thenReturn(StoreResult.success(List.of())); + when(store.save(scope)).thenReturn(StoreResult.success()); + + var impl = new DcpScopeRegistryImpl(transactionContext, store); + var res = impl.create(scope); + + assertThat(res).isSucceeded(); + verify(store).save(scope); + } + + @Test + void create_should_return_conflict_when_scope_already_exists() { + var scope = DcpScope.Builder.newInstance().id("s1").value("v").profile("p").build(); + when(store.query(any())).thenReturn(StoreResult.success(List.of(scope))); + + var impl = new DcpScopeRegistryImpl(transactionContext, store); + var res = impl.create(scope); + + assertThat(res).isFailed().detail().contains("already exists"); + verify(store, never()).save(any()); + } + + @Test + void update_should_save_when_scope_exists() { + var scope = DcpScope.Builder.newInstance().id("s1").value("v").profile("p").build(); + when(store.query(any())).thenReturn(StoreResult.success(List.of(scope))); + when(store.save(scope)).thenReturn(StoreResult.success()); + + var impl = new DcpScopeRegistryImpl(transactionContext, store); + var res = impl.update(scope); + + assertThat(res).isSucceeded(); + verify(store).save(scope); + } + + @Test + void update_should_return_not_found_when_scope_does_not_exist() { + var scope = DcpScope.Builder.newInstance().id("s1").value("v").profile("p").build(); + when(store.query(any())).thenReturn(StoreResult.success(List.of())); + + var impl = new DcpScopeRegistryImpl(transactionContext, store); + var res = impl.update(scope); + + assertThat(res).isFailed().detail().contains("does not exist"); + verify(store, never()).save(any()); + } + + @Test + void query_should_return_list_from_store() { + var s1 = DcpScope.Builder.newInstance().id("s1").value("v1").profile("p").build(); + var expected = List.of(s1); + when(store.query(any())).thenReturn(StoreResult.success(expected)); + + var impl = new DcpScopeRegistryImpl(transactionContext, store); + var res = impl.query(QuerySpec.max()); + + assertThat(res).isSucceeded().isEqualTo(expected); + verify(store).query(QuerySpec.max()); + } + @Test void remove_should_delegate_to_store_and_return_success() { when(store.delete("id")).thenReturn(StoreResult.success()); diff --git a/extensions/common/iam/decentralized-claims/decentralized-claims-store-sql/src/main/java/org/eclipse/edc/iam/decentralizedclaims/store/sql/SqlDcpScopeStore.java b/extensions/common/iam/decentralized-claims/decentralized-claims-store-sql/src/main/java/org/eclipse/edc/iam/decentralizedclaims/store/sql/SqlDcpScopeStore.java index 05b19584afb..75992d21329 100644 --- a/extensions/common/iam/decentralized-claims/decentralized-claims-store-sql/src/main/java/org/eclipse/edc/iam/decentralizedclaims/store/sql/SqlDcpScopeStore.java +++ b/extensions/common/iam/decentralized-claims/decentralized-claims-store-sql/src/main/java/org/eclipse/edc/iam/decentralizedclaims/store/sql/SqlDcpScopeStore.java @@ -71,7 +71,10 @@ public StoreResult save(DcpScope scope) { public StoreResult delete(String scopeId) { return transactionContext.execute(() -> { try (var connection = getConnection()) { - queryExecutor.execute(connection, statements.getDeleteTemplate(), scopeId); + var deleted = queryExecutor.execute(connection, statements.getDeleteTemplate(), scopeId); + if (deleted == 0) { + return StoreResult.notFound("DcpScope with id %s not found".formatted(scopeId)); + } return StoreResult.success(); } catch (SQLException e) { throw new EdcPersistenceException(e); diff --git a/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/build.gradle.kts b/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/build.gradle.kts new file mode 100644 index 00000000000..fa7804ec501 --- /dev/null +++ b/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/build.gradle.kts @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026 Metaform Systems, Inc. + * + * 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 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +plugins { + `java-library` + id(libs.plugins.swagger.get().pluginId) +} + +dependencies { + api(project(":spi:core-spi")) + api(project(":spi:control-plane-spi")) + api(project(":spi:decentralized-claims-spi")) + + implementation(project(":core:common:lib:core-lib")) + implementation(libs.jakarta.rsApi) + implementation(libs.jakarta.annotation) + + testImplementation(testFixtures(project(":extensions:common:http:jersey-core"))) + testImplementation(testFixtures(project(":core:common:lib:jsonld-lib"))) + testImplementation(libs.restAssured) + testImplementation(libs.awaitility) +} + +edcBuild { + swagger { + apiGroup("management-api") + } +} diff --git a/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/main/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/DcpScopeApiV5Extension.java b/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/main/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/DcpScopeApiV5Extension.java new file mode 100644 index 00000000000..573cde52a21 --- /dev/null +++ b/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/main/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/DcpScopeApiV5Extension.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026 Metaform Systems, Inc. + * + * 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 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.connector.controlplane.api.management.dcpscope; + +import jakarta.json.Json; +import org.eclipse.edc.api.management.schema.ManagementApiJsonSchema; +import org.eclipse.edc.connector.controlplane.api.management.dcpscope.transform.JsonObjectFromDcpScopeTransformer; +import org.eclipse.edc.connector.controlplane.api.management.dcpscope.transform.JsonObjectToDcpScopeTransformer; +import org.eclipse.edc.connector.controlplane.api.management.dcpscope.v5.DcpScopeApiV5Controller; +import org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScopeRegistry; +import org.eclipse.edc.jsonld.spi.JsonLd; +import org.eclipse.edc.runtime.metamodel.annotation.Extension; +import org.eclipse.edc.runtime.metamodel.annotation.Inject; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.system.ServiceExtension; +import org.eclipse.edc.spi.system.ServiceExtensionContext; +import org.eclipse.edc.spi.types.TypeManager; +import org.eclipse.edc.transform.spi.TypeTransformerRegistry; +import org.eclipse.edc.validator.spi.JsonObjectValidatorRegistry; +import org.eclipse.edc.web.jersey.providers.jsonld.JerseyJsonLdInterceptor; +import org.eclipse.edc.web.spi.WebService; +import org.eclipse.edc.web.spi.configuration.ApiContext; + +import java.util.Map; + +import static org.eclipse.edc.api.management.ManagementApi.MANAGEMENT_SCOPE_V4; +import static org.eclipse.edc.connector.controlplane.api.management.dcpscope.DcpScopeApiV5Extension.NAME; +import static org.eclipse.edc.spi.constants.CoreConstants.JSON_LD; + +@Extension(value = NAME) +public class DcpScopeApiV5Extension implements ServiceExtension { + + public static final String NAME = "DcpScope Management API Extension"; + + @Inject + private WebService webService; + + @Inject + private TypeTransformerRegistry transformerRegistry; + + @Inject + private JsonObjectValidatorRegistry validatorRegistry; + + @Inject + private JsonLd jsonLd; + + @Inject + private TypeManager typeManager; + + @Inject + private DcpScopeRegistry scopeRegistry; + + @Inject + private Monitor monitor; + + @Override + public void initialize(ServiceExtensionContext context) { + var factory = Json.createBuilderFactory(Map.of()); + + var managementApiTransformerRegistry = transformerRegistry.forContext("management-api"); + + managementApiTransformerRegistry.register(new JsonObjectToDcpScopeTransformer()); + managementApiTransformerRegistry.register(new JsonObjectFromDcpScopeTransformer(factory)); + + webService.registerResource(ApiContext.MANAGEMENT, new DcpScopeApiV5Controller(scopeRegistry, managementApiTransformerRegistry, monitor)); + webService.registerDynamicResource(ApiContext.MANAGEMENT, DcpScopeApiV5Controller.class, + new JerseyJsonLdInterceptor(jsonLd, typeManager, JSON_LD, MANAGEMENT_SCOPE_V4, validatorRegistry, ManagementApiJsonSchema.V4.version())); + } +} diff --git a/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/main/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/transform/JsonObjectFromDcpScopeTransformer.java b/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/main/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/transform/JsonObjectFromDcpScopeTransformer.java new file mode 100644 index 00000000000..73a194ace91 --- /dev/null +++ b/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/main/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/transform/JsonObjectFromDcpScopeTransformer.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 Metaform Systems, Inc. + * + * 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 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.connector.controlplane.api.management.dcpscope.transform; + +import jakarta.json.JsonBuilderFactory; +import jakarta.json.JsonObject; +import org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope; +import org.eclipse.edc.jsonld.spi.transformer.AbstractJsonLdTransformer; +import org.eclipse.edc.transform.spi.TransformerContext; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import static org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope.DCP_SCOPE_PREFIX_MAPPING_IRI; +import static org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope.DCP_SCOPE_PROFILE_IRI; +import static org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope.DCP_SCOPE_TYPE_IRI; +import static org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope.DCP_SCOPE_TYPE_PROPERTY_IRI; +import static org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope.DCP_SCOPE_VALUE_IRI; +import static org.eclipse.edc.jsonld.spi.JsonLdKeywords.ID; +import static org.eclipse.edc.jsonld.spi.JsonLdKeywords.TYPE; + +public class JsonObjectFromDcpScopeTransformer extends AbstractJsonLdTransformer { + + private final JsonBuilderFactory jsonFactory; + + public JsonObjectFromDcpScopeTransformer(JsonBuilderFactory jsonFactory) { + super(DcpScope.class, JsonObject.class); + this.jsonFactory = jsonFactory; + } + + @Override + public @Nullable JsonObject transform(@NotNull DcpScope scope, @NotNull TransformerContext context) { + var builder = jsonFactory.createObjectBuilder() + .add(TYPE, DCP_SCOPE_TYPE_IRI) + .add(ID, scope.getId()) + .add(DCP_SCOPE_VALUE_IRI, scope.getValue()) + .add(DCP_SCOPE_PROFILE_IRI, scope.getProfile()) + .add(DCP_SCOPE_TYPE_PROPERTY_IRI, scope.getType().name()); + + if (scope.getPrefixMapping() != null) { + builder.add(DCP_SCOPE_PREFIX_MAPPING_IRI, scope.getPrefixMapping()); + } + + return builder.build(); + } +} diff --git a/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/main/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/transform/JsonObjectToDcpScopeTransformer.java b/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/main/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/transform/JsonObjectToDcpScopeTransformer.java new file mode 100644 index 00000000000..78e769f6b2c --- /dev/null +++ b/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/main/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/transform/JsonObjectToDcpScopeTransformer.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 Metaform Systems, Inc. + * + * 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 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.connector.controlplane.api.management.dcpscope.transform; + +import jakarta.json.JsonObject; +import org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope; +import org.eclipse.edc.jsonld.spi.transformer.AbstractJsonLdTransformer; +import org.eclipse.edc.transform.spi.TransformerContext; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import static org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope.DCP_SCOPE_PREFIX_MAPPING_IRI; +import static org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope.DCP_SCOPE_PROFILE_IRI; +import static org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope.DCP_SCOPE_TYPE_PROPERTY_IRI; +import static org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope.DCP_SCOPE_VALUE_IRI; + +public class JsonObjectToDcpScopeTransformer extends AbstractJsonLdTransformer { + + public JsonObjectToDcpScopeTransformer() { + super(JsonObject.class, DcpScope.class); + } + + @Override + public @Nullable DcpScope transform(@NotNull JsonObject jsonObject, @NotNull TransformerContext context) { + var builder = DcpScope.Builder.newInstance(); + + var id = nodeId(jsonObject); + if (id != null) { + builder.id(id); + } + + var value = jsonObject.get(DCP_SCOPE_VALUE_IRI); + if (value != null) { + transformString(value, builder::value, context); + } + + var profile = jsonObject.get(DCP_SCOPE_PROFILE_IRI); + if (profile != null) { + transformString(profile, builder::profile, context); + } + + var prefixMapping = jsonObject.get(DCP_SCOPE_PREFIX_MAPPING_IRI); + if (prefixMapping != null) { + transformString(prefixMapping, builder::prefixMapping, context); + } + + var type = jsonObject.get(DCP_SCOPE_TYPE_PROPERTY_IRI); + if (type != null) { + transformString(type, t -> builder.type(DcpScope.Type.valueOf(t)), context); + } + + try { + return builder.build(); + } catch (RuntimeException e) { + context.reportProblem("Invalid DcpScope: " + e.getMessage()); + return null; + } + } +} diff --git a/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/main/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/v5/DcpScopeApiV5.java b/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/main/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/v5/DcpScopeApiV5.java new file mode 100644 index 00000000000..8824508d56c --- /dev/null +++ b/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/main/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/v5/DcpScopeApiV5.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026 Metaform Systems, Inc. + * + * 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 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.connector.controlplane.api.management.dcpscope.v5; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.json.JsonArray; +import jakarta.json.JsonObject; +import org.eclipse.edc.api.management.schema.ManagementApiJsonSchema; + +@OpenAPIDefinition(info = @Info(title = "DcpScope Management API", version = "v5beta")) +@Tag(name = "DcpScope v5beta") +public interface DcpScopeApiV5 { + + @Operation(description = "Creates a new DcpScope object.", + requestBody = @RequestBody(content = @Content(schema = @Schema(ref = ManagementApiJsonSchema.V4.DCP_SCOPE), mediaType = "application/json")), + responses = { + @ApiResponse(responseCode = "200", description = "The DcpScope was created successfully, its id is returned in the response body.", + content = @Content(schema = @Schema(ref = ManagementApiJsonSchema.V4.ID_RESPONSE))), + @ApiResponse(responseCode = "400", description = "Request body was malformed, or the request could not be processed", + content = @Content(array = @ArraySchema(schema = @Schema(ref = ManagementApiJsonSchema.V4.API_ERROR)), mediaType = "application/json")), + @ApiResponse(responseCode = "401", description = "The request could not be completed, because either the authentication was missing or was not valid.", + content = @Content(array = @ArraySchema(schema = @Schema(ref = ManagementApiJsonSchema.V4.API_ERROR)), mediaType = "application/json")), + @ApiResponse(responseCode = "409", description = "Can't create the DcpScope, because an object with the same ID already exists", + content = @Content(array = @ArraySchema(schema = @Schema(ref = ManagementApiJsonSchema.V4.API_ERROR)), mediaType = "application/json")) + } + ) + JsonObject createDcpScopeV5(JsonObject request); + + @Operation(description = "Updates an existing DcpScope object.", + requestBody = @RequestBody(content = @Content(schema = @Schema(ref = ManagementApiJsonSchema.V4.DCP_SCOPE), mediaType = "application/json")), + responses = { + @ApiResponse(responseCode = "204", description = "The DcpScope was updated successfully."), + @ApiResponse(responseCode = "400", description = "Request body was malformed, or the request could not be processed", + content = @Content(array = @ArraySchema(schema = @Schema(ref = ManagementApiJsonSchema.V4.API_ERROR)), mediaType = "application/json")), + @ApiResponse(responseCode = "401", description = "The request could not be completed, because either the authentication was missing or was not valid.", + content = @Content(array = @ArraySchema(schema = @Schema(ref = ManagementApiJsonSchema.V4.API_ERROR)), mediaType = "application/json")), + @ApiResponse(responseCode = "404", description = "A DcpScope with the given ID does not exist.", + content = @Content(array = @ArraySchema(schema = @Schema(ref = ManagementApiJsonSchema.V4.API_ERROR)), mediaType = "application/json")) + } + ) + void updateDcpScopeV5(String id, JsonObject request); + + @Operation(description = "Deletes a DcpScope.", + responses = { + @ApiResponse(responseCode = "204", description = "The DcpScope was deleted successfully"), + @ApiResponse(responseCode = "401", description = "The request could not be completed, because either the authentication was missing or was not valid.", + content = @Content(array = @ArraySchema(schema = @Schema(ref = ManagementApiJsonSchema.V4.API_ERROR)), mediaType = "application/json")), + @ApiResponse(responseCode = "404", description = "A DcpScope with the given ID does not exist.", + content = @Content(array = @ArraySchema(schema = @Schema(ref = ManagementApiJsonSchema.V4.API_ERROR)), mediaType = "application/json")) + } + ) + void deleteDcpScopeV5(String id); + + @Operation(description = "Returns all DcpScope objects according to a query.", + requestBody = @RequestBody(content = @Content(schema = @Schema(ref = ManagementApiJsonSchema.V4.QUERY_SPEC))), + responses = { + @ApiResponse(responseCode = "200", description = "The list of DcpScopes.", + content = @Content(array = @ArraySchema(schema = @Schema(ref = ManagementApiJsonSchema.V4.DCP_SCOPE)))), + @ApiResponse(responseCode = "400", description = "The query was malformed or was not understood by the server.", + content = @Content(array = @ArraySchema(schema = @Schema(ref = ManagementApiJsonSchema.V4.API_ERROR)), mediaType = "application/json")), + @ApiResponse(responseCode = "401", description = "The request could not be completed, because either the authentication was missing or was not valid.", + content = @Content(array = @ArraySchema(schema = @Schema(ref = ManagementApiJsonSchema.V4.API_ERROR)), mediaType = "application/json")) + } + ) + JsonArray queryDcpScopesV5(JsonObject querySpecJson); +} diff --git a/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/main/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/v5/DcpScopeApiV5Controller.java b/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/main/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/v5/DcpScopeApiV5Controller.java new file mode 100644 index 00000000000..4dc2b297baf --- /dev/null +++ b/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/main/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/v5/DcpScopeApiV5Controller.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026 Metaform Systems, Inc. + * + * 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 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.connector.controlplane.api.management.dcpscope.v5; + +import jakarta.json.JsonArray; +import jakarta.json.JsonObject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import org.eclipse.edc.api.auth.spi.RequiredScope; +import org.eclipse.edc.api.model.IdResponse; +import org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope; +import org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScopeRegistry; +import org.eclipse.edc.spi.EdcException; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.spi.result.Result; +import org.eclipse.edc.transform.spi.TypeTransformerRegistry; +import org.eclipse.edc.web.spi.exception.InvalidRequestException; +import org.eclipse.edc.web.spi.validation.SchemaType; + +import static jakarta.json.stream.JsonCollectors.toJsonArray; +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; +import static org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope.DCP_SCOPE_TYPE_TERM; +import static org.eclipse.edc.spi.query.QuerySpec.EDC_QUERY_SPEC_TYPE_TERM; +import static org.eclipse.edc.web.spi.exception.ServiceResultHandler.exceptionMapper; + +@Consumes(APPLICATION_JSON) +@Produces(APPLICATION_JSON) +@Path("/v5beta/dcpscopes") +public class DcpScopeApiV5Controller implements DcpScopeApiV5 { + + private final DcpScopeRegistry scopeRegistry; + private final TypeTransformerRegistry transformerRegistry; + private final Monitor monitor; + + public DcpScopeApiV5Controller(DcpScopeRegistry scopeRegistry, TypeTransformerRegistry transformerRegistry, Monitor monitor) { + this.scopeRegistry = scopeRegistry; + this.transformerRegistry = transformerRegistry; + this.monitor = monitor; + } + + @POST + @RequiredScope("management-api:admin") + @Override + public JsonObject createDcpScopeV5(@SchemaType(DCP_SCOPE_TYPE_TERM) JsonObject request) { + var scope = transformerRegistry.transform(request, DcpScope.class) + .orElseThrow(InvalidRequestException::new); + + scopeRegistry.create(scope) + .orElseThrow(exceptionMapper(DcpScope.class, scope.getId())); + + var idResponse = IdResponse.Builder.newInstance() + .id(scope.getId()) + .build(); + + return transformerRegistry.transform(idResponse, JsonObject.class) + .orElseThrow(f -> new EdcException("Error creating response body: " + f.getFailureDetail())); + } + + @PUT + @Path("{id}") + @RequiredScope("management-api:admin") + @Override + public void updateDcpScopeV5(@PathParam("id") String id, @SchemaType(DCP_SCOPE_TYPE_TERM) JsonObject request) { + var scope = transformerRegistry.transform(request, DcpScope.class) + .orElseThrow(InvalidRequestException::new); + + scopeRegistry.update(scope) + .orElseThrow(exceptionMapper(DcpScope.class, id)); + } + + @DELETE + @Path("{id}") + @RequiredScope("management-api:admin") + @Override + public void deleteDcpScopeV5(@PathParam("id") String id) { + scopeRegistry.remove(id) + .orElseThrow(exceptionMapper(DcpScope.class, id)); + } + + @POST + @Path("/request") + @RequiredScope("management-api:admin") + @Override + public JsonArray queryDcpScopesV5(@SchemaType(EDC_QUERY_SPEC_TYPE_TERM) JsonObject querySpecJson) { + QuerySpec querySpec; + if (querySpecJson == null) { + querySpec = QuerySpec.Builder.newInstance().build(); + } else { + querySpec = transformerRegistry.transform(querySpecJson, QuerySpec.class) + .orElseThrow(InvalidRequestException::new); + } + + return scopeRegistry.query(querySpec) + .orElseThrow(exceptionMapper(DcpScope.class, null)).stream() + .map(scope -> transformerRegistry.transform(scope, JsonObject.class)) + .peek(r -> r.onFailure(f -> monitor.warning(f.getFailureDetail()))) + .filter(Result::succeeded) + .map(Result::getContent) + .collect(toJsonArray()); + } +} diff --git a/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension b/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension new file mode 100644 index 00000000000..f815100bfcf --- /dev/null +++ b/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension @@ -0,0 +1,15 @@ +# +# Copyright (c) 2026 Metaform Systems, Inc. +# +# 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 +# +# SPDX-License-Identifier: Apache-2.0 +# +# Contributors: +# Metaform Systems, Inc. - initial API and implementation +# +# + +org.eclipse.edc.connector.controlplane.api.management.dcpscope.DcpScopeApiV5Extension diff --git a/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/test/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/transform/JsonObjectFromDcpScopeTransformerTest.java b/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/test/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/transform/JsonObjectFromDcpScopeTransformerTest.java new file mode 100644 index 00000000000..96099be7210 --- /dev/null +++ b/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/test/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/transform/JsonObjectFromDcpScopeTransformerTest.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026 Metaform Systems, Inc. + * + * 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 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.connector.controlplane.api.management.dcpscope.transform; + +import jakarta.json.Json; +import org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope; +import org.eclipse.edc.transform.spi.TransformerContext; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope.DCP_SCOPE_PREFIX_MAPPING_IRI; +import static org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope.DCP_SCOPE_PROFILE_IRI; +import static org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope.DCP_SCOPE_TYPE_IRI; +import static org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope.DCP_SCOPE_TYPE_PROPERTY_IRI; +import static org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope.DCP_SCOPE_VALUE_IRI; +import static org.eclipse.edc.jsonld.spi.JsonLdKeywords.ID; +import static org.eclipse.edc.jsonld.spi.JsonLdKeywords.TYPE; +import static org.mockito.Mockito.mock; + +class JsonObjectFromDcpScopeTransformerTest { + + private final TransformerContext context = mock(); + private final JsonObjectFromDcpScopeTransformer transformer = + new JsonObjectFromDcpScopeTransformer(Json.createBuilderFactory(Map.of())); + + @Test + void transform_defaultScope() { + var scope = DcpScope.Builder.newInstance().id("scope-1").value("org.example.scope").profile("profile-1").build(); + + var result = transformer.transform(scope, context); + + assertThat(result).isNotNull(); + assertThat(result.getString(ID)).isEqualTo("scope-1"); + assertThat(result.getString(TYPE)).isEqualTo(DCP_SCOPE_TYPE_IRI); + assertThat(result.getString(DCP_SCOPE_VALUE_IRI)).isEqualTo("org.example.scope"); + assertThat(result.getString(DCP_SCOPE_PROFILE_IRI)).isEqualTo("profile-1"); + assertThat(result.getString(DCP_SCOPE_TYPE_PROPERTY_IRI)).isEqualTo("DEFAULT"); + assertThat(result.containsKey(DCP_SCOPE_PREFIX_MAPPING_IRI)).isFalse(); + } + + @Test + void transform_policyScope() { + var scope = DcpScope.Builder.newInstance().id("scope-2").value("org.example.scope") + .type(DcpScope.Type.POLICY).prefixMapping("mapping").build(); + + var result = transformer.transform(scope, context); + + assertThat(result).isNotNull(); + assertThat(result.getString(DCP_SCOPE_TYPE_PROPERTY_IRI)).isEqualTo("POLICY"); + assertThat(result.getString(DCP_SCOPE_PREFIX_MAPPING_IRI)).isEqualTo("mapping"); + } +} diff --git a/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/test/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/transform/JsonObjectToDcpScopeTransformerTest.java b/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/test/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/transform/JsonObjectToDcpScopeTransformerTest.java new file mode 100644 index 00000000000..8a68fdf7aa2 --- /dev/null +++ b/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/test/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/transform/JsonObjectToDcpScopeTransformerTest.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 Metaform Systems, Inc. + * + * 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 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.connector.controlplane.api.management.dcpscope.transform; + +import jakarta.json.Json; +import jakarta.json.JsonObject; +import org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope; +import org.eclipse.edc.transform.spi.TransformerContext; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope.DCP_SCOPE_TYPE_TERM; +import static org.eclipse.edc.jsonld.spi.JsonLdKeywords.ID; +import static org.eclipse.edc.jsonld.spi.JsonLdKeywords.TYPE; +import static org.eclipse.edc.jsonld.test.TestJsonLd.expand; +import static org.eclipse.edc.spi.constants.CoreConstants.EDC_NAMESPACE; +import static org.mockito.Mockito.mock; + +class JsonObjectToDcpScopeTransformerTest { + + private final TransformerContext context = mock(); + private final JsonObjectToDcpScopeTransformer transformer = new JsonObjectToDcpScopeTransformer(); + + @Test + void types() { + assertThat(transformer.getInputType()).isEqualTo(JsonObject.class); + assertThat(transformer.getOutputType()).isEqualTo(DcpScope.class); + } + + @Test + void transform_defaultScope() { + var json = Json.createObjectBuilder() + .add("@context", Json.createObjectBuilder().add("@vocab", EDC_NAMESPACE)) + .add(TYPE, DCP_SCOPE_TYPE_TERM) + .add(ID, "scope-1") + .add("value", "org.example.scope") + .add("profile", "profile-1") + .build(); + + var result = transformer.transform(expand(json), context); + + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo("scope-1"); + assertThat(result.getValue()).isEqualTo("org.example.scope"); + assertThat(result.getProfile()).isEqualTo("profile-1"); + assertThat(result.getType()).isEqualTo(DcpScope.Type.DEFAULT); + assertThat(result.getPrefixMapping()).isNull(); + } + + @Test + void transform_policyScope() { + var json = Json.createObjectBuilder() + .add("@context", Json.createObjectBuilder().add("@vocab", EDC_NAMESPACE)) + .add(TYPE, DCP_SCOPE_TYPE_TERM) + .add(ID, "scope-2") + .add("value", "org.example.scope") + .add("type", "POLICY") + .add("prefixMapping", "mapping") + .build(); + + var result = transformer.transform(expand(json), context); + + assertThat(result).isNotNull(); + assertThat(result.getType()).isEqualTo(DcpScope.Type.POLICY); + assertThat(result.getPrefixMapping()).isEqualTo("mapping"); + } +} diff --git a/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/test/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/v5/DcpScopeApiV5ControllerTest.java b/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/test/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/v5/DcpScopeApiV5ControllerTest.java new file mode 100644 index 00000000000..01c665e4542 --- /dev/null +++ b/extensions/control-plane/api/management-api-v5/dcp-scope-api-v5/src/test/java/org/eclipse/edc/connector/controlplane/api/management/dcpscope/v5/DcpScopeApiV5ControllerTest.java @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2026 Metaform Systems, Inc. + * + * 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 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.connector.controlplane.api.management.dcpscope.v5; + +import jakarta.json.Json; +import jakarta.json.JsonObject; +import org.eclipse.edc.api.model.IdResponse; +import org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope; +import org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScopeRegistry; +import org.eclipse.edc.junit.annotations.ApiTest; +import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.spi.result.Result; +import org.eclipse.edc.spi.result.ServiceResult; +import org.eclipse.edc.transform.spi.TypeTransformerRegistry; +import org.eclipse.edc.web.jersey.testfixtures.RestControllerTestBase; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static io.restassured.RestAssured.given; +import static io.restassured.http.ContentType.JSON; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ApiTest +class DcpScopeApiV5ControllerTest extends RestControllerTestBase { + + private static final String BASE_URL = "/v5beta/dcpscopes"; + + private final DcpScopeRegistry scopeRegistry = mock(); + private final TypeTransformerRegistry transformerRegistry = mock(); + + private static String requestBody() { + return Json.createObjectBuilder() + .add("@context", Json.createObjectBuilder().add("@vocab", "https://w3id.org/edc/v0.0.1/ns/")) + .add("@type", "DcpScope") + .add("@id", "scope-1") + .add("value", "org.example.scope") + .build() + .toString(); + } + + private static DcpScope scope() { + return DcpScope.Builder.newInstance().id("scope-1").value("org.example.scope").profile("p").build(); + } + + @BeforeEach + void setUp() { + when(transformerRegistry.transform(isA(IdResponse.class), eq(JsonObject.class))) + .thenAnswer(a -> Result.success(Json.createObjectBuilder().add("@id", ((IdResponse) a.getArgument(0)).getId()).build())); + } + + @Test + void create_shouldReturnId() { + when(transformerRegistry.transform(isA(JsonObject.class), eq(DcpScope.class))).thenReturn(Result.success(scope())); + when(scopeRegistry.create(any())).thenReturn(ServiceResult.success()); + + given() + .port(port) + .contentType(JSON) + .body(requestBody()) + .post(BASE_URL) + .then() + .statusCode(200) + .contentType(JSON) + .body("'@id'", is("scope-1")); + + verify(scopeRegistry).create(any()); + } + + @Test + void create_shouldReturnBadRequest_whenTransformationFails() { + when(transformerRegistry.transform(isA(JsonObject.class), eq(DcpScope.class))).thenReturn(Result.failure("invalid")); + + given() + .port(port) + .contentType(JSON) + .body(requestBody()) + .post(BASE_URL) + .then() + .statusCode(400); + + verify(scopeRegistry, never()).create(any()); + } + + @Test + void create_shouldReturnConflict_whenAlreadyExists() { + when(transformerRegistry.transform(isA(JsonObject.class), eq(DcpScope.class))).thenReturn(Result.success(scope())); + when(scopeRegistry.create(any())).thenReturn(ServiceResult.conflict("already exists")); + + given() + .port(port) + .contentType(JSON) + .body(requestBody()) + .post(BASE_URL) + .then() + .statusCode(409); + } + + @Test + void update_shouldReturnNoContent() { + when(transformerRegistry.transform(isA(JsonObject.class), eq(DcpScope.class))).thenReturn(Result.success(scope())); + when(scopeRegistry.update(any())).thenReturn(ServiceResult.success()); + + given() + .port(port) + .contentType(JSON) + .body(requestBody()) + .put(BASE_URL + "/scope-1") + .then() + .statusCode(204); + + verify(scopeRegistry).update(any()); + } + + @Test + void update_shouldReturnNotFound_whenMissing() { + when(transformerRegistry.transform(isA(JsonObject.class), eq(DcpScope.class))).thenReturn(Result.success(scope())); + when(scopeRegistry.update(any())).thenReturn(ServiceResult.notFound("not found")); + + given() + .port(port) + .contentType(JSON) + .body(requestBody()) + .put(BASE_URL + "/scope-1") + .then() + .statusCode(404); + } + + @Test + void delete_shouldReturnNoContent() { + when(scopeRegistry.remove("scope-1")).thenReturn(ServiceResult.success()); + + given() + .port(port) + .delete(BASE_URL + "/scope-1") + .then() + .statusCode(204); + + verify(scopeRegistry).remove("scope-1"); + } + + @Test + void delete_shouldReturnNotFound_whenMissing() { + when(scopeRegistry.remove(any())).thenReturn(ServiceResult.notFound("not found")); + + given() + .port(port) + .delete(BASE_URL + "/scope-1") + .then() + .statusCode(404); + } + + @Test + void query_shouldReturnScopes() { + when(transformerRegistry.transform(isA(JsonObject.class), eq(QuerySpec.class))).thenReturn(Result.success(QuerySpec.max())); + when(scopeRegistry.query(any())).thenReturn(ServiceResult.success(List.of(scope()))); + when(transformerRegistry.transform(isA(DcpScope.class), eq(JsonObject.class))) + .thenReturn(Result.success(Json.createObjectBuilder().add("@id", "scope-1").build())); + + given() + .port(port) + .contentType(JSON) + .body(Json.createObjectBuilder().add("@type", "QuerySpec").build().toString()) + .post(BASE_URL + "/request") + .then() + .statusCode(200) + .contentType(JSON) + .body("size()", is(1)); + + verify(scopeRegistry).query(any()); + } + + @Test + void query_shouldReturnBadRequest_whenTransformationFails() { + when(transformerRegistry.transform(isA(JsonObject.class), eq(QuerySpec.class))).thenReturn(Result.failure("invalid")); + + given() + .port(port) + .contentType(JSON) + .body(Json.createObjectBuilder().add("@type", "QuerySpec").build().toString()) + .post(BASE_URL + "/request") + .then() + .statusCode(400); + + verifyNoInteractions(scopeRegistry); + } + + @Override + protected Object controller() { + return new DcpScopeApiV5Controller(scopeRegistry, transformerRegistry, monitor); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 587860a58ae..3eb701cc579 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -207,6 +207,7 @@ include(":extensions:control-plane:api:management-api-v5:participant-context-con include(":extensions:control-plane:api:management-api-v5:cel-api-v5") include(":extensions:control-plane:api:management-api-v5:dataspace-profile-context-api-v5") include(":extensions:control-plane:api:management-api-v5:discovery-api-v5") +include(":extensions:control-plane:api:management-api-v5:dcp-scope-api-v5") include(":extensions:control-plane:store:sql:asset-index-sql") include(":extensions:control-plane:store:sql:contract-definition-store-sql") diff --git a/spi/decentralized-claims-spi/src/main/java/org/eclipse/edc/iam/decentralizedclaims/spi/scope/DcpScope.java b/spi/decentralized-claims-spi/src/main/java/org/eclipse/edc/iam/decentralizedclaims/spi/scope/DcpScope.java index 7e708260124..842510ca3cd 100644 --- a/spi/decentralized-claims-spi/src/main/java/org/eclipse/edc/iam/decentralizedclaims/spi/scope/DcpScope.java +++ b/spi/decentralized-claims-spi/src/main/java/org/eclipse/edc/iam/decentralizedclaims/spi/scope/DcpScope.java @@ -16,6 +16,8 @@ import java.util.Objects; +import static org.eclipse.edc.spi.constants.CoreConstants.EDC_NAMESPACE; + /** * Represents a scope used by control plane. * @@ -29,6 +31,17 @@ public class DcpScope { public static final String WILDCARD = "*"; + public static final String DCP_SCOPE_TYPE_TERM = "DcpScope"; + public static final String DCP_SCOPE_TYPE_IRI = EDC_NAMESPACE + DCP_SCOPE_TYPE_TERM; + public static final String DCP_SCOPE_VALUE_TERM = "value"; + public static final String DCP_SCOPE_VALUE_IRI = EDC_NAMESPACE + DCP_SCOPE_VALUE_TERM; + public static final String DCP_SCOPE_TYPE_PROPERTY_TERM = "type"; + public static final String DCP_SCOPE_TYPE_PROPERTY_IRI = EDC_NAMESPACE + DCP_SCOPE_TYPE_PROPERTY_TERM; + public static final String DCP_SCOPE_PROFILE_TERM = "profile"; + public static final String DCP_SCOPE_PROFILE_IRI = EDC_NAMESPACE + DCP_SCOPE_PROFILE_TERM; + public static final String DCP_SCOPE_PREFIX_MAPPING_TERM = "prefixMapping"; + public static final String DCP_SCOPE_PREFIX_MAPPING_IRI = EDC_NAMESPACE + DCP_SCOPE_PREFIX_MAPPING_TERM; + public String profile = WILDCARD; private String id; diff --git a/spi/decentralized-claims-spi/src/main/java/org/eclipse/edc/iam/decentralizedclaims/spi/scope/DcpScopeRegistry.java b/spi/decentralized-claims-spi/src/main/java/org/eclipse/edc/iam/decentralizedclaims/spi/scope/DcpScopeRegistry.java index 78bc5828496..a49d1f0ef15 100644 --- a/spi/decentralized-claims-spi/src/main/java/org/eclipse/edc/iam/decentralizedclaims/spi/scope/DcpScopeRegistry.java +++ b/spi/decentralized-claims-spi/src/main/java/org/eclipse/edc/iam/decentralizedclaims/spi/scope/DcpScopeRegistry.java @@ -15,6 +15,7 @@ package org.eclipse.edc.iam.decentralizedclaims.spi.scope; import org.eclipse.edc.runtime.metamodel.annotation.ExtensionPoint; +import org.eclipse.edc.spi.query.QuerySpec; import org.eclipse.edc.spi.result.ServiceResult; import java.util.List; @@ -33,6 +34,30 @@ public interface DcpScopeRegistry { */ ServiceResult register(DcpScope scope); + /** + * Creates a DCP scope. Fails with a conflict if a scope with the same id already exists. + * + * @param scope the scope to create + * @return a service result indicating success or failure + */ + ServiceResult create(DcpScope scope); + + /** + * Updates an existing DCP scope. Fails with a not-found if no scope with the given id exists. + * + * @param scope the scope to update + * @return a service result indicating success or failure + */ + ServiceResult update(DcpScope scope); + + /** + * Queries DCP scopes based on the provided query specification. + * + * @param spec the query specification + * @return a service result containing the list of matching scopes + */ + ServiceResult> query(QuerySpec spec); + /** * Removes a DCP scope by its ID. * diff --git a/spi/decentralized-claims-spi/src/main/java/org/eclipse/edc/iam/decentralizedclaims/spi/scope/store/DcpScopeStore.java b/spi/decentralized-claims-spi/src/main/java/org/eclipse/edc/iam/decentralizedclaims/spi/scope/store/DcpScopeStore.java index 9c853bd9399..89137cb8691 100644 --- a/spi/decentralized-claims-spi/src/main/java/org/eclipse/edc/iam/decentralizedclaims/spi/scope/store/DcpScopeStore.java +++ b/spi/decentralized-claims-spi/src/main/java/org/eclipse/edc/iam/decentralizedclaims/spi/scope/store/DcpScopeStore.java @@ -27,6 +27,10 @@ @ExtensionPoint public interface DcpScopeStore { + default String notFoundErrorMessage(String id) { + return "A DcpScope with ID '%s' does not exist.".formatted(id); + } + /** * Saves a DCP scope. * diff --git a/system-tests/management-api/management-api-test-runner/build.gradle.kts b/system-tests/management-api/management-api-test-runner/build.gradle.kts index c943bcede6e..8144f0f9fd8 100644 --- a/system-tests/management-api/management-api-test-runner/build.gradle.kts +++ b/system-tests/management-api/management-api-test-runner/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { testImplementation(project(":data-protocols:dsp:dsp-spi")) testImplementation(project(":data-protocols:dsp:dsp-2025:dsp-spi-2025")) testImplementation(project(":spi:control-plane-spi")) + testImplementation(project(":spi:decentralized-claims-spi")) testImplementation(project(":core:common:connector-core")) testImplementation(project(":core:common:edr-store-core")) testImplementation(project(":core:control-plane:control-plane-transform")) diff --git a/system-tests/management-api/management-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/managementapi/Runtimes.java b/system-tests/management-api/management-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/managementapi/Runtimes.java index c683e927951..c81d599c80b 100644 --- a/system-tests/management-api/management-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/managementapi/Runtimes.java +++ b/system-tests/management-api/management-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/managementapi/Runtimes.java @@ -41,6 +41,12 @@ interface ControlPlane { }; + // DCP-enabled control plane (the DCP stack provides the IdentityService, so iam-mock is omitted) + String[] VIRTUAL_DCP_MODULES = new String[]{ + ":dist:bom:controlplane-virtual-base-bom", + ":dist:bom:controlplane-virtual-feature-dcp-bom" + }; + String[] SQL_MODULES = new String[]{ ":dist:bom:controlplane-feature-sql-bom" }; diff --git a/system-tests/management-api/management-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/managementapi/SerdeEndToEndTest.java b/system-tests/management-api/management-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/managementapi/SerdeEndToEndTest.java index e2fca241947..c7fbd1691d1 100644 --- a/system-tests/management-api/management-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/managementapi/SerdeEndToEndTest.java +++ b/system-tests/management-api/management-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/managementapi/SerdeEndToEndTest.java @@ -47,6 +47,8 @@ import org.eclipse.edc.connector.controlplane.transform.edc.participantcontext.to.JsonObjectToParticipantContextTransformer; import org.eclipse.edc.connector.dataplane.selector.spi.instance.DataPlaneInstance; import org.eclipse.edc.connector.dataplane.selector.spi.instance.DataPlaneInstanceStates; +import org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope; +import org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScopeRegistry; import org.eclipse.edc.jsonld.spi.JsonLd; import org.eclipse.edc.jsonld.spi.JsonLdNamespace; import org.eclipse.edc.junit.annotations.EndToEndTest; @@ -135,6 +137,8 @@ import static org.eclipse.edc.test.e2e.managementapi.TestFunctions.datasetRequestObject; import static org.eclipse.edc.test.e2e.managementapi.TestFunctions.datasetRequestObjectWithProfile; import static org.eclipse.edc.test.e2e.managementapi.TestFunctions.datasetRequestObjectWithProfileAndProtocol; +import static org.eclipse.edc.test.e2e.managementapi.TestFunctions.dcpScopeObject; +import static org.eclipse.edc.test.e2e.managementapi.TestFunctions.dcpScopePolicyObject; import static org.eclipse.edc.test.e2e.managementapi.TestFunctions.inForceDatePermission; import static org.eclipse.edc.test.e2e.managementapi.TestFunctions.participantContextConfigObject; import static org.eclipse.edc.test.e2e.managementapi.TestFunctions.participantContextObject; @@ -911,10 +915,13 @@ class SerdeV4Tests extends Tests { static RuntimeExtension runtime = ComponentRuntimeExtension.Builder.newInstance() .name(Runtimes.ControlPlane.NAME) .modules(Runtimes.ControlPlane.MODULES) - .modules(":extensions:common:api:management-api-schema-validator", ":extensions:data-plane-selector:data-plane-selector-control-api") + .modules(":extensions:common:api:management-api-schema-validator", + ":extensions:data-plane-selector:data-plane-selector-control-api", + ":extensions:control-plane:api:management-api-v5:dcp-scope-api-v5") .endpoints(Runtimes.ControlPlane.ENDPOINTS.build()) .configurationProvider(SerdeEndToEndTest::config) - .build(); + .build() + .registerServiceMock(DcpScopeRegistry.class, mock()); @BeforeAll static void beforeAll(TypeTransformerRegistry registry) { @@ -929,7 +936,6 @@ static void beforeAll(TypeTransformerRegistry registry) { registry.register(new JsonObjectToCelExpressionTestRequestTransformer()); registry.register(new JsonObjectFromDataspaceProfileContextTransformer(factory)); registry.register(new JsonObjectToAssociateDataspaceProfileContextTransformer()); - } @Override @@ -1002,6 +1008,16 @@ void de_AssociateDataspaceProfileContext(TypeTransformerRegistry typeTransformer assertThat(deserialized.profiles()).contains("profile1", "profile2"); } + + @Test + void serde_DcpScope(TypeTransformerRegistry typeTransformerRegistry, JsonObjectValidatorRegistry validatorRegistry, JsonLd jsonLd) { + verifySerde(typeTransformerRegistry, validatorRegistry, jsonLd, dcpScopeObject(jsonLdContext()), DcpScope.class, null); + } + + @Test + void serde_DcpScope_policy(TypeTransformerRegistry typeTransformerRegistry, JsonObjectValidatorRegistry validatorRegistry, JsonLd jsonLd) { + verifySerde(typeTransformerRegistry, validatorRegistry, jsonLd, dcpScopePolicyObject(jsonLdContext()), DcpScope.class, null); + } } } diff --git a/system-tests/management-api/management-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/managementapi/TestFunctions.java b/system-tests/management-api/management-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/managementapi/TestFunctions.java index 7580127cf4c..7740b699e04 100644 --- a/system-tests/management-api/management-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/managementapi/TestFunctions.java +++ b/system-tests/management-api/management-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/managementapi/TestFunctions.java @@ -181,6 +181,29 @@ public static JsonObject participantContextObject(String context) { .build(); } + public static JsonObject dcpScopeObject(String context) { + return createObjectBuilder() + .add(CONTEXT, createContextBuilder(context).build()) + .add(TYPE, "DcpScope") + .add(ID, "dcp-scope-id") + .add("type", "DEFAULT") + .add("value", "org.eclipse.edc.vc.type:SomeCredential:read") + .add("profile", "test-profile") + .build(); + } + + public static JsonObject dcpScopePolicyObject(String context) { + return createObjectBuilder() + .add(CONTEXT, createContextBuilder(context).build()) + .add(TYPE, "DcpScope") + .add(ID, "dcp-scope-policy-id") + .add("type", "POLICY") + .add("value", "org.eclipse.edc.vc.type:SomeCredential:read") + .add("profile", "test-profile") + .add("prefixMapping", "someMapping") + .build(); + } + public static JsonObject participantContextConfigObject(String context) { return createObjectBuilder() .add(CONTEXT, createContextBuilder(context).build()) diff --git a/system-tests/management-api/management-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/managementapi/v5/DcpScopeApiV5EndToEndTest.java b/system-tests/management-api/management-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/managementapi/v5/DcpScopeApiV5EndToEndTest.java new file mode 100644 index 00000000000..4b15de61d3b --- /dev/null +++ b/system-tests/management-api/management-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/managementapi/v5/DcpScopeApiV5EndToEndTest.java @@ -0,0 +1,364 @@ +/* + * Copyright (c) 2026 Metaform Systems, Inc. + * + * 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 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +package org.eclipse.edc.test.e2e.managementapi.v5; + +import org.eclipse.edc.api.authentication.OauthServer; +import org.eclipse.edc.api.authentication.OauthServerEndToEndExtension; +import org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScope; +import org.eclipse.edc.iam.decentralizedclaims.spi.scope.DcpScopeRegistry; +import org.eclipse.edc.junit.annotations.EndToEndTest; +import org.eclipse.edc.junit.annotations.PostgresqlIntegrationTest; +import org.eclipse.edc.junit.extensions.ComponentRuntimeExtension; +import org.eclipse.edc.junit.extensions.RuntimeExtension; +import org.eclipse.edc.participantcontext.spi.service.ParticipantContextService; +import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.spi.system.configuration.Config; +import org.eclipse.edc.spi.system.configuration.ConfigFactory; +import org.eclipse.edc.sql.testfixtures.PostgresqlEndToEndExtension; +import org.eclipse.edc.test.e2e.managementapi.Runtimes; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.RegisterExtension; + +import java.util.Map; +import java.util.Optional; + +import static io.restassured.http.ContentType.JSON; +import static jakarta.json.Json.createObjectBuilder; +import static java.util.stream.IntStream.range; +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.jsonld.spi.JsonLdKeywords.CONTEXT; +import static org.eclipse.edc.jsonld.spi.JsonLdKeywords.ID; +import static org.eclipse.edc.jsonld.spi.JsonLdKeywords.TYPE; +import static org.eclipse.edc.test.e2e.managementapi.v5.TestFunction.createParticipant; +import static org.eclipse.edc.test.e2e.managementapi.v5.TestFunction.jsonLdContext; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; + +public class DcpScopeApiV5EndToEndTest { + + private static final String DCP_SCOPES_PATH = "/v5beta/dcpscopes"; + + private static Config dcpConfig() { + return ConfigFactory.fromMap(Map.of( + "edc.iam.sts.oauth.token.url", "https://sts.com/token", + "edc.iam.sts.oauth.client.id", "test-client", + "edc.iam.sts.oauth.client.secret.alias", "test-alias", + "edc.iam.sts.privatekey.alias", "privatekey", + "edc.iam.sts.publickey.id", "publickey", + "edc.participant.did", "did:web:someone" + )); + } + + @SuppressWarnings("JUnitMalformedDeclaration") + abstract static class Tests { + + @AfterEach + void tearDown(DcpScopeRegistry registry, ParticipantContextService participantContextService) { + var list = participantContextService.search(QuerySpec.max()) + .orElseThrow(f -> new AssertionError(f.getFailureDetail())); + + for (var p : list) { + participantContextService.deleteParticipantContext(p.getParticipantContextId()).orElseThrow(f -> new AssertionError(f.getFailureDetail())); + } + registry.query(QuerySpec.max()).getContent() + .forEach(scope -> registry.remove(scope.getId()).getContent()); + } + + @Test + void create(ManagementEndToEndV5TestContext context, OauthServer authServer, DcpScopeRegistry registry) { + var scopeId = "scope-1"; + var token = authServer.createAdminToken(); + + var response = context.baseRequest(token) + .contentType(JSON) + .body(dcpScopeBody(scopeId, "DEFAULT", "org.example.scope", "*", null)) + .post(DCP_SCOPES_PATH) + .then() + .statusCode(200) + .extract().body().as(Map.class); + + assertThat(response.get("@id")).isEqualTo(scopeId); + assertThat(find(registry, scopeId)).isPresent() + .get().satisfies(scope -> assertThat(scope.getValue()).isEqualTo("org.example.scope")); + } + + @Test + void create_policyScope(ManagementEndToEndV5TestContext context, OauthServer authServer, DcpScopeRegistry registry) { + var scopeId = "scope-policy"; + var token = authServer.createAdminToken(); + + context.baseRequest(token) + .contentType(JSON) + .body(dcpScopeBody(scopeId, "POLICY", "org.example.scope", "*", "someMapping")) + .post(DCP_SCOPES_PATH) + .then() + .statusCode(200); + + assertThat(find(registry, scopeId)).isPresent() + .get().satisfies(scope -> { + assertThat(scope.getType()).isEqualTo(DcpScope.Type.POLICY); + assertThat(scope.getPrefixMapping()).isEqualTo("someMapping"); + }); + } + + @Test + void create_alreadyExists(ManagementEndToEndV5TestContext context, OauthServer authServer, DcpScopeRegistry registry) { + var scopeId = "scope-1"; + seedScope(registry, scopeId, "org.example.scope"); + + var token = authServer.createAdminToken(); + + context.baseRequest(token) + .contentType(JSON) + .body(dcpScopeBody(scopeId, "DEFAULT", "org.example.scope", "*", null)) + .post(DCP_SCOPES_PATH) + .then() + .statusCode(409); + } + + @Test + void create_validationFails(ManagementEndToEndV5TestContext context, OauthServer authServer) { + var body = createObjectBuilder() + .add(CONTEXT, jsonLdContext()) + .add(TYPE, "DcpScope") + .add(ID, "scope-1") + .add("type", "DEFAULT") + .add("profile", "*") + .build() // missing required 'value' + .toString(); + + var token = authServer.createAdminToken(); + + context.baseRequest(token) + .contentType(JSON) + .body(body) + .post(DCP_SCOPES_PATH) + .then() + .statusCode(400) + .body("[0].message", containsString("value")); + } + + @Test + void create_notAuthorized(ManagementEndToEndV5TestContext context, OauthServer authServer, ParticipantContextService srv) { + var participantContextId = "test-user"; + createParticipant(srv, participantContextId); + + var token = authServer.createToken(participantContextId); + + context.baseRequest(token) + .contentType(JSON) + .body(dcpScopeBody("scope-1", "DEFAULT", "org.example.scope", "*", null)) + .post(DCP_SCOPES_PATH) + .then() + .statusCode(403); + } + + @Test + void update(ManagementEndToEndV5TestContext context, OauthServer authServer, DcpScopeRegistry registry) { + var scopeId = "scope-1"; + seedScope(registry, scopeId, "org.example.scope"); + + var token = authServer.createAdminToken(); + + context.baseRequest(token) + .contentType(JSON) + .body(dcpScopeBody(scopeId, "DEFAULT", "org.example.updated", "*", null)) + .put(DCP_SCOPES_PATH + "/" + scopeId) + .then() + .statusCode(204); + + assertThat(find(registry, scopeId)).isPresent() + .get().satisfies(scope -> assertThat(scope.getValue()).isEqualTo("org.example.updated")); + } + + @Test + void update_notFound(ManagementEndToEndV5TestContext context, OauthServer authServer) { + var token = authServer.createAdminToken(); + + context.baseRequest(token) + .contentType(JSON) + .body(dcpScopeBody("unknown", "DEFAULT", "org.example.scope", "*", null)) + .put(DCP_SCOPES_PATH + "/unknown") + .then() + .statusCode(404); + } + + @Test + void update_notAuthorized(ManagementEndToEndV5TestContext context, OauthServer authServer, ParticipantContextService srv) { + var participantContextId = "test-user"; + createParticipant(srv, participantContextId); + + var token = authServer.createToken(participantContextId); + + context.baseRequest(token) + .contentType(JSON) + .body(dcpScopeBody("scope-1", "DEFAULT", "org.example.scope", "*", null)) + .put(DCP_SCOPES_PATH + "/scope-1") + .then() + .statusCode(403); + } + + @Test + void delete(ManagementEndToEndV5TestContext context, OauthServer authServer, DcpScopeRegistry registry) { + var scopeId = "scope-1"; + seedScope(registry, scopeId, "org.example.scope"); + + var token = authServer.createAdminToken(); + + context.baseRequest(token) + .delete(DCP_SCOPES_PATH + "/" + scopeId) + .then() + .statusCode(204); + + assertThat(find(registry, scopeId)).isEmpty(); + } + + @Test + void delete_notFound(ManagementEndToEndV5TestContext context, OauthServer authServer) { + var token = authServer.createAdminToken(); + + context.baseRequest(token) + .delete(DCP_SCOPES_PATH + "/unknown") + .then() + .statusCode(404); + } + + @Test + void delete_notAuthorized(ManagementEndToEndV5TestContext context, OauthServer authServer, ParticipantContextService srv) { + var participantContextId = "test-user"; + createParticipant(srv, participantContextId); + + var token = authServer.createToken(participantContextId); + + context.baseRequest(token) + .delete(DCP_SCOPES_PATH + "/scope-1") + .then() + .statusCode(403); + } + + @Test + void query(ManagementEndToEndV5TestContext context, OauthServer authServer, DcpScopeRegistry registry) { + range(0, 3).forEach(i -> seedScope(registry, "scope-" + i, "org.example.scope" + i)); + + var token = authServer.createAdminToken(); + + context.baseRequest(token) + .contentType(JSON) + .body(context.query().toString()) + .post(DCP_SCOPES_PATH + "/request") + .then() + .statusCode(200) + .body("size()", equalTo(3)); + } + + @Test + void query_notAuthorized(ManagementEndToEndV5TestContext context, OauthServer authServer, ParticipantContextService srv) { + var participantContextId = "test-user"; + createParticipant(srv, participantContextId); + + var token = authServer.createToken(participantContextId); + + context.baseRequest(token) + .contentType(JSON) + .body(context.query().toString()) + .post(DCP_SCOPES_PATH + "/request") + .then() + .statusCode(403); + } + + private void seedScope(DcpScopeRegistry registry, String id, String value) { + registry.create(DcpScope.Builder.newInstance().id(id).value(value).build()) + .orElseThrow(f -> new AssertionError(f.getFailureDetail())); + } + + private Optional find(DcpScopeRegistry registry, String id) { + return registry.query(QuerySpec.max()).getContent().stream() + .filter(scope -> scope.getId().equals(id)) + .findFirst(); + } + + private String dcpScopeBody(String id, String type, String value, String profile, String prefixMapping) { + var builder = createObjectBuilder() + .add(CONTEXT, jsonLdContext()) + .add(TYPE, "DcpScope") + .add(ID, id) + .add("type", type) + .add("value", value) + .add("profile", profile); + + if (prefixMapping != null) { + builder.add("prefixMapping", prefixMapping); + } + + return builder.build().toString(); + } + } + + @Nested + @EndToEndTest + class InMemory extends Tests { + + @Order(0) + @RegisterExtension + static final OauthServerEndToEndExtension AUTH_SERVER_EXTENSION = OauthServerEndToEndExtension.Builder.newInstance().build(); + + @Order(1) + @RegisterExtension + static RuntimeExtension runtime = ComponentRuntimeExtension.Builder.newInstance() + .name(Runtimes.ControlPlane.NAME) + .modules(Runtimes.ControlPlane.VIRTUAL_DCP_MODULES) + .endpoints(Runtimes.ControlPlane.ENDPOINTS.build()) + .configurationProvider(Runtimes.ControlPlane::config) + .configurationProvider(DcpScopeApiV5EndToEndTest::dcpConfig) + .configurationProvider(AUTH_SERVER_EXTENSION::getConfig) + .paramProvider(ManagementEndToEndV5TestContext.class, ManagementEndToEndV5TestContext::forContext) + .build(); + } + + @Nested + @PostgresqlIntegrationTest + class Postgres extends Tests { + + @Order(0) + @RegisterExtension + static final OauthServerEndToEndExtension AUTH_SERVER_EXTENSION = OauthServerEndToEndExtension.Builder.newInstance().build(); + + @RegisterExtension + @Order(0) + static final PostgresqlEndToEndExtension POSTGRES_EXTENSION = new PostgresqlEndToEndExtension(); + + @Order(1) + @RegisterExtension + static final BeforeAllCallback SETUP = context -> POSTGRES_EXTENSION.createDatabase(Runtimes.ControlPlane.NAME.toLowerCase()); + + @Order(2) + @RegisterExtension + static RuntimeExtension runtime = ComponentRuntimeExtension.Builder.newInstance() + .name(Runtimes.ControlPlane.NAME) + .modules(Runtimes.ControlPlane.VIRTUAL_DCP_MODULES) + .modules(Runtimes.ControlPlane.VIRTUAL_SQL_MODULES) + .endpoints(Runtimes.ControlPlane.ENDPOINTS.build()) + .configurationProvider(Runtimes.ControlPlane::config) + .configurationProvider(DcpScopeApiV5EndToEndTest::dcpConfig) + .configurationProvider(() -> POSTGRES_EXTENSION.configFor(Runtimes.ControlPlane.NAME.toLowerCase())) + .configurationProvider(AUTH_SERVER_EXTENSION::getConfig) + .paramProvider(ManagementEndToEndV5TestContext.class, ManagementEndToEndV5TestContext::forContext) + .build(); + } +}