Skip to content

Commit d917a60

Browse files
feat: fully-qualified credential type (#949)
* FQCT on presentation query * add tests for multiple scope variants * fix some tests, add new ones * clean up code * fix tests * source doc
1 parent 0c2633b commit d917a60

13 files changed

Lines changed: 519 additions & 68 deletions

File tree

core/common-core/src/main/java/org/eclipse/edc/identityhub/defaults/CredentialResourceLookup.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
public class CredentialResourceLookup extends ReflectionPropertyLookup {
3434
@Override
3535
public Object getProperty(String key, Object object) {
36+
if (key.endsWith("@context")) {
37+
key = key.replace("@context", "context");
38+
}
3639
var fieldValue = super.getProperty(key, object);
3740
if (fieldValue instanceof Instant) {
3841
fieldValue = fieldValue.toString();
@@ -43,6 +46,7 @@ public Object getProperty(String key, Object object) {
4346
return fieldValue.toString().replace("\n", "");
4447
}
4548

49+
4650
// the VerifiableCredential has some dynamic types, such as the CredentialSubject
4751
if (fieldValue == null && key.contains("credentialSubject") && object instanceof VerifiableCredentialResource credentialResource) {
4852
fieldValue = handleCredentialSubject(key, credentialResource);

core/common-core/src/main/java/org/eclipse/edc/identityhub/defaults/EdcScopeToCriterionTransformer.java

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import org.eclipse.edc.spi.query.Criterion;
1919
import org.eclipse.edc.spi.result.Result;
2020

21+
import java.util.ArrayList;
2122
import java.util.List;
2223

2324
import static org.eclipse.edc.spi.result.Result.failure;
@@ -39,29 +40,39 @@
3940
*/
4041
public class EdcScopeToCriterionTransformer implements ScopeToCriterionTransformer {
4142
public static final String TYPE_OPERAND = "verifiableCredential.credential.type";
43+
// this has to include the "@" for Postgres queries to work because they operate on JSON
44+
public static final String CONTEXT_OPERAND = "verifiableCredential.credential.@context";
4245
public static final String ALIAS_LITERAL = "org.eclipse.edc.vc.type";
43-
public static final String LIKE_OPERATOR = "like";
4446
public static final String CONTAINS_OPERATOR = "contains";
4547
private static final String SCOPE_SEPARATOR = ":";
4648
private final List<String> allowedOperations = List.of("read", "*", "all");
4749

4850
@Override
49-
public Result<Criterion> transform(String scope) {
51+
public Result<List<Criterion>> transformScope(String scope) {
5052
var tokens = tokenize(scope);
5153
if (tokens.failed()) {
5254
return failure("Scope string cannot be converted: %s".formatted(tokens.getFailureDetail()));
5355
}
54-
var credentialType = tokens.getContent()[1];
55-
return success(new Criterion(TYPE_OPERAND, CONTAINS_OPERATOR, credentialType));
56+
var discriminator = tokens.getContent()[1];
57+
58+
return convertDiscriminator(discriminator);
5659
}
5760

5861
protected Result<String[]> tokenize(String scope) {
5962
if (scope == null) return failure("Scope was null");
6063

61-
var tokens = scope.split(SCOPE_SEPARATOR);
62-
if (tokens.length != 3) {
64+
var firstSeparatorIndex = scope.indexOf(SCOPE_SEPARATOR);
65+
var lastSeparatorIndex = scope.lastIndexOf(SCOPE_SEPARATOR);
66+
67+
if (firstSeparatorIndex == -1 || lastSeparatorIndex == -1 || firstSeparatorIndex == lastSeparatorIndex) {
6368
return failure("Scope string has invalid format.");
6469
}
70+
71+
var tokens = new String[3];
72+
tokens[0] = scope.substring(0, firstSeparatorIndex);
73+
tokens[1] = scope.substring(firstSeparatorIndex + 1, lastSeparatorIndex);
74+
tokens[2] = scope.substring(lastSeparatorIndex + 1);
75+
6576
if (!ALIAS_LITERAL.equalsIgnoreCase(tokens[0])) {
6677
return failure("Scope alias MUST be %s but was %s".formatted(ALIAS_LITERAL, tokens[0]));
6778
}
@@ -71,4 +82,33 @@ protected Result<String[]> tokenize(String scope) {
7182

7283
return success(tokens);
7384
}
85+
86+
private Result<List<Criterion>> convertDiscriminator(String discriminator) {
87+
if (discriminator == null) {
88+
return failure("discriminator was null");
89+
}
90+
91+
var lastHashIndex = discriminator.lastIndexOf("#");
92+
93+
if (lastHashIndex == -1) {
94+
// No hash found, treat entire string as type
95+
var typeCriterion = new Criterion(TYPE_OPERAND, CONTAINS_OPERATOR, discriminator);
96+
return success(List.of(typeCriterion));
97+
}
98+
99+
var list = new ArrayList<Criterion>();
100+
var contextPart = discriminator.substring(0, lastHashIndex);
101+
if (!contextPart.isEmpty()) {
102+
var contextCriterion = new Criterion(CONTEXT_OPERAND, CONTAINS_OPERATOR, contextPart);
103+
list.add(contextCriterion);
104+
}
105+
106+
var typePart = discriminator.substring(lastHashIndex + 1);
107+
if (!typePart.isEmpty()) {
108+
var typeCriterion = new Criterion(TYPE_OPERAND, CONTAINS_OPERATOR, typePart);
109+
list.add(typeCriterion);
110+
}
111+
112+
return success(list);
113+
}
74114
}
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/*
2+
* Copyright (c) 2026 Metaform Systems, Inc.
3+
*
4+
* This program and the accompanying materials are made available under the
5+
* terms of the Apache License, Version 2.0 which is available at
6+
* https://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* SPDX-License-Identifier: Apache-2.0
9+
*
10+
* Contributors:
11+
* Metaform Systems, Inc. - initial API and implementation
12+
*
13+
*/
14+
15+
package org.eclipse.edc.identityhub.defaults;
16+
17+
import com.fasterxml.jackson.core.JsonProcessingException;
18+
import com.fasterxml.jackson.databind.ObjectMapper;
19+
import org.eclipse.edc.iam.verifiablecredentials.spi.model.CredentialFormat;
20+
import org.eclipse.edc.iam.verifiablecredentials.spi.model.VerifiableCredential;
21+
import org.eclipse.edc.iam.verifiablecredentials.spi.model.VerifiableCredentialContainer;
22+
import org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VcStatus;
23+
import org.eclipse.edc.identityhub.spi.verifiablecredentials.model.VerifiableCredentialResource;
24+
import org.eclipse.edc.jsonld.util.JacksonJsonLd;
25+
import org.junit.jupiter.api.BeforeEach;
26+
import org.junit.jupiter.api.Test;
27+
28+
import java.time.Instant;
29+
import java.util.List;
30+
import java.util.Map;
31+
32+
import static org.assertj.core.api.Assertions.assertThat;
33+
34+
class CredentialResourceLookupTest {
35+
36+
private static final String VC_JSON = """
37+
{
38+
"@context": [
39+
"https://www.w3.org/2018/credentials/v1",
40+
"https://www.w3.org/2018/credentials/examples/v1",
41+
"https://example.org/2026/foo/bar"
42+
],
43+
"id": "http://example.edu/credentials/1872",
44+
"type": [
45+
"VerifiableCredential",
46+
"AlumniCredential"
47+
],
48+
"issuer": "https://example.edu/issuers/565049",
49+
"issuanceDate": "2010-01-01T19:23:24Z",
50+
"expirationDate": "2999-01-01T19:23:24Z",
51+
"credentialSubject": {
52+
"id": "did:example:ebfeb1f712ebc6f1c276e12ec21",
53+
"alumniOf": {
54+
"id": "did:example:c276e12ec21ebfeb1f712ebc6f1",
55+
"name": "Example University"
56+
}
57+
},
58+
"proof": {
59+
"type": "RsaSignature2018",
60+
"created": "2017-06-18T21:19:10Z",
61+
"proofPurpose": "assertionMethod",
62+
"verificationMethod": "https://example.edu/issuers/565049#key-1",
63+
"jws": "eyJhbGciOiJSUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19..TCYt5X"
64+
}
65+
}
66+
""";
67+
68+
private final ObjectMapper objectMapper = JacksonJsonLd.createObjectMapper();
69+
private CredentialResourceLookup lookup;
70+
71+
@BeforeEach
72+
void setUp() {
73+
lookup = new CredentialResourceLookup();
74+
}
75+
76+
@Test
77+
void getProperty_whenContextFieldRequested_shouldReplaceAtSymbol() {
78+
var resource = createTestResource();
79+
80+
var result = lookup.getProperty("verifiableCredential.credential.@context", resource);
81+
82+
assertThat(result).isNotNull();
83+
}
84+
85+
@Test
86+
void getProperty_whenInstantField_shouldReturnString() {
87+
var timestamp = Instant.parse("2024-01-01T10:00:00Z");
88+
var resource = VerifiableCredentialResource.Builder.newHolder()
89+
.id("test-id")
90+
.issuerId("test-issuer")
91+
.holderId("test-holder")
92+
.build();
93+
resource.setCredentialStatus(VcStatus.INITIAL);
94+
95+
var result = lookup.getProperty("timeOfLastStatusUpdate", resource);
96+
97+
assertThat(result).isInstanceOf(String.class);
98+
}
99+
100+
@Test
101+
void getProperty_whenRawVcField_shouldRemoveNewlines() {
102+
var resource = createTestResource();
103+
104+
var result = lookup.getProperty("verifiableCredential.rawVc", resource);
105+
106+
assertThat(result).isInstanceOf(String.class);
107+
assertThat((String) result).doesNotContain("\n");
108+
}
109+
110+
@Test
111+
void getProperty_whenCredentialSubjectClaim_shouldExtractFromClaims() {
112+
var resource = createTestResource();
113+
114+
var result = lookup.getProperty("verifiableCredential.credential.credentialSubject.alumniOf", resource);
115+
116+
assertThat(result).isNotNull();
117+
assertThat(result).isInstanceOf(Map.class);
118+
@SuppressWarnings("unchecked")
119+
var alumniOf = (Map<String, Object>) result;
120+
assertThat(alumniOf).containsEntry("name", "Example University");
121+
}
122+
123+
@Test
124+
void getProperty_whenCredentialSubjectNestedClaim_shouldExtractNestedValue() {
125+
var resource = createTestResource();
126+
127+
var result = lookup.getProperty("verifiableCredential.credential.credentialSubject.alumniOf.name", resource);
128+
129+
assertThat(result).isEqualTo("Example University");
130+
}
131+
132+
@Test
133+
void getProperty_whenCredentialSubjectClaimNotFound_shouldReturnNull() {
134+
var resource = createTestResource();
135+
136+
var result = lookup.getProperty("verifiableCredential.credential.credentialSubject.nonExistentField", resource);
137+
138+
assertThat(result).isNull();
139+
}
140+
141+
@Test
142+
void getProperty_whenCredentialSubjectId_shouldExtractId() {
143+
var resource = createTestResource();
144+
145+
var result = lookup.getProperty("verifiableCredential.credential.credentialSubject.id", resource);
146+
147+
assertThat(result).isInstanceOf(List.class);
148+
assertThat((List<String>) result).contains("did:example:ebfeb1f712ebc6f1c276e12ec21");
149+
}
150+
151+
@Test
152+
void getProperty_whenStandardField_shouldReturnValue() {
153+
var resource = VerifiableCredentialResource.Builder.newHolder()
154+
.id("test-resource-id")
155+
.issuerId("test-issuer")
156+
.holderId("test-holder")
157+
.build();
158+
159+
var result = lookup.getProperty("id", resource);
160+
161+
assertThat(result).isEqualTo("test-resource-id");
162+
}
163+
164+
@Test
165+
void getProperty_whenNestedCredentialField_shouldReturnValue() {
166+
var resource = createTestResource();
167+
168+
var result = lookup.getProperty("verifiableCredential.credential.issuer", resource);
169+
170+
assertThat(result).isNotNull();
171+
}
172+
173+
@Test
174+
void getProperty_whenNonExistentField_shouldReturnNull() {
175+
var resource = createTestResource();
176+
177+
var result = lookup.getProperty("nonExistentField", resource);
178+
179+
assertThat(result).isNull();
180+
}
181+
182+
@Test
183+
void getProperty_whenCredentialType_shouldReturnTypeList() {
184+
var resource = createTestResource();
185+
186+
var result = lookup.getProperty("verifiableCredential.credential.type", resource);
187+
188+
assertThat(result).isInstanceOf(List.class);
189+
@SuppressWarnings("unchecked")
190+
var types = (List<String>) result;
191+
assertThat(types).containsExactlyInAnyOrder("VerifiableCredential", "AlumniCredential");
192+
}
193+
194+
private VerifiableCredentialResource createTestResource() {
195+
try {
196+
var credential = objectMapper.readValue(VC_JSON, VerifiableCredential.class);
197+
var container = new VerifiableCredentialContainer(VC_JSON, CredentialFormat.VC1_0_LD, credential);
198+
199+
return VerifiableCredentialResource.Builder.newHolder()
200+
.id("test-id")
201+
.credential(container)
202+
.state(VcStatus.INITIAL)
203+
.issuerId("test-issuer")
204+
.holderId("test-holder")
205+
.build();
206+
} catch (JsonProcessingException e) {
207+
throw new RuntimeException(e);
208+
}
209+
}
210+
}

core/common-core/src/test/java/org/eclipse/edc/identityhub/defaults/EdcScopeToCriterionTransformerTest.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@ class EdcScopeToCriterionTransformerTest {
2828
"org.eclipse.edc.vc.type:TestCredential:*",
2929
"org.eclipse.edc.vc.type:TestCredential:all",
3030
"org.eclipse.edc.vc.type:foo:all",
31+
"org.eclipse.edc.vc.type:https://example.com/contexts/v1#TestCredential:read",
32+
"org.eclipse.edc.vc.type:https://example.com/contexts/v1/#TestCredential:read",
3133
})
3234
void transform_validScope(String scope) {
33-
assertThat(transformer.transform(scope)).isSucceeded();
35+
assertThat(transformer.transformScope(scope)).isSucceeded();
3436
}
3537

3638
@ParameterizedTest
@@ -40,8 +42,9 @@ void transform_validScope(String scope) {
4042
"org.eclipse.edc.vc.type:TestCredential:foo",
4143
"org.eclipse.edc::foo",
4244
"org.eclipse.edc:foo",
45+
"org.eclipse.edc:https://example.com/contexts/v1#:foo",
4346
})
4447
void transform_invalidScope(String scope) {
45-
assertThat(transformer.transform(scope)).isFailed();
48+
assertThat(transformer.transformScope(scope)).isFailed();
4649
}
4750
}

core/identity-hub-core/src/main/java/org/eclipse/edc/identityhub/core/services/query/CredentialQueryResolverImpl.java

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,9 @@ private boolean filterInvalidCredentials(VerifiableCredentialResource verifiable
148148
* @param scopes The list of scope strings to parse and convert.
149149
* @return A {@link Result} containing the list of converted {@link Criterion} objects.
150150
*/
151-
private Result<List<Criterion>> parseScopes(List<String> scopes) {
151+
private Result<List<List<Criterion>>> parseScopes(List<String> scopes) {
152152
var transformResult = scopes.stream()
153-
.map(scopeTransformer::transform)
153+
.map(scopeTransformer::transformScope)
154154
.toList();
155155

156156
if (transformResult.stream().anyMatch(AbstractResult::failed)) {
@@ -159,7 +159,7 @@ private Result<List<Criterion>> parseScopes(List<String> scopes) {
159159
return success(transformResult.stream().map(AbstractResult::getContent).toList());
160160
}
161161

162-
private Result<Collection<VerifiableCredentialResource>> queryCredentials(List<Criterion> criteria, String participantContextId) {
162+
private Result<Collection<VerifiableCredentialResource>> queryCredentials(List<List<Criterion>> criteria, String participantContextId) {
163163
var results = criteria.stream()
164164
.map(criterion -> convertToQuerySpec(criterion, participantContextId))
165165
.map(credentialStore::query)
@@ -173,13 +173,17 @@ private Result<Collection<VerifiableCredentialResource>> queryCredentials(List<C
173173
.collect(Collectors.toList()));
174174
}
175175

176-
private QuerySpec convertToQuerySpec(Criterion criteria, String participantContextId) {
176+
private QuerySpec convertToQuerySpec(List<Criterion> criteria, String participantContextId) {
177177
var filterByParticipant = new Criterion("participantContextId", "=", participantContextId);
178178
var filterNotRevoked = new Criterion("state", "!=", VcStatus.REVOKED.code());
179179
var filterNotExpired = new Criterion("state", "!=", VcStatus.EXPIRED.code());
180180
var filterUsageHolder = new Criterion("usage", "=", CredentialUsage.Holder.toString());
181+
182+
var allCriteria = Stream.concat(criteria.stream(),
183+
Stream.of(filterByParticipant, filterNotRevoked, filterNotExpired, filterUsageHolder)).toList();
184+
181185
return QuerySpec.Builder.newInstance()
182-
.filter(List.of(criteria, filterByParticipant, filterNotRevoked, filterNotExpired, filterUsageHolder))
186+
.filter(allCriteria)
183187
.build();
184188
}
185189

0 commit comments

Comments
 (0)