Skip to content

Commit

Permalink
Added simple configuration for platform configuration as well as tena…
Browse files Browse the repository at this point in the history
…ncy configuration. #4
  • Loading branch information
jezzsantos committed Oct 19, 2023
1 parent 39162b5 commit 0dadaac
Show file tree
Hide file tree
Showing 28 changed files with 1,088 additions and 33 deletions.
46 changes: 46 additions & 0 deletions docs/design-principles/0040-configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Configuration

## Design Principles

1. We want all components in each host to have access to static configuration (read-only), that would be set at deployment time.
2. We want that configuration to be written into packaged assets just before deployment, in the CD pipeline.
3. We want that configuration to be specific to a specific environment (e.g. Local development, Staging, Holding or Production)
4. We do not want developers writing anything but local development environment settings (secrets or otherwise) into configuration files. With one exception: the configuration used to configure components for integration testing against real 3rd party systems (i.e. in tests of the category: `Integration.External`). These 3rd party accounts/environments, should never be related to production environments, and are designed only for testing-only. Configuration (and especially any secrets) used for these accounts/environments can NEVER lead those with access to them to compromise the system or its integrity.
5. We will need some configuration for the SaaS "platform" (all shared components), and some configuration for each "tenant" running on the platform. These two sets of configuration must be kept separate for each other, but may not be stored in the same repositories. (e.g. platform configuration is defined in appsettings.json, whilst tenancy configuration is stored in a database)
6. Configuration needs to be hierarchical (e.g. namespaced), and hierarchical in terms of layering.
7. Settings are expected to be of only 3 types: `string`, `number` and `boolean`
8. Components are responsible for reading their own configuration, and shall not re-use other components configuration.
9. Secrets may be stored separately from non-confidential configuration in other repositories (e.g. files, databases, 3rd party services).
10. We want to be able to change storage location of configuration at any time, without breaking code (e.g. files, databases, 3rd party services).
11. We want to use dependency injection to give components their configuration.

## Implementation

The `IConfigurationSettings` abstraction is used to give access to configuration for both "Platform" settings and "Tenancy" settings.

### Platform Settings

Platform settings are setting that are shared across all components running in the platform.

For example:

* Connection strings to centralized repositories (for hosting data pertaining to all tenants on the platform)
* Account details for accessing shared 3rd party system accounts via adapters (e.g. an email provider)
* Keys and defaults for various application and domain services

Most of these settings will be stored in standard places that are supported by the .NET runtime, such as `appsettings.json` files for the specific environment.

### Tenancy Settings

Tenancy settings are setting that are specific to a tenant running on the platform.

For example:

* Connection strings to a tenant's physically partitioned repository (e.g. in a nearby datacenter of their choice)
* Account details for accessing a specific 3rd party system account via adapters (e.g. an accounting integration)

At runtime, in a multi-tenanted host, when the inbound HTTP request is destined for an API that is tenanted, the `ITenantContext` will define the tenancy and settings for the current HTTP request.

These settings are generally read from a dynamic repository (e.g. a database, or 3rd party service), and they are unique to the specific tenant.

> Never to be accidentally accessed by or exposed to other tenants running on the platform
3 changes: 1 addition & 2 deletions src/ApiHost1/Program.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
using ApiHost1;
using Infrastructure.Common.Recording;
using Infrastructure.WebApi.Common;
using JetBrains.Annotations;

var modules = HostedModules.Get();

var app = WebApplication.CreateBuilder(args)
.ConfigureApiHost(modules, RecorderOptions.BackEndApiHost);
.ConfigureApiHost(modules, WebHostOptions.BackEndApiHost);
app.Run();

namespace ApiHost1
Expand Down
7 changes: 6 additions & 1 deletion src/ApiHost1/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,10 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"DomainServices": {
"TenantSettingService": {
"AesSecret": "V7z5SZnhHRa7z68adsvazQjeIbSiWWcR+4KuAUikhe0=::u4ErEVotb170bM8qKWyT8A=="
}
}
}
1 change: 1 addition & 0 deletions src/ApiHost1/tenantsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
8 changes: 8 additions & 0 deletions src/Application.Interfaces/Resources/Tenants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Application.Interfaces.Resources;

public class TenantSetting
{
public bool IsEncrypted { get; set; }

public string? Value { get; set; }
}
11 changes: 11 additions & 0 deletions src/Application.Interfaces/Services/ITenantSettingsService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Application.Interfaces.Resources;

namespace Application.Interfaces.Services;

/// <summary>
/// Defines an application service for working with tenant-specific settings
/// </summary>
public interface ITenantSettingsService
{
IReadOnlyDictionary<string, TenantSetting> CreateForNewTenant(ICallerContext context, string tenantId);
}
11 changes: 11 additions & 0 deletions src/Common/Configuration/IConfigurationSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace Common.Configuration;

/// <summary>
/// Configuration settings for the platform, and the current tenancy
/// </summary>
public interface IConfigurationSettings
{
ISettings Platform { get; }

ISettings Tenancy { get; }
}
15 changes: 15 additions & 0 deletions src/Common/Configuration/ISettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Common.Configuration;

/// <summary>
/// Defines a provider of simple settings
/// </summary>
public interface ISettings
{
public bool IsConfigured { get; }

public bool GetBool(string key);

public double GetNumber(string key);

public string GetString(string key);
}
9 changes: 9 additions & 0 deletions src/Domain.Interfaces/Services/ITenantSettingService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Domain.Interfaces.Services;

/// <summary>
/// Defines a domain service for reading tenant settings
/// </summary>
public interface ITenantSettingService
{
string Decrypt(string encryptedValue);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#if TESTINGONLY
using FluentAssertions;
using Infrastructure.Common.DomainServices;
using Xunit;

namespace Infrastructure.Common.UnitTests.DomainServices;

[Trait("Category", "Unit")]
public class AesEncryptionServiceSpec
{
private readonly AesEncryptionService _service;

public AesEncryptionServiceSpec()
{
var secret = AesEncryptionService.CreateAesSecret();

_service = new AesEncryptionService(secret);
}

[Fact]
public void WhenDecryptAndEncrypted_ThenReturnsPlainText()
{
var cipherText = _service.Encrypt("avalue");
var plainText = _service.Decrypt(cipherText);

cipherText.Should().NotBe("avalue");
plainText.Should().NotBe(cipherText);
plainText.Should().Be("avalue");
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Application.Interfaces\Application.Interfaces.csproj" />
<ProjectReference Include="..\Infrastructure.Common\Infrastructure.Common.csproj" />
<ProjectReference Include="..\UnitTesting.Common\UnitTesting.Common.csproj" />
</ItemGroup>

</Project>
90 changes: 90 additions & 0 deletions src/Infrastructure.Common/DomainServices/AesEncryptionService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using System.Security.Cryptography;

namespace Infrastructure.Common.DomainServices;

/// <summary>
/// Provides a domain service for encrypting values, using AES encryption
/// </summary>
public class AesEncryptionService
{
private const string SecretKeyDelimiter = "::";
private readonly string _aesSecret;

public AesEncryptionService(string aesSecret)
{
_aesSecret = aesSecret;
}

public string Decrypt(string cipherText)
{
using var aes = CreateAes();

Check failure on line 20 in src/Infrastructure.Common/DomainServices/AesEncryptionService.cs

View workflow job for this annotation

GitHub Actions / build-test

The name 'CreateAes' does not exist in the current context

Check failure on line 20 in src/Infrastructure.Common/DomainServices/AesEncryptionService.cs

View workflow job for this annotation

GitHub Actions / build-test

The name 'CreateAes' does not exist in the current context
var (cryptKey, iv) = GetAesKeysFromSecret(_aesSecret);
using var decryptor = aes.CreateDecryptor(cryptKey, iv);

var cipher = Convert.FromBase64String(cipherText);
using var ms = new MemoryStream(cipher);
using var cryptoStream = new CryptoStream(ms, decryptor, CryptoStreamMode.Read);
using var reader = new StreamReader(cryptoStream);
return reader.ReadToEnd();
}

public string Encrypt(string plainText)
{
using var aes = CreateAes();

Check failure on line 33 in src/Infrastructure.Common/DomainServices/AesEncryptionService.cs

View workflow job for this annotation

GitHub Actions / build-test

The name 'CreateAes' does not exist in the current context

Check failure on line 33 in src/Infrastructure.Common/DomainServices/AesEncryptionService.cs

View workflow job for this annotation

GitHub Actions / build-test

The name 'CreateAes' does not exist in the current context
var (cryptKey, iv) = GetAesKeysFromSecret(_aesSecret);
using var encryptor = aes.CreateEncryptor(cryptKey, iv);

byte[] cipher;
using (var ms = new MemoryStream())
{
using (var cryptoStream = new CryptoStream(ms, encryptor, CryptoStreamMode.Write))
{
using (var writer = new StreamWriter(cryptoStream))
{
writer.Write(plainText);
}

cipher = ms.ToArray();
}
}

return Convert.ToBase64String(cipher);
}

private static (byte[] key, byte[] iv) GetAesKeysFromSecret(string aesSecret)
{
var rightSide = aesSecret.Substring(0, aesSecret.IndexOf(SecretKeyDelimiter, StringComparison.Ordinal));
var leftSide = aesSecret.Substring(aesSecret.IndexOf(SecretKeyDelimiter, StringComparison.Ordinal)
+ SecretKeyDelimiter.Length);
var cryptKey = Convert.FromBase64String(rightSide);
var iv = Convert.FromBase64String(leftSide);

return (cryptKey, iv);
}

#if TESTINGONLY

public static string CreateAesSecret()
{
CreateKeyAndIv(out var cryptKey, out var iv);
return $"{Convert.ToBase64String(cryptKey)}{SecretKeyDelimiter}{Convert.ToBase64String(iv)}";
}

private static void CreateKeyAndIv(out byte[] cryptKey, out byte[] iv)
{
using var aes = CreateAes();
cryptKey = aes.Key;
iv = aes.IV;
}

private static SymmetricAlgorithm CreateAes()
{
var aes = Aes.Create();
aes.KeySize = 256;
aes.BlockSize = 128;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
return aes;
}
#endif
}
23 changes: 23 additions & 0 deletions src/Infrastructure.Common/DomainServices/TenantSettingService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Domain.Interfaces.Services;

namespace Infrastructure.Common.DomainServices;

/// <summary>
/// Provides a domain service for handling settings for a tenant
/// </summary>
public class TenantSettingService : ITenantSettingService
{
public const string EncryptionServiceSecretSettingName = "DomainServices:TenantSettingService:AesSecret";

private readonly AesEncryptionService _encryptionService;

public TenantSettingService(AesEncryptionService encryptionService)
{
_encryptionService = encryptionService;
}

public string Decrypt(string encryptedValue)
{
return _encryptionService.Decrypt(encryptedValue);
}
}
19 changes: 19 additions & 0 deletions src/Infrastructure.Common/SimpleTenancyContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Infrastructure.Interfaces;

namespace Infrastructure.Common;

/// <summary>
/// Defines a simple tenancy context that can be set
/// </summary>
public class SimpleTenancyContext : ITenancyContext
{
public string? Current { get; private set; }

public IReadOnlyDictionary<string, object> Settings { get; private set; } = new Dictionary<string, object>();

public void Set(string id, Dictionary<string, object> settings)
{
Current = id;
Settings = settings;
}
}
13 changes: 13 additions & 0 deletions src/Infrastructure.Interfaces/ITenancyContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Infrastructure.Interfaces;

/// <summary>
/// Defines the context of a tenancy operating on the platform
/// </summary>
public interface ITenancyContext
{
string? Current { get; }

public IReadOnlyDictionary<string, object> Settings { get; }

void Set(string id, Dictionary<string, object> settings);
}
Loading

0 comments on commit 0dadaac

Please sign in to comment.