diff --git a/Directory.Packages.props b/Directory.Packages.props
index 018b8f69..b0a07e09 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -57,15 +57,15 @@
-
-
+
+
-
-
-
+
+
+
-
-
+
+
@@ -81,6 +81,7 @@
+
diff --git a/NuGet.config b/NuGet.config
index 15231a89..2099dd14 100644
--- a/NuGet.config
+++ b/NuGet.config
@@ -1,7 +1,8 @@
-
+
+
diff --git a/src/SecretManager/Microsoft.DncEng.SecretManager.ScenarioTests/ScenarioTestsBase.cs b/src/SecretManager/Microsoft.DncEng.SecretManager.ScenarioTests/ScenarioTestsBase.cs
index 6eee445d..4781284d 100644
--- a/src/SecretManager/Microsoft.DncEng.SecretManager.ScenarioTests/ScenarioTestsBase.cs
+++ b/src/SecretManager/Microsoft.DncEng.SecretManager.ScenarioTests/ScenarioTestsBase.cs
@@ -44,6 +44,11 @@ public ScenarioTestsBase()
protected async Task ExecuteSynchronizeCommand(string manifest)
{
ServiceCollection services = new ServiceCollection();
+ // Dependency injection instruction needed to support properties used for Geneval Logging operations
+ services.AddSingleton(new GlobalCommand());
+ services.AddSingleton(new SecurityAuditLogger(Guid.Empty));
+
+ // Original dependency injection instructions
services.AddSingleton();
Program program = new Program();
diff --git a/src/SecretManager/Microsoft.DncEng.SecretManager.ScenarioTests/WrappedTokenProvider.cs b/src/SecretManager/Microsoft.DncEng.SecretManager.ScenarioTests/WrappedTokenProvider.cs
index 8b901934..cb60a8ef 100644
--- a/src/SecretManager/Microsoft.DncEng.SecretManager.ScenarioTests/WrappedTokenProvider.cs
+++ b/src/SecretManager/Microsoft.DncEng.SecretManager.ScenarioTests/WrappedTokenProvider.cs
@@ -1,3 +1,6 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
using Azure.Core;
@@ -7,9 +10,29 @@ public sealed class WrappedTokenProvider : ITokenCredentialProvider
{
private readonly TokenCredential _tokenCredential;
+ ///
+ public string ApplicationId { get; internal set; }
+ ///
+ public string TenantId { get; internal set; }
+
public WrappedTokenProvider(TokenCredential tokenCredential)
{
_tokenCredential = tokenCredential;
+ SetCredentialIdentityValues();
+ }
+
+ ///
+ internal void SetCredentialIdentityValues()
+ {
+ // Get a token from the crendential provider
+ var tokenRequestContext = new TokenRequestContext(new[] { "https://management.azure.com/.default" });
+ var token = _tokenCredential.GetToken(tokenRequestContext, CancellationToken.None);
+
+ // Decode the JWT to get user identity information
+ var handler = new JwtSecurityTokenHandler();
+ var jsonToken = handler.ReadToken(token.Token) as JwtSecurityToken;
+ ApplicationId = jsonToken?.Claims?.FirstOrDefault(claim => claim.Type == "oid")?.Value ?? "Claim Oid Not Found";
+ TenantId = jsonToken?.Claims?.FirstOrDefault(claim => claim.Type == "tenant_id")?.Value ?? "Claim tenant_id Not Found";
}
public Task GetCredentialAsync() => Task.FromResult(_tokenCredential);
diff --git a/src/SecretManager/Microsoft.DncEng.SecretManager.Tests/SynchronizeCommandTests.cs b/src/SecretManager/Microsoft.DncEng.SecretManager.Tests/SynchronizeCommandTests.cs
index fca77bd9..f5600984 100644
--- a/src/SecretManager/Microsoft.DncEng.SecretManager.Tests/SynchronizeCommandTests.cs
+++ b/src/SecretManager/Microsoft.DncEng.SecretManager.Tests/SynchronizeCommandTests.cs
@@ -1,8 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
-using System.Diagnostics.CodeAnalysis;
-using System.Globalization;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
@@ -26,6 +24,11 @@ private async Task TestCommand(DateTimeOffset now, string manifestText, string l
var cancellationToken = cts.Token;
var services = new ServiceCollection();
+ // Dependency injection instruction needed to support properties used for Geneval Logging operations
+ services.AddSingleton(new GlobalCommand());
+ services.AddSingleton(new SecurityAuditLogger(Guid.Empty));
+
+ // Original dependency injection instructions
services.AddSingleton(Mock.Of());
var storageLocationTypeRegistry = new Mock(MockBehavior.Strict);
diff --git a/src/SecretManager/Microsoft.DncEng.SecretManager/InfoCommand.cs b/src/SecretManager/Microsoft.DncEng.SecretManager/Commands/InfoCommand.cs
similarity index 69%
rename from src/SecretManager/Microsoft.DncEng.SecretManager/InfoCommand.cs
rename to src/SecretManager/Microsoft.DncEng.SecretManager/Commands/InfoCommand.cs
index e70452f0..f9d88a8c 100644
--- a/src/SecretManager/Microsoft.DncEng.SecretManager/InfoCommand.cs
+++ b/src/SecretManager/Microsoft.DncEng.SecretManager/Commands/InfoCommand.cs
@@ -3,21 +3,25 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.DncEng.CommandLineLib;
+using Microsoft.DncEng.SecretManager.Commands;
namespace Microsoft.DncEng.SecretManager;
[Command("info")]
-class InfoCommand : Command
+class InfoCommand : ProjectBaseCommand
{
private readonly IConsole _console;
- public InfoCommand(IConsole console)
+ public InfoCommand(GlobalCommand globalCommand, IConsole console): base(globalCommand)
{
_console = console;
}
public override Task RunAsync(CancellationToken cancellationToken)
{
+ // Provides a curtisy warning message if the ServiceTreeId option is set to a empty guid
+ ValidateServiceTreeIdOption();
+
var exeName = Process.GetCurrentProcess().ProcessName;
var version = Assembly.GetEntryAssembly()
?.GetCustomAttribute()
diff --git a/src/SecretManager/Microsoft.DncEng.SecretManager/Commands/ProjectBaseCommand.cs b/src/SecretManager/Microsoft.DncEng.SecretManager/Commands/ProjectBaseCommand.cs
new file mode 100644
index 00000000..ddfdf06b
--- /dev/null
+++ b/src/SecretManager/Microsoft.DncEng.SecretManager/Commands/ProjectBaseCommand.cs
@@ -0,0 +1,94 @@
+
+using System;
+using Microsoft.DncEng.CommandLineLib;
+using Mono.Options;
+
+namespace Microsoft.DncEng.SecretManager.Commands
+{
+ ///
+ /// This class is used to extend the CommandLineLib.GlobalCommand class to add a global options spacific to this project
+ ///
+ public class ProjectBaseCommand : GlobalCommand
+ {
+
+ ///
+ /// Indictes if the global option for 'quiet' is set
+ ///
+ public bool Quiet { get { return Verbosity == VerbosityLevel.Quiet; } }
+
+ ///
+ /// Check for local environment values to indicate you are running for Azure DevOps
+ /// SYSTEM_COLLECTIONURI is a default environment variable in Azure DevOps
+ ///
+ private bool RunningInAzureDevOps = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SYSTEM_COLLECTIONURI"));
+
+ ///
+ /// Provides the ServiceTreeId set with global options
+ /// The ID is a goid and is set to Guid.Empty if not set
+ ///
+ public Guid ServiceTreeId = Guid.Empty;
+
+ ///
+ /// Base constructor for the ProjectBaseCommand class
+ ///
+ public ProjectBaseCommand(GlobalCommand globalCommand)
+ {
+ Verbosity = globalCommand.Verbosity;
+ Help = globalCommand.Help;
+ }
+
+ ///
+ /// Overides the GetOptions method from the base class to add a cusotm option for the ServiceTreeId
+ ///
+ public override OptionSet GetOptions()
+ {
+ return new OptionSet()
+ {
+ {"servicetreeid=", "The service tree ID (must be a valid GUID id from aka.ms/servicetree)", id =>
+ {
+ if (Guid.TryParse(id, out var guid))
+ {
+ ServiceTreeId = guid;
+ }
+ // If running in Azure DevOps use VSO tagging in the console output to the warning message will be handled by the Azure DevOps build system
+ else if (RunningInAzureDevOps)
+ {
+ WriteWarningMessage($"##vso[task.logissue type=warning]Failed to parse a valid Guid value from ServiceTreeId value '{id}'! Security Audit logging will be suppressed!");
+ }
+ // Else write a general warning messgae to console
+ else
+ {
+ WriteWarningMessage($"Failed to parse a valid Guid value from ServiceTreeId value '{id}'! Security Audit logging will be suppressed!");
+ }
+ }
+ }
+ };
+ }
+
+ ///
+ /// Provides a non-volitie warning message if the ServiceTreeId option is set to a empty guid value and argments have been parsed
+ internal void ValidateServiceTreeIdOption()
+ {
+ if (!Quiet && ServiceTreeId == Guid.Empty)
+ {
+ // If running in Azure DevOps use VSO tagging in the console output to the warning message will be handled by the Azure DevOps build system
+ if (RunningInAzureDevOps)
+ {
+ WriteWarningMessage("##vso[task.logissue type=warning]ServiceTreeId is set to an Empty Guid! Security Audit logging will be suppressed!");
+ }
+ // Else write a general warning messgae to console
+ else
+ {
+ WriteWarningMessage("ServiceTreeId is set to an Empty Guid! Security Audit logging will be suppressed!");
+ }
+ }
+ }
+
+ internal void WriteWarningMessage(string message)
+ {
+ Console.ForegroundColor = ConsoleColor.Yellow;
+ Console.WriteLine(message);
+ Console.ResetColor();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/SecretManager/Microsoft.DncEng.SecretManager/Commands/SynchronizeCommand.cs b/src/SecretManager/Microsoft.DncEng.SecretManager/Commands/SynchronizeCommand.cs
index e7b822ba..b83bcfb5 100644
--- a/src/SecretManager/Microsoft.DncEng.SecretManager/Commands/SynchronizeCommand.cs
+++ b/src/SecretManager/Microsoft.DncEng.SecretManager/Commands/SynchronizeCommand.cs
@@ -7,13 +7,16 @@
using ConsoleTables;
using Microsoft.DncEng.CommandLineLib;
using Microsoft.DncEng.SecretManager.StorageTypes;
+using Microsoft.VisualStudio.Services.Common;
+
using Mono.Options;
+
using Command = Microsoft.DncEng.CommandLineLib.Command;
namespace Microsoft.DncEng.SecretManager.Commands;
[Command("synchronize")]
-public class SynchronizeCommand : Command
+public class SynchronizeCommand : ProjectBaseCommand
{
private readonly StorageLocationTypeRegistry _storageLocationTypeRegistry;
private readonly SecretTypeRegistry _secretTypeRegistry;
@@ -24,7 +27,7 @@ public class SynchronizeCommand : Command
private bool _verifyOnly = false;
private readonly List _forcedSecrets = new();
- public SynchronizeCommand(StorageLocationTypeRegistry storageLocationTypeRegistry, SecretTypeRegistry secretTypeRegistry, ISystemClock clock, IConsole console)
+ public SynchronizeCommand(GlobalCommand globalCommand, StorageLocationTypeRegistry storageLocationTypeRegistry, SecretTypeRegistry secretTypeRegistry, ISystemClock clock, IConsole console) : base(globalCommand)
{
_storageLocationTypeRegistry = storageLocationTypeRegistry;
_secretTypeRegistry = secretTypeRegistry;
@@ -42,19 +45,22 @@ public override List HandlePositionalArguments(List args)
public override OptionSet GetOptions()
{
- return new OptionSet
+ return base.GetOptions().AddRange(new OptionSet()
{
{"f|force", "Force rotate all secrets", f => _force = !string.IsNullOrEmpty(f)},
{"force-secret=", "Force rotate the specified secret", _forcedSecrets.Add},
{"skip-untracked", "Skip untracked secrets", f => _skipUntracked = !string.IsNullOrEmpty(f)},
{"verify-only", "Does not rotate any secrets, instead, produces an error for every secret that needs to be rotated.", v => _verifyOnly = !string.IsNullOrEmpty(v)},
- };
+ });
}
public override async Task RunAsync(CancellationToken cancellationToken)
{
try
{
+ // Provides a curtisy warning message if the ServiceTreeId option is set to a empty guid
+ ValidateServiceTreeIdOption();
+
Console.OutputEncoding = System.Text.Encoding.UTF8;
_console.WriteLine($"🔁 Synchronizing secrets contained in {_manifestFile}");
if (_force || _forcedSecrets.Any())
diff --git a/src/SecretManager/Microsoft.DncEng.SecretManager/TestCommand.cs b/src/SecretManager/Microsoft.DncEng.SecretManager/Commands/TestCommand.cs
similarity index 68%
rename from src/SecretManager/Microsoft.DncEng.SecretManager/TestCommand.cs
rename to src/SecretManager/Microsoft.DncEng.SecretManager/Commands/TestCommand.cs
index 59380ef9..9320076a 100644
--- a/src/SecretManager/Microsoft.DncEng.SecretManager/TestCommand.cs
+++ b/src/SecretManager/Microsoft.DncEng.SecretManager/Commands/TestCommand.cs
@@ -4,15 +4,15 @@
using Azure.Core;
using Microsoft.DncEng.CommandLineLib;
-namespace Microsoft.DncEng.SecretManager;
+namespace Microsoft.DncEng.SecretManager.Commands;
[Command("test")]
-class TestCommand : Command
+class TestCommand : ProjectBaseCommand
{
private readonly IConsole _console;
private readonly ITokenCredentialProvider _tokenProvider;
- public TestCommand(IConsole console, ITokenCredentialProvider tokenProvider)
+ public TestCommand(GlobalCommand globalCommand, IConsole console, ITokenCredentialProvider tokenProvider) : base(globalCommand)
{
_console = console;
_tokenProvider = tokenProvider;
@@ -20,6 +20,9 @@ public TestCommand(IConsole console, ITokenCredentialProvider tokenProvider)
public override async Task RunAsync(CancellationToken cancellationToken)
{
+ // Provides a curtisy warning message if the ServiceTreeId option is set to a empty guid
+ ValidateServiceTreeIdOption();
+
var creds = await _tokenProvider.GetCredentialAsync();
var token = await creds.GetTokenAsync(new TokenRequestContext(new[]
diff --git a/src/SecretManager/Microsoft.DncEng.SecretManager/Commands/ValidateAllCommand.cs b/src/SecretManager/Microsoft.DncEng.SecretManager/Commands/ValidateAllCommand.cs
index 79de1c98..4fe9e4e1 100644
--- a/src/SecretManager/Microsoft.DncEng.SecretManager/Commands/ValidateAllCommand.cs
+++ b/src/SecretManager/Microsoft.DncEng.SecretManager/Commands/ValidateAllCommand.cs
@@ -4,8 +4,10 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+
using Microsoft.DncEng.CommandLineLib;
using Microsoft.DncEng.SecretManager.StorageTypes;
+using Microsoft.VisualStudio.Services.Common;
using Mono.Options;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@@ -14,7 +16,7 @@
namespace Microsoft.DncEng.SecretManager.Commands;
[Command("validate-all")]
-public class ValidateAllCommand : Command
+public class ValidateAllCommand : ProjectBaseCommand
{
private readonly IConsole _console;
private readonly SettingsFileValidator _settingsFileValidator;
@@ -22,7 +24,7 @@ public class ValidateAllCommand : Command
private readonly List _manifestFiles = new List();
private string _basePath;
- public ValidateAllCommand(IConsole console, SettingsFileValidator settingsFileValidator, StorageLocationTypeRegistry storageLocationTypeRegistry)
+ public ValidateAllCommand(GlobalCommand globalCommand, IConsole console, SettingsFileValidator settingsFileValidator, StorageLocationTypeRegistry storageLocationTypeRegistry) : base(globalCommand)
{
_console = console;
_settingsFileValidator = settingsFileValidator;
@@ -31,11 +33,11 @@ public ValidateAllCommand(IConsole console, SettingsFileValidator settingsFileVa
public override OptionSet GetOptions()
{
- return new OptionSet
+ return base.GetOptions().AddRange(new OptionSet()
{
{"m|manifest-file=", "A secret manifest file. Can be specified more than once.", m => _manifestFiles.Add(m)},
{"b|base-path=", "The base path to search for settings files.", b => _basePath = b},
- };
+ });
}
public override bool AreRequiredOptionsSet()
@@ -45,6 +47,9 @@ public override bool AreRequiredOptionsSet()
public override async Task RunAsync(CancellationToken cancellationToken)
{
+ // Provides a curtisy warning message if the ServiceTreeId option is set to a empty guid
+ ValidateServiceTreeIdOption();
+
bool haveErrors = false;
var manifestFiles = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (string manifestFile in _manifestFiles)
diff --git a/src/SecretManager/Microsoft.DncEng.SecretManager/Commands/ValidateCommand.cs b/src/SecretManager/Microsoft.DncEng.SecretManager/Commands/ValidateCommand.cs
index 8a00f917..27b19fed 100644
--- a/src/SecretManager/Microsoft.DncEng.SecretManager/Commands/ValidateCommand.cs
+++ b/src/SecretManager/Microsoft.DncEng.SecretManager/Commands/ValidateCommand.cs
@@ -1,32 +1,33 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.DncEng.CommandLineLib;
+using Microsoft.VisualStudio.Services.Common;
using Mono.Options;
using Command = Microsoft.DncEng.CommandLineLib.Command;
namespace Microsoft.DncEng.SecretManager.Commands;
[Command("validate")]
-public class ValidateCommand : Command
+public class ValidateCommand : ProjectBaseCommand
{
private readonly SettingsFileValidator _settingsFileValidator;
private string _manifestFile;
private string _baseSettingsFile;
private string _envSettingsFile;
- public ValidateCommand(SettingsFileValidator settingsFileValidator)
+ public ValidateCommand(GlobalCommand globalCommand, SettingsFileValidator settingsFileValidator) : base(globalCommand)
{
_settingsFileValidator = settingsFileValidator;
}
public override OptionSet GetOptions()
{
- return new OptionSet
+ return base.GetOptions().AddRange(new OptionSet()
{
{"m|manifest-file=", "The secret manifest file", f => _manifestFile = f},
{"e|env-settings-file=", "The environment settings file to validate", f => _envSettingsFile = f},
{"b|base-settings-file=", "The base settings file to validate", f => _baseSettingsFile = f},
- };
+ });
}
public override bool AreRequiredOptionsSet()
@@ -38,6 +39,8 @@ public override bool AreRequiredOptionsSet()
public override async Task RunAsync(CancellationToken cancellationToken)
{
+ // Provides a curtisy warning message if the ServiceTreeId option is set to a empty guid
+ ValidateServiceTreeIdOption();
bool foundError = !await _settingsFileValidator.ValidateFileAsync(_envSettingsFile, _baseSettingsFile, _manifestFile, cancellationToken);
if (foundError)
diff --git a/src/SecretManager/Microsoft.DncEng.SecretManager/ITokenCredentialProvider.cs b/src/SecretManager/Microsoft.DncEng.SecretManager/ITokenCredentialProvider.cs
index 23925db5..366fbbb8 100644
--- a/src/SecretManager/Microsoft.DncEng.SecretManager/ITokenCredentialProvider.cs
+++ b/src/SecretManager/Microsoft.DncEng.SecretManager/ITokenCredentialProvider.cs
@@ -5,5 +5,15 @@ namespace Microsoft.DncEng.SecretManager;
public interface ITokenCredentialProvider
{
+ ///
+ /// The applicatoin ID for the credential provider.
+ ///
+ public string ApplicationId { get; }
+
+ ///
+ /// The tenant ID that provided the token from the credential provider.
+ ///
+ public string TenantId { get; }
+
public Task GetCredentialAsync();
}
diff --git a/src/SecretManager/Microsoft.DncEng.SecretManager/Microsoft.DncEng.SecretManager.csproj b/src/SecretManager/Microsoft.DncEng.SecretManager/Microsoft.DncEng.SecretManager.csproj
index fdb8dcc5..5a097030 100644
--- a/src/SecretManager/Microsoft.DncEng.SecretManager/Microsoft.DncEng.SecretManager.csproj
+++ b/src/SecretManager/Microsoft.DncEng.SecretManager/Microsoft.DncEng.SecretManager.csproj
@@ -38,6 +38,7 @@
+
diff --git a/src/SecretManager/Microsoft.DncEng.SecretManager/Program.cs b/src/SecretManager/Microsoft.DncEng.SecretManager/Program.cs
index f229f07e..d53d386f 100644
--- a/src/SecretManager/Microsoft.DncEng.SecretManager/Program.cs
+++ b/src/SecretManager/Microsoft.DncEng.SecretManager/Program.cs
@@ -4,8 +4,8 @@
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
using Microsoft.DncEng.CommandLineLib;
+using Microsoft.DncEng.SecretManager.Commands;
using Microsoft.DncEng.SecretManager.ServiceConnections;
-using Microsoft.Extensions.Azure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
@@ -13,13 +13,41 @@ namespace Microsoft.DncEng.SecretManager;
public class Program : DependencyInjectedConsoleApp
{
+ ///
+ /// Object stores global command setting as parsed from the command line at the main method
+ /// We mark this valud as protected so it can be accessed by processes that invoke the assembly outside of the command line
+ ///
+ protected static GlobalCommand _globalCommand = new GlobalCommand();
+
+
+ ///
+ /// The service tree id of calling service parsed from the command line at the main method
+ /// We mark this valeue as protected so it can be accessed by processes that invoke the assembly outside of the command line
+ ///
+ protected static Guid ServiceTreeId = Guid.Empty;
+
public static Task Main(string[] args)
{
+ // The GlobalCommand must be pre-parsed before passing to the ProjectBaseCommand object or the base global settings will be lost
+ var options = _globalCommand.GetOptions();
+ options.Parse(args);
+
+ // We then parse the ProjectBaseCommand to ensure we collect the service tree id at the start of the progress
+ // so it can be used for dependency ingjection
+ // The global option setings are passed to all other command objects that inhearit from the ProjectBaseCommand
+ var projectBaselCommand = new ProjectBaseCommand(_globalCommand);
+ options = projectBaselCommand.GetOptions();
+ options.Parse(args);
+ ServiceTreeId = projectBaselCommand?.ServiceTreeId ?? Guid.Empty;
+
return new Program().RunAsync(args);
}
protected override void ConfigureServices(IServiceCollection services)
{
+ // The injected service is needed to allow commands to consume global options set at the command line
+ services.AddSingleton(_globalCommand);
+ services.AddSingleton(new SecurityAuditLogger(ServiceTreeId));
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/src/SecretManager/Microsoft.DncEng.SecretManager/SecretManagerCredentialProvider.cs b/src/SecretManager/Microsoft.DncEng.SecretManager/SecretManagerCredentialProvider.cs
index 5ac0c90d..0b6d1466 100644
--- a/src/SecretManager/Microsoft.DncEng.SecretManager/SecretManagerCredentialProvider.cs
+++ b/src/SecretManager/Microsoft.DncEng.SecretManager/SecretManagerCredentialProvider.cs
@@ -1,4 +1,7 @@
using System;
+using System.IdentityModel.Tokens.Jwt;
+using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
using Azure.Core;
using Azure.Identity;
@@ -8,6 +11,16 @@ namespace Microsoft.DncEng.SecretManager;
public sealed class SecretManagerCredentialProvider : ITokenCredentialProvider
{
+ ///
+ public string ApplicationId { get; internal set; }
+ ///
+ public string TenantId { get; internal set; }
+
+ public SecretManagerCredentialProvider()
+ {
+ SetCredentialIdentityValues();
+ }
+
// Expect AzureCliCredential for CI and local dev environments.
// Use InteractiveBrowserCredential as a fallback for local dev environments.
private readonly Lazy _credential = new(() =>
@@ -15,9 +28,23 @@ public sealed class SecretManagerCredentialProvider : ITokenCredentialProvider
new AzureCliCredential(new AzureCliCredentialOptions { TenantId = ConfigurationConstants.MsftAdTenantId }),
new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions() { TenantId = ConfigurationConstants.MsftAdTenantId })
));
-
+
public Task GetCredentialAsync()
{
return Task.FromResult(_credential.Value);
}
+
+ ///
+ internal void SetCredentialIdentityValues()
+ {
+ // Get a token from the crendential provider
+ var tokenRequestContext = new TokenRequestContext(new[] { "https://management.azure.com/.default" });
+ var token = _credential.Value.GetToken(tokenRequestContext, CancellationToken.None);
+
+ // Decode the JWT to get user identity information
+ var handler = new JwtSecurityTokenHandler();
+ var jsonToken = handler.ReadToken(token.Token) as JwtSecurityToken;
+ ApplicationId = jsonToken?.Claims?.FirstOrDefault(claim => claim.Type == "appid")?.Value ?? "Claim appid (Application Id) Not Found";
+ TenantId = jsonToken?.Claims?.FirstOrDefault(claim => claim.Type == "tid")?.Value ?? "Claim tid (Tenant Id) Not Found";
+ }
}
diff --git a/src/SecretManager/Microsoft.DncEng.SecretManager/SecurityAuditLogger.cs b/src/SecretManager/Microsoft.DncEng.SecretManager/SecurityAuditLogger.cs
new file mode 100644
index 00000000..60b83ad1
--- /dev/null
+++ b/src/SecretManager/Microsoft.DncEng.SecretManager/SecurityAuditLogger.cs
@@ -0,0 +1,125 @@
+using System;
+using System.Linq;
+using System.Net;
+using System.Net.Sockets;
+using System.Runtime.CompilerServices;
+using Microsoft.Extensions.Logging;
+using OpenTelemetry.Audit.Geneva;
+
+namespace Microsoft.DncEng.SecretManager
+{
+ ///
+ /// SecurityAuditLogger is a class that is used to log security audit events to the security event log and Geneva
+ ///
+ public class SecurityAuditLogger
+ {
+ private ILogger ControlPanelLogger;
+
+ private bool SuppressAuditLogging = false;
+
+ ///
+ /// Base constructor for the SecurityAuditLogger
+ ///
+ public SecurityAuditLogger(Guid serviceTreeId)
+ {
+ var auditFactory = AuditLoggerFactory.Create(options =>
+ {
+ // We use ETW as the destination for the audit logs becsue the application is not gurenteed to run on windows
+ options.Destination = AuditLogDestination.ETW;
+ options.ServiceId = serviceTreeId;
+ // If the service ID is a empty guid we should suppress audit logging
+ if (serviceTreeId == Guid.Empty)
+ {
+ SuppressAuditLogging = true;
+ }
+ });
+
+ ControlPanelLogger = auditFactory.CreateControlPlaneLogger();
+ }
+
+ ///
+ /// Add an audit log for secret update operations perfomred on behalf of a user.
+ ///
+ public void LogSecretUpdate(ITokenCredentialProvider credentialProvider, string secretName, string secretStoreType, string secretLocation, OperationResult result = OperationResult.Success, string resultMessage = "", [CallerMemberName] string operationName = "")
+ {
+ try
+ {
+ LogSecretAction(OperationType.Update, operationName, credentialProvider, secretName, secretStoreType, secretLocation, result, resultMessage);
+ }
+ // Audit logging is a 'volitile' operation meaning it can throw exceptions if logging fails.
+ // This could lead to service instability caused by simple logging issues which is not desirable.
+ // So we catch all exceptions and write them to console as a last resort.
+ // The hope is that app insights will also catch the base exception for debugging.
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Failed to add audit log for secret update!: <{ex.Message}>");
+ }
+ }
+
+ internal void LogSecretAction(OperationType operationType, string operationName, ITokenCredentialProvider credentialProvider, string secretName, string secretStoreType, string secretLocation, OperationResult result, string resultMessage)
+ {
+ // Perform a no op if audit logging is suppressed
+ if (SuppressAuditLogging)
+ {
+ return;
+ }
+
+ // The token applicatoin id of the client running the assembly.
+ // NOTE: The user identity here should be something 'dynamic'.
+ // If you are hard coding this value you should question if this Audit Log is useful
+ // as it is likly redundant to lower level permission change logging that is already occuring.
+ var user = credentialProvider.ApplicationId;
+ // Get the tenant ID that provided the token for the credential provider.
+ var tenantId = credentialProvider.TenantId;
+ var auditRecord = new AuditRecord
+ {
+ OperationResultDescription = $"Action '{operationType}' For Secret '{secretName}' With Opeation '{operationName}' By User '{user}' On Source '{secretLocation}' Resulted In '{result}'.",
+ CallerAgent = GetType().Namespace,
+ OperationName = operationName,
+ OperationType = operationType,
+ OperationAccessLevel = GetOperationAccessLevel(operationType),
+ CallerIpAddress = GetLocalIPAddress(),
+ OperationResult = result
+ };
+
+ auditRecord.AddOperationCategory(OperationCategory.PasswordManagement);
+ auditRecord.AddCallerIdentity(CallerIdentityType.ApplicationID, user);
+ auditRecord.AddCallerIdentity(CallerIdentityType.TenantId, tenantId);
+ // This value is basically a hard coded 'guess'
+ // The access level is defiend by permission setting of the 'user'
+ // which are not static and not defined by the service
+ // So we are specifying what we belive the minmal acces level
+ // would be requried for this operation to be successful
+ auditRecord.AddCallerAccessLevel("Writer");
+ auditRecord.AddTargetResource(secretStoreType, secretLocation);
+ auditRecord.OperationResultDescription = (!string.IsNullOrWhiteSpace(resultMessage)) ? $"{resultMessage}" : $"'{operationName}' : '{result}'";
+
+ ControlPanelLogger.LogAudit(auditRecord);
+ }
+ internal static string GetLocalIPAddress()
+ {
+ var host = Dns.GetHostEntry(Dns.GetHostName());
+ var ipAddress = host.AddressList.FirstOrDefault(ip => ip.AddressFamily == AddressFamily.InterNetwork);
+ if (ipAddress == null)
+ {
+ throw new Exception("No network adapters with an IPv4 address in the system!");
+ }
+ return ipAddress.ToString();
+ }
+
+ internal string GetOperationAccessLevel(OperationType operation)
+ {
+ switch (operation)
+ {
+ case OperationType.Create:
+ return "Write";
+ case OperationType.Update:
+ return "Write";
+ case OperationType.Delete:
+ return "Write";
+ default:
+ return "Read";
+ }
+ }
+ }
+}
diff --git a/src/SecretManager/Microsoft.DncEng.SecretManager/StorageTypes/AzureKeyVault.cs b/src/SecretManager/Microsoft.DncEng.SecretManager/StorageTypes/AzureKeyVault.cs
index 8e793b3c..07849a71 100644
--- a/src/SecretManager/Microsoft.DncEng.SecretManager/StorageTypes/AzureKeyVault.cs
+++ b/src/SecretManager/Microsoft.DncEng.SecretManager/StorageTypes/AzureKeyVault.cs
@@ -1,13 +1,14 @@
-using Azure;
-using Azure.Security.KeyVault.Keys;
-using Azure.Security.KeyVault.Secrets;
-using JetBrains.Annotations;
-using Microsoft.DncEng.CommandLineLib;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
+using Azure;
+using Azure.Security.KeyVault.Keys;
+using Azure.Security.KeyVault.Secrets;
+using JetBrains.Annotations;
+using Microsoft.DncEng.CommandLineLib;
+using OpenTelemetry.Audit.Geneva;
namespace Microsoft.DncEng.SecretManager.StorageTypes;
@@ -23,11 +24,13 @@ public class AzureKeyVault : StorageLocationType
public const string NextRotationOnTag = "next-rotation-on";
private readonly ITokenCredentialProvider _tokenCredentialProvider;
private readonly IConsole _console;
+ private readonly SecurityAuditLogger _auditLogger;
- public AzureKeyVault(ITokenCredentialProvider tokenCredentialProvider, IConsole console)
+ public AzureKeyVault(ITokenCredentialProvider tokenCredentialProvider, IConsole console, SecurityAuditLogger auditLogger)
{
_tokenCredentialProvider = tokenCredentialProvider;
_console = console;
+ _auditLogger = auditLogger;
}
private async Task CreateSecretClient(AzureKeyVaultParameters parameters)
@@ -107,44 +110,106 @@ private static ImmutableDictionary GetTags(global::Azure.Securit
public override async Task SetSecretValueAsync(AzureKeyVaultParameters parameters, string name, SecretValue value)
{
- SecretClient client = await CreateSecretClient(parameters);
- var createdSecret = await client.SetSecretAsync(name, value.Value ?? "");
- var properties = createdSecret.Value.Properties;
- foreach (var (k, v) in value.Tags)
+ // The default audit state should alwasy be failure and overwritten with success at the end of the operation.
+ var operationAuditResult = OperationResult.Failure;
+ // Place holder for the operation result message which will cuase a default message to be logged on success.
+ // Custom messages only need to be defined on failure
+ var operationResultMessage = "";
+ try
{
- properties.Tags[k] = v;
+ SecretClient client = await CreateSecretClient(parameters);
+ var createdSecret = await client.SetSecretAsync(name, value.Value ?? "");
+ var properties = createdSecret.Value.Properties;
+ foreach (var (k, v) in value.Tags)
+ {
+ properties.Tags[k] = v;
+ }
+ properties.Tags[NextRotationOnTag] = value.NextRotationOn.ToString("O");
+ properties.Tags["ChangedBy"] = "secret-manager.exe";
+ // Tags to appease the old secret management system
+ properties.Tags["Owner"] = "secret-manager.exe";
+ properties.Tags["SecretType"] = "MANAGED";
+ properties.ExpiresOn = value.ExpiresOn;
+ await client.UpdateSecretPropertiesAsync(properties);
+ operationResultMessage = $"Secret '{name}' Updated...";
+ operationAuditResult = OperationResult.Success;
+ }
+ catch(Exception e)
+ {
+ operationResultMessage = e.Message;
+ throw;
+ }
+ finally
+ {
+ // Record an audit log for the secret update operation
+ _auditLogger.LogSecretUpdate(
+ credentialProvider: _tokenCredentialProvider,
+ secretName: name,
+ secretStoreType: "AzureKeyVault",
+ secretLocation: GetAzureKeyVaultUri(parameters),
+ result: operationAuditResult,
+ resultMessage: operationResultMessage
+ );
}
- properties.Tags[NextRotationOnTag] = value.NextRotationOn.ToString("O");
- properties.Tags["ChangedBy"] = "secret-manager.exe";
- // Tags to appease the old secret management system
- properties.Tags["Owner"] = "secret-manager.exe";
- properties.Tags["SecretType"] = "MANAGED";
- properties.ExpiresOn = value.ExpiresOn;
- await client.UpdateSecretPropertiesAsync(properties);
}
public override async Task EnsureKeyAsync(AzureKeyVaultParameters parameters, string name, SecretManifest.Key config)
{
- var client = await CreateKeyClient(parameters);
+ // The default audit state should alwasy be failure and overwritten with success at the end of the operation.
+ var operationAuditResult = OperationResult.Failure;
+ // Place holder for the operation result message which will cuase a default message to be logged on success.
+ // Custom messages only need to be defined on failure
+ var operationResultMessage = "";
+ // Tracks when ceay creation is required since we only want to write audit logs when a new key is created
+ var createKey = false;
try
{
- await client.GetKeyAsync(name);
- return; // key exists, so we are done.
+ var client = await CreateKeyClient(parameters);
+ try
+ {
+ await client.GetKeyAsync(name);
+ return; // key exists, so we are done.
+ }
+ catch (RequestFailedException ex) when (ex.Status == 404)
+ {
+ }
+
+ createKey = true;
+ switch (config.Type.ToLowerInvariant())
+ {
+ case "rsa":
+ await client.CreateKeyAsync(name, KeyType.Rsa, new CreateRsaKeyOptions(name)
+ {
+ KeySize = config.Size,
+ });
+ operationResultMessage = $"{config.Type} Key '{name}' Created...";
+ operationAuditResult = OperationResult.Success;
+ break;
+ default:
+ createKey = false;
+ throw new NotImplementedException(config.Type);
+ }
}
- catch (RequestFailedException ex) when (ex.Status == 404)
+ catch (Exception e)
{
+ operationResultMessage = e.Message;
+ throw;
}
-
- switch (config.Type.ToLowerInvariant())
+ finally
{
- case "rsa":
- await client.CreateKeyAsync(name, KeyType.Rsa, new CreateRsaKeyOptions(name)
- {
- KeySize = config.Size,
- });
- break;
- default:
- throw new NotImplementedException(config.Type);
+ // Only write an audit log if a new key is created
+ if (createKey)
+ {
+ // Record an audit log for the key creation operation
+ _auditLogger.LogSecretUpdate(
+ credentialProvider: _tokenCredentialProvider,
+ secretName: name,
+ secretStoreType: "AzureKeyVault",
+ secretLocation: GetAzureKeyVaultUri(parameters),
+ result: operationAuditResult,
+ resultMessage: operationResultMessage
+ );
+ }
}
}
}