From f1ac3f86840f04c3ab77925de77896ab63a821f6 Mon Sep 17 00:00:00 2001 From: Andres Uribe Date: Thu, 17 Aug 2023 14:18:15 -0400 Subject: [PATCH] Added batch update for statuses (#658) * Added batch update for statuses * PR feedback. --------- Co-authored-by: Gabe <7622243+decentralgabe@users.noreply.github.com> --- config/config.go | 4 +- config/dev.toml | 1 + config/kitchensink.toml | 1 + config/prod.toml | 1 + config/test.toml | 1 + doc/swagger.yaml | 76 +++++++ integration/common.go | 23 +++ .../credential_manifest_integration_test.go | 58 +++++- ...atch-update-credential-statuses-input.json | 12 ++ pkg/server/router/credential.go | 66 +++++++ pkg/server/server.go | 5 +- pkg/server/server_credential_test.go | 187 ++++++++++++++++++ pkg/server/server_test.go | 2 +- pkg/service/credential/model.go | 18 +- pkg/service/credential/service.go | 103 +++++++--- pkg/service/credential/storage.go | 18 +- 16 files changed, 539 insertions(+), 37 deletions(-) create mode 100644 integration/testdata/batch-update-credential-statuses-input.json diff --git a/config/config.go b/config/config.go index 337684a01..73c7bb0b9 100644 --- a/config/config.go +++ b/config/config.go @@ -146,8 +146,10 @@ func (d *DIDServiceConfig) IsEmpty() bool { } type CredentialServiceConfig struct { - // BatchCreateMaxItems set's the maximum amount that can be. + // BatchCreateMaxItems set's the maximum amount of credentials that can be created in a single request. BatchCreateMaxItems int `toml:"batch_create_max_items" conf:"default:100"` + // BatchUpdateStatusMaxItems set's the maximum amount of credentials statuses that can be updated in a single request. + BatchUpdateStatusMaxItems int `toml:"batch_update_status_max_items" conf:"default:100"` // TODO(gabe) supported key and signature types } diff --git a/config/dev.toml b/config/dev.toml index dca1ffc4b..949009129 100644 --- a/config/dev.toml +++ b/config/dev.toml @@ -41,6 +41,7 @@ batch_create_max_items = 100 [services.credential] batch_create_max_items = 100 +batch_update_status_max_items = 100 [services.webhook] webhook_timeout = "10s" diff --git a/config/kitchensink.toml b/config/kitchensink.toml index 7c66a722c..26daf1536 100644 --- a/config/kitchensink.toml +++ b/config/kitchensink.toml @@ -51,6 +51,7 @@ batch_create_max_items = 100 [services.credential] batch_create_max_items = 100 +batch_update_status_max_items = 100 [services.webhook] webhook_timeout = "10s" \ No newline at end of file diff --git a/config/prod.toml b/config/prod.toml index 7373790dd..8aba78071 100644 --- a/config/prod.toml +++ b/config/prod.toml @@ -51,6 +51,7 @@ batch_create_max_items = 100 [services.credential] batch_create_max_items = 100 +batch_update_status_max_items = 100 [services.webhook] webhook_timeout = "10s" diff --git a/config/test.toml b/config/test.toml index 536d0998d..6e4dc3725 100644 --- a/config/test.toml +++ b/config/test.toml @@ -53,6 +53,7 @@ batch_create_max_items = 100 [services.credential] batch_create_max_items = 100 +batch_update_status_max_items = 100 [services.webhook] webhook_timeout = "10s" diff --git a/doc/swagger.yaml b/doc/swagger.yaml index 43e02e955..1931a1d0b 100644 --- a/doc/swagger.yaml +++ b/doc/swagger.yaml @@ -563,6 +563,19 @@ definitions: description: Whether this credential is currently suspended. type: boolean type: object + github_com_tbd54566975_ssi-service_pkg_service_credential.Status: + properties: + id: + description: ID of the credentials whose status this object represents. + type: string + revoked: + type: boolean + suspended: + type: boolean + required: + - revoked + - suspended + type: object github_com_tbd54566975_ssi-service_pkg_service_did.CreateIONDIDOptions: properties: jwsPublicKeys: @@ -1088,6 +1101,24 @@ definitions: $ref: '#/definitions/did.Document' type: array type: object + pkg_server_router.BatchUpdateCredentialStatusRequest: + properties: + requests: + description: Required. The list of update credential requests. Cannot be more + than the config value in `services.credentials.batch_update_status_max_items`. + items: + $ref: '#/definitions/pkg_server_router.SingleUpdateCredentialStatusRequest' + type: array + required: + - requests + type: object + pkg_server_router.BatchUpdateCredentialStatusResponse: + properties: + credentialStatuses: + items: + $ref: '#/definitions/github_com_tbd54566975_ssi-service_pkg_service_credential.Status' + type: array + type: object pkg_server_router.CreateCredentialRequest: properties: '@context': @@ -1943,6 +1974,21 @@ definitions: id: type: string type: object + pkg_server_router.SingleUpdateCredentialStatusRequest: + properties: + id: + description: ID of the credential who's status should be updated. + type: string + revoked: + description: |- + The new revoked status of this credential. The status will be saved in the encodedList of the StatusList2021 + credential associated with this VC. + type: boolean + suspended: + type: boolean + required: + - id + type: object pkg_server_router.StateChange: properties: publicKeyIdsToRemove: @@ -2612,6 +2658,36 @@ paths: summary: Get a Credential Status List tags: - Credentials + /v1/credentials/status/batch: + put: + consumes: + - application/json + description: Updates the status all a batch of Verifiable Credentials. + parameters: + - description: request body + in: body + name: request + required: true + schema: + $ref: '#/definitions/pkg_server_router.BatchUpdateCredentialStatusRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/pkg_server_router.BatchUpdateCredentialStatusResponse' + "400": + description: Bad request + schema: + type: string + "500": + description: Internal server error + schema: + type: string + summary: Batch Update a Verifiable Credential's status + tags: + - Credentials /v1/credentials/verification: put: consumes: diff --git a/integration/common.go b/integration/common.go index 3070a9dc3..6de315f1e 100644 --- a/integration/common.go +++ b/integration/common.go @@ -242,6 +242,29 @@ func BatchCreateVerifiableCredentials(credentialInput batchCredInputParams) (str return output, nil } +type batchUpdateStatusInputParams struct { + CredentialID0 string + Suspended0 bool + CredentialID1 string + Revoked1 bool +} + +func BatchUpdateVerifiableCredentialStatuses(updateStatusInput batchUpdateStatusInputParams) (string, error) { + logrus.Println("\n\nCreate a verifiable credential") + + updateStatusesJSON, err := resolveTemplate(updateStatusInput, "batch-update-credential-statuses-input.json") + if err != nil { + return "", err + } + + output, err := put(endpoint+version+"credentials/status/batch", updateStatusesJSON) + if err != nil { + return "", errors.Wrap(err, "error writing batch update status") + } + + return output, nil +} + func BatchCreate100VerifiableCredentials(credentialInput credInputParams) (string, error) { logrus.Println("\n\nCreate a verifiable credential") diff --git a/integration/credential_manifest_integration_test.go b/integration/credential_manifest_integration_test.go index c36b4cb58..0c69b7018 100644 --- a/integration/credential_manifest_integration_test.go +++ b/integration/credential_manifest_integration_test.go @@ -1,6 +1,7 @@ package integration import ( + "strings" "testing" "github.com/TBD54566975/ssi-sdk/credential/parsing" @@ -125,7 +126,7 @@ func TestBatchCreateCredentialsIntegration(t *testing.T) { SchemaID: schemaID.(string), SubjectID0: issuerDID.(string), SubjectID1: issuerDID.(string), - Revocable0: false, + Suspendable0: true, Revocable1: true, }) assert.NoError(t, err) @@ -138,6 +139,61 @@ func TestBatchCreateCredentialsIntegration(t *testing.T) { credentialJWT1, err := getJSONElement(vcsOutput, "$.credentials[1].credentialJwt") assert.NoError(t, err) assert.NotEmpty(t, credentialJWT1) + + credentialID0, err := getJSONElement(vcsOutput, "$.credentials[0].credential.id") + assert.NoError(t, err) + + credentialID1, err := getJSONElement(vcsOutput, "$.credentials[1].credential.id") + assert.NoError(t, err) + + SetValue(credentialManifestContext, "credentialID0", credentialID0) + SetValue(credentialManifestContext, "credentialID1", credentialID1) +} + +func idFromURL(id string) string { + lastIdx := strings.LastIndex(id, "/") + return id[lastIdx+1:] +} + +func TestBatchUpdateCredentialStatusIntegration(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + fullCredentialID0, err := GetValue(credentialManifestContext, "credentialID0") + assert.NoError(t, err) + assert.NotEmpty(t, fullCredentialID0) + + fullCredentialID1, err := GetValue(credentialManifestContext, "credentialID1") + assert.NoError(t, err) + assert.NotEmpty(t, fullCredentialID1) + + credentialID0 := idFromURL(fullCredentialID0.(string)) + credentialID1 := idFromURL(fullCredentialID1.(string)) + updatesOutput, err := BatchUpdateVerifiableCredentialStatuses(batchUpdateStatusInputParams{ + CredentialID0: credentialID0, + Suspended0: true, + CredentialID1: credentialID1, + Revoked1: true, + }) + assert.NoError(t, err) + assert.NotEmpty(t, updatesOutput) + + id0, err := getJSONElement(updatesOutput, "$.credentialStatuses[0].id") + assert.NoError(t, err) + assert.Equal(t, credentialID0, id0) + + sus, err := getJSONElement(updatesOutput, "$.credentialStatuses[0].suspended") + assert.NoError(t, err) + assert.Equal(t, "true", sus) + + id1, err := getJSONElement(updatesOutput, "$.credentialStatuses[1].id") + assert.NoError(t, err) + assert.Equal(t, credentialID1, id1) + + rev, err := getJSONElement(updatesOutput, "$.credentialStatuses[1].revoked") + assert.NoError(t, err) + assert.Equal(t, "true", rev) } func TestBatchCreate100CredentialsIntegration(t *testing.T) { diff --git a/integration/testdata/batch-update-credential-statuses-input.json b/integration/testdata/batch-update-credential-statuses-input.json new file mode 100644 index 000000000..ffd373085 --- /dev/null +++ b/integration/testdata/batch-update-credential-statuses-input.json @@ -0,0 +1,12 @@ +{ + "requests": [ + { + "id": "{{.CredentialID0}}", + "suspended": {{.Suspended0}} + }, + { + "id": "{{.CredentialID1}}", + "revoked": {{.Revoked1}} + } + ] +} \ No newline at end of file diff --git a/pkg/server/router/credential.go b/pkg/server/router/credential.go index bd2a0563f..ca09e4f3e 100644 --- a/pkg/server/router/credential.go +++ b/pkg/server/router/credential.go @@ -339,6 +339,72 @@ type UpdateCredentialStatusResponse struct { Suspended bool `json:"suspended"` } +type SingleUpdateCredentialStatusRequest struct { + // ID of the credential who's status should be updated. + ID string `json:"id" validate:"required"` + UpdateCredentialStatusRequest +} + +type BatchUpdateCredentialStatusRequest struct { + // Required. The list of update credential requests. Cannot be more than the config value in `services.credentials.batch_update_status_max_items`. + Requests []SingleUpdateCredentialStatusRequest `json:"requests" maxItems:"100" validate:"required,dive"` +} + +func (r BatchUpdateCredentialStatusRequest) toServiceRequest() credential.BatchUpdateCredentialStatusRequest { + var req credential.BatchUpdateCredentialStatusRequest + for _, routerReq := range r.Requests { + serviceReq := routerReq.toServiceRequest(routerReq.ID) + req.Requests = append(req.Requests, serviceReq) + } + return req +} + +type BatchUpdateCredentialStatusResponse struct { + CredentialStatuses []credential.Status `json:"credentialStatuses"` +} + +// BatchUpdateCredentialStatus godoc +// +// @Summary Batch Update a Verifiable Credential's status +// @Description Updates the status all a batch of Verifiable Credentials. +// @Tags Credentials +// @Accept json +// @Produce json +// @Param request body BatchUpdateCredentialStatusRequest true "request body" +// @Success 201 {object} BatchUpdateCredentialStatusResponse +// @Failure 400 {string} string "Bad request" +// @Failure 500 {string} string "Internal server error" +// @Router /v1/credentials/status/batch [put] +func (cr CredentialRouter) BatchUpdateCredentialStatus(c *gin.Context) { + var batchRequest BatchUpdateCredentialStatusRequest + invalidCreateCredentialRequest := "invalid batch update credential request" + if err := framework.Decode(c.Request, &batchRequest); err != nil { + errMsg := invalidCreateCredentialRequest + framework.LoggingRespondErrWithMsg(c, err, errMsg, http.StatusBadRequest) + return + } + + batchUpdateMaxItems := cr.service.Config().BatchUpdateStatusMaxItems + if len(batchRequest.Requests) > batchUpdateMaxItems { + framework.LoggingRespondErrMsg(c, fmt.Sprintf("max number of requests is %d", batchUpdateMaxItems), http.StatusBadRequest) + return + } + + req := batchRequest.toServiceRequest() + batchUpdateResponse, err := cr.service.BatchUpdateCredentialStatus(c, req) + + if err != nil { + errMsg := "could not update credentials" + framework.LoggingRespondErrWithMsg(c, err, errMsg, http.StatusInternalServerError) + return + } + + var resp BatchUpdateCredentialStatusResponse + resp.CredentialStatuses = append(resp.CredentialStatuses, batchUpdateResponse.CredentialStatuses...) + + framework.Respond(c, resp, http.StatusOK) +} + // UpdateCredentialStatus godoc // // @Summary Update a Verifiable Credential's status diff --git a/pkg/server/server.go b/pkg/server/server.go index 9a986b87d..252f89cc8 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -44,6 +44,8 @@ const ( VerificationPath = "/verification" WebhookPrefix = "/webhooks" DIDConfigurationsPrefix = "/did-configurations" + + batchSuffix = "/batch" ) // SSIServer exposes all dependencies needed to run a http server and all its services @@ -209,7 +211,7 @@ func CredentialAPI(rg *gin.RouterGroup, service svcframework.Service, webhookSer // Credentials credentialAPI := rg.Group(CredentialsPrefix) credentialAPI.PUT("", middleware.Webhook(webhookService, webhook.Credential, webhook.Create), credRouter.CreateCredential) - credentialAPI.PUT("/batch", middleware.Webhook(webhookService, webhook.Credential, webhook.BatchCreate), credRouter.BatchCreateCredentials) + credentialAPI.PUT(batchSuffix, middleware.Webhook(webhookService, webhook.Credential, webhook.BatchCreate), credRouter.BatchCreateCredentials) credentialAPI.GET("", credRouter.ListCredentials) credentialAPI.GET("/:id", credRouter.GetCredential) credentialAPI.PUT(VerificationPath, credRouter.VerifyCredential) @@ -218,6 +220,7 @@ func CredentialAPI(rg *gin.RouterGroup, service svcframework.Service, webhookSer // Credential Status credentialAPI.GET("/:id"+StatusPrefix, credRouter.GetCredentialStatus) credentialAPI.PUT("/:id"+StatusPrefix, credRouter.UpdateCredentialStatus) + credentialAPI.PUT(StatusPrefix+batchSuffix, credRouter.BatchUpdateCredentialStatus) credentialAPI.GET(StatusPrefix+"/:id", credRouter.GetCredentialStatusList) return } diff --git a/pkg/server/server_credential_test.go b/pkg/server/server_credential_test.go index 8875ccf3e..5ec18b846 100644 --- a/pkg/server/server_credential_test.go +++ b/pkg/server/server_credential_test.go @@ -11,6 +11,7 @@ import ( "github.com/goccy/go-json" "github.com/google/uuid" + "github.com/mohae/deepcopy" "github.com/tbd54566975/ssi-service/pkg/testutil" @@ -31,6 +32,192 @@ func TestCredentialAPI(t *testing.T) { for _, test := range testutil.TestDatabases { t.Run(test.Name, func(tt *testing.T) { + tt.Run("Batch Update Credential Status", func(ttt *testing.T) { + db := test.ServiceStorage(ttt) + require.NotEmpty(ttt, db) + + keyStoreService, _ := testKeyStoreService(ttt, db) + didService, _ := testDIDService(ttt, db, keyStoreService, nil) + schemaService := testSchemaService(ttt, db, keyStoreService, didService) + credRouter := testCredentialRouter(ttt, db, keyStoreService, didService, schemaService) + + issuerDID, err := didService.CreateDIDByMethod(context.Background(), did.CreateDIDRequest{ + Method: didsdk.KeyMethod, + KeyType: crypto.Ed25519, + }) + assert.NoError(ttt, err) + assert.NotEmpty(ttt, issuerDID) + + batchCreateCredentialsRequest := router.BatchCreateCredentialsRequest{ + Requests: []router.CreateCredentialRequest{ + { + Issuer: issuerDID.DID.ID, + VerificationMethodID: issuerDID.DID.VerificationMethod[0].ID, + Subject: "did:abc:456", + Data: map[string]any{ + "firstName": "Jack", + "lastName": "Dorsey", + }, + Suspendable: true, + }, + { + Issuer: issuerDID.DID.ID, + VerificationMethodID: issuerDID.DID.VerificationMethod[0].ID, + Subject: "did:abc:789", + Data: map[string]any{ + "firstName": "Lemony", + "lastName": "Snickets", + }, + Revocable: true, + }, + { + Issuer: issuerDID.DID.ID, + VerificationMethodID: issuerDID.DID.VerificationMethod[0].ID, + Subject: "did:abc:abc", + Data: map[string]any{ + "firstName": "Curtis", + "lastName": "Fictious", + }, + Suspendable: true, + }, + }, + } + requestValue := newRequestValue(ttt, batchCreateCredentialsRequest) + req := httptest.NewRequest(http.MethodPut, "https://ssi-service.com/v1/credentials/batch", requestValue) + w := httptest.NewRecorder() + c := newRequestContext(w, req) + credRouter.BatchCreateCredentials(c) + assert.True(ttt, util.Is2xxResponse(w.Code)) + + var resp router.BatchCreateCredentialsResponse + err = json.NewDecoder(w.Body).Decode(&resp) + assert.NoError(ttt, err) + + assert.Len(ttt, resp.Credentials, 3) + + // Now we got to updates + updateCredStatusRequest := router.BatchUpdateCredentialStatusRequest{ + Requests: []router.SingleUpdateCredentialStatusRequest{ + { + ID: idFromURI(resp.Credentials[0].ID), + UpdateCredentialStatusRequest: router.UpdateCredentialStatusRequest{ + Suspended: true, + }, + }, + { + ID: idFromURI(resp.Credentials[1].ID), + UpdateCredentialStatusRequest: router.UpdateCredentialStatusRequest{ + Revoked: true, + }, + }, + { + ID: idFromURI(resp.Credentials[2].ID), + UpdateCredentialStatusRequest: router.UpdateCredentialStatusRequest{ + Suspended: true, + }, + }, + }, + } + + ttt.Run("empty batch returns success", func(ttt *testing.T) { + requestValue = newRequestValue(ttt, router.BatchUpdateCredentialStatusRequest{ + Requests: []router.SingleUpdateCredentialStatusRequest{}, + }) + req = httptest.NewRequest(http.MethodPut, "https://ssi-service.com/v1/credentials/status/batch", requestValue) + w = httptest.NewRecorder() + c = newRequestContext(w, req) + credRouter.BatchUpdateCredentialStatus(c) + assert.True(ttt, util.Is2xxResponse(w.Code)) + }) + + ttt.Run("all credentials are updated", func(ttt *testing.T) { + requestValue = newRequestValue(ttt, updateCredStatusRequest) + req = httptest.NewRequest(http.MethodPut, "https://ssi-service.com/v1/credentials/status/batch", requestValue) + w = httptest.NewRecorder() + c = newRequestContext(w, req) + credRouter.BatchUpdateCredentialStatus(c) + assert.True(ttt, util.Is2xxResponse(w.Code)) + + var credStatusUpdateResponse router.BatchUpdateCredentialStatusResponse + err = json.NewDecoder(w.Body).Decode(&credStatusUpdateResponse) + assert.NoError(ttt, err) + + assert.Len(ttt, credStatusUpdateResponse.CredentialStatuses, 3) + assert.True(ttt, credStatusUpdateResponse.CredentialStatuses[0].Suspended) + assert.True(ttt, credStatusUpdateResponse.CredentialStatuses[1].Revoked) + assert.True(ttt, credStatusUpdateResponse.CredentialStatuses[2].Suspended) + }) + + ttt.Run("updates are idempotent", func(ttt *testing.T) { + requestValue = newRequestValue(ttt, updateCredStatusRequest) + req = httptest.NewRequest(http.MethodPut, "https://ssi-service.com/v1/credentials/status/batch", requestValue) + w = httptest.NewRecorder() + c = newRequestContext(w, req) + credRouter.BatchUpdateCredentialStatus(c) + assert.True(ttt, util.Is2xxResponse(w.Code)) + + var credStatusUpdateResponse router.BatchUpdateCredentialStatusResponse + err = json.NewDecoder(w.Body).Decode(&credStatusUpdateResponse) + assert.NoError(ttt, err) + + assert.Len(ttt, credStatusUpdateResponse.CredentialStatuses, 3) + assert.True(ttt, credStatusUpdateResponse.CredentialStatuses[0].Suspended) + assert.True(ttt, credStatusUpdateResponse.CredentialStatuses[1].Revoked) + assert.True(ttt, credStatusUpdateResponse.CredentialStatuses[2].Suspended) + }) + + ttt.Run("missing ID fails", func(ttt *testing.T) { + updateCredStatusRequest := deepcopy.Copy(updateCredStatusRequest).(router.BatchUpdateCredentialStatusRequest) + updateCredStatusRequest.Requests[0].ID = "" + requestValue = newRequestValue(ttt, updateCredStatusRequest) + req = httptest.NewRequest(http.MethodPut, "https://ssi-service.com/v1/credentials/status/batch", requestValue) + w = httptest.NewRecorder() + c = newRequestContext(w, req) + credRouter.BatchUpdateCredentialStatus(c) + assert.False(ttt, util.Is2xxResponse(w.Code)) + + var errJSON map[string]any + err = json.NewDecoder(w.Body).Decode(&errJSON) + assert.NoError(ttt, err) + + assert.Contains(ttt, errJSON["error"], "field validation error") + }) + + ttt.Run("second credential does not exist", func(ttt *testing.T) { + updateCredStatusRequest := deepcopy.Copy(updateCredStatusRequest).(router.BatchUpdateCredentialStatusRequest) + updateCredStatusRequest.Requests[1].ID = "made up id 1" + requestValue = newRequestValue(ttt, updateCredStatusRequest) + req = httptest.NewRequest(http.MethodPut, "https://ssi-service.com/v1/credentials/status/batch", requestValue) + w = httptest.NewRecorder() + c = newRequestContext(w, req) + credRouter.BatchUpdateCredentialStatus(c) + assert.False(ttt, util.Is2xxResponse(w.Code)) + + var errJSON map[string]any + err = json.NewDecoder(w.Body).Decode(&errJSON) + assert.NoError(ttt, err) + + assert.Contains(ttt, errJSON["error"], "credential not found with id: made up id 1") + }) + + ttt.Run("revoking a suspendable credential returns error", func(ttt *testing.T) { + updateCredStatusRequest := deepcopy.Copy(updateCredStatusRequest).(router.BatchUpdateCredentialStatusRequest) + updateCredStatusRequest.Requests[2].Revoked = true + updateCredStatusRequest.Requests[2].Suspended = false + requestValue = newRequestValue(ttt, updateCredStatusRequest) + req = httptest.NewRequest(http.MethodPut, "https://ssi-service.com/v1/credentials/status/batch", requestValue) + w = httptest.NewRecorder() + c = newRequestContext(w, req) + credRouter.BatchUpdateCredentialStatus(c) + assert.False(ttt, util.Is2xxResponse(w.Code)) + + var errJSON map[string]any + err = json.NewDecoder(w.Body).Decode(&errJSON) + assert.NoError(ttt, err) + + assert.Contains(ttt, errJSON["error"], "has a different status purpose value than the status credential") + }) + }) tt.Run("Batch Create Credentials", func(ttt *testing.T) { db := test.ServiceStorage(ttt) diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index f3b624617..f0306b397 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -282,7 +282,7 @@ func testSchemaRouter(t *testing.T, bolt storage.ServiceStorage, keyStore *keyst } func testCredentialService(t *testing.T, db storage.ServiceStorage, keyStore *keystore.Service, did *did.Service, schema *schema.Service) *credential.Service { - serviceConfig := config.CredentialServiceConfig{BatchCreateMaxItems: 1000} + serviceConfig := config.CredentialServiceConfig{BatchCreateMaxItems: 1000, BatchUpdateStatusMaxItems: 10} // create a credential service credentialService, err := credential.NewCredentialService(serviceConfig, db, keyStore, did.GetResolver(), schema) diff --git a/pkg/service/credential/model.go b/pkg/service/credential/model.go index a14f56c0c..3e67af651 100644 --- a/pkg/service/credential/model.go +++ b/pkg/service/credential/model.go @@ -85,8 +85,22 @@ type UpdateCredentialStatusRequest struct { } type UpdateCredentialStatusResponse struct { - Revoked bool `json:"revoked" validate:"required"` - Suspended bool `json:"suspended" validate:"required"` + Status +} + +type Status struct { + // ID of the credentials whose status this object represents. + ID string `json:"id,omitempty"` + Revoked bool `json:"revoked" validate:"required"` + Suspended bool `json:"suspended" validate:"required"` +} + +type BatchUpdateCredentialStatusRequest struct { + Requests []UpdateCredentialStatusRequest `json:"requests"` +} + +type BatchUpdateCredentialStatusResponse struct { + CredentialStatuses []Status `json:"credentialStatuses"` } type GetCredentialStatusListRequest struct { diff --git a/pkg/service/credential/service.go b/pkg/service/credential/service.go index 3d4573df5..6574ccc06 100644 --- a/pkg/service/credential/service.go +++ b/pkg/service/credential/service.go @@ -425,34 +425,14 @@ func (s Service) GetCredentialStatusList(ctx context.Context, request GetCredent } func (s Service) UpdateCredentialStatus(ctx context.Context, request UpdateCredentialStatusRequest) (*UpdateCredentialStatusResponse, error) { - gotCred, err := s.storage.GetCredential(ctx, request.ID) - if err != nil { - return nil, sdkutil.LoggingErrorMsgf(err, "could not get credential: %s", request.ID) - } - - if gotCred.Credential.CredentialStatus == nil { - return nil, sdkutil.LoggingNewErrorf("credential %q has no credentialStatus field", gotCred.LocalCredentialID) - } - - statusPurpose := gotCred.Credential.CredentialStatus.(map[string]any)["statusPurpose"].(string) - if len(statusPurpose) == 0 { - return nil, sdkutil.LoggingNewErrorf("status purpose could not be derived from credential status") - } - statusListCredential, err := s.storage.GetStatusListCredentialKeyData(ctx, gotCred.Issuer, gotCred.Schema, statussdk.StatusPurpose(statusPurpose)) + statusListCredentialWatchKey, err := s.statusListCredentialWatchKey(ctx, request.ID) if err != nil { - return nil, errors.Wrap(err, "getting status list watch key uuid data") - } - - if statusListCredential == nil { - return nil, errors.Wrap(err, "status list credential should exist in order to update") + return nil, err } - statusListCredentialWatchKey := s.storage.GetStatusListCredentialWatchKey(gotCred.Issuer, gotCred.Schema, statusPurpose) - - slcMetadata := StatusListCredentialMetadata{statusListCredentialWatchKey: statusListCredentialWatchKey} - - watchKeys := []storage.WatchKey{statusListCredentialWatchKey} + slcMetadata := StatusListCredentialMetadata{statusListCredentialWatchKey: *statusListCredentialWatchKey} + watchKeys := []storage.WatchKey{*statusListCredentialWatchKey} returnFunc := s.updateCredentialStatusFunc(request, slcMetadata) returnValue, err := s.storage.db.Execute(ctx, returnFunc, watchKeys) @@ -492,7 +472,10 @@ func (s Service) updateCredentialStatusBusinessLogic(ctx context.Context, tx sto // if the request is the same as what the current credential is there is no action if gotCred.Revoked == request.Revoked && gotCred.Suspended == request.Suspended { logrus.Warn("request and credential have same status, no action is needed") - response := UpdateCredentialStatusResponse{Revoked: gotCred.Revoked, Suspended: gotCred.Suspended} + response := UpdateCredentialStatusResponse{Status{ + Revoked: gotCred.Revoked, + Suspended: gotCred.Suspended, + }} return &response, nil } @@ -501,7 +484,7 @@ func (s Service) updateCredentialStatusBusinessLogic(ctx context.Context, tx sto return nil, sdkutil.LoggingErrorMsg(err, "updating credential") } - response := UpdateCredentialStatusResponse{Revoked: container.Revoked, Suspended: container.Suspended} + response := UpdateCredentialStatusResponse{Status{Revoked: container.Revoked, Suspended: container.Suspended}} return &response, nil } @@ -675,3 +658,71 @@ func (s Service) BatchCreateCredentials(ctx context.Context, batchRequest BatchC return credResponse, nil } + +func (s Service) BatchUpdateCredentialStatus(ctx context.Context, batchRequest BatchUpdateCredentialStatusRequest) (*BatchUpdateCredentialStatusResponse, error) { + watchKeys := make([]storage.WatchKey, 0, len(batchRequest.Requests)) + updateFuncs := make([]storage.BusinessLogicFunc, 0, len(batchRequest.Requests)) + for _, request := range batchRequest.Requests { + statusListCredentialWatchKey, err := s.statusListCredentialWatchKey(ctx, request.ID) + if err != nil { + return nil, err + } + watchKeys = append(watchKeys, *statusListCredentialWatchKey) + + slcMetadata := StatusListCredentialMetadata{statusListCredentialWatchKey: *statusListCredentialWatchKey} + returnFunc := s.updateCredentialStatusFunc(request, slcMetadata) + updateFuncs = append(updateFuncs, returnFunc) + } + returnValue, err := s.storage.db.Execute(ctx, func(ctx context.Context, tx storage.Tx) (any, error) { + batchResponse := BatchUpdateCredentialStatusResponse{ + CredentialStatuses: make([]Status, 0, len(batchRequest.Requests)), + } + for i, updateFunc := range updateFuncs { + updateResp, err := updateFunc(ctx, tx) + if err != nil { + return nil, err + } + batchResponse.CredentialStatuses = append(batchResponse.CredentialStatuses, updateResp.(*UpdateCredentialStatusResponse).Status) + batchResponse.CredentialStatuses[i].ID = batchRequest.Requests[i].ID + } + return &batchResponse, nil + }, watchKeys) + if err != nil { + return nil, errors.Wrap(err, "execute") + } + + batchResponse, ok := returnValue.(*BatchUpdateCredentialStatusResponse) + if !ok { + return nil, errors.New("casting to BatchUpdateCredentialStatusResponse") + } + + return batchResponse, nil +} + +func (s Service) statusListCredentialWatchKey(ctx context.Context, id string) (*storage.WatchKey, error) { + gotCred, err := s.storage.GetCredential(ctx, id) + if err != nil { + return nil, errors.Wrap(err, "reading credential") + } + + if !gotCred.HasCredentialStatus() { + return nil, sdkutil.LoggingNewErrorf("credential %q has no credentialStatus field", gotCred.LocalCredentialID) + } + + statusPurpose := gotCred.GetStatusPurpose() + if len(statusPurpose) == 0 { + return nil, sdkutil.LoggingNewErrorf("status purpose could not be derived from credential status") + } + + statusListCredential, err := s.storage.GetStatusListCredentialKeyData(ctx, gotCred.Issuer, gotCred.Schema, statussdk.StatusPurpose(statusPurpose)) + if err != nil { + return nil, errors.Wrap(err, "getting status list watch key uuid data") + } + + if statusListCredential == nil { + return nil, errors.Wrap(err, "status list credential should exist in order to update") + } + + statusListCredentialWatchKey := s.storage.GetStatusListCredentialWatchKey(gotCred.Issuer, gotCred.Schema, statusPurpose) + return &statusListCredentialWatchKey, nil +} diff --git a/pkg/service/credential/storage.go b/pkg/service/credential/storage.go index a72fc1d8b..f645fdab7 100644 --- a/pkg/service/credential/storage.go +++ b/pkg/service/credential/storage.go @@ -50,7 +50,7 @@ type StoredCredential struct { Suspended bool `json:"suspended"` } -func (sc StoredCredential) FilterVariablesMap() map[string]any { +func (sc *StoredCredential) FilterVariablesMap() map[string]any { return map[string]any{ "issuer": sc.Issuer, "schema": sc.Schema, @@ -70,18 +70,26 @@ type StatusListCredentialMetadata struct { statusListCurrentIndexWatchKey storage.WatchKey } -func (sc StoredCredential) IsValid() bool { +func (sc *StoredCredential) IsValid() bool { return sc.Key != "" && (sc.HasDataIntegrityCredential() || sc.HasJWTCredential()) } -func (sc StoredCredential) HasDataIntegrityCredential() bool { +func (sc *StoredCredential) HasDataIntegrityCredential() bool { return sc.Credential != nil && sc.Credential.Proof != nil } -func (sc StoredCredential) HasJWTCredential() bool { +func (sc *StoredCredential) HasJWTCredential() bool { return sc.CredentialJWT != nil } +func (sc *StoredCredential) HasCredentialStatus() bool { + return sc != nil && sc.Credential != nil && sc.Credential.CredentialStatus != nil +} + +func (sc *StoredCredential) GetStatusPurpose() string { + return sc.Credential.CredentialStatus.(map[string]any)["statusPurpose"].(string) +} + const ( credentialNamespace = "credential" statusListCredentialNamespace = "status-list-credential" @@ -380,7 +388,7 @@ func (cs *Storage) ListCredentials(ctx context.Context, filter filtering.Filter, if err = json.Unmarshal(cred, &nextCred); err != nil { logrus.WithError(err).WithField("idx", i).Warnf("Skipping operation") } - include, err := shouldInclude(nextCred) + include, err := shouldInclude(&nextCred) // We explicitly ignore evaluation errors and simply include them in the result. if err != nil || include { storedCreds = append(storedCreds, nextCred)