Repository: TBD54566975/ssi-service
Pull request #216 (2022-12-12 21:15:17)

[OSE-176] Operations can be cancelled
# Overview
This PR implements the `CancelOperation` endpoint.

# Description
After an operation is cancelled, the associated resource's status must be recorded. We currently only support operations related to `Submission` resources. In order to do so, this PR moved a number of methods around so there are no dependency cycles. The rule I followed was: submissions can depend on operations, but not the other way around. 

Happy to hear feedback about better organizing packages. 

# How Has This Been Tested?
See added unit tests. 

# Checklist

Before submitting this PR, please make sure:

- [x] I have read the CONTRIBUTING document.
- [x] My code is consistent with the rest of the project 
- [x] I have tagged the relevant reviewers and/or interested parties
- [x] I have updated the READMEs and other documentation of affected packages

## References
SIP6



Patch:
diff --git a/doc/swagger.yaml b/doc/swagger.yaml
index a5d9f93..3833160 100644
--- a/doc/swagger.yaml
+++ b/doc/swagger.yaml
@@ -479,7 +479,7 @@ definitions:
           context values.
         type: string
       data:
-        additionalProperties: true
+        additionalProperties: {}
         type: object
       expiry:
         type: string
@@ -770,7 +770,7 @@ definitions:
         description: The reason why the submission was approved or denied.
         type: string
       status:
-        description: One of {`pending`, `approved`, `denied`}.
+        description: One of {`pending`, `approved`, `denied`, `cancelled`}.
         type: string
     required:
     - definition_id
@@ -790,7 +790,7 @@ definitions:
     properties:
       submissions:
         items:
-          $ref: '#/definitions/presentation.Submission'
+          $ref: '#/definitions/model.Submission'
         type: array
     type: object
   github.com_tbd54566975_ssi-service_pkg_server_router.Operation:
@@ -846,7 +846,7 @@ definitions:
         description: The reason why the submission was approved or denied.
         type: string
       status:
-        description: One of {`pending`, `approved`, `denied`}.
+        description: One of {`pending`, `approved`, `denied`, `cancelled`}.
         type: string
     required:
     - definition_id
@@ -1044,6 +1044,28 @@ definitions:
     - id
     - schema
     type: object
+  model.Submission:
+    properties:
+      definition_id:
+        type: string
+      descriptor_map:
+        items:
+          $ref: '#/definitions/exchange.SubmissionDescriptor'
+        type: array
+      id:
+        type: string
+      reason:
+        description: The reason why the submission was approved or denied.
+        type: string
+      status:
+        description: One of {`pending`, `approved`, `denied`, `cancelled`}.
+        type: string
+    required:
+    - definition_id
+    - descriptor_map
+    - id
+    - status
+    type: object
   pkg_server_router.CreateCredentialRequest:
     properties:
       '@context':
@@ -1051,7 +1073,7 @@ definitions:
           context values.
         type: string
       data:
-        additionalProperties: true
+        additionalProperties: {}
         type: object
       expiry:
         type: string
@@ -1342,7 +1364,7 @@ definitions:
         description: The reason why the submission was approved or denied.
         type: string
       status:
-        description: One of {`pending`, `approved`, `denied`}.
+        description: One of {`pending`, `approved`, `denied`, `cancelled`}.
         type: string
     required:
     - definition_id
@@ -1362,7 +1384,7 @@ definitions:
     properties:
       submissions:
         items:
-          $ref: '#/definitions/presentation.Submission'
+          $ref: '#/definitions/model.Submission'
         type: array
     type: object
   pkg_server_router.Operation:
@@ -1418,7 +1440,7 @@ definitions:
         description: The reason why the submission was approved or denied.
         type: string
       status:
-        description: One of {`pending`, `approved`, `denied`}.
+        description: One of {`pending`, `approved`, `denied`, `cancelled`}.
         type: string
     required:
     - definition_id
@@ -1501,28 +1523,6 @@ definitions:
       verified:
         type: boolean
     type: object
-  presentation.Submission:
-    properties:
-      definition_id:
-        type: string
-      descriptor_map:
-        items:
-          $ref: '#/definitions/exchange.SubmissionDescriptor'
-        type: array
-      id:
-        type: string
-      reason:
-        description: The reason why the submission was approved or denied.
-        type: string
-      status:
-        description: One of {`pending`, `approved`, `denied`}.
-        type: string
-    required:
-    - definition_id
-    - descriptor_map
-    - id
-    - status
-    type: object
   rendering.ColorResource:
     properties:
       color:
@@ -2411,7 +2411,7 @@ paths:
         "200":
           description: OK
           schema:
-            $ref: '#/definitions/github.com_tbd54566975_ssi-service_pkg_server_router.GetOperationsResponse'
+            $ref: '#/definitions/pkg_server_router.Operation'
         "400":
           description: Bad request
           schema:
@@ -2440,7 +2440,7 @@ paths:
         "200":
           description: OK
           schema:
-            $ref: '#/definitions/github.com_tbd54566975_ssi-service_pkg_server_router.Operation'
+            $ref: '#/definitions/pkg_server_router.Operation'
         "400":
           description: Bad request
           schema:
diff --git a/pkg/server/router/operation.go b/pkg/server/router/operation.go
index f0e1e64..596d0b7 100644
--- a/pkg/server/router/operation.go
+++ b/pkg/server/router/operation.go
@@ -53,7 +53,7 @@ func (o OperationRouter) GetOperation(ctx context.Context, w http.ResponseWriter
 		return framework.NewRequestError(
 			util.LoggingErrorMsg(err, "failed getting operation"), http.StatusInternalServerError)
 	}
-	return framework.Respond(ctx, w, routerModel(op), http.StatusOK)
+	return framework.Respond(ctx, w, routerModel(*op), http.StatusOK)
 }
 
 type GetOperationsRequest struct {
@@ -169,10 +169,22 @@ func routerModel(op operation.Operation) Operation {
 // @Accept       json
 // @Produce      json
 // @Param        id   path      string  true  "ID"
-// @Success      200  {object}  GetOperationsResponse  "OK"
+// @Success      200  {object}  Operation  "OK"
 // @Failure      400  {string}  string  "Bad request"
 // @Failure      500  {string}  string  "Internal server error"
 // @Router       /v1/operations [get]
 func (o OperationRouter) CancelOperation(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
-	return nil
+	id := framework.GetParam(ctx, IDParam)
+	if id == nil {
+		return framework.NewRequestError(
+			util.LoggingNewError("get operation request requires id"), http.StatusBadRequest)
+	}
+
+	op, err := o.service.CancelOperation(operation.CancelOperationRequest{ID: *id})
+
+	if err != nil {
+		return framework.NewRequestError(
+			util.LoggingErrorMsg(err, "failed cancelling operation"), http.StatusInternalServerError)
+	}
+	return framework.Respond(ctx, w, routerModel(*op), http.StatusOK)
 }
diff --git a/pkg/service/operation/bolt.go b/pkg/service/operation/bolt.go
new file mode 100644
index 0000000..df216f6
--- /dev/null
+++ b/pkg/service/operation/bolt.go
@@ -0,0 +1,134 @@
+package operation
+
+import (
+	"strings"
+
+	"github.com/goccy/go-json"
+	"github.com/pkg/errors"
+	"github.com/sirupsen/logrus"
+	"github.com/tbd54566975/ssi-service/internal/util"
+	opstorage "github.com/tbd54566975/ssi-service/pkg/service/operation/storage"
+	"github.com/tbd54566975/ssi-service/pkg/service/operation/storage/namespace"
+	"github.com/tbd54566975/ssi-service/pkg/service/operation/submission"
+	"github.com/tbd54566975/ssi-service/pkg/storage"
+	"go.einride.tech/aip/filtering"
+)
+
+const (
+	cancelledReason = "operation cancelled"
+)
+
+type BoltOperationStorage struct {
+	db *storage.BoltDB
+}
+
+func NewBoltOperationStorage(db *storage.BoltDB) (*BoltOperationStorage, error) {
+	if db == nil {
+		return nil, errors.New("bolt db reference is nil")
+	}
+	return &BoltOperationStorage{db: db}, nil
+
+}
+
+func (b BoltOperationStorage) CancelOperation(id string) (*opstorage.StoredOperation, error) {
+	if strings.HasPrefix(id, submission.ParentResource) {
+		_, opData, err := b.db.UpdateValueAndOperation(
+			submission.Namespace, submission.ID(id), storage.NewUpdater(map[string]any{
+				"status": submission.StatusCancelled,
+				"reason": cancelledReason,
+			}),
+			namespace.FromID(id), id, submission.OperationUpdater{
+				UpdaterWithMap: storage.NewUpdater(map[string]any{
+					"done": true,
+				}),
+			})
+		if err != nil {
+			return nil, errors.Wrap(err, "updating value and op")
+		}
+		var op opstorage.StoredOperation
+		if err = json.Unmarshal(opData, &op); err != nil {
+			return nil, errors.Wrap(err, "unmarshalling data")
+		}
+		return &op, nil
+	}
+	return nil, errors.New("unrecognized id structure")
+}
+
+func (b BoltOperationStorage) StoreOperation(op opstorage.StoredOperation) error {
+	id := op.ID
+	if id == "" {
+		return util.LoggingNewError("ID is required for storing operations")
+	}
+	jsonBytes, err := json.Marshal(op)
+	if err != nil {
+		return util.LoggingErrorMsgf(err, "marshalling operation with id: %s", id)
+	}
+	if err = b.db.Write(namespace.FromID(id), id, jsonBytes); err != nil {
+		return util.LoggingErrorMsg(err, "writing to db")
+	}
+	return nil
+}
+
+func (b BoltOperationStorage) GetOperation(id string) (opstorage.StoredOperation, error) {
+	var stored opstorage.StoredOperation
+	jsonBytes, err := b.db.Read(namespace.FromID(id), id)
+	if err != nil {
+		return stored, util.LoggingErrorMsgf(err, "reading operation with id: %s", id)
+	}
+	if len(jsonBytes) == 0 {
+		return stored, util.LoggingNewErrorf("operation not found with id: %s", id)
+	}
+	if err := json.Unmarshal(jsonBytes, &stored); err != nil {
+		return stored, util.LoggingErrorMsgf(err, "unmarshalling stored operation: %s", id)
+	}
+	return stored, nil
+}
+
+func (b BoltOperationStorage) GetOperations(parent string, filter filtering.Filter) ([]opstorage.StoredOperation, error) {
+	operations, err := b.db.ReadAll(namespace.FromParent(parent))
+	if err != nil {
+		return nil, util.LoggingErrorMsgf(err, "could not get all operations")
+	}
+
+	shouldInclude, err := storage.NewIncludeFunc(filter)
+	if err != nil {
+		return nil, err
+	}
+	stored := make([]opstorage.StoredOperation, 0, len(operations))
+	for i, manifestBytes := range operations {
+		var nextOp opstorage.StoredOperation
+		if err = json.Unmarshal(manifestBytes, &nextOp); err != nil {
+			logrus.WithError(err).WithField("idx", i).Warnf("Skipping operation")
+		}
+		include, err := shouldInclude(nextOp)
+		// We explicitly ignore evaluation errors and simply include them in the result.
+		if err != nil || include {
+			stored = append(stored, nextOp)
+		}
+	}
+	return stored, nil
+}
+
+func (b BoltOperationStorage) DeleteOperation(id string) error {
+	if err := b.db.Delete(namespace.FromID(id), id); err != nil {
+		return util.LoggingErrorMsgf(err, "deleting operation: %s", id)
+	}
+	return nil
+}
+
+func NewOperationStorage(s storage.ServiceStorage) (opstorage.Storage, error) {
+	switch s.Type() {
+	case storage.Bolt:
+		gotBolt, ok := s.(*storage.BoltDB)
+		if !ok {
+			return nil, util.LoggingNewErrorf("trouble instantiating : %s", s.Type())
+		}
+		boltStorage, err := NewBoltOperationStorage(gotBolt)
+		if err != nil {
+			return nil, util.LoggingErrorMsg(err, "could not instantiate schema bolt storage")
+		}
+		return boltStorage, err
+	default:
+		return nil, util.LoggingNewErrorf("unsupported storage type: %s", s.Type())
+	}
+}
diff --git a/pkg/service/operation/model.go b/pkg/service/operation/model.go
index fef7c62..d3e5c7a 100644
--- a/pkg/service/operation/model.go
+++ b/pkg/service/operation/model.go
@@ -1,11 +1,7 @@
 package operation
 
 import (
-	"fmt"
-	"strings"
-
 	"github.com/TBD54566975/ssi-sdk/util"
-	"github.com/tbd54566975/ssi-service/pkg/service/operation/storage"
 	"go.einride.tech/aip/filtering"
 )
 
@@ -20,16 +16,6 @@ type Operation struct {
 	Result Result `json:"result,omitempty"`
 }
 
-// SubmissionID attempts to parse the submission id from the ID of the operation. This is done by taking the last word
-// that results from splitting the id by "/". On failures, the empty string is returned.
-func SubmissionID(opID string) string {
-	i := strings.LastIndex(opID, "/")
-	if i == -1 {
-		return ""
-	}
-	return opID[(i + 1):]
-}
-
 type GetOperationsRequest struct {
 	Parent string `validate:"required"`
 	Filter filtering.Filter
@@ -52,7 +38,11 @@ func (r GetOperationRequest) Validate() error {
 	return util.NewValidator().Struct(r)
 }
 
-// IDFromSubmissionID returns a submission operation ID from the submission ID.
-func IDFromSubmissionID(id string) string {
-	return fmt.Sprintf("%s/%s", storage.SubmissionParentResource, id)
+type CancelOperationRequest struct {
+	ID string `json:"id" validate:"required"`
+}
+
+// Validate does struct validation and returns an error when invalid.
+func (r CancelOperationRequest) Validate() error {
+	return util.NewValidator().Struct(r)
 }
diff --git a/pkg/service/operation/service.go b/pkg/service/operation/service.go
index 71ab243..433c2e6 100644
--- a/pkg/service/operation/service.go
+++ b/pkg/service/operation/service.go
@@ -11,6 +11,7 @@ import (
 	"github.com/tbd54566975/ssi-service/internal/util"
 	"github.com/tbd54566975/ssi-service/pkg/service/framework"
 	opstorage "github.com/tbd54566975/ssi-service/pkg/service/operation/storage"
+	"github.com/tbd54566975/ssi-service/pkg/service/operation/submission"
 	"github.com/tbd54566975/ssi-service/pkg/service/presentation/model"
 	prestorage "github.com/tbd54566975/ssi-service/pkg/service/presentation/storage"
 	"github.com/tbd54566975/ssi-service/pkg/storage"
@@ -58,15 +59,15 @@ func (s Service) GetOperations(request GetOperationsRequest) (*GetOperationsResp
 			logrus.WithError(err).WithField("operation_id", op.ID).Error("converting to storage operations to model")
 			continue
 		}
-		resp.Operations[i] = newOp
+		resp.Operations[i] = *newOp
 	}
 	return resp, nil
 }
 
 type ServiceModelFunc func(any) any
 
-func serviceModel(op opstorage.StoredOperation) (Operation, error) {
-	newOp := Operation{
+func serviceModel(op opstorage.StoredOperation) (*Operation, error) {
+	newOp := &Operation{
 		ID:   op.ID,
 		Done: op.Done,
 		Result: Result{
@@ -76,34 +77,46 @@ func serviceModel(op opstorage.StoredOperation) (Operation, error) {
 
 	if len(op.Response) > 0 {
 		switch {
-		case strings.HasPrefix(op.ID, opstorage.SubmissionParentResource):
+		case strings.HasPrefix(op.ID, submission.ParentResource):
 			var s prestorage.StoredSubmission
 			if err := json.Unmarshal(op.Response, &s); err != nil {
-				return Operation{}, err
+				return nil, err
 			}
 			newOp.Result.Response = model.ServiceModel(&s)
 		default:
-			return newOp, errors.New("unknown response type")
+			return nil, errors.New("unknown response type")
 		}
 	}
 
 	return newOp, nil
 }
 
-func (s Service) GetOperation(request GetOperationRequest) (Operation, error) {
+func (s Service) GetOperation(request GetOperationRequest) (*Operation, error) {
 	if err := request.Validate(); err != nil {
-		return Operation{}, errors.Wrap(err, "invalid request")
+		return nil, errors.Wrap(err, "invalid request")
 	}
 
 	storedOp, err := s.storage.GetOperation(request.ID)
 	if err != nil {
-		return Operation{}, errors.Wrap(err, "fetching from storage")
+		return nil, errors.Wrap(err, "fetching from storage")
 	}
 	return serviceModel(storedOp)
 }
 
+func (s Service) CancelOperation(request CancelOperationRequest) (*Operation, error) {
+	if err := request.Validate(); err != nil {
+		return nil, errors.Wrap(err, "invalid request")
+	}
+
+	storedOp, err := s.storage.CancelOperation(request.ID)
+	if err != nil {
+		return nil, errors.Wrap(err, "marking as done")
+	}
+	return serviceModel(*storedOp)
+}
+
 func NewOperationService(s storage.ServiceStorage) (*Service, error) {
-	opStorage, err := opstorage.NewOperationStorage(s)
+	opStorage, err := NewOperationStorage(s)
 	if err != nil {
 		return nil, util.LoggingErrorMsg(err, "creating operation storage")
 	}
diff --git a/pkg/service/operation/storage/bolt.go b/pkg/service/operation/storage/bolt.go
deleted file mode 100644
index 4952bbe..0000000
--- a/pkg/service/operation/storage/bolt.go
+++ /dev/null
@@ -1,112 +0,0 @@
-package storage
-
-import (
-	"strings"
-
-	"github.com/goccy/go-json"
-	"github.com/pkg/errors"
-	"github.com/sirupsen/logrus"
-	"github.com/tbd54566975/ssi-service/internal/util"
-	"github.com/tbd54566975/ssi-service/pkg/storage"
-	"go.einride.tech/aip/filtering"
-)
-
-const (
-	namespace = "operation_submission"
-)
-
-const SubmissionParentResource = "/presentations/submissions"
-
-// NamespaceFromID returns a namespace from a given operation ID. An empty string is returned when the namespace cannot
-// be determined.
-func NamespaceFromID(id string) string {
-	i := strings.LastIndex(id, "/")
-	if i == -1 {
-		return ""
-	}
-	return namespaceFromParent(id[:i])
-}
-
-func namespaceFromParent(parent string) string {
-	switch parent {
-	case SubmissionParentResource:
-		return namespace
-	default:
-		return ""
-	}
-}
-
-type BoltOperationStorage struct {
-	db *storage.BoltDB
-}
-
-func (b BoltOperationStorage) StoreOperation(op StoredOperation) error {
-	id := op.ID
-	if id == "" {
-		return util.LoggingNewError("ID is required for storing operations")
-	}
-	jsonBytes, err := json.Marshal(op)
-	if err != nil {
-		return util.LoggingErrorMsgf(err, "marshalling operation with id: %s", id)
-	}
-	return b.db.Write(NamespaceFromID(id), id, jsonBytes)
-}
-
-func (b BoltOperationStorage) GetOperation(id string) (StoredOperation, error) {
-	var stored StoredOperation
-	jsonBytes, err := b.db.Read(NamespaceFromID(id), id)
-	if err != nil {
-		return stored, util.LoggingErrorMsgf(err, "reading operation with id: %s", id)
-	}
-	if len(jsonBytes) == 0 {
-		return stored, util.LoggingNewErrorf("operation not found with id: %s", id)
-	}
-	if err := json.Unmarshal(jsonBytes, &stored); err != nil {
-		return stored, util.LoggingErrorMsgf(err, "unmarshalling stored operation: %s", id)
-	}
-	return stored, nil
-}
-
-func (b BoltOperationStorage) GetOperations(parent string, filter filtering.Filter) ([]StoredOperation, error) {
-	operations, err := b.db.ReadAll(namespaceFromParent(parent))
-	if err != nil {
-		return nil, util.LoggingErrorMsgf(err, "could not get all operations")
-	}
-
-	shouldInclude, err := storage.NewIncludeFunc(filter)
-	if err != nil {
-		return nil, err
-	}
-	stored := make([]StoredOperation, 0, len(operations))
-	for i, manifestBytes := range operations {
-		var nextOp StoredOperation
-		if err = json.Unmarshal(manifestBytes, &nextOp); err != nil {
-			logrus.WithError(err).WithField("idx", i).Warnf("Skipping operation")
-		}
-		include, err := shouldInclude(nextOp)
-		// We explicitly ignore evaluation errors and simply include them in the result.
-		if err != nil {
-			stored = append(stored, nextOp)
-			continue
-		}
-		if include {
-			stored = append(stored, nextOp)
-		}
-	}
-	return stored, nil
-}
-
-func (b BoltOperationStorage) DeleteOperation(id string) error {
-	if err := b.db.Delete(NamespaceFromID(id), id); err != nil {
-		return util.LoggingErrorMsgf(err, "deleting operation: %s", id)
-	}
-	return nil
-}
-
-func NewBoltOperationStorage(db *storage.BoltDB) (*BoltOperationStorage, error) {
-	if db == nil {
-		return nil, errors.New("bolt db reference is nil")
-	}
-	return &BoltOperationStorage{db: db}, nil
-
-}
diff --git a/pkg/service/operation/storage/namespace/namespace.go b/pkg/service/operation/storage/namespace/namespace.go
new file mode 100644
index 0000000..6198e93
--- /dev/null
+++ b/pkg/service/operation/storage/namespace/namespace.go
@@ -0,0 +1,30 @@
+package namespace
+
+import (
+	"strings"
+
+	"github.com/tbd54566975/ssi-service/pkg/service/operation/submission"
+)
+
+const namespace = "operation_submission"
+
+// FromID returns a namespace from a given operation ID. An empty string is returned when the namespace cannot
+// be determined.
+func FromID(id string) string {
+	i := strings.LastIndex(id, "/")
+	if i == -1 {
+		return ""
+	}
+	return FromParent(id[:i])
+}
+
+// FromParent returns a namespace from a given parent resource name like "/presentations/submissions". Empty is returned
+// when the parent resource cannot be resolved.
+func FromParent(parent string) string {
+	switch parent {
+	case submission.ParentResource:
+		return namespace
+	default:
+		return ""
+	}
+}
diff --git a/pkg/service/operation/storage/storage.go b/pkg/service/operation/storage/storage.go
index e4e9b3c..d56f4d9 100644
--- a/pkg/service/operation/storage/storage.go
+++ b/pkg/service/operation/storage/storage.go
@@ -1,8 +1,6 @@
 package storage
 
 import (
-	"github.com/tbd54566975/ssi-service/internal/util"
-	"github.com/tbd54566975/ssi-service/pkg/storage"
 	"go.einride.tech/aip/filtering"
 )
 
@@ -35,21 +33,5 @@ type Storage interface {
 	GetOperation(id string) (StoredOperation, error)
 	GetOperations(parent string, filter filtering.Filter) ([]StoredOperation, error)
 	DeleteOperation(id string) error
-}
-
-func NewOperationStorage(s storage.ServiceStorage) (Storage, error) {
-	switch s.Type() {
-	case storage.Bolt:
-		gotBolt, ok := s.(*storage.BoltDB)
-		if !ok {
-			return nil, util.LoggingNewErrorf("trouble instantiating : %s", s.Type())
-		}
-		boltStorage, err := NewBoltOperationStorage(gotBolt)
-		if err != nil {
-			return nil, util.LoggingErrorMsg(err, "could not instantiate schema bolt storage")
-		}
-		return boltStorage, err
-	default:
-		return nil, util.LoggingNewErrorf("unsupported storage type: %s", s.Type())
-	}
+	CancelOperation(id string) (*StoredOperation, error)
 }
diff --git a/pkg/service/operation/submission/submission.go b/pkg/service/operation/submission/submission.go
new file mode 100644
index 0000000..94adf35
--- /dev/null
+++ b/pkg/service/operation/submission/submission.go
@@ -0,0 +1,87 @@
+package submission
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/goccy/go-json"
+	"github.com/pkg/errors"
+	opstorage "github.com/tbd54566975/ssi-service/pkg/service/operation/storage"
+	"github.com/tbd54566975/ssi-service/pkg/storage"
+)
+
+// IDFromSubmissionID returns a submission operation ID from the submission ID.
+func IDFromSubmissionID(id string) string {
+	return fmt.Sprintf("%s/%s", ParentResource, id)
+}
+
+// ID attempts to parse the submission id from the ID of the operation. This is done by taking the last word
+// that results from splitting the id by "/". On failures, the empty string is returned.
+func ID(opID string) string {
+	i := strings.LastIndex(opID, "/")
+	if i == -1 {
+		return ""
+	}
+	return opID[(i + 1):]
+}
+
+const (
+	// Namespace is the namespace to be used for storing submissions.
+	Namespace = "presentation_submission"
+	// ParentResource is the prefix of the submission parent resource.
+	ParentResource = "/presentations/submissions"
+)
+
+// Status indicates the current state of a submission.
+type Status uint8
+
+func (s Status) String() string {
+	switch s {
+	case StatusPending:
+		return "pending"
+	case StatusDenied:
+		return "denied"
+	case StatusApproved:
+		return "approved"
+	case StatusCancelled:
+		return "cancelled"
+	default:
+		return "unknown"
+	}
+}
+
+const (
+	StatusUnknown Status = iota
+	StatusPending
+	StatusCancelled
+	StatusDenied
+	StatusApproved
+)
+
+// OperationUpdater is an implementation of the storage.ResponseSettingUpdater. It's provides a way to update the
+// operation and submission within a single transaction.
+type OperationUpdater struct {
+	storage.UpdaterWithMap
+}
+
+func (u OperationUpdater) SetUpdatedResponse(response []byte) {
+	if u.UpdaterWithMap.Values == nil {
+		return
+	}
+	u.UpdaterWithMap.Values["response"] = response
+}
+
+func (u OperationUpdater) Validate(v []byte) error {
+	var op opstorage.StoredOperation
+	if err := json.Unmarshal(v, &op); err != nil {
+		return errors.Wrap(err, "unmarshalling operation")
+	}
+
+	if op.Done {
+		return errors.New("operation already marked as done")
+	}
+
+	return nil
+}
+
+var _ storage.ResponseSettingUpdater = (*OperationUpdater)(nil)
diff --git a/pkg/service/presentation/model/model.go b/pkg/service/presentation/model/model.go
index 47cada2..edbbfa6 100644
--- a/pkg/service/presentation/model/model.go
+++ b/pkg/service/presentation/model/model.go
@@ -67,7 +67,7 @@ type ListSubmissionRequest struct {
 }
 
 type Submission struct {
-	// One of {`pending`, `approved`, `denied`}.
+	// One of {`pending`, `approved`, `denied`, `cancelled`}.
 	Status string `json:"status" validate:"required"`
 	// The reason why the submission was approved or denied.
 	Reason string `json:"reason"`
diff --git a/pkg/service/presentation/service.go b/pkg/service/presentation/service.go
index 53605fc..7e80f27 100644
--- a/pkg/service/presentation/service.go
+++ b/pkg/service/presentation/service.go
@@ -16,6 +16,7 @@ import (
 	"github.com/tbd54566975/ssi-service/pkg/service/framework"
 	"github.com/tbd54566975/ssi-service/pkg/service/operation"
 	opstorage "github.com/tbd54566975/ssi-service/pkg/service/operation/storage"
+	"github.com/tbd54566975/ssi-service/pkg/service/operation/submission"
 	"github.com/tbd54566975/ssi-service/pkg/service/presentation/model"
 	presentationstorage "github.com/tbd54566975/ssi-service/pkg/service/presentation/storage"
 	"github.com/tbd54566975/ssi-service/pkg/service/schema"
@@ -58,7 +59,7 @@ func NewPresentationService(config config.PresentationServiceConfig, s storage.S
 	if err != nil {
 		return nil, util.LoggingErrorMsg(err, "could not instantiate definition storage for the presentation service")
 	}
-	opsStorage, err := opstorage.NewOperationStorage(s)
+	opsStorage, err := operation.NewOperationStorage(s)
 	if err != nil {
 		return nil, util.LoggingErrorMsg(err, "could not instantiate storage for the operations")
 	}
@@ -177,7 +178,7 @@ func (s Service) CreateSubmission(request model.CreateSubmissionRequest) (*opera
 	}
 
 	storedSubmission := presentationstorage.StoredSubmission{
-		Status:     presentationstorage.StatusPending,
+		Status:     submission.StatusPending,
 		Submission: request.Submission,
 	}
 
@@ -186,7 +187,7 @@ func (s Service) CreateSubmission(request model.CreateSubmissionRequest) (*opera
 		return nil, errors.Wrap(err, "could not store presentation")
 	}
 
-	opID := operation.IDFromSubmissionID(storedSubmission.Submission.ID)
+	opID := submission.IDFromSubmissionID(storedSubmission.Submission.ID)
 	storedOp := opstorage.StoredOperation{
 		ID:   opID,
 		Done: false,
@@ -235,7 +236,7 @@ func (s Service) ReviewSubmission(request model.ReviewSubmissionRequest) (*model
 		return nil, errors.Wrap(err, "invalid request")
 	}
 
-	updatedSubmission, _, err := s.storage.UpdateSubmission(request.ID, request.Approved, request.Reason, operation.IDFromSubmissionID(request.ID))
+	updatedSubmission, _, err := s.storage.UpdateSubmission(request.ID, request.Approved, request.Reason, submission.IDFromSubmissionID(request.ID))
 	if err != nil {
 		return nil, errors.Wrap(err, "updating submission")
 	}
diff --git a/pkg/service/presentation/storage/bolt.go b/pkg/service/presentation/storage/bolt.go
index 7556aac..2d68a06 100644
--- a/pkg/service/presentation/storage/bolt.go
+++ b/pkg/service/presentation/storage/bolt.go
@@ -8,58 +8,36 @@ import (
 	"github.com/sirupsen/logrus"
 	"github.com/tbd54566975/ssi-service/internal/util"
 	opstorage "github.com/tbd54566975/ssi-service/pkg/service/operation/storage"
+	"github.com/tbd54566975/ssi-service/pkg/service/operation/storage/namespace"
+	"github.com/tbd54566975/ssi-service/pkg/service/operation/submission"
 	"github.com/tbd54566975/ssi-service/pkg/storage"
 	"go.einride.tech/aip/filtering"
 )
 
 const (
-	namespace           = "presentation_definition"
-	submissionNamespace = "presentation_submission"
+	presentationDefinitionNamespace = "presentation_definition"
 )
 
 type BoltPresentationStorage struct {
 	db *storage.BoltDB
 }
 
-type opUpdater struct {
-	storage.UpdaterWithMap
-}
-
-func (u opUpdater) SetUpdatedResponse(response []byte) {
-	u.UpdaterWithMap.Values["response"] = response
-}
-
-func (u opUpdater) Validate(v []byte) error {
-	var op opstorage.StoredOperation
-	if err := json.Unmarshal(v, &op); err != nil {
-		return errors.Wrap(err, "unmarshalling operation")
-	}
-
-	if op.Done {
-		return errors.New("operation already marked as done")
-	}
-
-	return nil
-}
-
-var _ storage.ResponseSettingUpdater = (*opUpdater)(nil)
-
 func (b BoltPresentationStorage) UpdateSubmission(id string, approved bool, reason string, opID string) (StoredSubmission, opstorage.StoredOperation, error) {
 	m := map[string]any{
-		"status": StatusDenied,
+		"status": submission.StatusDenied,
 		"reason": reason,
 	}
 	if approved {
-		m["status"] = StatusApproved
+		m["status"] = submission.StatusApproved
 	}
 	submissionData, operationData, err := b.db.UpdateValueAndOperation(
-		submissionNamespace,
+		submission.Namespace,
 		id,
 		storage.NewUpdater(m),
-		opstorage.NamespaceFromID(opID),
+		namespace.FromID(opID),
 		opID,
-		opUpdater{
-			storage.NewUpdater(map[string]any{
+		submission.OperationUpdater{
+			UpdaterWithMap: storage.NewUpdater(map[string]any{
 				"done": true,
 			}),
 		})
@@ -79,7 +57,7 @@ func (b BoltPresentationStorage) UpdateSubmission(id string, approved bool, reas
 }
 
 func (b BoltPresentationStorage) ListSubmissions(filter filtering.Filter) ([]StoredSubmission, error) {
-	allData, err := b.db.ReadAll(submissionNamespace)
+	allData, err := b.db.ReadAll(submission.Namespace)
 	if err != nil {
 		return nil, errors.Wrap(err, "reading all data")
 	}
@@ -127,11 +105,11 @@ func (b BoltPresentationStorage) StorePresentation(presentation StoredPresentati
 		logrus.WithError(err).Error(errMsg)
 		return errors.Wrapf(err, errMsg)
 	}
-	return b.db.Write(namespace, id, jsonBytes)
+	return b.db.Write(presentationDefinitionNamespace, id, jsonBytes)
 }
 
 func (b BoltPresentationStorage) GetPresentation(id string) (*StoredPresentation, error) {
-	jsonBytes, err := b.db.Read(namespace, id)
+	jsonBytes, err := b.db.Read(presentationDefinitionNamespace, id)
 	if err != nil {
 		return nil, util.LoggingErrorMsgf(err, "could not get presentation definition: %s", id)
 	}
@@ -146,28 +124,28 @@ func (b BoltPresentationStorage) GetPresentation(id string) (*StoredPresentation
 }
 
 func (b BoltPresentationStorage) DeletePresentation(id string) error {
-	if err := b.db.Delete(namespace, id); err != nil {
+	if err := b.db.Delete(presentationDefinitionNamespace, id); err != nil {
 		return util.LoggingNewErrorf("could not delete presentation definition: %s", id)
 	}
 	return nil
 }
 
-func (b BoltPresentationStorage) StoreSubmission(submission StoredSubmission) error {
-	id := submission.Submission.ID
+func (b BoltPresentationStorage) StoreSubmission(s StoredSubmission) error {
+	id := s.Submission.ID
 	if id == "" {
 		err := errors.New("could not store submission definition without an ID")
 		logrus.WithError(err).Error()
 		return err
 	}
-	jsonBytes, err := json.Marshal(submission)
+	jsonBytes, err := json.Marshal(s)
 	if err != nil {
 		return util.LoggingNewErrorf("could not store submission definition: %s", id)
 	}
-	return b.db.Write(submissionNamespace, id, jsonBytes)
+	return b.db.Write(submission.Namespace, id, jsonBytes)
 }
 
 func (b BoltPresentationStorage) GetSubmission(id string) (*StoredSubmission, error) {
-	jsonBytes, err := b.db.Read(submissionNamespace, id)
+	jsonBytes, err := b.db.Read(submission.Namespace, id)
 	if err != nil {
 		return nil, util.LoggingNewErrorf("could not get submission definition: %s", id)
 	}
diff --git a/pkg/service/presentation/storage/storage.go b/pkg/service/presentation/storage/storage.go
index 97f4dc1..a8324ed 100644
--- a/pkg/service/presentation/storage/storage.go
+++ b/pkg/service/presentation/storage/storage.go
@@ -5,6 +5,7 @@ import (
 	"github.com/pkg/errors"
 	"github.com/tbd54566975/ssi-service/internal/util"
 	opstorage "github.com/tbd54566975/ssi-service/pkg/service/operation/storage"
+	"github.com/tbd54566975/ssi-service/pkg/service/operation/submission"
 	"github.com/tbd54566975/ssi-service/pkg/storage"
 	"go.einride.tech/aip/filtering"
 )
@@ -44,30 +45,8 @@ func NewPresentationStorage(s storage.ServiceStorage) (Storage, error) {
 	}
 }
 
-type Status uint8
-
-func (s Status) String() string {
-	switch s {
-	case StatusPending:
-		return "pending"
-	case StatusDenied:
-		return "denied"
-	case StatusApproved:
-		return "approved"
-	default:
-		return "unknown"
-	}
-}
-
-const (
-	StatusUnknown Status = iota
-	StatusPending
-	StatusDenied
-	StatusApproved
-)
-
 type StoredSubmission struct {
-	Status     Status                          `json:"status"`
+	Status     submission.Status               `json:"status"`
 	Submission exchange.PresentationSubmission `json:"submission"`
 	Reason     string                          `json:"reason"`
 }


Test patch:
diff --git a/pkg/server/server_operation_test.go b/pkg/server/server_operation_test.go
index 01c9272..d96613b 100644
--- a/pkg/server/server_operation_test.go
+++ b/pkg/server/server_operation_test.go
@@ -15,6 +15,7 @@ import (
 	"github.com/stretchr/testify/require"
 	"github.com/tbd54566975/ssi-service/pkg/server/router"
 	"github.com/tbd54566975/ssi-service/pkg/service/operation"
+	"github.com/tbd54566975/ssi-service/pkg/service/operation/submission"
 	"github.com/tbd54566975/ssi-service/pkg/storage"
 )
 
@@ -27,7 +28,7 @@ func TestOperationsAPI(t *testing.T) {
 		holderSigner, holderDID := getSigner(t)
 		definition := createPresentationDefinition(t, pRouter)
 		submissionOp := createSubmission(t, pRouter, definition.PresentationDefinition.ID, VerifiableCredential(), holderDID, holderSigner)
-		submission := reviewSubmission(t, pRouter, operation.SubmissionID(submissionOp.ID))
+		sub := reviewSubmission(t, pRouter, submission.ID(submissionOp.ID))
 
 		createdID := submissionOp.ID
 		req := httptest.NewRequest(
@@ -43,7 +44,7 @@ func TestOperationsAPI(t *testing.T) {
 		assert.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
 		assert.True(t, resp.Done)
 		assert.Empty(t, resp.Result.Error)
-		data, err := json.Marshal(submission)
+		data, err := json.Marshal(sub)
 		assert.NoError(t, err)
 		var responseAsMap map[string]any
 		assert.NoError(t, json.Unmarshal(data, &responseAsMap))
@@ -220,6 +221,57 @@ func TestOperationsAPI(t *testing.T) {
 		})
 	})
 
+	t.Run("CancelOperation", func(t *testing.T) {
+		t.Run("Marks an operation as done", func(t *testing.T) {
+			s := setupTestDB(t)
+			pRouter := setupPresentationRouter(t, s)
+			opRouter := setupOperationsRouter(t, s)
+
+			holderSigner, holderDID := getSigner(t)
+			definition := createPresentationDefinition(t, pRouter)
+			submissionOp := createSubmission(t, pRouter, definition.PresentationDefinition.ID, VerifiableCredential(), holderDID, holderSigner)
+
+			createdID := submissionOp.ID
+			req := httptest.NewRequest(
+				http.MethodPut,
+				fmt.Sprintf("https://ssi-service.com/v1/operations/%s", createdID),
+				nil)
+			w := httptest.NewRecorder()
+
+			err := opRouter.CancelOperation(newRequestContextWithParams(map[string]string{"id": createdID}), w, req)
+
+			assert.NoError(t, err)
+			var resp router.Operation
+			assert.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
+			assert.True(t, resp.Done)
+			assert.Contains(t, resp.Result.Response, "definition_id")
+			assert.Contains(t, resp.Result.Response, "descriptor_map")
+			assert.Equal(t, "cancelled", resp.Result.Response.(map[string]any)["status"])
+		})
+
+		t.Run("Returns error when operation is done already", func(t *testing.T) {
+			s := setupTestDB(t)
+			pRouter := setupPresentationRouter(t, s)
+			opRouter := setupOperationsRouter(t, s)
+
+			holderSigner, holderDID := getSigner(t)
+			definition := createPresentationDefinition(t, pRouter)
+			submissionOp := createSubmission(t, pRouter, definition.PresentationDefinition.ID, VerifiableCredential(), holderDID, holderSigner)
+			_ = reviewSubmission(t, pRouter, submission.ID(submissionOp.ID))
+
+			createdID := submissionOp.ID
+			req := httptest.NewRequest(
+				http.MethodPut,
+				fmt.Sprintf("https://ssi-service.com/v1/operations/%s", createdID),
+				nil)
+			w := httptest.NewRecorder()
+
+			err := opRouter.CancelOperation(newRequestContextWithParams(map[string]string{"id": createdID}), w, req)
+
+			assert.Error(t, err)
+		})
+	})
+
 }
 
 func setupTestDB(t *testing.T) storage.ServiceStorage {
diff --git a/pkg/server/server_presentation_test.go b/pkg/server/server_presentation_test.go
index ebbde08..55915cf 100644
--- a/pkg/server/server_presentation_test.go
+++ b/pkg/server/server_presentation_test.go
@@ -20,7 +20,7 @@ import (
 	"github.com/tbd54566975/ssi-service/config"
 	"github.com/tbd54566975/ssi-service/internal/keyaccess"
 	"github.com/tbd54566975/ssi-service/pkg/server/router"
-	"github.com/tbd54566975/ssi-service/pkg/service/operation"
+	"github.com/tbd54566975/ssi-service/pkg/service/operation/submission"
 	"github.com/tbd54566975/ssi-service/pkg/service/presentation"
 	"github.com/tbd54566975/ssi-service/pkg/service/presentation/model"
 	"github.com/tbd54566975/ssi-service/pkg/storage"
@@ -163,14 +163,14 @@ func TestPresentationAPI(t *testing.T) {
 					"id":             "did:web:andresuribe.com",
 				})), holderDID, holderSigner)
 
-			req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("https://ssi-service.com/v1/presentations/submissions/%s", operation.SubmissionID(op.ID)), nil)
+			req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("https://ssi-service.com/v1/presentations/submissions/%s", submission.ID(op.ID)), nil)
 			w := httptest.NewRecorder()
 
-			assert.NoError(t, pRouter.GetSubmission(newRequestContextWithParams(map[string]string{"id": operation.SubmissionID(op.ID)}), w, req))
+			assert.NoError(t, pRouter.GetSubmission(newRequestContextWithParams(map[string]string{"id": submission.ID(op.ID)}), w, req))
 
 			var resp router.GetSubmissionResponse
 			assert.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
-			assert.Equal(t, operation.SubmissionID(op.ID), resp.Submission.ID)
+			assert.Equal(t, submission.ID(op.ID), resp.Submission.ID)
 			assert.Equal(t, definition.PresentationDefinition.ID, resp.Submission.DefinitionID)
 			assert.Equal(t, "pending", resp.Submission.Status)
 		})
@@ -218,7 +218,7 @@ func TestPresentationAPI(t *testing.T) {
 			}
 
 			value := newRequestValue(t, request)
-			createdID := operation.SubmissionID(submissionOp.ID)
+			createdID := submission.ID(submissionOp.ID)
 			req := httptest.NewRequest(
 				http.MethodPut,
 				fmt.Sprintf("https://ssi-service.com/v1/presentations/submissions/%s/review", createdID),
@@ -243,7 +243,7 @@ func TestPresentationAPI(t *testing.T) {
 			holderSigner, holderDID := getSigner(t)
 			definition := createPresentationDefinition(t, pRouter)
 			submissionOp := createSubmission(t, pRouter, definition.PresentationDefinition.ID, VerifiableCredential(), holderDID, holderSigner)
-			createdID := operation.SubmissionID(submissionOp.ID)
+			createdID := submission.ID(submissionOp.ID)
 			_ = reviewSubmission(t, pRouter, createdID)
 
 			request := router.ReviewSubmissionRequest{
@@ -323,14 +323,14 @@ func TestPresentationAPI(t *testing.T) {
 				{
 					Status: "pending",
 					PresentationSubmission: &exchange.PresentationSubmission{
-						ID:           operation.SubmissionID(op.ID),
+						ID:           submission.ID(op.ID),
 						DefinitionID: definition.PresentationDefinition.ID,
 					},
 				},
 				{
 					Status: "pending",
 					PresentationSubmission: &exchange.PresentationSubmission{
-						ID:           operation.SubmissionID(op2.ID),
+						ID:           submission.ID(op2.ID),
 						DefinitionID: definition.PresentationDefinition.ID,
 					},
 				},
@@ -395,7 +395,7 @@ func TestPresentationAPI(t *testing.T) {
 				{
 					Status: "pending",
 					PresentationSubmission: &exchange.PresentationSubmission{
-						ID:           operation.SubmissionID(op.ID),
+						ID:           submission.ID(op.ID),
 						DefinitionID: definition.PresentationDefinition.ID,
 					},
 				},
diff --git a/pkg/service/operation/model_test.go b/pkg/service/operation/submission/submission_test.go
similarity index 89%
rename from pkg/service/operation/model_test.go
rename to pkg/service/operation/submission/submission_test.go
index db8084c..14b43b1 100644
--- a/pkg/service/operation/model_test.go
+++ b/pkg/service/operation/submission/submission_test.go
@@ -1,4 +1,4 @@
-package operation
+package submission
 
 import (
 	"testing"
@@ -30,7 +30,7 @@ func TestSubmissionID(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			assert.Equal(t, tt.want, SubmissionID(tt.opID))
+			assert.Equal(t, tt.want, ID(tt.opID))
 		})
 	}
 }