From 5e2195e58d5f2565f5e6a57628817418f1e9443c Mon Sep 17 00:00:00 2001 From: richard-churchman Date: Sun, 22 Mar 2026 14:33:49 +0100 Subject: [PATCH] Added secrets tokenisation with Docker Secrets compatibility: * Resolve secrets from file topology where file name is key and trimmed file contents are value, compatible with Docker Secrets tmpfs mounting. * Support [@Key@] token pattern inside Environment Variable strings, allowing verbose connection strings to live outside declared secrets. * SecretsPath configurable via environment variable or appSettings, falling back to working directory for local development convenience. * Secrets processing occurs at startup before logging, with diagnostics written to STDOUT. * Tokenisation extracted into standalone library and included in Dockerfile * Documentation added covering token pattern and Docker Secrets compatibility. --- Jube.App/Dockerfile | 1 + Jube.Case/Jube.Case.csproj | 1 + Jube.DynamicEnvironment/DynamicEnvironment.cs | 55 ++++++++++++++++++- .../Jube.DynamicEnvironment.csproj | 1 + ...ysisModelInstanceEntryPayloadExtensions.cs | 2 +- Jube.Tokenisation/Jube.Tokenisation.csproj | 9 +++ .../Tokenisation.cs | 2 +- Jube.sln | 6 ++ docs/Concepts/EnvironmentVariables/index.md | 16 +++++- 9 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 Jube.Tokenisation/Jube.Tokenisation.csproj rename {Jube.Case => Jube.Tokenisation}/Tokenisation.cs (98%) diff --git a/Jube.App/Dockerfile b/Jube.App/Dockerfile index c18ff2c..0c6d762 100644 --- a/Jube.App/Dockerfile +++ b/Jube.App/Dockerfile @@ -30,6 +30,7 @@ COPY ["Jube.TaskCancellation/Jube.TaskCancellation.csproj", "Jube.TaskCancellati COPY ["Jube.Tests/Jube.Tests.csproj", "Jube.Tests/"] COPY ["Jube.ResilientNpgsql/Jube.ResilientNpgsql.csproj", "Jube.ResilientNpgsql/"] COPY ["Jube.ResilientRedisConnection/Jube.ResilientRedisConnection.csproj", "Jube.ResilientRedisConnection/"] +COPY ["Jube.Tokenisation/Jube.Tokenisation.csproj", "Jube.Tokenisation/"] COPY ["Jube.CLI/Jube.CLI.csproj", "Jube.CLI/"] RUN dotnet restore "Jube.App/Jube.App.csproj" diff --git a/Jube.Case/Jube.Case.csproj b/Jube.Case/Jube.Case.csproj index ab4c993..f844b5a 100644 --- a/Jube.Case/Jube.Case.csproj +++ b/Jube.Case/Jube.Case.csproj @@ -8,6 +8,7 @@ + diff --git a/Jube.DynamicEnvironment/DynamicEnvironment.cs b/Jube.DynamicEnvironment/DynamicEnvironment.cs index f5fde7d..2deea35 100644 --- a/Jube.DynamicEnvironment/DynamicEnvironment.cs +++ b/Jube.DynamicEnvironment/DynamicEnvironment.cs @@ -26,6 +26,7 @@ namespace Jube.DynamicEnvironment using log4net.Core; using log4net.Layout; using log4net.Repository.Hierarchy; + using Tokenisation; public class DynamicEnvironment { @@ -310,9 +311,22 @@ namespace Jube.DynamicEnvironment }, { "RedisBackplane", "False" + }, + { + "SecretsPath", "" } }; + var secretsPath = Environment.GetEnvironmentVariable("SecretsPath") ?? String.Empty; + if (String.IsNullOrEmpty(secretsPath)) + { + secretsPath = appSettings["SecretsPath"] ?? String.Empty; + + Console.WriteLine(String.IsNullOrEmpty(secretsPath) ? + "Environment Variable SecretsPath not available. Will resolve to working directory." : + $"Environment Variable SecretsPath not available. Set to default prefix of {secretsPath}."); + } + foreach (var environmentVariable in from DictionaryEntry environmentVariable in Environment.GetEnvironmentVariables() where appSettings.ContainsKey(Convert.ToString(environmentVariable.Key) ?? String.Empty) @@ -320,8 +334,45 @@ namespace Jube.DynamicEnvironment appSettings[environmentVariable.Key.ToString() ?? String.Empty] select environmentVariable) { - appSettings[environmentVariable.Key.ToString() ?? String.Empty] = - Convert.ToString(environmentVariable.Value); + if (environmentVariable.Value == null) + { + continue; + } + + var replaced = Convert.ToString(environmentVariable.Value); + if (replaced != null) + { + foreach (var token in Tokenisation.ReturnTokens(replaced)) + { + var path = Path.Combine(secretsPath, token); + if (!File.Exists(path)) + { + Console.WriteLine($@"For environment variable {environmentVariable.Key} could not find {path} in container."); + continue; + } + + string secret; + try + { + secret = File.ReadAllText(path).Trim(); + } + catch (IOException ex) + { + Console.WriteLine($@"For environment variable {environmentVariable.Key} could not read {path}: {ex.Message}"); + continue; + } + + if (String.IsNullOrEmpty(secret)) + { + Console.WriteLine($@"For environment variable {environmentVariable.Key} path {path} in container but it is empty."); + continue; + } + + replaced = replaced.Replace($"[@{token}@]", secret); + } + + appSettings[environmentVariable.Key.ToString() ?? String.Empty] = replaced; + } } InstantiateLog4Net(); diff --git a/Jube.DynamicEnvironment/Jube.DynamicEnvironment.csproj b/Jube.DynamicEnvironment/Jube.DynamicEnvironment.csproj index 717b4d4..337779f 100644 --- a/Jube.DynamicEnvironment/Jube.DynamicEnvironment.csproj +++ b/Jube.DynamicEnvironment/Jube.DynamicEnvironment.csproj @@ -20,6 +20,7 @@ + \ No newline at end of file diff --git a/Jube.Engine/EntityAnalysisModelInvoke/Models/Payload/EntityAnalysisModelInstanceEntryPayload/Extensions/EntityAnalysisModelInstanceEntryPayloadExtensions.cs b/Jube.Engine/EntityAnalysisModelInvoke/Models/Payload/EntityAnalysisModelInstanceEntryPayload/Extensions/EntityAnalysisModelInstanceEntryPayloadExtensions.cs index 8d53379..ce024b3 100644 --- a/Jube.Engine/EntityAnalysisModelInvoke/Models/Payload/EntityAnalysisModelInstanceEntryPayload/Extensions/EntityAnalysisModelInstanceEntryPayloadExtensions.cs +++ b/Jube.Engine/EntityAnalysisModelInvoke/Models/Payload/EntityAnalysisModelInstanceEntryPayload/Extensions/EntityAnalysisModelInstanceEntryPayloadExtensions.cs @@ -16,7 +16,7 @@ namespace Jube.Engine.EntityAnalysisModelInvoke.Models.Payload.EntityAnalysisMod using System; using System.Collections.Generic; using System.Globalization; - using Case; + using Tokenisation; public static class EntityAnalysisModelInstanceEntryPayloadExtensions { diff --git a/Jube.Tokenisation/Jube.Tokenisation.csproj b/Jube.Tokenisation/Jube.Tokenisation.csproj new file mode 100644 index 0000000..17b910f --- /dev/null +++ b/Jube.Tokenisation/Jube.Tokenisation.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/Jube.Case/Tokenisation.cs b/Jube.Tokenisation/Tokenisation.cs similarity index 98% rename from Jube.Case/Tokenisation.cs rename to Jube.Tokenisation/Tokenisation.cs index dfadb48..8a3a3bf 100644 --- a/Jube.Case/Tokenisation.cs +++ b/Jube.Tokenisation/Tokenisation.cs @@ -11,7 +11,7 @@ * see . */ -namespace Jube.Case +namespace Jube.Tokenisation { using System.Text.RegularExpressions; diff --git a/Jube.sln b/Jube.sln index 05eaeda..cd39598 100644 --- a/Jube.sln +++ b/Jube.sln @@ -61,6 +61,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jube.ResilientNpgsql", "Jub EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jube.ResilientRedisConnection", "Jube.ResilientRedisConnection\Jube.ResilientRedisConnection.csproj", "{BB76862D-4122-42D6-9A47-34A32A5DDBC9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jube.Tokenisation", "Jube.Tokenisation\Jube.Tokenisation.csproj", "{D69B24BA-7DE7-42D4-8FBB-67CB0A179B8B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -183,6 +185,10 @@ Global {BB76862D-4122-42D6-9A47-34A32A5DDBC9}.Debug|Any CPU.Build.0 = Debug|Any CPU {BB76862D-4122-42D6-9A47-34A32A5DDBC9}.Release|Any CPU.ActiveCfg = Release|Any CPU {BB76862D-4122-42D6-9A47-34A32A5DDBC9}.Release|Any CPU.Build.0 = Release|Any CPU + {D69B24BA-7DE7-42D4-8FBB-67CB0A179B8B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D69B24BA-7DE7-42D4-8FBB-67CB0A179B8B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D69B24BA-7DE7-42D4-8FBB-67CB0A179B8B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D69B24BA-7DE7-42D4-8FBB-67CB0A179B8B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution EndGlobalSection diff --git a/docs/Concepts/EnvironmentVariables/index.md b/docs/Concepts/EnvironmentVariables/index.md index e044f47..2f1d6aa 100644 --- a/docs/Concepts/EnvironmentVariables/index.md +++ b/docs/Concepts/EnvironmentVariables/index.md @@ -105,7 +105,19 @@ following hardcoded Environment Variables, this would be to say defaults, are av | MaxInvokeControllerRequestBytes | 4000 | The maximum number of bytes that can be JSON parsed for the invocation pipeline, beyond which an exception will be thrown. | | PartialResponseMessageSerialisation | True | When true, only return elements in the response payload on the basis of ResponsePayload switch being true in varius model entities, otherwise return the same comprehensive payload as stored in the Archive table. Disabling PartialResponseMessageSerialisation is useful when developing configurations where testing needs total visibility of invocation context payload. | | UseRedisBackplane | True | In a load balanced environment the preference is not to use server affinity for the user interface, but this cases breakage in the Activation Watcher page. In the absence of server affinity Redis can be used as a backplane such that the connection does not need to be stateful. | +| SecretsPath | null | The file system path to look up secrets, where the file name is the key and the trimmed contents of the file is the value. This has special compatibility with Docker Secrets. If not provided, defaults to the application's working directory. Secrets are only tokenised where the [@Key@] pattern exists inside the Environment Variable string. | Environment Variables passed to Jube at startup will override the hardcoded defaults above. Environment Variables are -not written to disk in any form, and their contents are available only in the Jube applications memory and operating -system session. \ No newline at end of file +not written to disk in any form, and their contents are available only in the Jube application's memory and operating +system session. + +The Environment Variable string can be tokenised, and secrets can be swapped in from a file topology at a given path, +where the file name is the key and the contents of the file is the value. This approach provides full compatibility with +Docker Secrets, which mounts secrets as files using tmpfs (memory-mounted file system). For example, given the +token [@RedisPassword@], a file called RedisPassword and the contents of that file being localhost, the Environment +Variable may be passed as RedisConnectionString=[@RedisPassword@]. At startup the token is detected, extracted and +replaced with the contents of the file RedisPassword mounted by Docker Secrets. Tokens that resolve are replaced in +place; those that do not are left unchanged and an error is written to STDOUT. This approach ensures that only +genuinely secret information is stored in Docker Secrets, with more verbose Environment Variable strings being safe to +live outside declared secrets, allowing for clearer intent and greater maintainability. Secrets are processed at startup +and before logging, with any errors written to STDOUT rather than the logging library. \ No newline at end of file