From 804049ec568ba1296415d705b7e51d6b3d4fe537 Mon Sep 17 00:00:00 2001 From: Timothy Small Date: Thu, 11 Jan 2024 12:10:49 -0500 Subject: [PATCH 01/18] Add Tools.CsvImporter --- .vscode/tasks.json | 4 ++++ EntraMfaPrefillinator.sln | 10 ++++++++++ src/Tools/CsvImporter/CsvImporter.csproj | 16 ++++++++++++++++ src/Tools/CsvImporter/Program.cs | 2 ++ 4 files changed, 32 insertions(+) create mode 100644 src/Tools/CsvImporter/CsvImporter.csproj create mode 100644 src/Tools/CsvImporter/Program.cs diff --git a/.vscode/tasks.json b/.vscode/tasks.json index abfa120..a5889f2 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -230,6 +230,10 @@ { "label": "FunctionApp", "value": "src/FunctionApp" + }, + { + "label": "Tools.CsvImporter", + "value": "src/Tools/CsvImporter" } ] }, diff --git a/EntraMfaPrefillinator.sln b/EntraMfaPrefillinator.sln index 85576ef..2715895 100644 --- a/EntraMfaPrefillinator.sln +++ b/EntraMfaPrefillinator.sln @@ -9,6 +9,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lib", "src\Lib\Lib.csproj", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FunctionApp", "src\FunctionApp\FunctionApp.csproj", "{6CAFB055-0347-439F-91AB-4F3BC7D37713}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{33DE40FB-A917-4A86-AA79-5BAB5CC8E66A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CsvImporter", "src\Tools\CsvImporter\CsvImporter.csproj", "{B5F8BD80-AEDB-4F1B-AEA9-E53E0DF95310}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -26,9 +30,15 @@ Global {6CAFB055-0347-439F-91AB-4F3BC7D37713}.Debug|Any CPU.Build.0 = Debug|Any CPU {6CAFB055-0347-439F-91AB-4F3BC7D37713}.Release|Any CPU.ActiveCfg = Release|Any CPU {6CAFB055-0347-439F-91AB-4F3BC7D37713}.Release|Any CPU.Build.0 = Release|Any CPU + {B5F8BD80-AEDB-4F1B-AEA9-E53E0DF95310}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B5F8BD80-AEDB-4F1B-AEA9-E53E0DF95310}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5F8BD80-AEDB-4F1B-AEA9-E53E0DF95310}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B5F8BD80-AEDB-4F1B-AEA9-E53E0DF95310}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {44026C4A-055E-421A-9763-F769130A7562} = {47D1FEFD-4DFF-4080-98FC-7B35A63D3FD5} {6CAFB055-0347-439F-91AB-4F3BC7D37713} = {47D1FEFD-4DFF-4080-98FC-7B35A63D3FD5} + {33DE40FB-A917-4A86-AA79-5BAB5CC8E66A} = {47D1FEFD-4DFF-4080-98FC-7B35A63D3FD5} + {B5F8BD80-AEDB-4F1B-AEA9-E53E0DF95310} = {33DE40FB-A917-4A86-AA79-5BAB5CC8E66A} EndGlobalSection EndGlobal diff --git a/src/Tools/CsvImporter/CsvImporter.csproj b/src/Tools/CsvImporter/CsvImporter.csproj new file mode 100644 index 0000000..59bd0e8 --- /dev/null +++ b/src/Tools/CsvImporter/CsvImporter.csproj @@ -0,0 +1,16 @@ + + + + Exe + net8.0 + enable + enable + true + + + + EntraMfaPrefillinator.Tools.CsvImporter + entramfacsvimporter + + + diff --git a/src/Tools/CsvImporter/Program.cs b/src/Tools/CsvImporter/Program.cs new file mode 100644 index 0000000..3751555 --- /dev/null +++ b/src/Tools/CsvImporter/Program.cs @@ -0,0 +1,2 @@ +// See https://aka.ms/new-console-template for more information +Console.WriteLine("Hello, World!"); From 95df5a5a2a39904aec2b372200b806fc8c786e86 Mon Sep 17 00:00:00 2001 From: Timothy Small Date: Thu, 11 Jan 2024 12:10:59 -0500 Subject: [PATCH 02/18] Ignore 'local-files/' dir --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 89f0b09..154eb75 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +local-files/ + __blobstorage__/ __queuestorage__/ __azurite_*.json From b80f4cf67bd4a54937f3078738f4602ad0dca13a Mon Sep 17 00:00:00 2001 From: Timothy Small Date: Fri, 12 Jan 2024 10:24:04 -0500 Subject: [PATCH 03/18] Add extra properties for MS Graph user items --- src/Lib/Models/Graph/User.cs | 6 ++++++ src/Lib/Models/Graph/interfaces/IUser.cs | 3 +++ 2 files changed, 9 insertions(+) diff --git a/src/Lib/Models/Graph/User.cs b/src/Lib/Models/Graph/User.cs index 5696e1c..f278898 100644 --- a/src/Lib/Models/Graph/User.cs +++ b/src/Lib/Models/Graph/User.cs @@ -10,4 +10,10 @@ public class User : IUser [JsonPropertyName("userPrincipalName")] public string UserPrincipalName { get; set; } = null!; + + [JsonPropertyName("employeeId")] + public string? EmployeeId { get; set; } + + [JsonPropertyName("onPremisesSamAccountName")] + public string OnPremisesSamAccountName { get; set; } = null!; } \ No newline at end of file diff --git a/src/Lib/Models/Graph/interfaces/IUser.cs b/src/Lib/Models/Graph/interfaces/IUser.cs index 22c093d..0e2af14 100644 --- a/src/Lib/Models/Graph/interfaces/IUser.cs +++ b/src/Lib/Models/Graph/interfaces/IUser.cs @@ -3,5 +3,8 @@ namespace EntraMfaPrefillinator.Lib.Models.Graph; public interface IUser { string Id { get; set; } + string DisplayName { get; set; } string UserPrincipalName { get; set; } + string OnPremisesSamAccountName { get; set; } + string? EmployeeId { get; set; } } \ No newline at end of file From 7d5ceb9f09a814e280a2664446beb0f9e196b260 Mon Sep 17 00:00:00 2001 From: Timothy Small Date: Fri, 12 Jan 2024 10:24:31 -0500 Subject: [PATCH 04/18] Add extra properties for UserAuthUpdateQueueItem --- src/Lib/Models/UserAuthUpdateQueueItem.cs | 8 +++++++- src/Lib/Models/interfaces/IUserAuthUpdateQueueItem.cs | 4 +++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Lib/Models/UserAuthUpdateQueueItem.cs b/src/Lib/Models/UserAuthUpdateQueueItem.cs index 2e8b4b9..aa7dcc7 100644 --- a/src/Lib/Models/UserAuthUpdateQueueItem.cs +++ b/src/Lib/Models/UserAuthUpdateQueueItem.cs @@ -2,8 +2,14 @@ namespace EntraMfaPrefillinator.Lib.Models; public class UserAuthUpdateQueueItem : IUserAuthUpdateQueueItem { + [JsonPropertyName("employeeId")] + public string? EmployeeId { get; set; } + + [JsonPropertyName("userName")] + public string? UserName { get; set; } + [JsonPropertyName("userPrincipalName")] - public string UserPrincipalName { get; set; } = null!; + public string? UserPrincipalName { get; set; } [JsonPropertyName("emailAddress")] public string? EmailAddress { get; set; } diff --git a/src/Lib/Models/interfaces/IUserAuthUpdateQueueItem.cs b/src/Lib/Models/interfaces/IUserAuthUpdateQueueItem.cs index 4d64a87..0b103eb 100644 --- a/src/Lib/Models/interfaces/IUserAuthUpdateQueueItem.cs +++ b/src/Lib/Models/interfaces/IUserAuthUpdateQueueItem.cs @@ -2,7 +2,9 @@ namespace EntraMfaPrefillinator.Lib.Models; public interface IUserAuthUpdateQueueItem { - string UserPrincipalName { get; set; } + string? EmployeeId { get; set; } + string? UserName { get; set; } + string? UserPrincipalName { get; set; } string? EmailAddress { get; set; } string? PhoneNumber { get; set; } } \ No newline at end of file From 66369bf6b89c26ee32ff8858bae2966d16c43023 Mon Sep 17 00:00:00 2001 From: Timothy Small Date: Fri, 12 Jan 2024 10:25:06 -0500 Subject: [PATCH 05/18] Add custom exception for when the client is configured for dry runs --- .../GraphClientServiceDryRunException.cs | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/Lib/Services/GraphClientService/GraphClientServiceDryRunException.cs diff --git a/src/Lib/Services/GraphClientService/GraphClientServiceDryRunException.cs b/src/Lib/Services/GraphClientService/GraphClientServiceDryRunException.cs new file mode 100644 index 0000000..17a70e4 --- /dev/null +++ b/src/Lib/Services/GraphClientService/GraphClientServiceDryRunException.cs @@ -0,0 +1,8 @@ +namespace EntraMfaPrefillinator.Lib.Services; + +public class GraphClientDryRunException : Exception +{ + public GraphClientDryRunException() { } + public GraphClientDryRunException(string message) : base(message) { } + public GraphClientDryRunException(string message, Exception inner) : base(message, inner) { } +} \ No newline at end of file From 3cbdf23398b5d24a48c1844024b5ace112248a77 Mon Sep 17 00:00:00 2001 From: Timothy Small Date: Fri, 12 Jan 2024 10:25:36 -0500 Subject: [PATCH 06/18] Add dry run support for GraphClientService --- .../AddEmailAuthenticationMethodAsync.cs | 5 +++++ .../AddPhoneAuthenticationMethodAsync.cs | 5 +++++ src/Lib/Services/GraphClientService/GraphClientService.cs | 6 ++++++ 3 files changed, 16 insertions(+) diff --git a/src/Lib/Services/GraphClientService/AuthenticationMethods/AddEmailAuthenticationMethodAsync.cs b/src/Lib/Services/GraphClientService/AuthenticationMethods/AddEmailAuthenticationMethodAsync.cs index 0b25e0f..97e422d 100644 --- a/src/Lib/Services/GraphClientService/AuthenticationMethods/AddEmailAuthenticationMethodAsync.cs +++ b/src/Lib/Services/GraphClientService/AuthenticationMethods/AddEmailAuthenticationMethodAsync.cs @@ -6,6 +6,11 @@ public partial class GraphClientService { public async Task AddEmailAuthenticationMethodAsync(string userId, string emailAddress) { + if (_disableUpdateMethods) + { + throw new GraphClientDryRunException("AddEmailAuthenticationMethodAsync() was called, but the service is currently configured to disable update methods."); + } + string apiEndpoint = $"users/{userId}/authentication/emailMethods"; EmailAuthenticationMethod newEmailAuthMethod = new() diff --git a/src/Lib/Services/GraphClientService/AuthenticationMethods/AddPhoneAuthenticationMethodAsync.cs b/src/Lib/Services/GraphClientService/AuthenticationMethods/AddPhoneAuthenticationMethodAsync.cs index 6ab84ff..63dba53 100644 --- a/src/Lib/Services/GraphClientService/AuthenticationMethods/AddPhoneAuthenticationMethodAsync.cs +++ b/src/Lib/Services/GraphClientService/AuthenticationMethods/AddPhoneAuthenticationMethodAsync.cs @@ -6,6 +6,11 @@ public partial class GraphClientService { public async Task AddPhoneAuthenticationMethodAsync(string userId, string phoneNumber) { + if (_disableUpdateMethods) + { + throw new GraphClientDryRunException("AddPhoneAuthenticationMethodAsync() was called, but the service is currently configured to disable update methods."); + } + string apiEndpoint = $"users/{userId}/authentication/phoneMethods"; PhoneAuthenticationMethod newPhoneAuthMethod = new() diff --git a/src/Lib/Services/GraphClientService/GraphClientService.cs b/src/Lib/Services/GraphClientService/GraphClientService.cs index 6b04593..20f7fea 100644 --- a/src/Lib/Services/GraphClientService/GraphClientService.cs +++ b/src/Lib/Services/GraphClientService/GraphClientService.cs @@ -8,6 +8,7 @@ public partial class GraphClientService : IGraphClientService { private readonly HttpClient _graphClient; private readonly IEnumerable _apiScopes; + private readonly bool _disableUpdateMethods; private readonly IConfidentialClientApplication _confidentialClientApplication; public GraphClientService(GraphClientConfig graphClientConfig) @@ -28,6 +29,11 @@ public GraphClientService(GraphClientConfig graphClientConfig) _graphClient.DefaultRequestHeaders.Add("ConsistencyLevel", "eventual"); } + public GraphClientService(GraphClientConfig graphClientConfig, bool disableUpdateMethods) : this(graphClientConfig) + { + _disableUpdateMethods = disableUpdateMethods; + } + public HttpClient GraphClient => _graphClient; private bool _isConnected => _authToken is not null; From 8fef564aa275d642a2e2c3c18d120902b3e04c90 Mon Sep 17 00:00:00 2001 From: Timothy Small Date: Fri, 12 Jan 2024 10:25:54 -0500 Subject: [PATCH 07/18] Add GetUserByUserNameAndEmployeeNumberAsync() --- ...GetUserByUserNameAndEmployeeNumberAsync.cs | 80 +++++++++++++++++++ .../interfaces/IGraphClientService.cs | 1 + 2 files changed, 81 insertions(+) create mode 100644 src/Lib/Services/GraphClientService/Users/GetUserByUserNameAndEmployeeNumberAsync.cs diff --git a/src/Lib/Services/GraphClientService/Users/GetUserByUserNameAndEmployeeNumberAsync.cs b/src/Lib/Services/GraphClientService/Users/GetUserByUserNameAndEmployeeNumberAsync.cs new file mode 100644 index 0000000..0c4b9d1 --- /dev/null +++ b/src/Lib/Services/GraphClientService/Users/GetUserByUserNameAndEmployeeNumberAsync.cs @@ -0,0 +1,80 @@ +using System.Text; +using System.Web; +using EntraMfaPrefillinator.Lib.Models.Graph; + +namespace EntraMfaPrefillinator.Lib.Services; + +public partial class GraphClientService +{ + /// + public async Task GetUserByUserNameAndEmployeeNumberAsync(string? userName, string? employeeNumber) + { + if (userName is null && employeeNumber is null) + { + throw new ArgumentNullException(nameof(userName), "Both userName and employeeNumber cannot be null."); + } + + StringBuilder apiFilterBuilder = new(); + + if (userName is not null) + { + apiFilterBuilder.Append($"startsWith(userPrincipalName, '{userName}@')"); + } + + if (employeeNumber is not null) + { + if (apiFilterBuilder.Length > 0) + { + apiFilterBuilder.Append(" or "); + } + + apiFilterBuilder.Append($"employeeId eq '{employeeNumber}'"); + } + + string apiFilter = HttpUtility.UrlEncode(apiFilterBuilder.ToString()); + string apiEndpoint = $"users?$filter={apiFilter}&$select=id,userPrincipalName,displayName,onPremisesSamAccountName,employeeId"; + + string? apiResultString = await SendApiCallAsync( + endpoint: apiEndpoint, + httpMethod: HttpMethod.Get + ); + + if (apiResultString is null) + { + throw new Exception("API result string is null."); + } + + User user; + GraphCollection userCollection; + try + { + userCollection = JsonSerializer.Deserialize( + json: apiResultString, + jsonTypeInfo: GraphJsonContext.Default.GraphCollectionUser + )!; + + if (userCollection.Value is null) + { + throw new Exception("User collection value is null."); + } + + user = Array.Find(userCollection.Value, item => item.OnPremisesSamAccountName == userName || item.EmployeeId == employeeNumber) ?? throw new Exception("User not found."); + + if (string.IsNullOrEmpty(user.Id)) + { + throw new Exception("User ID is null or empty."); + } + } + catch + { + GraphErrorResponse? errorResponse = JsonSerializer.Deserialize( + json: apiResultString, + jsonTypeInfo: GraphJsonContext.Default.GraphErrorResponse + ); + + throw new Exception(errorResponse!.Error!.Message); + } + + return user; + } +} \ No newline at end of file diff --git a/src/Lib/Services/interfaces/IGraphClientService.cs b/src/Lib/Services/interfaces/IGraphClientService.cs index 0577e0f..a2061c2 100644 --- a/src/Lib/Services/interfaces/IGraphClientService.cs +++ b/src/Lib/Services/interfaces/IGraphClientService.cs @@ -7,6 +7,7 @@ public interface IGraphClientService HttpClient GraphClient { get; } Task GetUserAsync(string userId); + Task GetUserByUserNameAndEmployeeNumberAsync(string? userName, string? employeeNumber); Task AddPhoneAuthenticationMethodAsync(string userId, string phoneNumber); Task AddEmailAuthenticationMethodAsync(string userId, string emailAddress); Task GetPhoneAuthenticationMethodsAsync(string userId); From 5ad5a34953db83fbd1ae0978fb2e672c8e6def55 Mon Sep 17 00:00:00 2001 From: Timothy Small Date: Fri, 12 Jan 2024 10:26:18 -0500 Subject: [PATCH 08/18] Add GraphCollection to source generated JSON --- src/Lib/JsonSourceGen/GraphJsonContext.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Lib/JsonSourceGen/GraphJsonContext.cs b/src/Lib/JsonSourceGen/GraphJsonContext.cs index 789facf..d4df6ad 100644 --- a/src/Lib/JsonSourceGen/GraphJsonContext.cs +++ b/src/Lib/JsonSourceGen/GraphJsonContext.cs @@ -10,6 +10,7 @@ namespace EntraMfaPrefillinator.Lib; )] [JsonSerializable(typeof(User))] [JsonSerializable(typeof(User[]))] +[JsonSerializable(typeof(GraphCollection))] [JsonSerializable(typeof(EmailAuthenticationMethod))] [JsonSerializable(typeof(EmailAuthenticationMethod[]))] [JsonSerializable(typeof(GraphCollection))] From 0772ac8b03e18ca9f9de5dacde672118787c5b9e Mon Sep 17 00:00:00 2001 From: Timothy Small Date: Fri, 12 Jan 2024 10:27:54 -0500 Subject: [PATCH 09/18] Add support for processing queue items with new properties --- .../Functions/ProcessUserAuthUpdateQueue.cs | 59 ++++++++++++++----- 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/src/FunctionApp/Functions/ProcessUserAuthUpdateQueue.cs b/src/FunctionApp/Functions/ProcessUserAuthUpdateQueue.cs index 05db7a8..f042759 100644 --- a/src/FunctionApp/Functions/ProcessUserAuthUpdateQueue.cs +++ b/src/FunctionApp/Functions/ProcessUserAuthUpdateQueue.cs @@ -32,17 +32,38 @@ FunctionContext executionContext jsonTypeInfo: QueueJsonContext.Default.UserAuthUpdateQueueItem )!; - logger.LogInformation("Received request for {UserPrincipalName}.", queueItem.UserPrincipalName); + string userName = queueItem.UserName ?? queueItem.UserPrincipalName ?? throw new Exception("'userName' or 'userPrincipalName' must be supplied in the request."); + + logger.LogInformation("Received request for '{userName}'.", userName); User user; - try + if (queueItem.UserName is not null || queueItem.EmployeeId is not null) + { + try + { + user = await _graphClientService.GetUserByUserNameAndEmployeeNumberAsync(queueItem.UserName, queueItem.EmployeeId); + } + catch (Exception e) + { + logger.LogError(e, "Error getting user, '{userName}' [{employeeId}].", queueItem.UserName, queueItem.EmployeeId); + throw; + } + } + else if (queueItem.UserPrincipalName is not null) { - user = await _graphClientService.GetUserAsync(queueItem.UserPrincipalName); + try + { + user = await _graphClientService.GetUserAsync(queueItem.UserPrincipalName); + } + catch (Exception e) + { + logger.LogError(e, "Error getting user, '{userPrincipalName}'.", queueItem.UserPrincipalName); + throw; + } } - catch (Exception e) + else { - logger.LogError(e, "Error getting user, {UserPrincipalName}.", queueItem.UserPrincipalName); - throw; + throw new Exception("'userName' and 'employeeId' or 'userPrincipalName' must be supplied in the request."); } if (queueItem.EmailAddress is not null) @@ -51,7 +72,7 @@ FunctionContext executionContext if (emailAuthMethods is not null && emailAuthMethods.Length != 0) { - logger.LogWarning("'{UserPrincipalName}' already has email auth methods configured. Skipping...", queueItem.UserPrincipalName); + logger.LogWarning("'{userPrincipalName}' already has email auth methods configured. Skipping...", user.UserPrincipalName); } else { @@ -62,18 +83,22 @@ await _graphClientService.AddEmailAuthenticationMethodAsync( emailAddress: queueItem.EmailAddress ); - logger.LogInformation("Added email auth method for {UserPrincipalName}.", queueItem.UserPrincipalName); + logger.LogInformation("Added email auth method for '{userPrincipalName}'.", user.UserPrincipalName); + } + catch (GraphClientDryRunException) + { + logger.LogWarning("Dry run is enabled. Skipping adding email auth method for '{userPrincipalName}'.", user.UserPrincipalName); } catch (Exception e) { - logger.LogError(e, "Error adding email auth method for {UserPrincipalName}.", queueItem.UserPrincipalName); + logger.LogError(e, "Error adding email auth method for '{userPrincipalName}'.", user.UserPrincipalName); throw; } } } else { - logger.LogWarning("'{UserPrincipalName}' did not have an email address supplied in the request. Skipping...", queueItem.UserPrincipalName); + logger.LogWarning("'{userPrincipalName}' did not have an email address supplied in the request. Skipping...", user.UserPrincipalName); } if (queueItem.PhoneNumber is not null) @@ -82,7 +107,7 @@ await _graphClientService.AddEmailAuthenticationMethodAsync( if (phoneAuthMethods is not null && phoneAuthMethods.Length != 0) { - logger.LogWarning("'{UserPrincipalName}' already has phone auth methods configured. Skipping...", queueItem.UserPrincipalName); + logger.LogWarning("'{userPrincipalName}' already has phone auth methods configured. Skipping...", user.UserPrincipalName); } else { @@ -93,21 +118,25 @@ await _graphClientService.AddPhoneAuthenticationMethodAsync( phoneNumber: queueItem.PhoneNumber ); - logger.LogInformation("Added phone auth method for {UserPrincipalName}.", queueItem.UserPrincipalName); + logger.LogInformation("Added phone auth method for '{userPrincipalName}'.", user.UserPrincipalName); + } + catch (GraphClientDryRunException) + { + logger.LogWarning("Dry run is enabled. Skipping adding phone auth method for '{userPrincipalName}'.", user.UserPrincipalName); } catch (Exception e) { - logger.LogError(e, "Error adding phone auth method for {UserPrincipalName}.", queueItem.UserPrincipalName); + logger.LogError(e, "Error adding phone auth method for '{userPrincipalName}'.", user.UserPrincipalName); throw; } } } else { - logger.LogWarning("'{UserPrincipalName}' did not have a phone number supplied in the request. Skipping...", queueItem.UserPrincipalName); + logger.LogWarning("'{userPrincipalName}' did not have a phone number supplied in the request. Skipping...", user.UserPrincipalName); } stopwatch.Stop(); - logger.LogInformation("Processed request for {UserPrincipalName} in {ElapsedMilliseconds}ms.", queueItem.UserPrincipalName, stopwatch.ElapsedMilliseconds); + logger.LogInformation("Processed request for '{userPrincipalName}' in {ElapsedMilliseconds}ms.", user.UserPrincipalName, stopwatch.ElapsedMilliseconds); } } \ No newline at end of file From 27f44aced6ce8da22ab5709c5c7377636237a861 Mon Sep 17 00:00:00 2001 From: Timothy Small Date: Fri, 12 Jan 2024 10:28:23 -0500 Subject: [PATCH 10/18] Add support for running the FunctionApp in a dry run mode --- src/FunctionApp/Program.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/FunctionApp/Program.cs b/src/FunctionApp/Program.cs index ebc2ec4..85d538f 100644 --- a/src/FunctionApp/Program.cs +++ b/src/FunctionApp/Program.cs @@ -32,7 +32,8 @@ { "https://graph.microsoft.com/.default" } - } + }, + disableUpdateMethods: bool.Parse(provider.GetRequiredService()["dryRun"]) ) ); From c33f0f69f5f0812c8c653344ba56a6ca4fe2a757 Mon Sep 17 00:00:00 2001 From: Timothy Small Date: Fri, 12 Jan 2024 10:29:49 -0500 Subject: [PATCH 11/18] Add core functionality to CsvImporter tool --- src/Tools/CsvImporter/CsvImporter.csproj | 5 + .../JsonSourceGen/CoreJsonContext.cs | 22 ++ .../CsvImporter/Models/CsvImporterConfig.cs | 27 +++ src/Tools/CsvImporter/Models/UserDetails.cs | 133 +++++++++++ src/Tools/CsvImporter/Program.cs | 224 +++++++++++++++++- .../CsvImporter/Utilities/ConfigFileUtils.cs | 80 +++++++ .../CsvImporter/Utilities/ConsoleUtils.cs | 60 +++++ .../Utilities/CsvDataRegexTools.cs | 55 +++++ .../CsvImporter/Utilities/CsvFileReader.cs | 54 +++++ .../CsvImporter/Utilities/QueueClientUtils.cs | 55 +++++ 10 files changed, 713 insertions(+), 2 deletions(-) create mode 100644 src/Tools/CsvImporter/JsonSourceGen/CoreJsonContext.cs create mode 100644 src/Tools/CsvImporter/Models/CsvImporterConfig.cs create mode 100644 src/Tools/CsvImporter/Models/UserDetails.cs create mode 100644 src/Tools/CsvImporter/Utilities/ConfigFileUtils.cs create mode 100644 src/Tools/CsvImporter/Utilities/ConsoleUtils.cs create mode 100644 src/Tools/CsvImporter/Utilities/CsvDataRegexTools.cs create mode 100644 src/Tools/CsvImporter/Utilities/CsvFileReader.cs create mode 100644 src/Tools/CsvImporter/Utilities/QueueClientUtils.cs diff --git a/src/Tools/CsvImporter/CsvImporter.csproj b/src/Tools/CsvImporter/CsvImporter.csproj index 59bd0e8..27521f9 100644 --- a/src/Tools/CsvImporter/CsvImporter.csproj +++ b/src/Tools/CsvImporter/CsvImporter.csproj @@ -5,6 +5,7 @@ net8.0 enable enable + true true @@ -13,4 +14,8 @@ entramfacsvimporter + + + + diff --git a/src/Tools/CsvImporter/JsonSourceGen/CoreJsonContext.cs b/src/Tools/CsvImporter/JsonSourceGen/CoreJsonContext.cs new file mode 100644 index 0000000..bfc191f --- /dev/null +++ b/src/Tools/CsvImporter/JsonSourceGen/CoreJsonContext.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; +using EntraMfaPrefillinator.Lib.Models; +using EntraMfaPrefillinator.Tools.CsvImporter.Models; + +namespace EntraMfaPrefillinator.Tools.CsvImporter; + +/// +/// Source generation context for classes used in the CsvImporter tool that +/// are serialized to and deserialized from JSON. +/// +[JsonSourceGenerationOptions( + WriteIndented = false, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + GenerationMode = JsonSourceGenerationMode.Default, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull +)] +[JsonSerializable(typeof(UserAuthUpdateQueueItem))] +[JsonSerializable(typeof(UserAuthUpdateQueueItem[]))] +[JsonSerializable(typeof(CsvImporterConfig))] +internal partial class CoreJsonContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/src/Tools/CsvImporter/Models/CsvImporterConfig.cs b/src/Tools/CsvImporter/Models/CsvImporterConfig.cs new file mode 100644 index 0000000..2997f77 --- /dev/null +++ b/src/Tools/CsvImporter/Models/CsvImporterConfig.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace EntraMfaPrefillinator.Tools.CsvImporter.Models; + +/// +/// Holds the configuration for the CsvImporter tool. +/// +public class CsvImporterConfig +{ + /// + /// The path to the last CSV file that was imported. + /// + [JsonPropertyName("lastCsvPath")] + public string? LastCsvPath { get; set; } + + /// + /// The last date and time the tool was run. + /// + [JsonPropertyName("lastRunDateTime")] + public DateTimeOffset? LastRunDateTime { get; set; } + + /// + /// Whether the tool should run in dry run mode. + /// + [JsonPropertyName("dryRunEnabled")] + public bool DryRunEnabled { get; set; } +} \ No newline at end of file diff --git a/src/Tools/CsvImporter/Models/UserDetails.cs b/src/Tools/CsvImporter/Models/UserDetails.cs new file mode 100644 index 0000000..d685a78 --- /dev/null +++ b/src/Tools/CsvImporter/Models/UserDetails.cs @@ -0,0 +1,133 @@ +using System.Text.RegularExpressions; +using EntraMfaPrefillinator.Tools.CsvImporter.Utilities; + +namespace EntraMfaPrefillinator.Tools.CsvImporter.Models; + +/// +/// Holds data imported, from a CSV file, for a user's details. +/// +public class UserDetails +{ + /// + /// Initializes a new instance of the class. + /// + public UserDetails() + {} + + /// + /// Initializes a new instance of the class. + /// + /// The user's employee number. + /// The user's username. + /// The user's secondary email address. + /// The user's phone number. + public UserDetails(string? employeeNumber, string? userName, string? secondaryEmail, string? phoneNumber) + { + EmployeeNumber = employeeNumber; + UserName = userName; + SecondaryEmail = secondaryEmail; + PhoneNumber = phoneNumber; + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// This constructor is used for parsing the line from a CSV file. + /// + /// The line from a CSV file. + public UserDetails(string csvLine) + { + Match userDetailsMatch = CsvDataRegexTools.UserDetailsCsvLineRegex().Match(csvLine); + + EmployeeNumber = ParseEmployeeNumber(userDetailsMatch.Groups["employeeNumber"].Value); + UserName = ParseUserName(userDetailsMatch.Groups["userName"].Value); + SecondaryEmail = ParseSecondaryEmail(userDetailsMatch.Groups["emailAddress"].Value); + PhoneNumber = ParsePhoneNumber(userDetailsMatch.Groups["phoneNumber"].Value); + } + + /// + /// The user's employee number. + /// + public string? EmployeeNumber { get; set; } + + /// + /// The user's username. + /// + public string? UserName { get; set; } + + /// + /// The user's secondary email address. + /// + public string? SecondaryEmail { get; set; } + + /// + /// The user's phone number. + /// + public string? PhoneNumber { get; set; } + + /// + /// Parse the employee number provided. + /// + /// The employee number to parse. + /// The parsed employee number. + private static string? ParseEmployeeNumber(string? employeeNumber) + { + if (string.IsNullOrWhiteSpace(employeeNumber)) + { + return null; + } + + return employeeNumber.TrimStart('0'); + } + + /// + /// Parse the username provided. + /// + /// The username to parse. + /// The parsed username. + private static string? ParseUserName(string? userName) + { + if (string.IsNullOrWhiteSpace(userName)) + { + return null; + } + + return userName; + } + + /// + /// Parse the secondary email provided. + /// + /// The secondary email to parse. + /// The parsed secondary email. + private static string? ParseSecondaryEmail(string? secondaryEmail) + { + if (string.IsNullOrWhiteSpace(secondaryEmail) || !CsvDataRegexTools.IsValidEmailAddress(secondaryEmail)) + { + return null; + } + + return secondaryEmail; + } + + /// + /// Parse the phone number provided. + /// + /// The phone number to parse. + /// The parsed phone number. + private static string? ParsePhoneNumber(string? phoneNumber) + { + if (string.IsNullOrWhiteSpace(phoneNumber)) + { + return null; + } + + if (!CsvDataRegexTools.PhoneNumberHasCountryCode(phoneNumber)) + { + phoneNumber = $"+1 {phoneNumber}"; + } + + return phoneNumber; + } +} \ No newline at end of file diff --git a/src/Tools/CsvImporter/Program.cs b/src/Tools/CsvImporter/Program.cs index 3751555..6a5e21a 100644 --- a/src/Tools/CsvImporter/Program.cs +++ b/src/Tools/CsvImporter/Program.cs @@ -1,2 +1,222 @@ -// See https://aka.ms/new-console-template for more information -Console.WriteLine("Hello, World!"); +using System.Diagnostics; +using EntraMfaPrefillinator.Lib.Models; +using EntraMfaPrefillinator.Lib.Services; +using EntraMfaPrefillinator.Tools.CsvImporter.Models; +using EntraMfaPrefillinator.Tools.CsvImporter.Utilities; + +Stopwatch stopwatch = Stopwatch.StartNew(); + +// Set Azure Storage connection string and dry run flag from environment variables. +string storageConnectionString = Environment.GetEnvironmentVariable("STORAGE_CONNECTION_STRING") ?? throw new Exception("STORAGE_CONNECTION_STRING environment variable not set"); + +// Set CSV file path and max tasks from command line arguments. +string csvFilePathArg; +try +{ + csvFilePathArg = args[0]; +} +catch (Exception) +{ + throw new ArgumentException("Missing CSV file path argument"); +} + +string maxTasksArg; +try +{ + maxTasksArg = args[1]; +} +catch (Exception) +{ + maxTasksArg = "256"; +} + +int maxTasks; +try +{ + maxTasks = int.Parse(maxTasksArg); +} +catch (Exception ex) +{ + throw new ArgumentException($"Invalid max tasks argument: {maxTasksArg}", ex); +} + +// Read config file. +CsvImporterConfig csvImporterConfig; +try +{ + csvImporterConfig = await ConfigFileUtils.GetCsvImporterConfigAsync(); +} +catch (Exception ex) +{ + throw new Exception($"Error reading config file: {ex.Message}"); +} + +string runningDir = Environment.CurrentDirectory; +ConsoleUtils.WriteInfo($"Running from {runningDir}"); + +// Resolve CSV file path. +ConsoleUtils.WriteInfo($"Reading CSV file from {csvFilePathArg}"); +string relativePathToCsv = Path.GetRelativePath( + relativeTo: runningDir, + path: csvFilePathArg +); + +ConsoleUtils.WriteInfo($"Relative path to CSV file: {relativePathToCsv}"); +FileInfo csvFileInfo = new(relativePathToCsv); + +if (!csvFileInfo.Exists) +{ + throw new FileNotFoundException("File not found", csvFileInfo.FullName); +} + +if (csvFileInfo.Extension != ".csv") +{ + throw new ArgumentException("File must be a CSV file", csvFileInfo.FullName); +} + +ConsoleUtils.WriteInfo($"Reading CSV file: {csvFileInfo.FullName}"); + +// Read CSV file. +List userDetailsList; +try +{ + userDetailsList = await CsvFileReader.ReadCsvFileAsync( + csvFilePath: csvFileInfo.FullName + ); +} +catch (Exception) +{ + throw; +} + +ConsoleUtils.WriteInfo($"Found {userDetailsList.Count} users in CSV file"); + +// If last run CSV file path exists, +// read the last run CSV file. +List? lastRunUserDetailsList = null; +if (csvImporterConfig.LastCsvPath is not null) +{ + Path.GetRelativePath( + relativeTo: runningDir, + path: csvImporterConfig.LastCsvPath + ); + + FileInfo lastCsvFileInfo = new(csvImporterConfig.LastCsvPath); + + if (lastCsvFileInfo.Exists) + { + ConsoleUtils.WriteInfo($"Reading last run CSV file: {lastCsvFileInfo.FullName}"); + lastRunUserDetailsList = await CsvFileReader.ReadCsvFileAsync( + csvFilePath: lastCsvFileInfo.FullName + ); + } +} + +// If last run CSV file was read, +// get the delta between the last run CSV file and the current CSV file. +if (lastRunUserDetailsList is not null && lastRunUserDetailsList.Count != 0) +{ + ConsoleUtils.WriteInfo($"Found {lastRunUserDetailsList.Count} users in last run CSV file"); + + List deltaList = []; + + foreach (var userDetailsItem in userDetailsList) + { + UserDetails? lastRunUserDetailsItem = lastRunUserDetailsList.Find(item => item.UserName == userDetailsItem.UserName); + + // If the user was not found in the last run CSV file, + // add the user to the delta list. + if (lastRunUserDetailsItem is null) + { + deltaList.Add(userDetailsItem); + continue; + } + + // If the user was found in the last run CSV file and the email or phone number has changed, + // add the user to the delta list. + if (lastRunUserDetailsItem.PhoneNumber != userDetailsItem.PhoneNumber || lastRunUserDetailsItem.SecondaryEmail != userDetailsItem.SecondaryEmail) + { + deltaList.Add(userDetailsItem); + continue; + } + } + + ConsoleUtils.WriteInfo($"Filtered down to {deltaList.Count} users not in last run CSV file"); + + // Update the user details list to the delta list. + userDetailsList = deltaList; +} + +// Filter out users without an email or phone number set. +List filteredUserDetailsList = userDetailsList.FindAll( + match: userDetails => userDetails.SecondaryEmail is not null || userDetails.PhoneNumber is not null +); + +ConsoleUtils.WriteInfo($"Filtered to {filteredUserDetailsList.Count} users with email or phone number"); + +// If there are no users to process, exit. +if (filteredUserDetailsList.Count == 0) +{ + ConsoleUtils.WriteInfo($"No users to process, exiting"); + return; +} + +// If this is a dry run, exit. +if (csvImporterConfig.DryRunEnabled) +{ + ConsoleUtils.WriteInfo($"Dry run, exiting"); + return; +} + +// Configure Azure Storage Queue client service. +QueueClientService queueClientService = new( + connectionString: storageConnectionString +); + +// Set the initial semaphore count to half the max tasks. +double initialTasksCount = Math.Round((double)(maxTasks / 2), 0); + +using SemaphoreSlim semaphoreSlim = new( + initialCount: (int)initialTasksCount, + maxCount: maxTasks +); + +// Process each item and send to queue. +List tasks = []; +foreach (var userItem in filteredUserDetailsList) +{ + UserAuthUpdateQueueItem queueItem = new() + { + EmployeeId = userItem.EmployeeNumber, + UserName = userItem.UserName, + EmailAddress = userItem.SecondaryEmail, + PhoneNumber = userItem.PhoneNumber + }; + + var newQueueItemTask = QueueClientUtils.SendUserAuthUpdateQueueItemAsync(queueClientService, semaphoreSlim, queueItem); + + tasks.Add(newQueueItemTask); +} + +// Wait for all tasks to complete. +ConsoleUtils.WriteInfo($"Waiting for tasks to complete..."); +await Task.WhenAll(tasks); + +// Copy the CSV file used for this run to the config directory. +ConsoleUtils.WriteInfo($"Saving last run CSV file path to config file"); +string copiedCsvFilePath = Path.Combine(ConfigFileUtils.GetConfigDirPath(), "lastRun.csv"); +File.Copy( + sourceFileName: csvFileInfo.FullName, + destFileName: copiedCsvFilePath, + overwrite: true +); + +// Update the config file. +csvImporterConfig.LastCsvPath = copiedCsvFilePath; +csvImporterConfig.LastRunDateTime = DateTimeOffset.UtcNow; + +await ConfigFileUtils.SaveCsvImporterConfigAsync(csvImporterConfig); + +stopwatch.Stop(); + +ConsoleUtils.WriteInfo($"Completed in {stopwatch.ElapsedMilliseconds}ms"); diff --git a/src/Tools/CsvImporter/Utilities/ConfigFileUtils.cs b/src/Tools/CsvImporter/Utilities/ConfigFileUtils.cs new file mode 100644 index 0000000..14f5b8a --- /dev/null +++ b/src/Tools/CsvImporter/Utilities/ConfigFileUtils.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using EntraMfaPrefillinator.Tools.CsvImporter.Models; + +namespace EntraMfaPrefillinator.Tools.CsvImporter.Utilities; + +/// +/// Houses methods for reading and writing the config file for the CsvImporter tool. +/// +public static class ConfigFileUtils +{ + /// + /// The path to the config directory. + /// + private static readonly string _configDirPath = Path.Combine(AppContext.BaseDirectory, ".config"); + + /// + /// The path to the config file. + /// + private static readonly string _configFilePath = Path.Combine(_configDirPath, "config.json"); + + /// + /// Gets the path to the config directory. + /// + /// The path to the config directory. + public static string GetConfigDirPath() => _configDirPath; + + /// + /// Gets the config for the CsvImporter tool. + /// + /// The config for the CsvImporter tool. + /// Thrown when there is an error reading the config file. + public static async Task GetCsvImporterConfigAsync() + { + EnsureConfigDirExists(); + + CsvImporterConfig csvImporterConfig; + if (!File.Exists(_configFilePath)) + { + csvImporterConfig = new(); + await SaveCsvImporterConfigAsync(csvImporterConfig); + } + else + { + string configJson = await File.ReadAllTextAsync(_configFilePath); + csvImporterConfig = JsonSerializer.Deserialize( + json: configJson, + jsonTypeInfo: CoreJsonContext.Default.CsvImporterConfig + ) ?? throw new Exception($"Unable to deserialize config file: {_configFilePath}"); + } + + return csvImporterConfig; + } + + /// + /// Saves the config for the CsvImporter tool. + /// + /// The updated config for the CsvImporter tool. + public static async Task SaveCsvImporterConfigAsync(CsvImporterConfig csvImporterConfig) + { + EnsureConfigDirExists(); + + string configJson = JsonSerializer.Serialize( + value: csvImporterConfig, + jsonTypeInfo: CoreJsonContext.Default.CsvImporterConfig + ); + + await File.WriteAllTextAsync(_configFilePath, configJson); + } + + /// + /// Checks if the config directory exists and creates it if it doesn't. + /// + private static void EnsureConfigDirExists() + { + if (!Directory.Exists(_configDirPath)) + { + Directory.CreateDirectory(_configDirPath); + } + } +} \ No newline at end of file diff --git a/src/Tools/CsvImporter/Utilities/ConsoleUtils.cs b/src/Tools/CsvImporter/Utilities/ConsoleUtils.cs new file mode 100644 index 0000000..7309726 --- /dev/null +++ b/src/Tools/CsvImporter/Utilities/ConsoleUtils.cs @@ -0,0 +1,60 @@ +namespace EntraMfaPrefillinator.Tools.CsvImporter.Utilities; + +/// +/// Houses methods for writing to the console. +/// +public static class ConsoleUtils +{ + /// + /// Writes an error message to the console. + /// + /// The message to write. + public static void WriteError(string message) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.Error.WriteLine(message); + Console.ResetColor(); + } + + /// + /// Writes a warning message to the console. + /// + /// The message to write. + public static void WriteWarning(string message) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Out.WriteLine(message); + Console.ResetColor(); + } + + /// + /// Writes a success message to the console. + /// + /// The message to write. + public static void WriteSuccess(string message) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.Out.WriteLine(message); + Console.ResetColor(); + } + + /// + /// Writes an info message to the console. + /// + /// The message to write. + public static void WriteInfo(string message) + { + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Out.WriteLine(message); + Console.ResetColor(); + } + + /// + /// Writes a normal message to the console. + /// + /// The message to write. + public static void WriteOutput(string message) + { + Console.Out.WriteLine(message); + } +} \ No newline at end of file diff --git a/src/Tools/CsvImporter/Utilities/CsvDataRegexTools.cs b/src/Tools/CsvImporter/Utilities/CsvDataRegexTools.cs new file mode 100644 index 0000000..66604bb --- /dev/null +++ b/src/Tools/CsvImporter/Utilities/CsvDataRegexTools.cs @@ -0,0 +1,55 @@ +using System.Text.RegularExpressions; + +namespace EntraMfaPrefillinator.Tools.CsvImporter.Utilities; + +/// +/// Houses methods for parsing CSV data. +/// +public static partial class CsvDataRegexTools +{ + /// + /// Determines if a line from a CSV file is valid. + /// + /// The line from a CSV file. + /// True if the line is valid; otherwise, false. + public static bool IsValidCsvLine(string csvLine) => ValidCsvLineRegex().IsMatch(csvLine); + + [GeneratedRegex( + pattern: """(".*?"(?:,|)){4}""" + )] + private static partial Regex ValidCsvLineRegex(); + + [GeneratedRegex( + pattern: "\"(?'employeeNumber'.*?)\",\"(?'userName'.*?)\",\"(?'emailAddress'.*?)\",\"(?'phoneNumber'.*?)\"" + )] + public static partial Regex UserDetailsCsvLineRegex(); + + /// + /// Checks if the phone number has a country code. + /// + /// The phone number to check. + /// True if the phone number has a country code; otherwise, false. + public static bool PhoneNumberHasCountryCode(string phoneNumber) => PhoneNumberHasCountryCodeRegex().IsMatch(phoneNumber); + + [GeneratedRegex( + pattern: @"\+\d{1,} .+" + )] + private static partial Regex PhoneNumberHasCountryCodeRegex(); + + /// + /// Checks if the provided email address is valid. + /// + /// The email address to check. + /// True if the email address is valid; otherwise, false. + public static bool IsValidEmailAddress(string emailAddress) => EmailAddressRegex().IsMatch(emailAddress); + + [GeneratedRegex( + pattern: @".+?\@.+" + )] + private static partial Regex EmailAddressRegex(); + + [GeneratedRegex( + pattern: @"(?'leadingZeroes'0{1,})(?'number'\d+)" + )] + public static partial Regex LeadingZeroesRegex(); +} \ No newline at end of file diff --git a/src/Tools/CsvImporter/Utilities/CsvFileReader.cs b/src/Tools/CsvImporter/Utilities/CsvFileReader.cs new file mode 100644 index 0000000..091ee7b --- /dev/null +++ b/src/Tools/CsvImporter/Utilities/CsvFileReader.cs @@ -0,0 +1,54 @@ +using System.Text.RegularExpressions; +using EntraMfaPrefillinator.Tools.CsvImporter.Models; + +namespace EntraMfaPrefillinator.Tools.CsvImporter.Utilities; + +/// +/// Houses methods for reading CSV files. +/// +public static class CsvFileReader +{ + /// + /// Reads a CSV file and parses the data. + /// + /// The path to the CSV file. + /// A of objects. + /// Thrown when there is an error reading the CSV file. + public static async Task> ReadCsvFileAsync(string csvFilePath) + { + using StringReader csvFileReader = new(await File.ReadAllTextAsync(csvFilePath)); + + List userDetailsList = []; + + string? csvLine; + int currentLine = 0; + while ((csvLine = await csvFileReader.ReadLineAsync()) is not null) + { + bool isValidCsvLine = CsvDataRegexTools.IsValidCsvLine(csvLine); + + // Skip the header line if it is the first line. + // If the first line is not the header line, throw an exception. + if (isValidCsvLine && currentLine == 0) + { + currentLine++; + continue; + } + else if (!isValidCsvLine && currentLine == 0) + { + throw new Exception($"Invalid CSV line: {csvLine}"); + } + + // If the line is valid, add it to the list. + if (isValidCsvLine) + { + userDetailsList.Add(new(csvLine)); + } + else + { + ConsoleUtils.WriteError($"{csvLine} [Invalid]"); + } + } + + return userDetailsList; + } +} \ No newline at end of file diff --git a/src/Tools/CsvImporter/Utilities/QueueClientUtils.cs b/src/Tools/CsvImporter/Utilities/QueueClientUtils.cs new file mode 100644 index 0000000..faeb119 --- /dev/null +++ b/src/Tools/CsvImporter/Utilities/QueueClientUtils.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using EntraMfaPrefillinator.Lib.Models; +using EntraMfaPrefillinator.Lib.Services; + +namespace EntraMfaPrefillinator.Tools.CsvImporter.Utilities; + +/// +/// Houses methods for sending messages to the Azure Storage Queue. +/// +public static class QueueClientUtils +{ + /// + /// Sends a to the Azure Storage Queue. + /// + /// The to use. + /// The to use. + /// The auth update to send. + /// A representing the asynchronous operation. + public static Task SendUserAuthUpdateQueueItemAsync(QueueClientService queueClientService, SemaphoreSlim semaphoreSlim, UserAuthUpdateQueueItem userAuthUpdate) + { + var sendToQueueTask = Task.Run(async () => + { + // Wait for the semaphore to be available before sending the message. + await semaphoreSlim.WaitAsync(); + + try + { + // Serialize the user auth update to JSON and send it to the queue. + string userItemJson = JsonSerializer.Serialize( + value: userAuthUpdate, + jsonTypeInfo: CoreJsonContext.Default.UserAuthUpdateQueueItem + ); + + try + { + await queueClientService.AuthUpdateQueueClient.SendMessageAsync( + messageText: userItemJson + ); + } + catch (Exception ex) + { + ConsoleUtils.WriteError($"Error sending message to queue for '{userAuthUpdate.UserName}': {ex.Message}"); + + } + } + finally + { + // Release the semaphore when the message has been sent. + semaphoreSlim.Release(); + } + }); + + return sendToQueueTask; + } +} \ No newline at end of file From 8d2bffd7cbbf5d3aa95399091efb051bece38ec5 Mon Sep 17 00:00:00 2001 From: Timothy Small Date: Fri, 12 Jan 2024 10:30:42 -0500 Subject: [PATCH 12/18] Ignore 'build/' dir --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 154eb75..b635957 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ local-files/ +build/ __blobstorage__/ __queuestorage__/ From 017306cac2d44aade7b589ea7db98e895c6b06a8 Mon Sep 17 00:00:00 2001 From: Timothy Small Date: Fri, 12 Jan 2024 10:31:03 -0500 Subject: [PATCH 13/18] Add settings for Azure Functions --- .vscode/settings.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 4c5eeec..406a4e5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,5 +6,10 @@ "dotnet.server.path": "latest", "task.quickOpen.history": 0, "task.autoDetect": "off", - "powershell.startAutomatically": false + "powershell.startAutomatically": false, + "azureFunctions.projectSubpath": "src/FunctionApp", + "azureFunctions.deploySubpath": "src/FunctionApp/bin/Release/net8.0/publish", + "azureFunctions.projectLanguage": "C#", + "azureFunctions.projectRuntime": "~4", + "azureFunctions.preDeployTask": "publish (functions)" } \ No newline at end of file From 1d9f4d18bfef9d5d537684c0a36d1d45c4cc009b Mon Sep 17 00:00:00 2001 From: Timothy Small Date: Fri, 12 Jan 2024 10:31:13 -0500 Subject: [PATCH 14/18] Add recommended extensions --- .vscode/extensions.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .vscode/extensions.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..bb76300 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "ms-azuretools.vscode-azurefunctions", + "ms-dotnettools.csharp" + ] +} \ No newline at end of file From a091fadba820df9cf0e0f7c901882e9c9c616f21 Mon Sep 17 00:00:00 2001 From: Timothy Small Date: Fri, 12 Jan 2024 10:33:17 -0500 Subject: [PATCH 15/18] Add tasks needed for the Azure Functions VSCode extension --- .vscode/tasks.json | 87 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 3 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index a5889f2..fb1a72f 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -23,7 +23,7 @@ } }, "runOptions": { - "instanceLimit": 2, + "instanceLimit": 2 }, "presentation": { "echo": false, @@ -167,8 +167,6 @@ "Build: EntraMfaPrefillinator.FunctionApp" ] }, - // Remaining tasks are only for the VSCode launch configs - // or are supporting tasks. { "label": "Build: EntraMfaPrefillinator.FunctionApp", "detail": "Build the EntraMfaPrefillinator.FunctionApp project.", @@ -194,6 +192,89 @@ "showReuseMessage": true, "clear": true } + }, + // Remaining tasks are only for the VSCode launch configs + // or are supporting tasks. + { + "label": "clean (functions)", + "hide": true, + "command": "dotnet", + "args": [ + "clean", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "type": "process", + "problemMatcher": "$msCompile", + "options": { + "cwd": "${workspaceFolder}/src/FunctionApp" + } + }, + { + "label": "build (functions)", + "hide": true, + "command": "dotnet", + "args": [ + "build", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "type": "process", + "dependsOn": "clean (functions)", + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": "$msCompile", + "options": { + "cwd": "${workspaceFolder}/src/FunctionApp" + } + }, + { + "label": "clean release (functions)", + "hide": true, + "command": "dotnet", + "args": [ + "clean", + "--configuration", + "Release", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "type": "process", + "problemMatcher": "$msCompile", + "options": { + "cwd": "${workspaceFolder}/src/FunctionApp" + } + }, + { + "label": "publish (functions)", + "hide": true, + "command": "dotnet", + "args": [ + "publish", + "--configuration", + "Release", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "type": "process", + "dependsOn": "clean release (functions)", + "problemMatcher": "$msCompile", + "options": { + "cwd": "${workspaceFolder}/src/FunctionApp" + } + }, + { + "hide": true, + "type": "func", + "dependsOn": "build (functions)", + "options": { + "cwd": "${workspaceFolder}/src/FunctionApp/bin/Debug/net8.0" + }, + "command": "host start", + "isBackground": true, + "problemMatcher": "$func-dotnet-watch" } ], "inputs": [ From 8ad0a162d29ea81611b7f0e7bf2a55651d44c988 Mon Sep 17 00:00:00 2001 From: Timothy Small Date: Fri, 12 Jan 2024 10:35:59 -0500 Subject: [PATCH 16/18] Add EntraMfaPrefillinator.Tools.CsvImporter to Dependabot --- .github/dependabot.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index eee8bb5..64ccfc6 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -21,6 +21,18 @@ updates: assignees: - "Smalls1652" + # NuGet config for 'EntraMfaPrefillinator.Tools.CsvImporter' + - package-ecosystem: "nuget" + directory: "/src/Tools/CsvImporter" + target-branch: "main" + ignore: + - dependency-name: "Azure.Storage.Queues" + - dependency-name: "Microsoft.Identity.Client" + schedule: + interval: "daily" + assignees: + - "Smalls1652" + # GitHub Actions config for the repo - package-ecosystem: "github-actions" directory: "/" From 8de33632c4675bdf51814038c0b0f9818d9bee34 Mon Sep 17 00:00:00 2001 From: Timothy Small Date: Fri, 12 Jan 2024 10:37:29 -0500 Subject: [PATCH 17/18] Add CsvImporter to build workflow --- .github/workflows/build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2c14cc4..4ab782b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,8 @@ jobs: matrix: projectPath: [ "./src/FunctionApp/", - "./src/Lib/" + "./src/Lib/", + "./src/Tools/CsvImporter/" ] env: DOTNET_NOLOGO: true From 4cf4dacbc57f231eb51ff383040a1c8ff031dec1 Mon Sep 17 00:00:00 2001 From: Timothy Small Date: Fri, 12 Jan 2024 10:58:48 -0500 Subject: [PATCH 18/18] Add workflow for creating an artifact of the CsvImporter tool --- .../csvimporter-create-artifacts.yml | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/csvimporter-create-artifacts.yml diff --git a/.github/workflows/csvimporter-create-artifacts.yml b/.github/workflows/csvimporter-create-artifacts.yml new file mode 100644 index 0000000..8de1e5e --- /dev/null +++ b/.github/workflows/csvimporter-create-artifacts.yml @@ -0,0 +1,49 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow +name: CsvImporter / Create artifacts +on: + workflow_dispatch: + +permissions: + packages: read + +jobs: + create-artifacts: + name: Create artifacts + runs-on: [ ubuntu-latest, windows-latest ] + env: + DOTNET_NOLOGO: true + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Install .NET tools + run: dotnet tool restore + + - name: Update project files with GitVersion + run: dotnet tool run dotnet-gitversion /updateprojectfiles + + - name: Compile project (Windows) + if: ${{ runner.os == 'Windows' }} + run: | + dotnet restore ./src/Tools/CsvImporter/ + dotnet publish ./src/Tools/CsvImporter/ --configuration "Release" --runtime "win-x64" --output "../../../artifacts/CsvImporter" + + - name: Compile project (Linux) + if: ${{ runner.os == 'Linux' }} + run: | + dotnet restore ./src/Tools/CsvImporter/ + dotnet publish ./src/Tools/CsvImporter/ --configuration "Release" --runtime "linux-x64" --output "../../../artifacts/CsvImporter" + + - name: Create artifact + uses: actions/upload-artifact@v4 + with: + name: "CsvImporter_${{ runner.os }}_${{ github.sha }}" + path: artifacts/CsvImporter \ No newline at end of file