diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ce11b..352b925 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,27 @@ # Changelog -## Unreleased +## v0.6.0-alpha ### New -* Support for sub-orchestrations (#7) - contributed by @usemam +* Support for sub-orchestrations ([#7](https://github.com/microsoft/durabletask-mssql/pull/7)) - contributed by [@usemam](https://github.com/usemam) +* Support for explicit task hub name configuration +* Added `dt.GlobalSettings` table and `dt.SetGlobalSetting` stored procedure +* Added new permissions.sql setup script for setting up databaes permissions +* Added task hub documentation page + +## Breaking changes + +* Renamed `SqlProviderOptions` to `SqlOrchestrationServiceSettings` and added required constructor parameters +* User-based multitenancy is now disabled by default +* The `dt_runtime` role is now granted access to only specific stored procedures rather than all of them ## v0.5.0-alpha ### New -* Added support for .NET Standard 2.0 (DTFx only) (#6) -* Made batch size configurable (#5) - contributed by @usemam +* Added support for .NET Standard 2.0 (DTFx only) ([#6](https://github.com/microsoft/durabletask-mssql/pull/6)) +* Made batch size configurable ([#5](https://github.com/microsoft/durabletask-mssql/pull/5)) - contributed by [@usemam](https://github.com/usemam) ### Improved diff --git a/docs/architecture.md b/docs/architecture.md index 1b4aba1..c200e78 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,6 +18,7 @@ The tables are as follows: * **dt.NewTasks**: Contains a queue of unprocessed activity tasks for running instances. * **dt.Versions**: Contains a record of schema versions that have been provisioned in this database. * **dt.Payloads**: Contains the payload blobs for all instances, events, tasks, and history records. +* **dt.GlobalSettings**: Key-value configuration pairs that control the runtime behavior of the provider. You can find the current version of the database schema in the `dt.Versions` table. If you create an app using one version of the SQL provider and then later upgrade to a newer version of the provider, the provider will automatically take care of upgrading the database schema, without introducing any downtime. diff --git a/docs/introduction.md b/docs/introduction.md index 7ba8283..573fdfa 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -1,8 +1,6 @@ # Introduction -The [Durable Task Framework](https://github.com/Azure/durabletask) (DTFx) is a lightweight and portable framework that allows developers to build reliable workflows (orchestrations) using .NET tasks and standard C# async/await syntax. Task orchestrations and their activities are written using standard, imperative code. No DSLs or DAGs. - -The Microsoft SQL provider is a backend for DTFx that persists all task hub state in a Microsoft SQL database, which can be hosted in the cloud or in your own infrastructure. This provider includes support for all DTFx features, including orchestrations, activities, and entities, and has full support for [Azure Durable Functions](https://docs.microsoft.com/azure/azure-functions/durable/durable-functions-overview). +The Durable Task SQL Provider is a backend for the [Durable Task Framework](https://github.com/Azure/durabletask) (DTFx) and [Azure Durable Functions](https://docs.microsoft.com/azure/azure-functions/durable/durable-functions-overview) that persists all task hub state in a Microsoft SQL database. It's compatible with [on-premises SQL Server](https://www.microsoft.com/sql-server/), [SQL Server for Docker containers](https://hub.docker.com/_/microsoft-mssql-server), the cloud-hosted [Azure SQL Database](https://azure.microsoft.com/services/azure-sql/), and includes support for orchestrations, activities, and durable entities. ## Features @@ -10,7 +8,7 @@ The Microsoft SQL provider is just one of [many supported providers for the Dura ### Portability -Microsoft SQL Server is an industry leading database server available as a managed service or as a standalone installation and is supported by the leading cloud providers ([Azure SQL](https://azure.microsoft.com/services/azure-sql/), [SQL Server on AWS](https://aws.amazon.com/sql/), [Google Cloud SQL](https://cloud.google.com/sql/), etc.). It also is supported on multiple OS platforms, like [Windows Server](https://www.microsoft.com/sql-server/), [Linux containers](https://hub.docker.com/_/microsoft-mssql-server), and more recently on [IoT/Edge](https://azure.microsoft.com/services/sql-edge/) devices. All your orchestration data is contained in a single database that can easily be exported from one host to another, so there is no need to worry about having your data locked to a particular vendor. +Microsoft SQL Server is an industry leading database server available as a managed service or as a standalone installation and is supported by the leading cloud providers ([Azure SQL](https://azure.microsoft.com/services/azure-sql/), [SQL Server on AWS](https://aws.amazon.com/sql/), [Google Cloud SQL](https://cloud.google.com/sql/), etc.). It also is supported on multiple OS platforms, like [Windows Server](https://www.microsoft.com/sql-server/), [Linux Docker containers](https://hub.docker.com/_/microsoft-mssql-server), and more recently on [IoT/Edge](https://azure.microsoft.com/services/sql-edge/) devices. All your orchestration data is contained in a single database that can easily be exported from one host to another, so there is no need to worry about having your data locked to a particular vendor. ### Control diff --git a/docs/multitenancy.md b/docs/multitenancy.md index 8771492..1722112 100644 --- a/docs/multitenancy.md +++ b/docs/multitenancy.md @@ -1,20 +1,32 @@ # Multitenancy -One of the goals for the Microsoft SQL provider for the Durable Task Framework (DTFx) is to create a foundation for safe [multi-tenant deployments](https://en.wikipedia.org/wiki/Multitenancy). This is especially valuable when your organization has many small apps but prefers to manage only a single backend database. Different apps can connect to this database using different database login credentials. Database administrators will be able to query data across all tenants but individual apps will only have access to their own data. +This article describes the multitenancy features of the Durable Task SQL backend and how to enable them. -## Task hubs +## Overview -A **task hub** is an abstract grouping concept in DTFx and Durable Functions. Orchestrators, activities, and entities can only interact with each other when they belong to the same task hub. This is enforced at runtime by the underlying DTFx storage provider. In the case of the DTFx SQL provider, all stored procedures used by the runtime will only ever access data that belongs to the current task hub. +One of the goals for the Microsoft SQL provider for the Durable Task Framework (DTFx) is to enable [multi-tenant deployments](https://en.wikipedia.org/wiki/Multitenancy) with multiple apps sharing the same database. This is often valuable when your organization has many small apps but prefers to manage only a single backend database. When multitenancy is enabled, different apps connect to a shared database using different database login credentials. Database administrators will be able to query data across all tenants but individual apps will only have access to their own data. -The current task hub is determined by the credentials used to log into the database. For example, if your app connects to a Microsoft SQL database using **dbo** credentials (the default, built-in admin user for most databases), then the name of the connected task hub will be "dbo". It is not necessary to explicitly create or delete task hubs. All orchestrations and entities created under that connection will automatically be associated with the corresponding task hub. +Multitenancy works by isolating each app into a separate [task hub](taskhubs.md). The current task hub is determined by the credentials used to log into the database. For example, if your app connects to a Microsoft SQL database using **dbo** credentials (the default, built-in admin user for most databases), then the name of the connected task hub will be "dbo". Task hubs provide data isolation, ensuring that two users in the same database will not be able to access each other's data. -?> One difference between the Microsoft SQL provider and the Azure Storage provider is that all task hubs in the Microsoft SQL provider share the same tables. In the Azure Storage provider, each task hub is given a completely separate table in Azure Storage (along with isolated queues and blob containers). More importantly, however, is that the SQL provider allows task hubs to be securely isolated from each other. This is not possible with the Azure Storage provider - different tenants would need to be assigned to different storage accounts. The ability for multiple tenants to securely share a SQL databases is therefore much more cost-effective for implementing multitenancy. +?> Task hub isolation in the current version of the SQL provider prevents one tenant from accessing data that belongs to another tenant. However, it doesn't impose any restrictions on data volumes or database CPU usage. If this kind of strict resource isolation is required, then each tenant should instead be separated into their own database. -Each table in the Durable Task schema includes a `TaskHub` column that indicates the name of the tenant that a particular row belongs to. The stored procedures used to access data in the database will always filter data using the current task hub context. This ensures that each credential can only access data that is part of the same task hub. The task hub is also the first component in all primary keys within the database and is thus part of the identity of all instances. +## Enabling multitenancy -## Getting started +If you want to have multiple apps share a database (multitenancy) but want to ensure no app can access any data owned by another app, then you can configure a task hub via database login credentials. In this model, database administrators provide individual app owners with SQL credentials known only to them, and each credential maps to an isolated task hub within the database. When using this model, you do not configure a task hub name in code or configuration. Instead, the SQL login username is used as the task hub name. -To enable multitenancy, each tenant must be given its own login and user ID for the target database. To ensure that each tenant can only access its own data, you should add each user to the `dt_runtime` role that is created automatically by the setup scripts using the following T-SQL syntax. +Multitenancy is disabled by default. To enable multitenancy, a database administrator must set `TaskHubMode` to `1` in the `dt.GlobalSettings` table. This can be done using the `dt.SetGlobalSetting` stored procedure. + +```sql +EXECUTE dt.SetGlobalSetting @Name='TaskHubMode', @Value=1 +``` + +The value `1` instructs all runtime stored procedures to infer the current task hub from the [`USER_NAME()`](https://docs.microsoft.com/sql/t-sql/functions/user-name-transact-sql) function of SQL Server. Multitenancy can be disabled by setting `TaskHubMode` to `0`. + +!> Enabling or disabling multitenancy may result in subsequent logins using a different task hub name. Any orchestrations or entities created using a previous task hub names will not be visible to an app that switches to a new task hub name. Switching between task hub modes must therefore be done with careful planning and should not be done while apps are actively running. + +## Managing user credentials + +Once multitenancy is enabled, each tenant must be given its own login and user ID for the target database. To ensure that each tenant can only access its own data, you should add each user to the `dt_runtime` role that is created automatically by the database setup scripts. The following SQL statements illustrate how this can be done for a SQL database that supports username/password authentication. @@ -34,4 +46,4 @@ GO Each tenant should then use a SQL connection string with the above login credentials for their assigned user account. See [this SQL Server documentation](https://docs.microsoft.co/sql/relational-databases/security/authentication-access/create-a-database-user) for more information about how to create and manage database users. -!> Task hub names are limited to 50 characters. Database username lengths must therefore not exceed 50 characters. +?> Task hub names are limited to 50 characters. When multitenancy is enabled, the username is used as the task hub name. If the username exceeds 50 characters, the task hub name value used in the database will be a truncated version of the username followed by an MD5 hash of the full username. diff --git a/docs/sidebar.md b/docs/sidebar.md index 81d5f8a..fa2eca8 100644 --- a/docs/sidebar.md +++ b/docs/sidebar.md @@ -1,4 +1,5 @@ * [Introduction](introduction.md "Durable Task SQL Provider") * [Getting started](quickstart.md) * [Architecture](architecture.md) +* [Task Hubs](taskhubs.md) * [Multitenancy](multitenancy.md) \ No newline at end of file diff --git a/docs/taskhubs.md b/docs/taskhubs.md new file mode 100644 index 0000000..052eb5f --- /dev/null +++ b/docs/taskhubs.md @@ -0,0 +1,50 @@ +# Task Hubs + +This article describes what task hubs are and how they can be configured. + +## Overview + +A **task hub** is a logical grouping concept in both the Durable Task Framework (DTFx) and Durable Functions. Orchestrators, activities, and entities all belong to a single task hub and can only interact directly with other orchestrations, activities, and entities that are defined in the same task hub. In the SQL provider, a single database can contain multiple task hubs. Task hub data isolation is enforced at runtime by the underlying DTFx storage provider and its SQL stored procedures. In the case of the DTFx SQL provider, all stored procedures used by the runtime will only ever access data that belongs to the current task hub. + +Task hubs are also the primary unit of isolation within a database. Each table in the Durable Task schema includes a `TaskHub` column as part of its primary key and stored procedures will only access data that belongs to the current _task hub context_. This isolation serves two primary purposes: supporting side-by-side deployments of different application version and [enabling multitenancy](multitenancy.md), as explained in other articles. + +?> One difference between the Microsoft SQL provider and the Azure Storage provider is that all task hubs in the Microsoft SQL provider share the same tables. In the Azure Storage provider, each task hub is given a completely separate table in Azure Storage (along with isolated queues and blob containers). More importantly, however, is that the SQL provider allows task hubs to be securely isolated from each other. This is not possible with the Azure Storage provider - different tenants would need to be assigned to different storage accounts. The ability for multiple tenants to securely share a SQL databases is therefore much more cost-effective for implementing multitenancy. + +## Configuring task hub names + +Tasks hubs can be configured explicitly in the SQL provider configuration or can be inferred by details of the SQL connection string. For self-hosted DTFx apps, you can configure the task hub directly in the `SqlProviderOptions` class. + +```csharp +var options = new SqlProviderOptions +{ + TaskHub = "MyTaskHub", + ConnectionString = Environment.GetEnvironmentVariable("SQLDB_Connection"), +}; +``` + +For Durable Functions apps, the task hub name can be configured in the `extensions/durableTask/hubName` property of the **host.json** file. + +```json +{ + "version": "2.0", + "extensions": { + "durableTask": { + "hubName": "MyTaskHub", + "storageProvider": { + "type": "MicrosoftSQL", + "connectionStringName": "SQLDB_Connection" + } + } + } +} +``` + +Task hub names can alternatively be inferred from database user credentials. For more information, see [Multitenancy](multitenancy.md). + +?> Task hub names are limited to 50 characters. If the specified task hub name exceeds 50 characters, the configured task hub name will be truncated and suffixed with an MD5 hash of the full task hub name to keep it within 50 characters. + +## Case sensitivity + +Whether task hub names are case-sensitive depends on the collation of the SQL database. For example, if a [binary collation](https://docs.microsoft.com/sql/relational-databases/collations/collation-and-unicode-support#Binary-collations) is configured on the database, task hub names will be case-sensitive. Non-binary collations may result in case-insensitive string comparisons, making task hub names effectively case-insensitive. For more information on SQL database collations, see [Collation and Unicode support](https://docs.microsoft.com/sql/relational-databases/collations/collation-and-unicode-support) in the Microsoft SQL documentation. + +?> The preferred database collation for the Durable Task SQL provider is `Latin1_General_100_BIN2_UTF8`, which is a binary collation. diff --git a/src/DurableTask.SqlServer.AzureFunctions/SqlDurabilityOptions.cs b/src/DurableTask.SqlServer.AzureFunctions/SqlDurabilityOptions.cs index 87e85f6..9c43312 100644 --- a/src/DurableTask.SqlServer.AzureFunctions/SqlDurabilityOptions.cs +++ b/src/DurableTask.SqlServer.AzureFunctions/SqlDurabilityOptions.cs @@ -4,6 +4,10 @@ namespace DurableTask.SqlServer.AzureFunctions { using System; + using Microsoft.Azure.WebJobs.Extensions.DurableTask; + using Microsoft.Data.SqlClient; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Logging.Abstractions; using Newtonsoft.Json; public class SqlDurabilityOptions @@ -11,12 +15,50 @@ public class SqlDurabilityOptions [JsonProperty("connectionStringName")] public string ConnectionStringName { get; set; } = "SQLDB_Connection"; + [JsonProperty("taskHubName")] + public string TaskHubName { get; set; } = "default"; + [JsonProperty("taskEventLockTimeout")] public TimeSpan TaskEventLockTimeout { get; set; } = TimeSpan.FromMinutes(2); [JsonProperty("taskEventBatchSize")] public int TaskEventBatchSize { get; set; } = 10; - internal SqlProviderOptions ProviderOptions { get; set; } = new SqlProviderOptions(); + internal ILoggerFactory LoggerFactory { get; set; } = NullLoggerFactory.Instance; + + internal SqlOrchestrationServiceSettings GetOrchestrationServiceSettings( + IConnectionStringResolver connectionStringResolver) + { + if (connectionStringResolver == null) + { + throw new ArgumentNullException(nameof(connectionStringResolver)); + } + + string? connectionString = connectionStringResolver.Resolve(this.ConnectionStringName); + if (string.IsNullOrEmpty(connectionString)) + { + throw new InvalidOperationException( + $"No SQL connection string configuration was found for the app setting or environment variable named '{this.ConnectionStringName}'."); + } + + // Validate the connection string + try + { + new SqlConnectionStringBuilder(connectionString); + } + catch (ArgumentException e) + { + throw new ArgumentException("The provided connection string is invalid.", e); + } + + var settings = new SqlOrchestrationServiceSettings(connectionString, this.TaskHubName) + { + LoggerFactory = this.LoggerFactory, + WorkItemLockTimeout = this.TaskEventLockTimeout, + WorkItemBatchSize = this.TaskEventBatchSize, + }; + + return settings; + } } } diff --git a/src/DurableTask.SqlServer.AzureFunctions/SqlDurabilityProviderFactory.cs b/src/DurableTask.SqlServer.AzureFunctions/SqlDurabilityProviderFactory.cs index 5538e0a..d157606 100644 --- a/src/DurableTask.SqlServer.AzureFunctions/SqlDurabilityProviderFactory.cs +++ b/src/DurableTask.SqlServer.AzureFunctions/SqlDurabilityProviderFactory.cs @@ -36,6 +36,9 @@ public SqlDurabilityProviderFactory( this.connectionStringResolver = connectionStringResolver ?? throw new ArgumentNullException(nameof(connectionStringResolver)); } + // Called by the Durable trigger binding infrastructure + public string Name => "MicrosoftSQL"; + // Called by the Durable trigger binding infrastructure public DurabilityProvider GetDurabilityProvider() { @@ -69,7 +72,7 @@ public DurabilityProvider GetDurabilityProvider(DurableClientAttribute attribute SqlDurabilityOptions clientOptions = this.GetSqlOptions(attribute); IOrchestrationServiceClient serviceClient = - new SqlOrchestrationService(clientOptions.ProviderOptions); + new SqlOrchestrationService(clientOptions.GetOrchestrationServiceSettings(this.connectionStringResolver)); clientProvider = new SqlDurabilityProvider( this.GetOrchestrationService(), clientOptions, @@ -89,7 +92,9 @@ SqlOrchestrationService GetOrchestrationService() { if (this.service == null) { - this.service = new SqlOrchestrationService(this.GetDefaultSqlOptions().ProviderOptions); + SqlDurabilityOptions options = this.GetDefaultSqlOptions(); + this.service = new SqlOrchestrationService( + options.GetOrchestrationServiceSettings(this.connectionStringResolver)); } return this.service; @@ -107,36 +112,28 @@ SqlDurabilityOptions GetDefaultSqlOptions() SqlDurabilityOptions GetSqlOptions(DurableClientAttribute attribute) { - var options = new SqlDurabilityOptions(); + var options = new SqlDurabilityOptions + { + TaskHubName = this.extensionOptions.HubName, + LoggerFactory = this.loggerFactory, + }; // Deserialize the configuration directly from the host.json settings. // Note that not all settings can be applied from JSON. string configJson = JsonConvert.SerializeObject(this.extensionOptions.StorageProvider); JsonConvert.PopulateObject(configJson, options); - string connectionStringName = attribute.ConnectionName ?? options.ConnectionStringName; - string? connectionString = this.connectionStringResolver.Resolve(connectionStringName); - if (string.IsNullOrEmpty(connectionString)) + // Attribute properties can override host.json settings. + if (!string.IsNullOrEmpty(attribute.ConnectionName)) { - throw new InvalidOperationException( - $"No SQL connection string configuration was found for the app setting or environment variable named '{connectionStringName}'."); + options.ConnectionStringName = attribute.ConnectionName; } - // Validate the connection string - try - { - new SqlConnectionStringBuilder(connectionString); - } - catch (ArgumentException e) + if (!string.IsNullOrEmpty(attribute.TaskHub)) { - throw new ArgumentException("The provided connection string is invalid.", e); + options.TaskHubName = attribute.TaskHub; } - SqlProviderOptions providerOptions = options.ProviderOptions; - providerOptions.ConnectionString = connectionString; - providerOptions.LoggerFactory = this.loggerFactory; - providerOptions.WorkItemLockTimeout = options.TaskEventLockTimeout; - providerOptions.WorkItemBatchSize = options.TaskEventBatchSize; return options; } } diff --git a/src/DurableTask.SqlServer/Scripts/drop-schema.sql b/src/DurableTask.SqlServer/Scripts/drop-schema.sql index 93894cd..9cf15d3 100644 --- a/src/DurableTask.SqlServer/Scripts/drop-schema.sql +++ b/src/DurableTask.SqlServer/Scripts/drop-schema.sql @@ -13,6 +13,7 @@ DROP PROCEDURE IF EXISTS dt.CreateInstance DROP PROCEDURE IF EXISTS dt.GetInstanceHistory DROP PROCEDURE IF EXISTS dt.QuerySingleOrchestration DROP PROCEDURE IF EXISTS dt.RaiseEvent +DROP PROCEDURE IF EXISTS dt.SetGlobalSetting DROP PROCEDURE IF EXISTS dt.TerminateInstance DROP PROCEDURE IF EXISTS dt.PurgeInstanceState @@ -34,6 +35,7 @@ DROP TABLE IF EXISTS dt.NewEvents DROP TABLE IF EXISTS dt.History DROP TABLE IF EXISTS dt.Instances DROP TABLE IF EXISTS dt.Payloads +DROP TABLE IF EXISTS dt.GlobalSettings -- Custom types DROP TYPE IF EXISTS dt.MessageIDs diff --git a/src/DurableTask.SqlServer/Scripts/logic.sql b/src/DurableTask.SqlServer/Scripts/logic.sql index f0bcca4..966de14 100644 --- a/src/DurableTask.SqlServer/Scripts/logic.sql +++ b/src/DurableTask.SqlServer/Scripts/logic.sql @@ -6,16 +6,26 @@ CREATE OR ALTER FUNCTION dt.CurrentTaskHub() WITH EXECUTE AS CALLER AS BEGIN - -- Implemented as a function to make it easier to customize - DECLARE @TaskHub varchar(50) - IF LEN(CURRENT_USER) <= 50 - -- default behavior is to just use the username - SET @TaskHub = CONVERT(varchar(50), CURRENT_USER) - ELSE - -- if the username is too long, keep the first 16 characters and hash the rest - SET @TaskHub = CONVERT(varchar(16), CURRENT_USER) + '__' + CONVERT(varchar(32), HashBytes('MD5', CURRENT_USER), 2) + -- Task Hub modes: + -- 0: Task hub names are set by the app + -- 1: Task hub names are inferred from the user credential + DECLARE @taskHubMode sql_variant = (SELECT TOP 1 [Value] FROM dt.GlobalSettings WHERE [Name] = 'TaskHubMode'); + + DECLARE @taskHub varchar(150) + + IF @taskHubMode = 0 + SET @taskHub = APP_NAME() + IF @taskHubMode = 1 + SET @taskHub = USER_NAME() + + IF @taskHub IS NULL + SET @taskHub = 'default' + + -- if the name is too long, keep the first 16 characters and hash the rest + IF LEN(@taskHub) > 50 + SET @taskHub = CONVERT(varchar(16), @taskHub) + '__' + CONVERT(varchar(32), HASHBYTES('MD5', @taskHub), 2) - RETURN @TaskHub + RETURN @taskHub END GO @@ -369,6 +379,31 @@ END GO +CREATE OR ALTER PROCEDURE dt.SetGlobalSetting + @Name varchar(300), + @Value sql_variant +AS +BEGIN + BEGIN TRANSACTION + + UPDATE dt.GlobalSettings WITH (UPDLOCK, HOLDLOCK) + SET + [Value] = @Value, + [Timestamp] = SYSUTCDATETIME(), + [LastModifiedBy] = USER_NAME() + WHERE + [Name] = @Name + + IF @@ROWCOUNT = 0 + BEGIN + INSERT INTO dt.GlobalSettings ([Name], [Value]) VALUES (@Name, @Value) + END + + COMMIT TRANSACTION +END +GO + + CREATE OR ALTER PROCEDURE dt._LockNextOrchestration @BatchSize int, @LockedBy varchar(100), diff --git a/src/DurableTask.SqlServer/Scripts/permissions.sql b/src/DurableTask.SqlServer/Scripts/permissions.sql new file mode 100644 index 0000000..d92ac38 --- /dev/null +++ b/src/DurableTask.SqlServer/Scripts/permissions.sql @@ -0,0 +1,41 @@ +-- Copyright (c) .NET Foundation. All rights reserved. +-- Licensed under the MIT License. See LICENSE in the project root for license information. + +-- Security +IF DATABASE_PRINCIPAL_ID('dt_runtime') IS NULL +BEGIN + -- This is the role to which all low-privilege user accounts should be associated using + -- the 'ALTER ROLE dt_runtime ADD MEMBER []' statement. + CREATE ROLE dt_runtime +END + +-- Each stored procedure that is granted to dt_runtime must limits access to data based +-- on the task hub since that is. that no +-- database user can access data created by another database user. + +-- Public sprocs +GRANT EXECUTE ON OBJECT::dt.CreateInstance TO dt_runtime +GRANT EXECUTE ON OBJECT::dt.GetInstanceHistory TO dt_runtime +GRANT EXECUTE ON OBJECT::dt.QuerySingleOrchestration TO dt_runtime +GRANT EXECUTE ON OBJECT::dt.RaiseEvent TO dt_runtime +GRANT EXECUTE ON OBJECT::dt.TerminateInstance TO dt_runtime +GRANT EXECUTE ON OBJECT::dt.PurgeInstanceState TO dt_runtime + +-- Internal sprocs +GRANT EXECUTE ON OBJECT::dt._AddOrchestrationEvents TO dt_runtime +GRANT EXECUTE ON OBJECT::dt._CheckpointOrchestration TO dt_runtime +GRANT EXECUTE ON OBJECT::dt._CompleteTasks TO dt_runtime +GRANT EXECUTE ON OBJECT::dt._GetVersions TO dt_runtime +GRANT EXECUTE ON OBJECT::dt._LockNextOrchestration TO dt_runtime +GRANT EXECUTE ON OBJECT::dt._LockNextTask TO dt_runtime +GRANT EXECUTE ON OBJECT::dt._RenewOrchestrationLocks TO dt_runtime +GRANT EXECUTE ON OBJECT::dt._RenewTaskLocks TO dt_runtime +GRANT EXECUTE ON OBJECT::dt._UpdateVersion TO dt_runtime + +-- Types +GRANT EXECUTE ON TYPE::dt.HistoryEvents TO dt_runtime +GRANT EXECUTE ON TYPE::dt.MessageIDs TO dt_runtime +GRANT EXECUTE ON TYPE::dt.OrchestrationEvents TO dt_runtime +GRANT EXECUTE ON TYPE::dt.TaskEvents TO dt_runtime + +GO \ No newline at end of file diff --git a/src/DurableTask.SqlServer/Scripts/schema-0.2.0.sql b/src/DurableTask.SqlServer/Scripts/schema-0.2.0.sql index 64fd1d9..87fd5df 100644 --- a/src/DurableTask.SqlServer/Scripts/schema-0.2.0.sql +++ b/src/DurableTask.SqlServer/Scripts/schema-0.2.0.sql @@ -195,18 +195,21 @@ IF OBJECT_ID(N'dt.NewTasks', 'U') IS NULL CONSTRAINT FK_NewTasks_Instances FOREIGN KEY (TaskHub, InstanceID) REFERENCES dt.Instances(TaskHub, InstanceID) ON DELETE CASCADE, CONSTRAINT FK_NewTasks_Payloads FOREIGN KEY (TaskHub, InstanceID, PayloadID) REFERENCES dt.Payloads(TaskHub, InstanceID, PayloadID) ) + + -- This index is used by vScaleHints + CREATE NONCLUSTERED INDEX IX_NewTasks_InstanceID ON dt.NewTasks(TaskHub, InstanceID) + INCLUDE ([SequenceNumber], [Timestamp], [LockExpiration], [VisibleTime]) GO --- Security -IF DATABASE_PRINCIPAL_ID('dt_runtime') IS NULL +IF OBJECT_ID(N'dt.GlobalSettings', 'U') IS NULL BEGIN - -- This is the role to which all low-privilege user accounts should be associated using - -- the 'ALTER ROLE dt_runtime ADD MEMBER []' statement. - CREATE ROLE dt_runtime - - -- This low-privilege role will only have access to stored procedures in the dt schema. - -- Each stored procedure limits access to data based on the username, ensuring that no - -- database user can access data created by another database user. - GRANT EXECUTE ON SCHEMA::dt TO dt_runtime + CREATE TABLE dt.GlobalSettings ( + [Name] varchar(300) NOT NULL PRIMARY KEY, + [Value] sql_variant NULL, + [Timestamp] datetime2 NOT NULL CONSTRAINT DF_GlobalSettings_Timestamp DEFAULT SYSUTCDATETIME(), + [LastModifiedBy] nvarchar(128) NOT NULL CONSTRAINT DF_GlobalSettings_LastModifiedby DEFAULT USER_NAME() + ) + + INSERT INTO dt.GlobalSettings ([Name], [Value]) VALUES ('TaskHubMode', 0) END -GO \ No newline at end of file +GO diff --git a/src/DurableTask.SqlServer/SqlDbManager.cs b/src/DurableTask.SqlServer/SqlDbManager.cs index b10a3ee..0f19cf2 100644 --- a/src/DurableTask.SqlServer/SqlDbManager.cs +++ b/src/DurableTask.SqlServer/SqlDbManager.cs @@ -19,12 +19,12 @@ namespace DurableTask.SqlServer class SqlDbManager { - readonly SqlProviderOptions options; + readonly SqlOrchestrationServiceSettings settings; readonly LogHelper traceHelper; - public SqlDbManager(SqlProviderOptions options, LogHelper traceHelper) + public SqlDbManager(SqlOrchestrationServiceSettings settings, LogHelper traceHelper) { - this.options = options ?? throw new ArgumentNullException(nameof(options)); + this.settings = settings ?? throw new ArgumentNullException(nameof(settings)); this.traceHelper = traceHelper ?? throw new ArgumentNullException(nameof(traceHelper)); } @@ -103,6 +103,9 @@ public async Task CreateOrUpgradeSchemaAsync(bool recreateIfExists) // Add or update stored procedures, functions, and views await this.ExecuteSqlScriptAsync("logic.sql", dbLock); + // Configure security roles, permissions, etc. + await this.ExecuteSqlScriptAsync("permissions.sql", dbLock); + // Insert the current extension version number into the database and commit the transaction. // The extension version is used instead of the schema version to more accurately track whether // we need to update the sprocs or views. @@ -130,7 +133,7 @@ public async Task DeleteSchemaAsync() async Task AcquireDatabaseLockAsync() { - SqlConnection connection = this.options.CreateConnection(); + SqlConnection connection = this.settings.CreateConnection(); await connection.OpenAsync(); // It's possible that more than one worker may attempt to execute this creation logic at the same @@ -184,7 +187,7 @@ async Task ExecuteSqlScriptAsync(string scriptName, DatabaseLock dbLock) string schemaCommands = await GetScriptTextAsync(scriptName); // Reference: https://stackoverflow.com/questions/650098/how-to-execute-an-sql-script-file-using-c-sharp - using SqlConnection scriptRunnerConnection = this.options.CreateConnection(); + using SqlConnection scriptRunnerConnection = this.settings.CreateConnection(); var serverConnection = new ServerConnection(scriptRunnerConnection); Stopwatch latencyStopwatch = Stopwatch.StartNew(); diff --git a/src/DurableTask.SqlServer/SqlOrchestrationService.cs b/src/DurableTask.SqlServer/SqlOrchestrationService.cs index d1ad10b..03fda84 100644 --- a/src/DurableTask.SqlServer/SqlOrchestrationService.cs +++ b/src/DurableTask.SqlServer/SqlOrchestrationService.cs @@ -30,45 +30,45 @@ public class SqlOrchestrationService : OrchestrationServiceBase minimumInterval: TimeSpan.FromMilliseconds(50), maximumInterval: TimeSpan.FromSeconds(3)); // TODO: Configurable - readonly SqlProviderOptions options; + readonly SqlOrchestrationServiceSettings settings; readonly LogHelper traceHelper; readonly SqlDbManager dbManager; readonly string lockedByValue; - public SqlOrchestrationService(SqlProviderOptions? options) + public SqlOrchestrationService(SqlOrchestrationServiceSettings? settings) { - this.options = ValidateOptions(options) ?? throw new ArgumentNullException(nameof(options)); - this.traceHelper = new LogHelper(this.options.LoggerFactory.CreateLogger("DurableTask.SqlServer")); - this.dbManager = new SqlDbManager(this.options, this.traceHelper); - this.lockedByValue = $"{this.options.AppName}|{Process.GetCurrentProcess().Id}"; + this.settings = ValidateSettings(settings) ?? throw new ArgumentNullException(nameof(settings)); + this.traceHelper = new LogHelper(this.settings.LoggerFactory.CreateLogger("DurableTask.SqlServer")); + this.dbManager = new SqlDbManager(this.settings, this.traceHelper); + this.lockedByValue = $"{this.settings.AppName}|{Process.GetCurrentProcess().Id}"; } - static SqlProviderOptions? ValidateOptions(SqlProviderOptions? options) + static SqlOrchestrationServiceSettings? ValidateSettings(SqlOrchestrationServiceSettings? settings) { - if (options != null) + if (settings != null) { - if (string.IsNullOrEmpty(options.ConnectionString)) + if (string.IsNullOrEmpty(settings.TaskHubConnectionString)) { - throw new ArgumentException(nameof(options), $"A value for {options.ConnectionString} must be provided."); + throw new ArgumentException($"A non-empty connection string value must be provided.", nameof(settings)); } - if (options.WorkItemLockTimeout < TimeSpan.FromSeconds(10)) + if (settings.WorkItemLockTimeout < TimeSpan.FromSeconds(10)) { - throw new ArgumentException(nameof(options), $"The {options.WorkItemLockTimeout} property value must be at least 10 seconds."); + throw new ArgumentException($"The {nameof(settings.WorkItemLockTimeout)} property value must be at least 10 seconds.", nameof(settings)); } - if (options.WorkItemBatchSize < 10) + if (settings.WorkItemBatchSize < 10) { - throw new ArgumentException(nameof(options), $"The {options.WorkItemBatchSize} property value must be at least 10."); + throw new ArgumentException($"The {nameof(settings.WorkItemBatchSize)} property value must be at least 10.", nameof(settings)); } } - return options; + return settings; } async Task GetAndOpenConnectionAsync(CancellationToken cancelToken = default) { - SqlConnection connection = this.options.CreateConnection(); + SqlConnection connection = this.settings.CreateConnection(); await connection.OpenAsync(cancelToken); return connection; } @@ -107,8 +107,8 @@ public override Task DeleteAsync(bool deleteInstanceStore) using SqlConnection connection = await this.GetAndOpenConnectionAsync(cancellationToken); using SqlCommand command = this.GetSprocCommand(connection, "dt._LockNextOrchestration"); - int batchSize = this.options.WorkItemBatchSize; - DateTime lockExpiration = DateTime.UtcNow.Add(this.options.WorkItemLockTimeout); + int batchSize = this.settings.WorkItemBatchSize; + DateTime lockExpiration = DateTime.UtcNow.Add(this.settings.WorkItemLockTimeout); command.Parameters.Add("@BatchSize", SqlDbType.Int).Value = batchSize; command.Parameters.Add("@LockedBy", SqlDbType.VarChar, 100).Value = this.lockedByValue; @@ -222,7 +222,7 @@ public override async Task RenewTaskOrchestrationWorkItemLockAsync(TaskOrchestra using SqlConnection connection = await this.GetAndOpenConnectionAsync(); using SqlCommand command = this.GetSprocCommand(connection, "dt._RenewOrchestrationLocks"); - DateTime lockExpiration = DateTime.UtcNow.Add(this.options.WorkItemLockTimeout); + DateTime lockExpiration = DateTime.UtcNow.Add(this.settings.WorkItemLockTimeout); command.Parameters.Add("@InstanceID", SqlDbType.VarChar, size: 100).Value = workItem.InstanceId; command.Parameters.Add("@LockExpiration", SqlDbType.DateTime2).Value = lockExpiration; @@ -308,7 +308,7 @@ public override async Task CompleteTaskOrchestrationWorkItemAsync( using SqlConnection connection = await this.GetAndOpenConnectionAsync(); using SqlCommand command = this.GetSprocCommand(connection, "dt._LockNextTask"); - DateTime lockExpiration = DateTime.UtcNow.Add(this.options.WorkItemLockTimeout); + DateTime lockExpiration = DateTime.UtcNow.Add(this.settings.WorkItemLockTimeout); command.Parameters.Add("@LockedBy", SqlDbType.VarChar, size: 100).Value = this.lockedByValue; command.Parameters.Add("@LockExpiration", SqlDbType.DateTime2).Value = lockExpiration; @@ -348,7 +348,7 @@ public override async Task RenewTaskActivityWorkItemLockAs using SqlConnection connection = await this.GetAndOpenConnectionAsync(); using SqlCommand command = this.GetSprocCommand(connection, "dt._RenewTaskLocks"); - DateTime lockExpiration = DateTime.UtcNow.Add(this.options.WorkItemLockTimeout); + DateTime lockExpiration = DateTime.UtcNow.Add(this.settings.WorkItemLockTimeout); command.Parameters.AddMessageIdParameter("@RenewingTasks", workItem.TaskMessage); command.Parameters.Add("@LockExpiration", SqlDbType.DateTime2).Value = lockExpiration; diff --git a/src/DurableTask.SqlServer/SqlOrchestrationServiceSettings.cs b/src/DurableTask.SqlServer/SqlOrchestrationServiceSettings.cs new file mode 100644 index 0000000..b7c2d08 --- /dev/null +++ b/src/DurableTask.SqlServer/SqlOrchestrationServiceSettings.cs @@ -0,0 +1,82 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +namespace DurableTask.SqlServer +{ + using System; + using Microsoft.Data.SqlClient; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Logging.Abstractions; + using Newtonsoft.Json; + + /// + /// Configuration settings for the . + /// + public class SqlOrchestrationServiceSettings + { + /// + /// Initializes a new instance of the class. + /// + /// The connection string for connecting to the database. + /// Optional. The name of the task hub. If not specified, a default name will be used. + public SqlOrchestrationServiceSettings(string connectionString, string? taskHubName = null) + { + if (string.IsNullOrEmpty(connectionString)) + { + throw new ArgumentNullException(nameof(connectionString)); + } + + this.TaskHubName = taskHubName ?? "default"; + + var builder = new SqlConnectionStringBuilder(connectionString) + { + // We use the task hub name as the application name so that + // stored procedures have easy access to this information. + ApplicationName = this.TaskHubName, + }; + + this.TaskHubConnectionString = builder.ToString(); + } + + /// + /// Gets or sets the number of events that can be dequeued at a time. + /// + [JsonProperty("workItemBatchSize")] + public int WorkItemBatchSize { get; set; } = 10; + + /// + /// Gets or sets the amount of time a work item is locked after being dequeued. + /// + [JsonProperty("workItemLockTimeout")] + public TimeSpan WorkItemLockTimeout { get; set; } = TimeSpan.FromMinutes(2); + + /// + /// Gets or sets the name of the task hub. + /// + [JsonProperty("taskHubName")] + public string TaskHubName { get; } + + /// + /// Gets or sets the name of the app. Used for logging purposes. + /// + [JsonProperty("appName")] + public string AppName { get; set; } = Environment.MachineName; + + /// + /// Gets a SQL connection string associated with the configured task hub. + /// + [JsonIgnore] + public string TaskHubConnectionString { get; } + + /// + /// Gets a used for writing logs to the DurableTask.SqlServer trace source. + /// + [JsonIgnore] + public ILoggerFactory LoggerFactory { get; set; } = NullLoggerFactory.Instance; + + internal SqlConnection CreateConnection() + { + return new SqlConnection(this.TaskHubConnectionString); + } + } +} diff --git a/src/DurableTask.SqlServer/SqlProviderOptions.cs b/src/DurableTask.SqlServer/SqlProviderOptions.cs deleted file mode 100644 index 32bad84..0000000 --- a/src/DurableTask.SqlServer/SqlProviderOptions.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT License. See LICENSE in the project root for license information. - -namespace DurableTask.SqlServer -{ - using System; - using Microsoft.Data.SqlClient; - using Microsoft.Extensions.Logging; - using Microsoft.Extensions.Logging.Abstractions; - using Newtonsoft.Json; - - public class SqlProviderOptions - { - [JsonProperty("workItemBatchSize")] - public int WorkItemBatchSize { get; set; } = 10; - - [JsonProperty("workItemLockTimeout")] - public TimeSpan WorkItemLockTimeout { get; set; } = TimeSpan.FromMinutes(2); - - [JsonProperty("appName")] - public string AppName { get; set; } = Environment.MachineName; - - // Not serializeable (security sensitive) - must be initializd in code - public string ConnectionString { get; set; } = GetDefaultConnectionString(); - - // Not serializeable (complex object) - must be initialized in code - public ILoggerFactory LoggerFactory { get; set; } = NullLoggerFactory.Instance; - - internal SqlConnection CreateConnection() => new SqlConnection(this.ConnectionString); - - internal static string GetDefaultConnectionString() - { - // The default for local development on a Windows OS - string defaultConnectionString = "Server=localhost;Database=DurableDB;Trusted_Connection=True;"; - - // The use of SA_PASSWORD is intended for use with the mssql docker container - string saPassword = Environment.GetEnvironmentVariable("SA_PASSWORD"); - if (string.IsNullOrEmpty(saPassword)) - { - return defaultConnectionString; - } - - var builder = new SqlConnectionStringBuilder(defaultConnectionString) - { - IntegratedSecurity = false, - UserID = "sa", - Password = saPassword, - }; - - return builder.ToString(); - } - } -} diff --git a/src/common.props b/src/common.props index cc64b2d..7d40175 100644 --- a/src/common.props +++ b/src/common.props @@ -16,7 +16,7 @@ 0 - $(MajorVersion).5.0 + $(MajorVersion).6.0 alpha $(MajorVersion).0.0.0 .$(GITHUB_RUN_NUMBER) diff --git a/test/DurableTask.SqlServer.AzureFunctions.Tests/IntegrationTestBase.cs b/test/DurableTask.SqlServer.AzureFunctions.Tests/IntegrationTestBase.cs index 8e9f72d..eb9be44 100644 --- a/test/DurableTask.SqlServer.AzureFunctions.Tests/IntegrationTestBase.cs +++ b/test/DurableTask.SqlServer.AzureFunctions.Tests/IntegrationTestBase.cs @@ -9,6 +9,7 @@ namespace DurableTask.SqlServer.AzureFunctions.Tests using System.Linq; using System.Threading.Tasks; using DurableTask.SqlServer.Tests.Logging; + using DurableTask.SqlServer.Tests.Utils; using Microsoft.Azure.WebJobs; using Microsoft.Azure.WebJobs.Extensions.DurableTask; using Microsoft.Extensions.DependencyInjection; @@ -31,10 +32,7 @@ public IntegrationTestBase(ITestOutputHelper output) this.typeLocator = new TestFunctionTypeLocator(); this.settingsResolver = new TestSettingsResolver(); - // SqlServerProviderOptions resolves the default connection string from - // environment variables, or defaults to localhost. - var defaultOptions = new SqlProviderOptions(); - this.settingsResolver.AddSetting("SQLDB_Connection", defaultOptions.ConnectionString); + this.settingsResolver.AddSetting("SQLDB_Connection", SharedTestHelpers.GetDefaultConnectionString()); this.functionsHost = new HostBuilder() .ConfigureLogging( diff --git a/test/DurableTask.SqlServer.Tests/Integration/DatabaseManagement.cs b/test/DurableTask.SqlServer.Tests/Integration/DatabaseManagement.cs index 5679222..c03b07e 100644 --- a/test/DurableTask.SqlServer.Tests/Integration/DatabaseManagement.cs +++ b/test/DurableTask.SqlServer.Tests/Integration/DatabaseManagement.cs @@ -11,6 +11,7 @@ namespace DurableTask.SqlServer.Tests.Integration using System.Threading.Tasks; using DurableTask.Core; using DurableTask.SqlServer.Tests.Logging; + using DurableTask.SqlServer.Tests.Utils; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; using Microsoft.SqlServer.Management.Common; @@ -42,6 +43,7 @@ public void CanEnumerateEmbeddedSqlScripts() "drop-schema.sql", "schema-0.2.0.sql", "logic.sql", + "permissions.sql", }; // The actual prefix value may change if the project structure changes. @@ -83,6 +85,7 @@ public async Task CanCreateAndDropSchema() LogAssert.ExecutedSqlScript("drop-schema.sql"), LogAssert.ExecutedSqlScript("schema-0.2.0.sql"), LogAssert.ExecutedSqlScript("logic.sql"), + LogAssert.ExecutedSqlScript("permissions.sql"), LogAssert.SprocCompleted("dt._UpdateVersion")); ValidateDatabaseSchema(testDb); @@ -134,6 +137,7 @@ public async Task CanCreateIfNotExists() LogAssert.SprocCompleted("dt._GetVersions"), LogAssert.ExecutedSqlScript("schema-0.2.0.sql"), LogAssert.ExecutedSqlScript("logic.sql"), + LogAssert.ExecutedSqlScript("permissions.sql"), LogAssert.SprocCompleted("dt._UpdateVersion")); ValidateDatabaseSchema(testDb); @@ -166,6 +170,7 @@ public void SchemaCreationIsSerializedAndIdempotent() LogAssert.SprocCompleted("dt._GetVersions"), LogAssert.ExecutedSqlScript("schema-0.2.0.sql"), LogAssert.ExecutedSqlScript("logic.sql"), + LogAssert.ExecutedSqlScript("permissions.sql"), LogAssert.SprocCompleted("dt._UpdateVersion"), // 2nd LogAssert.AcquiredAppLock(statusCode: 1), @@ -187,9 +192,8 @@ TestDatabase CreateTestDb() IOrchestrationService CreateServiceWithTestDb(TestDatabase testDb) { - var options = new SqlProviderOptions + var options = new SqlOrchestrationServiceSettings(testDb.ConnectionString) { - ConnectionString = testDb.ConnectionString, LoggerFactory = LoggerFactory.Create(builder => { builder.SetMinimumLevel(LogLevel.Trace); @@ -206,6 +210,7 @@ static void ValidateDatabaseSchema(TestDatabase database) { "dt.NewEvents", "dt.NewTasks", + "dt.GlobalSettings", "dt.History", "dt.Instances", "dt.Payloads", @@ -218,6 +223,7 @@ static void ValidateDatabaseSchema(TestDatabase database) "dt.GetInstanceHistory", "dt.QuerySingleOrchestration", "dt.RaiseEvent", + "dt.SetGlobalSetting", "dt.TerminateInstance", "dt.PurgeInstanceState", "dt._AddOrchestrationEvents", @@ -278,11 +284,10 @@ public TestDatabase(ITestOutputHelper output) { string databaseName = $"TestDB_{DateTime.UtcNow:yyyyMMddhhmmssfffffff}"; - this.server = new Server(new ServerConnection(new SqlProviderOptions().CreateConnection())); + this.server = new Server(new ServerConnection(new SqlConnection(SharedTestHelpers.GetDefaultConnectionString()))); this.testDb = new Database(this.server, databaseName) { - // For SQL Server 2019, "Latin1_General_100_BIN2_UTF8" is preferred - Collation = "Latin1_General_100_BIN2", + Collation = "Latin1_General_100_BIN2_UTF8", }; this.ConnectionString = diff --git a/test/DurableTask.SqlServer.Tests/Integration/Orchestrations.cs b/test/DurableTask.SqlServer.Tests/Integration/Orchestrations.cs index ada0e8c..a3759ed 100644 --- a/test/DurableTask.SqlServer.Tests/Integration/Orchestrations.cs +++ b/test/DurableTask.SqlServer.Tests/Integration/Orchestrations.cs @@ -254,7 +254,7 @@ public async Task ActivityException() OrchestrationState state = await instance.WaitForCompletion( expectedStatus: OrchestrationStatus.Failed, - expectedOutputRegex: ".*(Kah-BOOOOOM!!!).*"); // TODO: Test for error message in output + expectedOutputRegex: ".*(Kah-BOOOOOM!!!).*"); } [Fact] diff --git a/test/DurableTask.SqlServer.Tests/Utils/SharedTestHelpers.cs b/test/DurableTask.SqlServer.Tests/Utils/SharedTestHelpers.cs new file mode 100644 index 0000000..a5e21af --- /dev/null +++ b/test/DurableTask.SqlServer.Tests/Utils/SharedTestHelpers.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +namespace DurableTask.SqlServer.Tests.Utils +{ + using System; + using Microsoft.Data.SqlClient; + + public static class SharedTestHelpers + { + public static string GetDefaultConnectionString() + { + // The default for local development on a Windows OS + string defaultConnectionString = "Server=localhost;Database=DurableDB;Trusted_Connection=True;"; + + // The use of SA_PASSWORD is intended for use with the mssql docker container + string saPassword = Environment.GetEnvironmentVariable("SA_PASSWORD"); + if (string.IsNullOrEmpty(saPassword)) + { + return defaultConnectionString; + } + + var builder = new SqlConnectionStringBuilder(defaultConnectionString) + { + IntegratedSecurity = false, + UserID = "sa", + Password = saPassword, + }; + + return builder.ToString(); + } + } +} diff --git a/test/DurableTask.SqlServer.Tests/Utils/TestService.cs b/test/DurableTask.SqlServer.Tests/Utils/TestService.cs index e92dba0..aee9ddd 100644 --- a/test/DurableTask.SqlServer.Tests/Utils/TestService.cs +++ b/test/DurableTask.SqlServer.Tests/Utils/TestService.cs @@ -40,13 +40,14 @@ public TestService(ITestOutputHelper output) }); this.testName = test.TestCase.TestMethod.Method.Name; - this.OrchestrationServiceOptions = new SqlProviderOptions + this.OrchestrationServiceOptions = new SqlOrchestrationServiceSettings( + SharedTestHelpers.GetDefaultConnectionString()) { LoggerFactory = this.loggerFactory, }; } - public SqlProviderOptions OrchestrationServiceOptions { get; } + public SqlOrchestrationServiceSettings OrchestrationServiceOptions { get; private set; } public Mock OrchestrationServiceMock { get; private set; } @@ -57,9 +58,15 @@ public async Task InitializeAsync() // The initialization requires administrative credentials (default) await new SqlOrchestrationService(this.OrchestrationServiceOptions).CreateIfNotExistsAsync(); + // Enable multitenancy to isolate each test using low-privilege credentials + await this.EnableMultitenancyAsync(); + // The runtime will use low-privilege credentials string taskHubConnectionString = await this.CreateTaskHubLoginAsync(); - this.OrchestrationServiceOptions.ConnectionString = taskHubConnectionString; + this.OrchestrationServiceOptions = new SqlOrchestrationServiceSettings(taskHubConnectionString) + { + LoggerFactory = this.loggerFactory, + }; this.OrchestrationServiceMock = new Mock(this.OrchestrationServiceOptions) { CallBase = true }; this.worker = await new TaskHubWorker(this.OrchestrationServiceMock.Object, this.loggerFactory).StartAsync(); @@ -192,6 +199,11 @@ public IEnumerable GetAndValidateLogs() } } + internal Task EnableMultitenancyAsync() + { + return ExecuteCommandAsync($"EXECUTE dt.SetGlobalSetting @Name='TaskHubMode', @Value=1"); + } + internal async Task CreateTaskHubLoginAsync() { // NOTE: Max length for user IDs is 128 characters @@ -203,7 +215,7 @@ internal async Task CreateTaskHubLoginAsync() await ExecuteCommandAsync($"CREATE USER [testuser_{userId}] FOR LOGIN [testlogin_{userId}]"); await ExecuteCommandAsync($"ALTER ROLE dt_runtime ADD MEMBER [testuser_{userId}]"); - var existing = new SqlConnectionStringBuilder(this.OrchestrationServiceOptions.ConnectionString); + var existing = new SqlConnectionStringBuilder(this.OrchestrationServiceOptions.TaskHubConnectionString); var builder = new SqlConnectionStringBuilder() { UserID = $"testlogin_{userId}", @@ -234,7 +246,7 @@ static async Task ExecuteCommandAsync(string commandText) { try { - string connectionString = SqlProviderOptions.GetDefaultConnectionString(); + string connectionString = SharedTestHelpers.GetDefaultConnectionString(); await using SqlConnection connection = new SqlConnection(connectionString); await using SqlCommand command = connection.CreateCommand(); await command.Connection.OpenAsync();