diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..bf68510 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,111 @@ +root = true + +[*] +charset = utf-8-bom +insert_final_newline = false +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 + +# SA1602: Enumeration items should be documented +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/.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..d1a7a68 --- /dev/null +++ b/.github/workflows/github-actions-release.yml @@ -0,0 +1,47 @@ +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 Azure Functions + run: dotnet publish + --property:Configuration=Release + --property:VersionPrefix=${{ github.event.inputs.VersionPrefix }} + --property:VersionSuffix=${{ github.event.inputs.VersionSuffix }} + --output ./publish + "src/Functions/Functions.csproj" + + - name: Package the AppRegistrationSecretWatcher Azure Functions + 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.Functions.net9.0.zip + overwrite_files: true + draft: ${{ !github.event.inputs.VersionSuffix }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..ff43f6b --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,57 @@ + + + + + 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..57dedcc --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,28 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.slnx b/PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.slnx new file mode 100644 index 0000000..d65c357 --- /dev/null +++ b/PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.slnx @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 0000000..f2d06ef Binary files /dev/null and b/docs/multi-tenants.webp differ 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/AppRegistrationSecretCheckParameters.cs b/src/Core/AppRegistrationSecretCheckParameters.cs new file mode 100644 index 0000000..e7ba7f8 --- /dev/null +++ b/src/Core/AppRegistrationSecretCheckParameters.cs @@ -0,0 +1,20 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher +{ + public class AppRegistrationSecretCheckParameters + { + public AppRegistrationSecretCheckParameters() + { + this.TenantIds = new Collection(); + } + + public Collection TenantIds { get; } + + public TimeSpan ExpirationThreshold { get; set; } + } +} \ No newline at end of file diff --git a/src/Core/AppRegistrationSecretCheckResult.cs b/src/Core/AppRegistrationSecretCheckResult.cs new file mode 100644 index 0000000..96e0554 --- /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; } + } +} \ No newline at end of file diff --git a/src/Core/AppRegistrationSecretCheckResultApplication.cs b/src/Core/AppRegistrationSecretCheckResultApplication.cs new file mode 100644 index 0000000..4dbae03 --- /dev/null +++ b/src/Core/AppRegistrationSecretCheckResultApplication.cs @@ -0,0 +1,24 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher +{ + public class AppRegistrationSecretCheckResultApplication + { + 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; } + } +} \ No newline at end of file diff --git a/src/Core/AppRegistrationSecretCheckResultApplicationSecret.cs b/src/Core/AppRegistrationSecretCheckResultApplicationSecret.cs new file mode 100644 index 0000000..d17759c --- /dev/null +++ b/src/Core/AppRegistrationSecretCheckResultApplicationSecret.cs @@ -0,0 +1,26 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher +{ + public class AppRegistrationSecretCheckResultApplicationSecret + { + public AppRegistrationSecretCheckResultApplicationSecret(string displayName, DateTime endDate, int daysBeforeExpiration) + { + this.DisplayName = displayName; + this.EndDate = endDate; + this.DaysBeforeExpiration = daysBeforeExpiration; + } + + public string DisplayName { get; } + + public DateTime EndDate { get; } + + public AppRegistrationSecretStatus Status { get; set; } + + public int DaysBeforeExpiration { get; } + } +} \ No newline at end of file diff --git a/src/Core/AppRegistrationSecretCheckResultTenant.cs b/src/Core/AppRegistrationSecretCheckResultTenant.cs new file mode 100644 index 0000000..35bc697 --- /dev/null +++ b/src/Core/AppRegistrationSecretCheckResultTenant.cs @@ -0,0 +1,24 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher +{ + public class AppRegistrationSecretCheckResultTenant + { + 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 new file mode 100644 index 0000000..354c118 --- /dev/null +++ b/src/Core/AppRegistrationSecretManager.cs @@ -0,0 +1,133 @@ +//----------------------------------------------------------------------- +// +// 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 IEntraIdClient entraIdApplicationClient; + + private readonly IEmailProvider emailProvider; + + private readonly IEmailGenerator emailGenerator; + + private readonly AppRegistrationSecretManagerOptions options; + + private readonly TimeProvider timeProvider; + + public AppRegistrationSecretManager(IEntraIdClient 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) + { + var now = this.timeProvider.GetUtcNow().UtcDateTime; + + // Gets the applications + var applicationsByTenant = await this.GetApplicationsByTenantAsync(parameters, cancellationToken); + + // Check the result + 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, now, cancellationToken); + + return result; + } + + private async Task> GetApplicationsByTenantAsync(AppRegistrationSecretCheckParameters parameters, CancellationToken cancellationToken) + { + var tenantTasks = new List>(parameters.TenantIds.Count); + + foreach (var tenantId in parameters.TenantIds) + { + tenantTasks.Add(this.entraIdApplicationClient.GetApplicationsAsync(tenantId, cancellationToken)); + } + + await Task.WhenAll(tenantTasks); + + return tenantTasks.Select(t => t.Result).ToArray(); + } + + private AppRegistrationSecretCheckResult BuildResult(IReadOnlyList tenants, DateTime now, TimeSpan expirationThreshold) + { + var tenantsResult = new List(tenants.Count); + + foreach (var tenant in tenants) + { + var tenantResult = new AppRegistrationSecretCheckResultTenant( + tenant.Id, + tenant.DisplayName, + tenant.Applications + .Select(app => this.Build(app, now, expirationThreshold)) + .OrderBy(app => app.DisplayName) + .ToArray()); + + tenantsResult.Add(tenantResult); + } + + return new AppRegistrationSecretCheckResult(tenantsResult); + } + + private AppRegistrationSecretCheckResultApplication Build(EntraIdApplication application, DateTime now, TimeSpan expirationThreshold) + { + var secrets = application.PasswordCredentials + .OrderBy(pc => pc.DisplayName) + .Select(pc => this.Build(pc, now, expirationThreshold)) + .ToArray(); + + return new AppRegistrationSecretCheckResultApplication(application.Id, application.DisplayName, secrets); + } + + 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, (passwordCredential.EndDateTime - now).Days); + + if (passwordCredential.EndDateTime < now) + { + secret.Status = AppRegistrationSecretStatus.Expired; + } + else if (passwordCredential.EndDateTime <= now + expirationThreshold) + { + secret.Status = AppRegistrationSecretStatus.ExpiringSoon; + } + + return secret; + } + + private async Task SendEmailAsync(string emailContent, DateTime now, CancellationToken cancellationToken) + { + var todayLocal = TimeZoneInfo.ConvertTimeFromUtc(now, this.timeProvider.LocalTimeZone); + + foreach (var recipient in this.options.EmailRecipients) + { + 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); + } + } + } +} \ No newline at end of file diff --git a/src/Core/AppRegistrationSecretManagerOptions.cs b/src/Core/AppRegistrationSecretManagerOptions.cs new file mode 100644 index 0000000..afc87d4 --- /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.EmailAddresses; + + public class AppRegistrationSecretManagerOptions + { + public AppRegistrationSecretManagerOptions() + { + this.EmailRecipients = new Collection(); + } + + public EmailAddress EmailSender { get; set; } = default!; + + public Collection EmailRecipients { get; } + } +} \ No newline at end of file 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/src/Core/Core.csproj b/src/Core/Core.csproj new file mode 100644 index 0000000..1346af2 --- /dev/null +++ b/src/Core/Core.csproj @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Emailing.EmailTemplate.html + + + + diff --git a/src/Core/Emailing/EmailContact.cs b/src/Core/Emailing/EmailContact.cs new file mode 100644 index 0000000..b378c1d --- /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; } + } +} \ No newline at end of file diff --git a/src/Core/Emailing/EmailMessage.cs b/src/Core/Emailing/EmailMessage.cs new file mode 100644 index 0000000..af6c6ac --- /dev/null +++ b/src/Core/Emailing/EmailMessage.cs @@ -0,0 +1,27 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing +{ + public sealed class 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; } + } +} \ No newline at end of file diff --git a/src/Core/Emailing/EmailTemplate.html b/src/Core/Emailing/EmailTemplate.html new file mode 100644 index 0000000..663b063 --- /dev/null +++ b/src/Core/Emailing/EmailTemplate.html @@ -0,0 +1,187 @@ + + + + + + +

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 }}

+ +
+ {{ 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 }} + + \ No newline at end of file diff --git a/src/Core/Emailing/Graph/GraphEmailProvider.cs b/src/Core/Emailing/Graph/GraphEmailProvider.cs new file mode 100644 index 0000000..7f6bcb6 --- /dev/null +++ b/src/Core/Emailing/Graph/GraphEmailProvider.cs @@ -0,0 +1,54 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Graph +{ + using Microsoft.Graph; + using Microsoft.Graph.Models; + using Microsoft.Graph.Users.Item.SendMail; + + 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, + }, + 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.Users[message.From.Email.ToString()].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 new file mode 100644 index 0000000..8372c9c --- /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 + { + Task GenerateAsync(AppRegistrationSecretCheckResult result, CancellationToken cancellationToken = default); + } +} \ No newline at end of file diff --git a/src/Core/Emailing/IEmailProvider.cs b/src/Core/Emailing/IEmailProvider.cs new file mode 100644 index 0000000..301b294 --- /dev/null +++ b/src/Core/Emailing/IEmailProvider.cs @@ -0,0 +1,13 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing +{ + public interface IEmailProvider + { + Task SendAsync(EmailMessage message, 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/src/Core/EntraId/EntraIdApplication.cs b/src/Core/EntraId/EntraIdApplication.cs new file mode 100644 index 0000000..9002ad5 --- /dev/null +++ b/src/Core/EntraId/EntraIdApplication.cs @@ -0,0 +1,24 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId +{ + 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; } + } +} \ No newline at end of file diff --git a/src/Core/EntraId/EntraIdApplicationPasswordCredential.cs b/src/Core/EntraId/EntraIdApplicationPasswordCredential.cs new file mode 100644 index 0000000..4d2d777 --- /dev/null +++ b/src/Core/EntraId/EntraIdApplicationPasswordCredential.cs @@ -0,0 +1,23 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId +{ + public class EntraIdApplicationPasswordCredential + { + public EntraIdApplicationPasswordCredential(string displayName, DateTime endDateTime) + { + Guard.IsUtc(endDateTime, nameof(endDateTime)); + + this.DisplayName = displayName; + this.EndDateTime = endDateTime; + } + + public string DisplayName { get; } + + public DateTime EndDateTime { 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/GraphEntraIdClient.cs b/src/Core/EntraId/GraphEntraIdClient.cs new file mode 100644 index 0000000..6bff802 --- /dev/null +++ b/src/Core/EntraId/GraphEntraIdClient.cs @@ -0,0 +1,53 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId +{ + public class GraphEntraIdClient : IEntraIdClient + { + private readonly IGraphServiceClientFactory graphServiceClientFactory; + + public GraphEntraIdClient(IGraphServiceClientFactory graphServiceClientFactory) + { + this.graphServiceClientFactory = graphServiceClientFactory; + } + + 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 => + { + 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}'."); + } + + 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/GraphEntraIdClientOptions.cs b/src/Core/EntraId/GraphEntraIdClientOptions.cs new file mode 100644 index 0000000..2ec1761 --- /dev/null +++ b/src/Core/EntraId/GraphEntraIdClientOptions.cs @@ -0,0 +1,15 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId +{ + public class GraphEntraIdClientOptions + { + public string? ClientId { get; set; } + + 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 new file mode 100644 index 0000000..44e075d --- /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 GraphEntraIdClientOptions 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); + } + } +} \ No newline at end of file diff --git a/src/Core/EntraId/IEntraIdClient.cs b/src/Core/EntraId/IEntraIdClient.cs new file mode 100644 index 0000000..e84dd29 --- /dev/null +++ b/src/Core/EntraId/IEntraIdClient.cs @@ -0,0 +1,13 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.EntraId +{ + public interface IEntraIdClient + { + Task GetApplicationsAsync(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 new file mode 100644 index 0000000..3a44986 --- /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); + } +} \ No newline at end of file diff --git a/src/Core/Guard.cs b/src/Core/Guard.cs new file mode 100644 index 0000000..846bf12 --- /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); + } + } + } +} \ No newline at end of file diff --git a/src/Core/IAppRegistrationSecretManager.cs b/src/Core/IAppRegistrationSecretManager.cs new file mode 100644 index 0000000..3389d6d --- /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); + } +} \ No newline at end of file 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/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/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/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/.editorconfig b/tests/.editorconfig new file mode 100644 index 0000000..7764d9a --- /dev/null +++ b/tests/.editorconfig @@ -0,0 +1,13 @@ +[*.cs] + +#### Code Analysis #### + +# CA2016: Forward the 'CancellationToken' parameter to methods +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..15b4773 --- /dev/null +++ b/tests/Core.Tests/AppRegistrationSecretCheckParametersTest.cs @@ -0,0 +1,30 @@ +//----------------------------------------------------------------------- +// +// 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(); + + 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 new file mode 100644 index 0000000..3114a67 --- /dev/null +++ b/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationSecretTest.cs @@ -0,0 +1,32 @@ +//----------------------------------------------------------------------- +// +// 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), 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); + } + + [Fact] + public void Status_ValueChanged() + { + var secret = new AppRegistrationSecretCheckResultApplicationSecret(default, default, default); + + secret.Status = AppRegistrationSecretStatus.ExpiringSoon; + + secret.Status.Should().Be(AppRegistrationSecretStatus.ExpiringSoon); + } + } +} \ No newline at end of file diff --git a/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationTest.cs new file mode 100644 index 0000000..ded27c9 --- /dev/null +++ b/tests/Core.Tests/AppRegistrationSecretCheckResultApplicationTest.cs @@ -0,0 +1,27 @@ +//----------------------------------------------------------------------- +// +// 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, default), + new AppRegistrationSecretCheckResultApplicationSecret(default, default, default), + }; + + 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); + } + } +} \ No newline at end of file diff --git a/tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs new file mode 100644 index 0000000..db5a211 --- /dev/null +++ b/tests/Core.Tests/AppRegistrationSecretCheckResultTenantTest.cs @@ -0,0 +1,27 @@ +//----------------------------------------------------------------------- +// +// 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, default, []), + new AppRegistrationSecretCheckResultApplication(default, default, []), + }; + + 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"); + } + } +} \ No newline at end of file diff --git a/tests/Core.Tests/AppRegistrationSecretCheckResultTest.cs b/tests/Core.Tests/AppRegistrationSecretCheckResultTest.cs new file mode 100644 index 0000000..bd140b4 --- /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, default, []), + new AppRegistrationSecretCheckResultTenant(default, default, []), + }; + + var tenant = new AppRegistrationSecretCheckResult(tenants); + + 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 new file mode 100644 index 0000000..6e51917 --- /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.EmailAddresses; + + 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 = EmailAddress.Parse("email@domain.com"); + + options.EmailSender = sender; + + 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 new file mode 100644 index 0000000..dc321a1 --- /dev/null +++ b/tests/Core.Tests/AppRegistrationSecretManagerTest.cs @@ -0,0 +1,182 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Identity.AppRegistrationSecretWatcher.Core.Tests +{ + 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 entraIdClient = new Mock(MockBehavior.Strict); + entraIdClient.Setup(c => c.GetApplicationsAsync("Tenant 1", cancellationToken)) + .ReturnsAsync( + new EntraIdTenant( + "The tenant ID 1", + "The tenant display name 1", + [ + 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 EntraIdTenant( + "The tenant ID 2", + "The tenant display name 2", + [ + 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.DisplayName.Should().BeEmpty(); + m.From.Email.Should().Be(EmailAddress.Parse("sender@domain.com")); + m.Subject.Should().Be($"Reminder: App Registration secrets expiring soon - [{new DateTime(2025, 6, 15):d}]"); + 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.DisplayName.Should().BeEmpty(); + m.From.Email.Should().Be(EmailAddress.Parse("sender@domain.com")); + m.Subject.Should().Be($"Reminder: App Registration secrets expiring soon - [{new DateTime(2025, 6, 15):d}]"); + m.To.DisplayName.Should().BeEmpty(); + }) + .Returns(Task.CompletedTask); + + var emailGenerator = new Mock(MockBehavior.Strict); + emailGenerator.Setup(g => g.GenerateAsync(It.IsAny(), cancellationToken)) + .Callback((AppRegistrationSecretCheckResult r, CancellationToken _) => + { + r.Tenants.Should().HaveCount(2); + + 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].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); + + 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].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); + + expectedResult = r; + }) + .ReturnsAsync("The content"); + + var timeProvider = new Mock(MockBehavior.Strict); + timeProvider.Setup(tp => tp.GetUtcNow()) + .Returns(now); + timeProvider.Setup(tp => tp.LocalTimeZone) + .Returns(TimeZoneInfo.FindSystemTimeZoneById("Asia/Manila")); + + var options = Options.Create(new AppRegistrationSecretManagerOptions() + { + EmailRecipients = + { + EmailAddress.Parse("email1@domain.com"), + EmailAddress.Parse("email2@domain.com"), + }, + EmailSender = EmailAddress.Parse("sender@domain.com"), + }); + + var parameters = new AppRegistrationSecretCheckParameters() + { + ExpirationThreshold = TimeSpan.FromDays(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(); + } + } +} \ No newline at end of file diff --git a/tests/Core.Tests/Core.Tests.csproj b/tests/Core.Tests/Core.Tests.csproj new file mode 100644 index 0000000..48e5c20 --- /dev/null +++ b/tests/Core.Tests/Core.Tests.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tests/Core.Tests/Emailing/EmailContactTest.cs b/tests/Core.Tests/Emailing/EmailContactTest.cs new file mode 100644 index 0000000..a8a99f0 --- /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"); + } + } +} \ No newline at end of file diff --git a/tests/Core.Tests/Emailing/EmailMessageTest.cs b/tests/Core.Tests/Emailing/EmailMessageTest.cs new file mode 100644 index 0000000..42443db --- /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); + } + } +} \ No newline at end of file diff --git a/tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs b/tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs new file mode 100644 index 0000000..455e078 --- /dev/null +++ b/tests/Core.Tests/Emailing/Graph/GraphEmailProviderTest.cs @@ -0,0 +1,69 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Foundations.Emailing.Graph.Tests +{ + using Microsoft.Graph; + using Microsoft.Graph.Models; + using Microsoft.Graph.Users.Item.SendMail; + 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/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.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(); + } + } +} \ No newline at end of file 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..941dcff --- /dev/null +++ b/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.GenerateAsync.verified.txt @@ -0,0 +1,341 @@ + + + + + + +

Entra ID app registrations secret expiration report

+ + + + +
+

The tenant 1

+ +
+ + +
+

The app 1-1

+ +
+ + +
+

Secret 1-1-1

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

Secret 1-1-2

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

The app 1-2

+ +
+ + +
+

Secret 1-2-1

+
+
+
Status:
+
Valid
+
+
+
Expiration date:
+
3-Mar-2025
+
+
+ +
Days before expiration:
+
30 days
+ +
+
+
+ +
+

Secret 1-2-2

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

The app 1-3

+ +
+ +
No secret
+ +
+
+ + +
+
+ +
+

The tenant 2

+ +
+ + +
+

The app 2-1

+ +
+ + +
+

Secret 2-1-1

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

The app 2-2

+ +
+ + +
+

Secret 2-1-1

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

The tenant 3

+ +
+ +
No application
+ +
+
+ + + \ 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..e6e9059 --- /dev/null +++ b/tests/Core.Tests/Emailing/ScribanEmailGeneratorTest.cs @@ -0,0 +1,69 @@ +//----------------------------------------------------------------------- +// +// 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 AppRegistrationSecretCheckResultApplication( + "Id 1-3", + "The app 1-3", + []) + ]), + 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 }, + ]) + ]), + new AppRegistrationSecretCheckResultTenant( + "Id 3", + "The tenant 3", + []), + ]); + + var generator = new ScribanEmailGenerator(); + + var content = await generator.GenerateAsync(checkResult, CancellationToken.None); + + await Verify(content); + } + } +} \ No newline at end of file diff --git a/tests/Core.Tests/EntraId/EntraIdApplicationPasswordCredentialTest.cs b/tests/Core.Tests/EntraId/EntraIdApplicationPasswordCredentialTest.cs new file mode 100644 index 0000000..cf4606b --- /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 DateTime(2021, 1, 2, 3, 4, 5, 6, DateTimeKind.Utc)); + + credential.DisplayName.Should().Be("The display name"); + 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 new file mode 100644 index 0000000..946c152 --- /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, DateTime.UtcNow), + new EntraIdApplicationPasswordCredential(default, DateTime.UtcNow), + }; + + 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); + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..463c2d9 --- /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 GraphEntraIdClientOptions(); + + options.ClientId.Should().BeNull(); + options.ClientSecret.Should().BeNull(); + } + + [Fact] + public void ClientId_ValueChanged() + { + var options = new GraphEntraIdClientOptions(); + + options.ClientId = "The client ID"; + + options.ClientId.Should().Be("The client ID"); + } + + [Fact] + public void ClientSecret_ValueChanged() + { + var options = new GraphEntraIdClientOptions(); + + options.ClientSecret = "The client secret"; + + 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 new file mode 100644 index 0000000..6b0405e --- /dev/null +++ b/tests/Core.Tests/EntraId/GraphEntraIdApplicationClientTest.cs @@ -0,0 +1,269 @@ +//----------------------------------------------------------------------- +// +// 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/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 _) => + { + 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, 1, 2, 1, 1, 1, 1, TimeSpan.FromHours(1)), + }, + new PasswordCredential() + { + DisplayName = "Password 2", + EndDateTime = new DateTimeOffset(2025, 2, 2, 4, 2, 2, 2, 2, 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 GraphEntraIdClient(graphServiceClientFactory.Object); + + var result = await client.GetApplicationsAsync("The tenant Id", cancellationToken); + + result.Applications.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.Applications[1].DisplayName.Should().Be("Display name 2"); + result.Applications[1].Id.Should().Be("App Id 2"); + result.Applications[1].PasswordCredentials.Should().BeEmpty(); + + result.DisplayName.Should().Be("The tenant name"); + result.Id.Should().Be("The tenant ID response"); + + 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/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 _) => + { + 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 GraphEntraIdClient(graphServiceClientFactory.Object); + + 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'."); + + 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/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 _) => + { + 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 GraphEntraIdClient(graphServiceClientFactory.Object); + + 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'."); + + graphServiceClient.VerifyAll(); + 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 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 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 + + + + + + + + + + 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 @@ + + + + +