This sample shows how to use Azure Cosmos DB client-side encryption to encrypt selected saga properties before they leave the process. The sample uses a client/server scenario in which the server stores saga data with Cosmos DB Persistence.
Prerequisites
Ensure that an instance of the latest Azure Cosmos DB Emulator is running.
Sample structure
This sample contains three projects, SharedMessages, Client and Server.
SharedMessages
The shared message contracts used by all endpoints.
Client
- Sends the
StartOrdermessage toServer. - Receives and handles the
OrderCompletedevent.
Server
- Receive the
StartOrdermessage and initiate anOrderSaga. OrderSagarequests a timeout with an instance ofCompleteOrderwith the saga data.OrderSagapublishes anOrderCompletedevent when theCompleteOrdertimeout fires.
Running the sample
- Start the
Serverproject and wait for the endpoint to report that it has started. - Start the
Clientproject. - Press S in the client window to start an order.
- After the saga timeout expires, verify that the server reports the completed saga and the client receives the
OrderCompletedevent.
Implementation highlights
Persistence config
In Program.cs of the Server project, the endpoint is configured to use Cosmos DB Persistence:
var endpointConfiguration = new EndpointConfiguration("Samples.CosmosDB.Encryption.Server");
var persistence = endpointConfiguration.UsePersistence<CosmosPersistence>();
persistence.DatabaseName("Samples.CosmosDB.Encryption");
// NServiceBus must use the encryption-enabled client so saga reads and writes apply the encryption policy.
persistence.CosmosClient(cosmosClient);
persistence.DefaultContainer("Server", "/id");
// The container is created explicitly with its encryption policy, so persistence must not create it.
persistence.DisableContainerCreation();
endpointConfiguration.UseTransport(new LearningTransport());
endpointConfiguration.UseSerialization<SystemJsonSerializer>();
endpointConfiguration.EnableInstallers();
builder.Services.AddNServiceBusEndpoint(endpointConfiguration);
In the non-transactional mode, the saga id is used as a partition key, and thus, the container needs to use / as the partition key path.
Encryption setup
The server wraps the regular CosmosClient with the Cosmos DB encryption client before passing it to NServiceBus. It then creates a client encryption key and a container with an encryption policy.
var containerProperties = new ContainerProperties(ContainerName, PartitionKeyPath)
{
DefaultTimeToLive = -1,
ClientEncryptionPolicy = new ClientEncryptionPolicy(
[
// NServiceBus derives the saga ID from the correlation value and performs a point read,
// so the correlated property can use randomized encryption.
Encrypted("/OrderId"),
Encrypted("/OrderDescription"),
// Policy format version 2 allows encrypting id and partition key
// but requires deterministic encryption for these properties.
EncryptedDeterministic("/id"),
EncryptedDeterministic("/PartitionKey")
// Deliberately NOT encrypted:
// /_NServiceBus-Persistence-Metadata - pessimistic locking patches a path INSIDE
// this object (SagaDataContainer-ReservedUntil).
// Only top-level paths can be encrypted, so
// including it would encrypt the whole subtree
// and break the lock.
],
policyFormatVersion: 2)
};
The policy uses encryption policy format version 2, which allows all saga data properties to be encrypted. OrderId and OrderDescription use randomized encryption, while id and PartitionKey use deterministic encryption because Cosmos DB requires these values for point reads and routing. The NServiceBus persistence metadata remains unencrypted because pessimistic locking updates a nested property within that object.
The key encryption key resolver stores a generated RSA private key under the server output directory. This is suitable only for demonstrating the encryption flow. Production systems should use a secure key store, such as Azure Key Vault, and must retain the key for as long as encrypted data needs to be read.
The server deletes and recreates the Samples. database each time it starts so that the sample always uses the expected encryption key and policy. Starting the server therefore removes all existing sample data.
Order saga data
The data stored on the saga is defined in the OrderSagaData. file in the Server project:
public class OrderSagaData :
ContainSagaData
{
public Guid OrderId { get; set; }
public string OrderDescription { get; set; }
}
Order saga
The handlers for this data are in the OrderSaga. file in the Server project:
public class OrderSaga(ILogger<OrderSaga> logger) :
Saga<OrderSagaData>,
IAmStartedByMessages<StartOrder>,
IHandleTimeouts<CompleteOrder>
{
protected override void ConfigureHowToFindSaga(SagaPropertyMapper<OrderSagaData> mapper)
{
mapper.MapSaga(saga => saga.OrderId).ToMessage<StartOrder>(msg => msg.OrderId);
}
public Task Handle(StartOrder message, IMessageHandlerContext context)
{
var orderDescription = $"The saga for order {message.OrderId}";
Data.OrderDescription = orderDescription;
logger.LogInformation("Received StartOrder message {OrderId}. Starting Saga", Data.OrderId);
var shipOrder = new ShipOrder
{
OrderId = message.OrderId
};
logger.LogInformation("Order will complete in 5 seconds");
CompleteOrder timeoutData = new()
{
OrderDescription = orderDescription,
};
return Task.WhenAll([
context.SendLocal(shipOrder),
RequestTimeout(context, TimeSpan.FromSeconds(5), timeoutData)
]);
}
public Task Timeout(CompleteOrder state, IMessageHandlerContext context)
{
logger.LogInformation("Saga with OrderId {OrderId} completed", Data.OrderId);
MarkAsComplete();
OrderCompleted orderCompleted = new()
{
OrderId = Data.OrderId
};
return context.Publish(orderCompleted);
}
}