<img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 "> Bitovi Blog - UX and UI design, JavaScript and Frontend development
Loading

Backend |

Using Temporal Cloud With On-Prem Data

Ensure data security when using Temporal Cloud. Learn how to customize Temporal to prevent sensitive information from being sent to the cloud.

Emil Kais

Emil Kais

Twitter Reddit

Using cloud services is standard practice for most backend application architectures. When using cloud services, it is important to understand and control what data is leaving your network and being sent to the cloud. Temporal Cloud has great options available to ensure that data sent to and from the cloud is securely encrypted. This post will showcase how Temporal Cloud might interact with your infrastructure by default and how you can customize Temporal to prevent any user or business-related data from being sent to the cloud.

Table of Contents

Understanding Temporal Cloud

Cloud services can be difficult to understand without proper visualization. We created an image to help explain how Temporal Cloud manages data.

Temporal-Cloud

While Temporal Workers are executing a Workflow, information will be sent into the Temporal Cloud and returned to your server or service upon completion. Now that you can visualize the path of the data, let’s tackle the issue of security and encryption when dealing with information sent to and from the Temporal Cloud.

Defining Your Own DataConverter Option for Your Temporal Client

Temporal provides a converter library that you can import in Go to create your own DataConverter to pass as an option to your Temporal Workers and Client. This customized data converter extends a PayloadCodec interface with two methods: Encode and Decode. These methods will be called to encode and decode the data that passes through the Temporal Workers, as well as the Temporal Client you create. Here is a high-level (and simplified) look at what it will look like:

temporal-data-encoding

Note: The Codec we use implements PayloadCodec which is using AES Crypt security

Worker and Temporal Client Integration

When instantiating the Temporal Client, there is an option to create your own DataConverter. You will be creating a method called NewEncryptionDataConverter, where you will define your own Codec to be used in the data conversion process.

NewEncryptionDataConverter Method

This method will return a *DataConverter struct, which is from the go.temporal.io/sdk/converter library. Pass in your own Codec struct, which you define to extend the PayloadCodec type. Your Codec struct has two methods: Encode and Decode, which we will review later in this post.

See below what this method can look like:

func NewEncryptionDataConverter(dataConverter converter.DataConverter, options DataConverterOptions) *DataConverter {
	codecs := []converter.PayloadCodec{
		&Codec{KeyID: options.KeyID},
	}

	return &DataConverter{
		parent:        dataConverter,
		DataConverter: converter.NewCodecDataConverter(dataConverter, codecs...),
		options:       options,
	}
}

Note: You can find a code sample of this implementation here: https://github.com/temporalio/samples-go/tree/main/encryption. Remember converter is from the go.temporal.io/sdk/converter library.

The key in the codec will be used to encrypt and decrypt the data input to the DataConverter. You may need to change how the key is passed within the DataConverter according to your security regulations or preferences. Then, finally, return the DataConverter struct that the Temporal Client needs.

The codec struct looks like this:

// Codec implements PayloadCodec using AES Crypt.
type Codec struct {
	KeyID string
}

Encode Method

// Encode implements converter.PayloadCodec.Encode.
func (e *Codec) Encode(payloads []*commonpb.Payload) ([]*commonpb.Payload, error) {
	result := make([]*commonpb.Payload, len(payloads))
	for i, p := range payloads {
		origBytes, err := p.Marshal()
		if err != nil {
			return payloads, err
		}

		key := e.getKey(e.KeyID)

		b, err := encrypt(origBytes, key)
		if err != nil {
			return payloads, err
		}

		result[i] = &commonpb.Payload{
			Metadata: map[string][]byte{
				converter.MetadataEncoding: []byte(MetadataEncodingEncrypted),
				MetadataEncryptionKeyID:    []byte(e.KeyID),
			},
			Data: b,
		}
	}

	return result, nil
}

The code above takes a protobuf payload that Temporal defined (commonpb.Payload). You retrieve the key needed to encrypt the payload, then the code returns a struct following the commonpb.Payload structure.

Decode Method

// Decode implements converter.PayloadCodec.Decode.
func (e *Codec) Decode(payloads []*commonpb.Payload) ([]*commonpb.Payload, error) {
	result := make([]*commonpb.Payload, len(payloads))
	for i, p := range payloads {
		// Only if it's encrypted
		if string(p.Metadata[converter.MetadataEncoding]) != MetadataEncodingEncrypted {
			result[i] = p
			continue
		}

		keyID, ok := p.Metadata[MetadataEncryptionKeyID]
		if !ok {
			return payloads, fmt.Errorf("no encryption key id")
		}

		key := e.getKey(string(keyID))

		b, err := decrypt(p.Data, key)
		if err != nil {
			return payloads, err
		}

		result[i] = &commonpb.Payload{}
		err = result[i].Unmarshal(b)
		if err != nil {
			return payloads, err
		}
	}

	return result, nil
}

The decode method takes a payload matching the specific encoding that was provided in the Encode method, using the key to decrypt the data and return it in the same protobuf style.

Quick Recap

You can add a layer of security to your information by passing your custom DataConverter to handle encryption/decryption. This method will be used within Temporal Clients and Temporal Workers. However, data security isn’t the only thing you can do with your DataConverter. Although the data is encoded, it is still a large amount of information to send to the Temporal Cloud. Instead, you can use your DataConverter to store the data in a datastore that you host. When you store your data separately, you’ll be sending an encrypted ID to Temporal Cloud, which acts as a reference to the information inside your datastore.

Adding a DataStore to Your DataConverter

Let’s say you wanted to integrate our own hosted MongoDB (this can be a SQL DB, Redis, S3, or any datastore you choose). Your DataConverter will then store the information during the encoding process and return an encrypted ID to reference the information inside your datastore. At a high level, this is how it will look:

temporal-datastore

The Encode and Decode methods must be updated to include the MongoDB dependency.

Updating the Codec Struct

Start by updating the Codec struct within the library containing your NewDataConverter method. This way, you can access your MongoDB datastore.

// Codec implements PayloadCodec using AES Crypt.
type Codec struct {
	KeyID string
	Db    *mongo.Controller
}

Changes to NewEncryptionDataConverter

Next, change the NewEncryptionDataConverter method. You’ll only have to change two lines in the method: creating a MongoDB instance and passing it in as your Dbfield in the Codec struct.

func NewEncryptionDataConverter(dataConverter converter.DataConverter, options DataConverterOptions) *DataConverter {
	mongoController := mongo.NewMongoController()
	codecs := []converter.PayloadCodec{
		&Codec{KeyID: options.KeyID, Db: mongoController},
	}
	... rest of your code
}

In this example, you have a basic MongoDB controller that you define, the code for which can be seen at the end of the blog post.

Changes to the Encode Method

Here, you’ll create your own UUID, insert the record in your own MongoDB datastore using the UUID as the primary key, and send the encrypted UUID to Temporal Cloud.

func (e *Codec) Encode(payloads []*commonpb.Payload) ([]*commonpb.Payload, error) {
	result := make([]*commonpb.Payload, len(payloads))
	for i, p := range payloads {
		//create uniqueID to return
		uuidToCodex := uuid.New()
		dataToInsert := make(map[string]interface{}, 0)
		err := json.Unmarshal(p.Data, &dataToInsert)
		if err != nil {
			return payloads, err
		}

		key := e.getKey(e.KeyID)

		// insert record into db
		dataToInsert["_id"] = uuidToCodex.String()

		e.Db.InsertRecord("codex-data", dataToInsert)

		//return the encrypted uuid back
		b, err := encrypt([]byte(uuidToCodex.String()), key)

		if err != nil {
			return payloads, err
		}

		result[i] = &commonpb.Payload{
			Metadata: map[string][]byte{
				converter.MetadataEncoding: []byte(MetadataEncodingEncrypted),
				MetadataEncryptionKeyID:    []byte(e.KeyID),
			},
			Data: b,
		}
	}

	return result, nil
}

Changes to the Decode Method

Once you decrypt the payload, use the UUID to retrieve the record from the collection in the datastore and then return the data as a commonpb.Payload type. It’s crucial to encode your result in "json/plain"; otherwise, the Workflow will have encoding errors. What you’ll see returned in your infrastructure is the exact data that we inputted for the workflow.

func (e *Codec) Decode(payloads []*commonpb.Payload) ([]*commonpb.Payload, error) {
	result := make([]*commonpb.Payload, len(payloads))
	for i, p := range payloads {
		...code as before
		b, err := decrypt(p.Data, key)
		if err != nil {
			return payloads, err
		}
		result[i] = &commonpb.Payload{}

		// retrieve record by converting []byte into the decrypted UUID
		storedObj, err := e.Db.RetrieveRecord("codex-data", string(b))
		if err != nil {
			return payloads, err
		}

		payload, err := json.Marshal(&storedObj)
		if err != nil {
			return payloads, err
		}
		
    // Metadata, on return the MetadataEncoding is "json/plain"
		result[i] = &commonpb.Payload{
			Metadata: map[string][]byte{
				converter.MetadataEncoding: []byte(converter.MetadataEncodingJSON),
			},
			Data: payload,
		}
	}

	return result, nil
}

Conclusion

This post covered the process of setting up your own DataConverter method in your infrastructure and employing your own encode/decode methods to match your business/regulatory requirements. From there, we reviewed how to reduce the information you send to Temporal Cloud by using your own datastore, sending only an encrypted reference to that data. With this method, you can use Temporal Cloud even if you have regulatory or security requirements that prevent you from sending information outside of your infrastructure. You can find a link to the GitHub repo with the sample code here: Using Temporal Cloud with On-Prem Data Code Samples.

Need more help with Temporal Cloud?

Bitovi is an official Temporal.io partner, and we offer free Temporal audits to new clients. Schedule a consultation for expert help with your Temporal implementation.