Repository: TBD54566975/ssi-service
Pull request #250 (2023-01-11 23:34:04)

Added manual overrides for credential issuance upon application review.
# Overview
When a review happens, the issuer admin might want to override some values that will be put in the credentials that will be produced.

# Description
This PR changes. Noteworthy: using a map instead of a list for the overrides. This is because the order of the overrides doesn't matter.

# How Has This Been Tested?
Added overrides for a credential in existing tests. Additionally modified the e2e tests. 

## References
Still part of SIP5


Patch:
diff --git a/pkg/server/router/manifest.go b/pkg/server/router/manifest.go
index 7674191..9dfcda6 100644
--- a/pkg/server/router/manifest.go
+++ b/pkg/server/router/manifest.go
@@ -517,13 +517,18 @@ func (mr ManifestRouter) DeleteResponse(ctx context.Context, w http.ResponseWrit
 type ReviewApplicationRequest struct {
 	Approved bool   `json:"approved"`
 	Reason   string `json:"reason"`
+
+	// Overrides to apply to the credentials that will be created. Keys are the ID that corresponds to an
+	// OutputDescriptor.ID from the manifest.
+	CredentialOverrides map[string]model.CredentialOverride `json:"credential_overrides,omitempty"`
 }
 
 func (r ReviewApplicationRequest) toServiceRequest(id string) model.ReviewApplicationRequest {
 	return model.ReviewApplicationRequest{
-		ID:       id,
-		Approved: r.Approved,
-		Reason:   r.Reason,
+		ID:                  id,
+		Approved:            r.Approved,
+		Reason:              r.Reason,
+		CredentialOverrides: r.CredentialOverrides,
 	}
 }
 
diff --git a/pkg/service/manifest/model/model.go b/pkg/service/manifest/model/model.go
index bf50625..e20da95 100644
--- a/pkg/service/manifest/model/model.go
+++ b/pkg/service/manifest/model/model.go
@@ -1,6 +1,8 @@
 package model
 
 import (
+	"time"
+
 	"github.com/TBD54566975/ssi-sdk/credential/exchange"
 	manifestsdk "github.com/TBD54566975/ssi-sdk/credential/manifest"
 	cred "github.com/tbd54566975/ssi-service/internal/credential"
@@ -93,6 +95,8 @@ type ReviewApplicationRequest struct {
 	ID       string `json:"id" validate:"required"`
 	Approved bool   `json:"approved"`
 	Reason   string `json:"reason"`
+
+	CredentialOverrides map[string]CredentialOverride `json:"credential_overrides,omitempty"`
 }
 
 // Response
@@ -121,3 +125,14 @@ func ServiceModel(storedResponse *storage.StoredResponse) SubmitApplicationRespo
 		ResponseJWT: storedResponse.ResponseJWT,
 	}
 }
+
+type CredentialOverride struct {
+	// Data that will be used to determine credential claims.
+	Data map[string]any `json:"data"`
+
+	// Parameter to determine the expiry of the credential.
+	Expiry *time.Time `json:"expiry"`
+
+	// Whether the credentials created should be revocable.
+	Revocable bool `json:"revocable"`
+}
diff --git a/pkg/service/manifest/response.go b/pkg/service/manifest/response.go
index 7aa522d..b842180 100644
--- a/pkg/service/manifest/response.go
+++ b/pkg/service/manifest/response.go
@@ -18,6 +18,7 @@ import (
 	"github.com/tbd54566975/ssi-service/pkg/service/credential"
 	"github.com/tbd54566975/ssi-service/pkg/service/issuing"
 	"github.com/tbd54566975/ssi-service/pkg/service/keystore"
+	"github.com/tbd54566975/ssi-service/pkg/service/manifest/model"
 )
 
 const (
@@ -56,6 +57,7 @@ func (s Service) buildCredentialResponse(
 	template *issuing.IssuanceTemplate,
 	application manifest.CredentialApplication,
 	applicationJSON map[string]any,
+	credentialOverrides map[string]model.CredentialOverride,
 ) (*manifest.CredentialResponse, []cred.Container, error) {
 	// TODO(gabe) need to check if this can be fulfilled and conditionally return success/denial
 	applicationID := application.ID
@@ -98,6 +100,7 @@ func (s Service) buildCredentialResponse(
 				return nil, nil, err
 			}
 		}
+		s.applyRequestData(&credentialRequest, credentialOverrides, od)
 
 		credentialResponse, err := s.credential.CreateCredential(ctx, credentialRequest)
 		if err != nil {
@@ -146,6 +149,19 @@ func (s Service) buildCredentialResponse(
 	return credRes, creds, nil
 }
 
+func (s Service) applyRequestData(credentialRequest *credential.CreateCredentialRequest, credentialOverrides map[string]model.CredentialOverride, od manifest.OutputDescriptor) {
+	if credentialOverride, ok := credentialOverrides[od.ID]; ok {
+		for k, v := range credentialOverride.Data {
+			credentialRequest.Data[k] = v
+		}
+
+		if credentialOverride.Expiry != nil {
+			credentialRequest.Expiry = credentialOverride.Expiry.Format(time.RFC3339)
+		}
+		credentialRequest.Revocable = credentialOverride.Revocable
+	}
+}
+
 func (s Service) applyIssuanceTemplate(
 	credentialRequest *credential.CreateCredentialRequest,
 	template *issuing.IssuanceTemplate,
diff --git a/pkg/service/manifest/service.go b/pkg/service/manifest/service.go
index addd6a9..5184661 100644
--- a/pkg/service/manifest/service.go
+++ b/pkg/service/manifest/service.go
@@ -374,17 +374,7 @@ func (s Service) maybeIssueAutomatically(
 		logrus.Warnf("found multiple issuance templates, using first entry only")
 	}
 
-	credResp, creds, err := s.buildCredentialResponse(
-		ctx,
-		applicantDID,
-		manifestID,
-		gotManifest.Manifest,
-		true,
-		"automatic creation via issuance template",
-		&issuanceTemplate,
-		request.Application,
-		request.ApplicationJSON,
-	)
+	credResp, creds, err := s.buildCredentialResponse(ctx, applicantDID, manifestID, gotManifest.Manifest, true, "automatic creation via issuance template", &issuanceTemplate, request.Application, request.ApplicationJSON, nil)
 	if err != nil {
 		return nil, err
 	}
@@ -458,6 +448,7 @@ func (s Service) ReviewApplication(ctx context.Context, request model.ReviewAppl
 		nil,
 		application.Application,
 		nil,
+		request.CredentialOverrides,
 	)
 	if err != nil {
 		return nil, util.LoggingErrorMsg(err, "could not build credential response")


Test patch:
diff --git a/integration/steelthread_integration_test.go b/integration/steelthread_integration_test.go
index b765b3d..0f9d48d 100644
--- a/integration/steelthread_integration_test.go
+++ b/integration/steelthread_integration_test.go
@@ -3,6 +3,7 @@ package integration
 import (
 	"testing"
 
+	"github.com/TBD54566975/ssi-sdk/credential/util"
 	"github.com/stretchr/testify/assert"
 	"github.com/tbd54566975/ssi-service/pkg/service/operation/storage"
 )
@@ -177,6 +178,10 @@ func TestSubmitAndReviewApplicationIntegration(t *testing.T) {
 	vc, err := getJSONElement(reviewApplicationOutput, "$.verifiableCredentials[0]")
 	assert.NoError(t, err)
 	assert.NotEmpty(t, vc)
+	typedVC, err := util.CredentialsFromInterface(vc)
+	assert.NoError(t, err)
+	assert.Equal(t, "Mister", typedVC.CredentialSubject["givenName"])
+	assert.Equal(t, "Tee", typedVC.CredentialSubject["familyName"])
 
 	operationOutput, err := get(endpoint + version + "operations/" + opID)
 	assert.NoError(t, err)
diff --git a/integration/testdata/review-application-input.json b/integration/testdata/review-application-input.json
index 305d0f8..4e2bb73 100644
--- a/integration/testdata/review-application-input.json
+++ b/integration/testdata/review-application-input.json
@@ -1,4 +1,14 @@
 {
   "approved": {{.Approved}},
-  "reason": "{{.Reason}}"
+  "reason": "{{.Reason}}",
+  "credential_overrides": {
+    "kyc_credential": {
+      "data": {
+        "familyName": "Tee",
+        "givenName": "Mister"
+      },
+      "expiry": null,
+      "revocable": true
+    }
+  }
 }
\ No newline at end of file
diff --git a/pkg/server/server_manifest_test.go b/pkg/server/server_manifest_test.go
index 99abc6b..7d738da 100644
--- a/pkg/server/server_manifest_test.go
+++ b/pkg/server/server_manifest_test.go
@@ -30,6 +30,14 @@ import (
 	"github.com/tbd54566975/ssi-service/pkg/service/schema"
 )
 
+func TestFoo(t *testing.T) {
+	data := []byte(`{"credential_overrides":{"some_key":{}}}`)
+	var p router.ReviewApplicationRequest
+	assert.NoError(t, json.Unmarshal(data, &p))
+	assert.Equal(t, map[string]manifestsvc.CredentialOverride{
+		"some_key": {},
+	}, p.CredentialOverrides)
+}
 func TestManifestAPI(t *testing.T) {
 	t.Run("Test Create Manifest", func(tt *testing.T) {
 		bolt := setupTestDB(tt)
@@ -581,7 +589,20 @@ func TestManifestAPI(t *testing.T) {
 		assert.Contains(tt, op.ID, "credentials/responses/")
 
 		// review application
-		reviewApplicationRequestValue := newRequestValue(tt, router.ReviewApplicationRequest{Approved: true, Reason: "I'm the almighty approver"})
+		expireAt := time.Date(2025, 10, 32, 0, 0, 0, 0, time.UTC)
+		reviewApplicationRequestValue := newRequestValue(tt, router.ReviewApplicationRequest{
+			Approved: true,
+			Reason:   "I'm the almighty approver",
+			CredentialOverrides: map[string]manifestsvc.CredentialOverride{
+				"id1": {
+					Data: map[string]any{
+						"looks": "pretty darn handsome",
+					},
+					Expiry:    &expireAt,
+					Revocable: true,
+				},
+			},
+		})
 		applicationID := storage.StatusObjectID(op.ID)
 		req = httptest.NewRequest(http.MethodPut, "https://ssi-service.com/v1/manifests/applications/"+applicationID+"/review", reviewApplicationRequestValue)
 		err = manifestRouter.ReviewApplication(newRequestContextWithParams(map[string]string{"id": applicationID}), w, req)
@@ -597,6 +618,16 @@ func TestManifestAPI(t *testing.T) {
 		assert.Len(tt, appResp.Response.Fulfillment.DescriptorMap, 2)
 		assert.Len(tt, appResp.Credentials, 2)
 		assert.Empty(tt, appResp.Response.Denial)
+
+		vc, err := util.CredentialsFromInterface(appResp.Credentials.([]any)[0])
+		assert.NoError(tt, err)
+		assert.Equal(tt, credsdk.CredentialSubject{
+			"id":    applicantDID.DID.ID,
+			"looks": "pretty darn handsome",
+		}, vc.CredentialSubject)
+		assert.Equal(tt, expireAt.Format(time.RFC3339), vc.ExpirationDate)
+		assert.NotEmpty(tt, vc.CredentialStatus)
+		assert.Equal(tt, createdSchema.ID, vc.CredentialSchema.ID)
 	})
 
 	t.Run("Test Denied Application", func(tt *testing.T) {