Repository: TBD54566975/ssi-service
Pull request #247 (2023-01-11 02:02:28)

Automatic Credentials based on Issuance Tempaltes
# Overview
This PR introduces issuance of credentials according to a stored issuance template.

# Description
When an issuance template exists in storage, we are able to issue credentials automatically. This PR introduces that automatic step. It's done immediately after the existing submission logic finishes.

# How Has This Been Tested?
A new test was added where an issuance template exists for the given application, and credentials were verified to have been created with the correct values.

## References
This is part of SIP 5



Patch:
diff --git a/go.mod b/go.mod
index c698678..273905a 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@ require (
 	github.com/TBD54566975/ssi-sdk v0.0.2-alpha.0.20221214220628-e2badfb960fa
 	github.com/alicebob/miniredis/v2 v2.30.0
 	github.com/ardanlabs/conf v1.5.0
+	github.com/benbjohnson/clock v1.3.0
 	github.com/dimfeld/httptreemux/v5 v5.5.0
 	github.com/go-playground/locales v0.14.1
 	github.com/go-playground/universal-translator v0.18.0
diff --git a/go.sum b/go.sum
index f9d5315..04141bf 100644
--- a/go.sum
+++ b/go.sum
@@ -10,6 +10,8 @@ github.com/antlr/antlr4/runtime/Go/antlr v1.4.10 h1:yL7+Jz0jTC6yykIK/Wh74gnTJnrG
 github.com/antlr/antlr4/runtime/Go/antlr v1.4.10/go.mod h1:F7bn7fEU90QkQ3tnmaTx3LTKLEDqnwWODIYppRQ5hnY=
 github.com/ardanlabs/conf v1.5.0 h1:5TwP6Wu9Xi07eLFEpiCUF3oQXh9UzHMDVnD3u/I5d5c=
 github.com/ardanlabs/conf v1.5.0/go.mod h1:ILsMo9dMqYzCxDjDXTiwMI0IgxOJd0MOiucbQY2wlJw=
+github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
+github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
 github.com/bits-and-blooms/bitset v1.4.0 h1:+YZ8ePm+He2pU3dZlIZiOeAKfrBkXi1lSrXJ/Xzgbu8=
 github.com/bits-and-blooms/bitset v1.4.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA=
 github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
diff --git a/pkg/service/issuing/model.go b/pkg/service/issuing/model.go
index af5fb82..80ae6cf 100644
--- a/pkg/service/issuing/model.go
+++ b/pkg/service/issuing/model.go
@@ -12,8 +12,12 @@ type GetIssuanceTemplateRequest struct {
 }
 
 type CredentialTemplateData struct {
-	// Optional. When present, it's the ID of the input descriptor in the application. Corresponds to one of the
-	// PresentationDefinition.InputDescriptors[].ID in the credential manifest.
+	// Optional.
+	// When present, it's the ID of the input descriptor in the application. Corresponds to one of the
+	// PresentationDefinition.InputDescriptors[].ID in the credential manifest. When creating a credential, the base
+	// data will be used from the provided submission that matches this ID.
+	// When absent, there will be no base data for the credentials created. Additionally, no JSON path strings in
+	// Claims.Data will be resolved.
 	CredentialInputDescriptor string `json:"credentialInputDescriptor"`
 
 	// The set of information that will be used to create claims.
@@ -41,7 +45,7 @@ type CredentialTemplate struct {
 	// ID of the CredentialSchema to be used for the issued credential.
 	Schema string `json:"schema"`
 
-	// Date that will be used to determine credential claims.
+	// Data that will be used to determine credential claims.
 	Data CredentialTemplateData `json:"data"`
 
 	// Parameter to determine the expiry of the credential.
diff --git a/pkg/service/issuing/storage.go b/pkg/service/issuing/storage.go
index 24e1788..e5a884d 100644
--- a/pkg/service/issuing/storage.go
+++ b/pkg/service/issuing/storage.go
@@ -81,3 +81,24 @@ func (s Storage) ListIssuanceTemplates(ctx context.Context) ([]IssuanceTemplate,
 	}
 	return ts, nil
 }
+
+func (s Storage) GetIssuanceTemplatesByManifestID(ctx context.Context, manifestID string) ([]StoredIssuanceTemplate, error) {
+	if manifestID == "" {
+		return nil, errors.New("cannot find issuance template without a manifest ID")
+	}
+	ms, err := s.db.ReadAll(ctx, namespace)
+	if err != nil {
+		return nil, errors.Wrap(err, "reading all values")
+	}
+	var ts []StoredIssuanceTemplate
+	for key, data := range ms {
+		var sit StoredIssuanceTemplate
+		if err := json.Unmarshal(data, &sit); err != nil {
+			return nil, errors.Wrapf(err, "unmarshalling <%s>", key)
+		}
+		if sit.IssuanceTemplate.CredentialManifest == manifestID {
+			ts = append(ts, sit)
+		}
+	}
+	return ts, nil
+}
diff --git a/pkg/service/manifest/response.go b/pkg/service/manifest/response.go
index 6d8ac6f..7aa522d 100644
--- a/pkg/service/manifest/response.go
+++ b/pkg/service/manifest/response.go
@@ -3,16 +3,20 @@ package manifest
 import (
 	"context"
 	"fmt"
+	"strings"
+	"time"
 
 	"github.com/TBD54566975/ssi-sdk/credential/exchange"
 	"github.com/TBD54566975/ssi-sdk/credential/manifest"
 	errresp "github.com/TBD54566975/ssi-sdk/error"
+	"github.com/oliveagle/jsonpath"
 	"github.com/pkg/errors"
-
+	"github.com/sirupsen/logrus"
 	cred "github.com/tbd54566975/ssi-service/internal/credential"
 	"github.com/tbd54566975/ssi-service/internal/keyaccess"
 	"github.com/tbd54566975/ssi-service/internal/util"
 	"github.com/tbd54566975/ssi-service/pkg/service/credential"
+	"github.com/tbd54566975/ssi-service/pkg/service/issuing"
 	"github.com/tbd54566975/ssi-service/pkg/service/keystore"
 )
 
@@ -28,7 +32,11 @@ func (s Service) signCredentialResponseJWT(ctx context.Context, signingDID strin
 	}
 	keyAccess, err := keyaccess.NewJWKKeyAccess(gotKey.ID, gotKey.Key)
 	if err != nil {
-		return nil, util.LoggingErrorMsgf(err, "could not create key access for signing response with key<%s>", gotKey.ID)
+		return nil, util.LoggingErrorMsgf(
+			err,
+			"could not create key access for signing response with key<%s>",
+			gotKey.ID,
+		)
 	}
 
 	// signing the response as a JWT
@@ -39,13 +47,34 @@ func (s Service) signCredentialResponseJWT(ctx context.Context, signingDID strin
 	return responseToken, nil
 }
 
-func (s Service) buildCredentialResponse(ctx context.Context, applicantDID, manifestID, applicationID string, credManifest manifest.CredentialManifest, approved bool, reason string) (*manifest.CredentialResponse, []cred.Container, error) {
+func (s Service) buildCredentialResponse(
+	ctx context.Context,
+	applicantDID, manifestID string,
+	credManifest manifest.CredentialManifest,
+	approved bool,
+	reason string,
+	template *issuing.IssuanceTemplate,
+	application manifest.CredentialApplication,
+	applicationJSON map[string]any,
+) (*manifest.CredentialResponse, []cred.Container, error) {
 	// TODO(gabe) need to check if this can be fulfilled and conditionally return success/denial
+	applicationID := application.ID
 	responseBuilder := manifest.NewCredentialResponseBuilder(manifestID)
 	if err := responseBuilder.SetApplicationID(applicationID); err != nil {
-		return nil, nil, util.LoggingErrorMsgf(err, "could not fulfill credential application: could not set application id: %s", applicationID)
+		return nil, nil, util.LoggingErrorMsgf(
+			err,
+			"could not fulfill credential credentials: could not set credentials id: %s",
+			applicationID,
+		)
 	}
 
+	templateMap := make(map[string]*issuing.CredentialTemplate)
+	if template != nil {
+		for _, templateCred := range template.Credentials {
+			templateCred := templateCred
+			templateMap[templateCred.ID] = &templateCred
+		}
+	}
 	creds := make([]cred.Container, 0, len(credManifest.OutputDescriptors))
 	for _, od := range credManifest.OutputDescriptors {
 		credentialRequest := credential.CreateCredentialRequest{
@@ -55,6 +84,20 @@ func (s Service) buildCredentialResponse(ctx context.Context, applicantDID, mani
 			// TODO(gabe) need to add in data here to match the request + schema
 			Data: make(map[string]any),
 		}
+		if template != nil {
+			err := s.applyIssuanceTemplate(
+				&credentialRequest,
+				template,
+				templateMap,
+				od,
+				applicationJSON,
+				credManifest,
+				application,
+			)
+			if err != nil {
+				return nil, nil, err
+			}
+		}
 
 		credentialResponse, err := s.credential.CreateCredential(ctx, credentialRequest)
 		if err != nil {
@@ -74,17 +117,22 @@ func (s Service) buildCredentialResponse(ctx context.Context, applicantDID, mani
 		if c.HasJWTCredential() {
 			format = string(exchange.JWTVC)
 		}
-		descriptors = append(descriptors, exchange.SubmissionDescriptor{
-			ID:     c.ID,
-			Format: format,
-			Path:   fmt.Sprintf("$.verifiableCredentials[%d]", i),
-		})
+		descriptors = append(
+			descriptors, exchange.SubmissionDescriptor{
+				ID:     c.ID,
+				Format: format,
+				Path:   fmt.Sprintf("$.verifiableCredentials[%d]", i),
+			},
+		)
 	}
 
 	// set the information for the fulfilled credentials in the response
 	if approved {
 		if err := responseBuilder.SetFulfillment(descriptors); err != nil {
-			return nil, nil, util.LoggingErrorMsg(err, "could not fulfill credential application: could not set fulfillment")
+			return nil, nil, util.LoggingErrorMsg(
+				err,
+				"could not fulfill credential credentials: could not set fulfillment",
+			)
 		}
 	} else {
 		if err := responseBuilder.SetDenial(reason); err != nil {
@@ -98,7 +146,109 @@ func (s Service) buildCredentialResponse(ctx context.Context, applicantDID, mani
 	return credRes, creds, nil
 }
 
-func buildDenialCredentialResponse(manifestID, applicationID, reason string, failedOutputDescriptorIDs ...string) (*manifest.CredentialResponse, error) {
+func (s Service) applyIssuanceTemplate(
+	credentialRequest *credential.CreateCredentialRequest,
+	template *issuing.IssuanceTemplate,
+	templateMap map[string]*issuing.CredentialTemplate,
+	od manifest.OutputDescriptor,
+	applicationJSON map[string]any,
+	credManifest manifest.CredentialManifest,
+	application manifest.CredentialApplication,
+) error {
+	credentialRequest.Issuer = template.Issuer
+
+	ct, ok := templateMap[od.ID]
+	if !ok {
+		logrus.Warnf("Did not find output_descriptor with ID \"%s\" in template. Skipping application.", od.ID)
+		return nil
+	}
+	c, err := getCredential(applicationJSON, ct, credManifest, application.PresentationSubmission)
+	if err != nil {
+		return err
+	}
+	for k, v := range ct.Data.Claims.Data {
+		claimValue := v
+		if vs, ok := v.(string); ok {
+			if strings.HasPrefix(vs, "$") {
+				claimValue, err = jsonpath.JsonPathLookup(c, vs)
+				if err != nil {
+					return errors.Wrapf(err, "looking up json path \"%s\" for key=\"%s\"", vs, k)
+				}
+			}
+		}
+		credentialRequest.Data[k] = claimValue
+	}
+
+	if ct.Expiry.Time != nil {
+		credentialRequest.Expiry = ct.Expiry.Time.Format(time.RFC3339)
+	}
+
+	if ct.Expiry.Duration != nil {
+		credentialRequest.Expiry = s.Clock.Now().Add(*ct.Expiry.Duration).Format(time.RFC3339)
+	}
+
+	credentialRequest.Revocable = ct.Revocable
+	return nil
+}
+
+func getCredential(
+	applicationJSON map[string]any,
+	ct *issuing.CredentialTemplate,
+	credManifest manifest.CredentialManifest,
+	submission *exchange.PresentationSubmission,
+) (any, error) {
+	if ct.Data.CredentialInputDescriptor == "" {
+		return nil, nil
+	}
+
+	if credManifest.PresentationDefinition == nil {
+		return nil, errors.New("cannot provide input descriptor when manifest does not have presentation definition")
+	}
+
+	// Lookup the claim that's sent in the submission.
+	for _, descriptor := range submission.DescriptorMap {
+		if descriptor.ID == ct.Data.CredentialInputDescriptor {
+			c, err := jsonpath.JsonPathLookup(applicationJSON, descriptor.Path)
+			if err != nil {
+				return nil, errors.Wrapf(
+					err,
+					"looking up json path \"%s\" for submission=\"%s\"",
+					descriptor.Path,
+					descriptor.ID,
+				)
+			}
+			return fromFormat(exchange.CredentialFormat(descriptor.Format), c)
+		}
+	}
+	return nil, errors.Errorf(
+		"could not find credential for input_descriptor=\"%s\"",
+		ct.Data.CredentialInputDescriptor,
+	)
+}
+
+func fromFormat(format exchange.CredentialFormat, claim any) (any, error) {
+	switch format {
+	case exchange.JWTVC.CredentialFormat():
+		_, token, err := util.ParseJWT(keyaccess.JWT(claim.(string)))
+		if err != nil {
+			return nil, errors.Wrapf(err, "parsing jwt as %s", exchange.JWTVC)
+		}
+
+		claims, ok := token.PrivateClaims()["vc"]
+		if !ok {
+			return nil, errors.New("\"vc\" field not found in claim")
+		}
+
+		return claims, nil
+	default:
+		return nil, errors.Errorf("unsupported format %s", format)
+	}
+}
+
+func buildDenialCredentialResponse(
+	manifestID, applicationID, reason string,
+	failedOutputDescriptorIDs ...string,
+) (*manifest.CredentialResponse, error) {
 	builder := manifest.NewCredentialResponseBuilder(manifestID)
 	if err := builder.SetApplicationID(applicationID); err != nil {
 		return nil, err
diff --git a/pkg/service/manifest/service.go b/pkg/service/manifest/service.go
index 1ce6065..addd6a9 100644
--- a/pkg/service/manifest/service.go
+++ b/pkg/service/manifest/service.go
@@ -8,6 +8,7 @@ import (
 	didsdk "github.com/TBD54566975/ssi-sdk/did"
 	errresp "github.com/TBD54566975/ssi-sdk/error"
 	sdkutil "github.com/TBD54566975/ssi-sdk/util"
+	"github.com/benbjohnson/clock"
 	"github.com/goccy/go-json"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
@@ -16,6 +17,7 @@ import (
 	"github.com/tbd54566975/ssi-service/internal/util"
 	"github.com/tbd54566975/ssi-service/pkg/service/credential"
 	"github.com/tbd54566975/ssi-service/pkg/service/framework"
+	"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"
 	manifeststg "github.com/tbd54566975/ssi-service/pkg/service/manifest/storage"
@@ -26,14 +28,17 @@ import (
 )
 
 type Service struct {
-	storage    *manifeststg.Storage
-	opsStorage *operation.Storage
-	config     config.ManifestServiceConfig
+	storage                 *manifeststg.Storage
+	opsStorage              *operation.Storage
+	issuanceTemplateStorage *issuing.Storage
+	config                  config.ManifestServiceConfig
 
 	// external dependencies
 	keyStore    *keystore.Service
 	didResolver *didsdk.Resolver
 	credential  *credential.Service
+
+	Clock clock.Clock
 }
 
 func (s Service) Type() framework.Type {
@@ -67,7 +72,13 @@ func (s Service) Config() config.ManifestServiceConfig {
 	return s.config
 }
 
-func NewManifestService(config config.ManifestServiceConfig, s storage.ServiceStorage, keyStore *keystore.Service, didResolver *didsdk.Resolver, credential *credential.Service) (*Service, error) {
+func NewManifestService(
+	config config.ManifestServiceConfig,
+	s storage.ServiceStorage,
+	keyStore *keystore.Service,
+	didResolver *didsdk.Resolver,
+	credential *credential.Service,
+) (*Service, error) {
 	manifestStorage, err := manifeststg.NewManifestStorage(s)
 	if err != nil {
 		return nil, util.LoggingErrorMsg(err, "could not instantiate storage for the manifest service")
@@ -76,13 +87,19 @@ func NewManifestService(config config.ManifestServiceConfig, s storage.ServiceSt
 	if err != nil {
 		return nil, util.LoggingErrorMsg(err, "could not instantiate storage for the operations")
 	}
+	issuingStorage, err := issuing.NewIssuingStorage(s)
+	if err != nil {
+		return nil, util.LoggingErrorMsg(err, "could not instantiate storage for issuance templates")
+	}
 	return &Service{
-		storage:     manifestStorage,
-		opsStorage:  opsStorage,
-		config:      config,
-		keyStore:    keyStore,
-		didResolver: didResolver,
-		credential:  credential,
+		storage:                 manifestStorage,
+		opsStorage:              opsStorage,
+		issuanceTemplateStorage: issuingStorage,
+		config:                  config,
+		keyStore:                keyStore,
+		didResolver:             didResolver,
+		credential:              credential,
+		Clock:                   clock.New(),
 	}, nil
 }
 
@@ -120,21 +137,31 @@ func (s Service) CreateManifest(ctx context.Context, request model.CreateManifes
 	if request.IssuerName != nil {
 		issuerName = *request.IssuerName
 	}
-	if err := builder.SetIssuer(manifest.Issuer{
-		ID:   request.IssuerDID,
-		Name: issuerName,
-	}); err != nil {
+	if err := builder.SetIssuer(
+		manifest.Issuer{
+			ID:   request.IssuerDID,
+			Name: issuerName,
+		},
+	); err != nil {
 		return nil, util.LoggingErrorMsgf(err, "could not set issuer<%s> for manifest", request.IssuerDID)
 	}
 	if err := builder.SetClaimFormat(*request.ClaimFormat); err != nil {
 		return nil, util.LoggingErrorMsgf(err, "could not set claim format<%+v> for manifest", request.ClaimFormat)
 	}
 	if err := builder.SetOutputDescriptors(request.OutputDescriptors); err != nil {
-		return nil, util.LoggingErrorMsgf(err, "could not set output descriptors<%+v> for manifest", request.OutputDescriptors)
+		return nil, util.LoggingErrorMsgf(
+			err,
+			"could not set output descriptors<%+v> for manifest",
+			request.OutputDescriptors,
+		)
 	}
 	if request.PresentationDefinition != nil {
 		if err := builder.SetPresentationDefinition(*request.PresentationDefinition); err != nil {
-			return nil, util.LoggingErrorMsgf(err, "could not set presentation definition<%+v> for manifest", request.PresentationDefinition)
+			return nil, util.LoggingErrorMsgf(
+				err,
+				"could not set presentation definition<%+v> for manifest",
+				request.PresentationDefinition,
+			)
 		}
 	}
 
@@ -171,7 +198,10 @@ func (s Service) CreateManifest(ctx context.Context, request model.CreateManifes
 func (s Service) VerifyManifest(request model.VerifyManifestRequest) (*model.VerifyManifestResponse, error) {
 	m, err := s.verifyManifestJWT(request.ManifestJWT)
 	if err != nil {
-		return &model.VerifyManifestResponse{Verified: false, Reason: "could not verify manifest's signature: " + err.Error()}, nil
+		return &model.VerifyManifestResponse{
+			Verified: false,
+			Reason:   "could not verify manifest's signature: " + err.Error(),
+		}, nil
 	}
 
 	// check the manifest is valid against its specification
@@ -227,8 +257,9 @@ type CredentialResponseContainer struct {
 	Credentials []any                       `json:"verifiableCredentials,omitempty"`
 }
 
-// ProcessApplicationSubmission stores the application in a pending state, along with an operation. Once the operation
-// is done, the Operation.Response field will be of type model.SubmitApplicationResponse.
+// ProcessApplicationSubmission stores the application in a pending state, along with an operation.
+// When there is an issuance template related to this manifest, the operation is done immediately.
+// Once the operation is done, the Operation.Response field will be of type model.SubmitApplicationResponse.
 // Invalid applications return an operation marked as done, with Response that represents denial.
 // The state of the application can be updated by calling CancelOperation, or by calling ReviewApplicationSubmission.
 // When the state is updated, the operation is marked as done.
@@ -238,18 +269,35 @@ func (s Service) ProcessApplicationSubmission(ctx context.Context, request model
 	gotManifest, err := s.storage.GetManifest(ctx, manifestID)
 	applicationID := request.Application.ID
 	if err != nil {
-		return nil, util.LoggingErrorMsgf(err, "problem with retrieving manifest<%s> during application<%s>'s validation", manifestID, applicationID)
+		return nil, util.LoggingErrorMsgf(
+			err,
+			"problem with retrieving manifest<%s> during application<%s>'s validation",
+			manifestID,
+			applicationID,
+		)
 	}
 	if gotManifest == nil {
-		return nil, util.LoggingNewErrorf("application<%s> is not valid; a manifest does not exist with id: %s", applicationID, manifestID)
+		return nil, util.LoggingNewErrorf(
+			"application<%s> is not valid; a manifest does not exist with id: %s",
+			applicationID,
+			manifestID,
+		)
 	}
 
 	opID := opcredential.IDFromResponseID(applicationID)
 	// validate the application
-	if unfulfilledInputDescriptorIDs, validationErr := s.validateCredentialApplication(ctx, gotManifest.Manifest, request); validationErr != nil {
+	if unfulfilledInputDescriptorIDs, validationErr := s.validateCredentialApplication(
+		ctx, gotManifest.Manifest,
+		request,
+	); validationErr != nil {
 		resp := errresp.GetErrorResponse(validationErr)
 		if resp.ErrorType == DenialResponse {
-			denialResp, err := buildDenialCredentialResponse(manifestID, applicationID, resp.Err.Error(), unfulfilledInputDescriptorIDs...)
+			denialResp, err := buildDenialCredentialResponse(
+				manifestID,
+				applicationID,
+				resp.Err.Error(),
+				unfulfilledInputDescriptorIDs...,
+			)
 			if err != nil {
 				return nil, util.LoggingErrorMsg(err, "could not build denial credential response")
 			}
@@ -286,13 +334,93 @@ func (s Service) ProcessApplicationSubmission(ctx context.Context, request model
 		return nil, util.LoggingErrorMsg(err, "could not store application")
 	}
 
-	storedOp := opstorage.StoredOperation{
+	storedOp := &opstorage.StoredOperation{
 		ID: opID,
 	}
-	if err = s.opsStorage.StoreOperation(ctx, storedOp); err != nil {
+	if err = s.opsStorage.StoreOperation(ctx, *storedOp); err != nil {
 		return nil, errors.Wrap(err, "storing operation")
 	}
-	return operation.ServiceModel(storedOp)
+
+	autoStoredOp, err := s.maybeIssueAutomatically(ctx, request, manifestID, applicantDID, applicationID, gotManifest)
+	if err != nil {
+		return nil, err
+	}
+
+	if autoStoredOp != nil {
+		storedOp = autoStoredOp
+	}
+	return operation.ServiceModel(*storedOp)
+}
+
+func (s Service) maybeIssueAutomatically(
+	ctx context.Context,
+	request model.SubmitApplicationRequest,
+	manifestID string,
+	applicantDID string,
+	applicationID string,
+	gotManifest *manifeststg.StoredManifest,
+) (*opstorage.StoredOperation, error) {
+	issuanceTemplates, err := s.issuanceTemplateStorage.GetIssuanceTemplatesByManifestID(ctx, manifestID)
+	if err != nil {
+		return nil, errors.Wrap(err, "fetching issuance templates by manifest ID")
+	}
+	if len(issuanceTemplates) == 0 {
+		return nil, nil
+	}
+
+	issuanceTemplate := issuanceTemplates[0].IssuanceTemplate
+
+	if len(issuanceTemplates) > 1 {
+		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,
+	)
+	if err != nil {
+		return nil, err
+	}
+	credentials := credint.ContainersToInterface(creds)
+
+	responseJWT, err := s.signCredentialResponseJWT(
+		ctx,
+		gotManifest.Issuer, CredentialResponseContainer{
+			Response:    *credResp,
+			Credentials: credentials,
+		},
+	)
+	if err != nil {
+		return nil, errors.Wrap(err, "signing credential response")
+	}
+
+	storeResponseRequest := manifeststg.StoredResponse{
+		ID:           credResp.ID,
+		ManifestID:   manifestID,
+		ApplicantDID: applicantDID,
+		Response:     *credResp,
+		Credentials:  creds,
+		ResponseJWT:  *responseJWT,
+	}
+	_, storedOp, err := s.storage.ReviewApplication(
+		ctx,
+		applicationID,
+		true,
+		"automatic from issuing template",
+		opcredential.IDFromResponseID(applicationID),
+		storeResponseRequest,
+	)
+	if err != nil {
+		return nil, errors.Wrap(err, "reviewing application")
+	}
+	return storedOp, nil
 }
 
 // ReviewApplication moves an application state and marks the operation associated with it as done. A credential
@@ -310,13 +438,27 @@ func (s Service) ReviewApplication(ctx context.Context, request model.ReviewAppl
 	}
 	applicationID := application.ID
 	if gotManifest == nil {
-		return nil, util.LoggingNewErrorf("application<%s> is not valid; a manifest does not exist with id: %s", applicationID, manifestID)
+		return nil, util.LoggingNewErrorf(
+			"application<%s> is not valid; a manifest does not exist with id: %s",
+			applicationID,
+			manifestID,
+		)
 	}
 	credManifest := gotManifest.Manifest
 	applicantDID := application.ApplicantDID
 
 	// build the credential response
-	credResp, creds, err := s.buildCredentialResponse(ctx, applicantDID, manifestID, applicationID, credManifest, request.Approved, request.Reason)
+	credResp, creds, err := s.buildCredentialResponse(
+		ctx,
+		applicantDID,
+		manifestID,
+		credManifest,
+		request.Approved,
+		request.Reason,
+		nil,
+		application.Application,
+		nil,
+	)
 	if err != nil {
 		return nil, util.LoggingErrorMsg(err, "could not build credential response")
 	}
@@ -325,10 +467,12 @@ func (s Service) ReviewApplication(ctx context.Context, request model.ReviewAppl
 	credentials := credint.ContainersToInterface(creds)
 
 	// sign the response before returning
-	responseJWT, err := s.signCredentialResponseJWT(ctx, gotManifest.Issuer, CredentialResponseContainer{
-		Response:    *credResp,
-		Credentials: credentials,
-	})
+	responseJWT, err := s.signCredentialResponseJWT(
+		ctx, gotManifest.Issuer, CredentialResponseContainer{
+			Response:    *credResp,
+			Credentials: credentials,
+		},
+	)
 	if err != nil {
 		return nil, util.LoggingErrorMsg(err, "could not sign credential response")
 	}
@@ -342,8 +486,13 @@ func (s Service) ReviewApplication(ctx context.Context, request model.ReviewAppl
 		Credentials:  creds,
 		ResponseJWT:  *responseJWT,
 	}
-
-	storedResponse, _, err := s.storage.ReviewApplication(ctx, request.ID, request.Approved, request.Reason, opcredential.IDFromResponseID(request.ID), storeResponseRequest)
+	storedResponse, _, err := s.storage.ReviewApplication(
+		ctx, request.ID,
+		request.Approved,
+		request.Reason,
+		opcredential.IDFromResponseID(request.ID),
+		storeResponseRequest,
+	)
 	if err != nil {
 		return nil, errors.Wrap(err, "updating submission")
 	}
diff --git a/pkg/service/manifest/storage/storage.go b/pkg/service/manifest/storage/storage.go
index e7d7865..34bbde4 100644
--- a/pkg/service/manifest/storage/storage.go
+++ b/pkg/service/manifest/storage/storage.go
@@ -247,7 +247,7 @@ func (ms *Storage) DeleteResponse(ctx context.Context, id string) error {
 //     creates in step 2.
 //
 // The operation and it's response (from 3) are returned.
-func (ms *Storage) ReviewApplication(ctx context.Context, id string, approved bool, reason string, opID string, response StoredResponse) (*StoredResponse, *opstorage.StoredOperation, error) {
+func (ms *Storage) ReviewApplication(ctx context.Context, applicationID string, approved bool, reason string, opID string, response StoredResponse) (*StoredResponse, *opstorage.StoredOperation, error) {
 	// TODO: everything should be in a single Tx.
 	m := map[string]any{
 		"status": opsubmission.StatusDenied,
@@ -256,7 +256,7 @@ func (ms *Storage) ReviewApplication(ctx context.Context, id string, approved bo
 	if approved {
 		m["status"] = opsubmission.StatusApproved
 	}
-	if _, err := ms.db.Update(ctx, credential.ApplicationNamespace, id, m); err != nil {
+	if _, err := ms.db.Update(ctx, credential.ApplicationNamespace, applicationID, m); err != nil {
 		return nil, nil, errors.Wrap(err, "updating application")
 	}
 


Test patch:
diff --git a/pkg/server/server_manifest_test.go b/pkg/server/server_manifest_test.go
index f7c9964..99abc6b 100644
--- a/pkg/server/server_manifest_test.go
+++ b/pkg/server/server_manifest_test.go
@@ -6,10 +6,16 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"testing"
+	"time"
 
+	credsdk "github.com/TBD54566975/ssi-sdk/credential"
+	"github.com/TBD54566975/ssi-sdk/credential/manifest"
+	"github.com/TBD54566975/ssi-sdk/credential/util"
 	"github.com/TBD54566975/ssi-sdk/crypto"
 	didsdk "github.com/TBD54566975/ssi-sdk/did"
+	"github.com/benbjohnson/clock"
 	"github.com/goccy/go-json"
+	"github.com/google/uuid"
 	"github.com/mr-tron/base58"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -18,6 +24,7 @@ import (
 	"github.com/tbd54566975/ssi-service/pkg/server/router"
 	"github.com/tbd54566975/ssi-service/pkg/service/credential"
 	"github.com/tbd54566975/ssi-service/pkg/service/did"
+	"github.com/tbd54566975/ssi-service/pkg/service/issuing"
 	manifestsvc "github.com/tbd54566975/ssi-service/pkg/service/manifest/model"
 	"github.com/tbd54566975/ssi-service/pkg/service/operation/storage"
 	"github.com/tbd54566975/ssi-service/pkg/service/schema"
@@ -308,6 +315,159 @@ func TestManifestAPI(t *testing.T) {
 		assert.Contains(tt, err.Error(), fmt.Sprintf("could not get manifest with id: %s", resp.Manifest.ID))
 	})
 
+	t.Run("Submit Application With Issuance Template", func(tt *testing.T) {
+		bolt := setupTestDB(tt)
+		require.NotNil(tt, bolt)
+
+		keyStoreService := testKeyStoreService(tt, bolt)
+		issuanceService := testIssuanceService(tt, bolt)
+		didService := testDIDService(tt, bolt, keyStoreService)
+		schemaService := testSchemaService(tt, bolt, keyStoreService, didService)
+		credentialService := testCredentialService(tt, bolt, keyStoreService, didService, schemaService)
+		manifestRouter, manifestSvc := testManifest(tt, bolt, keyStoreService, didService, credentialService)
+		// create an issuer
+		issuerDID, err := didService.CreateDIDByMethod(
+			context.Background(),
+			did.CreateDIDRequest{
+				Method:  didsdk.KeyMethod,
+				KeyType: crypto.Ed25519,
+			})
+		assert.NoError(tt, err)
+		assert.NotEmpty(tt, issuerDID)
+
+		// create an applicant
+		applicantDID, err := didService.CreateDIDByMethod(
+			context.Background(),
+			did.CreateDIDRequest{
+				Method:  didsdk.KeyMethod,
+				KeyType: crypto.Ed25519,
+			})
+		assert.NoError(tt, err)
+		assert.NotEmpty(tt, issuerDID)
+
+		// create a schema for the creds to be issued against
+		licenseSchema := map[string]any{
+			"type": "object",
+			"properties": map[string]any{
+				"licenseType": map[string]any{
+					"type": "string",
+				},
+			},
+			"additionalProperties": true,
+		}
+		createdSchema, err := schemaService.CreateSchema(
+			context.Background(),
+			schema.CreateSchemaRequest{Author: issuerDID.DID.ID, Name: "license schema", Schema: licenseSchema, Sign: true})
+		assert.NoError(tt, err)
+		assert.NotEmpty(tt, createdSchema)
+
+		// issue a credential against the schema to the subject, from the issuer
+		createdCred, err := credentialService.CreateCredential(
+			context.Background(),
+			credential.CreateCredentialRequest{
+				Issuer:     issuerDID.DID.ID,
+				Subject:    applicantDID.DID.ID,
+				JSONSchema: createdSchema.ID,
+				Data: map[string]any{
+					"licenseType": "WA-DL-CLASS-A",
+					"firstName":   "Tester",
+					"lastName":    "McTest",
+				},
+			})
+		assert.NoError(tt, err)
+		assert.NotEmpty(tt, createdCred)
+
+		// good request
+		createManifestRequest := getValidManifestRequest(issuerDID.DID.ID, createdSchema.ID)
+
+		requestValue := newRequestValue(tt, createManifestRequest)
+		req := httptest.NewRequest(http.MethodPut, "https://ssi-service.com/v1/manifests", requestValue)
+		w := httptest.NewRecorder()
+		err = manifestRouter.CreateManifest(newRequestContext(), w, req)
+		assert.NoError(tt, err)
+
+		var resp router.CreateManifestResponse
+		err = json.NewDecoder(w.Body).Decode(&resp)
+		assert.NoError(tt, err)
+
+		m := resp.Manifest
+		assert.NotEmpty(tt, m)
+		assert.Equal(tt, m.Issuer.ID, issuerDID.DID.ID)
+
+		// good application request
+		container := []credmodel.Container{{CredentialJWT: createdCred.CredentialJWT}}
+		applicationRequest := getValidApplicationRequest(m.ID, m.PresentationDefinition.ID, m.PresentationDefinition.InputDescriptors[0].ID, container)
+
+		// sign application
+		applicantPrivKeyBytes, err := base58.Decode(applicantDID.PrivateKeyBase58)
+		assert.NoError(tt, err)
+		applicantPrivKey, err := crypto.BytesToPrivKey(applicantPrivKeyBytes, applicantDID.KeyType)
+		assert.NoError(tt, err)
+		signer, err := keyaccess.NewJWKKeyAccess(applicantDID.DID.ID, applicantPrivKey)
+		assert.NoError(tt, err)
+		signed, err := signer.SignJSON(applicationRequest)
+		assert.NoError(tt, err)
+
+		now := time.Date(2022, 10, 31, 0, 0, 0, 0, time.UTC)
+		mockClock := clock.NewMock()
+		manifestSvc.Clock = mockClock
+		mockClock.Set(now)
+		duration := 5 * time.Second
+		issuanceTemplate, err := issuanceService.CreateIssuanceTemplate(
+			context.Background(),
+			getValidIssuanceTemplateRequest(m, issuerDID, createdSchema, now, duration))
+		assert.NoError(tt, err)
+		assert.NotEmpty(tt, issuanceTemplate)
+
+		applicationRequestValue := newRequestValue(tt, router.SubmitApplicationRequest{ApplicationJWT: *signed})
+		req = httptest.NewRequest(http.MethodPut, "https://ssi-service.com/v1/manifests/applications", applicationRequestValue)
+		err = manifestRouter.SubmitApplication(newRequestContext(), w, req)
+		assert.NoError(tt, err)
+
+		var op router.Operation
+		err = json.NewDecoder(w.Body).Decode(&op)
+		assert.NoError(tt, err)
+		assert.True(tt, op.Done)
+
+		var appResp router.SubmitApplicationResponse
+		respData, err := json.Marshal(op.Result.Response)
+		assert.NoError(tt, err)
+		err = json.Unmarshal(respData, &appResp)
+		assert.NoError(tt, err)
+
+		assert.Len(tt, appResp.Credentials, 2, "each output_descriptor in the definition should result in a credential")
+		vc, err := util.CredentialsFromInterface(appResp.Credentials.([]any)[0])
+		assert.NoError(tt, err)
+		assert.Equal(tt, credsdk.CredentialSubject{
+			"id":        applicantDID.DID.ID,
+			"state":     "CA",
+			"firstName": "Tester",
+			"lastName":  "McTest",
+		}, vc.CredentialSubject)
+		assert.Equal(tt, time.Date(2022, 10, 31, 0, 0, 0, 0, time.UTC).Format(time.RFC3339), vc.ExpirationDate)
+		assert.Equal(tt, createdSchema.ID, vc.CredentialSchema.ID)
+		assert.Empty(tt, vc.CredentialStatus)
+
+		vc2, err := util.CredentialsFromInterface(appResp.Credentials.([]any)[1])
+		assert.NoError(tt, err)
+		assert.Equal(tt, credsdk.CredentialSubject{
+			"id": applicantDID.DID.ID,
+			"someCrazyObject": map[string]any{
+				"foo": 123.,
+				"bar": false,
+				"baz": []any{
+					"yay", 123., nil,
+				},
+			},
+		}, vc2.CredentialSubject)
+		assert.Equal(
+			tt,
+			time.Date(2022, 10, 31, 0, 0, 5, 0, time.UTC).Format(time.RFC3339),
+			vc2.ExpirationDate,
+		)
+		assert.Equal(tt, createdSchema.ID, vc2.CredentialSchema.ID)
+		assert.NotEmpty(tt, vc2.CredentialStatus)
+	})
 	t.Run("Test Submit Application", func(tt *testing.T) {
 		bolt := setupTestDB(tt)
 		require.NotNil(tt, bolt)
@@ -883,3 +1043,52 @@ func TestManifestAPI(t *testing.T) {
 		assert.Contains(tt, err.Error(), fmt.Sprintf("could not get application with id: %s", appResp.Response.ID))
 	})
 }
+
+func getValidIssuanceTemplateRequest(m manifest.CredentialManifest, issuerDID *did.CreateDIDResponse, createdSchema *schema.CreateSchemaResponse, now time.Time, duration time.Duration) *issuing.CreateIssuanceTemplateRequest {
+	return &issuing.CreateIssuanceTemplateRequest{
+		IssuanceTemplate: issuing.IssuanceTemplate{
+			ID:                 uuid.NewString(),
+			CredentialManifest: m.ID,
+			Issuer:             issuerDID.DID.ID,
+			Credentials: []issuing.CredentialTemplate{
+				{
+					ID:     "id1",
+					Schema: createdSchema.ID,
+					Data: issuing.CredentialTemplateData{
+						CredentialInputDescriptor: "test-id",
+						Claims: issuing.ClaimTemplates{
+							Data: map[string]any{
+								"firstName": "$.credentialSubject.firstName",
+								"lastName":  "$.credentialSubject.lastName",
+								"state":     "CA",
+							},
+						},
+					},
+					Expiry: issuing.TimeLike{
+						Time: &now,
+					},
+				},
+				{
+					ID:     "id2",
+					Schema: createdSchema.ID,
+					Data: issuing.CredentialTemplateData{
+						CredentialInputDescriptor: "test-id",
+						Claims: issuing.ClaimTemplates{
+							Data: map[string]any{
+								"someCrazyObject": map[string]any{
+									"foo": 123,
+									"bar": false,
+									"baz": []any{
+										"yay", 123, nil,
+									},
+								},
+							},
+						},
+					},
+					Expiry:    issuing.TimeLike{Duration: &duration},
+					Revocable: true,
+				},
+			},
+		},
+	}
+}
diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go
index 2e8fddd..8d8b1b3 100644
--- a/pkg/server/server_test.go
+++ b/pkg/server/server_test.go
@@ -11,6 +11,7 @@ import (
 	"time"
 
 	"github.com/TBD54566975/ssi-sdk/credential/exchange"
+	"github.com/tbd54566975/ssi-service/pkg/service/issuing"
 	"github.com/tbd54566975/ssi-service/pkg/service/manifest/model"
 	"github.com/tbd54566975/ssi-service/pkg/testutil"
 
@@ -212,6 +213,17 @@ func testKeyStoreService(t *testing.T, db storage.ServiceStorage) *keystore.Serv
 	return keystoreService
 }
 
+func testIssuanceService(t *testing.T, db storage.ServiceStorage) *issuing.Service {
+	cfg := config.IssuingServiceConfig{
+		BaseServiceConfig: &config.BaseServiceConfig{Name: "test-issuance"},
+	}
+
+	s, err := issuing.NewIssuingService(cfg, db)
+	require.NoError(t, err)
+	require.NotEmpty(t, s)
+	return s
+}
+
 func testDIDService(t *testing.T, bolt storage.ServiceStorage, keyStore *keystore.Service) *did.Service {
 	serviceConfig := config.DIDServiceConfig{
 		BaseServiceConfig: &config.BaseServiceConfig{Name: "test-did"},