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 }