From e4885a535e03bf646f015efd91251f493f0a247e Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger Date: Wed, 11 Nov 2026 11:09:22 +0100 Subject: [PATCH] fix: revoke the participant's credentials when a VPA is disposed The onboarding agent's dispose never revoked anything. It queried the IssuerService for credentials whose holder ID is the participant context ID, but the IssuerService records the credential subject (the participant's DID) as the holder ID, so the query found nothing. Even with results, the revoke request put the tenant's participant context in the path instead of the issuer's, and passed the verifiable credential's ID where the IssuerService expects the ID of its credential resource. QueryCredentialsByType now takes the holder ID separately from the participant context the token is bound to, and the agent passes the DID from cfm.participant.id. RevokeCredential uses the issuer's participant context in the path, and the agent revokes the credential resource ID. Co-Authored-By: Claude Opus 5.5 --- agent/common/issuerservice/issuer.go | 12 ++-- agent/common/issuerservice/issuer_test.go | 28 +++++----- .../onboarding/activity/activity.go | 19 +++++-- .../onboarding/activity/activity_test.go | 56 +++++++++++++++++-- .../registration/activity/activity_test.go | 2 +- 5 files changed, 88 insertions(+), 29 deletions(-) diff --git a/agent/common/issuerservice/issuer.go b/agent/common/issuerservice/issuer.go index 8d1249a8..cfe75dbd 100644 --- a/agent/common/issuerservice/issuer.go +++ b/agent/common/issuerservice/issuer.go @@ -49,8 +49,12 @@ type IssuerCredentialResourceDto struct { type ApiClient interface { CreateHolder(ctx context.Context, participantContextID string, did string, holderID string, name string, properties map[string]any) error DeleteHolder(ctx context.Context, participantContextID string, holderID string) error + // RevokeCredential revokes an issued credential. credentialID is the ID of the IssuerService's credential resource + // (IssuerCredentialResourceDto.ID), not the ID of the verifiable credential itself. RevokeCredential(ctx context.Context, participantContextID string, credentialID string) error - QueryCredentialsByType(ctx context.Context, participantContextID string, credentialType string) ([]IssuerCredentialResourceDto, error) + // QueryCredentialsByType returns the credentials of the given type that were issued to the holder. The IssuerService + // records the credential subject's ID (the holder's DID) as the holder ID of an issued credential. + QueryCredentialsByType(ctx context.Context, participantContextID string, holderID string, credentialType string) ([]IssuerCredentialResourceDto, error) } type HttpApiClient struct { @@ -60,8 +64,8 @@ type HttpApiClient struct { HttpClient *http.Client } -func (i HttpApiClient) QueryCredentialsByType(ctx context.Context, holderID string, credentialType string) ([]IssuerCredentialResourceDto, error) { - accessToken, err := i.TokenProvider.GetToken(ctx, ScopeApiAdmin, holderID) +func (i HttpApiClient) QueryCredentialsByType(ctx context.Context, participantContextID string, holderID string, credentialType string) ([]IssuerCredentialResourceDto, error) { + accessToken, err := i.TokenProvider.GetToken(ctx, ScopeApiAdmin, participantContextID) if err != nil { return nil, fmt.Errorf("failed to get API access token: %w", err) } @@ -179,7 +183,7 @@ func (i HttpApiClient) RevokeCredential(ctx context.Context, participantContextI if err != nil { return err } - url := fmt.Sprintf("%s/v1/participants/%s/credentials/%s/revoke", i.BaseURL, url.PathEscape(participantContextID), url.PathEscape(credentialID)) + url := fmt.Sprintf("%s/v1/participants/%s/credentials/%s/revoke", i.BaseURL, url.PathEscape(i.IssuerID), url.PathEscape(credentialID)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) if err != nil { return err diff --git a/agent/common/issuerservice/issuer_test.go b/agent/common/issuerservice/issuer_test.go index 64176abd..b52f0d44 100644 --- a/agent/common/issuerservice/issuer_test.go +++ b/agent/common/issuerservice/issuer_test.go @@ -107,10 +107,8 @@ func TestHttpApiClient_CreateHolder_ApiReturnsError(t *testing.T) { } func TestHttpApiClient_RevokeCredential(t *testing.T) { - template := "/v1/participants/.*/credentials/.*/revoke" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - matched, _ := regexp.MatchString(template, r.URL.Path) - if matched && r.Method == http.MethodPost { + if r.URL.Path == "/v1/participants/test-issuer/credentials/test-credential-id/revoke" && r.Method == http.MethodPost { w.WriteHeader(http.StatusOK) } else { w.WriteHeader(http.StatusInternalServerError) @@ -119,7 +117,7 @@ func TestHttpApiClient_RevokeCredential(t *testing.T) { defer server.Close() tp := mocks.NewMockTokenProvider(t) - tp.On("GetToken", mock.Anything, mock.Anything, mock.Anything).Return("test token", nil) + tp.On("GetToken", mock.Anything, ScopeApiAdmin, "test-participant").Return("test token", nil) client := HttpApiClient{ BaseURL: server.URL, TokenProvider: tp, @@ -127,7 +125,7 @@ func TestHttpApiClient_RevokeCredential(t *testing.T) { HttpClient: &http.Client{}, } - err := client.RevokeCredential(t.Context(), "did:web:test-participant", "test-credential-id") + err := client.RevokeCredential(t.Context(), "test-participant", "test-credential-id") require.NoError(t, err) } @@ -257,11 +255,11 @@ func TestHttpApiClient_DeleteHolder_AuthError(t *testing.T) { } func TestHttpApiClient_QueryCredentialsByType(t *testing.T) { - template := "/v1/participants/.*/credentials/query" + template := "^/v1/participants/test-issuer/credentials/query$" credentials := []IssuerCredentialResourceDto{ { ID: "cred-1", - ParticipantContextID: "did:web:test-participant", + ParticipantContextID: "test-issuer", CredentialFormat: common.CredentialFormat_VCDM10_JWT, VerifiableCredential: common.VerifiableCredential{ ID: "vc-1", @@ -296,7 +294,7 @@ func TestHttpApiClient_QueryCredentialsByType(t *testing.T) { defer server.Close() tp := mocks.NewMockTokenProvider(t) - tp.On("GetToken", mock.Anything, mock.Anything, mock.Anything).Return("test token", nil) + tp.On("GetToken", mock.Anything, ScopeApiAdmin, "test-participant").Return("test token", nil) client := HttpApiClient{ BaseURL: server.URL, TokenProvider: tp, @@ -304,11 +302,11 @@ func TestHttpApiClient_QueryCredentialsByType(t *testing.T) { HttpClient: &http.Client{}, } - result, err := client.QueryCredentialsByType(t.Context(), "did:web:test-participant", "MembershipCredential") + result, err := client.QueryCredentialsByType(t.Context(), "test-participant", "did:web:test-participant", "MembershipCredential") require.NoError(t, err) require.Len(t, result, 1) assert.Equal(t, "cred-1", result[0].ID) - assert.Equal(t, "did:web:test-participant", result[0].ParticipantContextID) + assert.Equal(t, "test-issuer", result[0].ParticipantContextID) } func TestHttpApiClient_QueryCredentialsByType_AuthError(t *testing.T) { @@ -321,7 +319,7 @@ func TestHttpApiClient_QueryCredentialsByType_AuthError(t *testing.T) { HttpClient: &http.Client{}, } - result, err := client.QueryCredentialsByType(t.Context(), "did:web:test-participant", "MembershipCredential") + result, err := client.QueryCredentialsByType(t.Context(), "test-participant", "did:web:test-participant", "MembershipCredential") require.ErrorContains(t, err, "auth error") require.Nil(t, result) } @@ -347,7 +345,7 @@ func TestHttpApiClient_QueryCredentialsByType_ApiReturnsError(t *testing.T) { HttpClient: &http.Client{}, } - result, err := client.QueryCredentialsByType(t.Context(), "did:web:test-participant", "MembershipCredential") + result, err := client.QueryCredentialsByType(t.Context(), "test-participant", "did:web:test-participant", "MembershipCredential") require.ErrorContains(t, err, "failed to query credentials on IssuerService") require.Nil(t, result) } @@ -375,7 +373,7 @@ func TestHttpApiClient_QueryCredentialsByType_InvalidJson(t *testing.T) { HttpClient: &http.Client{}, } - result, err := client.QueryCredentialsByType(t.Context(), "did:web:test-participant", "MembershipCredential") + result, err := client.QueryCredentialsByType(t.Context(), "test-participant", "did:web:test-participant", "MembershipCredential") require.ErrorContains(t, err, "error parsing credentials response") require.Nil(t, result) } @@ -392,9 +390,9 @@ func TestHttpApiClient_RevokeCredential_EscapesPathSegments(t *testing.T) { client := HttpApiClient{ BaseURL: server.URL, TokenProvider: tp, - IssuerID: "test-issuer", + IssuerID: "../other?force=true#", HttpClient: &http.Client{}, } - require.NoError(t, client.RevokeCredential(t.Context(), "../other?force=true#", "a/b")) + require.NoError(t, client.RevokeCredential(t.Context(), "test-participant", "a/b")) } diff --git a/agent/orchestration/onboarding/activity/activity.go b/agent/orchestration/onboarding/activity/activity.go index b9b6f2d1..1e4aaf33 100644 --- a/agent/orchestration/onboarding/activity/activity.go +++ b/agent/orchestration/onboarding/activity/activity.go @@ -41,6 +41,12 @@ type credentialRequestData struct { CredentialRequestURL string `json:"credentialRequest"` } +// holderData identifies the participant as a holder in the IssuerService, which records the credential subject (the +// participant's DID) as the holder ID of every credential it issues. +type holderData struct { + HolderID string `json:"cfm.participant.id" validate:"required"` +} + // ProcessDeploy processes the deploy action for an onboarding activity by requesting the issuance of verifiable credentials. // if a credential request is in progress, the deploy action simply checks its status and reschedules itself. func (p OnboardingActivityProcessor) ProcessDeploy(ctx api.ActivityContext) api.ActivityResult { @@ -75,27 +81,32 @@ func (p OnboardingActivityProcessor) ProcessDispose(ctx api.ActivityContext) api return api.ActivityResult{Result: api.ActivityResultFatalError, Error: fmt.Errorf("error processing Onboarding activity for orchestration %s: %w", ctx.OID(), err)} } + var holder holderData + if err := ctx.ReadValues(&holder); err != nil { + return api.ActivityResult{Result: api.ActivityResultFatalError, Error: fmt.Errorf("error processing Onboarding activity for orchestration %s: %w", ctx.OID(), err)} + } + participantContextID := credentialRequest.ParticipantContextID - // query credentials by type, using the IdentityAPI + // query the holder's credentials by type, using the IssuerService's Admin API var revocationErrors []error for _, spec := range data.CredentialSpecs { credentialType := spec.Type - credentials, err := p.IssuerServiceApiClient.QueryCredentialsByType(ctx.Context(), participantContextID, credentialType) + credentials, err := p.IssuerServiceApiClient.QueryCredentialsByType(ctx.Context(), participantContextID, holder.HolderID, credentialType) if err != nil { return api.ActivityResult{Result: api.ActivityResultFatalError, Error: fmt.Errorf("error querying credentials by type %s for participant context %s: %w", credentialType, participantContextID, err)} } if len(credentials) == 0 { - p.Monitor.Infof("Rollback: could not revoke credentials of type '%s': 0 found for participant context '%s'", credentialType, participantContextID) + p.Monitor.Infof("Rollback: could not revoke credentials of type '%s': 0 found for holder '%s'", credentialType, holder.HolderID) continue } // for each credential, send a revocation request for _, credentialDto := range credentials { - err := p.IssuerServiceApiClient.RevokeCredential(ctx.Context(), participantContextID, credentialDto.VerifiableCredential.ID) + err := p.IssuerServiceApiClient.RevokeCredential(ctx.Context(), participantContextID, credentialDto.ID) if err != nil { revocationErrors = append(revocationErrors, err) } diff --git a/agent/orchestration/onboarding/activity/activity_test.go b/agent/orchestration/onboarding/activity/activity_test.go index f022af05..32432186 100644 --- a/agent/orchestration/onboarding/activity/activity_test.go +++ b/agent/orchestration/onboarding/activity/activity_test.go @@ -305,22 +305,27 @@ func TestOnboardingActivityProcessor_ProcessDeploy_WhenInvalidStateReceived(t *t } func TestOnboardingActivityProcessor_ProcessDispose(t *testing.T) { + queriedHolderIDs := make([]string, 0) + revokedIDs := make([]string, 0) processor := OnboardingActivityProcessor{ Monitor: system.NoopMonitor{}, IssuerServiceApiClient: MockIssuerServiceApiClient{ expectedCredentials: []issuerservice.IssuerCredentialResourceDto{ { - ID: "", - ParticipantContextID: "test-participant", + ID: "test-credential-id", + ParticipantContextID: "test-issuer", CredentialFormat: common.CredentialFormat_VCDM20_COSE, - VerifiableCredential: common.VerifiableCredential{}, + VerifiableCredential: common.VerifiableCredential{ID: "test-vc-id"}, }, }, + queriedHolderIDs: &queriedHolderIDs, + revokedIDs: &revokedIDs, }, } var processingData = map[string]any{ "participantContextId": "test-participant", + "cfm.participant.id": "did:web:test-participant", "cfm.vpa.credentials": []any{ map[string]string{ "id": "id", @@ -346,6 +351,40 @@ func TestOnboardingActivityProcessor_ProcessDispose(t *testing.T) { assert.Equal(t, api.ActivityResultType(api.ActivityResultComplete), result.Result) assert.NoError(t, result.Error) + assert.Equal(t, []string{"did:web:test-participant"}, queriedHolderIDs, "credentials must be queried by the holder's DID") + assert.Equal(t, []string{"test-credential-id"}, revokedIDs, "the credential resource ID must be revoked, not the VC ID") +} + +func TestOnboardingActivityProcessor_ProcessDispose_MissingParticipantID(t *testing.T) { + processor := OnboardingActivityProcessor{ + Monitor: system.NoopMonitor{}, + IssuerServiceApiClient: MockIssuerServiceApiClient{}, + } + + var processingData = map[string]any{ + "participantContextId": "test-participant", + "cfm.vpa.credentials": []any{ + map[string]string{ + "id": "id", + "format": "format", + "issuer": "issuer", + "type": "type", + }, + }, + } + + activity := api.Activity{ + ID: "test-activity", + Type: "edcv", + Discriminator: api.DisposeDiscriminator, + } + + activityContext := api.NewActivityContext(context.Background(), "orch-123", activity, processingData, make(map[string]any)) + + result := processor.ProcessDispose(activityContext) + + assert.Equal(t, api.ActivityResultType(api.ActivityResultFatalError), result.Result) + assert.Error(t, result.Error) } func TestOnboardingActivityProcessor_ProcessDispose_RevocationFails(t *testing.T) { @@ -368,6 +407,7 @@ func TestOnboardingActivityProcessor_ProcessDispose_RevocationFails(t *testing.T var processingData = map[string]any{ "participantContextId": "test-participant", + "cfm.participant.id": "did:web:test-participant", "cfm.vpa.credentials": []any{ map[string]string{ "id": "id", @@ -409,6 +449,7 @@ func TestOnboardingActivityProcessor_ProcessDispose_NoCredentials(t *testing.T) var processingData = map[string]any{ "participantContextId": "test-participant", + "cfm.participant.id": "did:web:test-participant", "cfm.vpa.credentials": []any{ map[string]string{ "id": "id", @@ -457,6 +498,7 @@ func TestOnboardingActivityProcessor_ProcessDispose_FirstTypeWithoutCredentials( var processingData = map[string]any{ "participantContextId": "test-participant", + "cfm.participant.id": "did:web:test-participant", "cfm.vpa.credentials": []any{ map[string]string{ "id": "id-1", @@ -488,7 +530,7 @@ func TestOnboardingActivityProcessor_ProcessDispose_FirstTypeWithoutCredentials( assert.Equal(t, api.ActivityResultType(api.ActivityResultComplete), result.Result) assert.NoError(t, result.Error) - assert.Equal(t, []string{"cred-2"}, revokedIDs, "credentials of later types must still be revoked when an earlier type has none") + assert.Equal(t, []string{"test-credential-id"}, revokedIDs, "credentials of later types must still be revoked when an earlier type has none") } type MockIdentityHubClient struct { @@ -524,9 +566,13 @@ type MockIssuerServiceApiClient struct { expectedCredentials []issuerservice.IssuerCredentialResourceDto credentialsByType map[string][]issuerservice.IssuerCredentialResourceDto revokedIDs *[]string + queriedHolderIDs *[]string } -func (m MockIssuerServiceApiClient) QueryCredentialsByType(ctx context.Context, participantContextID string, credentialType string) ([]issuerservice.IssuerCredentialResourceDto, error) { +func (m MockIssuerServiceApiClient) QueryCredentialsByType(ctx context.Context, participantContextID string, holderID string, credentialType string) ([]issuerservice.IssuerCredentialResourceDto, error) { + if m.queriedHolderIDs != nil { + *m.queriedHolderIDs = append(*m.queriedHolderIDs, holderID) + } if m.credentialsByType != nil { return m.credentialsByType[credentialType], nil } diff --git a/agent/orchestration/registration/activity/activity_test.go b/agent/orchestration/registration/activity/activity_test.go index e0377957..f0782b4e 100644 --- a/agent/orchestration/registration/activity/activity_test.go +++ b/agent/orchestration/registration/activity/activity_test.go @@ -386,7 +386,7 @@ type MockIssuerService struct { recorded regData } -func (m *MockIssuerService) QueryCredentialsByType(ctx context.Context, participantContextID string, credentialType string) ([]issuerservice.IssuerCredentialResourceDto, error) { +func (m *MockIssuerService) QueryCredentialsByType(ctx context.Context, participantContextID string, holderID string, credentialType string) ([]issuerservice.IssuerCredentialResourceDto, error) { return nil, nil }