Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -92,6 +94,8 @@ public class DefaultServicesExtension implements ServiceExtension {
private JtiValidationStore jtiValidationStore;
@Inject
private DiscriminatorMappingRegistry discriminatorMappingRegistry;
@Inject
private ScopeMappingRegistry scopeMappingRegistry;

@Override
public String name() {
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, ScopeMapping> 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
) {

}
}
Original file line number Diff line number Diff line change
@@ -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<List<Criterion>> transformScope(String scope) {
var criteria = scopeMappingRegistry.map(scope);
if (!criteria.isEmpty()) {
return success(criteria);
}
return fallback.transformScope(scope);
}
}
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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<ScopeMapping> 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<Criterion> map(String scope) {
var result = new ArrayList<Criterion>();
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) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@

org.eclipse.edc.identityhub.DefaultServicesExtension
org.eclipse.edc.identityhub.DiscriminatorMappingExtension
org.eclipse.edc.identityhub.ScopeMappingExtension
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading