From 7cf3527945e0d486d318dcda53c9eff79bedd4a1 Mon Sep 17 00:00:00 2001 From: Enrico Risa Date: Fri, 10 Jul 2026 10:39:31 +0200 Subject: [PATCH] feat: add dynamic scope mapping --- .../identityhub/DefaultServicesExtension.java | 8 +- .../identityhub/ScopeMappingExtension.java | 82 ++++++++++++ .../RegexScopeToCriterionTransformer.java | 49 +++++++ .../defaults/ScopeMappingRegistryImpl.java | 111 +++++++++++++++ ...rg.eclipse.edc.spi.system.ServiceExtension | 1 + .../ScopeMappingExtensionTest.java | 69 ++++++++++ .../RegexScopeToCriterionTransformerTest.java | 72 ++++++++++ .../ScopeMappingRegistryImplTest.java | 126 ++++++++++++++++++ .../transformation/ScopeMappingRegistry.java | 52 ++++++++ 9 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 core/common-core/src/main/java/org/eclipse/edc/identityhub/ScopeMappingExtension.java create mode 100644 core/common-core/src/main/java/org/eclipse/edc/identityhub/defaults/RegexScopeToCriterionTransformer.java create mode 100644 core/common-core/src/main/java/org/eclipse/edc/identityhub/defaults/ScopeMappingRegistryImpl.java create mode 100644 core/common-core/src/test/java/org/eclipse/edc/identityhub/ScopeMappingExtensionTest.java create mode 100644 core/common-core/src/test/java/org/eclipse/edc/identityhub/defaults/RegexScopeToCriterionTransformerTest.java create mode 100644 core/common-core/src/test/java/org/eclipse/edc/identityhub/defaults/ScopeMappingRegistryImplTest.java create mode 100644 spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/transformation/ScopeMappingRegistry.java diff --git a/core/common-core/src/main/java/org/eclipse/edc/identityhub/DefaultServicesExtension.java b/core/common-core/src/main/java/org/eclipse/edc/identityhub/DefaultServicesExtension.java index 507d5ef06..1682034c2 100644 --- a/core/common-core/src/main/java/org/eclipse/edc/identityhub/DefaultServicesExtension.java +++ b/core/common-core/src/main/java/org/eclipse/edc/identityhub/DefaultServicesExtension.java @@ -17,6 +17,7 @@ import org.eclipse.edc.iam.decentralizedclaims.spi.verification.SignatureSuiteRegistry; import org.eclipse.edc.identityhub.accesstoken.rules.ClaimIsPresentRule; import org.eclipse.edc.identityhub.defaults.EdcScopeToCriterionTransformer; +import org.eclipse.edc.identityhub.defaults.RegexScopeToCriterionTransformer; import org.eclipse.edc.identityhub.defaults.store.InMemoryCredentialOfferStore; import org.eclipse.edc.identityhub.defaults.store.InMemoryCredentialStore; import org.eclipse.edc.identityhub.defaults.store.InMemoryHolderCredentialRequestStore; @@ -25,6 +26,7 @@ import org.eclipse.edc.identityhub.spi.credential.request.store.HolderCredentialRequestStore; import org.eclipse.edc.identityhub.spi.keypair.store.KeyPairResourceStore; import org.eclipse.edc.identityhub.spi.transformation.DiscriminatorMappingRegistry; +import org.eclipse.edc.identityhub.spi.transformation.ScopeMappingRegistry; import org.eclipse.edc.identityhub.spi.transformation.ScopeToCriterionTransformer; import org.eclipse.edc.identityhub.spi.verifiablecredentials.store.CredentialOfferStore; import org.eclipse.edc.identityhub.spi.verifiablecredentials.store.CredentialStore; @@ -92,6 +94,8 @@ public class DefaultServicesExtension implements ServiceExtension { private JtiValidationStore jtiValidationStore; @Inject private DiscriminatorMappingRegistry discriminatorMappingRegistry; + @Inject + private ScopeMappingRegistry scopeMappingRegistry; @Override public String name() { @@ -131,7 +135,9 @@ public KeyPairResourceStore createDefaultKeyPairResourceStore() { @Provider(isDefault = true) public ScopeToCriterionTransformer createScopeTransformer(ServiceExtensionContext context) { - return new EdcScopeToCriterionTransformer(discriminatorMappingRegistry); + return new RegexScopeToCriterionTransformer( + scopeMappingRegistry, + new EdcScopeToCriterionTransformer(discriminatorMappingRegistry)); } @Provider(isDefault = true) diff --git a/core/common-core/src/main/java/org/eclipse/edc/identityhub/ScopeMappingExtension.java b/core/common-core/src/main/java/org/eclipse/edc/identityhub/ScopeMappingExtension.java new file mode 100644 index 000000000..66cfc9589 --- /dev/null +++ b/core/common-core/src/main/java/org/eclipse/edc/identityhub/ScopeMappingExtension.java @@ -0,0 +1,82 @@ +/* + * 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.identityhub; + +import org.eclipse.edc.identityhub.defaults.ScopeMappingRegistryImpl; +import org.eclipse.edc.identityhub.spi.transformation.ScopeMappingRegistry; +import org.eclipse.edc.runtime.metamodel.annotation.Configuration; +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.runtime.metamodel.annotation.Settings; +import org.eclipse.edc.spi.query.Criterion; +import org.eclipse.edc.spi.system.ServiceExtension; + +import java.util.Map; + +import static org.eclipse.edc.identityhub.ScopeMappingExtension.NAME; + + +@Extension(NAME) +public class ScopeMappingExtension implements ServiceExtension { + + public static final String NAME = "Scope Mapping Extension"; + + public static final String CONFIG_PREFIX = "edc.identityhub.scope"; + @Configuration(context = CONFIG_PREFIX) + private Map scopeMappings; + + @Override + public String name() { + return NAME; + } + + @Provider(isDefault = true) + public ScopeMappingRegistry createScopeMappingRegistry() { + var scopeMappingRegistry = new ScopeMappingRegistryImpl(); + scopeMappings.forEach((k, v) -> { + scopeMappingRegistry.addMapping(v.pattern(), new Criterion(v.leftOperand(), v.operator(), v.rightOperand())); + }); + + return scopeMappingRegistry; + } + + + @Settings + record ScopeMapping( + + @Setting( + key = "pattern", + description = "The regular expression the scope string is matched against." + ) + String pattern, + + @Setting( + key = "leftoperand", + description = "The left operand of the resulting criterion, may reference regex capture groups (e.g. $1)") + String leftOperand, + + @Setting( + key = "operator", + description = "The operator of the resulting criterion, e.g. 'contains'") + String operator, + @Setting( + key = "rightoperand", + description = "The right operand of the resulting criterion, may reference regex capture groups (e.g. $1)") + String rightOperand + ) { + + } +} diff --git a/core/common-core/src/main/java/org/eclipse/edc/identityhub/defaults/RegexScopeToCriterionTransformer.java b/core/common-core/src/main/java/org/eclipse/edc/identityhub/defaults/RegexScopeToCriterionTransformer.java new file mode 100644 index 000000000..afee8f0d4 --- /dev/null +++ b/core/common-core/src/main/java/org/eclipse/edc/identityhub/defaults/RegexScopeToCriterionTransformer.java @@ -0,0 +1,49 @@ +/* + * 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.identityhub.defaults; + +import org.eclipse.edc.identityhub.spi.transformation.ScopeMappingRegistry; +import org.eclipse.edc.identityhub.spi.transformation.ScopeToCriterionTransformer; +import org.eclipse.edc.spi.query.Criterion; +import org.eclipse.edc.spi.result.Result; + +import java.util.List; + +import static org.eclipse.edc.spi.result.Result.success; + +/** + * A {@link ScopeToCriterionTransformer} that first consults a customizable {@link ScopeMappingRegistry} of regex-based + * mappings. If at least one mapping matches the scope, the accumulated {@link Criterion} list is returned. Otherwise, the + * scope is delegated to a fallback transformer (typically the {@link EdcScopeToCriterionTransformer}). + */ +public class RegexScopeToCriterionTransformer implements ScopeToCriterionTransformer { + + private final ScopeMappingRegistry scopeMappingRegistry; + private final ScopeToCriterionTransformer fallback; + + public RegexScopeToCriterionTransformer(ScopeMappingRegistry scopeMappingRegistry, ScopeToCriterionTransformer fallback) { + this.scopeMappingRegistry = scopeMappingRegistry; + this.fallback = fallback; + } + + @Override + public Result> transformScope(String scope) { + var criteria = scopeMappingRegistry.map(scope); + if (!criteria.isEmpty()) { + return success(criteria); + } + return fallback.transformScope(scope); + } +} diff --git a/core/common-core/src/main/java/org/eclipse/edc/identityhub/defaults/ScopeMappingRegistryImpl.java b/core/common-core/src/main/java/org/eclipse/edc/identityhub/defaults/ScopeMappingRegistryImpl.java new file mode 100644 index 000000000..e889316be --- /dev/null +++ b/core/common-core/src/main/java/org/eclipse/edc/identityhub/defaults/ScopeMappingRegistryImpl.java @@ -0,0 +1,111 @@ +/* + * 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.identityhub.defaults; + +import org.eclipse.edc.identityhub.spi.transformation.ScopeMappingRegistry; +import org.eclipse.edc.spi.query.Criterion; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * An implementation of the {@link ScopeMappingRegistry} interface that maintains a list of regex-based scope mappings. + *

+ * Thread-Safety: the class is designed to handle multiple threads concurrently accessing or modifying the mappings. + */ +public class ScopeMappingRegistryImpl implements ScopeMappingRegistry { + + // this might be accessed from multiple threads (API requests), so it needs to be thread-safe + private final List mappings = new CopyOnWriteArrayList<>(); + + @Override + public void addMapping(String regex, Criterion criterionTemplate) { + // Pattern.compile throws PatternSyntaxException on an invalid regex, surfacing config errors early + mappings.add(new ScopeMapping(Pattern.compile(regex), criterionTemplate)); + } + + @Override + public List map(String scope) { + var result = new ArrayList(); + if (scope == null) { + return result; + } + for (var mapping : mappings) { + var matcher = mapping.pattern().matcher(scope); + if (matcher.matches()) { + var template = mapping.template(); + var left = substitute(matcher, template.getOperandLeft()); + var right = substitute(matcher, template.getOperandRight()); + result.add(new Criterion(left, template.getOperator(), right)); + } + } + return result; + } + + /** + * Substitutes regex capture groups ({@code $0}, {@code $1}, {@code ${1}}, …) into a (String) operand. A group that + * did not participate in the match is substituted with an empty string, and a reference to a non-existent group is + * left as-is. Non-String operands are returned unchanged. + */ + private static Object substitute(Matcher matcher, Object operand) { + if (!(operand instanceof String template)) { + return operand; + } + + var sb = new StringBuilder(); + var i = 0; + while (i < template.length()) { + var c = template.charAt(i); + if (c == '$' && i + 1 < template.length()) { + var braced = template.charAt(i + 1) == '{'; + var start = braced ? i + 2 : i + 1; + var j = start; + while (j < template.length() && Character.isDigit(template.charAt(j))) { + j++; + } + var validBraces = !braced || (j < template.length() && template.charAt(j) == '}'); + if (j > start && validBraces) { + // the substring is all digits; parseInt can only fail on overflow, which can never be + // a valid group index, so an unparseable/out-of-range reference is left as a literal + var group = parseGroup(template.substring(start, j)); + if (group >= 0 && group <= matcher.groupCount()) { + var value = matcher.group(group); + sb.append(value == null ? "" : value); + i = braced ? j + 1 : j; + continue; + } + } + } + sb.append(c); + i++; + } + return sb.toString(); + } + + private static int parseGroup(String digits) { + try { + return Integer.parseInt(digits); + } catch (NumberFormatException e) { + // digit run too long to fit in an int; cannot be a valid group index + return -1; + } + } + + private record ScopeMapping(Pattern pattern, Criterion template) { + } +} diff --git a/core/common-core/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension b/core/common-core/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension index 2a5a4284e..d34efa5ef 100644 --- a/core/common-core/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension +++ b/core/common-core/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension @@ -14,3 +14,4 @@ org.eclipse.edc.identityhub.DefaultServicesExtension org.eclipse.edc.identityhub.DiscriminatorMappingExtension +org.eclipse.edc.identityhub.ScopeMappingExtension diff --git a/core/common-core/src/test/java/org/eclipse/edc/identityhub/ScopeMappingExtensionTest.java b/core/common-core/src/test/java/org/eclipse/edc/identityhub/ScopeMappingExtensionTest.java new file mode 100644 index 000000000..b4ada0066 --- /dev/null +++ b/core/common-core/src/test/java/org/eclipse/edc/identityhub/ScopeMappingExtensionTest.java @@ -0,0 +1,69 @@ +/* + * 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.identityhub; + +import org.eclipse.edc.boot.system.injection.ObjectFactory; +import org.eclipse.edc.identityhub.defaults.ScopeMappingRegistryImpl; +import org.eclipse.edc.junit.extensions.DependencyInjectionExtension; +import org.eclipse.edc.junit.extensions.TestExtensionContext; +import org.eclipse.edc.spi.EdcException; +import org.eclipse.edc.spi.system.configuration.ConfigFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.eclipse.edc.identityhub.ScopeMappingExtension.CONFIG_PREFIX; + +@ExtendWith(DependencyInjectionExtension.class) +class ScopeMappingExtensionTest { + + @Test + void createScopeMappingRegistry(ScopeMappingExtension extension) { + assertThat(extension.createScopeMappingRegistry()) + .isInstanceOf(ScopeMappingRegistryImpl.class); + } + + @Test + void createScopeMappingRegistry_withSingleConfig(TestExtensionContext context, ObjectFactory factory) { + context.setConfig(ConfigFactory.fromMap(Map.of( + CONFIG_PREFIX + ".membership.pattern", "org\\.eclipse\\.custom\\.vc\\.type:(.+):(read|\\*|all)", + CONFIG_PREFIX + ".membership.leftoperand", "verifiableCredential.credential.type", + CONFIG_PREFIX + ".membership.operator", "contains", + CONFIG_PREFIX + ".membership.rightoperand", "$1"))); + + var extension = factory.constructInstance(ScopeMappingExtension.class); + var registry = extension.createScopeMappingRegistry(); + + assertThat(registry).isInstanceOf(ScopeMappingRegistryImpl.class); + assertThat(registry.map("org.eclipse.custom.vc.type:MembershipCredential:read")) + .singleElement() + .satisfies(c -> { + assertThat(c.getOperandLeft()).isEqualTo("verifiableCredential.credential.type"); + assertThat(c.getOperator()).isEqualTo("contains"); + assertThat(c.getOperandRight()).isEqualTo("MembershipCredential"); + }); + } + + @Test + void createScopeMappingRegistry_withIncompleteConfig(TestExtensionContext context, ObjectFactory factory) { + context.setConfig(ConfigFactory.fromMap(Map.of(CONFIG_PREFIX + ".membership.pattern", "vc:(.+):read"))); + + assertThatThrownBy(() -> factory.constructInstance(ScopeMappingExtension.class)) + .isInstanceOf(EdcException.class); + } +} diff --git a/core/common-core/src/test/java/org/eclipse/edc/identityhub/defaults/RegexScopeToCriterionTransformerTest.java b/core/common-core/src/test/java/org/eclipse/edc/identityhub/defaults/RegexScopeToCriterionTransformerTest.java new file mode 100644 index 000000000..5aca9b79e --- /dev/null +++ b/core/common-core/src/test/java/org/eclipse/edc/identityhub/defaults/RegexScopeToCriterionTransformerTest.java @@ -0,0 +1,72 @@ +/* + * 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.identityhub.defaults; + +import org.eclipse.edc.identityhub.spi.transformation.ScopeToCriterionTransformer; +import org.eclipse.edc.spi.query.Criterion; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; +import static org.eclipse.edc.spi.result.Result.failure; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class RegexScopeToCriterionTransformerTest { + + private final ScopeMappingRegistryImpl registry = new ScopeMappingRegistryImpl(); + private final ScopeToCriterionTransformer fallback = mock(); + private final RegexScopeToCriterionTransformer transformer = new RegexScopeToCriterionTransformer(registry, fallback); + + @Test + void transformScope_shouldUseRegistry_whenMappingMatches() { + registry.addMapping("org\\.eclipse\\.custom\\.vc\\.type:(.+):(read|\\*|all)", + new Criterion("verifiableCredential.credential.type", "contains", "$1")); + + var result = transformer.transformScope("org.eclipse.custom.vc.type:MembershipCredential:read"); + + assertThat(result).isSucceeded().satisfies(criteria -> { + assertThat(criteria).singleElement().satisfies(c -> { + assertThat(c.getOperandLeft()).isEqualTo("verifiableCredential.credential.type"); + assertThat(c.getOperator()).isEqualTo("contains"); + assertThat(c.getOperandRight()).isEqualTo("MembershipCredential"); + }); + }); + verifyNoInteractions(fallback); + } + + @Test + void transformScope_shouldDelegateToFallback_whenNoMappingMatches() { + registry.addMapping("nomatch:(.+)", new Criterion("type", "contains", "$1")); + when(fallback.transformScope("org.eclipse.dspace.dcp.vc.type:TestCredential:read")) + .thenReturn(failure("some failure")); + + var result = transformer.transformScope("org.eclipse.dspace.dcp.vc.type:TestCredential:read"); + + assertThat(result).isFailed(); + verify(fallback).transformScope("org.eclipse.dspace.dcp.vc.type:TestCredential:read"); + } + + @Test + void transformScope_shouldDelegateToFallback_whenRegistryEmpty() { + when(fallback.transformScope("any:scope:read")).thenReturn(failure("fallback used")); + + transformer.transformScope("any:scope:read"); + + verify(fallback).transformScope("any:scope:read"); + } +} diff --git a/core/common-core/src/test/java/org/eclipse/edc/identityhub/defaults/ScopeMappingRegistryImplTest.java b/core/common-core/src/test/java/org/eclipse/edc/identityhub/defaults/ScopeMappingRegistryImplTest.java new file mode 100644 index 000000000..cd12b4efe --- /dev/null +++ b/core/common-core/src/test/java/org/eclipse/edc/identityhub/defaults/ScopeMappingRegistryImplTest.java @@ -0,0 +1,126 @@ +/* + * 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.identityhub.defaults; + +import org.eclipse.edc.spi.query.Criterion; +import org.junit.jupiter.api.Test; + +import java.util.regex.PatternSyntaxException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ScopeMappingRegistryImplTest { + + private final ScopeMappingRegistryImpl registry = new ScopeMappingRegistryImpl(); + + @Test + void map_shouldSubstituteCaptureGroupInRightOperand() { + registry.addMapping("org\\.eclipse\\.custom\\.vc\\.type:(.+):(read|\\*|all)", + new Criterion("verifiableCredential.credential.type", "contains", "$1")); + + var criteria = registry.map("org.eclipse.custom.vc.type:MembershipCredential:read"); + + assertThat(criteria).singleElement().satisfies(c -> { + assertThat(c.getOperandLeft()).isEqualTo("verifiableCredential.credential.type"); + assertThat(c.getOperator()).isEqualTo("contains"); + assertThat(c.getOperandRight()).isEqualTo("MembershipCredential"); + }); + } + + @Test + void map_shouldSubstituteCaptureGroupInLeftOperand() { + registry.addMapping("(.+):(.+):read", + new Criterion("$1", "contains", "$2")); + + var criteria = registry.map("someLeft:someRight:read"); + + assertThat(criteria).singleElement().satisfies(c -> { + assertThat(c.getOperandLeft()).isEqualTo("someLeft"); + assertThat(c.getOperandRight()).isEqualTo("someRight"); + }); + } + + @Test + void map_shouldSupportBracedGroupReference() { + registry.addMapping("type:(.+)", new Criterion("type", "contains", "${1}Suffix")); + + var criteria = registry.map("type:Membership"); + + assertThat(criteria).singleElement() + .satisfies(c -> assertThat(c.getOperandRight()).isEqualTo("MembershipSuffix")); + } + + @Test + void map_shouldAccumulateAllMatchingMappings() { + registry.addMapping("vc:(.+):read", new Criterion("verifiableCredential.credential.type", "contains", "$1")); + registry.addMapping("vc:(.+):read", new Criterion("verifiableCredential.credential.@context", "contains", "https://example.com")); + + var criteria = registry.map("vc:MembershipCredential:read"); + + assertThat(criteria).hasSize(2); + assertThat(criteria).anyMatch(c -> c.getOperandRight().equals("MembershipCredential")); + assertThat(criteria).anyMatch(c -> c.getOperandRight().equals("https://example.com")); + } + + @Test + void map_shouldReturnEmptyList_whenNoMappingMatches() { + registry.addMapping("vc:(.+):read", new Criterion("verifiableCredential.credential.type", "contains", "$1")); + + assertThat(registry.map("something:completely:different")).isEmpty(); + } + + @Test + void map_shouldReturnEmptyList_whenNoMappingsRegistered() { + assertThat(registry.map("vc:MembershipCredential:read")).isEmpty(); + } + + @Test + void map_shouldReturnEmptyList_whenScopeIsNull() { + registry.addMapping("vc:(.+):read", new Criterion("verifiableCredential.credential.type", "contains", "$1")); + + assertThat(registry.map(null)).isEmpty(); + } + + @Test + void map_shouldOnlyMatchFullString() { + registry.addMapping("vc:(.+)", new Criterion("type", "contains", "$1")); + + // 'matches()' requires the whole string to match, so a leading prefix means no match + assertThat(registry.map("prefix-vc:Membership")).isEmpty(); + } + + @Test + void map_shouldLeaveLiteral_whenGroupReferenceIsOutOfRange() { + registry.addMapping("vc:(.+)", new Criterion("type", "contains", "$2")); + + assertThat(registry.map("vc:Membership")).singleElement() + .satisfies(c -> assertThat(c.getOperandRight()).isEqualTo("$2")); + } + + @Test + void map_shouldLeaveLiteral_whenGroupReferenceOverflowsInt() { + registry.addMapping("vc:(.+)", new Criterion("type", "contains", "$99999999999")); + + assertThat(registry.map("vc:Membership")).singleElement() + .satisfies(c -> assertThat(c.getOperandRight()).isEqualTo("$99999999999")); + } + + @Test + void addMapping_shouldThrow_whenRegexIsInvalid() { + assertThatThrownBy(() -> registry.addMapping("vc:(.+", new Criterion("type", "contains", "$1"))) + .isInstanceOf(PatternSyntaxException.class); + } +} diff --git a/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/transformation/ScopeMappingRegistry.java b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/transformation/ScopeMappingRegistry.java new file mode 100644 index 000000000..07fbc0f0e --- /dev/null +++ b/spi/identity-hub-spi/src/main/java/org/eclipse/edc/identityhub/spi/transformation/ScopeMappingRegistry.java @@ -0,0 +1,52 @@ +/* + * 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.identityhub.spi.transformation; + +import org.eclipse.edc.spi.query.Criterion; + +import java.util.List; + +/** + * Registry of customizable, regex-based mappings from a scope string to a {@link Criterion}. This allows a dataspace to + * express its own scope-to-criteria mappings (both the left and the right operand of the resulting {@link Criterion}) + * without having to implement a full {@link ScopeToCriterionTransformer}. + *

+ * Each mapping consists of a regular expression and a {@link Criterion} template. When a scope matches the + * regular expression, a concrete {@link Criterion} is produced by substituting the regex capture groups + * ({@code $0}, {@code $1}, …) into the template's operands. + */ +public interface ScopeMappingRegistry { + + /** + * Registers a regex mapping. When a scope matches {@code regex}, a {@link Criterion} is produced from + * {@code criterionTemplate} by substituting capture groups ({@code $0}, {@code $1}, …) into its + * {@code operandLeft} and {@code operandRight}. + * + * @param regex The regular expression the scope string is matched against (using + * {@link java.util.regex.Matcher#matches()}). + * @param criterionTemplate The {@link Criterion} template whose operands may reference capture groups, for example + * {@code verifiableCredential.credential.type contains $1}. + */ + void addMapping(String regex, Criterion criterionTemplate); + + /** + * Maps a scope string to the list of {@link Criterion} contributed by every mapping whose regular expression + * matches the scope. + * + * @param scope The scope string to map. + * @return All criteria contributed by matching mappings, or an empty list if no mapping matches. + */ + List map(String scope); +}