-
Notifications
You must be signed in to change notification settings - Fork 23
#4120: Selection CredentialSelector — filter candidates by PD field ID values #4121
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feature/4088-credential-selector
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,108 @@ | ||||||||||||
| /* | ||||||||||||
| * Copyright (C) 2026 Nuts community | ||||||||||||
| * | ||||||||||||
| * This program is free software: you can redistribute it and/or modify | ||||||||||||
| * it under the terms of the GNU General Public License as published by | ||||||||||||
| * the Free Software Foundation, either version 3 of the License, or | ||||||||||||
| * (at your option) any later version. | ||||||||||||
| * | ||||||||||||
| * This program is distributed in the hope that it will be useful, | ||||||||||||
| * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||||||||
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||||||||||||
| * GNU General Public License for more details. | ||||||||||||
| * | ||||||||||||
| * You should have received a copy of the GNU General Public License | ||||||||||||
| * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||||||||||||
| * | ||||||||||||
| */ | ||||||||||||
|
|
||||||||||||
| package pe | ||||||||||||
|
|
||||||||||||
| import ( | ||||||||||||
| "fmt" | ||||||||||||
|
|
||||||||||||
| "github.com/nuts-foundation/go-did/vc" | ||||||||||||
| ) | ||||||||||||
|
|
||||||||||||
| type fieldSelection struct { | ||||||||||||
| fieldID string | ||||||||||||
| expected string | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| // NewSelectionSelector creates a CredentialSelector that filters candidates using | ||||||||||||
| // named field ID values from the credential_selection parameter. | ||||||||||||
| func NewSelectionSelector(selection map[string]string, pd PresentationDefinition, fallback CredentialSelector) (CredentialSelector, error) { | ||||||||||||
| descriptorSelections := make(map[string][]fieldSelection) | ||||||||||||
| matchedKeys := make(map[string]bool) | ||||||||||||
|
|
||||||||||||
| for _, desc := range pd.InputDescriptors { | ||||||||||||
| if desc.Constraints == nil { | ||||||||||||
| continue | ||||||||||||
| } | ||||||||||||
| for _, field := range desc.Constraints.Fields { | ||||||||||||
| if field.Id == nil { | ||||||||||||
| continue | ||||||||||||
| } | ||||||||||||
| if expected, ok := selection[*field.Id]; ok { | ||||||||||||
| descriptorSelections[desc.Id] = append(descriptorSelections[desc.Id], fieldSelection{ | ||||||||||||
| fieldID: *field.Id, | ||||||||||||
| expected: expected, | ||||||||||||
| }) | ||||||||||||
| matchedKeys[*field.Id] = true | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| // Validate all selection keys match at least one field ID in the PD. | ||||||||||||
| for key := range selection { | ||||||||||||
| if !matchedKeys[key] { | ||||||||||||
| return nil, fmt.Errorf("credential_selection key '%s' does not match any field id in the presentation definition", key) | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| return func(descriptor InputDescriptor, candidates []vc.VerifiableCredential) (*vc.VerifiableCredential, error) { | ||||||||||||
| selections, ok := descriptorSelections[descriptor.Id] | ||||||||||||
| if !ok { | ||||||||||||
| return fallback(descriptor, candidates) | ||||||||||||
| } | ||||||||||||
|
Comment on lines
+63
to
+67
|
||||||||||||
|
|
||||||||||||
| var matched []vc.VerifiableCredential | ||||||||||||
| for _, candidate := range candidates { | ||||||||||||
| if descriptor.Constraints == nil { | ||||||||||||
| continue | ||||||||||||
| } | ||||||||||||
| isMatch, values, err := matchConstraint(descriptor.Constraints, candidate) | ||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We have multiple constraint types in Presentation Definitions. We generally use Does that work properly with |
||||||||||||
| if err != nil || !isMatch { | ||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is it worth logging the error, if we're not returning it? In which cases cant his occur?
|
||||||||||||
| if err != nil || !isMatch { | |
| if err != nil { | |
| return nil, fmt.Errorf("input descriptor '%s': %w", descriptor.Id, err) | |
| } | |
| if !isMatch { |
Copilot
AI
Mar 26, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NewSelectionSelector re-runs matchConstraint for each candidate even though the selector contract says all candidates already match the input descriptor (they’re produced by PresentationDefinition.matchConstraints). This duplicates JSON remarshal + full constraint evaluation and can be a noticeable overhead when wallets contain many VCs. Consider resolving only the selected field values (e.g., build a fieldID→Field map at construction and use matchField/getValueAtPath on a single remarshal), without re-checking the entire constraint set.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why are we doing this? Support for bools and numbers? Does that actually work?
Why not just comparison through ==?
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,220 @@ | ||
| /* | ||
| * Copyright (C) 2026 Nuts community | ||
| * | ||
| * This program is free software: you can redistribute it and/or modify | ||
| * it under the terms of the GNU General Public License as published by | ||
| * the Free Software Foundation, either version 3 of the License, or | ||
| * (at your option) any later version. | ||
| * | ||
| * This program is distributed in the hope that it will be useful, | ||
| * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| * GNU General Public License for more details. | ||
| * | ||
| * You should have received a copy of the GNU General Public License | ||
| * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
| * | ||
| */ | ||
|
|
||
| package pe | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| ssi "github.com/nuts-foundation/go-did" | ||
| "github.com/nuts-foundation/go-did/vc" | ||
| "github.com/nuts-foundation/nuts-node/core/to" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // nopeSelector returns a fallback that fails the test if called. | ||
| func nopeSelector(t *testing.T) CredentialSelector { | ||
| return func(_ InputDescriptor, _ []vc.VerifiableCredential) (*vc.VerifiableCredential, error) { | ||
| t.Fatal("fallback selector should not be called") | ||
| return nil, nil | ||
| } | ||
| } | ||
|
|
||
| func TestNewSelectionSelector(t *testing.T) { | ||
| id1 := ssi.MustParseURI("1") | ||
| id2 := ssi.MustParseURI("2") | ||
| vc1 := credentialToJSONLD(vc.VerifiableCredential{ID: &id1, CredentialSubject: []map[string]any{{"patientId": "123"}}}) | ||
| vc2 := credentialToJSONLD(vc.VerifiableCredential{ID: &id2, CredentialSubject: []map[string]any{{"patientId": "456"}}}) | ||
| pd := PresentationDefinition{ | ||
| InputDescriptors: []*InputDescriptor{ | ||
| { | ||
| Id: "patient_credential", | ||
| Constraints: &Constraints{ | ||
| Fields: []Field{ | ||
| { | ||
| Id: to.Ptr("patient_id"), | ||
| Path: []string{"$.credentialSubject.patientId"}, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| t.Run("selection picks the right credential by field value", func(t *testing.T) { | ||
| selector, err := NewSelectionSelector(map[string]string{ | ||
| "patient_id": "456", | ||
| }, pd, nopeSelector(t)) | ||
| require.NoError(t, err) | ||
|
|
||
| result, err := selector( | ||
| *pd.InputDescriptors[0], | ||
| []vc.VerifiableCredential{vc1, vc2}, | ||
| ) | ||
|
|
||
| require.NoError(t, err) | ||
| require.NotNil(t, result) | ||
| assert.Equal(t, &id2, result.ID) | ||
| }) | ||
| t.Run("zero matches returns ErrNoCredentials", func(t *testing.T) { | ||
| selector, err := NewSelectionSelector(map[string]string{ | ||
| "patient_id": "nonexistent", | ||
| }, pd, nopeSelector(t)) | ||
| require.NoError(t, err) | ||
|
|
||
| _, err = selector( | ||
| *pd.InputDescriptors[0], | ||
| []vc.VerifiableCredential{vc1, vc2}, | ||
| ) | ||
|
|
||
| assert.ErrorIs(t, err, ErrNoCredentials) | ||
| }) | ||
| t.Run("multiple matches returns ErrMultipleCredentials", func(t *testing.T) { | ||
| // Both VCs have a patientId field — selecting on a field that exists in both | ||
| // without narrowing to one should fail. | ||
| id3 := ssi.MustParseURI("3") | ||
| vc3 := credentialToJSONLD(vc.VerifiableCredential{ID: &id3, CredentialSubject: []map[string]any{{"patientId": "456"}}}) | ||
| selector, err := NewSelectionSelector(map[string]string{ | ||
| "patient_id": "456", | ||
| }, pd, nopeSelector(t)) | ||
| require.NoError(t, err) | ||
|
|
||
| _, err = selector( | ||
| *pd.InputDescriptors[0], | ||
| []vc.VerifiableCredential{vc2, vc3}, | ||
| ) | ||
|
|
||
| assert.ErrorIs(t, err, ErrMultipleCredentials) | ||
| }) | ||
| t.Run("unknown selection key returns construction error", func(t *testing.T) { | ||
| _, err := NewSelectionSelector(map[string]string{ | ||
| "nonexistent_field": "value", | ||
| }, pd, FirstMatchSelector) | ||
|
|
||
| assert.ErrorContains(t, err, "nonexistent_field") | ||
| }) | ||
| t.Run("no selection keys for descriptor falls back to default", func(t *testing.T) { | ||
| // Selection targets patient_credential, but we call with a different descriptor. | ||
| selector, err := NewSelectionSelector(map[string]string{ | ||
| "patient_id": "456", | ||
| }, pd, FirstMatchSelector) | ||
| require.NoError(t, err) | ||
|
|
||
| otherDescriptor := InputDescriptor{Id: "other_descriptor"} | ||
| result, err := selector( | ||
| otherDescriptor, | ||
| []vc.VerifiableCredential{vc1, vc2}, | ||
| ) | ||
|
|
||
| require.NoError(t, err) | ||
| require.NotNil(t, result) | ||
| // FirstMatchSelector picks vc1 | ||
| assert.Equal(t, &id1, result.ID) | ||
| }) | ||
| t.Run("multiple selection keys use AND semantics", func(t *testing.T) { | ||
| andPD := PresentationDefinition{ | ||
| InputDescriptors: []*InputDescriptor{ | ||
| { | ||
| Id: "enrollment", | ||
| Constraints: &Constraints{ | ||
| Fields: []Field{ | ||
| {Id: to.Ptr("patient_id"), Path: []string{"$.credentialSubject.patientId"}}, | ||
| {Id: to.Ptr("org_city"), Path: []string{"$.credentialSubject.city"}}, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
| // vc matching both criteria | ||
| idA := ssi.MustParseURI("A") | ||
| vcA := credentialToJSONLD(vc.VerifiableCredential{ID: &idA, CredentialSubject: []map[string]any{{"patientId": "123", "city": "Amsterdam"}}}) | ||
| // vc matching only patient_id | ||
| idB := ssi.MustParseURI("B") | ||
| vcB := credentialToJSONLD(vc.VerifiableCredential{ID: &idB, CredentialSubject: []map[string]any{{"patientId": "123", "city": "Rotterdam"}}}) | ||
|
|
||
| selector, err := NewSelectionSelector(map[string]string{ | ||
| "patient_id": "123", | ||
| "org_city": "Amsterdam", | ||
| }, andPD, nopeSelector(t)) | ||
| require.NoError(t, err) | ||
|
|
||
| result, err := selector( | ||
| *andPD.InputDescriptors[0], | ||
| []vc.VerifiableCredential{vcA, vcB}, | ||
| ) | ||
|
|
||
| require.NoError(t, err) | ||
| require.NotNil(t, result) | ||
| assert.Equal(t, &idA, result.ID) | ||
| }) | ||
| t.Run("multiple descriptors with independent selection keys", func(t *testing.T) { | ||
| multiPD := PresentationDefinition{ | ||
| InputDescriptors: []*InputDescriptor{ | ||
| { | ||
| Id: "org_credential", | ||
| Constraints: &Constraints{ | ||
| Fields: []Field{ | ||
| {Id: to.Ptr("ura"), Path: []string{"$.credentialSubject.ura"}}, | ||
| }, | ||
| }, | ||
| }, | ||
| { | ||
| Id: "patient_enrollment", | ||
| Constraints: &Constraints{ | ||
| Fields: []Field{ | ||
| {Id: to.Ptr("bsn"), Path: []string{"$.credentialSubject.bsn"}}, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
| idA := ssi.MustParseURI("A") | ||
| idB := ssi.MustParseURI("B") | ||
| idC := ssi.MustParseURI("C") | ||
| idD := ssi.MustParseURI("D") | ||
| vcA := credentialToJSONLD(vc.VerifiableCredential{ID: &idA, CredentialSubject: []map[string]any{{"ura": "URA-001"}}}) | ||
| vcB := credentialToJSONLD(vc.VerifiableCredential{ID: &idB, CredentialSubject: []map[string]any{{"ura": "URA-002"}}}) | ||
| vcC := credentialToJSONLD(vc.VerifiableCredential{ID: &idC, CredentialSubject: []map[string]any{{"bsn": "BSN-111"}}}) | ||
| vcD := credentialToJSONLD(vc.VerifiableCredential{ID: &idD, CredentialSubject: []map[string]any{{"bsn": "BSN-222"}}}) | ||
|
|
||
| selector, err := NewSelectionSelector(map[string]string{ | ||
| "ura": "URA-002", | ||
| "bsn": "BSN-111", | ||
| }, multiPD, nopeSelector(t)) | ||
| require.NoError(t, err) | ||
|
|
||
| // First descriptor: selects vcB (URA-002) | ||
| result, err := selector( | ||
| *multiPD.InputDescriptors[0], | ||
| []vc.VerifiableCredential{vcA, vcB}, | ||
| ) | ||
| require.NoError(t, err) | ||
| require.NotNil(t, result) | ||
| assert.Equal(t, &idB, result.ID) | ||
|
|
||
| // Second descriptor: selects vcC (BSN-111) | ||
| result, err = selector( | ||
| *multiPD.InputDescriptors[1], | ||
| []vc.VerifiableCredential{vcC, vcD}, | ||
| ) | ||
| require.NoError(t, err) | ||
| require.NotNil(t, result) | ||
| assert.Equal(t, &idC, result.ID) | ||
| }) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds like it selects a selection, but it just selects credentials? So why not
NewCredentialSelector?