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 + ); + } } } }