From 35eb8593e2b8aad30622287e4151aee198c46af4 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 9 Nov 2025 10:43:17 +0100 Subject: [PATCH 01/26] Add graph implementation --- .editorconfig | 98 +++++++ ....Identity.AppRegistrationSecretWatcher.sln | 63 +++++ Directory.Build.props | 56 ++++ Directory.Packages.props | 25 ++ global.json | 6 + src/Core/Core.csproj | 7 + src/Core/Emailing/EmailMessage.cs | 15 + src/Core/Emailing/IEmailProvider.cs | 13 + src/Core/EntraId/EntraIdApplication.cs | 26 ++ .../EntraIdApplicationPasswordCredential.cs | 21 ++ .../EntraId/GraphEntraIdApplicationClient.cs | 39 +++ .../GraphEntraIdApplicationClientOptions.cs | 15 + src/Core/EntraId/GraphServiceClientFactory.cs | 39 +++ src/Core/EntraId/IEntraIdApplicationClient.cs | 13 + .../EntraId/IGraphServiceClientFactory.cs | 15 + src/Directory.Build.props | 20 ++ src/Functions/.gitignore | 264 ++++++++++++++++++ src/Functions/Function1.cs | 26 ++ src/Functions/Functions.csproj | 22 ++ src/Functions/Program.cs | 14 + src/Functions/Properties/launchSettings.json | 9 + .../Properties/serviceDependencies.json | 11 + .../Properties/serviceDependencies.local.json | 11 + src/Functions/host.json | 12 + stylecop.json | 9 + tests/Core.Tests/Core.Tests.csproj | 5 + ...ntraIdApplicationPasswordCredentialTest.cs | 20 ++ .../EntraId/EntraIdApplicationTest.cs | 27 ++ ...raphEntraIdApplicationClientOptionsTest.cs | 40 +++ .../GraphEntraIdApplicationClientTest.cs | 179 ++++++++++++ tests/Directory.Build.props | 40 +++ 31 files changed, 1160 insertions(+) create mode 100644 .editorconfig create mode 100644 Azure.Identity.AppRegistrationSecretWatcher.sln create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props create mode 100644 global.json create mode 100644 src/Core/Core.csproj create mode 100644 src/Core/Emailing/EmailMessage.cs create mode 100644 src/Core/Emailing/IEmailProvider.cs create mode 100644 src/Core/EntraId/EntraIdApplication.cs create mode 100644 src/Core/EntraId/EntraIdApplicationPasswordCredential.cs create mode 100644 src/Core/EntraId/GraphEntraIdApplicationClient.cs create mode 100644 src/Core/EntraId/GraphEntraIdApplicationClientOptions.cs create mode 100644 src/Core/EntraId/GraphServiceClientFactory.cs create mode 100644 src/Core/EntraId/IEntraIdApplicationClient.cs create mode 100644 src/Core/EntraId/IGraphServiceClientFactory.cs create mode 100644 src/Directory.Build.props create mode 100644 src/Functions/.gitignore create mode 100644 src/Functions/Function1.cs create mode 100644 src/Functions/Functions.csproj create mode 100644 src/Functions/Program.cs create mode 100644 src/Functions/Properties/launchSettings.json create mode 100644 src/Functions/Properties/serviceDependencies.json create mode 100644 src/Functions/Properties/serviceDependencies.local.json create mode 100644 src/Functions/host.json create mode 100644 stylecop.json create mode 100644 tests/Core.Tests/Core.Tests.csproj create mode 100644 tests/Core.Tests/EntraId/EntraIdApplicationPasswordCredentialTest.cs create mode 100644 tests/Core.Tests/EntraId/EntraIdApplicationTest.cs create mode 100644 tests/Core.Tests/EntraId/GraphEntraIdApplicationClientOptionsTest.cs create mode 100644 tests/Core.Tests/EntraId/GraphEntraIdApplicationClientTest.cs create mode 100644 tests/Directory.Build.props diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9f7ed95 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,98 @@ +root = true + +[*] +charset = utf-8-bom +insert_final_newline = true +trim_trailing_whitespace = true + +# Markdown specific settings +[*.md] +indent_style = space +indent_size = 2 + +# YAML specific settings +[*.yaml] +indent_style = space +indent_size = 2 + +# x.proj specific settings +[*.{csproj,props}] +indent_style = space +indent_size = 2 + +[*.{cs,vb}] + +#### Naming styles #### +tab_width = 4 +indent_size = 4 +end_of_line = crlf + +csharp_style_prefer_primary_constructors = false:suggestion +csharp_using_directive_placement = inside_namespace:warning + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Naming styles + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_namespace_match_folder = false:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion + +### Visual Studio ### + +# IDE0005: Using directive is unnecessary. +dotnet_diagnostic.IDE0005.severity = warning + +# IDE0130: Namespace does not match folder structure +dotnet_diagnostic.IDE0130.severity = none + +### StyleCop ### + +# SA1600: Elements should be documented +dotnet_diagnostic.SA1600.severity = none diff --git a/Azure.Identity.AppRegistrationSecretWatcher.sln b/Azure.Identity.AppRegistrationSecretWatcher.sln new file mode 100644 index 0000000..1b27ffc --- /dev/null +++ b/Azure.Identity.AppRegistrationSecretWatcher.sln @@ -0,0 +1,63 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36616.10 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + .gitignore = .gitignore + Directory.Build.props = Directory.Build.props + Directory.Packages.props = Directory.Packages.props + global.json = global.json + LICENSE = LICENSE + README.md = README.md + stylecop.json = stylecop.json + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Functions", "src\Functions\Functions.csproj", "{869008E3-EFD1-0A48-7FEC-C8E6AEA3A97A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core", "src\Core\Core.csproj", "{7BD9F2E1-AB8E-4AA5-93BA-9B92D27EC064}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" + ProjectSection(SolutionItems) = preProject + src\Directory.Build.props = src\Directory.Build.props + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.Tests", "tests\Core.Tests\Core.Tests.csproj", "{C7D85722-68AB-4E9A-8E47-7A71B3387060}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{143067B7-D5C1-4471-BB0D-F31626A116A7}" + ProjectSection(SolutionItems) = preProject + tests\Directory.Build.props = tests\Directory.Build.props + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {869008E3-EFD1-0A48-7FEC-C8E6AEA3A97A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {869008E3-EFD1-0A48-7FEC-C8E6AEA3A97A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {869008E3-EFD1-0A48-7FEC-C8E6AEA3A97A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {869008E3-EFD1-0A48-7FEC-C8E6AEA3A97A}.Release|Any CPU.Build.0 = Release|Any CPU + {7BD9F2E1-AB8E-4AA5-93BA-9B92D27EC064}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7BD9F2E1-AB8E-4AA5-93BA-9B92D27EC064}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7BD9F2E1-AB8E-4AA5-93BA-9B92D27EC064}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7BD9F2E1-AB8E-4AA5-93BA-9B92D27EC064}.Release|Any CPU.Build.0 = Release|Any CPU + {C7D85722-68AB-4E9A-8E47-7A71B3387060}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C7D85722-68AB-4E9A-8E47-7A71B3387060}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C7D85722-68AB-4E9A-8E47-7A71B3387060}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C7D85722-68AB-4E9A-8E47-7A71B3387060}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {8EC462FD-D22E-90A8-E5CE-7E832BA40C5D} + {143067B7-D5C1-4471-BB0D-F31626A116A7} = {8EC462FD-D22E-90A8-E5CE-7E832BA40C5D} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {8EEC6F15-42CF-4ADA-A10D-0EC8CD2BF450} + EndGlobalSection +EndGlobal diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..268564d --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,56 @@ + + + + + Gilles TOURREAU + P.O.S Informatique + P.O.S Informatique + Copyright (c) P.O.S Informatique. All rights reserved. + https://github.com/PosInformatique/PosInformatique.Azure.Identity.AppRegistrationSecretWatcher + git + + + latest + + + enable + + + $(NoWarn);SA0001 + + + false + + + true + + + PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.$(MSBuildProjectName) + PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.$(MSBuildProjectName.Replace(" ", "_")) + + + + + stylecop.json + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..998281f --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,25 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..9d34b15 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "9.0.305", + "rollForward": "latestFeature" + } +} diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj new file mode 100644 index 0000000..d6bacdb --- /dev/null +++ b/src/Core/Core.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/Core/Emailing/EmailMessage.cs b/src/Core/Emailing/EmailMessage.cs new file mode 100644 index 0000000..5410821 --- /dev/null +++ b/src/Core/Emailing/EmailMessage.cs @@ -0,0 +1,15 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing +{ + public class EmailMessage + { + public EmailMessage() + { + } + } +} diff --git a/src/Core/Emailing/IEmailProvider.cs b/src/Core/Emailing/IEmailProvider.cs new file mode 100644 index 0000000..68a7bc1 --- /dev/null +++ b/src/Core/Emailing/IEmailProvider.cs @@ -0,0 +1,13 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing +{ + public interface IEmailProvider + { + Task SendAsync(EmailMessage message, CancellationToken cancellationToken); + } +} diff --git a/src/Core/EntraId/EntraIdApplication.cs b/src/Core/EntraId/EntraIdApplication.cs new file mode 100644 index 0000000..3d559ce --- /dev/null +++ b/src/Core/EntraId/EntraIdApplication.cs @@ -0,0 +1,26 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId +{ + using System.Collections.ObjectModel; + + public class EntraIdApplication + { + public EntraIdApplication(string id, string displayName, IReadOnlyList passwordCredentials) + { + this.Id = id; + this.DisplayName = displayName; + this.PasswordCredentials = new ReadOnlyCollection(passwordCredentials.ToArray()); + } + + public string Id { get; } + + public string DisplayName { get; } + + public ReadOnlyCollection PasswordCredentials { get; } + } +} diff --git a/src/Core/EntraId/EntraIdApplicationPasswordCredential.cs b/src/Core/EntraId/EntraIdApplicationPasswordCredential.cs new file mode 100644 index 0000000..b5ec7e1 --- /dev/null +++ b/src/Core/EntraId/EntraIdApplicationPasswordCredential.cs @@ -0,0 +1,21 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId +{ + public class EntraIdApplicationPasswordCredential + { + public EntraIdApplicationPasswordCredential(string displayName, DateTimeOffset endDateTime) + { + this.DisplayName = displayName; + this.EndDateTime = endDateTime; + } + + public string DisplayName { get; } + + public DateTimeOffset EndDateTime { get; } + } +} diff --git a/src/Core/EntraId/GraphEntraIdApplicationClient.cs b/src/Core/EntraId/GraphEntraIdApplicationClient.cs new file mode 100644 index 0000000..24187d5 --- /dev/null +++ b/src/Core/EntraId/GraphEntraIdApplicationClient.cs @@ -0,0 +1,39 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId +{ + public class GraphEntraIdApplicationClient : IEntraIdApplicationClient + { + private readonly IGraphServiceClientFactory graphServiceClientFactory; + + public GraphEntraIdApplicationClient(IGraphServiceClientFactory graphServiceClientFactory) + { + this.graphServiceClientFactory = graphServiceClientFactory; + } + + public async Task> GetAsync(string tenantId, CancellationToken cancellationToken = default) + { + using var graphClient = this.graphServiceClientFactory.Create(tenantId); + + var applications = await graphClient.Applications.GetAsync( + request => + { + request.QueryParameters.Select = ["appId", "displayName", "passwordCredentials"]; + }, + cancellationToken); + + if (applications is null || applications.Value is null) + { + throw new InvalidOperationException($"Unable to retrieve the list of the app registrations for the tenant '{tenantId}'."); + } + + return applications.Value + .Select(app => new EntraIdApplication(app.AppId!, app.DisplayName!, app.PasswordCredentials!.Select(pc => new EntraIdApplicationPasswordCredential(pc.DisplayName!, pc.EndDateTime!.Value)).ToArray())) + .ToArray(); + } + } +} diff --git a/src/Core/EntraId/GraphEntraIdApplicationClientOptions.cs b/src/Core/EntraId/GraphEntraIdApplicationClientOptions.cs new file mode 100644 index 0000000..c9e417d --- /dev/null +++ b/src/Core/EntraId/GraphEntraIdApplicationClientOptions.cs @@ -0,0 +1,15 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId +{ + public class GraphEntraIdApplicationClientOptions + { + public string? ClientId { get; set; } + + public string? ClientSecret { get; set; } + } +} diff --git a/src/Core/EntraId/GraphServiceClientFactory.cs b/src/Core/EntraId/GraphServiceClientFactory.cs new file mode 100644 index 0000000..7bac7f8 --- /dev/null +++ b/src/Core/EntraId/GraphServiceClientFactory.cs @@ -0,0 +1,39 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId +{ + using global::Azure.Core; + using global::Azure.Identity; + using Microsoft.Extensions.Options; + using Microsoft.Graph; + + public class GraphServiceClientFactory : IGraphServiceClientFactory + { + private readonly GraphEntraIdApplicationClientOptions options; + + public GraphServiceClientFactory(IOptions options) + { + this.options = options.Value; + } + + public GraphServiceClient Create(string tenantId) + { + TokenCredential credential; + + if (this.options.ClientId is null) + { + credential = new ManagedIdentityCredential(); + } + else + { + credential = new ClientSecretCredential(tenantId, this.options.ClientId, this.options.ClientSecret); + } + + return new GraphServiceClient(credential); + } + } +} diff --git a/src/Core/EntraId/IEntraIdApplicationClient.cs b/src/Core/EntraId/IEntraIdApplicationClient.cs new file mode 100644 index 0000000..a4e7a6e --- /dev/null +++ b/src/Core/EntraId/IEntraIdApplicationClient.cs @@ -0,0 +1,13 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId +{ + public interface IEntraIdApplicationClient + { + Task> GetAsync(string tenantId, CancellationToken cancellationToken = default); + } +} diff --git a/src/Core/EntraId/IGraphServiceClientFactory.cs b/src/Core/EntraId/IGraphServiceClientFactory.cs new file mode 100644 index 0000000..2b24649 --- /dev/null +++ b/src/Core/EntraId/IGraphServiceClientFactory.cs @@ -0,0 +1,15 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId +{ + using Microsoft.Graph; + + public interface IGraphServiceClientFactory + { + GraphServiceClient Create(string tenantId); + } +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..a67878d --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,20 @@ + + + + + + + net9.0 + enable + + + + + + <_Parameter1>$(AssemblyName).Tests + + + <_Parameter1>DynamicProxyGenAssembly2 + + + diff --git a/src/Functions/.gitignore b/src/Functions/.gitignore new file mode 100644 index 0000000..ff5b00c --- /dev/null +++ b/src/Functions/.gitignore @@ -0,0 +1,264 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# Azure Functions localsettings file +local.settings.json + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +project.fragment.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +#*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc \ No newline at end of file diff --git a/src/Functions/Function1.cs b/src/Functions/Function1.cs new file mode 100644 index 0000000..657cd34 --- /dev/null +++ b/src/Functions/Function1.cs @@ -0,0 +1,26 @@ +using System; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; + +namespace Azure.Identity.AppRegistrationSecretWatcher; + +public class Function1 +{ + private readonly ILogger _logger; + + public Function1(ILoggerFactory loggerFactory) + { + _logger = loggerFactory.CreateLogger(); + } + + [Function("Function1")] + public void Run([TimerTrigger("0 */5 * * * *")] TimerInfo myTimer) + { + _logger.LogInformation("C# Timer trigger function executed at: {executionTime}", DateTime.Now); + + if (myTimer.ScheduleStatus is not null) + { + _logger.LogInformation("Next timer schedule at: {nextSchedule}", myTimer.ScheduleStatus.Next); + } + } +} \ No newline at end of file diff --git a/src/Functions/Functions.csproj b/src/Functions/Functions.csproj new file mode 100644 index 0000000..772275c --- /dev/null +++ b/src/Functions/Functions.csproj @@ -0,0 +1,22 @@ + + + + v4 + Exe + + + + + + + + + + + + + + + + + diff --git a/src/Functions/Program.cs b/src/Functions/Program.cs new file mode 100644 index 0000000..704475c --- /dev/null +++ b/src/Functions/Program.cs @@ -0,0 +1,14 @@ +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +var builder = FunctionsApplication.CreateBuilder(args); + +builder.ConfigureFunctionsWebApplication(); + +builder.Services + .AddApplicationInsightsTelemetryWorkerService() + .ConfigureFunctionsApplicationInsights(); + +builder.Build().Run(); diff --git a/src/Functions/Properties/launchSettings.json b/src/Functions/Properties/launchSettings.json new file mode 100644 index 0000000..5aab1ee --- /dev/null +++ b/src/Functions/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "profiles": { + "Azure.Identity.AppRegistrationSecretWatcher": { + "commandName": "Project", + "commandLineArgs": "--port 7232", + "launchBrowser": false + } + } +} \ No newline at end of file diff --git a/src/Functions/Properties/serviceDependencies.json b/src/Functions/Properties/serviceDependencies.json new file mode 100644 index 0000000..df4dcc9 --- /dev/null +++ b/src/Functions/Properties/serviceDependencies.json @@ -0,0 +1,11 @@ +{ + "dependencies": { + "appInsights1": { + "type": "appInsights" + }, + "storage1": { + "type": "storage", + "connectionId": "AzureWebJobsStorage" + } + } +} \ No newline at end of file diff --git a/src/Functions/Properties/serviceDependencies.local.json b/src/Functions/Properties/serviceDependencies.local.json new file mode 100644 index 0000000..b804a28 --- /dev/null +++ b/src/Functions/Properties/serviceDependencies.local.json @@ -0,0 +1,11 @@ +{ + "dependencies": { + "appInsights1": { + "type": "appInsights.sdk" + }, + "storage1": { + "type": "storage.emulator", + "connectionId": "AzureWebJobsStorage" + } + } +} \ No newline at end of file diff --git a/src/Functions/host.json b/src/Functions/host.json new file mode 100644 index 0000000..ee5cf5f --- /dev/null +++ b/src/Functions/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + }, + "enableLiveMetricsFilters": true + } + } +} \ No newline at end of file diff --git a/stylecop.json b/stylecop.json new file mode 100644 index 0000000..4007702 --- /dev/null +++ b/stylecop.json @@ -0,0 +1,9 @@ +{ + "settings": { + "documentationRules": { + "companyName": "P.O.S Informatique", + "copyrightText": "Copyright (c) {companyName}. All rights reserved.", + "documentInternalElements": false + } + } +} diff --git a/tests/Core.Tests/Core.Tests.csproj b/tests/Core.Tests/Core.Tests.csproj new file mode 100644 index 0000000..71ceed6 --- /dev/null +++ b/tests/Core.Tests/Core.Tests.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/Core.Tests/EntraId/EntraIdApplicationPasswordCredentialTest.cs b/tests/Core.Tests/EntraId/EntraIdApplicationPasswordCredentialTest.cs new file mode 100644 index 0000000..5682a85 --- /dev/null +++ b/tests/Core.Tests/EntraId/EntraIdApplicationPasswordCredentialTest.cs @@ -0,0 +1,20 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId.Tests +{ + public class EntraIdApplicationPasswordCredentialTest + { + [Fact] + public void Constructor() + { + var credential = new EntraIdApplicationPasswordCredential("The display name", new DateTimeOffset(2021, 1, 2, 3, 4, 5, 6, TimeSpan.FromHours(1))); + + credential.DisplayName.Should().Be("The display name"); + credential.EndDateTime.Should().Be(new DateTimeOffset(2021, 1, 2, 3, 4, 5, 6, TimeSpan.FromHours(1))); + } + } +} diff --git a/tests/Core.Tests/EntraId/EntraIdApplicationTest.cs b/tests/Core.Tests/EntraId/EntraIdApplicationTest.cs new file mode 100644 index 0000000..c19c911 --- /dev/null +++ b/tests/Core.Tests/EntraId/EntraIdApplicationTest.cs @@ -0,0 +1,27 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId.Tests +{ + public class EntraIdApplicationTest + { + [Fact] + public void Constructor() + { + var passwordCredentials = new[] + { + new EntraIdApplicationPasswordCredential(default, default), + new EntraIdApplicationPasswordCredential(default, default), + }; + + var application = new EntraIdApplication("The id", "The display name", passwordCredentials); + + application.DisplayName.Should().Be("The display name"); + application.Id.Should().Be("The id"); + application.PasswordCredentials.Should().Equal(passwordCredentials); + } + } +} diff --git a/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientOptionsTest.cs b/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientOptionsTest.cs new file mode 100644 index 0000000..d9ef48b --- /dev/null +++ b/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientOptionsTest.cs @@ -0,0 +1,40 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId.Tests +{ + public class GraphEntraIdApplicationClientOptionsTest + { + [Fact] + public void Constructor() + { + var options = new GraphEntraIdApplicationClientOptions(); + + options.ClientId.Should().BeNull(); + options.ClientSecret.Should().BeNull(); + } + + [Fact] + public void ClientId_ValueChanged() + { + var options = new GraphEntraIdApplicationClientOptions(); + + options.ClientId = "The client ID"; + + options.ClientId.Should().Be("The client ID"); + } + + [Fact] + public void ClientSecret_ValueChanged() + { + var options = new GraphEntraIdApplicationClientOptions(); + + options.ClientSecret = "The client secret"; + + options.ClientSecret.Should().Be("The client secret"); + } + } +} diff --git a/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientTest.cs b/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientTest.cs new file mode 100644 index 0000000..178eab9 --- /dev/null +++ b/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientTest.cs @@ -0,0 +1,179 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId.Tests +{ + using Microsoft.Graph; + using Microsoft.Graph.Models; + using Microsoft.Kiota.Abstractions; + using Microsoft.Kiota.Abstractions.Serialization; + + public class GraphEntraIdApplicationClientTest + { + [Fact] + public async Task GetAsync() + { + var cancellationToken = new CancellationTokenSource().Token; + + var requestAdapter = new Mock(MockBehavior.Strict); + requestAdapter.Setup(r => r.BaseUrl) + .Returns("http://base/url"); + requestAdapter.Setup(r => r.EnableBackingStore(null)); + requestAdapter.Setup(r => r.SendAsync(It.IsAny(), It.IsAny>(), It.IsNotNull>>(), cancellationToken)) + .Callback((RequestInformation requestInfo, ParsableFactory factory, Dictionary> _, CancellationToken _) => + { + requestInfo.HttpMethod.Should().Be(Method.GET); + requestInfo.URI.Should().Be("http://base/url/applications?%24select=appId,displayName,passwordCredentials"); + + requestInfo.QueryParameters.Should().HaveCount(1); + requestInfo.QueryParameters["%24select"].As().Should().Equal("appId", "displayName", "passwordCredentials"); + + requestInfo.Content.Should().BeSameAs(Stream.Null); + }) + .ReturnsAsync(new ApplicationCollectionResponse() + { + Value = new List + { + new Application() + { + AppId = "App Id 1", + DisplayName = "Display name 1", + PasswordCredentials = new List + { + new PasswordCredential() + { + DisplayName = "Password 1", + EndDateTime = new DateTimeOffset(2025, 1, 2, 3, 4, 5, 6, 7, TimeSpan.FromHours(1)), + }, + new PasswordCredential() + { + DisplayName = "Password 2", + EndDateTime = new DateTimeOffset(2025, 1, 2, 3, 4, 5, 6, 7, TimeSpan.FromHours(2)), + }, + }, + }, + new Application() + { + AppId = "App Id 2", + DisplayName = "Display name 2", + PasswordCredentials = new List + { + }, + }, + }, + }); + + var graphServiceClient = new Mock(MockBehavior.Strict, requestAdapter.Object, null); + + var graphServiceClientFactory = new Mock(MockBehavior.Strict); + graphServiceClientFactory.Setup(gf => gf.Create("The tenant Id")) + .Returns(graphServiceClient.Object); + + var client = new GraphEntraIdApplicationClient(graphServiceClientFactory.Object); + + var result = await client.GetAsync("The tenant Id", cancellationToken); + + result.Should().HaveCount(2); + + result[0].DisplayName.Should().Be("Display name 1"); + result[0].Id.Should().Be("App Id 1"); + result[0].PasswordCredentials.Should().HaveCount(2); + result[0].PasswordCredentials[0].DisplayName.Should().Be("Password 1"); + result[0].PasswordCredentials[0].EndDateTime.Should().Be(new DateTimeOffset(2025, 1, 2, 3, 4, 5, 6, 7, TimeSpan.FromHours(1))); + result[0].PasswordCredentials[1].DisplayName.Should().Be("Password 2"); + result[0].PasswordCredentials[1].EndDateTime.Should().Be(new DateTimeOffset(2025, 1, 2, 3, 4, 5, 6, 7, TimeSpan.FromHours(2))); + + result[1].DisplayName.Should().Be("Display name 2"); + result[1].Id.Should().Be("App Id 2"); + result[1].PasswordCredentials.Should().BeEmpty(); + + graphServiceClient.VerifyAll(); + graphServiceClientFactory.VerifyAll(); + requestAdapter.VerifyAll(); + } + + [Fact] + public async Task GetAsync_ApplicationNull() + { + var cancellationToken = new CancellationTokenSource().Token; + + var requestAdapter = new Mock(MockBehavior.Strict); + requestAdapter.Setup(r => r.BaseUrl) + .Returns("http://base/url"); + requestAdapter.Setup(r => r.EnableBackingStore(null)); + requestAdapter.Setup(r => r.SendAsync(It.IsAny(), It.IsAny>(), It.IsNotNull>>(), cancellationToken)) + .Callback((RequestInformation requestInfo, ParsableFactory factory, Dictionary> _, CancellationToken _) => + { + requestInfo.HttpMethod.Should().Be(Method.GET); + requestInfo.URI.Should().Be("http://base/url/applications?%24select=appId,displayName,passwordCredentials"); + + requestInfo.QueryParameters.Should().HaveCount(1); + requestInfo.QueryParameters["%24select"].As().Should().Equal("appId", "displayName", "passwordCredentials"); + + requestInfo.Content.Should().BeSameAs(Stream.Null); + }) + .ReturnsAsync(new ApplicationCollectionResponse() + { + Value = null, + }); + + var graphServiceClient = new Mock(MockBehavior.Strict, requestAdapter.Object, null); + + var graphServiceClientFactory = new Mock(MockBehavior.Strict); + graphServiceClientFactory.Setup(gf => gf.Create("The tenant Id")) + .Returns(graphServiceClient.Object); + + var client = new GraphEntraIdApplicationClient(graphServiceClientFactory.Object); + + await client.Invoking(c => c.GetAsync("The tenant Id", cancellationToken)) + .Should().ThrowExactlyAsync() + .WithMessage("Unable to retrieve the list of the app registrations for the tenant 'The tenant Id'."); + + graphServiceClient.VerifyAll(); + graphServiceClientFactory.VerifyAll(); + requestAdapter.VerifyAll(); + } + + [Fact] + public async Task GetAsync_ResponseValueNull() + { + var cancellationToken = new CancellationTokenSource().Token; + + var requestAdapter = new Mock(MockBehavior.Strict); + requestAdapter.Setup(r => r.BaseUrl) + .Returns("http://base/url"); + requestAdapter.Setup(r => r.EnableBackingStore(null)); + requestAdapter.Setup(r => r.SendAsync(It.IsAny(), It.IsAny>(), It.IsNotNull>>(), cancellationToken)) + .Callback((RequestInformation requestInfo, ParsableFactory factory, Dictionary> _, CancellationToken _) => + { + requestInfo.HttpMethod.Should().Be(Method.GET); + requestInfo.URI.Should().Be("http://base/url/applications?%24select=appId,displayName,passwordCredentials"); + + requestInfo.QueryParameters.Should().HaveCount(1); + requestInfo.QueryParameters["%24select"].As().Should().Equal("appId", "displayName", "passwordCredentials"); + + requestInfo.Content.Should().BeSameAs(Stream.Null); + }) + .ReturnsAsync((ApplicationCollectionResponse)null); + + var graphServiceClient = new Mock(MockBehavior.Strict, requestAdapter.Object, null); + + var graphServiceClientFactory = new Mock(MockBehavior.Strict); + graphServiceClientFactory.Setup(gf => gf.Create("The tenant Id")) + .Returns(graphServiceClient.Object); + + var client = new GraphEntraIdApplicationClient(graphServiceClientFactory.Object); + + await client.Invoking(c => c.GetAsync("The tenant Id", cancellationToken)) + .Should().ThrowExactlyAsync() + .WithMessage("Unable to retrieve the list of the app registrations for the tenant 'The tenant Id'."); + + graphServiceClient.VerifyAll(); + graphServiceClientFactory.VerifyAll(); + requestAdapter.VerifyAll(); + } + } +} diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 0000000..0b956a7 --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,40 @@ + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + net9.0 + + + $(NoWarn);SA0001 + + + + + + + + + + From a3b540869ebd92ab220faeb47372c83cde6d7781 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Sun, 9 Nov 2025 11:13:34 +0100 Subject: [PATCH 02/26] Add graph implementation to send the emails. --- ....Identity.AppRegistrationSecretWatcher.sln | 1 + Directory.Packages.props | 1 + src/Core/Core.csproj | 1 + src/Core/Emailing/EmailContact.cs | 23 ++++++ src/Core/Emailing/EmailMessage.cs | 18 ++++- src/Core/Emailing/Graph/GraphEmailProvider.cs | 62 ++++++++++++++++ src/Core/Emailing/IEmailProvider.cs | 4 +- tests/.editorconfig | 10 +++ tests/Core.Tests/Emailing/EmailContactTest.cs | 24 +++++++ tests/Core.Tests/Emailing/EmailMessageTest.cs | 29 ++++++++ .../Emailing/Graph/GraphEmailProviderTest.cs | 71 +++++++++++++++++++ 11 files changed, 239 insertions(+), 5 deletions(-) create mode 100644 src/Core/Emailing/EmailContact.cs create mode 100644 src/Core/Emailing/Graph/GraphEmailProvider.cs create mode 100644 tests/.editorconfig create mode 100644 tests/Core.Tests/Emailing/EmailContactTest.cs create mode 100644 tests/Core.Tests/Emailing/EmailMessageTest.cs create mode 100644 tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs diff --git a/Azure.Identity.AppRegistrationSecretWatcher.sln b/Azure.Identity.AppRegistrationSecretWatcher.sln index 1b27ffc..903b773 100644 --- a/Azure.Identity.AppRegistrationSecretWatcher.sln +++ b/Azure.Identity.AppRegistrationSecretWatcher.sln @@ -28,6 +28,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.Tests", "tests\Core.Te EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{143067B7-D5C1-4471-BB0D-F31626A116A7}" ProjectSection(SolutionItems) = preProject + tests\.editorconfig = tests\.editorconfig tests\Directory.Build.props = tests\Directory.Build.props EndProjectSection EndProject diff --git a/Directory.Packages.props b/Directory.Packages.props index 998281f..75fb12b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,6 +16,7 @@ + diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index d6bacdb..83913d1 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -3,5 +3,6 @@ + diff --git a/src/Core/Emailing/EmailContact.cs b/src/Core/Emailing/EmailContact.cs new file mode 100644 index 0000000..3ba6553 --- /dev/null +++ b/src/Core/Emailing/EmailContact.cs @@ -0,0 +1,23 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing +{ + using PosInformatique.Foundations.EmailAddresses; + + public class EmailContact + { + public EmailContact(EmailAddress email, string displayName) + { + this.Email = email; + this.DisplayName = displayName; + } + + public EmailAddress Email { get; } + + public string DisplayName { get; } + } +} diff --git a/src/Core/Emailing/EmailMessage.cs b/src/Core/Emailing/EmailMessage.cs index 5410821..73e4b3f 100644 --- a/src/Core/Emailing/EmailMessage.cs +++ b/src/Core/Emailing/EmailMessage.cs @@ -4,12 +4,24 @@ // //----------------------------------------------------------------------- -namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing +namespace PosInformatique.Foundations.Emailing { - public class EmailMessage + public sealed class EmailMessage { - public EmailMessage() + public EmailMessage(EmailContact from, EmailContact to, string subject, string htmlContent) { + this.From = from; + this.To = to; + this.Subject = subject; + this.HtmlContent = htmlContent; } + + public EmailContact From { get; } + + public EmailContact To { get; } + + public string Subject { get; } + + public string HtmlContent { get; } } } diff --git a/src/Core/Emailing/Graph/GraphEmailProvider.cs b/src/Core/Emailing/Graph/GraphEmailProvider.cs new file mode 100644 index 0000000..0bfcf50 --- /dev/null +++ b/src/Core/Emailing/Graph/GraphEmailProvider.cs @@ -0,0 +1,62 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Graph +{ + using Microsoft.Graph; + using Microsoft.Graph.Me.SendMail; + using Microsoft.Graph.Models; + + public sealed class GraphEmailProvider : IEmailProvider + { + private readonly GraphServiceClient serviceClient; + + public GraphEmailProvider(GraphServiceClient serviceClient) + { + this.serviceClient = serviceClient; + } + + public async Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default) + { + var graphMessage = new Message() + { + Body = new ItemBody + { + ContentType = BodyType.Html, + Content = message.HtmlContent, + }, + From = new Recipient() + { + EmailAddress = new EmailAddress + { + Address = message.From.Email.ToString(), + Name = message.From.DisplayName, + }, + }, + Subject = message.Subject, + ToRecipients = new List + { + new() + { + EmailAddress = new EmailAddress + { + Address = message.To.Email.ToString(), + Name = message.To.DisplayName, + }, + }, + }, + }; + + var body = new SendMailPostRequestBody() + { + Message = graphMessage, + SaveToSentItems = false, + }; + + await this.serviceClient.Me.SendMail.PostAsync(body, cancellationToken: cancellationToken); + } + } +} diff --git a/src/Core/Emailing/IEmailProvider.cs b/src/Core/Emailing/IEmailProvider.cs index 68a7bc1..3e7baf3 100644 --- a/src/Core/Emailing/IEmailProvider.cs +++ b/src/Core/Emailing/IEmailProvider.cs @@ -4,10 +4,10 @@ // //----------------------------------------------------------------------- -namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing +namespace PosInformatique.Foundations.Emailing { public interface IEmailProvider { - Task SendAsync(EmailMessage message, CancellationToken cancellationToken); + Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default); } } diff --git a/tests/.editorconfig b/tests/.editorconfig new file mode 100644 index 0000000..7019821 --- /dev/null +++ b/tests/.editorconfig @@ -0,0 +1,10 @@ +[*.cs] + +#### Code Analysis #### + +# CA2016: Forward the 'CancellationToken' parameter to methods +dotnet_diagnostic.CA2016.severity = none + +#### Sonar Analyzers #### + +#### StyleCop #### diff --git a/tests/Core.Tests/Emailing/EmailContactTest.cs b/tests/Core.Tests/Emailing/EmailContactTest.cs new file mode 100644 index 0000000..400f44b --- /dev/null +++ b/tests/Core.Tests/Emailing/EmailContactTest.cs @@ -0,0 +1,24 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Tests +{ + using PosInformatique.Foundations.EmailAddresses; + + public class EmailContactTest + { + [Fact] + public void Constructor() + { + var emailAddress = EmailAddress.Parse("user@domain.com"); + + var contact = new EmailContact(emailAddress, "The display name"); + + contact.Email.Should().BeSameAs(emailAddress); + contact.DisplayName.Should().Be("The display name"); + } + } +} diff --git a/tests/Core.Tests/Emailing/EmailMessageTest.cs b/tests/Core.Tests/Emailing/EmailMessageTest.cs new file mode 100644 index 0000000..31a928c --- /dev/null +++ b/tests/Core.Tests/Emailing/EmailMessageTest.cs @@ -0,0 +1,29 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Tests +{ + public class EmailMessageTest + { + [Fact] + public void Constructor() + { + var from = new EmailContact(default, default); + var to = new EmailContact(default, default); + + var emailMessage = new EmailMessage( + from, + to, + "The subject", + "HTML content"); + + emailMessage.From.Should().Be(from); + emailMessage.HtmlContent.Should().Be("HTML content"); + emailMessage.Subject.Should().Be("The subject"); + emailMessage.To.Should().Be(to); + } + } +} diff --git a/tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs b/tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs new file mode 100644 index 0000000..93d1763 --- /dev/null +++ b/tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs @@ -0,0 +1,71 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Graph.Tests +{ + using Microsoft.Graph; + using Microsoft.Graph.Me.SendMail; + using Microsoft.Graph.Models; + using Microsoft.Kiota.Abstractions; + using Microsoft.Kiota.Abstractions.Serialization; + using Microsoft.Kiota.Serialization.Json; + + public class GraphEmailProviderTest + { + [Fact] + public async Task SendAsync() + { + var cancellationToken = new CancellationTokenSource().Token; + + var serializationWriterFactory = new Mock(MockBehavior.Strict); + serializationWriterFactory.Setup(f => f.GetSerializationWriter("application/json")) + .Returns(new JsonSerializationWriter()); + + var requestAdapter = new Mock(MockBehavior.Strict); + requestAdapter.Setup(r => r.BaseUrl) + .Returns("http://base/url"); + requestAdapter.Setup(r => r.EnableBackingStore(null)); + requestAdapter.Setup(r => r.SerializationWriterFactory) + .Returns(serializationWriterFactory.Object); + requestAdapter.Setup(r => r.SendNoContentAsync(It.IsAny(), It.IsNotNull>>(), cancellationToken)) + .Callback((RequestInformation requestInfo, Dictionary> _, CancellationToken _) => + { + requestInfo.HttpMethod.Should().Be(Method.POST); + requestInfo.URI.Should().Be("http://base/url/me/sendMail"); + + var jsonMessage = KiotaJsonSerializer.DeserializeAsync(requestInfo.Content).GetAwaiter().GetResult(); + + jsonMessage.Message.Attachments.Should().BeNull(); + jsonMessage.Message.Body.Content.Should().Be("The HTML content"); + jsonMessage.Message.Body.ContentType.Should().Be(BodyType.Html); + jsonMessage.Message.From.EmailAddress.Address.Should().Be("sender@domain.com"); + jsonMessage.Message.From.EmailAddress.Name.Should().Be("The sender"); + jsonMessage.Message.BccRecipients.Should().BeNull(); + jsonMessage.Message.CcRecipients.Should().BeNull(); + jsonMessage.Message.ToRecipients.Should().HaveCount(1); + jsonMessage.Message.ToRecipients[0].EmailAddress.Address.Should().Be("recipient@domain.com"); + jsonMessage.Message.ToRecipients[0].EmailAddress.Name.Should().Be("The recipient"); + jsonMessage.SaveToSentItems.Should().BeFalse(); + }) + .Returns(Task.CompletedTask); + + var graphServiceClient = new Mock(MockBehavior.Strict, requestAdapter.Object, null); + + var client = new GraphEmailProvider(graphServiceClient.Object); + + var from = new EmailContact(EmailAddresses.EmailAddress.Parse("sender@domain.com"), "The sender"); + var to = new EmailContact(EmailAddresses.EmailAddress.Parse("recipient@domain.com"), "The recipient"); + + var message = new EmailMessage(from, to, "The subject", "The HTML content"); + + await client.SendAsync(message, cancellationToken); + + graphServiceClient.VerifyAll(); + requestAdapter.VerifyAll(); + serializationWriterFactory.VerifyAll(); + } + } +} From 958d6fe7097e511311a21d4660e0cae0bd957ac9 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 10 Nov 2025 07:30:20 +0100 Subject: [PATCH 03/26] Add the check code. --- Directory.Build.props | 1 + .../AppRegistrationSecretCheckParameters.cs | 23 +++ src/Core/AppRegistrationSecretCheckResult.cs | 18 ++ ...egistrationSecretCheckResultApplication.cs | 21 +++ ...ationSecretCheckResultApplicationSecret.cs | 23 +++ .../AppRegistrationSecretCheckResultTenant.cs | 21 +++ src/Core/AppRegistrationSecretManager.cs | 142 +++++++++++++++ .../AppRegistrationSecretManagerOptions.cs | 22 +++ src/Core/Emailing/IEmailGenerator.cs | 13 ++ src/Core/EntraId/EntraIdApplication.cs | 2 - .../EntraIdApplicationPasswordCredential.cs | 6 +- .../EntraId/GraphEntraIdApplicationClient.cs | 2 +- src/Core/Guard.cs | 22 +++ src/Core/IAppRegistrationSecretManager.cs | 13 ++ tests/.editorconfig | 3 + ...ppRegistrationSecretCheckParametersTest.cs | 20 +++ ...nSecretCheckResultApplicationSecretTest.cs | 31 ++++ ...trationSecretCheckResultApplicationTest.cs | 26 +++ ...RegistrationSecretCheckResultTenantTest.cs | 26 +++ .../AppRegistrationSecretCheckResultTest.cs | 25 +++ ...AppRegistrationSecretManagerOptionsTest.cs | 34 ++++ .../AppRegistrationSecretManagerTest.cs | 164 ++++++++++++++++++ ...ntraIdApplicationPasswordCredentialTest.cs | 4 +- .../EntraId/EntraIdApplicationTest.cs | 4 +- .../GraphEntraIdApplicationClientTest.cs | 8 +- 25 files changed, 661 insertions(+), 13 deletions(-) create mode 100644 src/Core/AppRegistrationSecretCheckParameters.cs create mode 100644 src/Core/AppRegistrationSecretCheckResult.cs create mode 100644 src/Core/AppRegistrationSecretCheckResultApplication.cs create mode 100644 src/Core/AppRegistrationSecretCheckResultApplicationSecret.cs create mode 100644 src/Core/AppRegistrationSecretCheckResultTenant.cs create mode 100644 src/Core/AppRegistrationSecretManager.cs create mode 100644 src/Core/AppRegistrationSecretManagerOptions.cs create mode 100644 src/Core/Emailing/IEmailGenerator.cs create mode 100644 src/Core/Guard.cs create mode 100644 src/Core/IAppRegistrationSecretManager.cs create mode 100644 tests/Core.Tests/AppRegistrationSecretCheckParametersTest.cs create mode 100644 tests/Core.Tests/AppRegistrationSecretCheckResultApplicationSecretTest.cs create mode 100644 tests/Core.Tests/AppRegistrationSecretCheckResultApplicationTest.cs create mode 100644 tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs create mode 100644 tests/Core.Tests/AppRegistrationSecretCheckResultTest.cs create mode 100644 tests/Core.Tests/AppRegistrationSecretManagerOptionsTest.cs create mode 100644 tests/Core.Tests/AppRegistrationSecretManagerTest.cs diff --git a/Directory.Build.props b/Directory.Build.props index 268564d..ff43f6b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -50,6 +50,7 @@ + diff --git a/src/Core/AppRegistrationSecretCheckParameters.cs b/src/Core/AppRegistrationSecretCheckParameters.cs new file mode 100644 index 0000000..bccada7 --- /dev/null +++ b/src/Core/AppRegistrationSecretCheckParameters.cs @@ -0,0 +1,23 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher +{ + public class AppRegistrationSecretCheckParameters + { + public AppRegistrationSecretCheckParameters(DateTime expirationDateLimit) + { + Guard.IsUtc(expirationDateLimit, nameof(expirationDateLimit)); + + this.ExpirationDateLimit = expirationDateLimit; + this.TenantIds = new Collection(); + } + + public Collection TenantIds { get; } + + public DateTime ExpirationDateLimit { get; } + } +} diff --git a/src/Core/AppRegistrationSecretCheckResult.cs b/src/Core/AppRegistrationSecretCheckResult.cs new file mode 100644 index 0000000..59cc1a9 --- /dev/null +++ b/src/Core/AppRegistrationSecretCheckResult.cs @@ -0,0 +1,18 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher +{ + public class AppRegistrationSecretCheckResult + { + public AppRegistrationSecretCheckResult(IReadOnlyList tenants) + { + this.Tenants = new ReadOnlyCollection(tenants.ToArray()); + } + + public ReadOnlyCollection Tenants { get; } + } +} diff --git a/src/Core/AppRegistrationSecretCheckResultApplication.cs b/src/Core/AppRegistrationSecretCheckResultApplication.cs new file mode 100644 index 0000000..289de3c --- /dev/null +++ b/src/Core/AppRegistrationSecretCheckResultApplication.cs @@ -0,0 +1,21 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher +{ + public class AppRegistrationSecretCheckResultApplication + { + public AppRegistrationSecretCheckResultApplication(string displayName, IReadOnlyList secrets) + { + this.DisplayName = displayName; + this.Secrets = new ReadOnlyCollection(secrets.ToArray()); + } + + public string DisplayName { get; } + + public ReadOnlyCollection Secrets { get; } + } +} diff --git a/src/Core/AppRegistrationSecretCheckResultApplicationSecret.cs b/src/Core/AppRegistrationSecretCheckResultApplicationSecret.cs new file mode 100644 index 0000000..c5e4844 --- /dev/null +++ b/src/Core/AppRegistrationSecretCheckResultApplicationSecret.cs @@ -0,0 +1,23 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher +{ + public class AppRegistrationSecretCheckResultApplicationSecret + { + public AppRegistrationSecretCheckResultApplicationSecret(string displayName, DateTime endDate) + { + this.DisplayName = displayName; + this.EndDate = endDate; + } + + public string DisplayName { get; } + + public DateTime EndDate { get; } + + public bool Expired { get; set; } + } +} diff --git a/src/Core/AppRegistrationSecretCheckResultTenant.cs b/src/Core/AppRegistrationSecretCheckResultTenant.cs new file mode 100644 index 0000000..02b8bec --- /dev/null +++ b/src/Core/AppRegistrationSecretCheckResultTenant.cs @@ -0,0 +1,21 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher +{ + public class AppRegistrationSecretCheckResultTenant + { + public AppRegistrationSecretCheckResultTenant(string id, IReadOnlyCollection applications) + { + this.Id = id; + this.Applications = new ReadOnlyCollection(applications.ToArray()); + } + + public string Id { get; } + + public ReadOnlyCollection Applications { get; } + } +} diff --git a/src/Core/AppRegistrationSecretManager.cs b/src/Core/AppRegistrationSecretManager.cs new file mode 100644 index 0000000..b5c7559 --- /dev/null +++ b/src/Core/AppRegistrationSecretManager.cs @@ -0,0 +1,142 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher +{ + using Microsoft.Extensions.Options; + using PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing; + using PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId; + using PosInformatique.Foundations.Emailing; + + public class AppRegistrationSecretManager : IAppRegistrationSecretManager + { + private readonly IEntraIdApplicationClient entraIdApplicationClient; + + private readonly IEmailProvider emailProvider; + + private readonly IEmailGenerator emailGenerator; + + private readonly AppRegistrationSecretManagerOptions options; + + private readonly TimeProvider timeProvider; + + public AppRegistrationSecretManager(IEntraIdApplicationClient entraIdApplicationClient, IEmailProvider emailProvider, IEmailGenerator emailGenerator, TimeProvider timeProvider, IOptions options) + { + this.entraIdApplicationClient = entraIdApplicationClient; + this.emailProvider = emailProvider; + this.emailGenerator = emailGenerator; + this.timeProvider = timeProvider; + this.options = options.Value; + } + + public async Task CheckAsync(AppRegistrationSecretCheckParameters parameters, CancellationToken cancellationToken = default) + { + // Gets the applications + var applicationsByTenant = await this.GetApplicationsByTenantAsync(parameters, cancellationToken); + + // Check the result + var result = this.BuildResult(applicationsByTenant, parameters.ExpirationDateLimit); + + // Generate the e-mail + var emailContent = this.emailGenerator.Generate(result); + + // Send e-mail + await this.SendEmailAsync(emailContent, cancellationToken); + + return result; + } + + private async Task> GetApplicationsByTenantAsync(AppRegistrationSecretCheckParameters parameters, CancellationToken cancellationToken) + { + var applicationsTask = new List>>(parameters.TenantIds.Count); + + foreach (var tenantId in parameters.TenantIds) + { + applicationsTask.Add(this.entraIdApplicationClient.GetAsync(tenantId, cancellationToken)); + } + + await Task.WhenAll(applicationsTask); + + var result = new List(parameters.TenantIds.Count); + + for (int i = 0; i < parameters.TenantIds.Count; i++) + { + result.Add(new TenantApplications(parameters.TenantIds[i], applicationsTask[i].Result)); + } + + return result; + } + + private AppRegistrationSecretCheckResult BuildResult(IReadOnlyList tenants, DateTime expirationDateLimit) + { + var tenantsResult = new List(tenants.Count); + + foreach (var tenant in tenants) + { + var tenantResult = new AppRegistrationSecretCheckResultTenant( + tenant.Id, + tenant.Applications + .Select(app => this.Build(app, expirationDateLimit)) + .OrderBy(app => app.DisplayName) + .ToArray()); + + tenantsResult.Add(tenantResult); + } + + return new AppRegistrationSecretCheckResult(tenantsResult); + } + + private AppRegistrationSecretCheckResultApplication Build(EntraIdApplication application, DateTime expirationDateLimit) + { + var secrets = application.PasswordCredentials + .OrderBy(pc => pc.DisplayName) + .Select(pc => this.Build(pc, expirationDateLimit)) + .ToArray(); + + return new AppRegistrationSecretCheckResultApplication(application.DisplayName, secrets); + } + + private AppRegistrationSecretCheckResultApplicationSecret Build(EntraIdApplicationPasswordCredential passwordCredential, DateTime expirationDateLimit) + { + var localEndDateTime = TimeZoneInfo.ConvertTimeFromUtc(passwordCredential.EndDateTime, this.timeProvider.LocalTimeZone); + localEndDateTime = DateTime.SpecifyKind(localEndDateTime, DateTimeKind.Local); + + var secret = new AppRegistrationSecretCheckResultApplicationSecret(passwordCredential.DisplayName, localEndDateTime); + + if (passwordCredential.EndDateTime <= expirationDateLimit) + { + secret.Expired = true; + } + + return secret; + } + + private async Task SendEmailAsync(string emailContent, CancellationToken cancellationToken) + { + var todayLocal = this.timeProvider.GetLocalNow(); + + foreach (var recipient in this.options.EmailRecipients) + { + var message = new EmailMessage(this.options.EmailSender, recipient, $"Reminder: App Registration secrets expiring soon - [{todayLocal:d}]", emailContent); + + await this.emailProvider.SendAsync(message, cancellationToken); + } + } + + private sealed class TenantApplications + { + public TenantApplications(string id, IReadOnlyList applications) + { + this.Applications = applications; + this.Id = id; + } + + public string Id { get; } + + public IReadOnlyList Applications { get; } + } + } +} diff --git a/src/Core/AppRegistrationSecretManagerOptions.cs b/src/Core/AppRegistrationSecretManagerOptions.cs new file mode 100644 index 0000000..b364b4d --- /dev/null +++ b/src/Core/AppRegistrationSecretManagerOptions.cs @@ -0,0 +1,22 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher +{ + using PosInformatique.Foundations.Emailing; + + public class AppRegistrationSecretManagerOptions + { + public AppRegistrationSecretManagerOptions() + { + this.EmailRecipients = new Collection(); + } + + public EmailContact EmailSender { get; set; } = default!; + + public Collection EmailRecipients { get; } + } +} diff --git a/src/Core/Emailing/IEmailGenerator.cs b/src/Core/Emailing/IEmailGenerator.cs new file mode 100644 index 0000000..cb5e612 --- /dev/null +++ b/src/Core/Emailing/IEmailGenerator.cs @@ -0,0 +1,13 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing +{ + public interface IEmailGenerator + { + string Generate(AppRegistrationSecretCheckResult result); + } +} diff --git a/src/Core/EntraId/EntraIdApplication.cs b/src/Core/EntraId/EntraIdApplication.cs index 3d559ce..ab0fa20 100644 --- a/src/Core/EntraId/EntraIdApplication.cs +++ b/src/Core/EntraId/EntraIdApplication.cs @@ -6,8 +6,6 @@ namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId { - using System.Collections.ObjectModel; - public class EntraIdApplication { public EntraIdApplication(string id, string displayName, IReadOnlyList passwordCredentials) diff --git a/src/Core/EntraId/EntraIdApplicationPasswordCredential.cs b/src/Core/EntraId/EntraIdApplicationPasswordCredential.cs index b5ec7e1..875f830 100644 --- a/src/Core/EntraId/EntraIdApplicationPasswordCredential.cs +++ b/src/Core/EntraId/EntraIdApplicationPasswordCredential.cs @@ -8,14 +8,16 @@ namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId { public class EntraIdApplicationPasswordCredential { - public EntraIdApplicationPasswordCredential(string displayName, DateTimeOffset endDateTime) + public EntraIdApplicationPasswordCredential(string displayName, DateTime endDateTime) { + Guard.IsUtc(endDateTime, nameof(endDateTime)); + this.DisplayName = displayName; this.EndDateTime = endDateTime; } public string DisplayName { get; } - public DateTimeOffset EndDateTime { get; } + public DateTime EndDateTime { get; } } } diff --git a/src/Core/EntraId/GraphEntraIdApplicationClient.cs b/src/Core/EntraId/GraphEntraIdApplicationClient.cs index 24187d5..9c42c04 100644 --- a/src/Core/EntraId/GraphEntraIdApplicationClient.cs +++ b/src/Core/EntraId/GraphEntraIdApplicationClient.cs @@ -32,7 +32,7 @@ public async Task> GetAsync(string tenantId, C } return applications.Value - .Select(app => new EntraIdApplication(app.AppId!, app.DisplayName!, app.PasswordCredentials!.Select(pc => new EntraIdApplicationPasswordCredential(pc.DisplayName!, pc.EndDateTime!.Value)).ToArray())) + .Select(app => new EntraIdApplication(app.AppId!, app.DisplayName!, app.PasswordCredentials!.Select(pc => new EntraIdApplicationPasswordCredential(pc.DisplayName!, pc.EndDateTime!.Value.UtcDateTime)).ToArray())) .ToArray(); } } diff --git a/src/Core/Guard.cs b/src/Core/Guard.cs new file mode 100644 index 0000000..0f1f27f --- /dev/null +++ b/src/Core/Guard.cs @@ -0,0 +1,22 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique +{ + using System.Diagnostics; + + internal static class Guard + { + [DebuggerNonUserCode] + public static void IsUtc(DateTime dateTime, string paramName) + { + if (dateTime.Kind != DateTimeKind.Utc) + { + throw new ArgumentException("The argument must be an UTC date time.", paramName); + } + } + } +} diff --git a/src/Core/IAppRegistrationSecretManager.cs b/src/Core/IAppRegistrationSecretManager.cs new file mode 100644 index 0000000..c0f48f1 --- /dev/null +++ b/src/Core/IAppRegistrationSecretManager.cs @@ -0,0 +1,13 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher +{ + public interface IAppRegistrationSecretManager + { + Task CheckAsync(AppRegistrationSecretCheckParameters parameters, CancellationToken cancellationToken = default); + } +} diff --git a/tests/.editorconfig b/tests/.editorconfig index 7019821..7764d9a 100644 --- a/tests/.editorconfig +++ b/tests/.editorconfig @@ -7,4 +7,7 @@ dotnet_diagnostic.CA2016.severity = none #### Sonar Analyzers #### +# S6562: Always set the "DateTimeKind" when creating new "DateTime" instances +dotnet_diagnostic.S6562.severity = none + #### StyleCop #### diff --git a/tests/Core.Tests/AppRegistrationSecretCheckParametersTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckParametersTest.cs new file mode 100644 index 0000000..dc6459c --- /dev/null +++ b/tests/Core.Tests/AppRegistrationSecretCheckParametersTest.cs @@ -0,0 +1,20 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Tests +{ + public class AppRegistrationSecretCheckParametersTest + { + [Fact] + public void Constructor() + { + var parameters = new AppRegistrationSecretCheckParameters(new DateTime(2025, 1, 2, 3, 4, 5, 6, 7, DateTimeKind.Utc)); + + parameters.ExpirationDateLimit.Should().Be(new DateTime(2025, 1, 2, 3, 4, 5, 6, 7, DateTimeKind.Utc)); + parameters.TenantIds.Should().BeEmpty(); + } + } +} diff --git a/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationSecretTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationSecretTest.cs new file mode 100644 index 0000000..7ce75a8 --- /dev/null +++ b/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationSecretTest.cs @@ -0,0 +1,31 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Tests +{ + public class AppRegistrationSecretCheckResultApplicationSecretTest + { + [Fact] + public void Constructor() + { + var secret = new AppRegistrationSecretCheckResultApplicationSecret("The display name", new DateTime(2025, 1, 2, 3, 4, 5, 6, DateTimeKind.Utc)); + + secret.DisplayName.Should().Be("The display name"); + secret.EndDate.Should().Be(new DateTime(2025, 1, 2, 3, 4, 5, 6)).And.BeIn(DateTimeKind.Utc); + secret.Expired.Should().BeFalse(); + } + + [Fact] + public void Expired_ValueChanged() + { + var secret = new AppRegistrationSecretCheckResultApplicationSecret(default, default); + + secret.Expired = true; + + secret.Expired.Should().BeTrue(); + } + } +} diff --git a/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationTest.cs new file mode 100644 index 0000000..cb58718 --- /dev/null +++ b/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationTest.cs @@ -0,0 +1,26 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Tests +{ + public class AppRegistrationSecretCheckResultApplicationTest + { + [Fact] + public void Constructor() + { + var secrets = new[] + { + new AppRegistrationSecretCheckResultApplicationSecret(default, default), + new AppRegistrationSecretCheckResultApplicationSecret(default, default), + }; + + var application = new AppRegistrationSecretCheckResultApplication("The display name", secrets); + + application.DisplayName.Should().Be("The display name"); + application.Secrets.Should().Equal(secrets); + } + } +} diff --git a/tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs new file mode 100644 index 0000000..b3e2d26 --- /dev/null +++ b/tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs @@ -0,0 +1,26 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Tests +{ + public class AppRegistrationSecretCheckResultTenantTest + { + [Fact] + public void Constructor() + { + var applications = new[] + { + new AppRegistrationSecretCheckResultApplication(default, []), + new AppRegistrationSecretCheckResultApplication(default, []), + }; + + var tenant = new AppRegistrationSecretCheckResultTenant("The ID", applications); + + tenant.Applications.Should().Equal(applications); + tenant.Id.Should().Be("The ID"); + } + } +} diff --git a/tests/Core.Tests/AppRegistrationSecretCheckResultTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckResultTest.cs new file mode 100644 index 0000000..689cb52 --- /dev/null +++ b/tests/Core.Tests/AppRegistrationSecretCheckResultTest.cs @@ -0,0 +1,25 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Core.Tests +{ + public class AppRegistrationSecretCheckResultTest + { + [Fact] + public void Constructor() + { + var tenants = new[] + { + new AppRegistrationSecretCheckResultTenant(default, []), + new AppRegistrationSecretCheckResultTenant(default, []), + }; + + var tenant = new AppRegistrationSecretCheckResult(tenants); + + tenant.Tenants.Should().Equal(tenants); + } + } +} diff --git a/tests/Core.Tests/AppRegistrationSecretManagerOptionsTest.cs b/tests/Core.Tests/AppRegistrationSecretManagerOptionsTest.cs new file mode 100644 index 0000000..711c1c9 --- /dev/null +++ b/tests/Core.Tests/AppRegistrationSecretManagerOptionsTest.cs @@ -0,0 +1,34 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Tests +{ + using PosInformatique.Foundations.Emailing; + + public class AppRegistrationSecretManagerOptionsTest + { + [Fact] + public void Constructor() + { + var options = new AppRegistrationSecretManagerOptions(); + + options.EmailSender.Should().BeNull(); + options.EmailRecipients.Should().BeEmpty(); + } + + [Fact] + public void EmailSender_ValueChanged() + { + var options = new AppRegistrationSecretManagerOptions(); + + var sender = new EmailContact(default, default); + + options.EmailSender = sender; + + options.EmailSender.Should().Be(sender); + } + } +} diff --git a/tests/Core.Tests/AppRegistrationSecretManagerTest.cs b/tests/Core.Tests/AppRegistrationSecretManagerTest.cs new file mode 100644 index 0000000..3013f6b --- /dev/null +++ b/tests/Core.Tests/AppRegistrationSecretManagerTest.cs @@ -0,0 +1,164 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Core.Tests +{ + using System.Threading.Tasks; + using Microsoft.Extensions.Options; + using PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing; + using PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId; + using PosInformatique.Foundations.EmailAddresses; + using PosInformatique.Foundations.Emailing; + + public class AppRegistrationSecretManagerTest + { + [Fact] + public async Task CheckAsync() + { + var now = new DateTime(2025, 6, 15, 1, 2, 3, 4, 5, DateTimeKind.Utc); + + var cancellationToken = new CancellationTokenSource().Token; + + AppRegistrationSecretCheckResult expectedResult = null; + + var sender = new EmailContact(default, default); + + var entraIdClient = new Mock(MockBehavior.Strict); + entraIdClient.Setup(c => c.GetAsync("Tenant 1", cancellationToken)) + .ReturnsAsync( + [ + new EntraIdApplication( + "1-1", + "App 1-1", + [ + new EntraIdApplicationPasswordCredential("Secret 1-1-1", now.AddDays(60)), + new EntraIdApplicationPasswordCredential("Secret 1-1-2", now.AddDays(10)), + ]), + new EntraIdApplication( + "1-2", + "App 1-2", + [ + new EntraIdApplicationPasswordCredential("Secret 1-2-1", now.AddDays(30)), + new EntraIdApplicationPasswordCredential("Secret 1-2-2", now.AddDays(120)), + ]) + ]); + entraIdClient.Setup(c => c.GetAsync("Tenant 2", cancellationToken)) + .ReturnsAsync( + [ + new EntraIdApplication( + "2-1", + "App 2-1", + [ + new EntraIdApplicationPasswordCredential("Secret 2-1-1", now.AddDays(100)), + ]), + new EntraIdApplication( + "2-2", + "App 2-2", + [ + new EntraIdApplicationPasswordCredential("Secret 2-2-1", now.AddDays(300)), + ]) + ]); + + var emailProvider = new Mock(MockBehavior.Strict); + emailProvider.Setup(ep => ep.SendAsync(It.Is(m => m.To.Email.ToString() == "email1@domain.com"), cancellationToken)) + .Callback((EmailMessage m, CancellationToken _) => + { + m.HtmlContent.Should().Be("The content"); + m.From.Should().BeSameAs(sender); + m.Subject.Should().Be("Reminder: App Registration secrets expiring soon - [07/02/2020]"); + m.To.DisplayName.Should().Be("Email 1"); + }) + .Returns(Task.CompletedTask); + emailProvider.Setup(ep => ep.SendAsync(It.Is(m => m.To.Email.ToString() == "email2@domain.com"), cancellationToken)) + .Callback((EmailMessage m, CancellationToken _) => + { + m.HtmlContent.Should().Be("The content"); + m.From.Should().BeSameAs(sender); + m.Subject.Should().Be("Reminder: App Registration secrets expiring soon - [07/02/2020]"); + m.To.DisplayName.Should().Be("Email 2"); + }) + .Returns(Task.CompletedTask); + + var emailGenerator = new Mock(MockBehavior.Strict); + emailGenerator.Setup(g => g.Generate(It.IsAny())) + .Callback((AppRegistrationSecretCheckResult r) => + { + r.Tenants.Should().HaveCount(2); + + r.Tenants[0].Id.Should().Be("Tenant 1"); + r.Tenants[0].Applications.Should().HaveCount(2); + r.Tenants[0].Applications[0].DisplayName.Should().Be("App 1-1"); + r.Tenants[0].Applications[0].Secrets.Should().HaveCount(2); + r.Tenants[0].Applications[0].Secrets[0].DisplayName.Should().Be("Secret 1-1-1"); + r.Tenants[0].Applications[0].Secrets[0].EndDate.Should().Be(now.AddDays(60).AddHours(8)).And.BeIn(DateTimeKind.Local); + r.Tenants[0].Applications[0].Secrets[0].Expired.Should().BeFalse(); + r.Tenants[0].Applications[0].Secrets[1].DisplayName.Should().Be("Secret 1-1-2"); + r.Tenants[0].Applications[0].Secrets[1].EndDate.Should().Be(now.AddDays(10).AddHours(8)).And.BeIn(DateTimeKind.Local); + r.Tenants[0].Applications[0].Secrets[1].Expired.Should().BeTrue(); + r.Tenants[0].Applications[1].DisplayName.Should().Be("App 1-2"); + r.Tenants[0].Applications[1].Secrets.Should().HaveCount(2); + r.Tenants[0].Applications[1].Secrets[0].DisplayName.Should().Be("Secret 1-2-1"); + r.Tenants[0].Applications[1].Secrets[0].EndDate.Should().Be(now.AddDays(30).AddHours(8)).And.BeIn(DateTimeKind.Local); + r.Tenants[0].Applications[1].Secrets[0].Expired.Should().BeTrue(); + r.Tenants[0].Applications[1].Secrets[1].DisplayName.Should().Be("Secret 1-2-2"); + r.Tenants[0].Applications[1].Secrets[1].EndDate.Should().Be(now.AddDays(120).AddHours(8)).And.BeIn(DateTimeKind.Local); + r.Tenants[0].Applications[1].Secrets[1].Expired.Should().BeFalse(); + + r.Tenants[1].Id.Should().Be("Tenant 2"); + r.Tenants[1].Applications.Should().HaveCount(2); + r.Tenants[1].Applications[0].DisplayName.Should().Be("App 2-1"); + r.Tenants[1].Applications[0].Secrets.Should().HaveCount(1); + r.Tenants[1].Applications[0].Secrets[0].DisplayName.Should().Be("Secret 2-1-1"); + r.Tenants[1].Applications[0].Secrets[0].EndDate.Should().Be(now.AddDays(100).AddHours(8)).And.BeIn(DateTimeKind.Local); + r.Tenants[1].Applications[0].Secrets[0].Expired.Should().BeFalse(); + r.Tenants[1].Applications[1].DisplayName.Should().Be("App 2-2"); + r.Tenants[1].Applications[1].Secrets.Should().HaveCount(1); + r.Tenants[1].Applications[1].Secrets[0].DisplayName.Should().Be("Secret 2-2-1"); + r.Tenants[1].Applications[1].Secrets[0].EndDate.Should().Be(now.AddDays(300).AddHours(8)).And.BeIn(DateTimeKind.Local); + r.Tenants[1].Applications[1].Secrets[0].Expired.Should().BeFalse(); + + expectedResult = r; + }) + .Returns("The content"); + + var timeProvider = new Mock(MockBehavior.Strict); + timeProvider.Setup(tp => tp.GetUtcNow()) + .Returns(new DateTimeOffset(2020, 2, 7, 8, 9, 15, 10, 4, TimeSpan.Zero)); + timeProvider.Setup(tp => tp.LocalTimeZone) + .Returns(TimeZoneInfo.FindSystemTimeZoneById("Asia/Manila")); + + var options = Options.Create(new AppRegistrationSecretManagerOptions() + { + EmailRecipients = + { + new EmailContact(EmailAddress.Parse("email1@domain.com"), "Email 1"), + new EmailContact(EmailAddress.Parse("email2@domain.com"), "Email 2"), + }, + EmailSender = sender, + }); + + var parameters = new AppRegistrationSecretCheckParameters(now.AddDays(30)) + { + TenantIds = + { + "Tenant 1", + "Tenant 2", + }, + }; + + var manager = new AppRegistrationSecretManager(entraIdClient.Object, emailProvider.Object, emailGenerator.Object, timeProvider.Object, options); + + var result = await manager.CheckAsync(parameters, cancellationToken); + + result.Should().BeSameAs(expectedResult); + + emailGenerator.VerifyAll(); + emailProvider.VerifyAll(); + entraIdClient.VerifyAll(); + timeProvider.VerifyAll(); + } + } +} diff --git a/tests/Core.Tests/EntraId/EntraIdApplicationPasswordCredentialTest.cs b/tests/Core.Tests/EntraId/EntraIdApplicationPasswordCredentialTest.cs index 5682a85..b84b01e 100644 --- a/tests/Core.Tests/EntraId/EntraIdApplicationPasswordCredentialTest.cs +++ b/tests/Core.Tests/EntraId/EntraIdApplicationPasswordCredentialTest.cs @@ -11,10 +11,10 @@ public class EntraIdApplicationPasswordCredentialTest [Fact] public void Constructor() { - var credential = new EntraIdApplicationPasswordCredential("The display name", new DateTimeOffset(2021, 1, 2, 3, 4, 5, 6, TimeSpan.FromHours(1))); + var credential = new EntraIdApplicationPasswordCredential("The display name", new DateTime(2021, 1, 2, 3, 4, 5, 6, DateTimeKind.Utc)); credential.DisplayName.Should().Be("The display name"); - credential.EndDateTime.Should().Be(new DateTimeOffset(2021, 1, 2, 3, 4, 5, 6, TimeSpan.FromHours(1))); + credential.EndDateTime.Should().Be(new DateTime(2021, 1, 2, 3, 4, 5, 6)).And.BeIn(DateTimeKind.Utc); } } } diff --git a/tests/Core.Tests/EntraId/EntraIdApplicationTest.cs b/tests/Core.Tests/EntraId/EntraIdApplicationTest.cs index c19c911..7c5ef72 100644 --- a/tests/Core.Tests/EntraId/EntraIdApplicationTest.cs +++ b/tests/Core.Tests/EntraId/EntraIdApplicationTest.cs @@ -13,8 +13,8 @@ public void Constructor() { var passwordCredentials = new[] { - new EntraIdApplicationPasswordCredential(default, default), - new EntraIdApplicationPasswordCredential(default, default), + new EntraIdApplicationPasswordCredential(default, DateTime.UtcNow), + new EntraIdApplicationPasswordCredential(default, DateTime.UtcNow), }; var application = new EntraIdApplication("The id", "The display name", passwordCredentials); diff --git a/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientTest.cs b/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientTest.cs index 178eab9..2c6aa68 100644 --- a/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientTest.cs +++ b/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientTest.cs @@ -46,12 +46,12 @@ public async Task GetAsync() new PasswordCredential() { DisplayName = "Password 1", - EndDateTime = new DateTimeOffset(2025, 1, 2, 3, 4, 5, 6, 7, TimeSpan.FromHours(1)), + EndDateTime = new DateTimeOffset(2025, 1, 1, 2, 1, 1, 1, 1, TimeSpan.FromHours(1)), }, new PasswordCredential() { DisplayName = "Password 2", - EndDateTime = new DateTimeOffset(2025, 1, 2, 3, 4, 5, 6, 7, TimeSpan.FromHours(2)), + EndDateTime = new DateTimeOffset(2025, 2, 2, 4, 2, 2, 2, 2, TimeSpan.FromHours(2)), }, }, }, @@ -82,9 +82,9 @@ public async Task GetAsync() result[0].Id.Should().Be("App Id 1"); result[0].PasswordCredentials.Should().HaveCount(2); result[0].PasswordCredentials[0].DisplayName.Should().Be("Password 1"); - result[0].PasswordCredentials[0].EndDateTime.Should().Be(new DateTimeOffset(2025, 1, 2, 3, 4, 5, 6, 7, TimeSpan.FromHours(1))); + result[0].PasswordCredentials[0].EndDateTime.Should().Be(new DateTime(2025, 1, 1, 1, 1, 1, 1, 1)).And.BeIn(DateTimeKind.Utc); result[0].PasswordCredentials[1].DisplayName.Should().Be("Password 2"); - result[0].PasswordCredentials[1].EndDateTime.Should().Be(new DateTimeOffset(2025, 1, 2, 3, 4, 5, 6, 7, TimeSpan.FromHours(2))); + result[0].PasswordCredentials[1].EndDateTime.Should().Be(new DateTime(2025, 2, 2, 2, 2, 2, 2, 2)).And.BeIn(DateTimeKind.Utc); result[1].DisplayName.Should().Be("Display name 2"); result[1].Id.Should().Be("App Id 2"); From e4c1ab03e3430580f67ac95e88daf77d741c3b74 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 10 Nov 2025 07:31:39 +0100 Subject: [PATCH 04/26] Fix new line at the end of file. --- .editorconfig | 2 +- src/Core/AppRegistrationSecretCheckParameters.cs | 2 +- src/Core/AppRegistrationSecretCheckResult.cs | 2 +- src/Core/AppRegistrationSecretCheckResultApplication.cs | 2 +- src/Core/AppRegistrationSecretCheckResultApplicationSecret.cs | 2 +- src/Core/AppRegistrationSecretCheckResultTenant.cs | 2 +- src/Core/AppRegistrationSecretManager.cs | 2 +- src/Core/AppRegistrationSecretManagerOptions.cs | 2 +- src/Core/Emailing/EmailContact.cs | 2 +- src/Core/Emailing/EmailMessage.cs | 2 +- src/Core/Emailing/Graph/GraphEmailProvider.cs | 2 +- src/Core/Emailing/IEmailGenerator.cs | 2 +- src/Core/Emailing/IEmailProvider.cs | 2 +- src/Core/EntraId/EntraIdApplication.cs | 2 +- src/Core/EntraId/EntraIdApplicationPasswordCredential.cs | 2 +- src/Core/EntraId/GraphEntraIdApplicationClient.cs | 2 +- src/Core/EntraId/GraphEntraIdApplicationClientOptions.cs | 2 +- src/Core/EntraId/GraphServiceClientFactory.cs | 2 +- src/Core/EntraId/IEntraIdApplicationClient.cs | 2 +- src/Core/EntraId/IGraphServiceClientFactory.cs | 2 +- src/Core/Guard.cs | 2 +- src/Core/IAppRegistrationSecretManager.cs | 2 +- src/Functions/Program.cs | 2 +- tests/Core.Tests/AppRegistrationSecretCheckParametersTest.cs | 2 +- .../AppRegistrationSecretCheckResultApplicationSecretTest.cs | 2 +- .../AppRegistrationSecretCheckResultApplicationTest.cs | 2 +- tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs | 2 +- tests/Core.Tests/AppRegistrationSecretCheckResultTest.cs | 2 +- tests/Core.Tests/AppRegistrationSecretManagerOptionsTest.cs | 2 +- tests/Core.Tests/AppRegistrationSecretManagerTest.cs | 2 +- tests/Core.Tests/Emailing/EmailContactTest.cs | 2 +- tests/Core.Tests/Emailing/EmailMessageTest.cs | 2 +- tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs | 2 +- .../EntraId/EntraIdApplicationPasswordCredentialTest.cs | 2 +- tests/Core.Tests/EntraId/EntraIdApplicationTest.cs | 2 +- .../EntraId/GraphEntraIdApplicationClientOptionsTest.cs | 2 +- tests/Core.Tests/EntraId/GraphEntraIdApplicationClientTest.cs | 2 +- 37 files changed, 37 insertions(+), 37 deletions(-) diff --git a/.editorconfig b/.editorconfig index 9f7ed95..4aef792 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,7 +2,7 @@ [*] charset = utf-8-bom -insert_final_newline = true +insert_final_newline = false trim_trailing_whitespace = true # Markdown specific settings diff --git a/src/Core/AppRegistrationSecretCheckParameters.cs b/src/Core/AppRegistrationSecretCheckParameters.cs index bccada7..bca83fb 100644 --- a/src/Core/AppRegistrationSecretCheckParameters.cs +++ b/src/Core/AppRegistrationSecretCheckParameters.cs @@ -20,4 +20,4 @@ public AppRegistrationSecretCheckParameters(DateTime expirationDateLimit) public DateTime ExpirationDateLimit { get; } } -} +} \ No newline at end of file diff --git a/src/Core/AppRegistrationSecretCheckResult.cs b/src/Core/AppRegistrationSecretCheckResult.cs index 59cc1a9..96e0554 100644 --- a/src/Core/AppRegistrationSecretCheckResult.cs +++ b/src/Core/AppRegistrationSecretCheckResult.cs @@ -15,4 +15,4 @@ public AppRegistrationSecretCheckResult(IReadOnlyList Tenants { get; } } -} +} \ No newline at end of file diff --git a/src/Core/AppRegistrationSecretCheckResultApplication.cs b/src/Core/AppRegistrationSecretCheckResultApplication.cs index 289de3c..2c038fe 100644 --- a/src/Core/AppRegistrationSecretCheckResultApplication.cs +++ b/src/Core/AppRegistrationSecretCheckResultApplication.cs @@ -18,4 +18,4 @@ public AppRegistrationSecretCheckResultApplication(string displayName, IReadOnly public ReadOnlyCollection Secrets { get; } } -} +} \ No newline at end of file diff --git a/src/Core/AppRegistrationSecretCheckResultApplicationSecret.cs b/src/Core/AppRegistrationSecretCheckResultApplicationSecret.cs index c5e4844..ce9978a 100644 --- a/src/Core/AppRegistrationSecretCheckResultApplicationSecret.cs +++ b/src/Core/AppRegistrationSecretCheckResultApplicationSecret.cs @@ -20,4 +20,4 @@ public AppRegistrationSecretCheckResultApplicationSecret(string displayName, Dat public bool Expired { get; set; } } -} +} \ No newline at end of file diff --git a/src/Core/AppRegistrationSecretCheckResultTenant.cs b/src/Core/AppRegistrationSecretCheckResultTenant.cs index 02b8bec..6db9de1 100644 --- a/src/Core/AppRegistrationSecretCheckResultTenant.cs +++ b/src/Core/AppRegistrationSecretCheckResultTenant.cs @@ -18,4 +18,4 @@ public AppRegistrationSecretCheckResultTenant(string id, IReadOnlyCollection Applications { get; } } -} +} \ No newline at end of file diff --git a/src/Core/AppRegistrationSecretManager.cs b/src/Core/AppRegistrationSecretManager.cs index b5c7559..23813c2 100644 --- a/src/Core/AppRegistrationSecretManager.cs +++ b/src/Core/AppRegistrationSecretManager.cs @@ -139,4 +139,4 @@ public TenantApplications(string id, IReadOnlyList applicati public IReadOnlyList Applications { get; } } } -} +} \ No newline at end of file diff --git a/src/Core/AppRegistrationSecretManagerOptions.cs b/src/Core/AppRegistrationSecretManagerOptions.cs index b364b4d..08a359e 100644 --- a/src/Core/AppRegistrationSecretManagerOptions.cs +++ b/src/Core/AppRegistrationSecretManagerOptions.cs @@ -19,4 +19,4 @@ public AppRegistrationSecretManagerOptions() public Collection EmailRecipients { get; } } -} +} \ No newline at end of file diff --git a/src/Core/Emailing/EmailContact.cs b/src/Core/Emailing/EmailContact.cs index 3ba6553..b378c1d 100644 --- a/src/Core/Emailing/EmailContact.cs +++ b/src/Core/Emailing/EmailContact.cs @@ -20,4 +20,4 @@ public EmailContact(EmailAddress email, string displayName) public string DisplayName { get; } } -} +} \ No newline at end of file diff --git a/src/Core/Emailing/EmailMessage.cs b/src/Core/Emailing/EmailMessage.cs index 73e4b3f..af6c6ac 100644 --- a/src/Core/Emailing/EmailMessage.cs +++ b/src/Core/Emailing/EmailMessage.cs @@ -24,4 +24,4 @@ public EmailMessage(EmailContact from, EmailContact to, string subject, string h public string HtmlContent { get; } } -} +} \ No newline at end of file diff --git a/src/Core/Emailing/Graph/GraphEmailProvider.cs b/src/Core/Emailing/Graph/GraphEmailProvider.cs index 0bfcf50..dca987d 100644 --- a/src/Core/Emailing/Graph/GraphEmailProvider.cs +++ b/src/Core/Emailing/Graph/GraphEmailProvider.cs @@ -59,4 +59,4 @@ public async Task SendAsync(EmailMessage message, CancellationToken cancellation await this.serviceClient.Me.SendMail.PostAsync(body, cancellationToken: cancellationToken); } } -} +} \ No newline at end of file diff --git a/src/Core/Emailing/IEmailGenerator.cs b/src/Core/Emailing/IEmailGenerator.cs index cb5e612..d3c5f75 100644 --- a/src/Core/Emailing/IEmailGenerator.cs +++ b/src/Core/Emailing/IEmailGenerator.cs @@ -10,4 +10,4 @@ public interface IEmailGenerator { string Generate(AppRegistrationSecretCheckResult result); } -} +} \ No newline at end of file diff --git a/src/Core/Emailing/IEmailProvider.cs b/src/Core/Emailing/IEmailProvider.cs index 3e7baf3..301b294 100644 --- a/src/Core/Emailing/IEmailProvider.cs +++ b/src/Core/Emailing/IEmailProvider.cs @@ -10,4 +10,4 @@ public interface IEmailProvider { Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default); } -} +} \ No newline at end of file diff --git a/src/Core/EntraId/EntraIdApplication.cs b/src/Core/EntraId/EntraIdApplication.cs index ab0fa20..9002ad5 100644 --- a/src/Core/EntraId/EntraIdApplication.cs +++ b/src/Core/EntraId/EntraIdApplication.cs @@ -21,4 +21,4 @@ public EntraIdApplication(string id, string displayName, IReadOnlyList PasswordCredentials { get; } } -} +} \ No newline at end of file diff --git a/src/Core/EntraId/EntraIdApplicationPasswordCredential.cs b/src/Core/EntraId/EntraIdApplicationPasswordCredential.cs index 875f830..4d2d777 100644 --- a/src/Core/EntraId/EntraIdApplicationPasswordCredential.cs +++ b/src/Core/EntraId/EntraIdApplicationPasswordCredential.cs @@ -20,4 +20,4 @@ public EntraIdApplicationPasswordCredential(string displayName, DateTime endDate public DateTime EndDateTime { get; } } -} +} \ No newline at end of file diff --git a/src/Core/EntraId/GraphEntraIdApplicationClient.cs b/src/Core/EntraId/GraphEntraIdApplicationClient.cs index 9c42c04..fe49719 100644 --- a/src/Core/EntraId/GraphEntraIdApplicationClient.cs +++ b/src/Core/EntraId/GraphEntraIdApplicationClient.cs @@ -36,4 +36,4 @@ public async Task> GetAsync(string tenantId, C .ToArray(); } } -} +} \ No newline at end of file diff --git a/src/Core/EntraId/GraphEntraIdApplicationClientOptions.cs b/src/Core/EntraId/GraphEntraIdApplicationClientOptions.cs index c9e417d..1285aa2 100644 --- a/src/Core/EntraId/GraphEntraIdApplicationClientOptions.cs +++ b/src/Core/EntraId/GraphEntraIdApplicationClientOptions.cs @@ -12,4 +12,4 @@ public class GraphEntraIdApplicationClientOptions public string? ClientSecret { get; set; } } -} +} \ No newline at end of file diff --git a/src/Core/EntraId/GraphServiceClientFactory.cs b/src/Core/EntraId/GraphServiceClientFactory.cs index 7bac7f8..a87d744 100644 --- a/src/Core/EntraId/GraphServiceClientFactory.cs +++ b/src/Core/EntraId/GraphServiceClientFactory.cs @@ -36,4 +36,4 @@ public GraphServiceClient Create(string tenantId) return new GraphServiceClient(credential); } } -} +} \ No newline at end of file diff --git a/src/Core/EntraId/IEntraIdApplicationClient.cs b/src/Core/EntraId/IEntraIdApplicationClient.cs index a4e7a6e..9dbbb2f 100644 --- a/src/Core/EntraId/IEntraIdApplicationClient.cs +++ b/src/Core/EntraId/IEntraIdApplicationClient.cs @@ -10,4 +10,4 @@ public interface IEntraIdApplicationClient { Task> GetAsync(string tenantId, CancellationToken cancellationToken = default); } -} +} \ No newline at end of file diff --git a/src/Core/EntraId/IGraphServiceClientFactory.cs b/src/Core/EntraId/IGraphServiceClientFactory.cs index 2b24649..3a44986 100644 --- a/src/Core/EntraId/IGraphServiceClientFactory.cs +++ b/src/Core/EntraId/IGraphServiceClientFactory.cs @@ -12,4 +12,4 @@ public interface IGraphServiceClientFactory { GraphServiceClient Create(string tenantId); } -} +} \ No newline at end of file diff --git a/src/Core/Guard.cs b/src/Core/Guard.cs index 0f1f27f..846bf12 100644 --- a/src/Core/Guard.cs +++ b/src/Core/Guard.cs @@ -19,4 +19,4 @@ public static void IsUtc(DateTime dateTime, string paramName) } } } -} +} \ No newline at end of file diff --git a/src/Core/IAppRegistrationSecretManager.cs b/src/Core/IAppRegistrationSecretManager.cs index c0f48f1..3389d6d 100644 --- a/src/Core/IAppRegistrationSecretManager.cs +++ b/src/Core/IAppRegistrationSecretManager.cs @@ -10,4 +10,4 @@ public interface IAppRegistrationSecretManager { Task CheckAsync(AppRegistrationSecretCheckParameters parameters, CancellationToken cancellationToken = default); } -} +} \ No newline at end of file diff --git a/src/Functions/Program.cs b/src/Functions/Program.cs index 704475c..ecd0350 100644 --- a/src/Functions/Program.cs +++ b/src/Functions/Program.cs @@ -11,4 +11,4 @@ .AddApplicationInsightsTelemetryWorkerService() .ConfigureFunctionsApplicationInsights(); -builder.Build().Run(); +builder.Build().Run(); \ No newline at end of file diff --git a/tests/Core.Tests/AppRegistrationSecretCheckParametersTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckParametersTest.cs index dc6459c..0c233cc 100644 --- a/tests/Core.Tests/AppRegistrationSecretCheckParametersTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretCheckParametersTest.cs @@ -17,4 +17,4 @@ public void Constructor() parameters.TenantIds.Should().BeEmpty(); } } -} +} \ No newline at end of file diff --git a/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationSecretTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationSecretTest.cs index 7ce75a8..51492af 100644 --- a/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationSecretTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationSecretTest.cs @@ -28,4 +28,4 @@ public void Expired_ValueChanged() secret.Expired.Should().BeTrue(); } } -} +} \ No newline at end of file diff --git a/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationTest.cs index cb58718..c43a0e2 100644 --- a/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationTest.cs @@ -23,4 +23,4 @@ public void Constructor() application.Secrets.Should().Equal(secrets); } } -} +} \ No newline at end of file diff --git a/tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs index b3e2d26..683e042 100644 --- a/tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs @@ -23,4 +23,4 @@ public void Constructor() tenant.Id.Should().Be("The ID"); } } -} +} \ No newline at end of file diff --git a/tests/Core.Tests/AppRegistrationSecretCheckResultTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckResultTest.cs index 689cb52..dd39cfd 100644 --- a/tests/Core.Tests/AppRegistrationSecretCheckResultTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretCheckResultTest.cs @@ -22,4 +22,4 @@ public void Constructor() tenant.Tenants.Should().Equal(tenants); } } -} +} \ No newline at end of file diff --git a/tests/Core.Tests/AppRegistrationSecretManagerOptionsTest.cs b/tests/Core.Tests/AppRegistrationSecretManagerOptionsTest.cs index 711c1c9..9b9b65c 100644 --- a/tests/Core.Tests/AppRegistrationSecretManagerOptionsTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretManagerOptionsTest.cs @@ -31,4 +31,4 @@ public void EmailSender_ValueChanged() options.EmailSender.Should().Be(sender); } } -} +} \ No newline at end of file diff --git a/tests/Core.Tests/AppRegistrationSecretManagerTest.cs b/tests/Core.Tests/AppRegistrationSecretManagerTest.cs index 3013f6b..10a7828 100644 --- a/tests/Core.Tests/AppRegistrationSecretManagerTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretManagerTest.cs @@ -161,4 +161,4 @@ public async Task CheckAsync() timeProvider.VerifyAll(); } } -} +} \ No newline at end of file diff --git a/tests/Core.Tests/Emailing/EmailContactTest.cs b/tests/Core.Tests/Emailing/EmailContactTest.cs index 400f44b..a8a99f0 100644 --- a/tests/Core.Tests/Emailing/EmailContactTest.cs +++ b/tests/Core.Tests/Emailing/EmailContactTest.cs @@ -21,4 +21,4 @@ public void Constructor() contact.DisplayName.Should().Be("The display name"); } } -} +} \ No newline at end of file diff --git a/tests/Core.Tests/Emailing/EmailMessageTest.cs b/tests/Core.Tests/Emailing/EmailMessageTest.cs index 31a928c..42443db 100644 --- a/tests/Core.Tests/Emailing/EmailMessageTest.cs +++ b/tests/Core.Tests/Emailing/EmailMessageTest.cs @@ -26,4 +26,4 @@ public void Constructor() emailMessage.To.Should().Be(to); } } -} +} \ No newline at end of file diff --git a/tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs b/tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs index 93d1763..9b76de3 100644 --- a/tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs +++ b/tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs @@ -68,4 +68,4 @@ public async Task SendAsync() serializationWriterFactory.VerifyAll(); } } -} +} \ No newline at end of file diff --git a/tests/Core.Tests/EntraId/EntraIdApplicationPasswordCredentialTest.cs b/tests/Core.Tests/EntraId/EntraIdApplicationPasswordCredentialTest.cs index b84b01e..cf4606b 100644 --- a/tests/Core.Tests/EntraId/EntraIdApplicationPasswordCredentialTest.cs +++ b/tests/Core.Tests/EntraId/EntraIdApplicationPasswordCredentialTest.cs @@ -17,4 +17,4 @@ public void Constructor() credential.EndDateTime.Should().Be(new DateTime(2021, 1, 2, 3, 4, 5, 6)).And.BeIn(DateTimeKind.Utc); } } -} +} \ No newline at end of file diff --git a/tests/Core.Tests/EntraId/EntraIdApplicationTest.cs b/tests/Core.Tests/EntraId/EntraIdApplicationTest.cs index 7c5ef72..946c152 100644 --- a/tests/Core.Tests/EntraId/EntraIdApplicationTest.cs +++ b/tests/Core.Tests/EntraId/EntraIdApplicationTest.cs @@ -24,4 +24,4 @@ public void Constructor() application.PasswordCredentials.Should().Equal(passwordCredentials); } } -} +} \ No newline at end of file diff --git a/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientOptionsTest.cs b/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientOptionsTest.cs index d9ef48b..170c06d 100644 --- a/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientOptionsTest.cs +++ b/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientOptionsTest.cs @@ -37,4 +37,4 @@ public void ClientSecret_ValueChanged() options.ClientSecret.Should().Be("The client secret"); } } -} +} \ No newline at end of file diff --git a/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientTest.cs b/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientTest.cs index 2c6aa68..f3e440d 100644 --- a/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientTest.cs +++ b/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientTest.cs @@ -176,4 +176,4 @@ await client.Invoking(c => c.GetAsync("The tenant Id", cancellationToken)) requestAdapter.VerifyAll(); } } -} +} \ No newline at end of file From 2b5e59bff4f420262e1f2ff1ea15a0d094040e8a Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 10 Nov 2025 09:06:37 +0100 Subject: [PATCH 05/26] Retrieves the display name of the tenant. --- .../AppRegistrationSecretCheckResultTenant.cs | 5 +- src/Core/AppRegistrationSecretManager.cs | 37 ++---- src/Core/EntraId/EntraIdTenant.cs | 24 ++++ ...icationClient.cs => GraphEntraIdClient.cs} | 24 +++- ...ptions.cs => GraphEntraIdClientOptions.cs} | 4 +- src/Core/EntraId/GraphServiceClientFactory.cs | 4 +- ...ApplicationClient.cs => IEntraIdClient.cs} | 6 +- ...RegistrationSecretCheckResultTenantTest.cs | 3 +- .../AppRegistrationSecretCheckResultTest.cs | 4 +- .../AppRegistrationSecretManagerTest.cs | 74 ++++++----- tests/Core.Tests/EntraId/EntraIdTenantTest.cs | 27 ++++ ...raphEntraIdApplicationClientOptionsTest.cs | 6 +- .../GraphEntraIdApplicationClientTest.cs | 124 +++++++++++++++--- 13 files changed, 245 insertions(+), 97 deletions(-) create mode 100644 src/Core/EntraId/EntraIdTenant.cs rename src/Core/EntraId/{GraphEntraIdApplicationClient.cs => GraphEntraIdClient.cs} (60%) rename src/Core/EntraId/{GraphEntraIdApplicationClientOptions.cs => GraphEntraIdClientOptions.cs} (74%) rename src/Core/EntraId/{IEntraIdApplicationClient.cs => IEntraIdClient.cs} (56%) create mode 100644 tests/Core.Tests/EntraId/EntraIdTenantTest.cs diff --git a/src/Core/AppRegistrationSecretCheckResultTenant.cs b/src/Core/AppRegistrationSecretCheckResultTenant.cs index 6db9de1..35bc697 100644 --- a/src/Core/AppRegistrationSecretCheckResultTenant.cs +++ b/src/Core/AppRegistrationSecretCheckResultTenant.cs @@ -8,14 +8,17 @@ namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher { public class AppRegistrationSecretCheckResultTenant { - public AppRegistrationSecretCheckResultTenant(string id, IReadOnlyCollection applications) + public AppRegistrationSecretCheckResultTenant(string id, string displayName, IReadOnlyCollection applications) { this.Id = id; + this.DisplayName = displayName; this.Applications = new ReadOnlyCollection(applications.ToArray()); } public string Id { get; } + public string DisplayName { get; } + public ReadOnlyCollection Applications { get; } } } \ No newline at end of file diff --git a/src/Core/AppRegistrationSecretManager.cs b/src/Core/AppRegistrationSecretManager.cs index 23813c2..6809ca5 100644 --- a/src/Core/AppRegistrationSecretManager.cs +++ b/src/Core/AppRegistrationSecretManager.cs @@ -13,7 +13,7 @@ namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher public class AppRegistrationSecretManager : IAppRegistrationSecretManager { - private readonly IEntraIdApplicationClient entraIdApplicationClient; + private readonly IEntraIdClient entraIdApplicationClient; private readonly IEmailProvider emailProvider; @@ -23,7 +23,7 @@ public class AppRegistrationSecretManager : IAppRegistrationSecretManager private readonly TimeProvider timeProvider; - public AppRegistrationSecretManager(IEntraIdApplicationClient entraIdApplicationClient, IEmailProvider emailProvider, IEmailGenerator emailGenerator, TimeProvider timeProvider, IOptions options) + public AppRegistrationSecretManager(IEntraIdClient entraIdApplicationClient, IEmailProvider emailProvider, IEmailGenerator emailGenerator, TimeProvider timeProvider, IOptions options) { this.entraIdApplicationClient = entraIdApplicationClient; this.emailProvider = emailProvider; @@ -49,28 +49,21 @@ public async Task CheckAsync(AppRegistrationSe return result; } - private async Task> GetApplicationsByTenantAsync(AppRegistrationSecretCheckParameters parameters, CancellationToken cancellationToken) + private async Task> GetApplicationsByTenantAsync(AppRegistrationSecretCheckParameters parameters, CancellationToken cancellationToken) { - var applicationsTask = new List>>(parameters.TenantIds.Count); + var tenantTasks = new List>(parameters.TenantIds.Count); foreach (var tenantId in parameters.TenantIds) { - applicationsTask.Add(this.entraIdApplicationClient.GetAsync(tenantId, cancellationToken)); + tenantTasks.Add(this.entraIdApplicationClient.GetApplicationsAsync(tenantId, cancellationToken)); } - await Task.WhenAll(applicationsTask); + await Task.WhenAll(tenantTasks); - var result = new List(parameters.TenantIds.Count); - - for (int i = 0; i < parameters.TenantIds.Count; i++) - { - result.Add(new TenantApplications(parameters.TenantIds[i], applicationsTask[i].Result)); - } - - return result; + return tenantTasks.Select(t => t.Result).ToArray(); } - private AppRegistrationSecretCheckResult BuildResult(IReadOnlyList tenants, DateTime expirationDateLimit) + private AppRegistrationSecretCheckResult BuildResult(IReadOnlyList tenants, DateTime expirationDateLimit) { var tenantsResult = new List(tenants.Count); @@ -78,6 +71,7 @@ private AppRegistrationSecretCheckResult BuildResult(IReadOnlyList this.Build(app, expirationDateLimit)) .OrderBy(app => app.DisplayName) @@ -125,18 +119,5 @@ private async Task SendEmailAsync(string emailContent, CancellationToken cancell await this.emailProvider.SendAsync(message, cancellationToken); } } - - private sealed class TenantApplications - { - public TenantApplications(string id, IReadOnlyList applications) - { - this.Applications = applications; - this.Id = id; - } - - public string Id { get; } - - public IReadOnlyList Applications { get; } - } } } \ No newline at end of file diff --git a/src/Core/EntraId/EntraIdTenant.cs b/src/Core/EntraId/EntraIdTenant.cs new file mode 100644 index 0000000..9ab78dc --- /dev/null +++ b/src/Core/EntraId/EntraIdTenant.cs @@ -0,0 +1,24 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId +{ + public class EntraIdTenant + { + public EntraIdTenant(string id, string displayName, IReadOnlyList applications) + { + this.Id = id; + this.DisplayName = displayName; + this.Applications = new ReadOnlyCollection(applications.ToArray()); + } + + public string Id { get; } + + public string DisplayName { get; } + + public ReadOnlyCollection Applications { get; } + } +} \ No newline at end of file diff --git a/src/Core/EntraId/GraphEntraIdApplicationClient.cs b/src/Core/EntraId/GraphEntraIdClient.cs similarity index 60% rename from src/Core/EntraId/GraphEntraIdApplicationClient.cs rename to src/Core/EntraId/GraphEntraIdClient.cs index fe49719..6bff802 100644 --- a/src/Core/EntraId/GraphEntraIdApplicationClient.cs +++ b/src/Core/EntraId/GraphEntraIdClient.cs @@ -1,24 +1,36 @@ //----------------------------------------------------------------------- -// +// // Copyright (c) P.O.S Informatique. All rights reserved. // //----------------------------------------------------------------------- namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId { - public class GraphEntraIdApplicationClient : IEntraIdApplicationClient + public class GraphEntraIdClient : IEntraIdClient { private readonly IGraphServiceClientFactory graphServiceClientFactory; - public GraphEntraIdApplicationClient(IGraphServiceClientFactory graphServiceClientFactory) + public GraphEntraIdClient(IGraphServiceClientFactory graphServiceClientFactory) { this.graphServiceClientFactory = graphServiceClientFactory; } - public async Task> GetAsync(string tenantId, CancellationToken cancellationToken = default) + public async Task GetApplicationsAsync(string tenantId, CancellationToken cancellationToken = default) { using var graphClient = this.graphServiceClientFactory.Create(tenantId); + var tenant = await graphClient.Organization[tenantId].GetAsync( + request => + { + request.QueryParameters.Select = ["id", "displayName"]; + }, + cancellationToken); + + if (tenant is null) + { + throw new InvalidOperationException($"Unable to retrieve the tenant '{tenantId}'."); + } + var applications = await graphClient.Applications.GetAsync( request => { @@ -31,9 +43,11 @@ public async Task> GetAsync(string tenantId, C throw new InvalidOperationException($"Unable to retrieve the list of the app registrations for the tenant '{tenantId}'."); } - return applications.Value + var entraIdApplications = applications.Value .Select(app => new EntraIdApplication(app.AppId!, app.DisplayName!, app.PasswordCredentials!.Select(pc => new EntraIdApplicationPasswordCredential(pc.DisplayName!, pc.EndDateTime!.Value.UtcDateTime)).ToArray())) .ToArray(); + + return new EntraIdTenant(tenant.Id!, tenant.DisplayName!, entraIdApplications); } } } \ No newline at end of file diff --git a/src/Core/EntraId/GraphEntraIdApplicationClientOptions.cs b/src/Core/EntraId/GraphEntraIdClientOptions.cs similarity index 74% rename from src/Core/EntraId/GraphEntraIdApplicationClientOptions.cs rename to src/Core/EntraId/GraphEntraIdClientOptions.cs index 1285aa2..2ec1761 100644 --- a/src/Core/EntraId/GraphEntraIdApplicationClientOptions.cs +++ b/src/Core/EntraId/GraphEntraIdClientOptions.cs @@ -1,12 +1,12 @@ //----------------------------------------------------------------------- -// +// // Copyright (c) P.O.S Informatique. All rights reserved. // //----------------------------------------------------------------------- namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId { - public class GraphEntraIdApplicationClientOptions + public class GraphEntraIdClientOptions { public string? ClientId { get; set; } diff --git a/src/Core/EntraId/GraphServiceClientFactory.cs b/src/Core/EntraId/GraphServiceClientFactory.cs index a87d744..44e075d 100644 --- a/src/Core/EntraId/GraphServiceClientFactory.cs +++ b/src/Core/EntraId/GraphServiceClientFactory.cs @@ -13,9 +13,9 @@ namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId public class GraphServiceClientFactory : IGraphServiceClientFactory { - private readonly GraphEntraIdApplicationClientOptions options; + private readonly GraphEntraIdClientOptions options; - public GraphServiceClientFactory(IOptions options) + public GraphServiceClientFactory(IOptions options) { this.options = options.Value; } diff --git a/src/Core/EntraId/IEntraIdApplicationClient.cs b/src/Core/EntraId/IEntraIdClient.cs similarity index 56% rename from src/Core/EntraId/IEntraIdApplicationClient.cs rename to src/Core/EntraId/IEntraIdClient.cs index 9dbbb2f..e84dd29 100644 --- a/src/Core/EntraId/IEntraIdApplicationClient.cs +++ b/src/Core/EntraId/IEntraIdClient.cs @@ -1,13 +1,13 @@ //----------------------------------------------------------------------- -// +// // Copyright (c) P.O.S Informatique. All rights reserved. // //----------------------------------------------------------------------- namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId { - public interface IEntraIdApplicationClient + public interface IEntraIdClient { - Task> GetAsync(string tenantId, CancellationToken cancellationToken = default); + Task GetApplicationsAsync(string tenantId, CancellationToken cancellationToken = default); } } \ No newline at end of file diff --git a/tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs index 683e042..d1a0706 100644 --- a/tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs @@ -17,9 +17,10 @@ public void Constructor() new AppRegistrationSecretCheckResultApplication(default, []), }; - var tenant = new AppRegistrationSecretCheckResultTenant("The ID", applications); + var tenant = new AppRegistrationSecretCheckResultTenant("The ID", "The display name", applications); tenant.Applications.Should().Equal(applications); + tenant.DisplayName.Should().Be("The display name"); tenant.Id.Should().Be("The ID"); } } diff --git a/tests/Core.Tests/AppRegistrationSecretCheckResultTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckResultTest.cs index dd39cfd..bd140b4 100644 --- a/tests/Core.Tests/AppRegistrationSecretCheckResultTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretCheckResultTest.cs @@ -13,8 +13,8 @@ public void Constructor() { var tenants = new[] { - new AppRegistrationSecretCheckResultTenant(default, []), - new AppRegistrationSecretCheckResultTenant(default, []), + new AppRegistrationSecretCheckResultTenant(default, default, []), + new AppRegistrationSecretCheckResultTenant(default, default, []), }; var tenant = new AppRegistrationSecretCheckResult(tenants); diff --git a/tests/Core.Tests/AppRegistrationSecretManagerTest.cs b/tests/Core.Tests/AppRegistrationSecretManagerTest.cs index 10a7828..0b29555 100644 --- a/tests/Core.Tests/AppRegistrationSecretManagerTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretManagerTest.cs @@ -26,41 +26,47 @@ public async Task CheckAsync() var sender = new EmailContact(default, default); - var entraIdClient = new Mock(MockBehavior.Strict); - entraIdClient.Setup(c => c.GetAsync("Tenant 1", cancellationToken)) + var entraIdClient = new Mock(MockBehavior.Strict); + entraIdClient.Setup(c => c.GetApplicationsAsync("Tenant 1", cancellationToken)) .ReturnsAsync( - [ - new EntraIdApplication( - "1-1", - "App 1-1", + new EntraIdTenant( + "The tenant ID 1", + "The tenant display name 1", [ - new EntraIdApplicationPasswordCredential("Secret 1-1-1", now.AddDays(60)), - new EntraIdApplicationPasswordCredential("Secret 1-1-2", now.AddDays(10)), - ]), - new EntraIdApplication( - "1-2", - "App 1-2", - [ - new EntraIdApplicationPasswordCredential("Secret 1-2-1", now.AddDays(30)), - new EntraIdApplicationPasswordCredential("Secret 1-2-2", now.AddDays(120)), - ]) - ]); - entraIdClient.Setup(c => c.GetAsync("Tenant 2", cancellationToken)) + new EntraIdApplication( + "1-1", + "App 1-1", + [ + new EntraIdApplicationPasswordCredential("Secret 1-1-1", now.AddDays(60)), + new EntraIdApplicationPasswordCredential("Secret 1-1-2", now.AddDays(10)), + ]), + new EntraIdApplication( + "1-2", + "App 1-2", + [ + new EntraIdApplicationPasswordCredential("Secret 1-2-1", now.AddDays(30)), + new EntraIdApplicationPasswordCredential("Secret 1-2-2", now.AddDays(120)), + ]) + ])); + entraIdClient.Setup(c => c.GetApplicationsAsync("Tenant 2", cancellationToken)) .ReturnsAsync( - [ - new EntraIdApplication( - "2-1", - "App 2-1", - [ - new EntraIdApplicationPasswordCredential("Secret 2-1-1", now.AddDays(100)), - ]), - new EntraIdApplication( - "2-2", - "App 2-2", + new EntraIdTenant( + "The tenant ID 2", + "The tenant display name 2", [ - new EntraIdApplicationPasswordCredential("Secret 2-2-1", now.AddDays(300)), - ]) - ]); + new EntraIdApplication( + "2-1", + "App 2-1", + [ + new EntraIdApplicationPasswordCredential("Secret 2-1-1", now.AddDays(100)), + ]), + new EntraIdApplication( + "2-2", + "App 2-2", + [ + new EntraIdApplicationPasswordCredential("Secret 2-2-1", now.AddDays(300)), + ]) + ])); var emailProvider = new Mock(MockBehavior.Strict); emailProvider.Setup(ep => ep.SendAsync(It.Is(m => m.To.Email.ToString() == "email1@domain.com"), cancellationToken)) @@ -88,7 +94,8 @@ public async Task CheckAsync() { r.Tenants.Should().HaveCount(2); - r.Tenants[0].Id.Should().Be("Tenant 1"); + r.Tenants[0].DisplayName.Should().Be("The tenant display name 1"); + r.Tenants[0].Id.Should().Be("The tenant ID 1"); r.Tenants[0].Applications.Should().HaveCount(2); r.Tenants[0].Applications[0].DisplayName.Should().Be("App 1-1"); r.Tenants[0].Applications[0].Secrets.Should().HaveCount(2); @@ -107,7 +114,8 @@ public async Task CheckAsync() r.Tenants[0].Applications[1].Secrets[1].EndDate.Should().Be(now.AddDays(120).AddHours(8)).And.BeIn(DateTimeKind.Local); r.Tenants[0].Applications[1].Secrets[1].Expired.Should().BeFalse(); - r.Tenants[1].Id.Should().Be("Tenant 2"); + r.Tenants[1].DisplayName.Should().Be("The tenant display name 2"); + r.Tenants[1].Id.Should().Be("The tenant ID 2"); r.Tenants[1].Applications.Should().HaveCount(2); r.Tenants[1].Applications[0].DisplayName.Should().Be("App 2-1"); r.Tenants[1].Applications[0].Secrets.Should().HaveCount(1); diff --git a/tests/Core.Tests/EntraId/EntraIdTenantTest.cs b/tests/Core.Tests/EntraId/EntraIdTenantTest.cs new file mode 100644 index 0000000..3321c74 --- /dev/null +++ b/tests/Core.Tests/EntraId/EntraIdTenantTest.cs @@ -0,0 +1,27 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId.Tests +{ + public class EntraIdTenantTest + { + [Fact] + public void Constructor() + { + var applications = new[] + { + new EntraIdApplication(default, default, []), + new EntraIdApplication(default, default, []), + }; + + var tenant = new EntraIdTenant("The id", "The display name", applications); + + tenant.Applications.Should().Equal(applications); + tenant.DisplayName.Should().Be("The display name"); + tenant.Id.Should().Be("The id"); + } + } +} \ No newline at end of file diff --git a/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientOptionsTest.cs b/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientOptionsTest.cs index 170c06d..463c2d9 100644 --- a/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientOptionsTest.cs +++ b/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientOptionsTest.cs @@ -11,7 +11,7 @@ public class GraphEntraIdApplicationClientOptionsTest [Fact] public void Constructor() { - var options = new GraphEntraIdApplicationClientOptions(); + var options = new GraphEntraIdClientOptions(); options.ClientId.Should().BeNull(); options.ClientSecret.Should().BeNull(); @@ -20,7 +20,7 @@ public void Constructor() [Fact] public void ClientId_ValueChanged() { - var options = new GraphEntraIdApplicationClientOptions(); + var options = new GraphEntraIdClientOptions(); options.ClientId = "The client ID"; @@ -30,7 +30,7 @@ public void ClientId_ValueChanged() [Fact] public void ClientSecret_ValueChanged() { - var options = new GraphEntraIdApplicationClientOptions(); + var options = new GraphEntraIdClientOptions(); options.ClientSecret = "The client secret"; diff --git a/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientTest.cs b/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientTest.cs index f3e440d..6b0405e 100644 --- a/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientTest.cs +++ b/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientTest.cs @@ -22,6 +22,22 @@ public async Task GetAsync() requestAdapter.Setup(r => r.BaseUrl) .Returns("http://base/url"); requestAdapter.Setup(r => r.EnableBackingStore(null)); + requestAdapter.Setup(r => r.SendAsync(It.IsAny(), It.IsAny>(), It.IsNotNull>>(), cancellationToken)) + .Callback((RequestInformation requestInfo, ParsableFactory factory, Dictionary> _, CancellationToken _) => + { + requestInfo.HttpMethod.Should().Be(Method.GET); + requestInfo.URI.Should().Be("http://base/url/organization/The tenant Id?%24select=id,displayName"); + + requestInfo.QueryParameters.Should().HaveCount(1); + requestInfo.QueryParameters["%24select"].As().Should().Equal("id", "displayName"); + + requestInfo.Content.Should().BeSameAs(Stream.Null); + }) + .ReturnsAsync(new Organization() + { + Id = "The tenant ID response", + DisplayName = "The tenant name", + }); requestAdapter.Setup(r => r.SendAsync(It.IsAny(), It.IsAny>(), It.IsNotNull>>(), cancellationToken)) .Callback((RequestInformation requestInfo, ParsableFactory factory, Dictionary> _, CancellationToken _) => { @@ -72,23 +88,26 @@ public async Task GetAsync() graphServiceClientFactory.Setup(gf => gf.Create("The tenant Id")) .Returns(graphServiceClient.Object); - var client = new GraphEntraIdApplicationClient(graphServiceClientFactory.Object); + var client = new GraphEntraIdClient(graphServiceClientFactory.Object); + + var result = await client.GetApplicationsAsync("The tenant Id", cancellationToken); - var result = await client.GetAsync("The tenant Id", cancellationToken); + result.Applications.Should().HaveCount(2); - result.Should().HaveCount(2); + result.Applications[0].DisplayName.Should().Be("Display name 1"); + result.Applications[0].Id.Should().Be("App Id 1"); + result.Applications[0].PasswordCredentials.Should().HaveCount(2); + result.Applications[0].PasswordCredentials[0].DisplayName.Should().Be("Password 1"); + result.Applications[0].PasswordCredentials[0].EndDateTime.Should().Be(new DateTime(2025, 1, 1, 1, 1, 1, 1, 1)).And.BeIn(DateTimeKind.Utc); + result.Applications[0].PasswordCredentials[1].DisplayName.Should().Be("Password 2"); + result.Applications[0].PasswordCredentials[1].EndDateTime.Should().Be(new DateTime(2025, 2, 2, 2, 2, 2, 2, 2)).And.BeIn(DateTimeKind.Utc); - result[0].DisplayName.Should().Be("Display name 1"); - result[0].Id.Should().Be("App Id 1"); - result[0].PasswordCredentials.Should().HaveCount(2); - result[0].PasswordCredentials[0].DisplayName.Should().Be("Password 1"); - result[0].PasswordCredentials[0].EndDateTime.Should().Be(new DateTime(2025, 1, 1, 1, 1, 1, 1, 1)).And.BeIn(DateTimeKind.Utc); - result[0].PasswordCredentials[1].DisplayName.Should().Be("Password 2"); - result[0].PasswordCredentials[1].EndDateTime.Should().Be(new DateTime(2025, 2, 2, 2, 2, 2, 2, 2)).And.BeIn(DateTimeKind.Utc); + result.Applications[1].DisplayName.Should().Be("Display name 2"); + result.Applications[1].Id.Should().Be("App Id 2"); + result.Applications[1].PasswordCredentials.Should().BeEmpty(); - result[1].DisplayName.Should().Be("Display name 2"); - result[1].Id.Should().Be("App Id 2"); - result[1].PasswordCredentials.Should().BeEmpty(); + result.DisplayName.Should().Be("The tenant name"); + result.Id.Should().Be("The tenant ID response"); graphServiceClient.VerifyAll(); graphServiceClientFactory.VerifyAll(); @@ -104,6 +123,22 @@ public async Task GetAsync_ApplicationNull() requestAdapter.Setup(r => r.BaseUrl) .Returns("http://base/url"); requestAdapter.Setup(r => r.EnableBackingStore(null)); + requestAdapter.Setup(r => r.SendAsync(It.IsAny(), It.IsAny>(), It.IsNotNull>>(), cancellationToken)) + .Callback((RequestInformation requestInfo, ParsableFactory factory, Dictionary> _, CancellationToken _) => + { + requestInfo.HttpMethod.Should().Be(Method.GET); + requestInfo.URI.Should().Be("http://base/url/organization/The tenant Id?%24select=id,displayName"); + + requestInfo.QueryParameters.Should().HaveCount(1); + requestInfo.QueryParameters["%24select"].As().Should().Equal("id", "displayName"); + + requestInfo.Content.Should().BeSameAs(Stream.Null); + }) + .ReturnsAsync(new Organization() + { + Id = "The tenant ID response", + DisplayName = "The tenant name", + }); requestAdapter.Setup(r => r.SendAsync(It.IsAny(), It.IsAny>(), It.IsNotNull>>(), cancellationToken)) .Callback((RequestInformation requestInfo, ParsableFactory factory, Dictionary> _, CancellationToken _) => { @@ -126,9 +161,9 @@ public async Task GetAsync_ApplicationNull() graphServiceClientFactory.Setup(gf => gf.Create("The tenant Id")) .Returns(graphServiceClient.Object); - var client = new GraphEntraIdApplicationClient(graphServiceClientFactory.Object); + var client = new GraphEntraIdClient(graphServiceClientFactory.Object); - await client.Invoking(c => c.GetAsync("The tenant Id", cancellationToken)) + await client.Invoking(c => c.GetApplicationsAsync("The tenant Id", cancellationToken)) .Should().ThrowExactlyAsync() .WithMessage("Unable to retrieve the list of the app registrations for the tenant 'The tenant Id'."); @@ -146,6 +181,22 @@ public async Task GetAsync_ResponseValueNull() requestAdapter.Setup(r => r.BaseUrl) .Returns("http://base/url"); requestAdapter.Setup(r => r.EnableBackingStore(null)); + requestAdapter.Setup(r => r.SendAsync(It.IsAny(), It.IsAny>(), It.IsNotNull>>(), cancellationToken)) + .Callback((RequestInformation requestInfo, ParsableFactory factory, Dictionary> _, CancellationToken _) => + { + requestInfo.HttpMethod.Should().Be(Method.GET); + requestInfo.URI.Should().Be("http://base/url/organization/The tenant Id?%24select=id,displayName"); + + requestInfo.QueryParameters.Should().HaveCount(1); + requestInfo.QueryParameters["%24select"].As().Should().Equal("id", "displayName"); + + requestInfo.Content.Should().BeSameAs(Stream.Null); + }) + .ReturnsAsync(new Organization() + { + Id = "The tenant ID response", + DisplayName = "The tenant name", + }); requestAdapter.Setup(r => r.SendAsync(It.IsAny(), It.IsAny>(), It.IsNotNull>>(), cancellationToken)) .Callback((RequestInformation requestInfo, ParsableFactory factory, Dictionary> _, CancellationToken _) => { @@ -165,9 +216,9 @@ public async Task GetAsync_ResponseValueNull() graphServiceClientFactory.Setup(gf => gf.Create("The tenant Id")) .Returns(graphServiceClient.Object); - var client = new GraphEntraIdApplicationClient(graphServiceClientFactory.Object); + var client = new GraphEntraIdClient(graphServiceClientFactory.Object); - await client.Invoking(c => c.GetAsync("The tenant Id", cancellationToken)) + await client.Invoking(c => c.GetApplicationsAsync("The tenant Id", cancellationToken)) .Should().ThrowExactlyAsync() .WithMessage("Unable to retrieve the list of the app registrations for the tenant 'The tenant Id'."); @@ -175,5 +226,44 @@ await client.Invoking(c => c.GetAsync("The tenant Id", cancellationToken)) graphServiceClientFactory.VerifyAll(); requestAdapter.VerifyAll(); } + + [Fact] + public async Task GetAsync_NoOrganization() + { + var cancellationToken = new CancellationTokenSource().Token; + + var requestAdapter = new Mock(MockBehavior.Strict); + requestAdapter.Setup(r => r.BaseUrl) + .Returns("http://base/url"); + requestAdapter.Setup(r => r.EnableBackingStore(null)); + requestAdapter.Setup(r => r.SendAsync(It.IsAny(), It.IsAny>(), It.IsNotNull>>(), cancellationToken)) + .Callback((RequestInformation requestInfo, ParsableFactory factory, Dictionary> _, CancellationToken _) => + { + requestInfo.HttpMethod.Should().Be(Method.GET); + requestInfo.URI.Should().Be("http://base/url/organization/The tenant Id?%24select=id,displayName"); + + requestInfo.QueryParameters.Should().HaveCount(1); + requestInfo.QueryParameters["%24select"].As().Should().Equal("id", "displayName"); + + requestInfo.Content.Should().BeSameAs(Stream.Null); + }) + .ReturnsAsync((Organization)null); + + var graphServiceClient = new Mock(MockBehavior.Strict, requestAdapter.Object, null); + + var graphServiceClientFactory = new Mock(MockBehavior.Strict); + graphServiceClientFactory.Setup(gf => gf.Create("The tenant Id")) + .Returns(graphServiceClient.Object); + + var client = new GraphEntraIdClient(graphServiceClientFactory.Object); + + await client.Invoking(c => c.GetApplicationsAsync("The tenant Id", cancellationToken)) + .Should().ThrowExactlyAsync() + .WithMessage("Unable to retrieve the tenant 'The tenant Id'."); + + graphServiceClient.VerifyAll(); + graphServiceClientFactory.VerifyAll(); + requestAdapter.VerifyAll(); + } } } \ No newline at end of file From 57b045cb5c771be284d64a13550533e86cb92d2d Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 10 Nov 2025 09:49:36 +0100 Subject: [PATCH 06/26] Add id for the app reg. --- .../AppRegistrationSecretCheckResultApplication.cs | 5 ++++- src/Core/AppRegistrationSecretManager.cs | 4 ++-- ...AppRegistrationSecretCheckResultApplicationTest.cs | 3 ++- .../AppRegistrationSecretCheckResultTenantTest.cs | 4 ++-- tests/Core.Tests/AppRegistrationSecretManagerTest.cs | 11 +++++++---- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/Core/AppRegistrationSecretCheckResultApplication.cs b/src/Core/AppRegistrationSecretCheckResultApplication.cs index 2c038fe..4dbae03 100644 --- a/src/Core/AppRegistrationSecretCheckResultApplication.cs +++ b/src/Core/AppRegistrationSecretCheckResultApplication.cs @@ -8,12 +8,15 @@ namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher { public class AppRegistrationSecretCheckResultApplication { - public AppRegistrationSecretCheckResultApplication(string displayName, IReadOnlyList secrets) + public AppRegistrationSecretCheckResultApplication(string id, string displayName, IReadOnlyList secrets) { + this.Id = id; this.DisplayName = displayName; this.Secrets = new ReadOnlyCollection(secrets.ToArray()); } + public string Id { get; } + public string DisplayName { get; } public ReadOnlyCollection Secrets { get; } diff --git a/src/Core/AppRegistrationSecretManager.cs b/src/Core/AppRegistrationSecretManager.cs index 6809ca5..4cea917 100644 --- a/src/Core/AppRegistrationSecretManager.cs +++ b/src/Core/AppRegistrationSecretManager.cs @@ -41,7 +41,7 @@ public async Task CheckAsync(AppRegistrationSe var result = this.BuildResult(applicationsByTenant, parameters.ExpirationDateLimit); // Generate the e-mail - var emailContent = this.emailGenerator.Generate(result); + var emailContent = await this.emailGenerator.GenerateAsync(result, cancellationToken); // Send e-mail await this.SendEmailAsync(emailContent, cancellationToken); @@ -90,7 +90,7 @@ private AppRegistrationSecretCheckResultApplication Build(EntraIdApplication app .Select(pc => this.Build(pc, expirationDateLimit)) .ToArray(); - return new AppRegistrationSecretCheckResultApplication(application.DisplayName, secrets); + return new AppRegistrationSecretCheckResultApplication(application.Id, application.DisplayName, secrets); } private AppRegistrationSecretCheckResultApplicationSecret Build(EntraIdApplicationPasswordCredential passwordCredential, DateTime expirationDateLimit) diff --git a/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationTest.cs index c43a0e2..3b1bc3b 100644 --- a/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationTest.cs @@ -17,9 +17,10 @@ public void Constructor() new AppRegistrationSecretCheckResultApplicationSecret(default, default), }; - var application = new AppRegistrationSecretCheckResultApplication("The display name", secrets); + var application = new AppRegistrationSecretCheckResultApplication("The ID", "The display name", secrets); application.DisplayName.Should().Be("The display name"); + application.Id.Should().Be("The ID"); application.Secrets.Should().Equal(secrets); } } diff --git a/tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs index d1a0706..db5a211 100644 --- a/tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs @@ -13,8 +13,8 @@ public void Constructor() { var applications = new[] { - new AppRegistrationSecretCheckResultApplication(default, []), - new AppRegistrationSecretCheckResultApplication(default, []), + new AppRegistrationSecretCheckResultApplication(default, default, []), + new AppRegistrationSecretCheckResultApplication(default, default, []), }; var tenant = new AppRegistrationSecretCheckResultTenant("The ID", "The display name", applications); diff --git a/tests/Core.Tests/AppRegistrationSecretManagerTest.cs b/tests/Core.Tests/AppRegistrationSecretManagerTest.cs index 0b29555..46bbf97 100644 --- a/tests/Core.Tests/AppRegistrationSecretManagerTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretManagerTest.cs @@ -6,7 +6,6 @@ namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Core.Tests { - using System.Threading.Tasks; using Microsoft.Extensions.Options; using PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing; using PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId; @@ -89,8 +88,8 @@ public async Task CheckAsync() .Returns(Task.CompletedTask); var emailGenerator = new Mock(MockBehavior.Strict); - emailGenerator.Setup(g => g.Generate(It.IsAny())) - .Callback((AppRegistrationSecretCheckResult r) => + emailGenerator.Setup(g => g.GenerateAsync(It.IsAny(), cancellationToken)) + .Callback((AppRegistrationSecretCheckResult r, CancellationToken _) => { r.Tenants.Should().HaveCount(2); @@ -98,6 +97,7 @@ public async Task CheckAsync() r.Tenants[0].Id.Should().Be("The tenant ID 1"); r.Tenants[0].Applications.Should().HaveCount(2); r.Tenants[0].Applications[0].DisplayName.Should().Be("App 1-1"); + r.Tenants[0].Applications[0].Id.Should().Be("1-1"); r.Tenants[0].Applications[0].Secrets.Should().HaveCount(2); r.Tenants[0].Applications[0].Secrets[0].DisplayName.Should().Be("Secret 1-1-1"); r.Tenants[0].Applications[0].Secrets[0].EndDate.Should().Be(now.AddDays(60).AddHours(8)).And.BeIn(DateTimeKind.Local); @@ -106,6 +106,7 @@ public async Task CheckAsync() r.Tenants[0].Applications[0].Secrets[1].EndDate.Should().Be(now.AddDays(10).AddHours(8)).And.BeIn(DateTimeKind.Local); r.Tenants[0].Applications[0].Secrets[1].Expired.Should().BeTrue(); r.Tenants[0].Applications[1].DisplayName.Should().Be("App 1-2"); + r.Tenants[0].Applications[1].Id.Should().Be("1-2"); r.Tenants[0].Applications[1].Secrets.Should().HaveCount(2); r.Tenants[0].Applications[1].Secrets[0].DisplayName.Should().Be("Secret 1-2-1"); r.Tenants[0].Applications[1].Secrets[0].EndDate.Should().Be(now.AddDays(30).AddHours(8)).And.BeIn(DateTimeKind.Local); @@ -118,11 +119,13 @@ public async Task CheckAsync() r.Tenants[1].Id.Should().Be("The tenant ID 2"); r.Tenants[1].Applications.Should().HaveCount(2); r.Tenants[1].Applications[0].DisplayName.Should().Be("App 2-1"); + r.Tenants[1].Applications[0].Id.Should().Be("2-1"); r.Tenants[1].Applications[0].Secrets.Should().HaveCount(1); r.Tenants[1].Applications[0].Secrets[0].DisplayName.Should().Be("Secret 2-1-1"); r.Tenants[1].Applications[0].Secrets[0].EndDate.Should().Be(now.AddDays(100).AddHours(8)).And.BeIn(DateTimeKind.Local); r.Tenants[1].Applications[0].Secrets[0].Expired.Should().BeFalse(); r.Tenants[1].Applications[1].DisplayName.Should().Be("App 2-2"); + r.Tenants[1].Applications[1].Id.Should().Be("2-2"); r.Tenants[1].Applications[1].Secrets.Should().HaveCount(1); r.Tenants[1].Applications[1].Secrets[0].DisplayName.Should().Be("Secret 2-2-1"); r.Tenants[1].Applications[1].Secrets[0].EndDate.Should().Be(now.AddDays(300).AddHours(8)).And.BeIn(DateTimeKind.Local); @@ -130,7 +133,7 @@ public async Task CheckAsync() expectedResult = r; }) - .Returns("The content"); + .ReturnsAsync("The content"); var timeProvider = new Mock(MockBehavior.Strict); timeProvider.Setup(tp => tp.GetUtcNow()) From 2c7727f00fa33c9280b60bce6d1c84ac84e26982 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 10 Nov 2025 10:06:52 +0100 Subject: [PATCH 07/26] Changes Expired to status --- .editorconfig | 3 ++ .../AppRegistrationSecretCheckParameters.cs | 7 ++--- ...ationSecretCheckResultApplicationSecret.cs | 2 +- src/Core/AppRegistrationSecretManager.cs | 28 +++++++++++-------- src/Core/AppRegistrationSecretStatus.cs | 17 +++++++++++ ...ppRegistrationSecretCheckParametersTest.cs | 14 ++++++++-- ...nSecretCheckResultApplicationSecretTest.cs | 8 +++--- .../AppRegistrationSecretManagerTest.cs | 25 +++++++++-------- 8 files changed, 69 insertions(+), 35 deletions(-) create mode 100644 src/Core/AppRegistrationSecretStatus.cs diff --git a/.editorconfig b/.editorconfig index 4aef792..49728e0 100644 --- a/.editorconfig +++ b/.editorconfig @@ -96,3 +96,6 @@ dotnet_diagnostic.IDE0130.severity = none # SA1600: Elements should be documented dotnet_diagnostic.SA1600.severity = none + +# SA1602: Enumeration items should be documented +dotnet_diagnostic.SA1602.severity = none \ No newline at end of file diff --git a/src/Core/AppRegistrationSecretCheckParameters.cs b/src/Core/AppRegistrationSecretCheckParameters.cs index bca83fb..e7ba7f8 100644 --- a/src/Core/AppRegistrationSecretCheckParameters.cs +++ b/src/Core/AppRegistrationSecretCheckParameters.cs @@ -8,16 +8,13 @@ namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher { public class AppRegistrationSecretCheckParameters { - public AppRegistrationSecretCheckParameters(DateTime expirationDateLimit) + public AppRegistrationSecretCheckParameters() { - Guard.IsUtc(expirationDateLimit, nameof(expirationDateLimit)); - - this.ExpirationDateLimit = expirationDateLimit; this.TenantIds = new Collection(); } public Collection TenantIds { get; } - public DateTime ExpirationDateLimit { get; } + public TimeSpan ExpirationThreshold { get; set; } } } \ No newline at end of file diff --git a/src/Core/AppRegistrationSecretCheckResultApplicationSecret.cs b/src/Core/AppRegistrationSecretCheckResultApplicationSecret.cs index ce9978a..b1ea693 100644 --- a/src/Core/AppRegistrationSecretCheckResultApplicationSecret.cs +++ b/src/Core/AppRegistrationSecretCheckResultApplicationSecret.cs @@ -18,6 +18,6 @@ public AppRegistrationSecretCheckResultApplicationSecret(string displayName, Dat public DateTime EndDate { get; } - public bool Expired { get; set; } + public AppRegistrationSecretStatus Status { get; set; } } } \ No newline at end of file diff --git a/src/Core/AppRegistrationSecretManager.cs b/src/Core/AppRegistrationSecretManager.cs index 4cea917..f19b3f2 100644 --- a/src/Core/AppRegistrationSecretManager.cs +++ b/src/Core/AppRegistrationSecretManager.cs @@ -34,17 +34,19 @@ public AppRegistrationSecretManager(IEntraIdClient entraIdApplicationClient, IEm public async Task CheckAsync(AppRegistrationSecretCheckParameters parameters, CancellationToken cancellationToken = default) { + var now = this.timeProvider.GetUtcNow().UtcDateTime; + // Gets the applications var applicationsByTenant = await this.GetApplicationsByTenantAsync(parameters, cancellationToken); // Check the result - var result = this.BuildResult(applicationsByTenant, parameters.ExpirationDateLimit); + var result = this.BuildResult(applicationsByTenant, now, parameters.ExpirationThreshold); // Generate the e-mail var emailContent = await this.emailGenerator.GenerateAsync(result, cancellationToken); // Send e-mail - await this.SendEmailAsync(emailContent, cancellationToken); + await this.SendEmailAsync(emailContent, now, cancellationToken); return result; } @@ -63,7 +65,7 @@ private async Task> GetApplicationsByTenantAsync(Ap return tenantTasks.Select(t => t.Result).ToArray(); } - private AppRegistrationSecretCheckResult BuildResult(IReadOnlyList tenants, DateTime expirationDateLimit) + private AppRegistrationSecretCheckResult BuildResult(IReadOnlyList tenants, DateTime now, TimeSpan expirationThreshold) { var tenantsResult = new List(tenants.Count); @@ -73,7 +75,7 @@ private AppRegistrationSecretCheckResult BuildResult(IReadOnlyList this.Build(app, expirationDateLimit)) + .Select(app => this.Build(app, now, expirationThreshold)) .OrderBy(app => app.DisplayName) .ToArray()); @@ -83,34 +85,38 @@ private AppRegistrationSecretCheckResult BuildResult(IReadOnlyList pc.DisplayName) - .Select(pc => this.Build(pc, expirationDateLimit)) + .Select(pc => this.Build(pc, now, expirationThreshold)) .ToArray(); return new AppRegistrationSecretCheckResultApplication(application.Id, application.DisplayName, secrets); } - private AppRegistrationSecretCheckResultApplicationSecret Build(EntraIdApplicationPasswordCredential passwordCredential, DateTime expirationDateLimit) + private AppRegistrationSecretCheckResultApplicationSecret Build(EntraIdApplicationPasswordCredential passwordCredential, DateTime now, TimeSpan expirationThreshold) { var localEndDateTime = TimeZoneInfo.ConvertTimeFromUtc(passwordCredential.EndDateTime, this.timeProvider.LocalTimeZone); localEndDateTime = DateTime.SpecifyKind(localEndDateTime, DateTimeKind.Local); var secret = new AppRegistrationSecretCheckResultApplicationSecret(passwordCredential.DisplayName, localEndDateTime); - if (passwordCredential.EndDateTime <= expirationDateLimit) + if (passwordCredential.EndDateTime < now) + { + secret.Status = AppRegistrationSecretStatus.Expired; + } + else if (passwordCredential.EndDateTime <= now + expirationThreshold) { - secret.Expired = true; + secret.Status = AppRegistrationSecretStatus.ExpiringSoon; } return secret; } - private async Task SendEmailAsync(string emailContent, CancellationToken cancellationToken) + private async Task SendEmailAsync(string emailContent, DateTime now, CancellationToken cancellationToken) { - var todayLocal = this.timeProvider.GetLocalNow(); + var todayLocal = TimeZoneInfo.ConvertTimeFromUtc(now, this.timeProvider.LocalTimeZone); foreach (var recipient in this.options.EmailRecipients) { diff --git a/src/Core/AppRegistrationSecretStatus.cs b/src/Core/AppRegistrationSecretStatus.cs new file mode 100644 index 0000000..6f61a26 --- /dev/null +++ b/src/Core/AppRegistrationSecretStatus.cs @@ -0,0 +1,17 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher +{ + public enum AppRegistrationSecretStatus + { + Valid, + + ExpiringSoon, + + Expired, + } +} \ No newline at end of file diff --git a/tests/Core.Tests/AppRegistrationSecretCheckParametersTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckParametersTest.cs index 0c233cc..15b4773 100644 --- a/tests/Core.Tests/AppRegistrationSecretCheckParametersTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretCheckParametersTest.cs @@ -11,10 +11,20 @@ public class AppRegistrationSecretCheckParametersTest [Fact] public void Constructor() { - var parameters = new AppRegistrationSecretCheckParameters(new DateTime(2025, 1, 2, 3, 4, 5, 6, 7, DateTimeKind.Utc)); + var parameters = new AppRegistrationSecretCheckParameters(); - parameters.ExpirationDateLimit.Should().Be(new DateTime(2025, 1, 2, 3, 4, 5, 6, 7, DateTimeKind.Utc)); + parameters.ExpirationThreshold.Should().Be(TimeSpan.Zero); parameters.TenantIds.Should().BeEmpty(); } + + [Fact] + public void ExpirationThreshold_ValueChanged() + { + var parameters = new AppRegistrationSecretCheckParameters(); + + parameters.ExpirationThreshold = TimeSpan.FromDays(4); + + parameters.ExpirationThreshold.Should().Be(TimeSpan.FromDays(4)); + } } } \ No newline at end of file diff --git a/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationSecretTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationSecretTest.cs index 51492af..9d1e0cd 100644 --- a/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationSecretTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationSecretTest.cs @@ -15,17 +15,17 @@ public void Constructor() secret.DisplayName.Should().Be("The display name"); secret.EndDate.Should().Be(new DateTime(2025, 1, 2, 3, 4, 5, 6)).And.BeIn(DateTimeKind.Utc); - secret.Expired.Should().BeFalse(); + secret.Status.Should().Be(AppRegistrationSecretStatus.Valid); } [Fact] - public void Expired_ValueChanged() + public void Status_ValueChanged() { var secret = new AppRegistrationSecretCheckResultApplicationSecret(default, default); - secret.Expired = true; + secret.Status = AppRegistrationSecretStatus.ExpiringSoon; - secret.Expired.Should().BeTrue(); + secret.Status.Should().Be(AppRegistrationSecretStatus.ExpiringSoon); } } } \ No newline at end of file diff --git a/tests/Core.Tests/AppRegistrationSecretManagerTest.cs b/tests/Core.Tests/AppRegistrationSecretManagerTest.cs index 46bbf97..6bd963d 100644 --- a/tests/Core.Tests/AppRegistrationSecretManagerTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretManagerTest.cs @@ -57,7 +57,7 @@ public async Task CheckAsync() "2-1", "App 2-1", [ - new EntraIdApplicationPasswordCredential("Secret 2-1-1", now.AddDays(100)), + new EntraIdApplicationPasswordCredential("Secret 2-1-1", now.AddDays(-100)), ]), new EntraIdApplication( "2-2", @@ -73,7 +73,7 @@ public async Task CheckAsync() { m.HtmlContent.Should().Be("The content"); m.From.Should().BeSameAs(sender); - m.Subject.Should().Be("Reminder: App Registration secrets expiring soon - [07/02/2020]"); + m.Subject.Should().Be("Reminder: App Registration secrets expiring soon - [15/06/2025]"); m.To.DisplayName.Should().Be("Email 1"); }) .Returns(Task.CompletedTask); @@ -82,7 +82,7 @@ public async Task CheckAsync() { m.HtmlContent.Should().Be("The content"); m.From.Should().BeSameAs(sender); - m.Subject.Should().Be("Reminder: App Registration secrets expiring soon - [07/02/2020]"); + m.Subject.Should().Be("Reminder: App Registration secrets expiring soon - [15/06/2025]"); m.To.DisplayName.Should().Be("Email 2"); }) .Returns(Task.CompletedTask); @@ -101,19 +101,19 @@ public async Task CheckAsync() r.Tenants[0].Applications[0].Secrets.Should().HaveCount(2); r.Tenants[0].Applications[0].Secrets[0].DisplayName.Should().Be("Secret 1-1-1"); r.Tenants[0].Applications[0].Secrets[0].EndDate.Should().Be(now.AddDays(60).AddHours(8)).And.BeIn(DateTimeKind.Local); - r.Tenants[0].Applications[0].Secrets[0].Expired.Should().BeFalse(); + r.Tenants[0].Applications[0].Secrets[0].Status.Should().Be(AppRegistrationSecretStatus.Valid); r.Tenants[0].Applications[0].Secrets[1].DisplayName.Should().Be("Secret 1-1-2"); r.Tenants[0].Applications[0].Secrets[1].EndDate.Should().Be(now.AddDays(10).AddHours(8)).And.BeIn(DateTimeKind.Local); - r.Tenants[0].Applications[0].Secrets[1].Expired.Should().BeTrue(); + r.Tenants[0].Applications[0].Secrets[1].Status.Should().Be(AppRegistrationSecretStatus.ExpiringSoon); r.Tenants[0].Applications[1].DisplayName.Should().Be("App 1-2"); r.Tenants[0].Applications[1].Id.Should().Be("1-2"); r.Tenants[0].Applications[1].Secrets.Should().HaveCount(2); r.Tenants[0].Applications[1].Secrets[0].DisplayName.Should().Be("Secret 1-2-1"); r.Tenants[0].Applications[1].Secrets[0].EndDate.Should().Be(now.AddDays(30).AddHours(8)).And.BeIn(DateTimeKind.Local); - r.Tenants[0].Applications[1].Secrets[0].Expired.Should().BeTrue(); + r.Tenants[0].Applications[1].Secrets[0].Status.Should().Be(AppRegistrationSecretStatus.ExpiringSoon); r.Tenants[0].Applications[1].Secrets[1].DisplayName.Should().Be("Secret 1-2-2"); r.Tenants[0].Applications[1].Secrets[1].EndDate.Should().Be(now.AddDays(120).AddHours(8)).And.BeIn(DateTimeKind.Local); - r.Tenants[0].Applications[1].Secrets[1].Expired.Should().BeFalse(); + r.Tenants[0].Applications[1].Secrets[1].Status.Should().Be(AppRegistrationSecretStatus.Valid); r.Tenants[1].DisplayName.Should().Be("The tenant display name 2"); r.Tenants[1].Id.Should().Be("The tenant ID 2"); @@ -122,14 +122,14 @@ public async Task CheckAsync() r.Tenants[1].Applications[0].Id.Should().Be("2-1"); r.Tenants[1].Applications[0].Secrets.Should().HaveCount(1); r.Tenants[1].Applications[0].Secrets[0].DisplayName.Should().Be("Secret 2-1-1"); - r.Tenants[1].Applications[0].Secrets[0].EndDate.Should().Be(now.AddDays(100).AddHours(8)).And.BeIn(DateTimeKind.Local); - r.Tenants[1].Applications[0].Secrets[0].Expired.Should().BeFalse(); + r.Tenants[1].Applications[0].Secrets[0].EndDate.Should().Be(now.AddDays(-100).AddHours(8)).And.BeIn(DateTimeKind.Local); + r.Tenants[1].Applications[0].Secrets[0].Status.Should().Be(AppRegistrationSecretStatus.Expired); r.Tenants[1].Applications[1].DisplayName.Should().Be("App 2-2"); r.Tenants[1].Applications[1].Id.Should().Be("2-2"); r.Tenants[1].Applications[1].Secrets.Should().HaveCount(1); r.Tenants[1].Applications[1].Secrets[0].DisplayName.Should().Be("Secret 2-2-1"); r.Tenants[1].Applications[1].Secrets[0].EndDate.Should().Be(now.AddDays(300).AddHours(8)).And.BeIn(DateTimeKind.Local); - r.Tenants[1].Applications[1].Secrets[0].Expired.Should().BeFalse(); + r.Tenants[1].Applications[1].Secrets[0].Status.Should().Be(AppRegistrationSecretStatus.Valid); expectedResult = r; }) @@ -137,7 +137,7 @@ public async Task CheckAsync() var timeProvider = new Mock(MockBehavior.Strict); timeProvider.Setup(tp => tp.GetUtcNow()) - .Returns(new DateTimeOffset(2020, 2, 7, 8, 9, 15, 10, 4, TimeSpan.Zero)); + .Returns(now); timeProvider.Setup(tp => tp.LocalTimeZone) .Returns(TimeZoneInfo.FindSystemTimeZoneById("Asia/Manila")); @@ -151,8 +151,9 @@ public async Task CheckAsync() EmailSender = sender, }); - var parameters = new AppRegistrationSecretCheckParameters(now.AddDays(30)) + var parameters = new AppRegistrationSecretCheckParameters() { + ExpirationThreshold = TimeSpan.FromDays(30), TenantIds = { "Tenant 1", From de0521cd531b69d36052af05c301709caf79d4ed Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 10 Nov 2025 10:34:45 +0100 Subject: [PATCH 08/26] Add a DaysBeforeExpiration property. --- .../AppRegistrationSecretCheckResultApplicationSecret.cs | 5 ++++- src/Core/AppRegistrationSecretManager.cs | 2 +- ...AppRegistrationSecretCheckResultApplicationSecretTest.cs | 5 +++-- .../AppRegistrationSecretCheckResultApplicationTest.cs | 4 ++-- tests/Core.Tests/AppRegistrationSecretManagerTest.cs | 6 ++++++ 5 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/Core/AppRegistrationSecretCheckResultApplicationSecret.cs b/src/Core/AppRegistrationSecretCheckResultApplicationSecret.cs index b1ea693..d17759c 100644 --- a/src/Core/AppRegistrationSecretCheckResultApplicationSecret.cs +++ b/src/Core/AppRegistrationSecretCheckResultApplicationSecret.cs @@ -8,10 +8,11 @@ namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher { public class AppRegistrationSecretCheckResultApplicationSecret { - public AppRegistrationSecretCheckResultApplicationSecret(string displayName, DateTime endDate) + public AppRegistrationSecretCheckResultApplicationSecret(string displayName, DateTime endDate, int daysBeforeExpiration) { this.DisplayName = displayName; this.EndDate = endDate; + this.DaysBeforeExpiration = daysBeforeExpiration; } public string DisplayName { get; } @@ -19,5 +20,7 @@ public AppRegistrationSecretCheckResultApplicationSecret(string displayName, Dat public DateTime EndDate { get; } public AppRegistrationSecretStatus Status { get; set; } + + public int DaysBeforeExpiration { get; } } } \ No newline at end of file diff --git a/src/Core/AppRegistrationSecretManager.cs b/src/Core/AppRegistrationSecretManager.cs index f19b3f2..c9c6d0e 100644 --- a/src/Core/AppRegistrationSecretManager.cs +++ b/src/Core/AppRegistrationSecretManager.cs @@ -100,7 +100,7 @@ private AppRegistrationSecretCheckResultApplicationSecret Build(EntraIdApplicati var localEndDateTime = TimeZoneInfo.ConvertTimeFromUtc(passwordCredential.EndDateTime, this.timeProvider.LocalTimeZone); localEndDateTime = DateTime.SpecifyKind(localEndDateTime, DateTimeKind.Local); - var secret = new AppRegistrationSecretCheckResultApplicationSecret(passwordCredential.DisplayName, localEndDateTime); + var secret = new AppRegistrationSecretCheckResultApplicationSecret(passwordCredential.DisplayName, localEndDateTime, (passwordCredential.EndDateTime - now).Days); if (passwordCredential.EndDateTime < now) { diff --git a/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationSecretTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationSecretTest.cs index 9d1e0cd..3114a67 100644 --- a/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationSecretTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationSecretTest.cs @@ -11,8 +11,9 @@ public class AppRegistrationSecretCheckResultApplicationSecretTest [Fact] public void Constructor() { - var secret = new AppRegistrationSecretCheckResultApplicationSecret("The display name", new DateTime(2025, 1, 2, 3, 4, 5, 6, DateTimeKind.Utc)); + var secret = new AppRegistrationSecretCheckResultApplicationSecret("The display name", new DateTime(2025, 1, 2, 3, 4, 5, 6, DateTimeKind.Utc), 10); + secret.DaysBeforeExpiration.Should().Be(10); secret.DisplayName.Should().Be("The display name"); secret.EndDate.Should().Be(new DateTime(2025, 1, 2, 3, 4, 5, 6)).And.BeIn(DateTimeKind.Utc); secret.Status.Should().Be(AppRegistrationSecretStatus.Valid); @@ -21,7 +22,7 @@ public void Constructor() [Fact] public void Status_ValueChanged() { - var secret = new AppRegistrationSecretCheckResultApplicationSecret(default, default); + var secret = new AppRegistrationSecretCheckResultApplicationSecret(default, default, default); secret.Status = AppRegistrationSecretStatus.ExpiringSoon; diff --git a/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationTest.cs index 3b1bc3b..ded27c9 100644 --- a/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationTest.cs @@ -13,8 +13,8 @@ public void Constructor() { var secrets = new[] { - new AppRegistrationSecretCheckResultApplicationSecret(default, default), - new AppRegistrationSecretCheckResultApplicationSecret(default, default), + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default), + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default), }; var application = new AppRegistrationSecretCheckResultApplication("The ID", "The display name", secrets); diff --git a/tests/Core.Tests/AppRegistrationSecretManagerTest.cs b/tests/Core.Tests/AppRegistrationSecretManagerTest.cs index 6bd963d..51ae29e 100644 --- a/tests/Core.Tests/AppRegistrationSecretManagerTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretManagerTest.cs @@ -99,18 +99,22 @@ public async Task CheckAsync() r.Tenants[0].Applications[0].DisplayName.Should().Be("App 1-1"); r.Tenants[0].Applications[0].Id.Should().Be("1-1"); r.Tenants[0].Applications[0].Secrets.Should().HaveCount(2); + r.Tenants[0].Applications[0].Secrets[0].DaysBeforeExpiration.Should().Be(60); r.Tenants[0].Applications[0].Secrets[0].DisplayName.Should().Be("Secret 1-1-1"); r.Tenants[0].Applications[0].Secrets[0].EndDate.Should().Be(now.AddDays(60).AddHours(8)).And.BeIn(DateTimeKind.Local); r.Tenants[0].Applications[0].Secrets[0].Status.Should().Be(AppRegistrationSecretStatus.Valid); + r.Tenants[0].Applications[0].Secrets[1].DaysBeforeExpiration.Should().Be(10); r.Tenants[0].Applications[0].Secrets[1].DisplayName.Should().Be("Secret 1-1-2"); r.Tenants[0].Applications[0].Secrets[1].EndDate.Should().Be(now.AddDays(10).AddHours(8)).And.BeIn(DateTimeKind.Local); r.Tenants[0].Applications[0].Secrets[1].Status.Should().Be(AppRegistrationSecretStatus.ExpiringSoon); r.Tenants[0].Applications[1].DisplayName.Should().Be("App 1-2"); r.Tenants[0].Applications[1].Id.Should().Be("1-2"); r.Tenants[0].Applications[1].Secrets.Should().HaveCount(2); + r.Tenants[0].Applications[1].Secrets[0].DaysBeforeExpiration.Should().Be(30); r.Tenants[0].Applications[1].Secrets[0].DisplayName.Should().Be("Secret 1-2-1"); r.Tenants[0].Applications[1].Secrets[0].EndDate.Should().Be(now.AddDays(30).AddHours(8)).And.BeIn(DateTimeKind.Local); r.Tenants[0].Applications[1].Secrets[0].Status.Should().Be(AppRegistrationSecretStatus.ExpiringSoon); + r.Tenants[0].Applications[1].Secrets[1].DaysBeforeExpiration.Should().Be(120); r.Tenants[0].Applications[1].Secrets[1].DisplayName.Should().Be("Secret 1-2-2"); r.Tenants[0].Applications[1].Secrets[1].EndDate.Should().Be(now.AddDays(120).AddHours(8)).And.BeIn(DateTimeKind.Local); r.Tenants[0].Applications[1].Secrets[1].Status.Should().Be(AppRegistrationSecretStatus.Valid); @@ -121,12 +125,14 @@ public async Task CheckAsync() r.Tenants[1].Applications[0].DisplayName.Should().Be("App 2-1"); r.Tenants[1].Applications[0].Id.Should().Be("2-1"); r.Tenants[1].Applications[0].Secrets.Should().HaveCount(1); + r.Tenants[1].Applications[0].Secrets[0].DaysBeforeExpiration.Should().Be(-100); r.Tenants[1].Applications[0].Secrets[0].DisplayName.Should().Be("Secret 2-1-1"); r.Tenants[1].Applications[0].Secrets[0].EndDate.Should().Be(now.AddDays(-100).AddHours(8)).And.BeIn(DateTimeKind.Local); r.Tenants[1].Applications[0].Secrets[0].Status.Should().Be(AppRegistrationSecretStatus.Expired); r.Tenants[1].Applications[1].DisplayName.Should().Be("App 2-2"); r.Tenants[1].Applications[1].Id.Should().Be("2-2"); r.Tenants[1].Applications[1].Secrets.Should().HaveCount(1); + r.Tenants[1].Applications[1].Secrets[0].DaysBeforeExpiration.Should().Be(300); r.Tenants[1].Applications[1].Secrets[0].DisplayName.Should().Be("Secret 2-2-1"); r.Tenants[1].Applications[1].Secrets[0].EndDate.Should().Be(now.AddDays(300).AddHours(8)).And.BeIn(DateTimeKind.Local); r.Tenants[1].Applications[1].Secrets[0].Status.Should().Be(AppRegistrationSecretStatus.Valid); From b1a466cb84933df50386cb59ff0210ec508f5ca6 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 10 Nov 2025 10:57:55 +0100 Subject: [PATCH 09/26] Create the HTML report. --- Directory.Packages.props | 2 + src/Core/Core.csproj | 13 ++ src/Core/Emailing/EmailTemplate.html | 97 +++++++++++ src/Core/Emailing/IEmailGenerator.cs | 2 +- src/Core/Emailing/ScribanEmailGenerator.cs | 39 +++++ tests/Core.Tests/Core.Tests.csproj | 3 + ...ilGeneratorTest.GenerateAsync.verified.txt | 153 ++++++++++++++++++ .../Emailing/ScribanEmailGeneratorTest.cs | 61 +++++++ 8 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 src/Core/Emailing/EmailTemplate.html create mode 100644 src/Core/Emailing/ScribanEmailGenerator.cs create mode 100644 tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.GenerateAsync.verified.txt create mode 100644 tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 75fb12b..57dedcc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,8 +18,10 @@ + + diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 83913d1..1346af2 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -1,8 +1,21 @@  + + + + + + + + + + PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing.EmailTemplate.html + + + diff --git a/src/Core/Emailing/EmailTemplate.html b/src/Core/Emailing/EmailTemplate.html new file mode 100644 index 0000000..f869342 --- /dev/null +++ b/src/Core/Emailing/EmailTemplate.html @@ -0,0 +1,97 @@ + + + + + + +

Entra ID app registrations secret expiration report

+ + {{ + func status_to_css(status) + case status + when "Expired" + "expired" + when "ExpiringSoon" + "expiring-soon" + else + "valid" + end + end + }} + + {{ for tenant in Tenants }} +
+

{{ tenant.DisplayName }} ({{ tenant.Id }})

+ + {{ for application in tenant.Applications }} +
+

{{ application.DisplayName }} ({{ application.Id }})

+ + {{ for secret in application.Secrets }} +
+
Display name: {{ secret.DisplayName }}
+
Status: {{ secret.Status }}
+
Expiration date: {{ secret.EndDate | date.to_string '%e-%b-%Y' }}
+ {{ if secret.DaysBeforeExpiration > 0 }} +
Days before expiration: {{ secret.DaysBeforeExpiration }} days
+ {{ else }} +
Expired since: {{ -secret.DaysBeforeExpiration }} days
+ {{ end }} +
+ {{ end }} +
+ {{ end }} +
+ {{ end }} + + \ No newline at end of file diff --git a/src/Core/Emailing/IEmailGenerator.cs b/src/Core/Emailing/IEmailGenerator.cs index d3c5f75..8372c9c 100644 --- a/src/Core/Emailing/IEmailGenerator.cs +++ b/src/Core/Emailing/IEmailGenerator.cs @@ -8,6 +8,6 @@ namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing { public interface IEmailGenerator { - string Generate(AppRegistrationSecretCheckResult result); + Task GenerateAsync(AppRegistrationSecretCheckResult result, CancellationToken cancellationToken = default); } } \ No newline at end of file diff --git a/src/Core/Emailing/ScribanEmailGenerator.cs b/src/Core/Emailing/ScribanEmailGenerator.cs new file mode 100644 index 0000000..42ae33e --- /dev/null +++ b/src/Core/Emailing/ScribanEmailGenerator.cs @@ -0,0 +1,39 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing +{ + using Scriban; + using Scriban.Runtime; + + public class ScribanEmailGenerator : IEmailGenerator + { + public async Task GenerateAsync(AppRegistrationSecretCheckResult result, CancellationToken cancellationToken = default) + { + using var htmlTemplateStream = typeof(ScribanEmailGenerator).Assembly.GetManifestResourceStream("PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing.EmailTemplate.html")!; + using var reader = new StreamReader(htmlTemplateStream); + + var htmlTemplate = await reader.ReadToEndAsync(cancellationToken); + + var scribanTemplate = Template.Parse(htmlTemplate); + + var scriptObject = new ScriptObject + { + { nameof(result.Tenants), result.Tenants }, + }; + + var context = new TemplateContext() + { + MemberRenamer = r => r.Name, + MemberFilter = null, + }; + + context.PushGlobal(scriptObject); + + return await scribanTemplate.RenderAsync(context); + } + } +} \ No newline at end of file diff --git a/tests/Core.Tests/Core.Tests.csproj b/tests/Core.Tests/Core.Tests.csproj index 71ceed6..48e5c20 100644 --- a/tests/Core.Tests/Core.Tests.csproj +++ b/tests/Core.Tests/Core.Tests.csproj @@ -1,4 +1,7 @@  + + + diff --git a/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.GenerateAsync.verified.txt b/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.GenerateAsync.verified.txt new file mode 100644 index 0000000..8370a6d --- /dev/null +++ b/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.GenerateAsync.verified.txt @@ -0,0 +1,153 @@ + + + + + + +

Entra ID app registrations secret expiration report

+ + + + +
+

The tenant 1 (Id 1)

+ + +
+

The app 1-1 (Id 1-1)

+ + +
+
Display name: Secret 1-1-1
+
Status: Expired
+
Expiration date: 1-Jan-2025
+ +
Expired since: 10 days
+ +
+ +
+
Display name: Secret 1-1-2
+
Status: Valid
+
Expiration date: 2-Feb-2025
+ +
Days before expiration: 20 days
+ +
+ +
+ +
+

The app 1-2 (Id 1-2)

+ + +
+
Display name: Secret 1-2-1
+
Status: Valid
+
Expiration date: 3-Mar-2025
+ +
Days before expiration: 30 days
+ +
+ +
+
Display name: Secret 1-2-2
+
Status: ExpiringSoon
+
Expiration date: 4-Apr-2025
+ +
Days before expiration: 40 days
+ +
+ +
+ +
+ +
+

The tenant 2 (Id 2)

+ + +
+

The app 2-1 (Id 2-1)

+ + +
+
Display name: Secret 2-1-1
+
Status: Valid
+
Expiration date: 5-May-2025
+ +
Days before expiration: 50 days
+ +
+ +
+ +
+

The app 2-2 (Id 2-2)

+ + +
+
Display name: Secret 2-1-1
+
Status: Valid
+
Expiration date: 6-Jun-2025
+ +
Days before expiration: 60 days
+ +
+ +
+ +
+ + + \ No newline at end of file diff --git a/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.cs b/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.cs new file mode 100644 index 0000000..e486d02 --- /dev/null +++ b/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.cs @@ -0,0 +1,61 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing.Tests +{ + public class ScribanEmailGeneratorTest + { + [Fact] + public async Task GenerateAsync() + { + var checkResult = new AppRegistrationSecretCheckResult( + [ + new AppRegistrationSecretCheckResultTenant( + "Id 1", + "The tenant 1", + [ + new AppRegistrationSecretCheckResultApplication( + "Id 1-1", + "The app 1-1", + [ + new AppRegistrationSecretCheckResultApplicationSecret("Secret 1-1-1", new DateTime(2025, 1, 1), -10) { Status = AppRegistrationSecretStatus.Expired }, + new AppRegistrationSecretCheckResultApplicationSecret("Secret 1-1-2", new DateTime(2025, 2, 2), 20) { Status = AppRegistrationSecretStatus.Valid }, + ]), + new AppRegistrationSecretCheckResultApplication( + "Id 1-2", + "The app 1-2", + [ + new AppRegistrationSecretCheckResultApplicationSecret("Secret 1-2-1", new DateTime(2025, 3, 3), 30) { Status = AppRegistrationSecretStatus.Valid }, + new AppRegistrationSecretCheckResultApplicationSecret("Secret 1-2-2", new DateTime(2025, 4, 4), 40) { Status = AppRegistrationSecretStatus.ExpiringSoon }, + ]) + ]), + new AppRegistrationSecretCheckResultTenant( + "Id 2", + "The tenant 2", + [ + new AppRegistrationSecretCheckResultApplication( + "Id 2-1", + "The app 2-1", + [ + new AppRegistrationSecretCheckResultApplicationSecret("Secret 2-1-1", new DateTime(2025, 5, 5), 50) { Status = AppRegistrationSecretStatus.Valid }, + ]), + new AppRegistrationSecretCheckResultApplication( + "Id 2-2", + "The app 2-2", + [ + new AppRegistrationSecretCheckResultApplicationSecret("Secret 2-1-1", new DateTime(2025, 6, 6), 60) { Status = AppRegistrationSecretStatus.Valid }, + ]) + ]), + ]); + + var generator = new ScribanEmailGenerator(); + + var content = await generator.GenerateAsync(checkResult, CancellationToken.None); + + await Verify(content); + } + } +} \ No newline at end of file From 8461b60a0a729de246e70e864a98164d98c9dc21 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 10 Nov 2025 16:02:26 +0100 Subject: [PATCH 10/26] Fix graph email provider. --- src/Core/Emailing/Graph/GraphEmailProvider.cs | 12 ++---------- .../Emailing/Graph/GraphEmailProviderTest.cs | 8 +++----- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/Core/Emailing/Graph/GraphEmailProvider.cs b/src/Core/Emailing/Graph/GraphEmailProvider.cs index dca987d..7f6bcb6 100644 --- a/src/Core/Emailing/Graph/GraphEmailProvider.cs +++ b/src/Core/Emailing/Graph/GraphEmailProvider.cs @@ -7,8 +7,8 @@ namespace PosInformatique.Foundations.Emailing.Graph { using Microsoft.Graph; - using Microsoft.Graph.Me.SendMail; using Microsoft.Graph.Models; + using Microsoft.Graph.Users.Item.SendMail; public sealed class GraphEmailProvider : IEmailProvider { @@ -28,14 +28,6 @@ public async Task SendAsync(EmailMessage message, CancellationToken cancellation ContentType = BodyType.Html, Content = message.HtmlContent, }, - From = new Recipient() - { - EmailAddress = new EmailAddress - { - Address = message.From.Email.ToString(), - Name = message.From.DisplayName, - }, - }, Subject = message.Subject, ToRecipients = new List { @@ -56,7 +48,7 @@ public async Task SendAsync(EmailMessage message, CancellationToken cancellation SaveToSentItems = false, }; - await this.serviceClient.Me.SendMail.PostAsync(body, cancellationToken: cancellationToken); + await this.serviceClient.Users[message.From.Email.ToString()].SendMail.PostAsync(body, cancellationToken: cancellationToken); } } } \ No newline at end of file diff --git a/tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs b/tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs index 9b76de3..972b374 100644 --- a/tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs +++ b/tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs @@ -7,8 +7,8 @@ namespace PosInformatique.Foundations.Emailing.Graph.Tests { using Microsoft.Graph; - using Microsoft.Graph.Me.SendMail; using Microsoft.Graph.Models; + using Microsoft.Graph.Users.Item.SendMail; using Microsoft.Kiota.Abstractions; using Microsoft.Kiota.Abstractions.Serialization; using Microsoft.Kiota.Serialization.Json; @@ -34,15 +34,13 @@ public async Task SendAsync() .Callback((RequestInformation requestInfo, Dictionary> _, CancellationToken _) => { requestInfo.HttpMethod.Should().Be(Method.POST); - requestInfo.URI.Should().Be("http://base/url/me/sendMail"); + requestInfo.URI.Should().Be("http://base/url/users/sender%40domain.com/sendMail"); var jsonMessage = KiotaJsonSerializer.DeserializeAsync(requestInfo.Content).GetAwaiter().GetResult(); jsonMessage.Message.Attachments.Should().BeNull(); jsonMessage.Message.Body.Content.Should().Be("The HTML content"); jsonMessage.Message.Body.ContentType.Should().Be(BodyType.Html); - jsonMessage.Message.From.EmailAddress.Address.Should().Be("sender@domain.com"); - jsonMessage.Message.From.EmailAddress.Name.Should().Be("The sender"); jsonMessage.Message.BccRecipients.Should().BeNull(); jsonMessage.Message.CcRecipients.Should().BeNull(); jsonMessage.Message.ToRecipients.Should().HaveCount(1); @@ -68,4 +66,4 @@ public async Task SendAsync() serializationWriterFactory.VerifyAll(); } } -} \ No newline at end of file +} From 4607c2e57335dd90887decf1489ee9eebbe59fcd Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 10 Nov 2025 16:12:35 +0100 Subject: [PATCH 11/26] Remove usage of display name for sender / recipients. --- src/Core/AppRegistrationSecretManager.cs | 6 +++++- .../AppRegistrationSecretManagerOptions.cs | 8 ++++---- .../AppRegistrationSecretManagerOptionsTest.cs | 4 ++-- .../AppRegistrationSecretManagerTest.cs | 18 +++++++++--------- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/Core/AppRegistrationSecretManager.cs b/src/Core/AppRegistrationSecretManager.cs index c9c6d0e..354c118 100644 --- a/src/Core/AppRegistrationSecretManager.cs +++ b/src/Core/AppRegistrationSecretManager.cs @@ -120,7 +120,11 @@ private async Task SendEmailAsync(string emailContent, DateTime now, Cancellatio foreach (var recipient in this.options.EmailRecipients) { - var message = new EmailMessage(this.options.EmailSender, recipient, $"Reminder: App Registration secrets expiring soon - [{todayLocal:d}]", emailContent); + var message = new EmailMessage( + new EmailContact(this.options.EmailSender, string.Empty), + new EmailContact(recipient, string.Empty), + $"Reminder: App Registration secrets expiring soon - [{todayLocal:d}]", + emailContent); await this.emailProvider.SendAsync(message, cancellationToken); } diff --git a/src/Core/AppRegistrationSecretManagerOptions.cs b/src/Core/AppRegistrationSecretManagerOptions.cs index 08a359e..afc87d4 100644 --- a/src/Core/AppRegistrationSecretManagerOptions.cs +++ b/src/Core/AppRegistrationSecretManagerOptions.cs @@ -6,17 +6,17 @@ namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher { - using PosInformatique.Foundations.Emailing; + using PosInformatique.Foundations.EmailAddresses; public class AppRegistrationSecretManagerOptions { public AppRegistrationSecretManagerOptions() { - this.EmailRecipients = new Collection(); + this.EmailRecipients = new Collection(); } - public EmailContact EmailSender { get; set; } = default!; + public EmailAddress EmailSender { get; set; } = default!; - public Collection EmailRecipients { get; } + public Collection EmailRecipients { get; } } } \ No newline at end of file diff --git a/tests/Core.Tests/AppRegistrationSecretManagerOptionsTest.cs b/tests/Core.Tests/AppRegistrationSecretManagerOptionsTest.cs index 9b9b65c..6e51917 100644 --- a/tests/Core.Tests/AppRegistrationSecretManagerOptionsTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretManagerOptionsTest.cs @@ -6,7 +6,7 @@ namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Tests { - using PosInformatique.Foundations.Emailing; + using PosInformatique.Foundations.EmailAddresses; public class AppRegistrationSecretManagerOptionsTest { @@ -24,7 +24,7 @@ public void EmailSender_ValueChanged() { var options = new AppRegistrationSecretManagerOptions(); - var sender = new EmailContact(default, default); + var sender = EmailAddress.Parse("email@domain.com"); options.EmailSender = sender; diff --git a/tests/Core.Tests/AppRegistrationSecretManagerTest.cs b/tests/Core.Tests/AppRegistrationSecretManagerTest.cs index 51ae29e..4d6a655 100644 --- a/tests/Core.Tests/AppRegistrationSecretManagerTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretManagerTest.cs @@ -23,8 +23,6 @@ public async Task CheckAsync() AppRegistrationSecretCheckResult expectedResult = null; - var sender = new EmailContact(default, default); - var entraIdClient = new Mock(MockBehavior.Strict); entraIdClient.Setup(c => c.GetApplicationsAsync("Tenant 1", cancellationToken)) .ReturnsAsync( @@ -72,18 +70,20 @@ public async Task CheckAsync() .Callback((EmailMessage m, CancellationToken _) => { m.HtmlContent.Should().Be("The content"); - m.From.Should().BeSameAs(sender); + m.From.DisplayName.Should().BeEmpty(); + m.From.Email.Should().Be(EmailAddress.Parse("sender@domain.com")); m.Subject.Should().Be("Reminder: App Registration secrets expiring soon - [15/06/2025]"); - m.To.DisplayName.Should().Be("Email 1"); + m.To.DisplayName.Should().BeEmpty(); }) .Returns(Task.CompletedTask); emailProvider.Setup(ep => ep.SendAsync(It.Is(m => m.To.Email.ToString() == "email2@domain.com"), cancellationToken)) .Callback((EmailMessage m, CancellationToken _) => { m.HtmlContent.Should().Be("The content"); - m.From.Should().BeSameAs(sender); + m.From.DisplayName.Should().BeEmpty(); + m.From.Email.Should().Be(EmailAddress.Parse("sender@domain.com")); m.Subject.Should().Be("Reminder: App Registration secrets expiring soon - [15/06/2025]"); - m.To.DisplayName.Should().Be("Email 2"); + m.To.DisplayName.Should().BeEmpty(); }) .Returns(Task.CompletedTask); @@ -151,10 +151,10 @@ public async Task CheckAsync() { EmailRecipients = { - new EmailContact(EmailAddress.Parse("email1@domain.com"), "Email 1"), - new EmailContact(EmailAddress.Parse("email2@domain.com"), "Email 2"), + EmailAddress.Parse("email1@domain.com"), + EmailAddress.Parse("email2@domain.com"), }, - EmailSender = sender, + EmailSender = EmailAddress.Parse("sender@domain.com"), }); var parameters = new AppRegistrationSecretCheckParameters() From df1c1a5264691bcf59bf5cd6e00ab074b8c6a7da Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 10 Nov 2025 16:16:42 +0100 Subject: [PATCH 12/26] Implementation of the Azure Function. --- ....Identity.AppRegistrationSecretWatcher.sln | 6 + ...AppRegistrationSecretWatcherApplication.cs | 145 ++++++++++++++++++ .../AppRegistrationSecretWatcherFunctions.cs | 42 +++++ ...gistrationSecretWatcherFunctionsOptions.cs | 20 +++ src/Functions/Function1.cs | 26 ---- src/Functions/Program.cs | 14 -- ...rationSecretWatcherFunctionsOptionsTest.cs | 30 ++++ ...pRegistrationSecretWatcherFunctionsTest.cs | 43 ++++++ tests/Functions.Tests/Functions.Tests.csproj | 5 + 9 files changed, 291 insertions(+), 40 deletions(-) create mode 100644 src/Functions/AppRegistrationSecretWatcherApplication.cs create mode 100644 src/Functions/AppRegistrationSecretWatcherFunctions.cs create mode 100644 src/Functions/AppRegistrationSecretWatcherFunctionsOptions.cs delete mode 100644 src/Functions/Function1.cs delete mode 100644 src/Functions/Program.cs create mode 100644 tests/Functions.Tests/AppRegistrationSecretWatcherFunctionsOptionsTest.cs create mode 100644 tests/Functions.Tests/AppRegistrationSecretWatcherFunctionsTest.cs create mode 100644 tests/Functions.Tests/Functions.Tests.csproj diff --git a/Azure.Identity.AppRegistrationSecretWatcher.sln b/Azure.Identity.AppRegistrationSecretWatcher.sln index 903b773..fac1a8b 100644 --- a/Azure.Identity.AppRegistrationSecretWatcher.sln +++ b/Azure.Identity.AppRegistrationSecretWatcher.sln @@ -32,6 +32,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{143067B7 tests\Directory.Build.props = tests\Directory.Build.props EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Functions.Tests", "tests\Functions.Tests\Functions.Tests.csproj", "{309097B3-0840-4407-A85F-7038F18201E0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -50,6 +52,10 @@ Global {C7D85722-68AB-4E9A-8E47-7A71B3387060}.Debug|Any CPU.Build.0 = Debug|Any CPU {C7D85722-68AB-4E9A-8E47-7A71B3387060}.Release|Any CPU.ActiveCfg = Release|Any CPU {C7D85722-68AB-4E9A-8E47-7A71B3387060}.Release|Any CPU.Build.0 = Release|Any CPU + {309097B3-0840-4407-A85F-7038F18201E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {309097B3-0840-4407-A85F-7038F18201E0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {309097B3-0840-4407-A85F-7038F18201E0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {309097B3-0840-4407-A85F-7038F18201E0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Functions/AppRegistrationSecretWatcherApplication.cs b/src/Functions/AppRegistrationSecretWatcherApplication.cs new file mode 100644 index 0000000..8775b53 --- /dev/null +++ b/src/Functions/AppRegistrationSecretWatcherApplication.cs @@ -0,0 +1,145 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Functions +{ + using System.Globalization; + using global::Azure.Identity; + using Microsoft.Azure.Functions.Worker; + using Microsoft.Azure.Functions.Worker.Builder; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + using Microsoft.Graph; + using PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing; + using PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId; + using PosInformatique.Foundations.EmailAddresses; + using PosInformatique.Foundations.Emailing; + using PosInformatique.Foundations.Emailing.Graph; + + public static class AppRegistrationSecretWatcherApplication + { + public static async Task Main(string[] args) + { + var builder = FunctionsApplication.CreateBuilder(args); + + string? appSecretWatcherClientId; + string? appSecretWatcherClientSecret; + + // Check the APP_SECRET_WATCHER_CLIENT_ID and APP_SECRET_WATCHER_CLIENT_SECRET + if (!string.IsNullOrWhiteSpace(builder.Configuration["APP_SECRET_WATCHER_CLIENT_ID"])) + { + if (string.IsNullOrWhiteSpace(builder.Configuration["APP_SECRET_WATCHER_CLIENT_SECRET"])) + { + throw new InvalidOperationException("No client secret has been specified for the app registrations watcher (Missing setting: APP_SECRET_WATCHER_CLIENT_SECRET)."); + } + + appSecretWatcherClientId = builder.Configuration["APP_SECRET_WATCHER_CLIENT_ID"]; + appSecretWatcherClientSecret = builder.Configuration["APP_SECRET_WATCHER_CLIENT_SECRET"]; + } + else + { + appSecretWatcherClientId = null; + appSecretWatcherClientSecret = null; + } + + // Check the APP_SECRET_WATCHER_TENANT_IDS + if (string.IsNullOrWhiteSpace(builder.Configuration["APP_SECRET_WATCHER_TENANT_IDS"])) + { + throw new InvalidOperationException("No tenant ids has been specified for the app registrations watcher (Missing setting: APP_SECRET_WATCHER_TENANT_IDS)."); + } + + var tenantIds = builder.Configuration["APP_SECRET_WATCHER_TENANT_IDS"]!.Split(';', StringSplitOptions.RemoveEmptyEntries); + + // Check APP_SECRET_WATCHER_SENDER_EMAIL + if (string.IsNullOrWhiteSpace(builder.Configuration["APP_SECRET_WATCHER_SENDER_EMAIL"])) + { + throw new InvalidOperationException("No email sender has been specified for the app registrations watcher (Missing setting: APP_SECRET_WATCHER_SENDER_EMAIL)."); + } + + if (!EmailAddress.TryParse(builder.Configuration["APP_SECRET_WATCHER_SENDER_EMAIL"], out var emailSender)) + { + throw new InvalidOperationException("The email sender specified for the app registrations watcher is invalid (Invalid setting: APP_SECRET_WATCHER_SENDER_EMAIL)."); + } + + // Check APP_SECRET_WATCHER_RECIPIENTS_EMAIL + if (string.IsNullOrWhiteSpace(builder.Configuration["APP_SECRET_WATCHER_RECIPIENTS_EMAIL"])) + { + throw new InvalidOperationException("No recipient emails address has been specified for the app registrations watcher (Missing setting: APP_SECRET_WATCHER_RECIPIENTS_EMAIL)."); + } + + var recipientEmailAddresses = builder.Configuration["APP_SECRET_WATCHER_RECIPIENTS_EMAIL"]!.Split(';', StringSplitOptions.RemoveEmptyEntries); + + // Check APP_SECRET_WATCHER_EXPIRATION_THRESHOLD + var expirationThreshold = TimeSpan.Zero; + + if (!string.IsNullOrWhiteSpace(builder.Configuration["APP_SECRET_WATCHER_EXPIRATION_THRESHOLD"])) + { + if (!TimeSpan.TryParse(builder.Configuration["APP_SECRET_WATCHER_EXPIRATION_THRESHOLD"], CultureInfo.InvariantCulture, out var expirationThresholdParsed)) + { + throw new InvalidOperationException("The expiration threshold specified for the app registrations watcher is invalid (Invalid setting: APP_SECRET_WATCHER_EXPIRATION_THRESHOLD)."); + } + + expirationThreshold = expirationThresholdParsed; + } + + builder.ConfigureFunctionsWebApplication(); + + // Infrastructure + builder.Services.AddSingleton(TimeProvider.System); + + // Add Application Insights + builder.Services + .AddApplicationInsightsTelemetryWorkerService() + .ConfigureFunctionsApplicationInsights(); + + // Emailing + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // Graph API + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => + { + return new GraphServiceClient(new DefaultAzureCredential()); + }); + + builder.Services.Configure(opt => + { + opt.ClientId = appSecretWatcherClientId; + opt.ClientSecret = appSecretWatcherClientSecret; + }); + + // App registrations secret manager + builder.Services.AddSingleton(); + + builder.Services.Configure(opt => + { + opt.EmailSender = emailSender; + + foreach (var recipientEmailAddress in recipientEmailAddresses) + { + opt.EmailRecipients.Add(recipientEmailAddress); + } + }); + + // Function + builder.Services.Configure(opt => + { + foreach (var tenantId in tenantIds) + { + opt.TenantIds.Add(tenantId); + } + + opt.ExpirationThreshold = expirationThreshold; + }); + + var host = builder.Build(); + + await host.RunAsync(); + } + } +} \ No newline at end of file diff --git a/src/Functions/AppRegistrationSecretWatcherFunctions.cs b/src/Functions/AppRegistrationSecretWatcherFunctions.cs new file mode 100644 index 0000000..3ad8083 --- /dev/null +++ b/src/Functions/AppRegistrationSecretWatcherFunctions.cs @@ -0,0 +1,42 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Functions +{ + using Microsoft.Azure.Functions.Worker; + using Microsoft.Extensions.Options; + + public class AppRegistrationSecretWatcherFunctions + { + private readonly IAppRegistrationSecretManager manager; + + private readonly AppRegistrationSecretWatcherFunctionsOptions options; + + public AppRegistrationSecretWatcherFunctions(IAppRegistrationSecretManager manager, IOptions options) + { + this.manager = manager; + this.options = options.Value; + } + + [Function("WatchAppSecrets")] +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + public async Task WatchAppSecretsAsync([TimerTrigger("%APP_SECRET_WATCHER_FREQUENCY%")] TimerInfo _, CancellationToken cancellationToken) +#pragma warning restore SA1313 // Parameter names should begin with lower-case letter + { + var parameters = new AppRegistrationSecretCheckParameters() + { + ExpirationThreshold = this.options.ExpirationThreshold, + }; + + foreach (var tenantId in this.options.TenantIds) + { + parameters.TenantIds.Add(tenantId); + } + + await this.manager.CheckAsync(parameters, cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/Functions/AppRegistrationSecretWatcherFunctionsOptions.cs b/src/Functions/AppRegistrationSecretWatcherFunctionsOptions.cs new file mode 100644 index 0000000..78c7d2d --- /dev/null +++ b/src/Functions/AppRegistrationSecretWatcherFunctionsOptions.cs @@ -0,0 +1,20 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Functions +{ + public class AppRegistrationSecretWatcherFunctionsOptions + { + public AppRegistrationSecretWatcherFunctionsOptions() + { + this.TenantIds = new Collection(); + } + + public Collection TenantIds { get; } + + public TimeSpan ExpirationThreshold { get; set; } + } +} \ No newline at end of file diff --git a/src/Functions/Function1.cs b/src/Functions/Function1.cs deleted file mode 100644 index 657cd34..0000000 --- a/src/Functions/Function1.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Extensions.Logging; - -namespace Azure.Identity.AppRegistrationSecretWatcher; - -public class Function1 -{ - private readonly ILogger _logger; - - public Function1(ILoggerFactory loggerFactory) - { - _logger = loggerFactory.CreateLogger(); - } - - [Function("Function1")] - public void Run([TimerTrigger("0 */5 * * * *")] TimerInfo myTimer) - { - _logger.LogInformation("C# Timer trigger function executed at: {executionTime}", DateTime.Now); - - if (myTimer.ScheduleStatus is not null) - { - _logger.LogInformation("Next timer schedule at: {nextSchedule}", myTimer.ScheduleStatus.Next); - } - } -} \ No newline at end of file diff --git a/src/Functions/Program.cs b/src/Functions/Program.cs deleted file mode 100644 index ecd0350..0000000 --- a/src/Functions/Program.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Builder; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -var builder = FunctionsApplication.CreateBuilder(args); - -builder.ConfigureFunctionsWebApplication(); - -builder.Services - .AddApplicationInsightsTelemetryWorkerService() - .ConfigureFunctionsApplicationInsights(); - -builder.Build().Run(); \ No newline at end of file diff --git a/tests/Functions.Tests/AppRegistrationSecretWatcherFunctionsOptionsTest.cs b/tests/Functions.Tests/AppRegistrationSecretWatcherFunctionsOptionsTest.cs new file mode 100644 index 0000000..d45ebde --- /dev/null +++ b/tests/Functions.Tests/AppRegistrationSecretWatcherFunctionsOptionsTest.cs @@ -0,0 +1,30 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Functions.Tests +{ + public class AppRegistrationSecretWatcherFunctionsOptionsTest + { + [Fact] + public void Constructor() + { + var options = new AppRegistrationSecretWatcherFunctionsOptions(); + + options.ExpirationThreshold.Should().Be(TimeSpan.Zero); + options.TenantIds.Should().BeEmpty(); + } + + [Fact] + public void ExpirationThreshold_ValueChanged() + { + var options = new AppRegistrationSecretWatcherFunctionsOptions(); + + options.ExpirationThreshold = TimeSpan.FromDays(4); + + options.ExpirationThreshold.Should().Be(TimeSpan.FromDays(4)); + } + } +} \ No newline at end of file diff --git a/tests/Functions.Tests/AppRegistrationSecretWatcherFunctionsTest.cs b/tests/Functions.Tests/AppRegistrationSecretWatcherFunctionsTest.cs new file mode 100644 index 0000000..2012f46 --- /dev/null +++ b/tests/Functions.Tests/AppRegistrationSecretWatcherFunctionsTest.cs @@ -0,0 +1,43 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Functions.Tests +{ + using Microsoft.Extensions.Options; + + public class AppRegistrationSecretWatcherFunctionsTest + { + [Fact] + public async Task WatchAppSecretsAsync() + { + var cancellationToken = new CancellationTokenSource().Token; + + var manager = new Mock(MockBehavior.Strict); + manager.Setup(m => m.CheckAsync(It.IsAny(), cancellationToken)) + .Callback((AppRegistrationSecretCheckParameters p, CancellationToken _) => + { + p.ExpirationThreshold.Should().Be(TimeSpan.FromDays(2)); + }) + .ReturnsAsync((AppRegistrationSecretCheckResult)null); + + var options = Options.Create(new AppRegistrationSecretWatcherFunctionsOptions() + { + ExpirationThreshold = TimeSpan.FromDays(2), + TenantIds = + { + "Tenant 1", + "Tenant 2", + }, + }); + + var functions = new AppRegistrationSecretWatcherFunctions(manager.Object, options); + + await functions.WatchAppSecretsAsync(null, cancellationToken); + + manager.VerifyAll(); + } + } +} \ No newline at end of file diff --git a/tests/Functions.Tests/Functions.Tests.csproj b/tests/Functions.Tests/Functions.Tests.csproj new file mode 100644 index 0000000..33052e0 --- /dev/null +++ b/tests/Functions.Tests/Functions.Tests.csproj @@ -0,0 +1,5 @@ + + + + + From 0f88581646401b4a0b9cb9f3d4da6c2005789cc1 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 10 Nov 2025 16:29:46 +0100 Subject: [PATCH 13/26] Display explicitly no secret / no application. --- src/Core/Emailing/EmailTemplate.html | 38 ++-- ...ilGeneratorTest.GenerateAsync.verified.txt | 162 ++++++++++-------- .../Emailing/ScribanEmailGeneratorTest.cs | 10 +- 3 files changed, 127 insertions(+), 83 deletions(-) diff --git a/src/Core/Emailing/EmailTemplate.html b/src/Core/Emailing/EmailTemplate.html index f869342..409a2f6 100644 --- a/src/Core/Emailing/EmailTemplate.html +++ b/src/Core/Emailing/EmailTemplate.html @@ -73,23 +73,31 @@

Entra ID app registrations secret expiration report

{{ tenant.DisplayName }} ({{ tenant.Id }})

- {{ for application in tenant.Applications }} -
-

{{ application.DisplayName }} ({{ application.Id }})

+ {{ if tenant.Applications.size == 0 }} +
No application
+ {{ else }} + {{ for application in tenant.Applications }} +
+

{{ application.DisplayName }} ({{ application.Id }})

- {{ for secret in application.Secrets }} -
-
Display name: {{ secret.DisplayName }}
-
Status: {{ secret.Status }}
-
Expiration date: {{ secret.EndDate | date.to_string '%e-%b-%Y' }}
- {{ if secret.DaysBeforeExpiration > 0 }} -
Days before expiration: {{ secret.DaysBeforeExpiration }} days
- {{ else }} -
Expired since: {{ -secret.DaysBeforeExpiration }} days
+ {{ if application.Secrets.size == 0 }} +
No secret
+ {{ else }} + {{ for secret in application.Secrets }} +
+
Display name: {{ secret.DisplayName }}
+
Status: {{ secret.Status }}
+
Expiration date: {{ secret.EndDate | date.to_string '%e-%b-%Y' }}
+ {{ if secret.DaysBeforeExpiration > 0 }} +
Days before expiration: {{ secret.DaysBeforeExpiration }} days
+ {{ else }} +
Expired since: {{ -secret.DaysBeforeExpiration }} days
+ {{ end }} +
{{ end }} -
- {{ end }} -
+ {{ end }} +
+ {{ end }} {{ end }}
{{ end }} diff --git a/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.GenerateAsync.verified.txt b/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.GenerateAsync.verified.txt index 8370a6d..b2c1c4f 100644 --- a/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.GenerateAsync.verified.txt +++ b/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.GenerateAsync.verified.txt @@ -63,53 +63,67 @@

The tenant 1 (Id 1)

-
-

The app 1-1 (Id 1-1)

- - -
-
Display name: Secret 1-1-1
-
Status: Expired
-
Expiration date: 1-Jan-2025
- -
Expired since: 10 days
- -
+ +
+

The app 1-1 (Id 1-1)

+ -
-
Display name: Secret 1-1-2
-
Status: Valid
-
Expiration date: 2-Feb-2025
- -
Days before expiration: 20 days
+ +
+
Display name: Secret 1-1-1
+
Status: Expired
+
Expiration date: 1-Jan-2025
+ +
Expired since: 10 days
+ +
-
- -
- -
-

The app 1-2 (Id 1-2)

- - -
-
Display name: Secret 1-2-1
-
Status: Valid
-
Expiration date: 3-Mar-2025
- -
Days before expiration: 30 days
+
+
Display name: Secret 1-1-2
+
Status: Valid
+
Expiration date: 2-Feb-2025
+ +
Days before expiration: 20 days
+ +
-
- -
-
Display name: Secret 1-2-2
-
Status: ExpiringSoon
-
Expiration date: 4-Apr-2025
-
Days before expiration: 40 days
+
+ +
+

The app 1-2 (Id 1-2)

+ + + +
+
Display name: Secret 1-2-1
+
Status: Valid
+
Expiration date: 3-Mar-2025
+ +
Days before expiration: 30 days
+ +
-
+
+
Display name: Secret 1-2-2
+
Status: ExpiringSoon
+
Expiration date: 4-Apr-2025
+ +
Days before expiration: 40 days
+ +
+ + +
+ +
+

The app 1-3 (Id 1-3)

+ -
+
No secret
+ +
+ @@ -117,35 +131,49 @@

The tenant 2 (Id 2)

-
-

The app 2-1 (Id 2-1)

- - -
-
Display name: Secret 2-1-1
-
Status: Valid
-
Expiration date: 5-May-2025
- -
Days before expiration: 50 days
- -
+ +
+

The app 2-1 (Id 2-1)

+ -
- -
-

The app 2-2 (Id 2-2)

- - -
-
Display name: Secret 2-1-1
-
Status: Valid
-
Expiration date: 6-Jun-2025
- -
Days before expiration: 60 days
+ +
+
Display name: Secret 2-1-1
+
Status: Valid
+
Expiration date: 5-May-2025
+ +
Days before expiration: 50 days
+ +
-
+ +
+ +
+

The app 2-2 (Id 2-2)

+ -
+ +
+
Display name: Secret 2-1-1
+
Status: Valid
+
Expiration date: 6-Jun-2025
+ +
Days before expiration: 60 days
+ +
+ + +
+ + + + +
+

The tenant 3 (Id 3)

+ + +
No application
diff --git a/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.cs b/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.cs index e486d02..e6e9059 100644 --- a/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.cs +++ b/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.cs @@ -30,7 +30,11 @@ public async Task GenerateAsync() [ new AppRegistrationSecretCheckResultApplicationSecret("Secret 1-2-1", new DateTime(2025, 3, 3), 30) { Status = AppRegistrationSecretStatus.Valid }, new AppRegistrationSecretCheckResultApplicationSecret("Secret 1-2-2", new DateTime(2025, 4, 4), 40) { Status = AppRegistrationSecretStatus.ExpiringSoon }, - ]) + ]), + new AppRegistrationSecretCheckResultApplication( + "Id 1-3", + "The app 1-3", + []) ]), new AppRegistrationSecretCheckResultTenant( "Id 2", @@ -49,6 +53,10 @@ public async Task GenerateAsync() new AppRegistrationSecretCheckResultApplicationSecret("Secret 2-1-1", new DateTime(2025, 6, 6), 60) { Status = AppRegistrationSecretStatus.Valid }, ]) ]), + new AppRegistrationSecretCheckResultTenant( + "Id 3", + "The tenant 3", + []), ]); var generator = new ScribanEmailGenerator(); From b44e3b45192c21dd90d1448b90283be8ccf2dc4a Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 10 Nov 2025 16:31:17 +0100 Subject: [PATCH 14/26] Change to .slnx --- ....Identity.AppRegistrationSecretWatcher.sln | 70 ------------------- ...Identity.AppRegistrationSecretWatcher.slnx | 24 +++++++ 2 files changed, 24 insertions(+), 70 deletions(-) delete mode 100644 Azure.Identity.AppRegistrationSecretWatcher.sln create mode 100644 Azure.Identity.AppRegistrationSecretWatcher.slnx diff --git a/Azure.Identity.AppRegistrationSecretWatcher.sln b/Azure.Identity.AppRegistrationSecretWatcher.sln deleted file mode 100644 index fac1a8b..0000000 --- a/Azure.Identity.AppRegistrationSecretWatcher.sln +++ /dev/null @@ -1,70 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.14.36616.10 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}" - ProjectSection(SolutionItems) = preProject - .editorconfig = .editorconfig - .gitignore = .gitignore - Directory.Build.props = Directory.Build.props - Directory.Packages.props = Directory.Packages.props - global.json = global.json - LICENSE = LICENSE - README.md = README.md - stylecop.json = stylecop.json - EndProjectSection -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Functions", "src\Functions\Functions.csproj", "{869008E3-EFD1-0A48-7FEC-C8E6AEA3A97A}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core", "src\Core\Core.csproj", "{7BD9F2E1-AB8E-4AA5-93BA-9B92D27EC064}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" - ProjectSection(SolutionItems) = preProject - src\Directory.Build.props = src\Directory.Build.props - EndProjectSection -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.Tests", "tests\Core.Tests\Core.Tests.csproj", "{C7D85722-68AB-4E9A-8E47-7A71B3387060}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{143067B7-D5C1-4471-BB0D-F31626A116A7}" - ProjectSection(SolutionItems) = preProject - tests\.editorconfig = tests\.editorconfig - tests\Directory.Build.props = tests\Directory.Build.props - EndProjectSection -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Functions.Tests", "tests\Functions.Tests\Functions.Tests.csproj", "{309097B3-0840-4407-A85F-7038F18201E0}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {869008E3-EFD1-0A48-7FEC-C8E6AEA3A97A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {869008E3-EFD1-0A48-7FEC-C8E6AEA3A97A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {869008E3-EFD1-0A48-7FEC-C8E6AEA3A97A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {869008E3-EFD1-0A48-7FEC-C8E6AEA3A97A}.Release|Any CPU.Build.0 = Release|Any CPU - {7BD9F2E1-AB8E-4AA5-93BA-9B92D27EC064}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7BD9F2E1-AB8E-4AA5-93BA-9B92D27EC064}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7BD9F2E1-AB8E-4AA5-93BA-9B92D27EC064}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7BD9F2E1-AB8E-4AA5-93BA-9B92D27EC064}.Release|Any CPU.Build.0 = Release|Any CPU - {C7D85722-68AB-4E9A-8E47-7A71B3387060}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C7D85722-68AB-4E9A-8E47-7A71B3387060}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C7D85722-68AB-4E9A-8E47-7A71B3387060}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C7D85722-68AB-4E9A-8E47-7A71B3387060}.Release|Any CPU.Build.0 = Release|Any CPU - {309097B3-0840-4407-A85F-7038F18201E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {309097B3-0840-4407-A85F-7038F18201E0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {309097B3-0840-4407-A85F-7038F18201E0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {309097B3-0840-4407-A85F-7038F18201E0}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {8EC462FD-D22E-90A8-E5CE-7E832BA40C5D} - {143067B7-D5C1-4471-BB0D-F31626A116A7} = {8EC462FD-D22E-90A8-E5CE-7E832BA40C5D} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {8EEC6F15-42CF-4ADA-A10D-0EC8CD2BF450} - EndGlobalSection -EndGlobal diff --git a/Azure.Identity.AppRegistrationSecretWatcher.slnx b/Azure.Identity.AppRegistrationSecretWatcher.slnx new file mode 100644 index 0000000..65b296f --- /dev/null +++ b/Azure.Identity.AppRegistrationSecretWatcher.slnx @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + From 76dd7c73eaba64d8355303956bc8e8be78fe53e1 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 10 Nov 2025 16:36:58 +0100 Subject: [PATCH 15/26] Add github actions. --- .github/workflows/github-actions-ci.yaml | 39 +++++++++++++++++++ .github/workflows/github-actions-release.yml | 33 ++++++++++++++++ ...Identity.AppRegistrationSecretWatcher.slnx | 4 ++ 3 files changed, 76 insertions(+) create mode 100644 .github/workflows/github-actions-ci.yaml create mode 100644 .github/workflows/github-actions-release.yml rename Azure.Identity.AppRegistrationSecretWatcher.slnx => PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.slnx (81%) diff --git a/.github/workflows/github-actions-ci.yaml b/.github/workflows/github-actions-ci.yaml new file mode 100644 index 0000000..d2bff7f --- /dev/null +++ b/.github/workflows/github-actions-ci.yaml @@ -0,0 +1,39 @@ +name: Continuous Integration + +on: + pull_request: + branches: [ "main" ] + push: + branches: [ "releases/**" ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.x + + - name: Restore dependencies + run: dotnet restore PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.slnx + + - name: Build solution + run: dotnet build PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.slnx --configuration Release --no-restore + + - name: Run tests + run: | + dotnet test PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.slnx \ + --configuration Release \ + --no-build \ + --logger "trx;LogFileName=test_results.trx" \ + --results-directory ./TestResults + + - name: Publish Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: (!cancelled()) + with: + files: | + TestResults/**/*.trx \ No newline at end of file diff --git a/.github/workflows/github-actions-release.yml b/.github/workflows/github-actions-release.yml new file mode 100644 index 0000000..df38c2b --- /dev/null +++ b/.github/workflows/github-actions-release.yml @@ -0,0 +1,33 @@ +name: Release + +on: + workflow_dispatch: + inputs: + VersionPrefix: + type: string + description: The version of the application + required: true + default: 1.0.0 + VersionSuffix: + type: string + description: The version suffix of the application (for example rc.1) + +run-name: ${{ inputs.VersionSuffix && format('{0}-{1}', inputs.VersionPrefix, inputs.VersionSuffix) || inputs.VersionPrefix }} + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 9.x + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.x' + + - name: Build AppRegistrationSecretWatcher functions + run: dotnet build + --property:Configuration=Release + --property:VersionPrefix=${{ github.event.inputs.VersionPrefix }} + --property:VersionSuffix=${{ github.event.inputs.VersionSuffix }} + "src/Functions/Functions.csproj" \ No newline at end of file diff --git a/Azure.Identity.AppRegistrationSecretWatcher.slnx b/PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.slnx similarity index 81% rename from Azure.Identity.AppRegistrationSecretWatcher.slnx rename to PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.slnx index 65b296f..119b89a 100644 --- a/Azure.Identity.AppRegistrationSecretWatcher.slnx +++ b/PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.slnx @@ -10,6 +10,10 @@ + + + + From 73c01aa69ca248dac59b4e2846e3b2bcb1ac6fe9 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 10 Nov 2025 16:44:54 +0100 Subject: [PATCH 16/26] Fix unit tests previously failed. --- tests/Core.Tests/AppRegistrationSecretManagerTest.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Core.Tests/AppRegistrationSecretManagerTest.cs b/tests/Core.Tests/AppRegistrationSecretManagerTest.cs index 4d6a655..dc321a1 100644 --- a/tests/Core.Tests/AppRegistrationSecretManagerTest.cs +++ b/tests/Core.Tests/AppRegistrationSecretManagerTest.cs @@ -72,7 +72,7 @@ public async Task CheckAsync() m.HtmlContent.Should().Be("The content"); m.From.DisplayName.Should().BeEmpty(); m.From.Email.Should().Be(EmailAddress.Parse("sender@domain.com")); - m.Subject.Should().Be("Reminder: App Registration secrets expiring soon - [15/06/2025]"); + m.Subject.Should().Be($"Reminder: App Registration secrets expiring soon - [{new DateTime(2025, 6, 15):d}]"); m.To.DisplayName.Should().BeEmpty(); }) .Returns(Task.CompletedTask); @@ -82,7 +82,7 @@ public async Task CheckAsync() m.HtmlContent.Should().Be("The content"); m.From.DisplayName.Should().BeEmpty(); m.From.Email.Should().Be(EmailAddress.Parse("sender@domain.com")); - m.Subject.Should().Be("Reminder: App Registration secrets expiring soon - [15/06/2025]"); + m.Subject.Should().Be($"Reminder: App Registration secrets expiring soon - [{new DateTime(2025, 6, 15):d}]"); m.To.DisplayName.Should().BeEmpty(); }) .Returns(Task.CompletedTask); From 10bd72f7f7d02b2f77eb9e29ba239f0b6482faa7 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Mon, 10 Nov 2025 16:50:37 +0100 Subject: [PATCH 17/26] Fix the release github action. --- .github/workflows/github-actions-release.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/github-actions-release.yml b/.github/workflows/github-actions-release.yml index df38c2b..c2f6807 100644 --- a/.github/workflows/github-actions-release.yml +++ b/.github/workflows/github-actions-release.yml @@ -25,9 +25,22 @@ jobs: with: dotnet-version: '9.x' - - name: Build AppRegistrationSecretWatcher functions - run: dotnet build + - name: Build AppRegistrationSecretWatcher Azure Functions + run: dotnet publish --property:Configuration=Release --property:VersionPrefix=${{ github.event.inputs.VersionPrefix }} --property:VersionSuffix=${{ github.event.inputs.VersionSuffix }} - "src/Functions/Functions.csproj" \ No newline at end of file + --output ./publish + "src/Functions/Functions.csproj" + + - name: Package the AppRegistrationSecretWatcher Azure Functions + run: cd ./publish && zip -r ../PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.zip . + + - name: Upload to release + uses: softprops/action-gh-release@v1 + with: + tag_name: v${{ github.event.inputs.VersionPrefix }}${{ github.event.inputs.VersionSuffix && format('-{0}', github.event.inputs.VersionSuffix) || '' }} + files: functionapp.zip + overwrite: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file From ee24acd2fed7c06e2be2089a17720c274785bdd2 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Tue, 11 Nov 2025 03:05:53 +0100 Subject: [PATCH 18/26] Fix release github actions. --- .github/workflows/github-actions-release.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/github-actions-release.yml b/.github/workflows/github-actions-release.yml index c2f6807..9e88731 100644 --- a/.github/workflows/github-actions-release.yml +++ b/.github/workflows/github-actions-release.yml @@ -40,7 +40,8 @@ jobs: uses: softprops/action-gh-release@v1 with: tag_name: v${{ github.event.inputs.VersionPrefix }}${{ github.event.inputs.VersionSuffix && format('-{0}', github.event.inputs.VersionSuffix) || '' }} - files: functionapp.zip - overwrite: true + files: ../PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.zip + overwrite_files: true + draft: ${{ !github.event.inputs.VersionSuffix }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file From e2f14fd73061d19b3c516a9f7441abaf12fe5fa6 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Tue, 11 Nov 2025 03:09:00 +0100 Subject: [PATCH 19/26] Use the v2 of softprops/action-gh-release --- .github/workflows/github-actions-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/github-actions-release.yml b/.github/workflows/github-actions-release.yml index 9e88731..30aae1d 100644 --- a/.github/workflows/github-actions-release.yml +++ b/.github/workflows/github-actions-release.yml @@ -37,7 +37,7 @@ jobs: run: cd ./publish && zip -r ../PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.zip . - name: Upload to release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: tag_name: v${{ github.event.inputs.VersionPrefix }}${{ github.event.inputs.VersionSuffix && format('-{0}', github.event.inputs.VersionSuffix) || '' }} files: ../PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.zip From 20f0f47a0ba1c3144f8baea37092eca510cb343b Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Tue, 11 Nov 2025 03:12:44 +0100 Subject: [PATCH 20/26] Fiox git hub actions. --- .github/workflows/github-actions-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/github-actions-release.yml b/.github/workflows/github-actions-release.yml index 30aae1d..479f02b 100644 --- a/.github/workflows/github-actions-release.yml +++ b/.github/workflows/github-actions-release.yml @@ -40,7 +40,7 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: v${{ github.event.inputs.VersionPrefix }}${{ github.event.inputs.VersionSuffix && format('-{0}', github.event.inputs.VersionSuffix) || '' }} - files: ../PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.zip + files: ./PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.zip overwrite_files: true draft: ${{ !github.event.inputs.VersionSuffix }} env: From a3c77329d32460edde8edfb81680f4dbfd8137a3 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Wed, 12 Nov 2025 13:13:23 +0100 Subject: [PATCH 21/26] Fix style cop --- tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs b/tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs index 972b374..455e078 100644 --- a/tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs +++ b/tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs @@ -66,4 +66,4 @@ public async Task SendAsync() serializationWriterFactory.VerifyAll(); } } -} +} \ No newline at end of file From 5ef505e796c415aaef5bf3842558ff7f9a24518b Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Wed, 12 Nov 2025 13:19:35 +0100 Subject: [PATCH 22/26] Suffix the zip package with the net9.0.zip. --- .github/workflows/github-actions-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/github-actions-release.yml b/.github/workflows/github-actions-release.yml index 479f02b..d1a7a68 100644 --- a/.github/workflows/github-actions-release.yml +++ b/.github/workflows/github-actions-release.yml @@ -34,13 +34,13 @@ jobs: "src/Functions/Functions.csproj" - name: Package the AppRegistrationSecretWatcher Azure Functions - run: cd ./publish && zip -r ../PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.zip . + run: cd ./publish && zip -r ../PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Functions.net9.0.zip . - name: Upload to release uses: softprops/action-gh-release@v2 with: tag_name: v${{ github.event.inputs.VersionPrefix }}${{ github.event.inputs.VersionSuffix && format('-{0}', github.event.inputs.VersionSuffix) || '' }} - files: ./PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.zip + files: ./PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Functions.net9.0.zip overwrite_files: true draft: ${{ !github.event.inputs.VersionSuffix }} env: From 02566afbed361850e9a1faec09fb5c192d0e0b0a Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Thu, 13 Nov 2025 10:33:02 +0100 Subject: [PATCH 23/26] Improve the HTML template. --- src/Core/Emailing/EmailTemplate.html | 148 +++++++++++++++++++++------ 1 file changed, 115 insertions(+), 33 deletions(-) diff --git a/src/Core/Emailing/EmailTemplate.html b/src/Core/Emailing/EmailTemplate.html index 409a2f6..663b063 100644 --- a/src/Core/Emailing/EmailTemplate.html +++ b/src/Core/Emailing/EmailTemplate.html @@ -9,33 +9,77 @@ } .tenant { - margin-bottom: 30px; - padding: 20px; + margin-bottom: 15px; + padding: 15px; background-color: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } .tenant h2 { - margin-bottom: 10px; + margin-top: 0px; + margin-bottom: 4px; + } + + .tenant-metadata { + margin-bottom: 2px; + margin-left: 8px; + font-size: 0.75em; + line-height: 1.4; + } + + .tenant-content { + background-color: #f9f9fc; + padding: 8px; + border-radius: 6px; } .application { - margin-left: 10px; - margin-bottom: 20px; + background-color: #f2f2f2; + margin-left: 2px; + margin-bottom: 15px; + border-radius: 6px; + padding: 6px } .application h3 { - margin-bottom: 8px; + margin-top: 2px; + margin-bottom: 6px; + } + + .application-content { + padding: 2px; + border-radius: 6px; + } + + .application-metadata { + margin-bottom: 15px; + margin-left: 8px; + font-size: 0.75em; + line-height: 1.4; } .secret { - margin-left: 20px; padding: 8px; border-radius: 6px; margin-bottom: 6px; } + .secret h4 { + margin-top: 2px; + margin-bottom: 10px; + font-size: 1em; + font-weight: 600; + } + + .secret-content { + margin-left: 2px; + } + + .label { + font-weight: bold; + } + .expired { background-color: #ffd6d6; } @@ -51,6 +95,24 @@ .status { font-weight: bold; } + + .info-row { + display: grid; + grid-template-columns: 160px 1fr; + gap: 8px 16px; + align-items: center; + margin: 0.5em 0; + font-size: 0.85em; + } + + .info-label { + color: #555; + font-weight: 600; + } + + .info-value { + color: #333; + } @@ -71,34 +133,54 @@

Entra ID app registrations secret expiration report

{{ for tenant in Tenants }}
-

{{ tenant.DisplayName }} ({{ tenant.Id }})

- - {{ if tenant.Applications.size == 0 }} -
No application
- {{ else }} - {{ for application in tenant.Applications }} -
-

{{ application.DisplayName }} ({{ application.Id }})

- - {{ if application.Secrets.size == 0 }} -
No secret
- {{ else }} - {{ for secret in application.Secrets }} -
-
Display name: {{ secret.DisplayName }}
-
Status: {{ secret.Status }}
-
Expiration date: {{ secret.EndDate | date.to_string '%e-%b-%Y' }}
- {{ if secret.DaysBeforeExpiration > 0 }} -
Days before expiration: {{ secret.DaysBeforeExpiration }} days
- {{ else }} -
Expired since: {{ -secret.DaysBeforeExpiration }} days
+

{{ tenant.DisplayName }}

+ +
+ {{ if tenant.Applications.size == 0 }} +
No application
+ {{ else }} + {{ for application in tenant.Applications }} +
+

{{ application.DisplayName }}

+ +
+ {{ if application.Secrets.size == 0 }} +
No secret
+ {{ else }} + {{ for secret in application.Secrets }} +
+

{{ secret.DisplayName }}

+
+
+
Status:
+
{{ secret.Status }}
+
+
+
Expiration date:
+
{{ secret.EndDate | date.to_string '%e-%b-%Y' }}
+
+
+ {{ if secret.DaysBeforeExpiration > 0 }} +
Days before expiration:
+
{{ secret.DaysBeforeExpiration }} days
+ {{ else }} +
Expired since:
+
{{ -secret.DaysBeforeExpiration }} days
+ {{ end }} +
+
+
{{ end }} -
- {{ end }} - {{ end }} -
+ {{ end }} +
+
+ {{ end }} {{ end }} - {{ end }} +
{{ end }} From fd0242b4ac1d2022016c3693dab5065f75cfec61 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Thu, 13 Nov 2025 11:12:36 +0100 Subject: [PATCH 24/26] Add the README --- README.md | 114 +++++++++++++++++++++++++++++++++++++++- docs/multi-tenants.webp | Bin 0 -> 12310 bytes 2 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 docs/multi-tenants.webp diff --git a/README.md b/README.md index 3dab2e5..9affa25 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,112 @@ -# PosInformatique.Azure.Identity.AppRegistrationSecretWatcher -Monitors Azure App Registration secrets expiration and sends notifications when they are about to expire. This repository provides the executable for an Azure Function implementing the monitoring logic. +# PosInformatique.Azure.Identity.AppRegistrationSecretWatcher + +Monitors Azure App Registration secrets expiration and emails a periodic report. This repository ships an Azure Functions +executable with the monitoring logic already packaged. + +## Features +- Monitor secrets across one or multiple Entra ID tenants (Entra ID, Azure B2C, Entra External ID,...). +- Send a consolidated report at a customizable interval (cron-based). +- Simple deployment to Azure Functions (pre-packaged, no build/CD required). +- Runs on Azure Functions Consumption plan (**NO COST!!!**). + +## How it works +- Enumerates App Registrations and checks client secrets and certificates nearing expiration. +- Sends a summary report by email using Microsoft Graph. +- Can run with Managed Identity (single-tenant) or a dedicated App Registration (multi-tenant). + +## Identity and tenant models + +### Single-tenant +- Use the Azure Function with system or user assigned managed identity. +- Monitors only the current tenant. +- No client ID/secret needed. + +### Multi-tenant + +![Multi-tenant configuration](./docs/multi-tenants.webp) + +- Create an App Registration in one *Home Tenant* tenant with a client secret. +- Register this application in each additional tenant (*Azure AD 2*, *Azure AD 3*,...) and obtain admin consent. +- Configure this application credentials in the Function App to query all target tenants. + +## Requirements and configuration + +The **AppRegistrationSecretWatcher** requires the following Microsoft Graph permissions: + +For each tenant to watch: +- `Application.Read.All` +- `Organization.Read.All` + +To send the e-mail using Graph API: +- `Mail.Send` + +### Configuration + +- `APP_SECRET_WATCHER_CLIENT_ID`: + Client ID of the App Registration used to query secrets across tenants. If omitted, the Function managed identity is used (single-tenant only). +- `APP_SECRET_WATCHER_CLIENT_SECRET`: + Client secret of the App Registration. Not required if using managed identity or certificate auth. +- `APP_SECRET_WATCHER_EXPIRATION_THRESHOLD`: + Time span threshold to raise warnings before secret expiration. Example: `30.00:00:00` for 30 days. +- `APP_SECRET_WATCHER_FREQUENCY`: + Cron expression for the timer trigger (report frequency, daily minimum recommended). Example: `0 0 6 * * *` (every day at 06:00 UTC). +- `APP_SECRET_WATCHER_RECIPIENTS_EMAIL`: + Semicolon-separated email recipients for the report. Example: `ops@contoso.com;security@contoso.com`. +- `APP_SECRET_WATCHER_SENDER_EMAIL`: + Sender email used by Graph. Must exist in Entra ID (user or shared mailbox). +- `APP_SECRET_WATCHER_TENANT_IDS`: + Semicolon-separated list of tenant IDs to monitor. Example: `11111111-1111-1111-1111-111111111111;22222222-2222-2222-2222-222222222222`. + +> If the application have to watch only a single tenant, use a managed identity. In this case, you don't need to +define the `APP_SECRET_WATCHER_CLIENT_ID` and `APP_SECRET_WATCHER_CLIENT_SECRET` environment variables. + +### Email Sending +- By default, the Azure Function uses Microsoft Graph with the Function managed identity. +- If you must use a specific service principal instead, set the following environment variable: + - `AZURE_CLIENT_ID`: The application client id to use to send the e-mail with the Graph API (and have the `Mail.Send` application permission). + - `AZURE_CLIENT_SECRET`: The secret of the application which will be use to send the e-mail with the Graph API. + - `AZURE_TENANT_ID`: The tenant ID where the application to send the e-mail with Graph API is located. + +## Deployment + +### 1) Azure Function App +- Create an Azure Function App (Isolated v4, .NET 9), use a *Consumption plan* if you want to have no cost. +- Enable managed identity if you plan to run single-tenant and/or use managed identity to send the e-mail with Microsoft Graph API. + +### 2) Run from Package (no CD needed) +- Set the `WEBSITE_RUN_FROM_PACKAGE` app setting to the release package URL. Example: + - `https://github.com/PosInformatique/PosInformatique.Azure.Identity.AppRegistrationSecretWatcher/releases/download/v1.0.0/PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Functions.net9.0.zip` +- You can choose the AppRegistrationSecretWatcher version and the .NET target by selecting the appropriate asset in Releases. + +This approach lets Azure manage the package directly; you only update the `WEBSITE_RUN_FROM_PACKAGE` URL to upgrade. + +### 3) Configure App Settings +- Add all environment variables listed in *Requirements and configuration* section. + +### Scheduling Examples + +- Daily at 06:00 UTC: + - `APP_SECRET_WATCHER_FREQUENCY = 0 0 6 * * *` +- Every 12 hours: + - `APP_SECRET_WATCHER_FREQUENCY = 0 0 */12 * * *` + +### Permissions Summary + +Grant the following Microsoft Graph application permissions to the identity used for directory reads: +- `Application.Read.All` +- `Organization.Read.All` + +Then grant Admin Consent in each monitored tenant. + +For email sending, ensure the sender exists and the identity used managed identity or service principal is allowed +to send mail via Graph withb the `Mail.Send` application permission. + +## Notes and Best Practices + +- Use Managed Identity for simplicity when monitoring only the current tenant. +- Keep `APP_SECRET_WATCHER_EXPIRATION_THRESHOLD` aligned with your rotation policy (e.g., 30–60 days). +- Ensure recipients are a monitored distribution list or shared mailbox to avoid missed alerts. + +## Compatibility +- .NET: 9.0 +- Azure Functions: Isolated v4 diff --git a/docs/multi-tenants.webp b/docs/multi-tenants.webp new file mode 100644 index 0000000000000000000000000000000000000000..f2d06ef7c184e3b74cbccc04aa4e6bb704657ecc GIT binary patch literal 12310 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 Date: Thu, 13 Nov 2025 11:15:41 +0100 Subject: [PATCH 25/26] Fix unit test --- ...ilGeneratorTest.GenerateAsync.verified.txt | 362 +++++++++++++----- 1 file changed, 261 insertions(+), 101 deletions(-) diff --git a/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.GenerateAsync.verified.txt b/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.GenerateAsync.verified.txt index b2c1c4f..941dcff 100644 --- a/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.GenerateAsync.verified.txt +++ b/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.GenerateAsync.verified.txt @@ -9,33 +9,77 @@ } .tenant { - margin-bottom: 30px; - padding: 20px; + margin-bottom: 15px; + padding: 15px; background-color: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } .tenant h2 { - margin-bottom: 10px; + margin-top: 0px; + margin-bottom: 4px; + } + + .tenant-metadata { + margin-bottom: 2px; + margin-left: 8px; + font-size: 0.75em; + line-height: 1.4; + } + + .tenant-content { + background-color: #f9f9fc; + padding: 8px; + border-radius: 6px; } .application { - margin-left: 10px; - margin-bottom: 20px; + background-color: #f2f2f2; + margin-left: 2px; + margin-bottom: 15px; + border-radius: 6px; + padding: 6px } .application h3 { - margin-bottom: 8px; + margin-top: 2px; + margin-bottom: 6px; + } + + .application-content { + padding: 2px; + border-radius: 6px; + } + + .application-metadata { + margin-bottom: 15px; + margin-left: 8px; + font-size: 0.75em; + line-height: 1.4; } .secret { - margin-left: 20px; padding: 8px; border-radius: 6px; margin-bottom: 6px; } + .secret h4 { + margin-top: 2px; + margin-bottom: 10px; + font-size: 1em; + font-weight: 600; + } + + .secret-content { + margin-left: 2px; + } + + .label { + font-weight: bold; + } + .expired { background-color: #ffd6d6; } @@ -51,6 +95,24 @@ .status { font-weight: bold; } + + .info-row { + display: grid; + grid-template-columns: 160px 1fr; + gap: 8px 16px; + align-items: center; + margin: 0.5em 0; + font-size: 0.85em; + } + + .info-label { + color: #555; + font-weight: 600; + } + + .info-value { + color: #333; + } @@ -60,121 +122,219 @@
-

The tenant 1 (Id 1)

- - - -
-

The app 1-1 (Id 1-1)

- - +

The tenant 1

+ +
+ + +
+

The app 1-1

+ +
-
-
Display name: Secret 1-1-1
-
Status: Expired
-
Expiration date: 1-Jan-2025
- -
Expired since: 10 days
+ +
+

Secret 1-1-1

+
+
+
Status:
+
Expired
+
+
+
Expiration date:
+
1-Jan-2025
+
+
+ +
Expired since:
+
10 days
+ +
+
+
-
- -
-
Display name: Secret 1-1-2
-
Status: Valid
-
Expiration date: 2-Feb-2025
- -
Days before expiration: 20 days
+
+

Secret 1-1-2

+
+
+
Status:
+
Valid
+
+
+
Expiration date:
+
2-Feb-2025
+
+
+ +
Days before expiration:
+
20 days
+ +
+
+
-
- - -
- -
-

The app 1-2 (Id 1-2)

- + +
+
+
+

The app 1-2

+ +
-
-
Display name: Secret 1-2-1
-
Status: Valid
-
Expiration date: 3-Mar-2025
- -
Days before expiration: 30 days
+ +
+

Secret 1-2-1

+
+
+
Status:
+
Valid
+
+
+
Expiration date:
+
3-Mar-2025
+
+
+ +
Days before expiration:
+
30 days
+ +
+
+
-
- -
-
Display name: Secret 1-2-2
-
Status: ExpiringSoon
-
Expiration date: 4-Apr-2025
- -
Days before expiration: 40 days
+
+

Secret 1-2-2

+
+
+
Status:
+
ExpiringSoon
+
+
+
Expiration date:
+
4-Apr-2025
+
+
+ +
Days before expiration:
+
40 days
+ +
+
+
-
- - -
- -
-

The app 1-3 (Id 1-3)

- + +
+
+ +
+

The app 1-3

+ +
+ +
No secret
+ +
+
-
No secret
- -
- +
-

The tenant 2 (Id 2)

- - - -
-

The app 2-1 (Id 2-1)

- - +

The tenant 2

+ +
+ + +
+

The app 2-1

+ +
-
-
Display name: Secret 2-1-1
-
Status: Valid
-
Expiration date: 5-May-2025
- -
Days before expiration: 50 days
+ +
+

Secret 2-1-1

+
+
+
Status:
+
Valid
+
+
+
Expiration date:
+
5-May-2025
+
+
+ +
Days before expiration:
+
50 days
+ +
+
+
-
- - -
- -
-

The app 2-2 (Id 2-2)

- + +
+
+
+

The app 2-2

+ +
-
-
Display name: Secret 2-1-1
-
Status: Valid
-
Expiration date: 6-Jun-2025
- -
Days before expiration: 60 days
+ +
+

Secret 2-1-1

+
+
+
Status:
+
Valid
+
+
+
Expiration date:
+
6-Jun-2025
+
+
+ +
Days before expiration:
+
60 days
+ +
+
+
-
- - -
+ +
+
+ - +
-

The tenant 3 (Id 3)

- - -
No application
- +

The tenant 3

+ +
+ +
No application
+ +
From db885446a39827c0fe911b900eed5afe9ca6bb66 Mon Sep 17 00:00:00 2001 From: Gilles TOURREAU Date: Thu, 13 Nov 2025 12:31:26 +0100 Subject: [PATCH 26/26] Add unit tests to fix Verify() setup. --- .editorconfig | 12 +++++++++++- .gitattributes | 4 ++++ ...zure.Identity.AppRegistrationSecretWatcher.slnx | 1 + tests/Core.Tests/VerifyChecksTests.cs | 14 ++++++++++++++ 4 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 tests/Core.Tests/VerifyChecksTests.cs diff --git a/.editorconfig b/.editorconfig index 49728e0..bf68510 100644 --- a/.editorconfig +++ b/.editorconfig @@ -98,4 +98,14 @@ dotnet_diagnostic.IDE0130.severity = none dotnet_diagnostic.SA1600.severity = none # SA1602: Enumeration items should be documented -dotnet_diagnostic.SA1602.severity = none \ No newline at end of file +dotnet_diagnostic.SA1602.severity = none + +# Verify +[*.{received,verified}.{txt}] +charset = utf-8-bom +end_of_line = lf +indent_size = unset +indent_style = unset +insert_final_newline = false +tab_width = unset +trim_trailing_whitespace = false \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..02cd1f1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +*.verified.txt text eol=lf working-tree-encoding=UTF-8 +*.verified.xml text eol=lf working-tree-encoding=UTF-8 +*.verified.json text eol=lf working-tree-encoding=UTF-8 +*.verified.bin binary \ No newline at end of file diff --git a/PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.slnx b/PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.slnx index 119b89a..d65c357 100644 --- a/PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.slnx +++ b/PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.slnx @@ -1,6 +1,7 @@ + diff --git a/tests/Core.Tests/VerifyChecksTests.cs b/tests/Core.Tests/VerifyChecksTests.cs new file mode 100644 index 0000000..cc6fa09 --- /dev/null +++ b/tests/Core.Tests/VerifyChecksTests.cs @@ -0,0 +1,14 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Core.Tests +{ + public class VerifyChecksTests + { + [Fact] + public Task Run() => VerifyChecks.Run(); + } +} \ No newline at end of file